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
This commit is contained in:
81
app/api/img/[key]/route.ts
Normal file
81
app/api/img/[key]/route.ts
Normal file
@@ -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`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -136,42 +136,19 @@ export const DEFAULT_IMAGES: Record<string, { url: string; label: string }> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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<string> {
|
|
||||||
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.
|
* 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/<key>
|
||||||
|
* 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<Record<string, string>> {
|
export async function getSiteImages(): Promise<Record<string, string>> {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
// Mettre les defaults d'abord
|
// Initialiser avec les defaults
|
||||||
for (const [key, val] of Object.entries(DEFAULT_IMAGES)) {
|
for (const [key, val] of Object.entries(DEFAULT_IMAGES)) {
|
||||||
result[key] = val.url;
|
result[key] = val.url;
|
||||||
}
|
}
|
||||||
@@ -182,17 +159,19 @@ export async function getSiteImages(): Promise<Record<string, string>> {
|
|||||||
const rows = (data ?? []) as unknown as Pick<SiteImage, "key" | "url">[];
|
const rows = (data ?? []) as unknown as Pick<SiteImage, "key" | "url">[];
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
// Résoudre toutes les URLs en parallèle (signed URLs pour les paths storage:)
|
for (const row of rows) {
|
||||||
await Promise.all(
|
if (!row.url) continue;
|
||||||
rows.map(async (row) => {
|
if (row.url.startsWith("storage:")) {
|
||||||
if (row.url) {
|
// Proxy URL → la signed URL est générée à la demande côté navigateur
|
||||||
result[row.key] = await resolveUrl(row.url);
|
result[row.key] = `/api/img/${row.key}`;
|
||||||
}
|
} else {
|
||||||
})
|
// URL externe directe
|
||||||
);
|
result[row.key] = row.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Table n'existe pas encore, on utilise les defaults
|
// Table absente → on conserve les defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user