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