// Médias d'annonces : stockage disque local (STORAGE_DIR, défaut data/media), traitement serveur avec sharp : // vérification du type réel (magic bytes), conversion HEIC, ré-encodage WebP (neutralise les charges utiles), suppression EXIF/GPS, variantes 1600/800/320. import sharp from "sharp"; import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import path from "node:path"; import { randomBytes } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { db } from "@/db/client"; import { listingMedia } from "@/db/schema"; export const STORAGE_DIR = process.env.STORAGE_DIR ?? path.join(process.cwd(), "data", "media"); export const MAX_BYTES = 25 * 1024 * 1024, MAX_PER_LISTING = 30; export const VARIANTS = [1600, 800, 320] as const; const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]); export function newUploadToken(): string { return randomBytes(24).toString("base64url"); } export function mediaUrl(key: string, w: (typeof VARIANTS)[number] = 800): string { return `/api/media/${key}/${w}.webp`; } export function mediaPath(key: string, w: number): string { return path.join(STORAGE_DIR, key, `${w}.webp`); } function sniff(buf: Buffer): string | null { if (buf.length < 12) return null; if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg"; if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png"; if (buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp"; if (buf.subarray(4, 8).toString("ascii") === "ftyp") { const brand = buf.subarray(8, 12).toString("ascii"); if (/^(heic|heix|hevc|hevx|mif1|msf1|heim|heis)$/.test(brand)) return "image/heic"; } return null; } export class MediaError extends Error { constructor(public code: string, msg: string) { super(msg); } } /** Traite un fichier téléversé : retourne la clé de stockage et les dimensions, ou lève MediaError (rejet propre). */ export async function processUpload(buf: Buffer, declaredMime: string | null): Promise<{ key: string; width: number; height: number; bytes: number; mime: string }> { if (buf.length > MAX_BYTES) throw new MediaError("too_large", "Image trop volumineuse (25 Mo max)"); const real = sniff(buf); if (!real || !ALLOWED_MIME.has(real)) throw new MediaError("bad_type", "Format non pris en charge : utilisez JPEG, PNG, WebP ou HEIC"); if (declaredMime && declaredMime !== "application/octet-stream" && !declaredMime.startsWith("image/")) throw new MediaError("bad_type", "Type de fichier incohérent"); let input = buf; if (real === "image/heic") { try { const mod = await import("heic-convert"); const conv = (mod.default ?? mod) as unknown as (o: { buffer: Buffer; format: "JPEG"; quality: number }) => Promise; const outBuf = await conv({ buffer: buf, format: "JPEG", quality: 0.92 }); input = Buffer.isBuffer(outBuf) ? outBuf : Buffer.from(new Uint8Array(outBuf as ArrayBuffer)); } catch { try { await sharp(buf).metadata(); } catch { throw new MediaError("heic_unsupported", "Photo HEIC non convertible ici : exportez-la en JPEG depuis votre téléphone"); } } } const img = sharp(input, { failOn: "error", limitInputPixels: 80_000_000 }).rotate(); // .rotate() applique l'orientation EXIF puis les métadonnées sont retirées const m = await img.metadata().catch(() => { throw new MediaError("corrupt", "Image illisible ou corrompue"); }); if (!m.width || !m.height || m.width < 200 || m.height < 200) throw new MediaError("too_small", "Image trop petite (200 px minimum)"); const key = `${new Date().toISOString().slice(0, 7)}/${randomBytes(12).toString("hex")}`; mkdirSync(path.join(STORAGE_DIR, key), { recursive: true }); let width = 0, height = 0, bytes = 0; for (const w of VARIANTS) { const out = await img.clone().resize({ width: w, withoutEnlargement: true }).webp({ quality: w >= 1600 ? 84 : 80 }).toBuffer({ resolveWithObject: true }); // pas de withMetadata() → EXIF/GPS/ICC retirés writeFileSync(mediaPath(key, w), out.data); if (w === 1600) { width = out.info.width; height = out.info.height; bytes = out.data.length; } } return { key, width, height, bytes, mime: real }; } export async function countMedia(listingId: string): Promise { const [r] = await db().select({ n: sql`count(*)::int` }).from(listingMedia).where(and(eq(listingMedia.listingId, listingId), sql`${listingMedia.status} <> 'DELETED'`)); return Number(r?.n ?? 0); } export function removeFiles(key: string) { const dir = path.join(STORAGE_DIR, key); if (existsSync(/*turbopackIgnore: true*/ dir)) rmSync(dir, { recursive: true, force: true }); }