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

+65
View File
@@ -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"]);
});
});
+72
View File
@@ -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);
}