API externe : envoi d'images avec la clé API

Avec la clé API, aucune route ne permettait d'envoyer une image : /publish
n'acceptait qu'une URL publique à télécharger, et le parseur JSON global
plafonnait toute requête à 100 Ko, ce qui interdisait le base64.

- POST /api/v1/media : envoi d'une image (multipart « file » ou JSON
  « imageData » en base64), rangée dans la médiathèque ; renvoie un id.
- /publish et PATCH /posts/:id acceptent en plus mediaId, imageData ou un
  fichier multipart « image » (une seule source par requête).
- Requêtes JSON sous /api/v1 limitées à 15 Mo ; images à 10 Mo, format
  vérifié d'après le contenu (JPEG, PNG, WebP, GIF).
- PATCH résout l'image avant toute modification, pour ne pas laisser un
  post à moitié modifié si l'image est refusée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NqYZn5796yWGo9Rq81an9M
This commit is contained in:
Claude committed 2026-10-04 19:20:42 +00:00
1 parent 7806f8bdc8
commit ff0b4d3f20
6 files changed
+597 -84

No files matched your search

+215 -73
View File
@@ -1,20 +1,60 @@
import { Router } from "express";
import { Router, type Request, type Response as ExpressResponse, type NextFunction } from "express";
import { z } from "zod";
import path from "path";
import multer from "multer";
import { storage } from "../storage";
import { db } from "../db";
import { postMedia } from "@shared/schema";
import { postMedia, type Media } from "@shared/schema";
import { minioService } from "../services/minio";
import { requireApiKey } from "../middleware/apiKey";
import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, validateImage } from "../services/imageData";
const router = Router();
router.use(requireApiKey);
/** Erreur imputable à la requête : renvoyée telle quelle avec son code HTTP. */
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
// Fichier gardé en mémoire : 10 MB max, le contenu est validé par sa signature
const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_EXTERNAL_IMAGE_SIZE, files: 1 },
});
/**
* Accepte un fichier image en multipart/form-data sous le champ `field`.
* Sans effet sur les requêtes JSON. Les erreurs de multer deviennent des
* réponses JSON explicites au lieu de remonter au gestionnaire global.
*/
function acceptImageFile(field: string) {
const middleware = imageUpload.single(field);
return (req: Request, res: ExpressResponse, next: NextFunction) => {
middleware(req, res, (err: unknown) => {
if (!err) return next();
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(413).json({ error: "Image trop volumineuse (max 10 MB)" });
}
if (err.code === "LIMIT_UNEXPECTED_FILE") {
return res.status(400).json({ error: `Fichier attendu dans le champ « ${field} »` });
}
}
const message = err instanceof Error ? err.message : "Requête multipart invalide";
return res.status(400).json({ error: message });
});
};
}
const publishSchema = z.object({
content: z.string().min(1, "Le contenu est requis"),
imageUrl: z.string().url("URL d'image invalide").optional(),
pageIds: z.array(z.string()).min(1, "Au moins une page est requise"),
imageData: z.string().min(1).optional(),
mediaId: z.string().min(1).optional(),
pageIds: z.preprocess(normalizePageIds, z.array(z.string()).min(1, "Au moins une page est requise")),
scheduledAt: z.string().datetime({ offset: true }).optional(),
postType: z.enum(["feed", "story", "both"]).default("feed"),
userId: z.string().optional(),
@@ -59,6 +99,151 @@ async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string
return { buffer, ext, mimeType: contentType };
}
/** Enregistre une image dans la médiathèque de `ownerId`. */
async function storeImage(buffer: Buffer, ownerId: string, prefix: string): Promise<Media> {
let detected: { mimeType: string; ext: string };
try {
detected = validateImage(buffer);
} catch (error) {
const tooLarge = buffer.length > MAX_EXTERNAL_IMAGE_SIZE;
throw new ApiError(tooLarge ? 413 : 400, error instanceof Error ? error.message : "Image invalide");
}
const fileName = `${prefix}-${Date.now()}${detected.ext}`;
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, detected.mimeType);
return storage.createMedia({
userId: ownerId,
type: "image",
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: buffer.length,
});
}
interface ImageSources {
file?: Express.Multer.File;
imageUrl?: string;
imageData?: string;
mediaId?: string;
}
function countImageSources({ file, imageUrl, imageData, mediaId }: ImageSources): number {
return [file, imageUrl, imageData, mediaId].filter((source) => source !== undefined).length;
}
/**
* Résout l'image d'une publication, quelle que soit la façon dont elle a été
* transmise : fichier multipart, base64 (`imageData`), média déjà envoyé via
* POST /api/v1/media (`mediaId`) ou URL publique à télécharger (`imageUrl`).
*/
async function resolveImage(sources: ImageSources, ownerId: string, prefix: string): Promise<Media | null> {
if (countImageSources(sources) > 1) {
throw new ApiError(400, "Une seule source d'image à la fois : fichier, imageData, mediaId ou imageUrl");
}
for (const key of ["imageUrl", "imageData", "mediaId"] as const) {
if (sources[key] !== undefined && typeof sources[key] !== "string") {
throw new ApiError(400, `${key} doit être une chaîne`);
}
}
if (sources.mediaId !== undefined) {
const media = await storage.getMediaById(sources.mediaId);
if (!media) {
throw new ApiError(400, `Média introuvable: ${sources.mediaId}`);
}
return media;
}
if (sources.file) {
return storeImage(sources.file.buffer, ownerId, prefix);
}
if (sources.imageData !== undefined) {
let buffer: Buffer;
try {
buffer = decodeImageData(sources.imageData);
} catch (error) {
throw new ApiError(400, error instanceof Error ? error.message : "imageData invalide");
}
return storeImage(buffer, ownerId, prefix);
}
if (sources.imageUrl !== undefined) {
const { buffer } = await downloadImage(sources.imageUrl);
return storeImage(buffer, ownerId, prefix);
}
return null;
}
/** Propriétaire des médias et posts créés : `userId` fourni, sinon le premier admin. */
async function resolveOwnerId(userId: string | undefined): Promise<string> {
if (userId) {
const user = await storage.getUser(userId);
if (!user) {
throw new ApiError(400, `Utilisateur introuvable: ${userId}`);
}
return userId;
}
const users = await storage.getAllUsers();
const admin = users.find((u) => u.role === "admin");
if (!admin) {
throw new ApiError(500, "Aucun utilisateur admin trouvé");
}
return admin.id;
}
function sendError(res: ExpressResponse, error: unknown, context: string) {
if (error instanceof ApiError) {
return res.status(error.status).json({ error: error.message });
}
console.error(`[external API] ${context} error:`, error);
const message = error instanceof Error ? error.message : "Erreur interne";
return res.status(500).json({ error: message });
}
/**
* POST /api/v1/media
*
* Envoie une image dans la médiathèque, sans passer par un hébergeur public.
* Renvoie un `id` à passer en `mediaId` à /publish ou PATCH /posts/:id.
*
* Deux formats :
* multipart/form-data champ `file` (+ `userId` optionnel)
* application/json { "imageData": "<base64 ou data URL>", "userId"?: "..." }
*
* Formats : JPEG, PNG, WebP, GIF — 10 MB max.
*/
router.post("/media", acceptImageFile("file"), async (req, res) => {
try {
const { imageData, userId } = (req.body ?? {}) as { imageData?: unknown; userId?: unknown };
if (imageData !== undefined && typeof imageData !== "string") {
return res.status(400).json({ error: "imageData doit être une chaîne base64" });
}
if (!req.file && imageData === undefined) {
return res.status(400).json({
error: "Aucune image reçue : envoyez un fichier (multipart, champ « file ») ou imageData (base64)",
});
}
if (req.file && imageData !== undefined) {
return res.status(400).json({ error: "Envoyez soit un fichier, soit imageData, pas les deux" });
}
const ownerId = await resolveOwnerId(typeof userId === "string" && userId ? userId : undefined);
const media = await resolveImage({ file: req.file, imageData }, ownerId, "external");
return res.status(201).json({
id: media!.id,
url: media!.originalUrl,
type: media!.type,
fileName: media!.fileName,
fileSize: media!.fileSize,
});
} catch (error) {
return sendError(res, error, "POST /media");
}
});
/**
* POST /api/v1/publish
*
@@ -71,12 +256,18 @@ async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string
* Body:
* content string Texte de la publication
* imageUrl string? URL publique de l'image à télécharger
* imageData string? Image en base64 (ou data URL)
* mediaId string? Image déjà envoyée via POST /api/v1/media
* pageIds string[] IDs des pages cibles (social_pages.id)
* scheduledAt ISO8601? Date/heure de publication (absent = immédiat)
* postType feed|story|both Type de publication (défaut: feed)
* userId string? ID utilisateur propriétaire (défaut: premier admin)
*
* Accepte aussi multipart/form-data : mêmes champs en texte (pageIds en
* tableau JSON ou séparés par des virgules) et l'image dans le champ `image`.
* Une seule source d'image par requête.
*/
router.post("/publish", async (req, res) => {
router.post("/publish", acceptImageFile("image"), async (req, res) => {
try {
const parsed = publishSchema.safeParse(req.body);
if (!parsed.success) {
@@ -86,23 +277,10 @@ router.post("/publish", async (req, res) => {
});
}
const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
const { content, imageUrl, imageData, mediaId, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
const imageSources: ImageSources = { file: req.file, imageUrl, imageData, mediaId };
// Résoudre l'utilisateur propriétaire
let ownerId = bodyUserId;
if (!ownerId) {
const users = await storage.getAllUsers();
const admin = users.find((u) => u.role === "admin");
if (!admin) {
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
}
ownerId = admin.id;
} else {
const user = await storage.getUser(ownerId);
if (!user) {
return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` });
}
}
const ownerId = await resolveOwnerId(bodyUserId);
// Vérifier que toutes les pages existent
const resolvedPages: Array<{ id: string; pageName: string }> = [];
@@ -115,31 +293,11 @@ router.post("/publish", async (req, res) => {
}
// Validation story → image obligatoire
if ((postType === "story" || postType === "both") && !imageUrl) {
return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" });
if ((postType === "story" || postType === "both") && countImageSources(imageSources) === 0) {
return res.status(400).json({ error: "Les stories nécessitent une image (fichier, imageData, mediaId ou imageUrl)" });
}
// Télécharger l'image si fournie
let mediaRecord: { id: string; originalUrl: string } | null = null;
if (imageUrl) {
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
const fileName = `external-${Date.now()}${ext}`;
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
mediaRecord = await storage.createMedia({
userId: ownerId,
type: mediaType,
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: buffer.length,
});
}
const mediaRecord = await resolveImage(imageSources, ownerId, "external");
// Créer le post
const scheduledFor = scheduledAt ? new Date(scheduledAt) : null;
@@ -209,9 +367,7 @@ router.post("/publish", async (req, res) => {
},
});
} catch (error) {
console.error("[external API] Error:", error);
const message = error instanceof Error ? error.message : "Erreur interne";
return res.status(500).json({ error: message });
return sendError(res, error, "POST /publish");
}
});
@@ -363,11 +519,15 @@ router.get("/posts", async (req, res) => {
* content string? Nouveau texte
* scheduledAt ISO8601? Nouvelle date/heure de publication
* imageUrl string? URL d'une nouvelle image (remplace l'existante)
* imageData string? Nouvelle image en base64 (remplace l'existante)
* mediaId string? Image déjà envoyée via POST /api/v1/media
*
* Accepte aussi multipart/form-data avec l'image dans le champ `image`.
*/
router.patch("/posts/:id", async (req, res) => {
router.patch("/posts/:id", acceptImageFile("image"), async (req, res) => {
try {
const { id } = req.params;
const { content, scheduledAt, imageUrl } = req.body;
const { content, scheduledAt, imageUrl, imageData, mediaId } = req.body ?? {};
// Vérifier que le post existe et est modifiable
const post = await storage.getPost(id);
@@ -378,6 +538,9 @@ router.patch("/posts/:id", async (req, res) => {
return res.status(400).json({ error: "Impossible de modifier un post déjà publié" });
}
// Image résolue d'abord : une image refusée ne laisse pas le post à moitié modifié
const mediaRecord = await resolveImage({ file: req.file, imageUrl, imageData, mediaId }, post.userId, "external-edit");
// Mettre à jour le contenu si fourni
if (content !== undefined) {
await storage.updatePost(id, { content });
@@ -397,27 +560,8 @@ router.patch("/posts/:id", async (req, res) => {
}
}
// Remplacer le média si une nouvelle imageUrl est fournie
if (imageUrl !== undefined) {
// Résoudre le userId pour le stockage du média
const ownerId = post.userId;
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
const fileName = `external-edit-${Date.now()}${ext}`;
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
const mediaRecord = await storage.createMedia({
userId: ownerId,
type: mediaType,
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: buffer.length,
});
// Remplacer les médias existants par le nouveau
// Remplacer les médias existants par la nouvelle image
if (mediaRecord) {
await storage.updatePostMedia(id, [mediaRecord.id]);
}
@@ -461,9 +605,7 @@ router.patch("/posts/:id", async (req, res) => {
},
});
} catch (error) {
console.error("[external API] PATCH /posts/:id error:", error);
const message = error instanceof Error ? error.message : "Erreur interne";
return res.status(500).json({ error: message });
return sendError(res, error, "PATCH /posts/:id");
}
});