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

@@ -13,10 +13,11 @@ interface SiteImage {
type UploadState = "idle" | "uploading" | "saving" | "done" | "error"; type UploadState = "idle" | "uploading" | "saving" | "done" | "error";
interface ImageCardState { interface ImageCardState {
editUrl: string; // valeur brute en cours d'édition editUrl: string; // valeur brute en cours d'édition
previewUrl: string; // URL pour l'aperçu previewUrl: string; // URL pour l'aperçu
uploadState: UploadState; uploadState: UploadState;
uploadError: string | null; uploadError: string | null;
optimizationSummary: string | null; // ex: "2 400 Ko → 680 Ko (WebP q82)"
} }
export default function AdminImages() { export default function AdminImages() {
@@ -40,6 +41,7 @@ export default function AdminImages() {
previewUrl: img.previewUrl, previewUrl: img.previewUrl,
uploadState: "idle", uploadState: "idle",
uploadError: null, uploadError: null,
optimizationSummary: null,
}; };
} }
setCardState(state); setCardState(state);
@@ -73,7 +75,10 @@ export default function AdminImages() {
return; return;
} }
const { storagePath } = uploadData as { storagePath: string }; const { storagePath, optimization } = uploadData as {
storagePath: string;
optimization?: { summary: string; inRange: boolean };
};
// Sauvegarde automatique en BDD // Sauvegarde automatique en BDD
updateCard(key, { uploadState: "saving" }); updateCard(key, { uploadState: "saving" });
@@ -90,7 +95,12 @@ export default function AdminImages() {
} }
// Succès : mettre à jour l'état // 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) => setImages((prev) =>
prev.map((img) => prev.map((img) =>
img.key === key img.key === key
@@ -98,8 +108,11 @@ export default function AdminImages() {
: img : img
) )
); );
setGlobalMessage({ type: "success", text: `"${key}" uploadé et sauvegardé !` }); const successMsg = optimization
setTimeout(() => updateCard(key, { uploadState: "idle" }), 3000); ? `"${key}" sauvegardé — ${optimization.summary}`
: `"${key}" uploadé et sauvegardé !`;
setGlobalMessage({ type: "success", text: successMsg });
setTimeout(() => updateCard(key, { uploadState: "idle", optimizationSummary: null }), 5000);
} catch { } catch {
updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" }); updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" });
} }
@@ -179,7 +192,7 @@ export default function AdminImages() {
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Images du site</h1> <h1 className="text-3xl font-bold text-white mb-2">Images du site</h1>
<p className="text-white/60"> <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> </p>
</div> </div>
@@ -301,18 +314,23 @@ CREATE POLICY "service_role_full_access"
/> />
{isUploading ? ( {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 ? ( ) : isSaving ? (
<p className="text-orange text-xs font-medium">Sauvegarde...</p> <p className="text-orange text-xs font-medium">Sauvegarde...</p>
) : isDone ? ( ) : isDone ? (
<p className="text-success text-xs font-medium">Fichier enregistré !</p> <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"> <div className="space-y-1">
<p className="text-white/50 text-xs"> <p className="text-white/50 text-xs">
<span className="text-white/80 font-medium">Glissez une image</span> ou{" "} <span className="text-white/80 font-medium">Glissez une image</span> ou{" "}
<span className="text-orange font-medium underline">parcourir</span> <span className="text-orange font-medium underline">parcourir</span>
</p> </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>
)} )}
</div> </div>

View File

@@ -1,18 +1,16 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createClient, createAdminClient } from "@/lib/supabase/server"; import { createClient, createAdminClient } from "@/lib/supabase/server";
import type { Profile } from "@/types/database.types"; import type { Profile } from "@/types/database.types";
import sharp from "sharp";
const BUCKET = "private-gallery"; const BUCKET = "private-gallery";
// ── Types MIME autorisés → extension de stockage ─────────────────────────── // Taille cible : entre 300 Ko et 1 Mo
// L'extension est dérivée du MIME validé côté serveur, jamais du nom de fichier. const TARGET_MAX_BYTES = 1_000_000; // 1 Mo
const MIME_TO_EXT: Record<string, string> = { const TARGET_MIN_BYTES = 300_000; // 300 Ko (indicatif — on ne force pas l'inflation)
"image/jpeg": "jpg",
"image/png": "png", // Paliers de qualité WebP : on descend jusqu'à rentrer sous 1 Mo
"image/webp": "webp", const QUALITY_STEPS = [82, 72, 62, 50];
"image/gif": "gif",
"image/avif": "avif",
};
// ── Signatures magic bytes ────────────────────────────────────────────────── // ── Signatures magic bytes ──────────────────────────────────────────────────
// Permet de détecter le vrai format binaire indépendamment du Content-Type // Permet de détecter le vrai format binaire indépendamment du Content-Type
@@ -33,7 +31,6 @@ const MAGIC_SIGNATURES: Array<{
}, },
{ {
mime: "image/gif", mime: "image/gif",
// GIF87a ou GIF89a
check: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38, 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; 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() { async function checkAdmin() {
const supabase = await createClient(); const supabase = await createClient();
const { const {
@@ -78,7 +108,7 @@ async function checkAdmin() {
return (profile as Pick<Profile, "is_admin"> | null)?.is_admin === true; 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) { export async function POST(request: NextRequest) {
const isAdmin = await checkAdmin(); const isAdmin = await checkAdmin();
if (!isAdmin) { if (!isAdmin) {
@@ -99,54 +129,50 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 }); return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 });
} }
// ── 1. Vérifier le type MIME déclaré ────────────────────────────────────── // ── 1. Limite de taille brute (avant optimisation) ────────────────────────
if (!Object.hasOwn(MIME_TO_EXT, file.type)) { if (file.size > 20 * 1024 * 1024) {
return NextResponse.json( return NextResponse.json({ error: "Fichier trop volumineux (max 20 Mo avant optimisation)" }, { status: 400 });
{ error: "Type de fichier non supporté. Utilisez JPEG, PNG, WebP, GIF ou AVIF." },
{ status: 400 }
);
} }
// ── 2. Limite de taille ─────────────────────────────────────────────────── // ── 2. Lire le contenu binaire ────────────────────────────────────────────
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: "Fichier trop volumineux (max 5 Mo)" }, { status: 400 });
}
// ── 3. Lire le contenu binaire ────────────────────────────────────────────
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer); const rawBuffer = new Uint8Array(arrayBuffer);
// ── 4. Valider les magic bytes (anti-MIME spoofing) ─────────────────────── // ── 3. Valider les magic bytes (anti-MIME spoofing) ───────────────────────
// On détecte le vrai format à partir des octets du fichier, pas du Content-Type const detectedMime = detectMimeFromBytes(rawBuffer);
// envoyé par le client.
const detectedMime = detectMimeFromBytes(buffer);
if (!detectedMime) { if (!detectedMime) {
return NextResponse.json( return NextResponse.json(
{ error: "Le fichier ne correspond pas à un format image valide." }, { error: "Le fichier ne correspond pas à un format image valide (JPEG, PNG, WebP, AVIF, GIF)." },
{ 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." },
{ status: 400 } { 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 ─────────────────────────────────── // ── 5. Construire le chemin de stockage ───────────────────────────────────
// Extension toujours dérivée du MIME validé, jamais du nom de fichier fourni. // Toujours .webp quelle que soit l'entrée (JPEG, PNG, AVIF…)
const ext = MIME_TO_EXT[detectedMime];
const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_"); const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_");
const filePath = `${sanitizedKey}/image.${ext}`; const filePath = `${sanitizedKey}/image.webp`;
// ── 6. Upload vers Supabase Storage ────────────────────────────────────── // ── 6. Upload vers Supabase Storage ──────────────────────────────────────
const adminClient = createAdminClient(); const adminClient = createAdminClient();
const { error } = await adminClient.storage const { error } = await adminClient.storage
.from(BUCKET) .from(BUCKET)
.upload(filePath, buffer, { .upload(filePath, buffer, {
contentType: detectedMime, contentType: "image/webp",
upsert: true, upsert: true,
cacheControl: "public, max-age=31536000", // 1 an (CDN Supabase)
}); });
if (error) { if (error) {
@@ -157,5 +183,22 @@ export async function POST(request: NextRequest) {
} }
const storagePath = `storage:${filePath}`; 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": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"resend": "^6.9.1", "resend": "^6.9.1",
"sharp": "^0.34.5",
"stripe": "^20.3.1" "stripe": "^20.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@sanity/vision": "^3.99.0", "@sanity/vision": "^3.99.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20.19.33",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"eslint": "^9", "eslint": "^9",
@@ -3231,7 +3232,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -10521,7 +10521,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -18452,7 +18451,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -18496,7 +18494,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },

View File

@@ -18,13 +18,14 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"resend": "^6.9.1", "resend": "^6.9.1",
"sharp": "^0.34.5",
"stripe": "^20.3.1" "stripe": "^20.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@sanity/vision": "^3.99.0", "@sanity/vision": "^3.99.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20.19.33",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"eslint": "^9", "eslint": "^9",