// Edge Function `public-gallery` // // Contrat : GET {SUPABASE_URL}/functions/v1/public-gallery?slug= // // Tourne avec la service_role key (cf. _shared/supabase-admin.ts), MAIS // sans aucune vérification de token — c'est le point central de cette // fonction, à la différence de `admin-gallery`. L'accès est intentionnellement // ouvert à quiconque connaît le `slug` : même modèle de confiance que // l'upload invité (le slug circule via QR code, ce n'est pas un secret). // // Origine : revirement produit du 2026-09-18 (cf. docs/decisions.md) — les // invités peuvent désormais voir toutes les photos d'un événement, pas // seulement envoyer la leur. Ça inverse la règle "lecture des photos jamais // publique" du cahier des charges initial / docs/modele-donnees.md : ce // revirement est acté comme décision explicite du 2026-09-18, pas tranché // par cet agent. // // Sécurité : on résout le slug via `public.get_public_event` (PAS // `get_event_for_admin`, qui expose admin_token) — cette fonction ne // renvoie que {id, nom, statut, date_evenement, expire_at, // max_photos_per_guest}, jamais admin_token / client_nom / facture_montant. // Les photos elles-mêmes viennent de `public.get_event_photos` // (service_role uniquement), qui ne renvoie que {id, url_storage, // nom_invite, message, uploaded_at} — jamais l'email/téléphone d'un invité // (ces colonnes n'existent d'ailleurs pas dans photobooth.photos, cf. // docs/modele-donnees.md). // // `get_public_event` renvoie l'événement même si son statut n'est pas // `actif` (expiré/archivé) — comportement hérité, inchangé ici. Cette // fonction renvoie donc telles quelles les photos d'un événement expiré si // elles existent encore en base (la purge Storage/DB à J+7 est un job // séparé, cf. expire-events) : pas de filtre `statut = 'actif'` ajouté ici, // le front peut décider d'afficher un message adapté à partir de // `event.statut`, cohérent avec le comportement déjà choisi pour // `get_public_event` côté page d'upload. import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts"; import { corsHeaders, handleCorsPreflight } from "../_shared/cors.ts"; // URLs signées affichées dans la galerie invité : courte durée, c'est pour // un affichage immédiat dans le navigateur de l'invité, pas pour un lien à // partager. Même valeur que `admin-gallery` (cf. GALLERY_SIGNED_URL_TTL_SECONDS // dans supabase/functions/admin-gallery/index.ts). const GALLERY_SIGNED_URL_TTL_SECONDS = 10 * 60; // 10 minutes type PhotoRow = { id: string; nom_invite: string | null; message: string | null; uploaded_at: string; url_storage: string; }; type PublicEventRow = { id: string; nom: string; statut: string; date_evenement: string; expire_at: string; max_photos_per_guest: number | null; }; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders(), "Content-Type": "application/json" }, }); } // 404 générique : même statut/corps que le "slug inconnu" de admin-gallery, // pour ne pas distinguer un slug qui n'existe pas d'un autre cas d'erreur // silencieuse côté appelant. function notFound(): Response { return jsonResponse(404, { error: "not_found" }); } 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"); if (!slug) { return jsonResponse(400, { error: "missing_parameters" }); } const supabaseAdmin = getSupabaseAdmin(); // 1. Résout l'event par slug via public.get_public_event (anon-safe, // n'expose jamais admin_token/client_nom/facture_montant) — pas // get_event_for_admin, réservée à admin-gallery. const { data: event, error: eventError } = await supabaseAdmin .rpc("get_public_event", { p_slug: slug }) .maybeSingle(); if (eventError) { console.error("[public-gallery] get_public_event failed", eventError); return jsonResponse(500, { error: "internal_error" }); } if (!event) { return notFound(); } const typedEvent = event as PublicEventRow; // 2. Photos de l'event, triées chronologiquement (get_event_photos renvoie // déjà uploaded_at ASC) — pas de tri inversé ici contrairement à // admin-gallery : une galerie invité affiche naturellement l'ordre // d'arrivée des photos, pas de raison produit de l'inverser pour l'instant. let photos: PhotoRow[]; try { const { data, error } = await supabaseAdmin.rpc("get_event_photos", { p_event_id: typedEvent.id, }); if (error) throw error; photos = (data ?? []) as PhotoRow[]; } catch (err) { console.error("[public-gallery] get_event_photos failed", err); return jsonResponse(500, { error: "internal_error" }); } // Event sans photo : état normal (pas d'upload pour l'instant), pas une // erreur — 200 avec photos: [], contrairement à admin-gallery?action=zip // qui renvoie 404/no_photos (là, l'absence de photo empêche l'action // demandée "générer un ZIP" ; ici il n'y a pas d'action, juste un état // d'affichage à rendre). const signedUrlByPath = new Map(); if (photos.length > 0) { const paths = photos.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("[public-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( `[public-gallery] signed url failed for ${paths[i]}`, entry.error ); } } } return jsonResponse(200, { event: { nom: typedEvent.nom, date_evenement: typedEvent.date_evenement, statut: typedEvent.statut, photo_count: photos.length, }, photos: photos.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, })), }); });