Schéma dédié `photobooth` (jamais exposé via PostgREST, accès exclusivement via fonctions SECURITY DEFINER dans public) avec RLS, policies Storage sur bucket privé event-photos, Edge Functions admin-gallery et expire-events, et job pg_cron d'expiration J+7. Le tout déployé et testé en conditions réelles sur le projet Supabase kevin-lecou-hub avant relecture sécurité (faille d'abus sur insert_public_photo corrigée en cours de route). Côté front : page upload invité (compression + HEIC + RGPD), galerie admin avec export ZIP, générateur de QR code autonome. Workflow n8n de purge Storage J+7 fourni (à activer manuellement côté n8n).
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
// Edge Function `admin-gallery`
|
|
//
|
|
// Contrat (fixé par le chef de projet, à ne pas renommer) :
|
|
// GET {SUPABASE_URL}/functions/v1/admin-gallery?slug=<slug>&token=<admin_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<PhotoRow[]> {
|
|
// 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<Response> {
|
|
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<string, string>();
|
|
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<Response> {
|
|
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<Uint8Array, Uint8Array>();
|
|
const zipWriter = new ZipWriter(writable);
|
|
|
|
const usedNames = new Set<string>();
|
|
|
|
// 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}"`,
|
|
},
|
|
});
|
|
}
|