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);
}