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
+469
View File
@@ -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();