From ff0b4d3f20f47cf0e2d0053f4279224551f2d141 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:20:42 +0000 Subject: [PATCH] =?UTF-8?q?API=20externe=20:=20envoi=20d'images=20avec=20l?= =?UTF-8?q?a=20cl=C3=A9=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NqYZn5796yWGo9Rq81an9M --- SOCIALFLOW_API_DOCUMENTATION.md | 46 ++++- server/index.ts | 14 +- server/routes/external.test.ts | 196 ++++++++++++++++++++ server/routes/external.ts | 288 ++++++++++++++++++++++-------- server/services/imageData.test.ts | 65 +++++++ server/services/imageData.ts | 72 ++++++++ 6 files changed, 597 insertions(+), 84 deletions(-) create mode 100644 server/routes/external.test.ts create mode 100644 server/services/imageData.test.ts create mode 100644 server/services/imageData.ts diff --git a/SOCIALFLOW_API_DOCUMENTATION.md b/SOCIALFLOW_API_DOCUMENTATION.md index 263da7e..1cd134f 100644 --- a/SOCIALFLOW_API_DOCUMENTATION.md +++ b/SOCIALFLOW_API_DOCUMENTATION.md @@ -945,8 +945,42 @@ POST /api/analytics/tokens/check Ces routes sont optimisées pour les appels de scripts d'intégration tierce. Toutes les requêtes doivent contenir le header `X-API-Key`. -### Publier un message avec téléchargement d'image -Crée une publication en téléchargeant automatiquement l'image depuis une URL publique fournie (ex: depuis votre ERP ou flux e-commerce), et programme sa publication. +Les requêtes JSON sous `/api/v1` peuvent peser jusqu'à 15 Mo (contre 100 Ko ailleurs), pour transporter une image en base64. + +### Envoyer une image dans la médiathèque +Envoie une image directement, sans hébergeur public intermédiaire. L'`id` renvoyé se passe en `mediaId` à `/publish` ou à `PATCH /posts/:id`. +Formats acceptés : JPEG, PNG, WebP, GIF (reconnus par leur contenu, pas par l'extension) — 10 Mo maximum. +```http +POST /api/v1/media +``` +**Body (multipart/form-data) :** champ `file` contenant l'image. +```bash +curl -X POST https://socialflow.example/api/v1/media \ + -H "X-API-Key: $SOCIALFLOW_API_KEY" \ + -F "file=@galette-chaise.jpg" +``` +**Ou body (JSON) :** l'image en base64, brute ou en data URL. +```json +{ "imageData": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." } +``` +**Réponse (201 Created) :** +```json +{ + "id": "med_ext_123", + "url": "/uploads/media/external-1791141548234.jpg", + "type": "image", + "fileName": "external-1791141548234.jpg", + "fileSize": 2483120 +} +``` +**Erreurs :** `400` (aucune image, format non reconnu), `401` (clé absente ou invalide), `413` (image de plus de 10 Mo). + +### Publier un message avec image +Crée une publication et programme sa publication. L'image peut être fournie de quatre façons (une seule par requête) : +- `mediaId` : image déjà envoyée via `POST /api/v1/media` ; +- `imageData` : image en base64 (ou data URL) ; +- `imageUrl` : URL publique que Socialflow télécharge lui-même ; +- en multipart/form-data, un fichier dans le champ `image` (les autres champs en texte, `pageIds` en tableau JSON ou séparés par des virgules). ```http POST /api/v1/publish ``` @@ -954,7 +988,7 @@ POST /api/v1/publish ```json { "content": "Découvrez cet article exceptionnel !", - "imageUrl": "https://mon-site.com/images/produit.jpg", + "mediaId": "med_ext_123", "pageIds": ["page_999"], "scheduledAt": "2026-06-02T08:00:00.000Z", "postType": "feed" @@ -974,8 +1008,8 @@ POST /api/v1/publish { "pageId": "page_999", "pageName": "Ma Page", "scheduledPostId": "sch_ext_001" } ], "media": { - "id": "med_ext_999", - "url": "/uploads/media/external-17169.jpg" + "id": "med_ext_123", + "url": "/uploads/media/external-1791141548234.jpg" } } } @@ -1018,7 +1052,7 @@ PATCH /api/v1/posts/:id "imageUrl": "https://mon-site.com/images/produit-rectifie.jpg" } ``` -*Note : Tous les champs sont facultatifs. L'envoi d'une nouvelle URL d'image écrase et remplace le média précédent.* +*Note : Tous les champs sont facultatifs. Une nouvelle image (`imageUrl`, `imageData`, `mediaId` ou fichier multipart `image`) remplace le média précédent.* **Réponse (200 OK) :** *(Retourne la publication modifiée avec sa liste de planification actualisée).* diff --git a/server/index.ts b/server/index.ts index 8f52097..c5b0522 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,4 +1,5 @@ import express, { type Request, Response, NextFunction } from "express"; +import type { IncomingMessage } from "http"; import path from "path"; import fs from "fs"; import session from "express-session"; @@ -87,11 +88,14 @@ declare module 'http' { rawBody: unknown } } -app.use(express.json({ - verify: (req, _res, buf) => { - req.rawBody = buf; - } -})); +const keepRawBody = (req: IncomingMessage, _res: unknown, buf: Buffer) => { + req.rawBody = buf; +}; +// L'API externe accepte des images en base64 (imageData) : 10 MB d'image font +// ~13,4 MB une fois encodés. Ce parseur passe avant le parseur global (100 KB), +// qui ignore ensuite un corps déjà lu. +app.use('/api/v1', express.json({ limit: '15mb', verify: keepRawBody })); +app.use(express.json({ verify: keepRawBody })); app.use(express.urlencoded({ extended: false })); // Validation renforcée du SESSION_SECRET diff --git a/server/routes/external.test.ts b/server/routes/external.test.ts new file mode 100644 index 0000000..cf6dc4c --- /dev/null +++ b/server/routes/external.test.ts @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import type { AddressInfo } from "net"; +import type { Server } from "http"; + +const API_KEY = "cle-de-test"; + +const mocks = vi.hoisted(() => ({ + storage: { + getAppConfig: vi.fn(), + getAllUsers: vi.fn(), + getUser: vi.fn(), + getSocialPage: vi.fn(), + getMediaById: vi.fn(), + createMedia: vi.fn(), + createPost: vi.fn(), + createScheduledPost: vi.fn(), + }, + insertValues: vi.fn(), + uploadMedia: vi.fn(), +})); + +vi.mock("../storage", () => ({ storage: mocks.storage })); +vi.mock("../db", () => ({ db: { insert: () => ({ values: mocks.insertValues }) } })); +vi.mock("../services/minio", () => ({ minioService: { uploadMedia: mocks.uploadMedia } })); + +const { externalRouter } = await import("./external"); + +// Un PNG valide (signature) de la taille voulue +function png(size: number): Buffer { + const header = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + return Buffer.concat([header, Buffer.alloc(Math.max(0, size - header.length))]); +} + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + // Même montage que server/index.ts : parseur large pour /api/v1, puis global + const app = express(); + app.use("/api/v1", express.json({ limit: "15mb" })); + app.use(express.json()); + app.use("/api/v1", externalRouter); + server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1`; +}); + +afterAll(() => { + server.close(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.storage.getAppConfig.mockResolvedValue({ externalApiKey: API_KEY }); + mocks.storage.getAllUsers.mockResolvedValue([{ id: "admin-1", role: "admin" }]); + mocks.storage.getSocialPage.mockImplementation(async (id: string) => + id === "page-1" ? { id, pageName: "Ma Page" } : undefined + ); + mocks.uploadMedia.mockImplementation(async (_buffer: Buffer, fileName: string) => ({ + publicId: `media/${fileName}`, + originalUrl: `/uploads/media/${fileName}`, + facebookFeedUrl: null, + instagramFeedUrl: null, + instagramStoryUrl: null, + })); + mocks.storage.createMedia.mockImplementation(async (data: Record) => ({ id: "media-1", ...data })); + mocks.storage.createPost.mockImplementation(async (data: Record) => ({ id: "post-1", ...data })); + mocks.storage.createScheduledPost.mockImplementation(async (data: Record) => ({ id: `sp-${data.postType}`, ...data })); +}); + +function form(fields: Record, file?: { field: string; buffer: Buffer; name: string; type?: string }) { + const body = new FormData(); + for (const [key, value] of Object.entries(fields)) body.append(key, value); + if (file) body.append(file.field, new Blob([file.buffer], { type: file.type ?? "image/png" }), file.name); + return body; +} + +describe("POST /api/v1/media", () => { + it("refuse sans clé API", async () => { + const res = await fetch(`${baseUrl}/media`, { method: "POST", body: form({}, { field: "file", buffer: png(100), name: "a.png" }) }); + expect(res.status).toBe(401); + expect(mocks.uploadMedia).not.toHaveBeenCalled(); + }); + + it("accepte un fichier multipart et le range dans la médiathèque de l'admin", async () => { + const res = await fetch(`${baseUrl}/media`, { + method: "POST", + headers: { "X-API-Key": API_KEY }, + body: form({}, { field: "file", buffer: png(3 * 1024 * 1024), name: "galette.png", type: "application/octet-stream" }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ id: "media-1", type: "image", fileSize: 3 * 1024 * 1024 }); + expect(body.url).toMatch(/^\/uploads\/media\/external-\d+\.png$/); + expect(mocks.storage.createMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: "admin-1" })); + }); + + it("accepte une image base64 bien au-delà de 100 KB", async () => { + const res = await fetch(`${baseUrl}/media`, { + method: "POST", + headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" }, + body: JSON.stringify({ imageData: `data:image/png;base64,${png(2 * 1024 * 1024).toString("base64")}` }), + }); + expect(res.status).toBe(201); + expect(mocks.uploadMedia.mock.calls[0][0].length).toBe(2 * 1024 * 1024); + }); + + it("renvoie 413 au-delà de 10 MB", async () => { + const res = await fetch(`${baseUrl}/media`, { + method: "POST", + headers: { "X-API-Key": API_KEY }, + body: form({}, { field: "file", buffer: png(10 * 1024 * 1024 + 1), name: "enorme.png" }), + }); + expect(res.status).toBe(413); + expect(mocks.uploadMedia).not.toHaveBeenCalled(); + }); + + it("refuse un fichier qui n'est pas une image", async () => { + const res = await fetch(`${baseUrl}/media`, { + method: "POST", + headers: { "X-API-Key": API_KEY }, + body: form({}, { field: "file", buffer: Buffer.from(""), name: "page.png" }), + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/Format/); + }); + + it("refuse une requête sans image", async () => { + const res = await fetch(`${baseUrl}/media`, { + method: "POST", + headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); +}); + +describe("POST /api/v1/publish", () => { + it("programme un post avec un mediaId déjà envoyé", async () => { + mocks.storage.getMediaById.mockResolvedValue({ id: "media-9", originalUrl: "/uploads/media/x.png", type: "image" }); + const res = await fetch(`${baseUrl}/publish`, { + method: "POST", + headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", pageIds: ["page-1"], scheduledAt: "2026-10-05T12:15:00+02:00", postType: "both" }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.post.media).toEqual({ id: "media-9", url: "/uploads/media/x.png" }); + expect(body.post.pages).toHaveLength(2); + expect(mocks.insertValues).toHaveBeenCalledWith({ postId: "post-1", mediaId: "media-9", displayOrder: 0 }); + expect(mocks.uploadMedia).not.toHaveBeenCalled(); + }); + + it("accepte l'image en multipart avec pageIds en texte", async () => { + const res = await fetch(`${baseUrl}/publish`, { + method: "POST", + headers: { "X-API-Key": API_KEY }, + body: form({ content: "Bonjour", pageIds: "page-1", scheduledAt: "2026-10-05T10:15:00Z" }, { field: "image", buffer: png(500_000), name: "chaise.png" }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.post.media.id).toBe("media-1"); + expect(body.post.pages).toEqual([{ pageId: "page-1", pageName: "Ma Page", scheduledPostId: "sp-feed" }]); + }); + + it("refuse un mediaId inconnu sans créer de post", async () => { + mocks.storage.getMediaById.mockResolvedValue(undefined); + const res = await fetch(`${baseUrl}/publish`, { + method: "POST", + headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Bonjour", mediaId: "absent", pageIds: ["page-1"] }), + }); + expect(res.status).toBe(400); + expect(mocks.storage.createPost).not.toHaveBeenCalled(); + }); + + it("refuse plusieurs sources d'image", async () => { + const res = await fetch(`${baseUrl}/publish`, { + method: "POST", + headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", imageUrl: "https://exemple.fr/a.png", pageIds: ["page-1"] }), + }); + expect(res.status).toBe(400); + expect(mocks.storage.createPost).not.toHaveBeenCalled(); + }); + + it("exige une image pour une story", async () => { + const res = await fetch(`${baseUrl}/publish`, { + method: "POST", + headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Bonjour", pageIds: ["page-1"], postType: "story" }), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/server/routes/external.ts b/server/routes/external.ts index 44bdc69..e6c4b2e 100644 --- a/server/routes/external.ts +++ b/server/routes/external.ts @@ -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 { + 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 { + 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 { + 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": "", "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"); } }); diff --git a/server/services/imageData.test.ts b/server/services/imageData.test.ts new file mode 100644 index 0000000..0ce63e7 --- /dev/null +++ b/server/services/imageData.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, sniffImageMime, validateImage } from "./imageData"; + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]); +const GIF = Buffer.from("GIF89a\0\0", "latin1"); +const WEBP = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WEBPVP8 ")]); + +describe("sniffImageMime", () => { + it("reconnaît les formats acceptés par leur signature", () => { + expect(sniffImageMime(PNG)).toBe("image/png"); + expect(sniffImageMime(JPEG)).toBe("image/jpeg"); + expect(sniffImageMime(GIF)).toBe("image/gif"); + expect(sniffImageMime(WEBP)).toBe("image/webp"); + }); + + it("refuse le reste", () => { + expect(sniffImageMime(Buffer.from(""))).toBeNull(); + expect(sniffImageMime(Buffer.alloc(0))).toBeNull(); + }); +}); + +describe("decodeImageData", () => { + it("décode le base64 brut et les data URL", () => { + const base64 = PNG.toString("base64"); + expect(decodeImageData(base64).equals(PNG)).toBe(true); + expect(decodeImageData(`data:image/png;base64,${base64}`).equals(PNG)).toBe(true); + }); + + it("tolère les retours à la ligne du base64", () => { + const base64 = JPEG.toString("base64"); + expect(decodeImageData(`${base64.slice(0, 4)}\n${base64.slice(4)}`).equals(JPEG)).toBe(true); + }); + + it("refuse ce qui n'est pas du base64", () => { + expect(() => decodeImageData("https://exemple.fr/image.jpg")).toThrow(/base64/); + expect(() => decodeImageData("")).toThrow(/base64/); + }); +}); + +describe("validateImage", () => { + it("renvoie type et extension", () => { + expect(validateImage(JPEG)).toEqual({ mimeType: "image/jpeg", ext: ".jpg" }); + }); + + it("refuse une image au-delà de 10 MB", () => { + const big = Buffer.concat([PNG, Buffer.alloc(MAX_EXTERNAL_IMAGE_SIZE)]); + expect(() => validateImage(big)).toThrow(/trop volumineuse/); + }); + + it("refuse un format non reconnu", () => { + expect(() => validateImage(Buffer.from("pas une image"))).toThrow(/Format/); + }); +}); + +describe("normalizePageIds", () => { + it("laisse un tableau intact", () => { + expect(normalizePageIds(["a", "b"])).toEqual(["a", "b"]); + }); + + it("accepte un tableau JSON ou une liste séparée par des virgules", () => { + expect(normalizePageIds('["a","b"]')).toEqual(["a", "b"]); + expect(normalizePageIds("a, b,")).toEqual(["a", "b"]); + }); +}); diff --git a/server/services/imageData.ts b/server/services/imageData.ts new file mode 100644 index 0000000..d074630 --- /dev/null +++ b/server/services/imageData.ts @@ -0,0 +1,72 @@ +// Décodage et identification des images reçues par l'API externe : le type +// annoncé par le client (multipart, data URL) n'est pas fiable, iOS et bien des +// scripts envoient « application/octet-stream », donc on lit les octets. + +export const MAX_EXTERNAL_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB + +export const IMAGE_EXTENSIONS: Record = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", +}; + +/** Type MIME d'après la signature du fichier, ou null si ce n'est pas une image acceptée. */ +export function sniffImageMime(buffer: Buffer): string | null { + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "image/jpeg"; + } + if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return "image/png"; + } + if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString("latin1"))) { + return "image/gif"; + } + if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") { + return "image/webp"; + } + return null; +} + +/** + * Décode une image transmise en base64, brute ou sous forme de data URL + * (`data:image/png;base64,...`). + */ +export function decodeImageData(data: string): Buffer { + const match = /^data:[^;,]*;base64,([\s\S]*)$/.exec(data.trim()); + const base64 = (match ? match[1] : data).replace(/\s+/g, ""); + + if (base64.length === 0 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64)) { + throw new Error("imageData doit être une image encodée en base64 (ou une data URL)"); + } + return Buffer.from(base64, "base64"); +} + +/** Vérifie taille et format ; renvoie le type MIME et l'extension détectés. */ +export function validateImage(buffer: Buffer): { mimeType: string; ext: string } { + if (buffer.length > MAX_EXTERNAL_IMAGE_SIZE) { + throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`); + } + const mimeType = sniffImageMime(buffer); + if (!mimeType) { + throw new Error("Format d'image non reconnu. Formats acceptés : JPEG, PNG, WebP, GIF"); + } + return { mimeType, ext: IMAGE_EXTENSIONS[mimeType] }; +} + +/** + * `pageIds` arrive en tableau en JSON, mais en texte en multipart : + * on accepte un tableau JSON (`["a","b"]`) ou une liste séparée par des virgules. + */ +export function normalizePageIds(value: unknown): unknown { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (trimmed.startsWith("[")) { + try { + return JSON.parse(trimmed); + } catch { + return value; + } + } + return trimmed.split(",").map((id) => id.trim()).filter(Boolean); +}