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