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