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:
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user