From 800a9c08b4d67e55b1079cb3e3578268d0db9429 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 09:17:39 +0000 Subject: [PATCH] feat(upload): optimisation automatique WebP avec Sharp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Installe sharp pour le traitement d'image côté serveur - Conversion automatique de tout upload en WebP (meilleur ratio qualité/web) - Auto-rotation basée sur l'orientation EXIF (corrige les photos de téléphone) - Strip de toutes les métadonnées personnelles (GPS, appareil, EXIF) - Compression adaptative par paliers (q82 → q72 → q62 → q50) pour viser ≤ 1 Mo - Augmentation de la limite brute à 20 Mo (avant optimisation) - Métadonnées Supabase Storage : Content-Type + Cache-Control 1 an - UI : stats d'optimisation affichées après upload (ex: "2400 Ko → 680 Ko (WebP q82)") - Mise à jour du texte d'aide dans l'admin images https://claude.ai/code/session_01PzA98VhLMmsHpzs7gnLHGs --- app/admin/images/page.tsx | 38 ++++++++--- app/api/admin/upload/route.ts | 125 +++++++++++++++++++++++----------- package-lock.json | 7 +- package.json | 3 +- 4 files changed, 116 insertions(+), 57 deletions(-) diff --git a/app/admin/images/page.tsx b/app/admin/images/page.tsx index 07025b9..e958b4f 100644 --- a/app/admin/images/page.tsx +++ b/app/admin/images/page.tsx @@ -13,10 +13,11 @@ interface SiteImage { type UploadState = "idle" | "uploading" | "saving" | "done" | "error"; interface ImageCardState { - editUrl: string; // valeur brute en cours d'édition - previewUrl: string; // URL pour l'aperçu + editUrl: string; // valeur brute en cours d'édition + previewUrl: string; // URL pour l'aperçu uploadState: UploadState; uploadError: string | null; + optimizationSummary: string | null; // ex: "2 400 Ko → 680 Ko (WebP q82)" } export default function AdminImages() { @@ -40,6 +41,7 @@ export default function AdminImages() { previewUrl: img.previewUrl, uploadState: "idle", uploadError: null, + optimizationSummary: null, }; } setCardState(state); @@ -73,7 +75,10 @@ export default function AdminImages() { return; } - const { storagePath } = uploadData as { storagePath: string }; + const { storagePath, optimization } = uploadData as { + storagePath: string; + optimization?: { summary: string; inRange: boolean }; + }; // Sauvegarde automatique en BDD updateCard(key, { uploadState: "saving" }); @@ -90,7 +95,12 @@ export default function AdminImages() { } // Succès : mettre à jour l'état - updateCard(key, { editUrl: storagePath, uploadState: "done", uploadError: null }); + updateCard(key, { + editUrl: storagePath, + uploadState: "done", + uploadError: null, + optimizationSummary: optimization?.summary ?? null, + }); setImages((prev) => prev.map((img) => img.key === key @@ -98,8 +108,11 @@ export default function AdminImages() { : img ) ); - setGlobalMessage({ type: "success", text: `"${key}" uploadé et sauvegardé !` }); - setTimeout(() => updateCard(key, { uploadState: "idle" }), 3000); + const successMsg = optimization + ? `"${key}" sauvegardé — ${optimization.summary}` + : `"${key}" uploadé et sauvegardé !`; + setGlobalMessage({ type: "success", text: successMsg }); + setTimeout(() => updateCard(key, { uploadState: "idle", optimizationSummary: null }), 5000); } catch { updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" }); } @@ -179,7 +192,7 @@ export default function AdminImages() {

Images du site

- Uploadez vos fichiers directement dans le bucket privé Supabase, ou collez une URL externe. + Uploadez vos fichiers directement dans le bucket privé Supabase. Les images sont automatiquement converties en WebP et optimisées entre 300 Ko et 1 Mo.

