Ajoute une limite de photos par invité, réglable par événement

Nouvelle colonne max_photos_per_guest (NULL = illimité). Appliquée via un
guest_id anonyme persisté en localStorage — pas une identité vérifiée,
contournable en changeant d'appareil, mais fait respecter côté serveur
(source de vérité) et bloque l'UI proactivement côté client. Ancienne
signature de insert_public_photo (sans guest_id) supprimée pour empêcher
tout contournement.

Testé en conditions réelles sur kevin-lecou-hub (2 photos acceptées,
3e refusée avec max_photos_per_guest=2).
This commit is contained in:
2026-09-16 15:27:05 +02:00
parent 73143f0ec2
commit 66435b0705
3 changed files with 239 additions and 10 deletions
+12
View File
@@ -48,6 +48,18 @@ par Kévin.
adapté. Risque jugé faible : les slugs sont partagés via QR code, donc adapté. Risque jugé faible : les slugs sont partagés via QR code, donc
non secrets par nature. non secrets par nature.
## 2026-09-16 (quater)
- **Limite de photos par invité** : nouvelle colonne
`photobooth.events.max_photos_per_guest` (NULL = illimité par défaut),
réglable uniquement en SQL à la création de l'event pour l'instant (pas
d'UI admin dédiée). Appliquée via un `guest_id` anonyme généré et
persisté côté client (localStorage) — **pas une identité vérifiée**,
contournable en changeant d'appareil ou en vidant le storage. Fait
respecter côté serveur (RPC `insert_public_photo`, source de vérité) et
côté client de façon proactive (évite un envoi pour se le faire refuser
à la dernière étape).
## 2026-09-16 (ter) ## 2026-09-16 (ter)
- **Domaine de production (V1)** : `photobooth.assistantaikev.eu`, sous-domaine - **Domaine de production (V1)** : `photobooth.assistantaikev.eu`, sous-domaine
+83 -10
View File
@@ -71,6 +71,8 @@ const ERROR_MESSAGES = {
"Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.", "Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.",
"event-closed": "event-closed":
"Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !", "Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !",
"guest-limit-reached":
"Tu as déjà envoyé le nombre maximum de photos autorisé pour cet événement. Merci pour ta participation !",
"file-missing": "Choisis d'abord une photo avant d'envoyer.", "file-missing": "Choisis d'abord une photo avant d'envoyer.",
"file-not-image": "file-not-image":
"Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).", "Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).",
@@ -149,6 +151,55 @@ function getSlugFromPath() {
return match ? decodeURIComponent(match[1]) : null; return match ? decodeURIComponent(match[1]) : null;
} }
// ---------------------------------------------------------------------------
// Identité invité (anonyme) + compteur local — pour faire respecter
// max_photos_per_guest. guest_id est généré une fois par appareil/navigateur
// (localStorage), envoyé au serveur qui fait foi pour la limite réelle ;
// le compteur local sert uniquement à bloquer l'UI de façon proactive
// (éviter de faire recompresser+uploader une photo pour se la faire
// refuser à la toute dernière étape). Contournable (autre appareil,
// navigation privée, storage vidé) — ce n'est pas une garantie forte,
// juste un frein raisonnable pour un usage V1.
const GUEST_ID_STORAGE_KEY = "photobooth_guest_id";
function getGuestId() {
try {
let id = window.localStorage.getItem(GUEST_ID_STORAGE_KEY);
if (!id) {
id = generateId();
window.localStorage.setItem(GUEST_ID_STORAGE_KEY, id);
}
return id;
} catch {
// localStorage indisponible (navigation privée stricte, etc.) : on
// régénère un id éphémère, la limite sera simplement moins efficace
// pour cet invité plutôt que de bloquer tout envoi.
return generateId();
}
}
function guestCountStorageKey(slug) {
return `photobooth_guest_count_${slug}`;
}
function getLocalGuestPhotoCount(slug) {
try {
return Number(window.localStorage.getItem(guestCountStorageKey(slug))) || 0;
} catch {
return 0;
}
}
function incrementLocalGuestPhotoCount(slug) {
try {
const next = getLocalGuestPhotoCount(slug) + 1;
window.localStorage.setItem(guestCountStorageKey(slug), String(next));
} catch {
// Pas grave : au pire l'invité pourra tenter un envoi de plus que prévu
// et se le fera refuser côté serveur (source de vérité réelle).
}
}
function isHeicFile(file) { function isHeicFile(file) {
const type = (file.type || "").toLowerCase(); const type = (file.type || "").toLowerCase();
if (type === "image/heic" || type === "image/heif") return true; if (type === "image/heic" || type === "image/heif") return true;
@@ -195,7 +246,7 @@ function showError(rawErr, retryAction) {
// Erreurs bloquantes de chargement d'événement : on masque le formulaire. // Erreurs bloquantes de chargement d'événement : on masque le formulaire.
// Erreurs liées à une tentative d'envoi : le formulaire reste visible. // Erreurs liées à une tentative d'envoi : le formulaire reste visible.
if (["event-not-found", "event-closed", "config-missing"].includes(err.code)) { if (["event-not-found", "event-closed", "config-missing", "guest-limit-reached"].includes(err.code)) {
form.hidden = true; form.hidden = true;
} }
@@ -298,6 +349,12 @@ async function initEventCheck() {
try { try {
const event = await fetchEventPublicInfo(slug); const event = await fetchEventPublicInfo(slug);
if (!isEventActive(event)) throw appError("event-closed"); if (!isEventActive(event)) throw appError("event-closed");
if (
event.max_photos_per_guest != null &&
getLocalGuestPhotoCount(slug) >= event.max_photos_per_guest
) {
throw appError("guest-limit-reached");
}
state.event = event; state.event = event;
showForm(event); showForm(event);
} catch (err) { } catch (err) {
@@ -418,6 +475,7 @@ async function insertPhotoRow(eventId, path, nom, message) {
p_url_storage: path, p_url_storage: path,
p_nom_invite: nom || null, p_nom_invite: nom || null,
p_message: message || null, p_message: message || null,
p_guest_id: getGuestId(),
}), }),
}); });
} catch (err) { } catch (err) {
@@ -425,7 +483,16 @@ async function insertPhotoRow(eventId, path, nom, message) {
} }
if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res)); if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res));
if (!res.ok) throw appError("db-insert-failed", await safeReadText(res)); if (!res.ok) {
const bodyText = await safeReadText(res);
// La RPC renvoie un message métier explicite (ex. "guest_photo_limit_reached")
// dans le corps PostgREST -- on le distingue d'une erreur technique
// générique pour afficher le bon message (et ne pas proposer "réessayer").
if (bodyText && bodyText.includes("guest_photo_limit_reached")) {
throw appError("guest-limit-reached", bodyText);
}
throw appError("db-insert-failed", bodyText);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -502,6 +569,9 @@ async function submitPhoto() {
setProgress("Dernière étape…"); setProgress("Dernière étape…");
await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim()); await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim());
const slug = getSlugFromPath();
if (slug) incrementLocalGuestPhotoCount(slug);
setProgress(""); setProgress("");
showSuccess(); showSuccess();
} catch (err) { } catch (err) {
@@ -529,15 +599,18 @@ retryBtn.addEventListener("click", () => {
addAnotherBtn.addEventListener("click", () => { addAnotherBtn.addEventListener("click", () => {
form.reset(); form.reset();
state.selectedFile = null; resetPhotoFields();
state.compressedBlob = null;
fileChosenLabel.textContent = "Aucun fichier choisi";
previewImg.hidden = true;
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
successBlock.hidden = true; successBlock.hidden = true;
// Revérifie la limite par invité : le dernier envoi peut avoir atteint
// exactement le max, auquel cas on ne rouvre pas le formulaire.
const slug = getSlugFromPath();
const limit = state.event ? state.event.max_photos_per_guest : null;
if (slug && limit != null && getLocalGuestPhotoCount(slug) >= limit) {
showError(appError("guest-limit-reached"));
return;
}
form.hidden = false; form.hidden = false;
}); });
@@ -0,0 +1,144 @@
-- Migration : limite de photos par invité, réglable par événement
-- Réf. : demande explicite de Kévin le 2026-09-16.
--
-- max_photos_per_guest (photobooth.events) : NULL = illimité (comportement
-- par défaut, inchangé). Réglable uniquement en SQL à la création de l'event
-- pour l'instant (pas d'UI admin dédiée en V1, cohérent avec le reste du
-- produit).
--
-- guest_id (photobooth.photos) : identifiant anonyme généré et persisté
-- côté client (localStorage), envoyé à chaque insert_public_photo. Sert
-- uniquement à faire respecter max_photos_per_guest — contournable si
-- l'invité change d'appareil ou vide son storage, ce n'est pas une
-- identité vérifiée.
--
-- Déjà appliqué et testé en conditions réelles sur kevin-lecou-hub le
-- 2026-09-16 (2 photos acceptées, 3e refusée avec max_photos_per_guest=2 ;
-- ancienne signature sans guest_id supprimée pour éviter tout contournement).
alter table photobooth.events
add column if not exists max_photos_per_guest integer
check (max_photos_per_guest is null or max_photos_per_guest > 0);
comment on column photobooth.events.max_photos_per_guest is
'Nombre max de photos qu''un même invité peut envoyer pour cet événement. NULL = illimité. Réglé à la création de l''event (pas d''UI admin dédiée en V1).';
alter table photobooth.photos
add column if not exists guest_id uuid;
comment on column photobooth.photos.guest_id is
'Identifiant anonyme côté client (localStorage), pour appliquer max_photos_per_guest. Pas une identité vérifiée.';
create index if not exists idx_photos_event_guest
on photobooth.photos (event_id, guest_id);
-- get_public_event : ajoute max_photos_per_guest pour que le front puisse
-- bloquer l'UI proactivement (avant même une tentative d'envoi refusée).
drop function if exists public.get_public_event(text);
create function public.get_public_event(p_slug text)
returns table (
id uuid,
nom text,
statut text,
date_evenement date,
expire_at timestamptz,
max_photos_per_guest integer
)
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.max_photos_per_guest
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, y compris max_photos_per_guest), pour la page d''upload.';
revoke all on function public.get_public_event(text) from public;
grant execute on function public.get_public_event(text) to anon;
-- insert_public_photo : ancienne signature (4 arguments, sans guest_id)
-- supprimée -- elle créait une ambiguïté de résolution de surcharge côté
-- PostgREST (erreur PGRST203) et permettrait sinon de contourner
-- max_photos_per_guest en omettant guest_id.
drop function if exists public.insert_public_photo(uuid, text, text, text);
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,
p_guest_id uuid default null
)
returns uuid
language plpgsql
security definer
set search_path = public, photobooth, storage, pg_temp
as $$
declare
v_photo_id uuid;
v_max_per_guest integer;
v_current_count integer;
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;
select max_photos_per_guest into v_max_per_guest
from photobooth.events
where id = p_event_id;
if v_max_per_guest is not null then
if p_guest_id is null then
raise exception 'guest_id_required'
using errcode = 'P0001',
detail = 'Cet événement limite le nombre de photos par invité : un identifiant invité est requis.';
end if;
select count(*) into v_current_count
from photobooth.photos
where event_id = p_event_id
and guest_id = p_guest_id;
if v_current_count >= v_max_per_guest then
raise exception 'guest_photo_limit_reached'
using errcode = 'P0001',
detail = format('Limite de %s photo(s) par invité atteinte pour cet événement.', v_max_per_guest);
end if;
end if;
insert into photobooth.photos (event_id, url_storage, nom_invite, message, guest_id)
values (p_event_id, p_url_storage, p_nom_invite, p_message, p_guest_id)
returning id into v_photo_id;
return v_photo_id;
end;
$$;
comment on function public.insert_public_photo(uuid, text, text, text, uuid) is
'Chemin d''écriture unique pour les invités. Revalide event_accepts_uploads(), la cohérence url_storage/event_id, l''existence du fichier Storage, ET la limite max_photos_per_guest (par guest_id anonyme côté client) si définie sur l''event.';
revoke all on function public.insert_public_photo(uuid, text, text, text, uuid) from public;
grant execute on function public.insert_public_photo(uuid, text, text, text, uuid) to anon;