diff --git a/app/api/img/[key]/route.ts b/app/api/img/[key]/route.ts new file mode 100644 index 0000000..4e942d0 --- /dev/null +++ b/app/api/img/[key]/route.ts @@ -0,0 +1,81 @@ +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`, + }, + }); +} diff --git a/lib/site-images.ts b/lib/site-images.ts index b6d9496..9feb756 100644 --- a/lib/site-images.ts +++ b/lib/site-images.ts @@ -136,42 +136,19 @@ export const DEFAULT_IMAGES: Record = { }, }; -const STORAGE_PREFIX = "storage:"; -const BUCKET = "private-gallery"; -// Durée de validité des Signed URLs : 1 heure -const SIGNED_URL_TTL = 3600; - -/** - * Résout une valeur stockée en BDD vers une URL publique. - * Si la valeur commence par "storage:", génère une Signed URL temporaire (60 min) - * depuis le bucket privé Supabase. - * Sinon, retourne la valeur telle quelle (URL externe). - */ -async function resolveUrl(raw: string): Promise { - if (!raw.startsWith(STORAGE_PREFIX)) return raw; - - const filePath = raw.slice(STORAGE_PREFIX.length); // ex: "hero_portrait/image.jpg" - const supabase = createAdminClient(); - const { data, error } = await supabase.storage - .from(BUCKET) - .createSignedUrl(filePath, SIGNED_URL_TTL); - - if (error || !data?.signedUrl) { - // En cas d'erreur, on renvoie l'URL brute (le placeholder s'affichera) - return raw; - } - return data.signedUrl; -} - /** * Récupère toutes les images du site depuis Supabase. - * Les valeurs "storage:..." sont converties en Signed URLs à la volée. - * Fallback sur les valeurs par défaut si la table n'existe pas. + * + * - URL externe (https://...) → retournée telle quelle + * - Chemin privé (storage:...) → retourné comme /api/img/ + * Le proxy génère une Signed URL fraîche à chaque requête du navigateur + * (cache navigateur/CDN 55 min) — aucune signed URL n'est jamais embarquée + * dans le HTML statique, ce qui élimine tout risque d'expiration. */ export async function getSiteImages(): Promise> { const result: Record = {}; - // Mettre les defaults d'abord + // Initialiser avec les defaults for (const [key, val] of Object.entries(DEFAULT_IMAGES)) { result[key] = val.url; } @@ -182,17 +159,19 @@ export async function getSiteImages(): Promise> { const rows = (data ?? []) as unknown as Pick[]; if (!error) { - // Résoudre toutes les URLs en parallèle (signed URLs pour les paths storage:) - await Promise.all( - rows.map(async (row) => { - if (row.url) { - result[row.key] = await resolveUrl(row.url); - } - }) - ); + for (const row of rows) { + if (!row.url) continue; + if (row.url.startsWith("storage:")) { + // Proxy URL → la signed URL est générée à la demande côté navigateur + result[row.key] = `/api/img/${row.key}`; + } else { + // URL externe directe + result[row.key] = row.url; + } + } } } catch { - // Table n'existe pas encore, on utilise les defaults + // Table absente → on conserve les defaults } return result;