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,19 @@
|
|||||||
|
---
|
||||||
|
name: front-upload
|
||||||
|
description: Utiliser pour la page upload invité et la compression image côté client du projet photobooth-qr.
|
||||||
|
tools: Read, Edit, Write, Bash, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
Tu es responsable exclusivement du front invité (page upload, mobile-first,
|
||||||
|
sans framework lourd) du projet photobooth-qr.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Compression obligatoire côté client avant upload, via la lib
|
||||||
|
browser-image-compression (décision actée, ne pas réinventer en Canvas
|
||||||
|
maison sauf si explicitement demandé).
|
||||||
|
- Gère l'orientation EXIF et les formats HEIC (iPhone) — cas fréquent,
|
||||||
|
ne pas négliger.
|
||||||
|
- Ne touche jamais aux migrations Supabase (supabase/) sauf demande
|
||||||
|
explicite.
|
||||||
|
- Interface simple, sobre, mobile-first — pas de dépendance front lourde
|
||||||
|
sans justification.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: reviewer-securite
|
||||||
|
description: Utiliser après chaque bloc de code significatif, pour relecture sécurité/RGPD du projet photobooth-qr. Lecture seule — ne corrige rien, se contente de challenger et lister les problèmes.
|
||||||
|
tools: Read, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
Tu relis le code du projet photobooth-qr avec un œil critique, uniquement
|
||||||
|
en lecture. Tu ne modifies rien toi-même.
|
||||||
|
|
||||||
|
Points de vigilance systématiques :
|
||||||
|
- La clé anon Supabase ne doit JAMAIS permettre de lister/lire les photos
|
||||||
|
d'un autre event (vérifie les RLS).
|
||||||
|
- L'admin_token doit être suffisamment long et généré aléatoirement
|
||||||
|
(pas de pattern prévisible).
|
||||||
|
- La mention RGPD doit être visible avant l'upload, pas cachée.
|
||||||
|
- Toute donnée qui devrait expirer à J+7 doit effectivement être supprimée
|
||||||
|
(fichiers Storage ET lignes DB, pas l'un sans l'autre).
|
||||||
|
- Signale tout appel qui utiliserait la service_role key côté client
|
||||||
|
(front) au lieu de côté serveur — c'est une faille critique.
|
||||||
|
|
||||||
|
Sois direct et liste les problèmes trouvés, du plus critique au moins
|
||||||
|
critique. Ne complimente pas le code juste pour être agréable.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: supabase-backend
|
||||||
|
description: Responsable exclusif de la couche Supabase du projet photobooth-qr (schéma DB, RLS, migrations SQL, Edge Functions). À utiliser pour toute tâche touchant aux tables events/photos, aux policies RLS, au job d'expiration J+7, ou à l'Edge Function admin de zip/listing des photos.
|
||||||
|
tools: Read, Edit, Write, Bash, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
Tu es responsable exclusivement de la couche Supabase du projet photobooth-qr :
|
||||||
|
schéma DB (tables events, photos), policies RLS, migrations SQL, Edge Functions
|
||||||
|
(notamment le job d'expiration J+7 via pg_cron, et l'Edge Function admin qui
|
||||||
|
utilise la service_role key pour lister/zipper les photos).
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Ne touche jamais au front (src/) sauf si explicitement demandé.
|
||||||
|
- Toute policy RLS doit suivre le principe : upload anonyme autorisé
|
||||||
|
uniquement si l'event est actif, lecture des photos jamais publique.
|
||||||
|
- Référence-toi systématiquement à docs/modele-donnees.md avant toute
|
||||||
|
modification de schéma.
|
||||||
|
- Si une décision structurante n'est pas déjà actée dans docs/decisions.md,
|
||||||
|
ne tranche pas seul : signale-le clairement dans ta réponse.
|
||||||
+1
-1
@@ -12,7 +12,7 @@ SUPABASE_ANON_KEY=
|
|||||||
SUPABASE_SERVICE_ROLE_KEY=
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
# Nom du bucket Supabase Storage utilisé pour les photos
|
# Nom du bucket Supabase Storage utilisé pour les photos
|
||||||
SUPABASE_STORAGE_BUCKET=photos
|
SUPABASE_STORAGE_BUCKET=event-photos
|
||||||
|
|
||||||
# URL de base de l'application (utilisée pour générer les liens QR code)
|
# URL de base de l'application (utilisée pour générer les liens QR code)
|
||||||
APP_BASE_URL=
|
APP_BASE_URL=
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ node_modules/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Config front runtime (contient l'URL + la clé anon Supabase réelles,
|
||||||
|
# cf. src/e/config.example.js pour le modèle à copier)
|
||||||
|
src/e/config.js
|
||||||
|
|
||||||
|
# Config front runtime — galerie admin (cf. src/admin/gallery/config.example.js)
|
||||||
|
src/admin/gallery/config.js
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.supabase.com/mcp?features=docs,account,database,debugging,development,functions,branching"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,5 +51,3 @@ flowchart TD
|
|||||||
- Mécanisme exact du job d'expiration (cron Supabase, Edge Function planifiée,
|
- Mécanisme exact du job d'expiration (cron Supabase, Edge Function planifiée,
|
||||||
ou script sur le VPS existant).
|
ou script sur le VPS existant).
|
||||||
- Génération et hébergement du QR code (généré à la volée ou stocké).
|
- Génération et hébergement du QR code (généré à la volée ou stocké).
|
||||||
- Bibliothèque de compression image côté client (ou implémentation maison via
|
|
||||||
Canvas API pour rester sobre en dépendances).
|
|
||||||
|
|||||||
+233
-1
@@ -1 +1,233 @@
|
|||||||
# Cahier des charges
|
# Cahier des charges — Application Photobooth QR Code
|
||||||
|
**Porteur de projet :** Kévin Lecou
|
||||||
|
**Marque :** Personnelle (Kévin Lecou) — usage v1 perso, ouverture commerciale v2/v3
|
||||||
|
**Durée de dev estimée :** ~1 journée (V1)
|
||||||
|
**Date de rédaction :** 13/09/2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 1. Contexte & objectif
|
||||||
|
|
||||||
|
Application web (pas d'appli native, pas de compte requis) permettant aux invités d'un événement (mariage, anniversaire, soirée) d'uploader leurs photos via un QR code affiché sur place. Les photos sont centralisées, accompagnées d'un nom/message optionnel façon livre d'or, et récupérables en un clic par l'organisateur (Kévin ou son client) sous forme de ZIP.
|
||||||
|
|
||||||
|
**V1 (maintenant) :** usage personnel de Kévin, pour ses propres événements.
|
||||||
|
**V2/V3 (plus tard) :** produit facturé à l'événement pour des clients externes (mariages, entreprises), potentiellement piloté via n8n/Notion.
|
||||||
|
|
||||||
|
> ⚠️ Point à garder en tête dès la V1 : même si l'usage est perso pour l'instant, autant construire la structure de données comme si chaque "événement" était déjà un client — ça évite de tout refaire en V2. C'est déjà comme ça que je décris le modèle de données plus bas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 2. Stack technique imposée
|
||||||
|
|
||||||
|
| Brique | Outil | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend / DB / Storage | **Supabase** (déjà connecté) | Stockage des photos, table événements, table photos, gestion RLS |
|
||||||
|
| Front | À définir par le dev (recommandé : léger, mobile-first, pas de framework lourd) | Page upload + page galerie admin |
|
||||||
|
| Automatisation (V2) | **n8n** | Création d'événement, notifications, facturation |
|
||||||
|
| Suivi client (V2) | **Notion** | Base "Événements" (statut, date, lien, facturation) |
|
||||||
|
| Notifications (V2) | **Telegram** | Alerte "X nouvelles photos" |
|
||||||
|
| Hébergement front | À proposer par le dev — critère : **écoconception** (hébergeur mutualisé/vert type Scalingo, o2switch, ou hébergement statique très léger type Cloudflare Pages/Netlify avec faible empreinte) |
|
||||||
|
| Génération QR code | Lib front ou back (ex: `qrcode` npm) — pas de service tiers payant nécessaire |
|
||||||
|
|
||||||
|
**Contrainte transverse :** sobriété numérique — compression des images côté client avant upload (réduit stockage Supabase + bande passante + empreinte carbone).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ 3. Modèle de données (Supabase)
|
||||||
|
|
||||||
|
### Table `events`
|
||||||
|
| Champ | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid (PK) | généré auto |
|
||||||
|
| slug | text unique | utilisé dans l'URL publique (ex: `/e/mariage-julie-marc`) |
|
||||||
|
| nom | text | nom de l'événement |
|
||||||
|
| date_evenement | date | |
|
||||||
|
| created_at | timestamptz | |
|
||||||
|
| expire_at | timestamptz | **date_evenement + 7 jours** (décidé : le compteur démarre à la date de l'événement, pas à la création de la fiche ni à la 1ère photo) |
|
||||||
|
| statut | enum | `actif`, `expire`, `telecharge`, `archive` |
|
||||||
|
| admin_token | text (secret) | token unique pour accéder à la galerie admin + déclencher le ZIP |
|
||||||
|
| client_nom | text (nullable) | vide en V1, utilisé en V2 pour la facturation |
|
||||||
|
| facture_montant | numeric (nullable) | V2 |
|
||||||
|
|
||||||
|
### Table `photos`
|
||||||
|
| Champ | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid (PK) | |
|
||||||
|
| event_id | uuid (FK → events) | |
|
||||||
|
| url_storage | text | chemin dans le bucket Supabase |
|
||||||
|
| nom_invite | text (nullable) | champ "livre d'or" |
|
||||||
|
| message | text (nullable) | message optionnel associé à la photo |
|
||||||
|
| uploaded_at | timestamptz | |
|
||||||
|
|
||||||
|
### Storage bucket
|
||||||
|
- 1 bucket par environnement (`event-photos`), organisé en sous-dossiers par `event_id`.
|
||||||
|
- **RLS (Row Level Security) :**
|
||||||
|
- Écriture (upload) : **anonyme autorisée**, mais uniquement sur un `event_id` existant et **actif** (pas expiré) — pas d'auth invité, le lien/QR = le ticket d'entrée.
|
||||||
|
- Lecture publique des fichiers : **non** — seul l'admin (via `admin_token`) peut lister/voir les photos. Les invités uploadent en aveugle, ils ne voient pas la galerie des autres.
|
||||||
|
- Suppression : réservée à l'admin_token, ou automatique après expiration (cf. §6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 4. Fonctionnalités V1 (scope figé pour le dev)
|
||||||
|
|
||||||
|
### Côté invité (page publique, mobile-first)
|
||||||
|
1. Scan du QR code → arrive sur `/e/{slug}`.
|
||||||
|
2. Page simple : nom de l'événement, bouton "Ajouter une photo" (accès caméra + galerie du téléphone).
|
||||||
|
3. Champ optionnel : "Ton prénom" + "Un petit mot" (livre d'or).
|
||||||
|
4. Upload → compression client → confirmation visuelle ("Merci ! Ta photo est bien envoyée 🎉").
|
||||||
|
5. **Pas de galerie visible côté invité** (confirmé : upload only, pas de consultation).
|
||||||
|
6. **Pas de téléchargement invité** (confirmé).
|
||||||
|
7. Si l'événement est expiré/archivé → message "Cet événement n'accepte plus de photos".
|
||||||
|
|
||||||
|
### Côté admin (Kévin uniquement, V1)
|
||||||
|
1. Accès via URL secrète type `/admin/{slug}?token={admin_token}` (décidé : pas de vrai login en V1, juste le token dans l'URL — token long et non devinable, généré aléatoirement, suffisant pour un usage perso).
|
||||||
|
2. Vue galerie : toutes les photos de l'événement + nom/message associé.
|
||||||
|
3. Bouton **"Télécharger tout en ZIP"**.
|
||||||
|
4. Statut de l'événement (actif / expire dans X jours / expiré).
|
||||||
|
|
||||||
|
### QR code
|
||||||
|
- Généré automatiquement à la création de l'événement (lien direct vers `/e/{slug}`).
|
||||||
|
- Exportable en image (PNG/SVG) pour impression sur le support physique (comme sur ta photo de référence).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔁 5. Flux fonctionnel (schéma)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Création événement] (manuel en V1, via un simple formulaire ou directement en base)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Génère : slug + admin_token + QR code + expire_at (J+7)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[QR code imprimé / affiché lors de la soirée]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Invité scanne] → page /e/{slug}
|
||||||
|
│
|
||||||
|
├─ Événement actif ? ──NON──▶ "Événement clos"
|
||||||
|
│
|
||||||
|
OUI
|
||||||
|
▼
|
||||||
|
Formulaire upload (photo + prénom + message optionnel)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Compression image (client-side)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Upload → Supabase Storage + insert ligne `photos`
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Confirmation "Merci !"
|
||||||
|
|
||||||
|
--- Côté admin, à tout moment ---
|
||||||
|
|
||||||
|
[Kévin ouvre /admin/{slug}?token={admin_token}]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Liste des photos + noms/messages
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Bouton "Télécharger ZIP"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Génération ZIP (backend) → téléchargement
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
statut passe à "telecharge" (mais les fichiers restent en ligne :
|
||||||
|
décidé → pas de suppression immédiate même après téléchargement du ZIP)
|
||||||
|
|
||||||
|
--- Expiration ---
|
||||||
|
|
||||||
|
[J+7 après la date de l'événement] (décidé : basé sur date_evenement, pas sur la création de la fiche)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Cron/tâche planifiée Supabase (Edge Function + pg_cron) :
|
||||||
|
statut → "expire", upload bloqué
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Suppression effective des fichiers (storage + lignes `photos`) à J+7,
|
||||||
|
que le ZIP ait été téléchargé ou non (décidé : le J+7 prime dans tous les cas —
|
||||||
|
⚠️ à bien faire comprendre au client/à l'organisateur que s'il n'a pas téléchargé
|
||||||
|
son ZIP avant J+7, les photos sont perdues)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erreurs à gérer explicitement (pour le dev) :**
|
||||||
|
- Upload d'un fichier non-image → rejeté avec message clair.
|
||||||
|
- Fichier trop volumineux même après compression → limite fixée (ex : 15 Mo max avant compression, ~2-3 Mo après).
|
||||||
|
- Connexion coupée pendant l'upload → retry ou message d'erreur explicite (pas d'échec silencieux).
|
||||||
|
- Événement inexistant (mauvais slug) → page 404 propre, pas d'erreur technique visible.
|
||||||
|
- Deux invités uploadent en simultané → pas de conflit attendu (chaque upload = une ligne indépendante), mais à tester en charge légère (ex: 20-30 uploads simultanés, taille réaliste d'une soirée).
|
||||||
|
- Quota Supabase (plan gratuit) approché/dépassé → **⚠️ point ouvert : je n'ai pas les seuils exacts à jour du plan gratuit Supabase (ils évoluent), à vérifier par le dev directement sur la doc Supabase au moment du dev plutôt que de se fier à un chiffre que je pourrais avoir en tête et qui serait faux.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 6. RGPD — à intégrer dans le script (V1)
|
||||||
|
|
||||||
|
Comme les invités uploadent des photos pouvant contenir des visages d'autres personnes, quelques éléments minimaux sont à intégrer. **Je ne suis pas juriste et ceci ne remplace pas un avis légal — mais voici la base habituellement recommandée pour ce type d'outil :**
|
||||||
|
|
||||||
|
1. **Mention d'information** affichée sur la page d'upload avant envoi (courte, pas besoin de case à cocher bloquante en V1 perso, mais recommandé dès la V2 commerciale) :
|
||||||
|
> *"En envoyant une photo, tu acceptes qu'elle soit visible par l'organisateur de l'événement et éventuellement partagée dans le cadre de cette soirée. Les photos sont automatiquement supprimées après [durée]. Pour toute demande de suppression, contacte [email/contact organisateur]."*
|
||||||
|
|
||||||
|
2. **Durée de conservation limitée** : cohérent avec ton besoin (7 jours / jusqu'à téléchargement du ZIP) — c'est déjà une bonne pratique RGPD (minimisation).
|
||||||
|
|
||||||
|
3. **Droit à la suppression** : prévoir un moyen simple (même juste un email de contact affiché) pour qu'une personne photographiée demande le retrait d'une photo la concernant.
|
||||||
|
|
||||||
|
4. **Pas de données personnelles obligatoires** : le prénom/message sont optionnels — bon réflexe déjà prévu.
|
||||||
|
|
||||||
|
5. **Pour la V2 commerciale** (clients externes) : il faudra à ce moment-là un texte de mentions légales plus formel, et probablement une case à cocher explicite selon le nombre d'invités et le contexte — **⚠️ à faire valider par un professionnel du droit avant la V2 commerciale**, je peux t'aider à préparer un brouillon mais je ne peux pas garantir la conformité légale complète.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 7. Livrables attendus du développeur
|
||||||
|
|
||||||
|
État au 2026-09-15 (fin de journée) — voir `docs/decisions.md` pour les décisions actées et le SQL complet dans `supabase/migrations/`.
|
||||||
|
|
||||||
|
- [x] Page publique d'upload (`/e/{slug}`) — code écrit (`src/e/`), compression + HEIC + gestion d'erreurs. **Non testée en navigateur réel** (pas de projet Supabase live pour l'instant).
|
||||||
|
- [x] Page admin galerie + export ZIP (`/admin/{slug}?token=...`) — code écrit (`src/admin/gallery/`), consomme l'Edge Function `admin-gallery`. **Non testée en réel**, dépend de l'Edge Function ci-dessous.
|
||||||
|
- [x] Génération du QR code (export PNG/SVG imprimable) — code écrit (`src/admin/qrcode/`), page démo utilisable dès maintenant.
|
||||||
|
- [ ] Schéma Supabase (tables `pq_events`/`pq_photos` + RLS + policies Storage) — **SQL écrit et relu (sécurité OK après corrections), PAS ENCORE APPLIQUÉ**. En attente de validation finale de Kévin avant `apply_migration`.
|
||||||
|
- [x] Compression image côté client avant upload — `browser-image-compression`, intégrée dans `src/e/upload.js`.
|
||||||
|
- [x] Gestion de l'expiration (statut événement, blocage upload, suppression J+7) — blocage upload prêt côté schéma (`event_accepts_uploads`) ; job SQL `pq_expire_events` (pg_cron) écrit. **Reste ouvert** : mécanisme de déclenchement de la suppression des fichiers Storage (pg_net vs n8n, cf. `docs/decisions.md`) — à trancher avant implémentation.
|
||||||
|
- [x] Mention RGPD intégrée sur la page upload — texte de `docs/rgpd.md` repris verbatim.
|
||||||
|
- [x] Edge Function admin (liste photos + ZIP) — code écrit (`supabase/functions/admin-gallery/`), ZIP en streaming. **Non déployée, dépendance `zip.js` non testée en environnement réel** — à vérifier via `supabase functions serve` avant tout déploiement.
|
||||||
|
- [ ] Documentation courte : comment créer un nouvel événement manuellement (en attendant l'automatisation n8n en V2) — pas encore rédigée.
|
||||||
|
- [ ] Code source versionné (repo Git — **à préciser : GitHub perso de Kévin, ou autre ?** ⚠️ point ouvert) — dépôt local Git existant, pas de remote configuré à ce stade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 8. Budget & contraintes
|
||||||
|
|
||||||
|
- Budget outils externes : **léger accepté** (ex: nom de domaine, éventuel plan Supabase payant si besoin, hébergement front à petit coût). Pas de contrainte "0€ strict".
|
||||||
|
- Écoconception à privilégier dans les choix d'hébergement et de dépendances (pas de lib front lourde inutile).
|
||||||
|
- Dev réalisé par un tiers (freelance/dev externe) sur la base de ce document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 9. Roadmap V2/V3 (hors scope dev V1, pour information)
|
||||||
|
|
||||||
|
- Création d'événement automatisée via **n8n** (formulaire → génère event + QR + entrée Notion).
|
||||||
|
- Notification **Telegram** en temps réel ("5 nouvelles photos ajoutées").
|
||||||
|
- Base **Notion "Événements"** : statut, client, date, lien galerie, montant facturé.
|
||||||
|
- Authentification admin renforcée (vrai login) si ouverture à plusieurs clients simultanés.
|
||||||
|
- Facturation à l'événement (tarif à définir).
|
||||||
|
- Éventuelle marque dédiée si le produit décolle (distincte du nom personnel Kévin Lecou).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ 10. Décisions actées
|
||||||
|
|
||||||
|
1. ✅ Le J+7 démarre à la **date de l'événement** (`date_evenement`), pas à la création de la fiche ni à la 1ère photo.
|
||||||
|
2. ✅ Sécurité admin V1 : **token secret dans l'URL**, pas de vrai login.
|
||||||
|
3. ✅ Le J+7 **prime toujours** : suppression effective des photos à J+7 même si le ZIP a déjà été téléchargé (pas de suppression anticipée au moment du téléchargement).
|
||||||
|
4. ⚠️ **Repo de code — point encore ouvert.** Tu ne sais pas trop, donc voici ma recommandation concrète : crée un compte **GitHub gratuit à ton nom** (ou au nom "Kévin Lecou" / futur nom de marque), en **dépôt privé**. Pourquoi :
|
||||||
|
- C'est toi le porteur du projet et celui qui va potentiellement le revendre en V2/V3 → tu dois être **propriétaire du code**, pas le dev.
|
||||||
|
- Un repo privé gratuit suffit largement pour ce volume de code.
|
||||||
|
- Ça te permet de changer de dev plus tard sans dépendre de qui que ce soit.
|
||||||
|
- Si tu n'as pas encore de compte GitHub, je peux te faire un guide pas-à-pas (2 minutes, gratuit) — dis-le moi.
|
||||||
|
- Alternative si tu préfères tout garder sur ton VPS : un dépôt Git auto-hébergé (ex: via Gitea, léger) est possible vu que tu as déjà un VPS pour n8n — mais GitHub reste plus simple si tu ne gères pas encore ça au quotidien.
|
||||||
|
5. ✅ Nom de domaine : **probablement sur ton VPS/serveur existant** — pas de coût supplémentaire à prévoir a priori, à confirmer avec le dev que ton VPS peut effectivement héberger le front + les redirections DNS nécessaires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document rédigé pour servir de brief à un développeur externe. Le point marqué ⚠️ reste à trancher (repo de code) — une recommandation est proposée en attendant ta décision finale.*
|
||||||
@@ -17,3 +17,45 @@ par Kévin.
|
|||||||
compris si l'organisateur a déjà téléchargé le ZIP d'export des photos.
|
compris si l'organisateur a déjà téléchargé le ZIP d'export des photos.
|
||||||
- **Hébergement** : utilisation du **VPS existant de Kévin**
|
- **Hébergement** : utilisation du **VPS existant de Kévin**
|
||||||
(148.230.114.175), pas de nouvel hébergement dédié pour l'instant.
|
(148.230.114.175), pas de nouvel hébergement dédié pour l'instant.
|
||||||
|
|
||||||
|
## 2026-09-16
|
||||||
|
|
||||||
|
- **Compression image côté client** : utilisation de la lib
|
||||||
|
**`browser-image-compression`**, plutôt qu'une implémentation maison via
|
||||||
|
Canvas API. Doit gérer l'orientation EXIF et les formats HEIC (iPhone).
|
||||||
|
|
||||||
|
## 2026-09-15 (bis)
|
||||||
|
|
||||||
|
- **Préfixage des tables** : le projet Supabase de photobooth-qr sera
|
||||||
|
mutualisé avec les autres projets de Kévin (KOMI, KAZA...). Tables
|
||||||
|
préfixées `pq_` (`pq_events`, `pq_photos`), conformément à la convention
|
||||||
|
du CLAUDE.md global. Les fonctions Postgres exposées au front
|
||||||
|
(`get_public_event`, `event_accepts_uploads`) restent sans préfixe : ce
|
||||||
|
sont des points de contrat public, pas des tables internes.
|
||||||
|
- **Bucket Supabase Storage `event-photos`** : bucket **privé** (pas de
|
||||||
|
lecture publique). Convention de chemin : `{event_id}/{uuid}.{ext}`.
|
||||||
|
Consultation admin exclusivement via URLs signées générées par l'Edge
|
||||||
|
Function `service_role`. Aucune policy SELECT/UPDATE/DELETE publique sur
|
||||||
|
`storage.objects`. Limité à `file_size_limit = 6 Mo` et
|
||||||
|
`allowed_mime_types = image/jpeg` (aligné sur la sortie de compression
|
||||||
|
du front, qui force tout en JPEG y compris les HEIC convertis).
|
||||||
|
- **`pq_photos.url_storage`** : contrainte `CHECK` garantissant que le
|
||||||
|
chemin commence par `event_id/`, pour empêcher qu'une ligne de l'event A
|
||||||
|
pointe vers un fichier de l'event B (isolation multi-tenant), trouvé lors
|
||||||
|
de la relecture sécurité du 2026-09-15.
|
||||||
|
- **`get_public_event`** : renvoie l'événement même si son statut n'est pas
|
||||||
|
`actif` (expiré/archivé), pour permettre au front d'afficher un message
|
||||||
|
adapté. Risque jugé faible : les slugs sont partagés via QR code, donc
|
||||||
|
non secrets par nature.
|
||||||
|
|
||||||
|
## 2026-09-16 (bis)
|
||||||
|
|
||||||
|
- **Déclenchement de la purge Storage à J+7** : **option n8n retenue**
|
||||||
|
(plutôt que `pg_cron`+`pg_net`). Un workflow n8n planifié quotidien
|
||||||
|
appelle l'Edge Function `expire-events` avec la `service_role` key
|
||||||
|
(Credentials Store n8n), avec notification Telegram en cas d'échec.
|
||||||
|
Cohérent avec la stack n8n déjà utilisée pour KOMI/KAZA, et le VPS n8n
|
||||||
|
existant sert de point d'orchestration commun. Conception du workflow :
|
||||||
|
`n8n/workflows/purge-storage-j7.json` (JSON importable, **non testé sur
|
||||||
|
l'instance n8n réelle**, credentials à relier manuellement avant
|
||||||
|
activation).
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"name": "photobooth-qr — Purge Storage J+7",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "cronExpression",
|
||||||
|
"expression": "30 3 * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1a1a1a1-0000-4000-8000-000000000001",
|
||||||
|
"name": "Cron quotidien 03h30 UTC",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [ -400, 0 ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"content": "## photobooth-qr — Purge Storage J+7\n\n**Pourquoi 03h30 et pas 03h00 ?** Le job `public.expire_events()` (pg_cron, côté Supabase, projet `kevin-lecou-hub`) tourne à 03h00 UTC et marque les events expirés (`statut = 'expire'`) + supprime les lignes `photobooth.photos`. Ce workflow tourne 30 min plus tard pour laisser le temps à ce job SQL de terminer avant d'aller nettoyer les fichiers réels dans Storage.\n\n**Input** : aucun (le trigger est un cron, pas de payload).\n\n**Ce que fait l'Edge Function `expire-events`** : elle liste elle-même les events `statut = 'expire'` côté Supabase (via `public.get_expired_event_ids()`) et purge leur dossier `{event_id}/` dans le bucket `event-photos`. Ce workflow n'a donc rien à lui passer — il se contente de l'appeler et de vérifier que ça s'est bien passé.\n\n**Credential requis** : `Supabase service_role (photobooth-qr)` — credential type Header Auth, header **`Authorization`**, valeur `Bearer <clé service_role>`. Seul ce header est nécessaire (pas de header `apikey` — vérifié en test réel le 2026-09-16, `Authorization` seul suffit). ⚠️ Utiliser la clé au **nouveau format `sb_secret_...`** (Dashboard Supabase → kevin-lecou-hub → Settings → API Keys), PAS l'ancien JWT `service_role` legacy — les deux sont visibles dans le dashboard mais seule la valeur `sb_secret_...` est celle injectée par la plateforme et vérifiée côté Edge Function. À créer dans le Credentials Store n8n, jamais en dur dans le workflow.\n\n**Statut** : Edge Function déployée et testée en conditions réelles le 2026-09-16 (upload, purge, idempotence — tout fonctionne). Il reste à créer le credential ci-dessus dans n8n et à activer ce workflow (`active: false` actuellement).\n\n**Sortie / échec** : en cas d'échec (HTTP != 200, ou erreur réseau), notification Telegram immédiate — aucune erreur ne doit passer silencieusement, cf. règle globale CLAUDE.md.",
|
||||||
|
"height": 460,
|
||||||
|
"width": 460
|
||||||
|
},
|
||||||
|
"id": "a1a1a1a1-0000-4000-8000-000000000099",
|
||||||
|
"name": "Sticky Note — Doc workflow",
|
||||||
|
"type": "n8n-nodes-base.stickyNote",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [ -400, -520 ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{ $env.SUPABASE_URL }}/functions/v1/expire-events",
|
||||||
|
"authentication": "predefinedCredentialType",
|
||||||
|
"nodeCredentialType": "httpHeaderAuth",
|
||||||
|
"options": {
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1a1a1a1-0000-4000-8000-000000000002",
|
||||||
|
"name": "Appel Edge Function expire-events",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [ -160, 0 ],
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": {
|
||||||
|
"id": "REMPLACER_PAR_ID_CREDENTIAL_N8N",
|
||||||
|
"name": "Supabase service_role (photobooth-qr)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onError": "continueErrorOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cond-1",
|
||||||
|
"leftValue": "={{ $json.statusCode }}",
|
||||||
|
"rightValue": 200,
|
||||||
|
"operator": {
|
||||||
|
"type": "number",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1a1a1a1-0000-4000-8000-000000000003",
|
||||||
|
"name": "Purge réussie ?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [ 80, 0 ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"chatId": "={{ $env.TELEGRAM_CHAT_ID_KEVIN }}",
|
||||||
|
"text": "=⚠️ *photobooth-qr — échec purge Storage J+7*\n\nL'appel à l'Edge Function `expire-events` a échoué.\n\nStatut HTTP : {{ $json.statusCode || 'inconnu (erreur réseau)' }}\nDétail : {{ $json.error?.message || $json.body || 'voir logs n8n' }}\n\n➡️ Les fichiers expirés ne sont PAS supprimés tant que ce n'est pas corrigé — vérifier manuellement le bucket `event-photos` si le problème persiste plusieurs jours (engagement RGPD J+7).",
|
||||||
|
"additionalFields": {
|
||||||
|
"parse_mode": "Markdown"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1a1a1a1-0000-4000-8000-000000000004",
|
||||||
|
"name": "Telegram → Erreur purge Storage",
|
||||||
|
"type": "n8n-nodes-base.telegram",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [ 340, 120 ],
|
||||||
|
"credentials": {
|
||||||
|
"telegramApi": {
|
||||||
|
"id": "REMPLACER_PAR_ID_CREDENTIAL_N8N",
|
||||||
|
"name": "Telegram Bot Kevin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"chatId": "={{ $env.TELEGRAM_CHAT_ID_KEVIN }}",
|
||||||
|
"text": "=⚠️ *photobooth-qr — échec purge Storage J+7 (erreur réseau/timeout)*\n\nL'appel HTTP vers `expire-events` n'a même pas abouti (timeout ou erreur réseau).\n\n➡️ Vérifier que l'Edge Function est bien déployée et que le VPS/Supabase sont accessibles.",
|
||||||
|
"additionalFields": {
|
||||||
|
"parse_mode": "Markdown"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1a1a1a1-0000-4000-8000-000000000005",
|
||||||
|
"name": "Telegram → Erreur réseau",
|
||||||
|
"type": "n8n-nodes-base.telegram",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [ -160, 240 ],
|
||||||
|
"credentials": {
|
||||||
|
"telegramApi": {
|
||||||
|
"id": "REMPLACER_PAR_ID_CREDENTIAL_N8N",
|
||||||
|
"name": "Telegram Bot Kevin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"id": "a1a1a1a1-0000-4000-8000-000000000006",
|
||||||
|
"name": "OK (pas de notif)",
|
||||||
|
"type": "n8n-nodes-base.noOp",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [ 340, -100 ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Cron quotidien 03h30 UTC": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{ "node": "Appel Edge Function expire-events", "type": "main", "index": 0 }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Appel Edge Function expire-events": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{ "node": "Purge réussie ?", "type": "main", "index": 0 }
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"error": [
|
||||||
|
[
|
||||||
|
{ "node": "Telegram → Erreur réseau", "type": "main", "index": 0 }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Purge réussie ?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{ "node": "OK (pas de notif)", "type": "main", "index": 0 }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ "node": "Telegram → Erreur purge Storage", "type": "main", "index": 0 }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"instructions": "Workflow conçu hors-ligne (pas de connexion à l'instance n8n réelle) — à relire et adapter dans l'éditeur n8n avant activation : (1) lier les credentials 'Supabase service_role (photobooth-qr)' et 'Telegram Bot Kevin' existants ou à créer, (2) vérifier les noms d'env vars $env.SUPABASE_URL et $env.TELEGRAM_CHAT_ID_KEVIN dans la config n8n du VPS, (3) laisser 'active: false' tant que l'Edge Function expire-events n'a pas été testée en réel."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copier ce fichier en config.js (non commité, voir .gitignore) et renseigner
|
||||||
|
// les vraies valeurs. Ce fichier est chargé avant gallery.js dans index.html.
|
||||||
|
//
|
||||||
|
// SUPABASE_URL sert uniquement à construire l'URL de l'Edge Function
|
||||||
|
// "admin-gallery" (https://<SUPABASE_URL>/functions/v1/admin-gallery).
|
||||||
|
// Le contrôle d'accès à la galerie repose sur le slug + le token secret
|
||||||
|
// transmis dans l'URL de la page (/admin/<slug>?token=...), PAS sur une clé
|
||||||
|
// Supabase : aucune clé n'est donc requise en théorie.
|
||||||
|
window.__PHOTOBOOTH_CONFIG__ = {
|
||||||
|
SUPABASE_URL: "https://xxxxxxxxxxxx.supabase.co",
|
||||||
|
|
||||||
|
// Requis. La fonction "admin-gallery" est déployée avec verify_jwt=true
|
||||||
|
// (vérifié en conditions réelles le 2026-09-16) : la gateway Supabase
|
||||||
|
// exige un header apikey/Authorization valide en plus du slug+token.
|
||||||
|
// La clé publique (anon/publishable) suffit ici — le contrôle d'accès
|
||||||
|
// réel à la galerie reste entièrement basé sur le slug + admin_token,
|
||||||
|
// cette clé ne fait que satisfaire la vérification JWT de la gateway.
|
||||||
|
SUPABASE_ANON_KEY: "sb_publishable_xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
};
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
// Page galerie admin — photobooth-qr
|
||||||
|
//
|
||||||
|
// Aucune dépendance framework, aucune lib tierce. Accès contrôlé par un
|
||||||
|
// slug (lu dans le chemin /admin/<slug>) et un token secret (lu dans le
|
||||||
|
// query param ?token=..., voir docs/decisions.md : "accès admin via token
|
||||||
|
// secret dans l'URL, pas de login"). L'un ou l'autre invalide -> message
|
||||||
|
// générique, on ne distingue jamais "slug inconnu" de "token invalide"
|
||||||
|
// côté client (cf. contrat de l'Edge Function).
|
||||||
|
//
|
||||||
|
// Contrat backend attendu (Edge Function "admin-gallery", implémentée en
|
||||||
|
// parallèle par un autre agent — voir rapport de tâche pour le détail des
|
||||||
|
// points d'ambiguïté identifiés) :
|
||||||
|
// GET {SUPABASE_URL}/functions/v1/admin-gallery?slug=<slug>&token=<token>
|
||||||
|
// -> 200 { event: { nom, date_evenement, statut, photo_count },
|
||||||
|
// photos: [ { id, nom_invite, message, uploaded_at,
|
||||||
|
// signed_url, expires_in } ] }
|
||||||
|
// -> 403/404 générique si slug/token invalide (pas de distinction)
|
||||||
|
// GET {...}&action=zip
|
||||||
|
// -> 200 application/zip (binaire, toutes les photos de l'événement)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Éléments DOM
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const eventNameEl = document.getElementById("event-name");
|
||||||
|
const eventMetaEl = document.getElementById("event-meta");
|
||||||
|
const loadingBlock = document.getElementById("status-loading");
|
||||||
|
const errorBlock = document.getElementById("status-error");
|
||||||
|
const errorMessageEl = document.getElementById("status-error-message");
|
||||||
|
const retryBtn = document.getElementById("retry-btn");
|
||||||
|
const contentBlock = document.getElementById("gallery-content");
|
||||||
|
const statusBanner = document.getElementById("status-banner");
|
||||||
|
const refreshBtn = document.getElementById("refresh-btn");
|
||||||
|
const zipBtn = document.getElementById("download-zip-btn");
|
||||||
|
const zipStatusEl = document.getElementById("zip-status");
|
||||||
|
const emptyStateEl = document.getElementById("empty-state");
|
||||||
|
const photoGridEl = document.getElementById("photo-grid");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// État
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
slug: null,
|
||||||
|
token: null,
|
||||||
|
event: null,
|
||||||
|
photos: [],
|
||||||
|
loadedAt: 0, // Date.now() au moment de la dernière réponse reçue
|
||||||
|
lastRetryAction: null,
|
||||||
|
isZipping: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Erreurs applicatives : un code technique -> un message clair pour l'admin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
"config-missing":
|
||||||
|
"Cette page n'est pas configurée correctement. Contacte le développeur du service.",
|
||||||
|
"link-invalid":
|
||||||
|
"Ce lien est invalide ou a expiré. Vérifie l'URL utilisée, ou redemande un lien d'accès.",
|
||||||
|
network:
|
||||||
|
"La connexion a été coupée. Vérifie ta connexion internet et réessaie.",
|
||||||
|
"server-error":
|
||||||
|
"Le service est momentanément indisponible. Réessaie dans quelques instants.",
|
||||||
|
unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RETRYABLE_CODES = new Set(["network", "server-error", "unknown"]);
|
||||||
|
|
||||||
|
function appError(code, cause) {
|
||||||
|
const err = new Error(code);
|
||||||
|
err.code = code;
|
||||||
|
err.cause = cause;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(err) {
|
||||||
|
if (err && err.code && ERROR_MESSAGES[err.code]) return err;
|
||||||
|
if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau
|
||||||
|
return appError("unknown", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const cfg = window.__PHOTOBOOTH_CONFIG__;
|
||||||
|
if (!cfg || !cfg.SUPABASE_URL) {
|
||||||
|
throw appError("config-missing");
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lecture slug (chemin) + token (query param)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getSlugFromPath() {
|
||||||
|
const match = window.location.pathname.match(/\/admin\/([^/]+)\/?$/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenFromQuery() {
|
||||||
|
return new URLSearchParams(window.location.search).get("token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Affichage des états
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function showLoading() {
|
||||||
|
loadingBlock.hidden = false;
|
||||||
|
errorBlock.hidden = true;
|
||||||
|
contentBlock.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(rawErr, retryAction) {
|
||||||
|
const err = normalizeError(rawErr);
|
||||||
|
loadingBlock.hidden = true;
|
||||||
|
contentBlock.hidden = true;
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown;
|
||||||
|
|
||||||
|
state.lastRetryAction = retryAction || null;
|
||||||
|
const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code);
|
||||||
|
retryBtn.hidden = !canRetry;
|
||||||
|
|
||||||
|
if (err.cause) {
|
||||||
|
console.error(`[gallery:${err.code}]`, err.cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUT_LABELS = {
|
||||||
|
actif: "Actif",
|
||||||
|
expire: "Expiré",
|
||||||
|
archive: "Archivé",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateFr(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
// "date" Postgres arrive en "YYYY-MM-DD" : on force un parsing en heure
|
||||||
|
// locale pour éviter un décalage de jour selon le fuseau du navigateur.
|
||||||
|
const d = new Date(`${dateStr}T00:00:00`);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeFr(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return d.toLocaleString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader(event) {
|
||||||
|
eventNameEl.textContent = event.nom || "Galerie événement";
|
||||||
|
document.title = `${event.nom || "Galerie événement"} — admin`;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
const dateFr = formatDateFr(event.date_evenement);
|
||||||
|
if (dateFr) parts.push(dateFr);
|
||||||
|
const count = Number.isFinite(event.photo_count) ? event.photo_count : state.photos.length;
|
||||||
|
parts.push(`${count} photo${count > 1 ? "s" : ""}`);
|
||||||
|
const statutLabel = STATUT_LABELS[event.statut] || event.statut || "";
|
||||||
|
if (statutLabel) parts.push(statutLabel);
|
||||||
|
|
||||||
|
eventMetaEl.textContent = parts.join(" · ");
|
||||||
|
eventMetaEl.hidden = parts.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusBanner(event) {
|
||||||
|
if (event.statut === "expire") {
|
||||||
|
statusBanner.textContent =
|
||||||
|
"Cet événement est expiré : les photos ne sont plus disponibles (suppression automatique après la période de conservation).";
|
||||||
|
statusBanner.hidden = false;
|
||||||
|
} else if (event.statut === "archive") {
|
||||||
|
statusBanner.textContent = "Cet événement est archivé. Les photos ne sont plus disponibles.";
|
||||||
|
statusBanner.hidden = false;
|
||||||
|
} else {
|
||||||
|
statusBanner.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyState(event) {
|
||||||
|
if (state.photos.length > 0) {
|
||||||
|
emptyStateEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyStateEl.hidden = false;
|
||||||
|
emptyStateEl.textContent =
|
||||||
|
event.statut === "actif"
|
||||||
|
? "Aucune photo n'a encore été déposée pour cet événement."
|
||||||
|
: "Il n'y a plus de photo disponible pour cet événement.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoExpiryTimestamp(photo) {
|
||||||
|
if (!Number.isFinite(photo.expires_in)) return null;
|
||||||
|
return state.loadedAt + photo.expires_in * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPhotoLinkLikelyExpired(photo) {
|
||||||
|
const expiresAt = photoExpiryTimestamp(photo);
|
||||||
|
return expiresAt !== null && Date.now() >= expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhotoGrid() {
|
||||||
|
photoGridEl.textContent = "";
|
||||||
|
|
||||||
|
for (const photo of state.photos) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "photo-card";
|
||||||
|
|
||||||
|
const thumbWrap = document.createElement("div");
|
||||||
|
thumbWrap.className = "photo-card__thumb-wrap";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "photo-card__thumb";
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = photo.nom_invite
|
||||||
|
? `Photo envoyée par ${photo.nom_invite}`
|
||||||
|
: "Photo envoyée par un invité";
|
||||||
|
img.src = photo.signed_url;
|
||||||
|
img.onerror = () => {
|
||||||
|
li.classList.add("photo-card--broken");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallback = document.createElement("div");
|
||||||
|
fallback.className = "photo-card__thumb-fallback";
|
||||||
|
fallback.textContent = "Aperçu indisponible (lien peut-être expiré — clique sur Actualiser)";
|
||||||
|
|
||||||
|
thumbWrap.append(img, fallback);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "photo-card__meta";
|
||||||
|
|
||||||
|
if (photo.nom_invite) {
|
||||||
|
const nameEl = document.createElement("p");
|
||||||
|
nameEl.className = "photo-card__name";
|
||||||
|
nameEl.textContent = photo.nom_invite;
|
||||||
|
meta.appendChild(nameEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.message) {
|
||||||
|
const msgEl = document.createElement("p");
|
||||||
|
msgEl.className = "photo-card__message";
|
||||||
|
msgEl.textContent = photo.message;
|
||||||
|
meta.appendChild(msgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFr = formatDateTimeFr(photo.uploaded_at);
|
||||||
|
if (dateFr) {
|
||||||
|
const dateEl = document.createElement("p");
|
||||||
|
dateEl.className = "photo-card__date";
|
||||||
|
dateEl.textContent = dateFr;
|
||||||
|
meta.appendChild(dateEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadLink = document.createElement("a");
|
||||||
|
downloadLink.className = "photo-card__download";
|
||||||
|
downloadLink.href = photo.signed_url;
|
||||||
|
downloadLink.target = "_blank";
|
||||||
|
downloadLink.rel = "noopener";
|
||||||
|
// Indication pour les navigateurs same-origin ; pour une URL signée
|
||||||
|
// cross-origin (cas normal ici), l'attribut "download" est ignoré par
|
||||||
|
// la plupart des navigateurs, qui ouvrent l'image dans un nouvel onglet
|
||||||
|
// à la place (limitation connue, acceptée pour éviter un fetch+blob par
|
||||||
|
// photo qui multiplierait les requêtes réseau).
|
||||||
|
downloadLink.download = "";
|
||||||
|
downloadLink.textContent = "Télécharger";
|
||||||
|
downloadLink.addEventListener("click", (evt) => {
|
||||||
|
if (isPhotoLinkLikelyExpired(photo)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
zipStatusEl.hidden = false;
|
||||||
|
zipStatusEl.className = "zip-status zip-status--error";
|
||||||
|
zipStatusEl.textContent =
|
||||||
|
"Ce lien a probablement expiré. Clique sur \"Actualiser\" puis réessaie.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
li.append(thumbWrap, meta, downloadLink);
|
||||||
|
photoGridEl.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(event, photos) {
|
||||||
|
state.event = event;
|
||||||
|
state.photos = Array.isArray(photos) ? photos : [];
|
||||||
|
|
||||||
|
loadingBlock.hidden = true;
|
||||||
|
errorBlock.hidden = true;
|
||||||
|
contentBlock.hidden = false;
|
||||||
|
|
||||||
|
renderHeader(event);
|
||||||
|
renderStatusBanner(event);
|
||||||
|
renderEmptyState(event);
|
||||||
|
renderPhotoGrid();
|
||||||
|
|
||||||
|
const hasPhotos = state.photos.length > 0;
|
||||||
|
zipBtn.disabled = !hasPhotos;
|
||||||
|
zipBtn.title = hasPhotos ? "" : "Aucune photo à télécharger";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Appels réseau
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildFunctionUrl(action) {
|
||||||
|
const { SUPABASE_URL } = getConfig();
|
||||||
|
const url = new URL(`${SUPABASE_URL.replace(/\/+$/, "")}/functions/v1/admin-gallery`);
|
||||||
|
url.searchParams.set("slug", state.slug);
|
||||||
|
url.searchParams.set("token", state.token);
|
||||||
|
if (action) url.searchParams.set("action", action);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthHeaders() {
|
||||||
|
// SUPABASE_ANON_KEY est optionnelle (voir config.example.js) : ne
|
||||||
|
// renseignée que si l'Edge Function nécessite un header apikey/Authorization
|
||||||
|
// en plus du token applicatif. Non requis dans le cas nominal attendu
|
||||||
|
// (accès contrôlé uniquement par slug+token, verify_jwt=false côté
|
||||||
|
// Supabase) — cf. rapport de tâche pour le détail de cette hypothèse.
|
||||||
|
const cfg = window.__PHOTOBOOTH_CONFIG__ || {};
|
||||||
|
if (!cfg.SUPABASE_ANON_KEY) return {};
|
||||||
|
return {
|
||||||
|
apikey: cfg.SUPABASE_ANON_KEY,
|
||||||
|
Authorization: `Bearer ${cfg.SUPABASE_ANON_KEY}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeReadText(res) {
|
||||||
|
try {
|
||||||
|
return await res.text();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGallery() {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(buildFunctionUrl(), { headers: buildAuthHeaders() });
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("network", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 403 || res.status === 404) throw appError("link-invalid");
|
||||||
|
if (res.status >= 500) throw appError("server-error", await safeReadText(res));
|
||||||
|
if (!res.ok) throw appError("unknown", await safeReadText(res));
|
||||||
|
|
||||||
|
const data = await res.json().catch((err) => {
|
||||||
|
throw appError("unknown", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data || !data.event) throw appError("unknown", data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGallery() {
|
||||||
|
showLoading();
|
||||||
|
try {
|
||||||
|
const data = await fetchGallery();
|
||||||
|
state.loadedAt = Date.now();
|
||||||
|
renderContent(data.event, data.photos);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, () => loadGallery());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseZipFilename(res, fallback) {
|
||||||
|
const header = res.headers.get("Content-Disposition") || "";
|
||||||
|
const match = header.match(/filename\*?=(?:UTF-8'')?"?([^";\n]+)"?/i);
|
||||||
|
return match ? decodeURIComponent(match[1]) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerBlobDownload(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadZip() {
|
||||||
|
if (state.isZipping) return;
|
||||||
|
state.isZipping = true;
|
||||||
|
zipBtn.disabled = true;
|
||||||
|
zipStatusEl.hidden = false;
|
||||||
|
zipStatusEl.className = "zip-status";
|
||||||
|
zipStatusEl.textContent = "Préparation du ZIP…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(buildFunctionUrl("zip"), { headers: buildAuthHeaders() });
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("network", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 403 || res.status === 404) throw appError("link-invalid");
|
||||||
|
if (res.status >= 500) throw appError("server-error", await safeReadText(res));
|
||||||
|
if (!res.ok) throw appError("unknown", await safeReadText(res));
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const filename = parseZipFilename(res, `photos-${state.slug}.zip`);
|
||||||
|
triggerBlobDownload(blob, filename);
|
||||||
|
|
||||||
|
zipStatusEl.textContent = "Téléchargement lancé.";
|
||||||
|
} catch (err) {
|
||||||
|
const normalized = normalizeError(err);
|
||||||
|
zipStatusEl.className = "zip-status zip-status--error";
|
||||||
|
zipStatusEl.textContent = ERROR_MESSAGES[normalized.code] || ERROR_MESSAGES.unknown;
|
||||||
|
if (normalized.cause) console.error(`[gallery:zip:${normalized.code}]`, normalized.cause);
|
||||||
|
} finally {
|
||||||
|
state.isZipping = false;
|
||||||
|
zipBtn.disabled = state.photos.length === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Événements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
retryBtn.addEventListener("click", () => {
|
||||||
|
if (state.lastRetryAction) state.lastRetryAction();
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn.addEventListener("click", () => {
|
||||||
|
zipStatusEl.hidden = true;
|
||||||
|
loadGallery();
|
||||||
|
});
|
||||||
|
|
||||||
|
zipBtn.addEventListener("click", () => {
|
||||||
|
downloadZip();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Démarrage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
try {
|
||||||
|
getConfig();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.slug = getSlugFromPath();
|
||||||
|
state.token = getTokenFromQuery();
|
||||||
|
|
||||||
|
if (!state.slug || !state.token) {
|
||||||
|
showError(appError("link-invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Galerie événement — admin</title>
|
||||||
|
<!--
|
||||||
|
Cette page est servie pour toute URL du type /admin/<slug> (le slug est lu
|
||||||
|
côté client depuis window.location.pathname, le token depuis les query
|
||||||
|
params ?token=..., voir gallery.js). Comme pour /e/<slug> (voir
|
||||||
|
src/e/index.html), ça suppose une règle de réécriture côté serveur qui
|
||||||
|
renvoie ce fichier pour toute requête sous /admin/* — hors scope de ce
|
||||||
|
fichier front, à mettre en place lors du déploiement.
|
||||||
|
-->
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="page__header">
|
||||||
|
<h1 id="event-name">Galerie événement</h1>
|
||||||
|
<p id="event-meta" class="event-meta" hidden></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="status-loading" class="state-block" role="status">
|
||||||
|
<p>Chargement de la galerie…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="status-error" class="state-block state-block--error" hidden role="alert">
|
||||||
|
<p id="status-error-message"></p>
|
||||||
|
<button type="button" id="retry-btn" hidden>Réessayer</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="gallery-content" hidden>
|
||||||
|
<p id="status-banner" class="status-banner" hidden></p>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" id="refresh-btn" class="btn btn--secondary">Actualiser</button>
|
||||||
|
<button type="button" id="download-zip-btn" class="btn">Télécharger tout (ZIP)</button>
|
||||||
|
</div>
|
||||||
|
<p id="zip-status" class="zip-status" aria-live="polite" hidden></p>
|
||||||
|
|
||||||
|
<p id="empty-state" class="state-block" hidden></p>
|
||||||
|
|
||||||
|
<ul id="photo-grid" class="photo-grid"></ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
config.js n'est PAS commité (voir .gitignore) : copier config.example.js
|
||||||
|
en config.js et renseigner les vraies valeurs avant déploiement. Chargé en
|
||||||
|
script classique (donc synchrone, avant le module) pour garantir que
|
||||||
|
window.__PHOTOBOOTH_CONFIG__ existe avant l'exécution de gallery.js.
|
||||||
|
-->
|
||||||
|
<script src="config.js"></script>
|
||||||
|
<script type="module" src="gallery.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/* Page galerie admin — mobile-first mais utilisable au clavier/souris sur
|
||||||
|
desktop (l'admin peut consulter depuis un ordinateur), sans dépendance
|
||||||
|
externe. Mêmes tokens de couleur que la page invité (src/e/style.css)
|
||||||
|
pour rester cohérent visuellement. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-bg: #faf9f7;
|
||||||
|
--color-text: #2a2a28;
|
||||||
|
--color-muted: #6b6b66;
|
||||||
|
--color-accent: #8a5a44;
|
||||||
|
--color-accent-contrast: #ffffff;
|
||||||
|
--color-error-bg: #fdecea;
|
||||||
|
--color-error-text: #7a1f14;
|
||||||
|
--color-warning-bg: #fdf3e3;
|
||||||
|
--color-warning-text: #7a5a14;
|
||||||
|
--radius: 10px;
|
||||||
|
--spacing: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-meta {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block--error {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block p {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
color: var(--color-warning-text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 3px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zip-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zip-status--error {
|
||||||
|
color: var(--color-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__thumb-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__thumb-fallback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card--broken .photo-card__thumb {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card--broken .photo-card__thumb-fallback {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__meta {
|
||||||
|
padding: 0.6rem 0.7rem 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__message {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__date {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__download {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.55rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card__download:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Générateur de QR code — photobooth-qr (admin)</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--border: #d8d8d8;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #666;
|
||||||
|
--accent: #1a1a1a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.25rem;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: #fafafa;
|
||||||
|
max-width: 480px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
p.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.preview svg {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.preview .url {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #b00020;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Générateur de QR code — événement</h1>
|
||||||
|
<p class="hint">
|
||||||
|
Outil admin autonome : saisir le slug d'un événement et le domaine de
|
||||||
|
l'app pour générer et exporter le QR code (PNG / SVG) à imprimer.
|
||||||
|
Ne fait aucun appel à Supabase.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="slug">Slug de l'événement</label>
|
||||||
|
<input id="slug" type="text" placeholder="mariage-julie-marc" autocomplete="off" />
|
||||||
|
|
||||||
|
<label for="baseUrl">Domaine de base de l'app</label>
|
||||||
|
<input id="baseUrl" type="text" placeholder="https://photobooth.mondomaine.fr" autocomplete="off" />
|
||||||
|
|
||||||
|
<label for="ecLevel">Niveau de correction d'erreur</label>
|
||||||
|
<select id="ecLevel">
|
||||||
|
<option value="L">L — bas (fichier le plus léger)</option>
|
||||||
|
<option value="M" selected>M — moyen (par défaut)</option>
|
||||||
|
<option value="Q">Q — élevé (recommandé si affiché en extérieur)</option>
|
||||||
|
<option value="H">H — maximal (support très exposé/manipulé)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="preview" id="preview" hidden>
|
||||||
|
<div id="svgContainer"></div>
|
||||||
|
<div class="url" id="urlDisplay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error" id="error" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="downloadSvg" disabled>Télécharger SVG</button>
|
||||||
|
<button id="downloadPng" class="secondary" disabled>Télécharger PNG</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import {
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
buildEventUrl,
|
||||||
|
generateEventQrSvg,
|
||||||
|
downloadEventQrSvg,
|
||||||
|
downloadEventQrPng,
|
||||||
|
} from './qrcode.js';
|
||||||
|
|
||||||
|
const slugInput = document.getElementById('slug');
|
||||||
|
const baseUrlInput = document.getElementById('baseUrl');
|
||||||
|
const ecLevelSelect = document.getElementById('ecLevel');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
const svgContainer = document.getElementById('svgContainer');
|
||||||
|
const urlDisplay = document.getElementById('urlDisplay');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const btnSvg = document.getElementById('downloadSvg');
|
||||||
|
const btnPng = document.getElementById('downloadPng');
|
||||||
|
|
||||||
|
// Champ laissé vide par défaut : si non renseigné, le module résout le
|
||||||
|
// domaine via window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL (si présent,
|
||||||
|
// voir src/e/config.example.js) puis via DEFAULT_BASE_URL en secours.
|
||||||
|
baseUrlInput.placeholder = DEFAULT_BASE_URL;
|
||||||
|
|
||||||
|
function currentOptions() {
|
||||||
|
const baseUrl = baseUrlInput.value.trim() || undefined;
|
||||||
|
const errorCorrectionLevel = ecLevelSelect.value;
|
||||||
|
return { baseUrl, errorCorrectionLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
errorEl.textContent = '';
|
||||||
|
const slug = slugInput.value.trim();
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
preview.hidden = true;
|
||||||
|
btnSvg.disabled = true;
|
||||||
|
btnPng.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = currentOptions();
|
||||||
|
const svg = generateEventQrSvg(slug, options);
|
||||||
|
svgContainer.innerHTML = svg;
|
||||||
|
urlDisplay.textContent = buildEventUrl(slug, options.baseUrl);
|
||||||
|
preview.hidden = false;
|
||||||
|
btnSvg.disabled = false;
|
||||||
|
btnPng.disabled = false;
|
||||||
|
} catch (err) {
|
||||||
|
preview.hidden = true;
|
||||||
|
btnSvg.disabled = true;
|
||||||
|
btnPng.disabled = true;
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slugInput.addEventListener('input', refresh);
|
||||||
|
baseUrlInput.addEventListener('input', refresh);
|
||||||
|
ecLevelSelect.addEventListener('change', refresh);
|
||||||
|
|
||||||
|
btnSvg.addEventListener('click', () => {
|
||||||
|
const slug = slugInput.value.trim();
|
||||||
|
if (!slug) return;
|
||||||
|
downloadEventQrSvg(slug, { ...currentOptions(), filename: `qr-${slug}.svg` });
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPng.addEventListener('click', async () => {
|
||||||
|
const slug = slugInput.value.trim();
|
||||||
|
if (!slug) return;
|
||||||
|
btnPng.disabled = true;
|
||||||
|
try {
|
||||||
|
await downloadEventQrPng(slug, { ...currentOptions(), filename: `qr-${slug}.png` });
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
btnPng.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Génération de QR code pour un événement photobooth-qr.
|
||||||
|
*
|
||||||
|
* Module autonome : ne dépend d'aucun appel Supabase / base de données.
|
||||||
|
* Le `slug` de l'événement est fourni en paramètre par l'appelant (page
|
||||||
|
* admin, script de création d'événement, etc.).
|
||||||
|
*
|
||||||
|
* Lib utilisée : `qrcode-generator` (Kazuhiko Arase, MIT), vendorée telle
|
||||||
|
* quelle dans `src/vendor/qrcode-generator.mjs`. Choisie plutôt que
|
||||||
|
* `browser-image-compression`-style bundle plus lourd car :
|
||||||
|
* - zéro dépendance, un seul fichier, ~50 Ko non minifié ;
|
||||||
|
* - fonctionne aussi bien côté navigateur que côté Node (pas de DOM requis
|
||||||
|
* pour calculer la matrice ou produire le SVG) ;
|
||||||
|
* - fournit déjà un export SVG vectoriel natif, idéal pour l'impression.
|
||||||
|
*
|
||||||
|
* Ce module expose :
|
||||||
|
* - des fonctions pures (buildEventUrl, generateEventQrSvg, getEventQrMatrix)
|
||||||
|
* utilisables sans navigateur ;
|
||||||
|
* - des fonctions qui nécessitent un navigateur (canvas, Blob, URL, DOM)
|
||||||
|
* pour l'export PNG et le déclenchement de téléchargement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { qrcode as createQrCodeMatrix } from '../../vendor/qrcode-generator.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domaine de secours utilisé pour construire l'URL publique de l'événement
|
||||||
|
* si aucun `baseUrl` n'est fourni à l'appel ET qu'aucune config globale
|
||||||
|
* n'est disponible. À ne PAS considérer comme le domaine réel de
|
||||||
|
* production.
|
||||||
|
*
|
||||||
|
* Résolution du domaine (par ordre de priorité) :
|
||||||
|
* 1. paramètre explicite `baseUrl` passé à la fonction ;
|
||||||
|
* 2. `window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL` si défini — même
|
||||||
|
* convention de config que la page invité (voir `src/e/config.example.js`,
|
||||||
|
* chargée via un `config.js` non commité) ;
|
||||||
|
* 3. cette constante de secours.
|
||||||
|
*
|
||||||
|
* Volontairement pas de lecture directe de `process.env`/`import.meta.env` :
|
||||||
|
* ce module doit rester utilisable tel quel dans un simple
|
||||||
|
* `<script type="module">` sans étape de build.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_BASE_URL = 'https://REPLACE_ME.example.com';
|
||||||
|
|
||||||
|
function resolveBaseUrl(baseUrl) {
|
||||||
|
if (baseUrl) {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && window.__PHOTOBOOTH_CONFIG__?.APP_BASE_URL) {
|
||||||
|
return window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL;
|
||||||
|
}
|
||||||
|
return DEFAULT_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Niveau de correction d'erreur par défaut ('L' | 'M' | 'Q' | 'H').
|
||||||
|
* 'M' est un bon compromis densité/robustesse pour un usage courant.
|
||||||
|
* Pour un support imprimé très exposé (extérieur, manipulation fréquente),
|
||||||
|
* préférer 'Q' ou 'H' via les options. */
|
||||||
|
export const DEFAULT_ERROR_CORRECTION_LEVEL = 'M';
|
||||||
|
|
||||||
|
/** Résolution cible par défaut (en pixels, largeur = hauteur) pour l'export
|
||||||
|
* PNG. 1200 px est largement suffisant pour une impression nette (un QR
|
||||||
|
* code étant un aplat noir/blanc à fort contraste, il ne nécessite pas une
|
||||||
|
* définition aussi élevée qu'une photo pour rester lisible à l'impression). */
|
||||||
|
export const DEFAULT_PNG_TARGET_SIZE_PX = 1200;
|
||||||
|
|
||||||
|
function assertNonEmptyString(value, label) {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw new TypeError(`${label} requis (chaîne non vide).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'URL publique d'upload d'un événement à partir de son slug.
|
||||||
|
*
|
||||||
|
* @param {string} slug - slug unique de l'événement (ex: "mariage-julie-marc").
|
||||||
|
* @param {string} [baseUrl] - domaine de base de l'app
|
||||||
|
* (ex: "https://photobooth.mondomaine.fr"), sans slash final obligatoire.
|
||||||
|
* Si omis, résolu via `window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL` puis
|
||||||
|
* `DEFAULT_BASE_URL` (voir `resolveBaseUrl`).
|
||||||
|
* @returns {string} URL complète, ex: "https://.../e/mariage-julie-marc".
|
||||||
|
*/
|
||||||
|
export function buildEventUrl(slug, baseUrl) {
|
||||||
|
assertNonEmptyString(slug, 'slug');
|
||||||
|
const resolvedBaseUrl = resolveBaseUrl(baseUrl);
|
||||||
|
assertNonEmptyString(resolvedBaseUrl, 'baseUrl');
|
||||||
|
|
||||||
|
const cleanBase = resolvedBaseUrl.trim().replace(/\/+$/, '');
|
||||||
|
const cleanSlug = encodeURIComponent(slug.trim());
|
||||||
|
|
||||||
|
return `${cleanBase}/e/${cleanSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule la matrice QR (objet natif de la lib `qrcode-generator`) pour un
|
||||||
|
* texte donné. Fonction bas niveau, utilisable pour un rendu personnalisé
|
||||||
|
* (ex: dessiner directement sur un <canvas> existant dans une page admin).
|
||||||
|
*
|
||||||
|
* @param {string} text - contenu à encoder (typiquement une URL d'événement).
|
||||||
|
* @param {{errorCorrectionLevel?: 'L'|'M'|'Q'|'H'}} [options]
|
||||||
|
* @returns {{getModuleCount: () => number, isDark: (row: number, col: number) => boolean}}
|
||||||
|
*/
|
||||||
|
export function buildQrMatrix(text, { errorCorrectionLevel = DEFAULT_ERROR_CORRECTION_LEVEL } = {}) {
|
||||||
|
assertNonEmptyString(text, 'text');
|
||||||
|
|
||||||
|
// typeNumber = 0 => la lib choisit automatiquement la plus petite version
|
||||||
|
// de QR code capable de contenir les données (URL courte à moyenne ici).
|
||||||
|
const qr = createQrCodeMatrix(0, errorCorrectionLevel);
|
||||||
|
qr.addData(text);
|
||||||
|
qr.make();
|
||||||
|
return qr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule la matrice QR correspondant directement à l'URL d'un événement.
|
||||||
|
*
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {{baseUrl?: string, errorCorrectionLevel?: 'L'|'M'|'Q'|'H'}} [options]
|
||||||
|
* @returns {{url: string, matrix: object}}
|
||||||
|
*/
|
||||||
|
export function getEventQrMatrix(slug, { baseUrl, errorCorrectionLevel } = {}) {
|
||||||
|
const url = buildEventUrl(slug, baseUrl);
|
||||||
|
const matrix = buildQrMatrix(url, { errorCorrectionLevel });
|
||||||
|
return { url, matrix };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le QR code d'un événement au format SVG (vectoriel), le meilleur
|
||||||
|
* choix pour une impression de qualité (mise à l'échelle sans perte).
|
||||||
|
* Fonction pure, ne nécessite pas de DOM (fonctionne aussi côté Node).
|
||||||
|
*
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {{
|
||||||
|
* baseUrl?: string,
|
||||||
|
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||||
|
* cellSize?: number,
|
||||||
|
* margin?: number,
|
||||||
|
* }} [options]
|
||||||
|
* @returns {string} balise <svg>...</svg> complète.
|
||||||
|
*/
|
||||||
|
export function generateEventQrSvg(slug, options = {}) {
|
||||||
|
const { baseUrl, errorCorrectionLevel, cellSize = 10, margin = cellSize * 4 } = options;
|
||||||
|
const { url, matrix } = getEventQrMatrix(slug, { baseUrl, errorCorrectionLevel });
|
||||||
|
|
||||||
|
return matrix.createSvgTag({
|
||||||
|
cellSize,
|
||||||
|
margin,
|
||||||
|
alt: `QR code d'accès à l'événement : ${url}`,
|
||||||
|
title: `QR code — ${slug}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dessine la matrice QR d'un événement sur un <canvas> fourni par
|
||||||
|
* l'appelant (utile pour un aperçu live dans une page admin, sans passer
|
||||||
|
* par un export fichier). Nécessite un navigateur.
|
||||||
|
*
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {{
|
||||||
|
* baseUrl?: string,
|
||||||
|
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||||
|
* targetSizePx?: number,
|
||||||
|
* marginModules?: number,
|
||||||
|
* }} [options]
|
||||||
|
* @returns {HTMLCanvasElement} le canvas passé en argument, modifié.
|
||||||
|
*/
|
||||||
|
export function renderEventQrToCanvas(canvas, slug, options = {}) {
|
||||||
|
if (!canvas || typeof canvas.getContext !== 'function') {
|
||||||
|
throw new TypeError('renderEventQrToCanvas: un élément <canvas> valide est requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
baseUrl,
|
||||||
|
errorCorrectionLevel,
|
||||||
|
targetSizePx = DEFAULT_PNG_TARGET_SIZE_PX,
|
||||||
|
marginModules = 4,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const { matrix } = getEventQrMatrix(slug, { baseUrl, errorCorrectionLevel });
|
||||||
|
const moduleCount = matrix.getModuleCount();
|
||||||
|
const cellSize = Math.max(1, Math.round(targetSizePx / (moduleCount + marginModules * 2)));
|
||||||
|
const margin = cellSize * marginModules;
|
||||||
|
const size = moduleCount * cellSize + margin * 2;
|
||||||
|
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
|
||||||
|
for (let row = 0; row < moduleCount; row += 1) {
|
||||||
|
for (let col = 0; col < moduleCount; col += 1) {
|
||||||
|
if (matrix.isDark(row, col)) {
|
||||||
|
ctx.fillRect(col * cellSize + margin, row * cellSize + margin, cellSize, cellSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le QR code d'un événement au format PNG (Blob), en résolution
|
||||||
|
* suffisante pour l'impression. Nécessite un navigateur (canvas.toBlob).
|
||||||
|
*
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {{
|
||||||
|
* baseUrl?: string,
|
||||||
|
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||||
|
* targetSizePx?: number,
|
||||||
|
* marginModules?: number,
|
||||||
|
* }} [options]
|
||||||
|
* @returns {Promise<Blob>}
|
||||||
|
*/
|
||||||
|
export function generateEventQrPngBlob(slug, options = {}) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
renderEventQrToCanvas(canvas, slug, options);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Échec de la génération du PNG (canvas.toBlob a renvoyé null).'));
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerBlobDownload(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
// Laisse le temps au téléchargement de démarrer avant de libérer l'URL.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclenche le téléchargement du QR code d'un événement au format SVG.
|
||||||
|
* Nécessite un navigateur.
|
||||||
|
*
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {{
|
||||||
|
* baseUrl?: string,
|
||||||
|
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||||
|
* cellSize?: number,
|
||||||
|
* margin?: number,
|
||||||
|
* filename?: string,
|
||||||
|
* }} [options]
|
||||||
|
*/
|
||||||
|
export function downloadEventQrSvg(slug, options = {}) {
|
||||||
|
const svg = generateEventQrSvg(slug, options);
|
||||||
|
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||||
|
triggerBlobDownload(blob, options.filename || `qr-${slug}.svg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclenche le téléchargement du QR code d'un événement au format PNG.
|
||||||
|
* Nécessite un navigateur.
|
||||||
|
*
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {{
|
||||||
|
* baseUrl?: string,
|
||||||
|
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||||
|
* targetSizePx?: number,
|
||||||
|
* marginModules?: number,
|
||||||
|
* filename?: string,
|
||||||
|
* }} [options]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function downloadEventQrPng(slug, options = {}) {
|
||||||
|
const blob = await generateEventQrPngBlob(slug, options);
|
||||||
|
triggerBlobDownload(blob, options.filename || `qr-${slug}.png`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Copier ce fichier en config.js (non commité, voir .gitignore) et renseigner
|
||||||
|
// les vraies valeurs du projet Supabase. Ce fichier est chargé avant
|
||||||
|
// upload.js dans index.html.
|
||||||
|
//
|
||||||
|
// SUPABASE_ANON_KEY est la clé PUBLIQUE (anon) Supabase — jamais la
|
||||||
|
// service_role key, qui ne doit jamais être exposée côté client.
|
||||||
|
window.__PHOTOBOOTH_CONFIG__ = {
|
||||||
|
SUPABASE_URL: "https://xxxxxxxxxxxx.supabase.co",
|
||||||
|
SUPABASE_ANON_KEY: "REMPLACER_PAR_LA_CLE_ANON_PUBLIQUE",
|
||||||
|
// Doit correspondre à SUPABASE_STORAGE_BUCKET dans .env
|
||||||
|
SUPABASE_STORAGE_BUCKET: "event-photos",
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Ajouter une photo</title>
|
||||||
|
<!--
|
||||||
|
Cette page est servie pour toute URL du type /e/<slug> (le slug est lu
|
||||||
|
côté client depuis window.location.pathname, voir upload.js).
|
||||||
|
Ça suppose une règle de réécriture côté serveur (ex. nginx try_files)
|
||||||
|
qui renvoie ce fichier pour toute requête sous /e/* — à mettre en place
|
||||||
|
lors du déploiement, hors scope de ce fichier front.
|
||||||
|
-->
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="page__header">
|
||||||
|
<h1 id="event-name">Ajouter une photo</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="status-loading" class="state-block" role="status">
|
||||||
|
<p>Chargement de l'événement…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="status-error" class="state-block state-block--error" hidden role="alert">
|
||||||
|
<p id="status-error-message"></p>
|
||||||
|
<button type="button" id="retry-btn" hidden>Réessayer</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="success-block" class="state-block state-block--success" hidden role="status">
|
||||||
|
<p>Merci ! Ta photo est bien envoyée 🎉</p>
|
||||||
|
<button type="button" id="add-another-btn">Ajouter une autre photo</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form id="upload-form" class="upload-form" hidden novalidate>
|
||||||
|
<div class="field">
|
||||||
|
<label for="photo-input">Ta photo</label>
|
||||||
|
<!-- Pas d'attribut "capture" : on laisse le téléphone proposer à la
|
||||||
|
fois l'appareil photo ET la galerie (capture forcerait l'un ou
|
||||||
|
l'autre selon les navigateurs). -->
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="photo-input"
|
||||||
|
name="photo"
|
||||||
|
accept="image/*,.heic,.heif"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img id="preview" class="preview" alt="Aperçu de la photo choisie" hidden>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="nom-input">Ton prénom <span class="optional">(facultatif)</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="nom-input"
|
||||||
|
name="nom"
|
||||||
|
maxlength="60"
|
||||||
|
autocomplete="given-name"
|
||||||
|
placeholder="Ex : Camille"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="message-input">Un petit mot <span class="optional">(facultatif)</span></label>
|
||||||
|
<textarea
|
||||||
|
id="message-input"
|
||||||
|
name="message"
|
||||||
|
maxlength="300"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Ton petit mot pour l'occasion…"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="legal-notice">
|
||||||
|
En déposant une photo, vous acceptez qu'elle soit stockée temporairement
|
||||||
|
dans le cadre de cet événement et mise à disposition de l'organisateur.
|
||||||
|
Vos photos sont automatiquement supprimées 7 jours après la date de
|
||||||
|
l'événement, sans action de votre part. Aucune photo n'est utilisée à
|
||||||
|
d'autres fins, ni transmise à des tiers. Vous pouvez demander la
|
||||||
|
suppression anticipée d'une photo en contactant l'organisateur de
|
||||||
|
l'événement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p id="progress-text" class="progress-text" aria-live="polite"></p>
|
||||||
|
|
||||||
|
<button type="submit" id="submit-btn">Envoyer ma photo</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
config.js n'est PAS commité (voir .gitignore) : copier config.example.js
|
||||||
|
en config.js et renseigner les vraies valeurs Supabase avant déploiement.
|
||||||
|
Chargé en script classique (donc synchrone, avant le module) pour garantir
|
||||||
|
que window.__PHOTOBOOTH_CONFIG__ existe avant l'exécution de upload.js.
|
||||||
|
-->
|
||||||
|
<script src="config.js"></script>
|
||||||
|
<script type="module" src="upload.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
/* Page upload invité — mobile-first, sobre, sans dépendance externe */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-bg: #faf9f7;
|
||||||
|
--color-text: #2a2a28;
|
||||||
|
--color-muted: #6b6b66;
|
||||||
|
--color-accent: #8a5a44;
|
||||||
|
--color-accent-contrast: #ffffff;
|
||||||
|
--color-error-bg: #fdecea;
|
||||||
|
--color-error-text: #7a1f14;
|
||||||
|
--color-success-bg: #eaf5ec;
|
||||||
|
--color-success-text: #1e5c2d;
|
||||||
|
--radius: 10px;
|
||||||
|
--spacing: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block--error {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block--success {
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
color: var(--color-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block p {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field .optional {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="file"],
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border: 1px solid #d8d5cf;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fff;
|
||||||
|
color: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
padding: 0.55rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 3px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-notice {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
background: #f1efe9;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
min-height: 1.2em;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
min-height: 48px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-btn,
|
||||||
|
#add-another-btn {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#retry-btn {
|
||||||
|
background: var(--color-error-text);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
+503
@@ -0,0 +1,503 @@
|
|||||||
|
// Page upload invité — photobooth-qr
|
||||||
|
//
|
||||||
|
// Aucune dépendance framework. Une seule lib tierce chargée systématiquement
|
||||||
|
// (browser-image-compression, décision actée dans docs/decisions.md), plus
|
||||||
|
// heic2any chargée UNIQUEMENT si un fichier HEIC/HEIF est détecté (lazy load,
|
||||||
|
// pour ne pas alourdir la page pour les invités qui n'en ont pas besoin).
|
||||||
|
//
|
||||||
|
// Contrat backend (schéma `photobooth`, privé, jamais exposé via PostgREST —
|
||||||
|
// cf. supabase/migrations/20260916090000_create_photobooth_schema.sql) :
|
||||||
|
// - RPC "get_public_event(p_slug text)" (POST .../rpc/get_public_event) :
|
||||||
|
// { id, nom, statut, expire_at } (ou null) SANS exposer admin_token.
|
||||||
|
// - RPC "insert_public_photo(p_event_id, p_url_storage, p_nom_invite,
|
||||||
|
// p_message)" (POST .../rpc/insert_public_photo) : seul chemin d'écriture,
|
||||||
|
// revalide elle-même que l'event accepte encore les uploads.
|
||||||
|
|
||||||
|
import imageCompression from "../vendor/browser-image-compression.mjs";
|
||||||
|
|
||||||
|
const MAX_INPUT_BYTES = 15 * 1024 * 1024; // 15 Mo avant compression (cahier des charges)
|
||||||
|
const MAX_OUTPUT_BYTES = 6 * 1024 * 1024; // garde-fou après compression (cible ~2-3 Mo)
|
||||||
|
|
||||||
|
const COMPRESSION_OPTIONS = {
|
||||||
|
maxSizeMB: 2,
|
||||||
|
maxWidthOrHeight: 1920,
|
||||||
|
useWebWorker: true,
|
||||||
|
fileType: "image/jpeg",
|
||||||
|
initialQuality: 0.8,
|
||||||
|
preserveExif: false, // on ne conserve pas les métadonnées (GPS, etc.) — minimisation RGPD
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Éléments DOM
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const eventNameEl = document.getElementById("event-name");
|
||||||
|
const loadingBlock = document.getElementById("status-loading");
|
||||||
|
const errorBlock = document.getElementById("status-error");
|
||||||
|
const errorMessageEl = document.getElementById("status-error-message");
|
||||||
|
const retryBtn = document.getElementById("retry-btn");
|
||||||
|
const successBlock = document.getElementById("success-block");
|
||||||
|
const addAnotherBtn = document.getElementById("add-another-btn");
|
||||||
|
const form = document.getElementById("upload-form");
|
||||||
|
const photoInput = document.getElementById("photo-input");
|
||||||
|
const previewImg = document.getElementById("preview");
|
||||||
|
const nomInput = document.getElementById("nom-input");
|
||||||
|
const messageInput = document.getElementById("message-input");
|
||||||
|
const progressText = document.getElementById("progress-text");
|
||||||
|
const submitBtn = document.getElementById("submit-btn");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// État
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
event: null,
|
||||||
|
selectedFile: null,
|
||||||
|
compressedBlob: null,
|
||||||
|
isSubmitting: false,
|
||||||
|
lastRetryAction: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Erreurs applicatives : un code technique -> un message clair pour l'invité
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
"config-missing":
|
||||||
|
"Cette page n'est pas configurée correctement. Préviens l'organisateur de l'événement.",
|
||||||
|
"event-not-found":
|
||||||
|
"Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.",
|
||||||
|
"event-closed":
|
||||||
|
"Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !",
|
||||||
|
"file-missing": "Choisis d'abord une photo avant d'envoyer.",
|
||||||
|
"file-not-image":
|
||||||
|
"Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).",
|
||||||
|
"file-too-large":
|
||||||
|
"Cette photo est trop lourde (plus de 15 Mo). Essaie avec une autre photo.",
|
||||||
|
"heic-load-failed":
|
||||||
|
"Impossible de préparer la conversion de cette photo iPhone (HEIC). Vérifie ta connexion et réessaie, ou choisis une autre photo.",
|
||||||
|
"heic-convert-failed":
|
||||||
|
"Impossible de lire cette photo au format HEIC. Essaie de changer le format des photos dans les réglages de ton iPhone (Réglages > Appareil photo > Formats > « Le plus compatible »), ou choisis une autre photo.",
|
||||||
|
"compression-failed":
|
||||||
|
"La préparation de la photo a échoué. Réessaie, ou choisis une autre photo.",
|
||||||
|
"compressed-too-large":
|
||||||
|
"Cette photo reste trop lourde même après compression. Essaie une autre photo.",
|
||||||
|
network:
|
||||||
|
"La connexion a été coupée pendant l'envoi. Vérifie ta connexion internet et réessaie.",
|
||||||
|
"storage-upload-failed":
|
||||||
|
"L'envoi de la photo a échoué. Réessaie dans un instant.",
|
||||||
|
"db-insert-failed":
|
||||||
|
"Ta photo a bien été transmise, mais une erreur est survenue à la dernière étape. Réessaie, ou préviens l'organisateur si ça persiste.",
|
||||||
|
"server-overloaded":
|
||||||
|
"Le service est momentanément surchargé. Réessaie dans quelques minutes.",
|
||||||
|
unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Codes pour lesquels on propose un bouton "Réessayer" (échecs transitoires,
|
||||||
|
// pas la peine pour un fichier invalide ou un événement clos).
|
||||||
|
const RETRYABLE_CODES = new Set([
|
||||||
|
"network",
|
||||||
|
"storage-upload-failed",
|
||||||
|
"db-insert-failed",
|
||||||
|
"server-overloaded",
|
||||||
|
"compression-failed",
|
||||||
|
"heic-load-failed",
|
||||||
|
"heic-convert-failed",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function appError(code, cause) {
|
||||||
|
const err = new Error(code);
|
||||||
|
err.code = code;
|
||||||
|
err.cause = cause;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(err) {
|
||||||
|
if (err && err.code && ERROR_MESSAGES[err.code]) return err;
|
||||||
|
if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau
|
||||||
|
return appError("unknown", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const cfg = window.__PHOTOBOOTH_CONFIG__;
|
||||||
|
if (!cfg || !cfg.SUPABASE_URL || !cfg.SUPABASE_ANON_KEY || !cfg.SUPABASE_STORAGE_BUCKET) {
|
||||||
|
throw appError("config-missing");
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utilitaires
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||||||
|
return window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Repli simple pour de très vieux navigateurs (pas un usage cryptographique ici)
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlugFromPath() {
|
||||||
|
const match = window.location.pathname.match(/\/e\/([^/]+)\/?$/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHeicFile(file) {
|
||||||
|
const type = (file.type || "").toLowerCase();
|
||||||
|
if (type === "image/heic" || type === "image/heif") return true;
|
||||||
|
return /\.hei[cf]$/i.test(file.name || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAcceptableImageFile(file) {
|
||||||
|
if (isHeicFile(file)) return true;
|
||||||
|
if (file.type && file.type.startsWith("image/")) return true;
|
||||||
|
// Sur certains navigateurs Android, un fichier HEIC peut arriver avec un
|
||||||
|
// type MIME vide ou générique : on se rabat sur l'extension.
|
||||||
|
return /\.(jpe?g|png|gif|webp|bmp)$/i.test(file.name || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Affichage des états
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function showLoading() {
|
||||||
|
loadingBlock.hidden = false;
|
||||||
|
errorBlock.hidden = true;
|
||||||
|
successBlock.hidden = true;
|
||||||
|
form.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showForm(event) {
|
||||||
|
eventNameEl.textContent = event.nom || "Ajouter une photo";
|
||||||
|
loadingBlock.hidden = true;
|
||||||
|
errorBlock.hidden = true;
|
||||||
|
successBlock.hidden = true;
|
||||||
|
form.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(rawErr, retryAction) {
|
||||||
|
const err = normalizeError(rawErr);
|
||||||
|
loadingBlock.hidden = true;
|
||||||
|
successBlock.hidden = true;
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown;
|
||||||
|
|
||||||
|
state.lastRetryAction = retryAction || null;
|
||||||
|
const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code);
|
||||||
|
retryBtn.hidden = !canRetry;
|
||||||
|
|
||||||
|
// Erreurs bloquantes de chargement d'événement : on masque le formulaire.
|
||||||
|
// Erreurs liées à une tentative d'envoi : le formulaire reste visible.
|
||||||
|
if (["event-not-found", "event-closed", "config-missing"].includes(err.code)) {
|
||||||
|
form.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.cause) {
|
||||||
|
// Pas de jargon technique affiché à l'invité, mais on garde une trace
|
||||||
|
// en console pour le débogage.
|
||||||
|
console.error(`[upload:${err.code}]`, err.cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess() {
|
||||||
|
loadingBlock.hidden = true;
|
||||||
|
errorBlock.hidden = true;
|
||||||
|
form.hidden = true;
|
||||||
|
successBlock.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(text) {
|
||||||
|
progressText.textContent = text || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubmitting(isSubmitting) {
|
||||||
|
state.isSubmitting = isSubmitting;
|
||||||
|
submitBtn.disabled = isSubmitting;
|
||||||
|
submitBtn.textContent = isSubmitting ? "Envoi en cours…" : "Envoyer ma photo";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chargement de l'événement (vérifie existence + statut actif + non expiré)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function fetchEventPublicInfo(slug) {
|
||||||
|
const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig();
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/get_public_event`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ p_slug: slug }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("network", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 404) throw appError("event-not-found");
|
||||||
|
if (res.status >= 500) throw appError("server-overloaded");
|
||||||
|
if (!res.ok) throw appError("unknown", await safeReadText(res));
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
const event = Array.isArray(data) ? data[0] : data;
|
||||||
|
if (!event) throw appError("event-not-found");
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeReadText(res) {
|
||||||
|
try {
|
||||||
|
return await res.text();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEventActive(event) {
|
||||||
|
if (!event) return false;
|
||||||
|
if (event.statut !== "actif") return false;
|
||||||
|
if (event.expire_at && new Date(event.expire_at).getTime() <= Date.now()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initEventCheck() {
|
||||||
|
showLoading();
|
||||||
|
const slug = getSlugFromPath();
|
||||||
|
if (!slug) {
|
||||||
|
showError(appError("event-not-found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const event = await fetchEventPublicInfo(slug);
|
||||||
|
if (!isEventActive(event)) throw appError("event-closed");
|
||||||
|
state.event = event;
|
||||||
|
showForm(event);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, () => initEventCheck());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conversion HEIC (lazy load) + compression
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let heic2anyLoadPromise = null;
|
||||||
|
|
||||||
|
function loadHeic2any() {
|
||||||
|
if (window.heic2any) return Promise.resolve();
|
||||||
|
if (heic2anyLoadPromise) return heic2anyLoadPromise;
|
||||||
|
|
||||||
|
heic2anyLoadPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = new URL("../vendor/heic2any.min.js", import.meta.url).href;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => {
|
||||||
|
heic2anyLoadPromise = null;
|
||||||
|
reject(appError("heic-load-failed"));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return heic2anyLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertHeicToJpeg(file) {
|
||||||
|
await loadHeic2any();
|
||||||
|
let resultBlob;
|
||||||
|
try {
|
||||||
|
resultBlob = await window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.9 });
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("heic-convert-failed", err);
|
||||||
|
}
|
||||||
|
const blob = Array.isArray(resultBlob) ? resultBlob[0] : resultBlob;
|
||||||
|
return new File([blob], file.name.replace(/\.hei[cf]$/i, ".jpg"), { type: "image/jpeg" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeAndCompress(file) {
|
||||||
|
let workingFile = file;
|
||||||
|
if (isHeicFile(file)) {
|
||||||
|
setProgress("Conversion de la photo…");
|
||||||
|
workingFile = await convertHeicToJpeg(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress("Compression de la photo…");
|
||||||
|
let compressed;
|
||||||
|
try {
|
||||||
|
compressed = await imageCompression(workingFile, COMPRESSION_OPTIONS);
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("compression-failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compressed.size > MAX_OUTPUT_BYTES) {
|
||||||
|
throw appError("compressed-too-large");
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Envoi vers Supabase (Storage + table photos)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function uploadToStorage(eventId, blob) {
|
||||||
|
const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_STORAGE_BUCKET } = getConfig();
|
||||||
|
const path = `${eventId}/${generateId()}.jpg`;
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(
|
||||||
|
`${SUPABASE_URL}/storage/v1/object/${encodeURIComponent(SUPABASE_STORAGE_BUCKET)}/${path}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||||
|
"Content-Type": blob.type || "image/jpeg",
|
||||||
|
"x-upsert": "false",
|
||||||
|
},
|
||||||
|
body: blob,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("network", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res));
|
||||||
|
if (!res.ok) throw appError("storage-upload-failed", await safeReadText(res));
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertPhotoRow(eventId, path, nom, message) {
|
||||||
|
const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig();
|
||||||
|
|
||||||
|
// photobooth.photos n'est jamais exposée directement (schéma privé, cf.
|
||||||
|
// supabase/migrations/20260916090000_create_photobooth_schema.sql) : le
|
||||||
|
// seul chemin d'écriture est la RPC public.insert_public_photo, qui
|
||||||
|
// revalide elle-même que l'event accepte encore les uploads.
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/insert_public_photo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Prefer: "return=minimal",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_event_id: eventId,
|
||||||
|
p_url_storage: path,
|
||||||
|
p_nom_invite: nom || null,
|
||||||
|
p_message: message || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("network", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res));
|
||||||
|
if (!res.ok) throw appError("db-insert-failed", await safeReadText(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formulaire
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let previewObjectUrl = null;
|
||||||
|
|
||||||
|
photoInput.addEventListener("change", () => {
|
||||||
|
// Nouveau fichier choisi -> on invalide toute compression déjà faite.
|
||||||
|
state.compressedBlob = null;
|
||||||
|
state.selectedFile = photoInput.files[0] || null;
|
||||||
|
|
||||||
|
if (previewObjectUrl) {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
|
previewObjectUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = state.selectedFile;
|
||||||
|
if (file && file.type && file.type.startsWith("image/")) {
|
||||||
|
previewObjectUrl = URL.createObjectURL(file);
|
||||||
|
previewImg.src = previewObjectUrl;
|
||||||
|
previewImg.hidden = false;
|
||||||
|
previewImg.onerror = () => {
|
||||||
|
previewImg.hidden = true;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Pas d'aperçu fiable pour les HEIC sur la plupart des navigateurs.
|
||||||
|
previewImg.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitPhoto() {
|
||||||
|
if (state.isSubmitting) return;
|
||||||
|
if (!state.event) {
|
||||||
|
showError(appError("event-closed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = photoInput.files[0];
|
||||||
|
if (!file) return showError(appError("file-missing"));
|
||||||
|
if (!isAcceptableImageFile(file)) return showError(appError("file-not-image"));
|
||||||
|
if (file.size > MAX_INPUT_BYTES) return showError(appError("file-too-large"));
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
let blob = state.compressedBlob;
|
||||||
|
if (!blob || state.selectedFile !== file) {
|
||||||
|
blob = await normalizeAndCompress(file);
|
||||||
|
state.compressedBlob = blob;
|
||||||
|
state.selectedFile = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress("Envoi de la photo…");
|
||||||
|
const path = await uploadToStorage(state.event.id, blob);
|
||||||
|
|
||||||
|
setProgress("Dernière étape…");
|
||||||
|
await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim());
|
||||||
|
|
||||||
|
setProgress("");
|
||||||
|
showSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setProgress("");
|
||||||
|
showError(err, () => submitPhoto());
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
submitPhoto();
|
||||||
|
});
|
||||||
|
|
||||||
|
retryBtn.addEventListener("click", () => {
|
||||||
|
if (state.lastRetryAction) state.lastRetryAction();
|
||||||
|
});
|
||||||
|
|
||||||
|
addAnotherBtn.addEventListener("click", () => {
|
||||||
|
form.reset();
|
||||||
|
state.selectedFile = null;
|
||||||
|
state.compressedBlob = null;
|
||||||
|
previewImg.hidden = true;
|
||||||
|
if (previewObjectUrl) {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
|
previewObjectUrl = null;
|
||||||
|
}
|
||||||
|
successBlock.hidden = true;
|
||||||
|
form.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Démarrage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
initEventCheck();
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Librairies vendorisées
|
||||||
|
|
||||||
|
Ces fichiers sont copiés tels quels (pas de build/bundler dans ce projet) et
|
||||||
|
ne doivent pas être modifiés manuellement. Pour mettre à jour, retélécharger
|
||||||
|
depuis la source indiquée.
|
||||||
|
|
||||||
|
## browser-image-compression
|
||||||
|
|
||||||
|
- Version : 2.0.2 (dernière version publiée au 15/09/2026)
|
||||||
|
- Licence : MIT
|
||||||
|
- Source : `https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.mjs`
|
||||||
|
- Usage : compression + correction d'orientation EXIF, obligatoire avant tout
|
||||||
|
upload (décision actée dans `docs/decisions.md`). Chargé systématiquement
|
||||||
|
via `import` dans `src/e/upload.js`.
|
||||||
|
|
||||||
|
## heic2any
|
||||||
|
|
||||||
|
- Version : 0.0.4 (dernière version publiée au 15/09/2026)
|
||||||
|
- Licence : MIT
|
||||||
|
- Source : `https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js`
|
||||||
|
- Usage : conversion HEIC/HEIF (photos iPhone) vers JPEG avant compression.
|
||||||
|
Fichier volumineux (~1,3 Mo, décodeur HEIF en WASM) : **chargé uniquement à
|
||||||
|
la demande** (lazy load via injection de `<script>`), seulement si un
|
||||||
|
fichier HEIC/HEIF est détecté côté client — cf. `loadHeic2any()` dans
|
||||||
|
`src/e/upload.js`. La majorité des invités (Android, ou iPhone dont Safari
|
||||||
|
a déjà transcodé le fichier en JPEG lors de la sélection) ne téléchargeront
|
||||||
|
jamais ce fichier.
|
||||||
+9
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2237
File diff suppressed because it is too large
Load Diff
@@ -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