Files
obc-terrassement/app/api/img/[key]/route.ts
Claude 9fe1f8bea2 fix: éliminer définitivement les bugs d'affichage des images privées
Cause racine : les Signed URLs Supabase étaient générées au moment du
rendu ISR et embarquées dans le HTML mis en cache. Si Vercel servait
le HTML avant régénération ou si la signed URL expirait, l'image
était cassée et necessitait plusieurs rechargements.

Solution — proxy /api/img/[key] :
- app/api/img/[key]/route.ts : nouvelle route publique force-dynamic
  qui génère une Signed URL fraîche à chaque requête image (TTL 1h),
  avec Cache-Control 55 min côté navigateur/CDN ; fallback sur l'image
  Unsplash par défaut si createSignedUrl échoue ; 302 direct pour les
  URLs externes
- lib/site-images.ts : getSiteImages() ne génère plus jamais de Signed
  URL — les chemins storage: retournent /api/img/<key> (URL permanente),
  les URLs externes sont retournées telles quelles

Résultat : le HTML statique/ISR ne contient plus jamais de signed URL
éphémère → zéro image cassée, zéro rechargement nécessaire.

https://claude.ai/code/session_01PzA98VhLMmsHpzs7gnLHGs
2026-02-20 07:16:20 +00:00

82 lines
2.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/server";
import { DEFAULT_IMAGES } from "@/lib/site-images";
export const dynamic = "force-dynamic";
const BUCKET = "private-gallery";
// Signed URL valide 1h côté Supabase
const SIGNED_URL_TTL = 3600;
// Le navigateur/CDN met en cache la redirection 55 min
// (légèrement inférieur au TTL pour éviter tout risque d'expiration)
const REDIRECT_CACHE_TTL = 3300;
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
// Valider la clé (alphanumérique + underscores uniquement)
if (!/^[a-z0-9_]+$/.test(key)) {
return new NextResponse(null, { status: 400 });
}
const adminClient = createAdminClient();
// Valeur par défaut
let rawUrl: string = DEFAULT_IMAGES[key]?.url ?? "";
// Valeur en BDD (prioritaire)
try {
const { data } = await adminClient
.from("site_images")
.select("url")
.eq("key", key)
.single();
if (data?.url) rawUrl = data.url;
} catch {
// Aucune ligne trouvée ou table absente → on garde le default
}
// Aucune image configurée (clé inconnue ou default vide)
if (!rawUrl) {
return new NextResponse(null, { status: 404 });
}
// ── URL externe (Unsplash, etc.) ──────────────────────────────────────────
if (!rawUrl.startsWith("storage:")) {
return NextResponse.redirect(rawUrl, {
status: 302,
headers: {
"Cache-Control": "public, max-age=86400, s-maxage=86400",
},
});
}
// ── Chemin bucket privé → générer une Signed URL fraîche ─────────────────
const filePath = rawUrl.slice("storage:".length);
const { data, error } = await adminClient.storage
.from(BUCKET)
.createSignedUrl(filePath, SIGNED_URL_TTL);
if (error || !data?.signedUrl) {
// Fallback sur l'image par défaut Unsplash si la génération échoue
const fallback = DEFAULT_IMAGES[key]?.url;
if (fallback) {
return NextResponse.redirect(fallback, {
status: 302,
headers: { "Cache-Control": "public, max-age=60, s-maxage=60" },
});
}
return new NextResponse(null, { status: 503 });
}
return NextResponse.redirect(data.signedUrl, {
status: 302,
headers: {
"Cache-Control": `public, max-age=${REDIRECT_CACHE_TTL}, s-maxage=${REDIRECT_CACHE_TTL}, stale-while-revalidate=60`,
},
});
}