@@ -301,18 +314,23 @@ CREATE POLICY "service_role_full_access" /> {isUploading ? ( -

Upload en cours...

+

Optimisation et upload en cours...

) : isSaving ? (

Sauvegarde...

) : isDone ? ( -

Fichier enregistré !

+
+

Fichier enregistré !

+ {state.optimizationSummary && ( +

{state.optimizationSummary}

+ )} +
) : (

Glissez une image ou{" "} parcourir

-

JPEG · PNG · WebP · GIF · AVIF — max 5 Mo

+

JPEG · PNG · WebP · AVIF — converti en WebP, max 20 Mo

)} diff --git a/app/api/admin/upload/route.ts b/app/api/admin/upload/route.ts index 61fa53e..73702f8 100644 --- a/app/api/admin/upload/route.ts +++ b/app/api/admin/upload/route.ts @@ -1,18 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient, createAdminClient } from "@/lib/supabase/server"; import type { Profile } from "@/types/database.types"; +import sharp from "sharp"; const BUCKET = "private-gallery"; -// ── Types MIME autorisés → extension de stockage ─────────────────────────── -// L'extension est dérivée du MIME validé côté serveur, jamais du nom de fichier. -const MIME_TO_EXT: Record = { - "image/jpeg": "jpg", - "image/png": "png", - "image/webp": "webp", - "image/gif": "gif", - "image/avif": "avif", -}; +// Taille cible : entre 300 Ko et 1 Mo +const TARGET_MAX_BYTES = 1_000_000; // 1 Mo +const TARGET_MIN_BYTES = 300_000; // 300 Ko (indicatif — on ne force pas l'inflation) + +// Paliers de qualité WebP : on descend jusqu'à rentrer sous 1 Mo +const QUALITY_STEPS = [82, 72, 62, 50]; // ── Signatures magic bytes ────────────────────────────────────────────────── // Permet de détecter le vrai format binaire indépendamment du Content-Type @@ -33,7 +31,6 @@ const MAGIC_SIGNATURES: Array<{ }, { mime: "image/gif", - // GIF87a ou GIF89a check: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38, }, { @@ -61,6 +58,39 @@ function detectMimeFromBytes(buffer: Uint8Array): string | null { return null; } +/** + * Optimise l'image : + * – Conversion en WebP (meilleur ratio qualité/poids sur le web) + * – Auto-rotation via l'orientation EXIF (corrige les photos de téléphone) + * – Strip de toutes les métadonnées (GPS, modèle appareil, EXIF) — les navigateurs assument sRGB + * – Compression adaptative : démarre à q82, descend par paliers si > 1 Mo + * + * Retourne le buffer WebP optimisé et les stats (pour logging). + */ +async function optimizeToWebP( + input: Buffer +): Promise<{ buffer: Buffer; quality: number; originalBytes: number; finalBytes: number }> { + const originalBytes = input.length; + + for (const quality of QUALITY_STEPS) { + const output = await sharp(input) + .rotate() // Auto-rotation EXIF (corrige portrait/paysage) + // withMetadata() non appelé → Sharp strip tout par défaut : + // GPS, modèle appareil, IPTC… supprimés. Navigateurs assument sRGB. + .webp({ quality, effort: 4 }) // effort 4 = bon compromis vitesse/compression + .toBuffer(); + + // On s'arrête dès qu'on passe sous 1 Mo + // ou si on est déjà au dernier palier (on prend quoi qu'il en soit) + if (output.length <= TARGET_MAX_BYTES || quality === QUALITY_STEPS.at(-1)) { + return { buffer: output, quality, originalBytes, finalBytes: output.length }; + } + } + + // Ne devrait jamais être atteint — TypeScript exige un return exhaustif + throw new Error("Optimisation impossible"); +} + async function checkAdmin() { const supabase = await createClient(); const { @@ -78,7 +108,7 @@ async function checkAdmin() { return (profile as Pick | null)?.is_admin === true; } -// POST - Upload un fichier dans le bucket private-gallery +// POST — Upload + optimisation automatique vers Supabase Storage export async function POST(request: NextRequest) { const isAdmin = await checkAdmin(); if (!isAdmin) { @@ -99,54 +129,50 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 }); } - // ── 1. Vérifier le type MIME déclaré ────────────────────────────────────── - if (!Object.hasOwn(MIME_TO_EXT, file.type)) { - return NextResponse.json( - { error: "Type de fichier non supporté. Utilisez JPEG, PNG, WebP, GIF ou AVIF." }, - { status: 400 } - ); + // ── 1. Limite de taille brute (avant optimisation) ──────────────────────── + if (file.size > 20 * 1024 * 1024) { + return NextResponse.json({ error: "Fichier trop volumineux (max 20 Mo avant optimisation)" }, { status: 400 }); } - // ── 2. Limite de taille ─────────────────────────────────────────────────── - if (file.size > 5 * 1024 * 1024) { - return NextResponse.json({ error: "Fichier trop volumineux (max 5 Mo)" }, { status: 400 }); - } - - // ── 3. Lire le contenu binaire ──────────────────────────────────────────── + // ── 2. Lire le contenu binaire ──────────────────────────────────────────── const arrayBuffer = await file.arrayBuffer(); - const buffer = new Uint8Array(arrayBuffer); + const rawBuffer = new Uint8Array(arrayBuffer); - // ── 4. Valider les magic bytes (anti-MIME spoofing) ─────────────────────── - // On détecte le vrai format à partir des octets du fichier, pas du Content-Type - // envoyé par le client. - const detectedMime = detectMimeFromBytes(buffer); + // ── 3. Valider les magic bytes (anti-MIME spoofing) ─────────────────────── + const detectedMime = detectMimeFromBytes(rawBuffer); if (!detectedMime) { return NextResponse.json( - { error: "Le fichier ne correspond pas à un format image valide." }, - { status: 400 } - ); - } - // Le MIME réel doit correspondre au MIME déclaré - if (detectedMime !== file.type) { - return NextResponse.json( - { error: "Le type du fichier ne correspond pas à son contenu." }, + { error: "Le fichier ne correspond pas à un format image valide (JPEG, PNG, WebP, AVIF, GIF)." }, { status: 400 } ); } + // ── 4. Optimisation : conversion WebP + compression adaptative ──────────── + let optimized: Awaited>; + try { + optimized = await optimizeToWebP(Buffer.from(rawBuffer)); + } catch { + return NextResponse.json( + { error: "Erreur lors de l'optimisation de l'image." }, + { status: 500 } + ); + } + + const { buffer, quality, originalBytes, finalBytes } = optimized; + // ── 5. Construire le chemin de stockage ─────────────────────────────────── - // Extension toujours dérivée du MIME validé, jamais du nom de fichier fourni. - const ext = MIME_TO_EXT[detectedMime]; + // Toujours .webp quelle que soit l'entrée (JPEG, PNG, AVIF…) const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_"); - const filePath = `${sanitizedKey}/image.${ext}`; + const filePath = `${sanitizedKey}/image.webp`; // ── 6. Upload vers Supabase Storage ────────────────────────────────────── const adminClient = createAdminClient(); const { error } = await adminClient.storage .from(BUCKET) .upload(filePath, buffer, { - contentType: detectedMime, + contentType: "image/webp", upsert: true, + cacheControl: "public, max-age=31536000", // 1 an (CDN Supabase) }); if (error) { @@ -157,5 +183,22 @@ export async function POST(request: NextRequest) { } const storagePath = `storage:${filePath}`; - return NextResponse.json({ storagePath, filePath }); + + // Infos retournées pour le feedback admin + const originalKb = Math.round(originalBytes / 1024); + const finalKb = Math.round(finalBytes / 1024); + const inRange = finalBytes >= TARGET_MIN_BYTES && finalBytes <= TARGET_MAX_BYTES; + + return NextResponse.json({ + storagePath, + filePath, + optimization: { + originalKb, + finalKb, + quality, + inRange, + // Message lisible en fr pour l'UI + summary: `${originalKb} Ko → ${finalKb} Ko (WebP q${quality})`, + }, + }); } diff --git a/package-lock.json b/package-lock.json index 16ec86a..6620af9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,13 +17,14 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "resend": "^6.9.1", + "sharp": "^0.34.5", "stripe": "^20.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@sanity/vision": "^3.99.0", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/node": "^20.19.33", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "eslint": "^9", @@ -3231,7 +3232,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -10521,7 +10521,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -18452,7 +18451,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -18496,7 +18494,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index af1fca2..05828a2 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,14 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "resend": "^6.9.1", + "sharp": "^0.34.5", "stripe": "^20.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@sanity/vision": "^3.99.0", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/node": "^20.19.33", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "eslint": "^9",