Ajoute la V1 fonctionnelle : upload invité, galerie admin, QR code, backend Supabase

Schéma dédié `photobooth` (jamais exposé via PostgREST, accès exclusivement
via fonctions SECURITY DEFINER dans public) avec RLS, policies Storage sur
bucket privé event-photos, Edge Functions admin-gallery et expire-events,
et job pg_cron d'expiration J+7. Le tout déployé et testé en conditions
réelles sur le projet Supabase kevin-lecou-hub avant relecture sécurité
(faille d'abus sur insert_public_photo corrigée en cours de route).

Côté front : page upload invité (compression + HEIC + RGPD), galerie admin
avec export ZIP, générateur de QR code autonome. Workflow n8n de purge
Storage J+7 fourni (à activer manuellement côté n8n).
This commit is contained in:
2026-09-16 11:32:31 +02:00
parent 2007eab006
commit 9b1461978b
37 changed files with 6255 additions and 4 deletions
+12
View File
@@ -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",
};
+102
View File
@@ -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&nbsp;! 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
View File
@@ -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
View File
@@ -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();