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,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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Migration : schéma dédié `photobooth`
|
||||
--
|
||||
-- Changement d'architecture (décision actée avec Kévin, 2026-09-16) :
|
||||
-- le projet Supabase définitif ("kevin-lecou-hub", ref ctveboqnbatwmadwsnvw)
|
||||
-- est mutualisé avec de futurs produits. Plutôt qu'un préfixe `pq_` sur des
|
||||
-- tables dans `public` (approche de la veille), on isole désormais
|
||||
-- photobooth-qr dans son propre schéma Postgres `photobooth`. Les tables
|
||||
-- s'appellent donc simplement `events` / `photos` (sans préfixe), l'isolation
|
||||
-- venant du schéma et non plus du nom de table.
|
||||
--
|
||||
-- Contrainte technique actée : exposer un schéma custom via l'API REST
|
||||
-- (PostgREST) nécessite un réglage MANUEL dans le dashboard Supabase
|
||||
-- ("Exposed schemas", Project Settings > Data API) — impossible à faire en
|
||||
-- SQL pur. Décision : ne JAMAIS faire ce réglage pour `photobooth`. Ce
|
||||
-- schéma reste **totalement privé, jamais exposé via PostgREST** — ni pour
|
||||
-- `anon`, ni pour `service_role` (l'exposition de schéma chez PostgREST est
|
||||
-- un filtre au niveau du routage HTTP, indépendant du rôle appelant : même
|
||||
-- avec la service_role key, un accès REST direct à `photobooth.events`
|
||||
-- n'aurait aucune route pour y répondre).
|
||||
--
|
||||
-- Tout accès (anon ET service_role) passe donc exclusivement par des
|
||||
-- fonctions SECURITY DEFINER dans le schéma `public` (qui, lui, reste
|
||||
-- exposé par défaut) — cf. 20260916090400_create_public_functions.sql.
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent : Kévin applique lui-même via son
|
||||
-- accès MCP Supabase direct une fois la relecture faite.
|
||||
|
||||
create schema if not exists photobooth;
|
||||
|
||||
comment on schema photobooth is
|
||||
'Schéma dédié au projet photobooth-qr (mariage/soirée, galerie photo par QR code). JAMAIS ajouté aux "Exposed schemas" (Project Settings > Data API) : aucun accès direct via PostgREST, ni anon ni service_role. Tout accès passe par des fonctions SECURITY DEFINER dans public.';
|
||||
|
||||
-- Défensif / documentaire plutôt que strictement nécessaire : un schéma
|
||||
-- nouvellement créé n'accorde par défaut AUCUN privilège à PUBLIC (ce
|
||||
-- comportement spécial n'existe que pour le schéma `public` historique).
|
||||
-- On le rend explicite pour que l'intention soit lisible sans dépendre de
|
||||
-- ce détail de comportement Postgres.
|
||||
revoke all on schema photobooth from public;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Migration : création de la table `photobooth.events`
|
||||
-- Réf. : docs/modele-donnees.md (section "Table `events`"),
|
||||
-- docs/decisions.md (expiration J+7, admin_token dans l'URL)
|
||||
--
|
||||
-- Table déplacée de `public.pq_events` (2026-09-15) vers `photobooth.events`
|
||||
-- (2026-09-16) : l'isolation multi-projets vient désormais du schéma dédié
|
||||
-- `photobooth` (jamais exposé via PostgREST, cf.
|
||||
-- 20260916090000_create_photobooth_schema.sql), plus besoin de préfixe de
|
||||
-- table.
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
-- pgcrypto est nécessaire pour gen_random_bytes() (génération de l'admin_token).
|
||||
create extension if not exists pgcrypto with schema extensions;
|
||||
|
||||
create table if not exists photobooth.events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
nom text not null,
|
||||
date_evenement date not null,
|
||||
created_at timestamptz not null default now(),
|
||||
-- expire_at est calculé automatiquement (trigger ci-dessous) à partir de
|
||||
-- date_evenement + 7 jours, conformément à docs/decisions.md.
|
||||
expire_at timestamptz,
|
||||
-- Statut modélisé en texte + CHECK (plutôt qu'un type ENUM Postgres) car
|
||||
-- modele-donnees.md indique explicitement "à affiner" : un CHECK est plus
|
||||
-- simple à faire évoluer par migration qu'un type ENUM.
|
||||
statut text not null default 'actif'
|
||||
check (statut in ('actif', 'expire', 'archive')),
|
||||
-- Token secret d'accès admin. 32 octets aléatoires (256 bits) encodés en
|
||||
-- hexadécimal (pas de caractères spéciaux, utilisable tel quel dans une URL).
|
||||
admin_token text not null unique
|
||||
default encode(extensions.gen_random_bytes(32), 'hex'),
|
||||
client_nom text,
|
||||
facture_montant numeric check (facture_montant is null or facture_montant >= 0)
|
||||
);
|
||||
|
||||
comment on table photobooth.events is
|
||||
'Un événement (mariage, soirée, etc.) : point d''entrée multi-tenant du service photobooth-qr. Schéma photobooth jamais exposé via PostgREST, cf. 20260916090000_create_photobooth_schema.sql.';
|
||||
comment on column photobooth.events.slug is
|
||||
'Identifiant lisible utilisé dans l''URL publique d''upload (/e/<slug>).';
|
||||
comment on column photobooth.events.expire_at is
|
||||
'Calculé automatiquement = date_evenement + 7 jours (trigger trg_events_set_expire_at). RGPD : suppression à cette échéance, cf. docs/rgpd.md.';
|
||||
comment on column photobooth.events.statut is
|
||||
'Statut de l''événement : actif, expire, archive.';
|
||||
comment on column photobooth.events.admin_token is
|
||||
'Token secret donnant accès à la galerie admin (/admin/<slug>?token=...). Jamais exposé à anon (seule la fonction public.get_event_for_admin, réservée à service_role, le renvoie).';
|
||||
comment on column photobooth.events.client_nom is
|
||||
'Nom du client, utile en V2/V3 (vente du service).';
|
||||
comment on column photobooth.events.facture_montant is
|
||||
'Montant facturé au client, utile en V2/V3.';
|
||||
|
||||
-- Calcule systématiquement expire_at à partir de date_evenement, pour
|
||||
-- garantir que la règle "J+7 après la date de l'événement" (docs/decisions.md)
|
||||
-- est toujours respectée, indépendamment de ce que l'appelant tente d'insérer.
|
||||
-- Fonction interne au schéma photobooth (pas un point de contrat public,
|
||||
-- pas besoin d'être dans public).
|
||||
create or replace function photobooth.events_set_expire_at()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
new.expire_at := new.date_evenement + interval '7 days';
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_events_set_expire_at on photobooth.events;
|
||||
create trigger trg_events_set_expire_at
|
||||
before insert or update of date_evenement on photobooth.events
|
||||
for each row
|
||||
execute function photobooth.events_set_expire_at();
|
||||
|
||||
-- Index utile pour le futur job d'expiration (recherche des événements
|
||||
-- actifs dont expire_at est dépassé).
|
||||
create index if not exists idx_events_statut_expire_at
|
||||
on photobooth.events (statut, expire_at);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Migration : création de la table `photobooth.photos`
|
||||
-- Réf. : docs/modele-donnees.md (section "Table `photos`")
|
||||
--
|
||||
-- Table déplacée de `public.pq_photos` (2026-09-15) vers `photobooth.photos`
|
||||
-- (2026-09-16), cf. 20260916090100_create_events_table.sql pour le contexte
|
||||
-- du changement de schéma.
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
create table if not exists photobooth.photos (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
event_id uuid not null references photobooth.events (id) on delete cascade,
|
||||
-- Contrainte d'isolation multi-tenant (héritée de la relecture sécurité du
|
||||
-- 2026-09-15, conservée à l'identique) : url_storage DOIT commencer par
|
||||
-- "<event_id>/", pour empêcher qu'une ligne photobooth.photos de l'event A
|
||||
-- pointe vers un objet Storage de l'event B. S'applique à TOUT rôle qui
|
||||
-- insère, y compris le rôle propriétaire des fonctions SECURITY DEFINER
|
||||
-- (postgres) — défense en profondeur même si, avec ce nouveau schéma, le
|
||||
-- seul chemin d'écriture "normal" est déjà revérifié explicitement dans
|
||||
-- public.insert_public_photo() (cf. 20260916090400_create_public_functions.sql).
|
||||
url_storage text not null
|
||||
check (starts_with(url_storage, event_id::text || '/')),
|
||||
-- Bornes alignées sur les `maxlength` du formulaire (src/e/index.html :
|
||||
-- 60 pour le prénom, 300 pour le message).
|
||||
nom_invite text check (char_length(nom_invite) <= 60),
|
||||
message text check (char_length(message) <= 300),
|
||||
uploaded_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table photobooth.photos is
|
||||
'Photos/vidéos déposées par les invités pour un événement donné. Schéma photobooth jamais exposé via PostgREST : seul chemin d''écriture pour anon = public.insert_public_photo().';
|
||||
comment on column photobooth.photos.event_id is
|
||||
'Événement associé (photobooth.events). Suppression en cascade si l''événement est supprimé.';
|
||||
comment on column photobooth.photos.url_storage is
|
||||
'Chemin du fichier dans Supabase Storage (bucket `event-photos`, chemin `{event_id}/{uuid}.{ext}`). CHECK : doit commencer par "<event_id>/" (isolation multi-tenant).';
|
||||
comment on column photobooth.photos.nom_invite is
|
||||
'Nom saisi par l''invité (optionnel, aucune donnée d''identification obligatoire, cf. docs/rgpd.md). Limité à 60 caractères (aligné sur src/e/index.html).';
|
||||
comment on column photobooth.photos.message is
|
||||
'Message laissé par l''invité (optionnel). Limité à 300 caractères (aligné sur src/e/index.html).';
|
||||
|
||||
-- Index pour les accès en lecture par événement (galerie admin, export ZIP,
|
||||
-- job d'expiration/suppression).
|
||||
create index if not exists idx_photos_event_id
|
||||
on photobooth.photos (event_id);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Migration : activation RLS sur photobooth.events / photobooth.photos
|
||||
-- Réf. : docs/modele-donnees.md (section "Row Level Security (RLS)")
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
alter table photobooth.events enable row level security;
|
||||
alter table photobooth.photos enable row level security;
|
||||
|
||||
-- Choix DÉLIBÉRÉ : AUCUNE policy sur photobooth.events ni photobooth.photos,
|
||||
-- pour anon/authenticated/service_role — ni SELECT, ni INSERT, ni UPDATE, ni
|
||||
-- DELETE. RLS activé + zéro policy = accès refusé par défaut pour tout rôle
|
||||
-- non-BYPASSRLS.
|
||||
--
|
||||
-- Pourquoi (documenté suite à la consigne "garde la policy d'INSERT anon si
|
||||
-- tu juges ça utile, sinon documente pourquoi tu la retires") : dans cette
|
||||
-- architecture, le schéma `photobooth` n'est de toute façon JAMAIS exposé
|
||||
-- via PostgREST (cf. 20260916090000_create_photobooth_schema.sql) — aucune
|
||||
-- route REST n'existe pour `photobooth.photos`, donc une policy anon INSERT
|
||||
-- ne servirait à rien en temps normal (personne ne peut l'atteindre par ce
|
||||
-- chemin). Le SEUL chemin d'écriture pour anon est désormais la fonction
|
||||
-- SECURITY DEFINER public.insert_public_photo(), qui revalide elle-même
|
||||
-- event_accepts_uploads() et la cohérence url_storage/event_id avant
|
||||
-- d'insérer (cf. 20260916090400_create_public_functions.sql) — et une
|
||||
-- fonction SECURITY DEFINER s'exécute avec les privilèges de son
|
||||
-- propriétaire, PAS ceux de l'appelant : elle n'a donc pas besoin d'une
|
||||
-- policy RLS permissive pour anon afin de fonctionner.
|
||||
--
|
||||
-- Garder RLS activé SANS policy sert uniquement de filet de sécurité en cas
|
||||
-- d'erreur humaine future (ex. quelqu'un ajoute `photobooth` aux "Exposed
|
||||
-- schemas" par erreur un jour) : dans ce scénario, sans policy, un accès
|
||||
-- REST direct resterait refusé pour anon/authenticated. Ajouter UNE policy
|
||||
-- anon INSERT "juste au cas où" recréerait exactement le chemin
|
||||
-- d'écriture non revalidé qu'on vient de fermer avec
|
||||
-- public.insert_public_photo() — jugé contre-productif, donc pas ajoutée.
|
||||
@@ -0,0 +1,279 @@
|
||||
-- Migration : fonctions SECURITY DEFINER dans `public`
|
||||
--
|
||||
-- Seul point d'accès (lecture ET écriture) à photobooth.events /
|
||||
-- photobooth.photos, puisque le schéma `photobooth` n'est jamais exposé via
|
||||
-- PostgREST (cf. 20260916090000_create_photobooth_schema.sql). Chaque
|
||||
-- fonction ci-dessous ne renvoie/n'accepte que le strict nécessaire pour son
|
||||
-- appelant (anon vs service_role), avec des grants minimaux.
|
||||
--
|
||||
-- search_path = public, photobooth, pg_temp sur toutes les fonctions de ce
|
||||
-- fichier : nécessaire pour qu'elles résolvent `events`/`photos` sans avoir
|
||||
-- à préfixer chaque référence par `photobooth.` (fait quand même ci-dessous
|
||||
-- par lisibilité/explicité, le search_path reste une sécurité supplémentaire
|
||||
-- contre une résolution ambiguë si un jour une table `events`/`photos`
|
||||
-- apparaissait aussi dans public).
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. event_accepts_uploads — anon
|
||||
-- =============================================================================
|
||||
-- Utilisée par la policy RLS storage.objects (cf.
|
||||
-- 20260916090500_storage_event_photos_policies.sql) et par
|
||||
-- public.insert_public_photo() ci-dessous : vérifie qu'un événement est actif
|
||||
-- et non expiré, sans exposer la ligne photobooth.events.
|
||||
create or replace function public.event_accepts_uploads(p_event_id uuid)
|
||||
returns boolean
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public, photobooth, pg_temp
|
||||
as $$
|
||||
select exists (
|
||||
select 1
|
||||
from photobooth.events e
|
||||
where e.id = p_event_id
|
||||
and e.statut = 'actif'
|
||||
and e.expire_at is not null
|
||||
and now() < e.expire_at
|
||||
);
|
||||
$$;
|
||||
|
||||
comment on function public.event_accepts_uploads(uuid) is
|
||||
'Vrai si l''événement (photobooth.events) est actif et non expiré. Utilisée par public.insert_public_photo() et par les policies storage.objects du bucket event-photos.';
|
||||
|
||||
revoke all on function public.event_accepts_uploads(uuid) from public;
|
||||
grant execute on function public.event_accepts_uploads(uuid) to anon;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. get_public_event — anon
|
||||
-- =============================================================================
|
||||
-- Résout un slug public en informations minimales d'événement pour la page
|
||||
-- d'upload, sans jamais exposer admin_token, client_nom ou facture_montant.
|
||||
--
|
||||
-- Comportement volontaire (confirmé en relecture sécurité le 2026-09-15,
|
||||
-- inchangé ici) : renvoie l'événement même si statut != 'actif', pour que le
|
||||
-- front affiche un message adapté ("événement terminé/expiré") plutôt qu'une
|
||||
-- absence de résultat indifférenciée. Jugé acceptable : le slug n'est pas un
|
||||
-- secret (diffusé via QR code), et les colonnes renvoyées ne sont pas
|
||||
-- sensibles. Point de comportement produit à valider explicitement par
|
||||
-- Kévin et consigner dans docs/decisions.md (pas encore fait à ce jour).
|
||||
create or replace function public.get_public_event(p_slug text)
|
||||
returns table (
|
||||
id uuid,
|
||||
nom text,
|
||||
statut text,
|
||||
date_evenement date,
|
||||
expire_at timestamptz
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public, photobooth, pg_temp
|
||||
as $$
|
||||
select e.id, e.nom, e.statut, e.date_evenement, e.expire_at
|
||||
from photobooth.events e
|
||||
where e.slug = p_slug;
|
||||
$$;
|
||||
|
||||
comment on function public.get_public_event(text) is
|
||||
'Résout un slug public en informations minimales d''événement (photobooth.events, jamais admin_token/client_nom/facture_montant), pour la page d''upload. Nom aligné sur le contrat attendu par src/e/upload.js.';
|
||||
|
||||
revoke all on function public.get_public_event(text) from public;
|
||||
grant execute on function public.get_public_event(text) to anon;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. insert_public_photo — anon (NOUVEAU, remplace l'ancien POST direct sur
|
||||
-- la table, qui n'est plus possible puisque photobooth n'est plus exposé)
|
||||
-- =============================================================================
|
||||
-- Chemin d'écriture unique pour les invités. Revalide elle-même (ne fait PAS
|
||||
-- confiance à un event_id/url_storage "déjà bons" côté appelant) :
|
||||
-- 1. l'événement est actif et non expiré (event_accepts_uploads) ;
|
||||
-- 2. url_storage commence bien par "<p_event_id>/" (même règle que le
|
||||
-- CHECK sur la table, revérifiée ici en amont pour renvoyer une erreur
|
||||
-- métier explicite plutôt que de compter uniquement sur la contrainte
|
||||
-- table, qui renverrait une erreur Postgres générique moins lisible
|
||||
-- côté front).
|
||||
-- ⚠️ CONTRAT FRONT : src/e/upload.js fait aujourd'hui un POST REST direct
|
||||
-- sur (l'ex-)`pq_photos`. Ce chemin n'existe plus dans cette architecture
|
||||
-- (photobooth.photos n'est pas exposée). Le front doit être mis à jour pour
|
||||
-- appeler `POST {SUPABASE_URL}/rest/v1/rpc/insert_public_photo` avec un
|
||||
-- corps JSON `{ p_event_id, p_url_storage, p_nom_invite, p_message }` — hors
|
||||
-- périmètre de cette migration (pas de modification de src/ par cet agent),
|
||||
-- signalé explicitement dans le rapport de tâche.
|
||||
create or replace function public.insert_public_photo(
|
||||
p_event_id uuid,
|
||||
p_url_storage text,
|
||||
p_nom_invite text default null,
|
||||
p_message text default null
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, photobooth, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_photo_id uuid;
|
||||
begin
|
||||
if not public.event_accepts_uploads(p_event_id) then
|
||||
raise exception 'event_not_accepting_uploads'
|
||||
using errcode = 'P0001',
|
||||
detail = 'L''événement est inconnu, inactif ou expiré.';
|
||||
end if;
|
||||
|
||||
if not starts_with(p_url_storage, p_event_id::text || '/') then
|
||||
raise exception 'url_storage_event_mismatch'
|
||||
using errcode = 'P0001',
|
||||
detail = 'url_storage doit commencer par "<event_id>/".';
|
||||
end if;
|
||||
|
||||
-- Les bornes de longueur sur nom_invite/message sont déjà appliquées par
|
||||
-- les CHECK de la table (cf. 20260916090200_create_photos_table.sql) ; pas
|
||||
-- besoin de les dupliquer ici, l'INSERT échouera proprement sinon.
|
||||
insert into photobooth.photos (event_id, url_storage, nom_invite, message)
|
||||
values (p_event_id, p_url_storage, p_nom_invite, p_message)
|
||||
returning id into v_photo_id;
|
||||
|
||||
return v_photo_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function public.insert_public_photo(uuid, text, text, text) is
|
||||
'Chemin d''écriture unique pour les invités (remplace le POST REST direct sur photobooth.photos, non exposée). Revalide event_accepts_uploads() et la cohérence url_storage/event_id avant insertion. Retourne l''id de la ligne créée. Lève une exception (errcode P0001) si l''événement n''accepte pas d''upload ou si url_storage est incohérent.';
|
||||
|
||||
revoke all on function public.insert_public_photo(uuid, text, text, text) from public;
|
||||
grant execute on function public.insert_public_photo(uuid, text, text, text) to anon;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. storage_event_photos_upload_allowed — anon (logique inchangée)
|
||||
-- =============================================================================
|
||||
-- Utilisée par la policy INSERT sur storage.objects (bucket event-photos).
|
||||
-- Elle vivait déjà dans public hier (les policies storage.objects sont
|
||||
-- indépendantes de l'exposition de schéma Postgres, cf. consigne de tâche),
|
||||
-- donc pas de changement de schéma ici — seule la référence à
|
||||
-- event_accepts_uploads() est conservée telle quelle (son corps interne
|
||||
-- pointe maintenant vers photobooth.events, mais la signature/l'usage ne
|
||||
-- changent pas pour cette fonction-ci).
|
||||
create or replace function public.storage_event_photos_upload_allowed(p_name text)
|
||||
returns boolean
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public, photobooth, storage, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_event_id uuid;
|
||||
begin
|
||||
-- Premier segment du chemin ({event_id}/{uuid}.{ext}) = storage.foldername(name)[1].
|
||||
-- Cast en uuid dans un bloc protégé : un path malformé doit être refusé
|
||||
-- proprement, pas planter la requête.
|
||||
begin
|
||||
v_event_id := (storage.foldername(p_name))[1]::uuid;
|
||||
exception when others then
|
||||
return false;
|
||||
end;
|
||||
|
||||
if v_event_id is null then
|
||||
return false;
|
||||
end if;
|
||||
|
||||
return public.event_accepts_uploads(v_event_id);
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function public.storage_event_photos_upload_allowed(text) is
|
||||
'Vrai si le premier segment du chemin storage.objects.name (convention {event_id}/{uuid}.{ext}) correspond à un event_id existant, actif et non expiré (public.event_accepts_uploads). Jamais d''exception levée (path invalide -> false). Utilisée par la policy INSERT anonyme sur storage.objects du bucket event-photos.';
|
||||
|
||||
revoke all on function public.storage_event_photos_upload_allowed(text) from public;
|
||||
grant execute on function public.storage_event_photos_upload_allowed(text) to anon;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. get_event_for_admin — service_role UNIQUEMENT (NOUVEAU)
|
||||
-- =============================================================================
|
||||
-- Renvoie admin_token EN CLAIR : ne doit jamais être accordée à anon ni
|
||||
-- authenticated. Conçue pour n'être appelée QUE par l'Edge Function
|
||||
-- `admin-gallery` (service_role), qui fait elle-même la comparaison en
|
||||
-- temps constant entre le token reçu en query param et cette valeur — cette
|
||||
-- fonction ne fait aucune vérification de token elle-même, c'est
|
||||
-- volontaire : la logique de comparaison reste côté Edge Function (Deno),
|
||||
-- pas dupliquée en SQL.
|
||||
create or replace function public.get_event_for_admin(p_slug text)
|
||||
returns table (
|
||||
id uuid,
|
||||
nom text,
|
||||
statut text,
|
||||
date_evenement date,
|
||||
expire_at timestamptz,
|
||||
admin_token text
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public, photobooth, pg_temp
|
||||
as $$
|
||||
select e.id, e.nom, e.statut, e.date_evenement, e.expire_at, e.admin_token
|
||||
from photobooth.events e
|
||||
where e.slug = p_slug;
|
||||
$$;
|
||||
|
||||
comment on function public.get_event_for_admin(text) is
|
||||
'Renvoie un événement (photobooth.events) AVEC admin_token en clair. Réservée à service_role (Edge Function admin-gallery uniquement) : ne JAMAIS accorder EXECUTE à anon/authenticated.';
|
||||
|
||||
revoke all on function public.get_event_for_admin(text) from public, anon, authenticated;
|
||||
grant execute on function public.get_event_for_admin(text) to service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. get_event_photos — service_role UNIQUEMENT (NOUVEAU)
|
||||
-- =============================================================================
|
||||
-- Renvoie les photos d'un événement (galerie admin / export ZIP). Ordre
|
||||
-- chronologique croissant (uploaded_at asc) — l'Edge Function admin-gallery
|
||||
-- inverse l'ordre côté JS pour l'affichage galerie (plus récent en premier)
|
||||
-- si besoin, cf. supabase/functions/admin-gallery/index.ts.
|
||||
create or replace function public.get_event_photos(p_event_id uuid)
|
||||
returns table (
|
||||
id uuid,
|
||||
url_storage text,
|
||||
nom_invite text,
|
||||
message text,
|
||||
uploaded_at timestamptz
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public, photobooth, pg_temp
|
||||
as $$
|
||||
select p.id, p.url_storage, p.nom_invite, p.message, p.uploaded_at
|
||||
from photobooth.photos p
|
||||
where p.event_id = p_event_id
|
||||
order by p.uploaded_at asc;
|
||||
$$;
|
||||
|
||||
comment on function public.get_event_photos(uuid) is
|
||||
'Renvoie les photos (photobooth.photos) d''un événement, triées par uploaded_at croissant. Réservée à service_role (Edge Function admin-gallery) : ne JAMAIS accorder EXECUTE à anon/authenticated (aucune vérification d''admin_token ici, c''est fait en amont côté Edge Function).';
|
||||
|
||||
revoke all on function public.get_event_photos(uuid) from public, anon, authenticated;
|
||||
grant execute on function public.get_event_photos(uuid) to service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. get_expired_event_ids — service_role UNIQUEMENT (NOUVEAU)
|
||||
-- =============================================================================
|
||||
-- Utilisée par l'Edge Function `expire-events` pour savoir quels dossiers
|
||||
-- Storage `{event_id}/` purger. Ne renvoie que des uuid (pas de slug/statut
|
||||
-- détaillé) : c'est tout ce dont cette fonction a besoin.
|
||||
create or replace function public.get_expired_event_ids()
|
||||
returns setof uuid
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public, photobooth, pg_temp
|
||||
as $$
|
||||
select id
|
||||
from photobooth.events
|
||||
where statut = 'expire';
|
||||
$$;
|
||||
|
||||
comment on function public.get_expired_event_ids() is
|
||||
'Renvoie les id des événements (photobooth.events) au statut ''expire''. Réservée à service_role (Edge Function expire-events) : ne JAMAIS accorder EXECUTE à anon/authenticated.';
|
||||
|
||||
revoke all on function public.get_expired_event_ids() from public, anon, authenticated;
|
||||
grant execute on function public.get_expired_event_ids() to service_role;
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Migration : bucket Storage `event-photos` (privé) et policies RLS
|
||||
-- Réf. : docs/modele-donnees.md, docs/cahier-des-charges.md (section
|
||||
-- "Storage bucket"), CLAUDE.md ("lecture des photos jamais publique").
|
||||
--
|
||||
-- Logique inchangée par rapport à la version du 2026-09-15 (le passage au
|
||||
-- schéma `photobooth` ne concerne que Postgres/PostgREST : les policies
|
||||
-- storage.objects et la config du bucket sont indépendantes de l'exposition
|
||||
-- de schéma, cf. consigne de tâche). Seule différence : la fonction
|
||||
-- public.storage_event_photos_upload_allowed() vit désormais dans le
|
||||
-- fichier 20260916090400_create_public_functions.sql (regroupée avec les
|
||||
-- autres fonctions public.*) au lieu d'être définie ici.
|
||||
--
|
||||
-- Décision actée (bucket privé) : le bucket `event-photos` est PRIVÉ
|
||||
-- (public = false). Aucune lecture publique/anonyme des fichiers. La
|
||||
-- consultation admin passe exclusivement par des URLs signées générées côté
|
||||
-- Edge Function `admin-gallery` (service_role).
|
||||
--
|
||||
-- file_size_limit / allowed_mime_types (relecture sécurité 2026-09-15,
|
||||
-- conservés à l'identique) :
|
||||
-- - file_size_limit = 6 291 456 octets (6 Mo) = MAX_OUTPUT_BYTES dans
|
||||
-- src/e/upload.js (garde-fou côté client après compression).
|
||||
-- - allowed_mime_types = uniquement 'image/jpeg' : la pipeline de
|
||||
-- compression (browser-image-compression, fileType: "image/jpeg", y
|
||||
-- compris pour les HEIC convertis au préalable via heic2any) force
|
||||
-- systématiquement une sortie JPEG. ⚠️ À mettre à jour si le front
|
||||
-- change un jour cette pipeline.
|
||||
--
|
||||
-- Convention de chemin utilisée côté front (src/e/upload.js) :
|
||||
-- {event_id}/{uuid}.{ext} -- ex : 8f3.../a1b2.jpg
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Bucket
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
values (
|
||||
'event-photos',
|
||||
'event-photos',
|
||||
false,
|
||||
6291456,
|
||||
array['image/jpeg']
|
||||
)
|
||||
on conflict (id) do update
|
||||
set public = false,
|
||||
file_size_limit = excluded.file_size_limit,
|
||||
allowed_mime_types = excluded.allowed_mime_types;
|
||||
|
||||
-- RLS est activé par défaut sur storage.objects sur les projets Supabase
|
||||
-- hébergés. Cette ligne est défensive (idempotente) pour les environnements
|
||||
-- où ce ne serait pas déjà le cas (ex. stack Supabase locale/self-managed).
|
||||
alter table storage.objects enable row level security;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Policies storage.objects
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- INSERT anonyme autorisé uniquement dans le bucket event-photos, et
|
||||
-- uniquement si l'event_id déduit du chemin est actif et non expiré
|
||||
-- (public.storage_event_photos_upload_allowed, définie dans
|
||||
-- 20260916090400_create_public_functions.sql).
|
||||
create policy "anon_insert_event_photos_if_event_active"
|
||||
on storage.objects
|
||||
for insert
|
||||
to anon
|
||||
with check (
|
||||
bucket_id = 'event-photos'
|
||||
and public.storage_event_photos_upload_allowed(name)
|
||||
);
|
||||
|
||||
-- Aucune policy SELECT anon/authenticated sur ce bucket : lecture jamais
|
||||
-- publique. La consultation admin passe exclusivement par des URLs signées
|
||||
-- générées par l'Edge Function service_role `admin-gallery`, cf.
|
||||
-- supabase/functions/admin-gallery/index.ts.
|
||||
-- Aucune policy UPDATE : un invité ne modifie jamais un fichier déjà déposé.
|
||||
-- Aucune policy DELETE publique : la suppression des fichiers (job
|
||||
-- d'expiration J+7) passe par l'Edge Function service_role `expire-events`,
|
||||
-- hors RLS — cf. supabase/functions/expire-events/index.ts et
|
||||
-- 20260916090600_expire_events_job.sql.
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Migration : job d'expiration J+7 (côté base de données)
|
||||
-- Réf. : docs/decisions.md (suppression à J+7 à partir de date_evenement,
|
||||
-- dans tous les cas, même si le ZIP a été téléchargé),
|
||||
-- docs/rgpd.md (politique de conservation).
|
||||
--
|
||||
-- Renommée `pq_expire_events` (2026-09-15) -> `public.expire_events`
|
||||
-- (2026-09-16), body mis à jour pour référencer photobooth.events /
|
||||
-- photobooth.photos.
|
||||
--
|
||||
-- Périmètre de cette migration : UNIQUEMENT la base de données (statut de
|
||||
-- l'event + suppression des lignes photobooth.photos). La suppression des
|
||||
-- FICHIERS réels dans Supabase Storage (bucket event-photos) ne peut pas se
|
||||
-- faire en SQL pur — traitée par l'Edge Function
|
||||
-- `supabase/functions/expire-events/index.ts`, qui appelle
|
||||
-- public.get_expired_event_ids() (cf. 20260916090400_create_public_functions.sql)
|
||||
-- pour savoir quels dossiers purger. Déclenchement de cette Edge Function
|
||||
-- NON TRANCHÉ définitivement (cf. rapport de tâche du 2026-09-15, toujours
|
||||
-- valable : option pg_cron+pg_net vs cron n8n, recommandation n8n par
|
||||
-- défaut) — inchangé par le passage au schéma photobooth.
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Fonction public.expire_events()
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Pour chaque event actif dont expire_at est dépassé :
|
||||
-- - supprime les lignes photobooth.photos associées (métadonnées) ;
|
||||
-- - passe statut à 'expire'.
|
||||
-- Idempotent : si rejouée alors qu'aucun event n'est éligible, ne fait rien.
|
||||
create or replace function public.expire_events()
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, photobooth, pg_temp
|
||||
as $$
|
||||
begin
|
||||
delete from photobooth.photos
|
||||
where event_id in (
|
||||
select id
|
||||
from photobooth.events
|
||||
where statut = 'actif'
|
||||
and expire_at is not null
|
||||
and now() >= expire_at
|
||||
);
|
||||
|
||||
update photobooth.events
|
||||
set statut = 'expire'
|
||||
where statut = 'actif'
|
||||
and expire_at is not null
|
||||
and now() >= expire_at;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function public.expire_events() is
|
||||
'Job quotidien (pg_cron) : marque statut=''expire'' et supprime les lignes photobooth.photos des events dont expire_at est dépassé. Ne supprime PAS les fichiers Storage (cf. Edge Function expire-events + public.get_expired_event_ids()). Exécutée par pg_cron (rôle postgres), pas de grant anon/authenticated.';
|
||||
|
||||
-- Aucun grant à anon/authenticated/service_role : effet de bord destructeur
|
||||
-- global, appelée uniquement par pg_cron.
|
||||
revoke all on function public.expire_events() from public, anon, authenticated, service_role;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Planification pg_cron
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
create extension if not exists pg_cron with schema extensions;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if exists (select 1 from cron.job where jobname = 'expire_events_daily') then
|
||||
perform cron.unschedule('expire_events_daily');
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Tous les jours à 03:00 UTC (créneau creux).
|
||||
select cron.schedule(
|
||||
'expire_events_daily',
|
||||
'0 3 * * *',
|
||||
$$select public.expire_events();$$
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Migration : durcissement de public.insert_public_photo()
|
||||
-- Réf. : relecture sécurité finale du 2026-09-16 (reviewer-securite).
|
||||
--
|
||||
-- Problème trouvé : insert_public_photo() ne vérifiait pas que le fichier
|
||||
-- référencé par p_url_storage existe réellement dans Storage. N'importe qui
|
||||
-- connaissant un event_id actif (trivialement récupérable via
|
||||
-- get_public_event(slug), le slug étant public par conception, diffusé via
|
||||
-- QR code) pouvait appeler cette RPC directement, en boucle, sans jamais
|
||||
-- uploader de fichier réel — polluant la galerie admin de lignes fantômes
|
||||
-- (images "cassées") ou de contenu arbitraire dans nom_invite/message.
|
||||
--
|
||||
-- Correctif : vérifie l'existence de l'objet dans storage.objects avant
|
||||
-- d'insérer la ligne. Ne supprime pas entièrement le risque d'abus (pas de
|
||||
-- rate limiting/CAPTCHA), mais un attaquant doit désormais réellement
|
||||
-- uploader un fichier (soumis à file_size_limit/allowed_mime_types) pour
|
||||
-- chaque ligne créée, ce qui relève nettement le coût de l'abus. Risque
|
||||
-- résiduel accepté pour la V1 (usage perso) ; à revisiter avant la V2/V3
|
||||
-- commerciale si le volume d'événements/invités augmente.
|
||||
--
|
||||
-- Appliqué et testé en conditions réelles sur kevin-lecou-hub le 2026-09-16
|
||||
-- (insert sans upload préalable -> rejeté ; insert après upload réel ->
|
||||
-- fonctionne toujours).
|
||||
create or replace function public.insert_public_photo(
|
||||
p_event_id uuid,
|
||||
p_url_storage text,
|
||||
p_nom_invite text default null,
|
||||
p_message text default null
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, photobooth, storage, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_photo_id uuid;
|
||||
begin
|
||||
if not public.event_accepts_uploads(p_event_id) then
|
||||
raise exception 'event_not_accepting_uploads'
|
||||
using errcode = 'P0001',
|
||||
detail = 'L''événement est inconnu, inactif ou expiré.';
|
||||
end if;
|
||||
|
||||
if not starts_with(p_url_storage, p_event_id::text || '/') then
|
||||
raise exception 'url_storage_event_mismatch'
|
||||
using errcode = 'P0001',
|
||||
detail = 'url_storage doit commencer par "<event_id>/".';
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from storage.objects
|
||||
where bucket_id = 'event-photos'
|
||||
and name = p_url_storage
|
||||
) then
|
||||
raise exception 'storage_object_not_found'
|
||||
using errcode = 'P0001',
|
||||
detail = 'Aucun fichier trouvé dans Storage pour ce chemin. Uploadez le fichier avant d''enregistrer la ligne.';
|
||||
end if;
|
||||
|
||||
insert into photobooth.photos (event_id, url_storage, nom_invite, message)
|
||||
values (p_event_id, p_url_storage, p_nom_invite, p_message)
|
||||
returning id into v_photo_id;
|
||||
|
||||
return v_photo_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function public.insert_public_photo(uuid, text, text, text) is
|
||||
'Chemin d''écriture unique pour les invités. Revalide event_accepts_uploads(), la cohérence url_storage/event_id, ET l''existence réelle du fichier dans storage.objects avant insertion. Retourne l''id de la ligne créée.';
|
||||
Reference in New Issue
Block a user