mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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:
6 files changed
+597
-84
No files matched your search
@@ -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).*
|
||||
|
||||
+9
-5
@@ -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
|
||||
|
||||
@@ -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<string, unknown>) => ({ id: "media-1", ...data }));
|
||||
mocks.storage.createPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: "post-1", ...data }));
|
||||
mocks.storage.createScheduledPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: `sp-${data.postType}`, ...data }));
|
||||
});
|
||||
|
||||
function form(fields: Record<string, string>, 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("<html></html>"), 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);
|
||||
});
|
||||
});
|
||||
+215
-73
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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("<html></html>"))).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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"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);
|
||||
}
|
||||
Reference in new issue
Block a user