feat(upload): optimisation automatique WebP avec Sharp

- 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
This commit is contained in:
Claude
2026-02-21 09:17:39 +00:00
parent 3843595e18
commit 800a9c08b4
4 changed files with 116 additions and 57 deletions

View File

@@ -17,6 +17,7 @@ interface ImageCardState {
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() {
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Images du site</h1>
<p className="text-white/60">
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.
</p>
</div>
@@ -301,18 +314,23 @@ CREATE POLICY "service_role_full_access"
/>
{isUploading ? (
<p className="text-orange text-xs font-medium">Upload en cours...</p>
<p className="text-orange text-xs font-medium">Optimisation et upload en cours...</p>
) : isSaving ? (
<p className="text-orange text-xs font-medium">Sauvegarde...</p>
) : isDone ? (
<div className="space-y-0.5">
<p className="text-success text-xs font-medium">Fichier enregistré !</p>
{state.optimizationSummary && (
<p className="text-white/40 text-xs">{state.optimizationSummary}</p>
)}
</div>
) : (
<div className="space-y-1">
<p className="text-white/50 text-xs">
<span className="text-white/80 font-medium">Glissez une image</span> ou{" "}
<span className="text-orange font-medium underline">parcourir</span>
</p>
<p className="text-white/25 text-xs">JPEG · PNG · WebP · GIF · AVIF max 5 Mo</p>
<p className="text-white/25 text-xs">JPEG · PNG · WebP · AVIF converti en WebP, max 20 Mo</p>
</div>
)}
</div>

View File

@@ -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<string, string> = {
"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<Profile, "is_admin"> | 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<ReturnType<typeof optimizeToWebP>>;
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})`,
},
});
}

7
package-lock.json generated
View File

@@ -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"
},

View File

@@ -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",