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
@@ -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.';