// Edge Function `admin-gallery` // // Contrat (fixé par le chef de projet, à ne pas renommer) : // GET {SUPABASE_URL}/functions/v1/admin-gallery?slug=&token=[&action=zip] // // Tourne avec la service_role key (cf. _shared/supabase-admin.ts). // // Changement d'architecture (2026-09-16) : les tables photobooth.events / // photobooth.photos ne sont PLUS accessibles, même en service_role, via une // requête `.from(...)` classique — le schéma `photobooth` n'est jamais // exposé via PostgREST (cf. // supabase/migrations/20260916090000_create_photobooth_schema.sql). Tout // passe désormais par des RPC `public.get_event_for_admin` / // `public.get_event_photos`, réservées à service_role côté SQL (grants). // // Pas d'auth Supabase (pas de compte admin en V1, cf. docs/decisions.md) : // la sécurité repose entièrement sur `admin_token`, comparé ici (jamais en // SQL) en temps constant. // // ⚠️ Ne pas déployer depuis cet agent (pas de `deploy_edge_function`) : // fichier fourni pour relecture humaine avant déploiement (Kévin applique // lui-même via son accès MCP Supabase direct). // // Dépendance : "jsr:@zip-js/zip-js" pour la génération de ZIP en flux // (cf. plus bas, section `action=zip`). Non testée en exécution dans cet // environnement (pas d'accès réseau/deploy) — à valider avec // `supabase functions serve` avant premier déploiement réel. import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts"; import { corsHeaders, handleCorsPreflight } from "../_shared/cors.ts"; import { timingSafeEqual } from "../_shared/timing-safe.ts"; import { HttpReader, ZipWriter } from "jsr:@zip-js/zip-js"; // URL signées affichées dans la galerie admin : courte durée, c'est pour un // affichage/téléchargement immédiat dans le navigateur de l'admin, pas pour // un lien à partager (cf. consigne de tâche). const GALLERY_SIGNED_URL_TTL_SECONDS = 10 * 60; // 10 minutes // URLs signées utilisées en interne par la fonction pour lire les fichiers // et les empaqueter dans le ZIP : pas renvoyées au client, la durée n'a donc // pas besoin d'être aussi courte, mais doit couvrir le temps de génération // du ZIP en cas de gros volume. const ZIP_INTERNAL_SIGNED_URL_TTL_SECONDS = 30 * 60; // 30 minutes // Garde-fou volume ZIP (placeholder, non validé empiriquement — cf. rapport // de tâche) : au-delà, on refuse `action=zip` plutôt que de risquer un // timeout Edge Function (~150s) ou un flux qui traîne indéfiniment. À // ajuster une fois testé avec un volume réel de photos. const MAX_ZIP_PHOTOS = 400; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders(), "Content-Type": "application/json" }, }); } // Réponse générique utilisée à la fois pour "event inconnu" et "token // invalide" : MÊME statut HTTP (404) et MÊME corps dans les deux cas. // // ⚠️ Écart assumé par rapport à la consigne initiale du 2026-09-15 ("404 si // absent, 403 si mismatch, même forme de réponse que le 404") : si les deux // cas renvoient des STATUTS HTTP différents (404 vs 403), un attaquant peut // malgré tout distinguer "slug inconnu" de "slug connu mais token invalide" // rien qu'en lisant le code HTTP, même si le corps JSON est identique — ce // qui va à l'encontre de l'objectif explicitement énoncé. Pour respecter // l'objectif de sécurité plutôt que la lettre de la consigne, les deux cas // renvoient ici exactement la même réponse (statut ET corps). Toujours à // confirmer avec le chef de projet / Kévin — non retranché depuis le // rapport du 2026-09-15. function notFoundOrForbidden(): Response { return jsonResponse(404, { error: "not_found" }); } function sanitizeFilenamePart(input: string | null | undefined, fallback: string): string { // NFKD décompose les caractères accentués en (lettre de base + marque // diacritique combinante) ; le filtre `[^\w \-]` qui suit élimine ensuite // ces marques (elles ne font pas partie de `\w`), on récupère donc la // lettre de base sans accent, sans avoir besoin d'une plage unicode // explicite dédiée. const cleaned = (input ?? "") .normalize("NFKD") .replace(/[^\w \-]+/g, "") .trim() .slice(0, 40); const withDashes = cleaned.replace(/\s+/g, "-"); return withDashes.length > 0 ? withDashes : fallback; } function extensionFromPath(path: string): string { const match = /\.([a-zA-Z0-9]+)$/.exec(path); return match ? match[1].toLowerCase() : "jpg"; } type PhotoRow = { id: string; nom_invite: string | null; message: string | null; uploaded_at: string; url_storage: string; }; type AdminEventRow = { id: string; nom: string; statut: string; date_evenement: string; expire_at: string; admin_token: string; }; function buildZipEntryName(photo: PhotoRow, index: number, ext: string): string { if (photo.nom_invite && photo.nom_invite.trim().length > 0) { const safeName = sanitizeFilenamePart(photo.nom_invite, "invite"); return `${safeName}-${photo.id}.${ext}`; } return `photo-${index + 1}.${ext}`; } Deno.serve(async (req) => { const preflight = handleCorsPreflight(req); if (preflight) return preflight; if (req.method !== "GET") { return jsonResponse(405, { error: "method_not_allowed" }); } const url = new URL(req.url); const slug = url.searchParams.get("slug"); const token = url.searchParams.get("token"); const action = url.searchParams.get("action"); if (!slug || !token) { return jsonResponse(400, { error: "missing_parameters" }); } const supabaseAdmin = getSupabaseAdmin(); const bucket = getStorageBucket(); // 1. Charge l'event par slug via la RPC public.get_event_for_admin // (service_role uniquement, renvoie admin_token en clair — jamais via une // requête `.from("photobooth.events" | "events")`, cette table n'est pas // exposée par PostgREST). const { data: event, error: eventError } = await supabaseAdmin .rpc("get_event_for_admin", { p_slug: slug }) .maybeSingle(); if (eventError) { console.error("[admin-gallery] get_event_for_admin failed", eventError); return jsonResponse(500, { error: "internal_error" }); } if (!event) { return notFoundOrForbidden(); } const typedEvent = event as AdminEventRow; // 2. Comparaison du token en temps constant (cf. _shared/timing-safe.ts). // C'est la fonction SQL get_event_for_admin qui renvoie admin_token, mais // c'est bien ICI, côté Deno, que la comparaison a lieu — jamais en SQL. const tokenMatches = await timingSafeEqual(token, typedEvent.admin_token); if (!tokenMatches) { return notFoundOrForbidden(); } // Pas de rate limiting / anti brute-force sur `token` (décision assumée, // cf. rapport de tâche du 2026-09-15) : admin_token fait 256 bits // d'entropie, un brute-force en ligne est impraticable en pratique. if (action === "zip") { // `slug` (paramètre déjà validé plus haut) sert de base au nom du // fichier ZIP téléchargé : get_event_for_admin ne renvoie PAS le slug // (signature fixée côté SQL, cf. // 20260916090400_create_public_functions.sql), on réutilise donc // directement celui de la query string plutôt que de modifier la RPC // pour ça. return await handleZipExport(supabaseAdmin, bucket, typedEvent, slug); } return await handleJsonGallery(supabaseAdmin, typedEvent); }); async function fetchEventPhotos( // deno-lint-ignore no-explicit-any supabaseAdmin: any, eventId: string ): Promise { // public.get_event_photos (service_role uniquement) renvoie les photos // triées par uploaded_at ASC (ordre chronologique), cf. // 20260916090400_create_public_functions.sql. const { data, error } = await supabaseAdmin.rpc("get_event_photos", { p_event_id: eventId, }); if (error) { throw error; } return (data ?? []) as PhotoRow[]; } async function handleJsonGallery( // deno-lint-ignore no-explicit-any supabaseAdmin: any, event: AdminEventRow ): Promise { let photosAsc: PhotoRow[]; try { photosAsc = await fetchEventPhotos(supabaseAdmin, event.id); } catch (err) { console.error("[admin-gallery] get_event_photos failed", err); return jsonResponse(500, { error: "internal_error" }); } // Galerie admin : plus récent en premier. get_event_photos renvoie déjà // un tableau trié ASC (chronologique) ; on l'inverse ici plutôt que // d'ajouter un paramètre d'ordre à la RPC (le volume par événement reste // modeste, cf. MAX_ZIP_PHOTOS = 400, donc un `.reverse()` en mémoire est // largement suffisant). const photosDesc = [...photosAsc].reverse(); // Une seule requête batch pour toutes les URLs signées (createSignedUrls), // plutôt qu'un appel par photo : moins de requêtes réseau, cohérent avec // le principe d'écoconception du projet (CLAUDE.md). const signedUrlBypath = new Map(); if (photosDesc.length > 0) { const paths = photosDesc.map((p) => p.url_storage); const bucket = getStorageBucket(); const { data: signedUrls, error: signError } = await supabaseAdmin.storage .from(bucket) .createSignedUrls(paths, GALLERY_SIGNED_URL_TTL_SECONDS); if (signError) { console.error("[admin-gallery] createSignedUrls failed", signError); return jsonResponse(500, { error: "internal_error" }); } for (let i = 0; i < paths.length; i++) { const entry = signedUrls?.[i]; if (entry?.signedUrl) { signedUrlBypath.set(paths[i], entry.signedUrl); } else if (entry?.error) { // Une photo dont l'URL n'a pas pu être signée (ex. fichier absent du // Storage alors que la ligne existe encore côté DB) ne doit pas // faire échouer toute la galerie : on la renvoie avec // signed_url: null, au front de gérer l'affichage. console.warn( `[admin-gallery] signed url failed for ${paths[i]}`, entry.error ); } } } return jsonResponse(200, { event: { nom: event.nom, date_evenement: event.date_evenement, statut: event.statut, photo_count: photosDesc.length, }, photos: photosDesc.map((p) => ({ id: p.id, nom_invite: p.nom_invite, message: p.message, uploaded_at: p.uploaded_at, signed_url: signedUrlBypath.get(p.url_storage) ?? null, expires_in: GALLERY_SIGNED_URL_TTL_SECONDS, })), }); } async function handleZipExport( // deno-lint-ignore no-explicit-any supabaseAdmin: any, bucket: string, event: AdminEventRow, slug: string ): Promise { let photos: PhotoRow[]; try { photos = await fetchEventPhotos(supabaseAdmin, event.id); } catch (err) { console.error("[admin-gallery] zip: get_event_photos failed", err); return jsonResponse(500, { error: "internal_error" }); } if (photos.length === 0) { return jsonResponse(404, { error: "no_photos" }); } // Garde-fou volume (cf. MAX_ZIP_PHOTOS ci-dessus) : évite de partir sur un // ZIP qui a de bonnes chances de timeout côté Edge Function pour un très // gros événement. Valeur provisoire, à valider avec un test réel — cf. // rapport de tâche. if (photos.length > MAX_ZIP_PHOTOS) { return jsonResponse(413, { error: "too_many_photos_for_zip", photo_count: photos.length, max_zip_photos: MAX_ZIP_PHOTOS, }); } const paths = photos.map((p) => p.url_storage); const { data: signedUrls, error: signError } = await supabaseAdmin.storage .from(bucket) .createSignedUrls(paths, ZIP_INTERNAL_SIGNED_URL_TTL_SECONDS); if (signError) { console.error("[admin-gallery] zip: createSignedUrls failed", signError); return jsonResponse(500, { error: "internal_error" }); } // Construction du ZIP en FLUX (pas de Blob/Uint8Array accumulé en mémoire // pour l'ensemble des photos) : on écrit dans un TransformStream, et on // renvoie sa moitié "readable" comme corps de la Response immédiatement, // pendant que la moitié "writable" continue de recevoir les fichiers // (téléchargés directement depuis Storage via HttpReader). `level: 0` // (STORE, pas de compression) : les photos sont déjà des JPEG compressés // côté client (cf. src/e/upload.js), les recompresser dans le ZIP // coûterait du CPU pour un gain quasi nul. const { readable, writable } = new TransformStream(); const zipWriter = new ZipWriter(writable); const usedNames = new Set(); // Fire-and-forget assumé : on ne peut pas `await` la fin du ZIP avant de // `return new Response(readable, ...)`, sinon on perdrait l'intérêt du // streaming. La fonction Edge reste vivante tant que la réponse en // streaming n'est pas entièrement consommée par le client. (async () => { try { for (let i = 0; i < photos.length; i++) { const photo = photos[i]; const signed = signedUrls?.[i]; if (!signed?.signedUrl) { console.warn( `[admin-gallery] zip: skipping ${photo.url_storage} (no signed url)` ); continue; } const ext = extensionFromPath(photo.url_storage); let entryName = buildZipEntryName(photo, i, ext); // Garantit l'unicité même en cas de collision improbable. let suffix = 1; while (usedNames.has(entryName)) { entryName = `${buildZipEntryName(photo, i, ext).replace(`.${ext}`, "")}-${suffix}.${ext}`; suffix++; } usedNames.add(entryName); await zipWriter.add(entryName, new HttpReader(signed.signedUrl), { level: 0, }); } } catch (err) { // ⚠️ Limite connue : si une erreur survient APRÈS que les premiers // octets de la réponse ont déjà été envoyés (statut 200 déjà commis), // il est impossible de renvoyer un statut d'erreur HTTP à ce stade. // Le client reçoit un ZIP tronqué/corrompu plutôt qu'un message // d'erreur propre — limite inhérente au streaming HTTP. console.error("[admin-gallery] zip: stream build failed", err); } finally { try { await zipWriter.close(); } catch (closeErr) { console.error("[admin-gallery] zip: writer close failed", closeErr); } } })(); const zipFilename = `${sanitizeFilenamePart(slug || event.nom, "export")}.zip`; return new Response(readable, { status: 200, headers: { ...corsHeaders(), "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${zipFilename}"`, }, }); }