Ajoute la V1 fonctionnelle : upload invité, galerie admin, QR code, backend Supabase

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).
This commit is contained in:
2026-09-16 11:32:31 +02:00
parent 2007eab006
commit 9b1461978b
37 changed files with 6255 additions and 4 deletions
+40
View File
@@ -0,0 +1,40 @@
// Helpers CORS partagés entre les Edge Functions du projet photobooth-qr.
//
// `admin-gallery` est appelée directement depuis le navigateur (page galerie
// admin, cf. contrat fixé par le chef de projet) : elle a donc besoin de
// CORS. `expire-events` est appelée uniquement en serveur-à-serveur
// (cron/n8n), elle n'en a pas besoin mais ça ne coûte rien de partager le
// helper.
//
// APP_BASE_URL doit être configurée comme secret de l'Edge Function
// (`supabase secrets set APP_BASE_URL=...`), avec la même valeur que
// .env.example côté front : c'est l'unique origine autorisée à appeler ces
// fonctions en cross-origin. Volontairement PAS de fallback vers "*" : une
// route qui peut renvoyer des données nominatives (nom_invite, message) ne
// doit pas être appelable depuis n'importe quelle origine.
const APP_BASE_URL = Deno.env.get("APP_BASE_URL");
if (!APP_BASE_URL) {
console.warn(
"[_shared/cors] APP_BASE_URL n'est pas configurée : Access-Control-Allow-Origin sera 'null' " +
"(aucune origine navigateur ne pourra appeler cette fonction en cross-origin)."
);
}
export function corsHeaders(): HeadersInit {
return {
"Access-Control-Allow-Origin": APP_BASE_URL ?? "null",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
Vary: "Origin",
};
}
/** Renvoie une réponse pour une requête OPTIONS (preflight), sinon null. */
export function handleCorsPreflight(req: Request): Response | null {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders() });
}
return null;
}
@@ -0,0 +1,44 @@
// Client Supabase service_role, partagé entre les Edge Functions du projet.
//
// SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY sont injectées automatiquement
// par le runtime Supabase Edge Functions dans chaque fonction déployée (pas
// besoin de les définir soi-même via `supabase secrets set`) — cf. doc
// Supabase "Edge Functions > Environment variables". En local
// (`supabase functions serve`), elles sont également injectées à partir du
// projet local.
//
// ⚠️ Ce client ne doit JAMAIS être exposé côté front (cf. CLAUDE.md :
// "Ne jamais exposer la service_role key côté client"). Il ne doit exister
// que dans ces Edge Functions, exécutées côté serveur par construction.
import {
createClient,
type SupabaseClient,
} from "npm:@supabase/supabase-js@2";
let cachedClient: SupabaseClient | null = null;
export function getSupabaseAdmin(): SupabaseClient {
if (cachedClient) return cachedClient;
const url = Deno.env.get("SUPABASE_URL");
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!url || !serviceRoleKey) {
throw new Error(
"SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY manquantes dans l'environnement de l'Edge Function."
);
}
cachedClient = createClient(url, serviceRoleKey, {
auth: { persistSession: false },
});
return cachedClient;
}
/** Nom du bucket Storage des photos. Même variable que côté front
* (.env.example : SUPABASE_STORAGE_BUCKET), à définir comme secret de
* fonction ; valeur de repli alignée sur la migration
* 20260915120400_storage_event_photos_policies.sql. */
export function getStorageBucket(): string {
return Deno.env.get("SUPABASE_STORAGE_BUCKET") ?? "event-photos";
}
+39
View File
@@ -0,0 +1,39 @@
// Comparaison de secrets en temps constant, pour éviter les attaques par
// mesure de timing sur des comparaisons naïves (`a === b`) qui court-
// circuitent dès le premier caractère différent.
//
// On hache d'abord les deux valeurs en SHA-256 (Web Crypto native, pas de
// dépendance tierce) : ça a deux effets utiles ici -
// 1. la comparaison finale porte sur deux buffers de longueur FIXE
// (32 octets), donc la longueur du secret fourni par l'appelant ne
// fuite pas non plus (pas de court-circuit "longueur différente" sur
// l'entrée brute) ;
// 2. on peut comparer octet par octet sans early-return (accumulation par
// OR bit à bit) pour un temps d'exécution indépendant du contenu.
async function sha256(input: string): Promise<Uint8Array> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return new Uint8Array(digest);
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
// a et b sont toujours des digests SHA-256 ici (32 octets), donc cette
// comparaison de longueur ne dépend jamais des entrées utilisateur.
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
/** Vrai si `provided` correspond à `expected`, en temps constant. */
export async function timingSafeEqual(
provided: string | null | undefined,
expected: string | null | undefined
): Promise<boolean> {
if (!provided || !expected) return false;
const [a, b] = await Promise.all([sha256(provided), sha256(expected)]);
return constantTimeEqual(a, b);
}
+383
View File
@@ -0,0 +1,383 @@
// 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}"`,
},
});
}
+182
View File
@@ -0,0 +1,182 @@
// Edge Function `expire-events`
//
// Rôle : purge les fichiers Supabase Storage des événements déjà marqués
// `statut = 'expire'` par le job SQL `public.expire_events()` (cf.
// supabase/migrations/20260916090600_expire_events_job.sql).
//
// Changement d'architecture (2026-09-16) : la table photobooth.events n'est
// plus accessible via une requête `.from(...)`, même en service_role (le
// schéma `photobooth` n'est jamais exposé via PostgREST, cf.
// supabase/migrations/20260916090000_create_photobooth_schema.sql). La
// liste des events expirés est obtenue via la RPC
// `public.get_expired_event_ids()` (réservée à service_role côté SQL).
//
// Pourquoi une Edge Function séparée plutôt que de tout faire en SQL :
// l'API Storage (list/remove d'objets) n'est pas appelable depuis une
// fonction Postgres standard (ni via l'extension pg_cron elle-même). La
// fonction SQL public.expire_events() gère donc la partie "base de données"
// (statut + suppression des lignes photobooth.photos), et CETTE fonction
// gère la partie "fichiers" (Storage), séparément.
//
// Design volontairement DÉCOUPLÉ et IDEMPOTENT :
// - Cette fonction ne dépend PAS des lignes photobooth.photos (qui
// peuvent déjà avoir été supprimées par public.expire_events() au
// moment où elle s'exécute) : elle liste directement le contenu du
// dossier Storage `{event_id}/` pour chaque event_id renvoyé par
// get_expired_event_ids(), et supprime tout ce qui s'y trouve.
// - Rejouer cette fonction plusieurs fois sur le même event ne pose aucun
// problème : un dossier déjà vide ne produit aucune suppression
// supplémentaire (`list()` renvoie un tableau vide).
//
// ⚠️ Mécanisme de déclenchement NON TRANCHÉ définitivement — cf. rapport de
// tâche du 2026-09-15 pour les deux options (pg_cron + pg_net, vs cron n8n)
// et pourquoi n8n est recommandé par défaut ici, à valider avec Kévin.
// Inchangé par le passage au schéma photobooth.
//
// ⚠️ 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).
//
// Sécurité : PAS de vérification via admin_token (cette fonction n'agit pas
// au nom d'un event particulier mais purge TOUS les events expirés en une
// passe). Le contrôle d'accès Supabase par défaut (vérification JWT côté
// gateway) accepterait n'importe quel JWT valide — y compris l'anon key,
// qui est PUBLIQUE (embarquée dans le front). Ça ne suffit donc PAS à
// protéger un endpoint destructeur comme celui-ci : on vérifie ici EN PLUS,
// explicitement, que le Bearer fourni est exactement la service_role key
// (comparaison en temps constant), pour s'assurer que seul un appelant
// serveur-à-serveur de confiance (cron pg_net, ou workflow n8n avec la clé
// stockée dans le Credentials Store, jamais côté client) peut déclencher
// cette purge.
import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts";
import { timingSafeEqual } from "../_shared/timing-safe.ts";
const STORAGE_LIST_PAGE_SIZE = 100;
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function extractBearerToken(req: Request): string | null {
const header = req.headers.get("authorization") ?? "";
const match = /^Bearer\s+(.+)$/i.exec(header);
return match ? match[1] : null;
}
/**
* Supprime tous les objets du bucket sous le préfixe `{eventId}/`, par
* pages, jusqu'à ce qu'il n'en reste plus. Ne suppose PAS l'existence de
* lignes photobooth.photos correspondantes (cf. commentaire en tête de
* fichier).
*/
async function purgeEventStorage(
// deno-lint-ignore no-explicit-any
supabaseAdmin: any,
bucket: string,
eventId: string
): Promise<number> {
const prefix = eventId;
let purgedCount = 0;
// On relit systématiquement la "première page" après chaque suppression
// (plutôt que de paginer avec un offset) : comme on vient de supprimer ce
// qu'on avait listé, ce qui reste devient naturellement la nouvelle
// première page.
while (true) {
const { data: files, error: listError } = await supabaseAdmin.storage
.from(bucket)
.list(prefix, { limit: STORAGE_LIST_PAGE_SIZE });
if (listError) {
throw new Error(
`list() failed for prefix "${prefix}": ${listError.message ?? listError}`
);
}
if (!files || files.length === 0) break;
const paths = files.map((f: { name: string }) => `${prefix}/${f.name}`);
const { error: removeError } = await supabaseAdmin.storage
.from(bucket)
.remove(paths);
if (removeError) {
throw new Error(
`remove() failed for prefix "${prefix}": ${removeError.message ?? removeError}`
);
}
purgedCount += paths.length;
if (files.length < STORAGE_LIST_PAGE_SIZE) break; // dernière page
}
return purgedCount;
}
Deno.serve(async (req) => {
if (req.method !== "POST") {
return jsonResponse(405, { error: "method_not_allowed" });
}
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
const bearer = extractBearerToken(req);
if (!serviceRoleKey || !(await timingSafeEqual(bearer, serviceRoleKey))) {
return jsonResponse(401, { error: "unauthorized" });
}
const supabaseAdmin = getSupabaseAdmin();
const bucket = getStorageBucket();
// public.get_expired_event_ids() renvoie `setof uuid` : PostgREST expose
// ça comme un tableau de scalaires bruts (["uuid1", "uuid2", ...]), pas
// un tableau d'objets — donc `expiredEventIds` est déjà une liste d'id.
const { data: expiredEventIds, error: listEventsError } =
await supabaseAdmin.rpc("get_expired_event_ids");
if (listEventsError) {
console.error(
"[expire-events] get_expired_event_ids failed",
listEventsError
);
return jsonResponse(500, { error: "internal_error" });
}
const results: Array<{
event_id: string;
ok: boolean;
purged_files?: number;
error?: string;
}> = [];
for (const eventId of (expiredEventIds ?? []) as string[]) {
try {
const purgedFiles = await purgeEventStorage(supabaseAdmin, bucket, eventId);
results.push({ event_id: eventId, ok: true, purged_files: purgedFiles });
} catch (err) {
console.error(`[expire-events] purge failed for event ${eventId}`, err);
results.push({
event_id: eventId,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
const hasErrors = results.some((r) => !r.ok);
// 200 même en cas d'erreurs partielles (le détail est dans `results`) :
// un mécanisme de cron/n8n externe peut avoir besoin de lire le corps
// pour décider s'il notifie (ex. node Telegram côté n8n sur
// `hasErrors === true`), plutôt que de se baser uniquement sur le code
// HTTP.
return jsonResponse(200, {
processed: results.length,
has_errors: hasErrors,
results,
});
});