TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1// Médias d'annonces : stockage disque local (STORAGE_DIR, défaut data/media), traitement serveur avec sharp :2// vérification du type réel (magic bytes), conversion HEIC, ré-encodage WebP (neutralise les charges utiles), suppression EXIF/GPS, variantes 1600/800/320.3import sharp from "sharp";4import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";5import path from "node:path";6import { randomBytes } from "node:crypto";7import { and, eq, sql } from "drizzle-orm";8import { db } from "@/db/client";9import { listingMedia } from "@/db/schema";1011export const STORAGE_DIR = process.env.STORAGE_DIR ?? path.join(process.cwd(), "data", "media");12export const MAX_BYTES = 25 * 1024 * 1024, MAX_PER_LISTING = 30;13export const VARIANTS = [1600, 800, 320] as const;14const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);1516export function newUploadToken(): string { return randomBytes(24).toString("base64url"); }17export function mediaUrl(key: string, w: (typeof VARIANTS)[number] = 800): string { return `/api/media/${key}/${w}.webp`; }18export function mediaPath(key: string, w: number): string { return path.join(STORAGE_DIR, key, `${w}.webp`); }1920function sniff(buf: Buffer): string | null {21 if (buf.length < 12) return null;22 if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";23 if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";24 if (buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";25 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"; }26 return null;27}2829export class MediaError extends Error { constructor(public code: string, msg: string) { super(msg); } }3031/** Traite un fichier téléversé : retourne la clé de stockage et les dimensions, ou lève MediaError (rejet propre). */32export async function processUpload(buf: Buffer, declaredMime: string | null): Promise<{ key: string; width: number; height: number; bytes: number; mime: string }> {33 if (buf.length > MAX_BYTES) throw new MediaError("too_large", "Image trop volumineuse (25 Mo max)");34 const real = sniff(buf);35 if (!real || !ALLOWED_MIME.has(real)) throw new MediaError("bad_type", "Format non pris en charge : utilisez JPEG, PNG, WebP ou HEIC");36 if (declaredMime && declaredMime !== "application/octet-stream" && !declaredMime.startsWith("image/")) throw new MediaError("bad_type", "Type de fichier incohérent");37 let input = buf;38 if (real === "image/heic") {39 try { const mod = await import("heic-convert"); const conv = (mod.default ?? mod) as unknown as (o: { buffer: Buffer; format: "JPEG"; quality: number }) => Promise<ArrayBuffer | Buffer>; const outBuf = await conv({ buffer: buf, format: "JPEG", quality: 0.92 }); input = Buffer.isBuffer(outBuf) ? outBuf : Buffer.from(new Uint8Array(outBuf as ArrayBuffer)); }40 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"); } }41 }42 const img = sharp(input, { failOn: "error", limitInputPixels: 80_000_000 }).rotate(); // .rotate() applique l'orientation EXIF puis les métadonnées sont retirées43 const m = await img.metadata().catch(() => { throw new MediaError("corrupt", "Image illisible ou corrompue"); });44 if (!m.width || !m.height || m.width < 200 || m.height < 200) throw new MediaError("too_small", "Image trop petite (200 px minimum)");45 const key = `${new Date().toISOString().slice(0, 7)}/${randomBytes(12).toString("hex")}`;46 mkdirSync(path.join(STORAGE_DIR, key), { recursive: true });47 let width = 0, height = 0, bytes = 0;48 for (const w of VARIANTS) {49 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és50 writeFileSync(mediaPath(key, w), out.data);51 if (w === 1600) { width = out.info.width; height = out.info.height; bytes = out.data.length; }52 }53 return { key, width, height, bytes, mime: real };54}5556export async function countMedia(listingId: string): Promise<number> { const [r] = await db().select({ n: sql<number>`count(*)::int` }).from(listingMedia).where(and(eq(listingMedia.listingId, listingId), sql`${listingMedia.status} <> 'DELETED'`)); return Number(r?.n ?? 0); }57export function removeFiles(key: string) { const dir = path.join(STORAGE_DIR, key); if (existsSync(/*turbopackIgnore: true*/ dir)) rmSync(dir, { recursive: true, force: true }); }58