maintenance: allow /partage through gate, fix fragment routing, add visibility table in admin

Extract shared filepond logic into src/FilepondHandler.php class.
Admin filepond endpoints delegate to the handler after AdminAuth check.
New partage filepond endpoints at /partage/actions/filepond/ verify
share_active session flag + CSRF token, no admin auth required.

JS reads filepond-base meta tag to determine endpoint path:
- Admin pages: /admin/actions/filepond (via head.php isAdmin check)
- Partage form: /partage/actions/filepond (explicit meta)

partage/index.php sets share_active = true on form render, cleans up on
successful submit. Partage process endpoint rate-limited to 30/5min per
session. No nginx changes needed — /partage/ location already handles
PHP without auth_basic.
This commit is contained in:
Pontoporeia
2026-05-12 15:19:32 +02:00
parent da153fc604
commit 6f7a02244f
22 changed files with 15010 additions and 532 deletions

View File

@@ -1135,6 +1135,69 @@ th.admin-ap-col {
.admin-import-log__item--error::before { content: '✗'; color: var(--error); }
/* ── Paramètres page (flat, semantic) ──────────────────────────────────── */
.param-access-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin-bottom: var(--space-m);
font-size: var(--step--1);
border: 1px solid var(--border-primary);
border-radius: var(--radius);
overflow: hidden;
}
.param-access-table caption {
caption-side: top;
font-weight: 600;
margin-bottom: var(--space-2xs);
text-align: left;
}
.param-access-table th,
.param-access-table td {
padding: var(--space-2xs) var(--space-s);
text-align: left;
border-bottom: 1px solid var(--border-primary);
}
.param-access-table th:not(:last-child),
.param-access-table td:not(:last-child) {
border-right: 1px solid var(--border-primary);
}
.param-access-table thead th {
background: var(--bg-secondary);
font-weight: 600;
}
.param-access-table thead tr:first-child th:first-child {
border-top-left-radius: var(--radius);
}
.param-access-table thead tr:first-child th:last-child {
border-top-right-radius: var(--radius);
}
.param-access-table tbody tr:last-child td:first-child {
border-bottom-left-radius: var(--radius);
}
.param-access-table tbody tr:last-child td:last-child {
border-bottom-right-radius: var(--radius);
}
.param-access-table tbody tr:last-child td {
border-bottom: none;
}
.param-access-yes {
color: var(--accent-green);
}
.param-access-no {
color: var(--text-secondary);
}
.param-maintenance-row {
display: flex;
align-items: center;

View File

@@ -16,9 +16,7 @@
* 8. Edit mode: loads existing files via data-existing-files JSON + server.load.
*/
(function () {
"use strict";
(() => {
// ── Per-queue-type configuration ────────────────────────────────────
// Single source of truth for validation. These specificatons are also
// reflected in the PHP-synthesised accept attributes on inputs.
@@ -26,15 +24,29 @@
var QUEUE_CONFIG = {
tfe: {
acceptedFileTypes: [
"image/jpeg", "image/png", "image/gif", "image/webp",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"video/mp4", "video/webm", "video/ogg", "video/quicktime",
"audio/mpeg", "audio/ogg", "audio/flac", "audio/x-wav", "audio/aac", "audio/mp4",
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"audio/mpeg",
"audio/ogg",
"audio/flac",
"audio/x-wav",
"audio/aac",
"audio/mp4",
"text/vtt",
"application/zip", "application/x-tar", "application/gzip"
"application/zip",
"application/x-tar",
"application/gzip",
],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "PDF, Images, Vidéos, Audio, VTT, Archives",
fileValidateTypeLabelExpectedTypes:
"PDF, Images, Vidéos, Audio, VTT, Archives",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
@@ -42,18 +54,32 @@
// Per-extension size limits: certain types get higher caps.
perExtensionMaxSize: {
pdf: "100MB",
mp4: "2GB", webm: "2GB", ogv: "2GB", mov: "2GB",
mp3: "2GB", ogg: "2GB", oga: "2GB", wav: "2GB", flac: "2GB", aac: "2GB", m4a: "2GB"
}
mp4: "2GB",
webm: "2GB",
ogv: "2GB",
mov: "2GB",
mp3: "2GB",
ogg: "2GB",
oga: "2GB",
wav: "2GB",
flac: "2GB",
aac: "2GB",
m4a: "2GB",
},
},
annexe: {
acceptedFileTypes: ["application/pdf", "application/zip", "application/x-tar", "application/gzip"],
acceptedFileTypes: [
"application/pdf",
"application/zip",
"application/x-tar",
"application/gzip",
],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "PDF, ZIP, TAR, GZ",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: true
allowMultiple: true,
},
cover: {
acceptedFileTypes: ["image/jpeg", "image/png", "image/webp"],
@@ -62,7 +88,7 @@
maxFileSize: "20MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: false
allowMultiple: false,
},
note_intention: {
acceptedFileTypes: ["application/pdf"],
@@ -71,7 +97,7 @@
maxFileSize: "100MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: false
allowMultiple: false,
},
};
@@ -85,7 +111,13 @@
if (!m) return 0;
var val = parseFloat(m[1]);
var unit = m[2].toUpperCase();
var mult = {B: 1, KB: 1024, MB: 1024*1024, GB: 1024*1024*1024, TB: 1024*1024*1024*1024};
var mult = {
B: 1,
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
};
return Math.round(val * (mult[unit] || 1));
}
@@ -102,7 +134,16 @@
*/
function getCsrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
return meta ? meta.getAttribute("content") : "";
}
/**
* Get the FilePond endpoint base URL from meta tag.
* Defaults to /admin/actions/filepond/ for backward compat.
*/
function getFilepondBase() {
var meta = document.querySelector('meta[name="filepond-base"]');
return meta ? meta.getAttribute("content") : "/admin/actions/filepond";
}
// ── Order serialization ───────────────────────────────────────────────
@@ -120,10 +161,14 @@
var files = pond.getFiles();
// Remove old order input and all queue_file hidden inputs for this queueType
var oldOrder = form.querySelector("input[name='queue_order[" + queueType + "]']");
var oldOrder = form.querySelector(
`input[name='queue_order[${queueType}]']`,
);
if (oldOrder) oldOrder.remove();
var oldHidden = form.querySelectorAll("input[name='queue_file[" + queueType + "][]'][data-filepond-id]");
var oldHidden = form.querySelectorAll(
`input[name='queue_file[${queueType}][]'][data-filepond-id]`,
);
for (var h = 0; h < oldHidden.length; h++) {
oldHidden[h].remove();
}
@@ -140,7 +185,7 @@
ids.push(id);
var hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "queue_file[" + queueType + "][]";
hidden.name = `queue_file[${queueType}][]`;
hidden.value = id;
hidden.setAttribute("data-filepond-id", "1");
form.appendChild(hidden);
@@ -151,7 +196,7 @@
if (ids.length > 0) {
var orderInput = document.createElement("input");
orderInput.type = "hidden";
orderInput.name = "queue_order[" + queueType + "]";
orderInput.name = `queue_order[${queueType}]`;
orderInput.value = ids.join("|");
form.appendChild(orderInput);
}
@@ -161,65 +206,94 @@
function buildServerConfig(queueType) {
var csrfToken = getCsrfToken();
console.log('[filepond] buildServerConfig | queueType=' + queueType + ' | csrfToken=' + (csrfToken ? csrfToken.substring(0, 8) + '...' : 'MISSING'));
console.log(
"[filepond] buildServerConfig | queueType=" +
queueType +
" | csrfToken=" +
(csrfToken ? `${csrfToken.substring(0, 8)}...` : "MISSING"),
);
var base = getFilepondBase();
console.log(
"[filepond] buildServerConfig | queueType=" +
queueType +
" | csrfToken=" +
(csrfToken ? `${csrfToken.substring(0, 8)}...` : "MISSING") +
" | base=" +
base,
);
return {
process: {
url: '/admin/actions/filepond/process.php',
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
ondata: function (formData) {
formData.append('queue_type', queueType);
console.log('[filepond] process ondata | queueType=' + queueType);
url: `${base}/process.php`,
method: "POST",
headers: { "X-CSRF-Token": csrfToken },
ondata: (formData) => {
formData.append("queue_type", queueType);
console.log(`[filepond] process ondata | queueType=${queueType}`);
return formData;
},
onload: function (response) {
onload: (response) => {
var id = response.trim();
console.log('[filepond] process onload | serverId=' + id);
console.log(`[filepond] process onload | serverId=${id}`);
return id; // file_id stored as serverId
},
onerror: function (response) {
console.error('[filepond] process onerror | status=' + response.status + ' | body=' + response);
onerror: (response) => {
console.error(
"[filepond] process onerror | status=" +
response.status +
" | body=" +
response,
);
return response;
},
},
revert: {
url: '/admin/actions/filepond/revert.php',
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
onload: function () { console.log('[filepond] revert OK'); },
onerror: function (r) { console.error('[filepond] revert ERROR | body=' + r); },
url: `${base}/revert.php`,
method: "DELETE",
headers: { "X-CSRF-Token": csrfToken },
onload: () => {
console.log("[filepond] revert OK");
},
onerror: (r) => {
console.error(`[filepond] revert ERROR | body=${r}`);
},
},
load: '/admin/actions/filepond/load.php?id=',
load: `${base}/load.php?id=`,
// FilePond appends the source value (db_id) automatically
remove: function (source, load, error) {
console.log('[filepond] remove called | db_id=' + source);
fetch('/admin/actions/filepond/remove.php', {
method: 'DELETE',
remove: (source, load, error) => {
console.log(`[filepond] remove called | db_id=${source}`);
fetch(`${base}/remove.php`, {
method: "DELETE",
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ db_id: source }),
})
.then(function (r) {
console.log('[filepond] remove response | ok=' + r.ok + ' | status=' + r.status);
r.ok ? load() : error('Erreur suppression');
})
.catch(function (e) {
console.error('[filepond] remove fetch error', e);
error('Erreur réseau');
});
.then((r) => {
console.log(
"[filepond] remove response | ok=" +
r.ok +
" | status=" +
r.status,
);
r.ok ? load() : error("Erreur suppression");
})
.catch((e) => {
console.error("[filepond] remove fetch error", e);
error("Erreur réseau");
});
},
};
}
// ── FilePond configuration per queue type ─────────────────────────────
function buildFilePondOptions(queueType, input) {
function buildFilePondOptions(queueType, _input) {
var cfg = QUEUE_CONFIG[queueType];
if (!cfg) return null;
@@ -236,13 +310,15 @@
// ── Native FilePond validation ──
acceptedFileTypes: cfg.acceptedFileTypes,
labelFileTypeNotAllowed: cfg.labelFileTypeNotAllowed,
fileValidateTypeLabelExpectedTypes: cfg.fileValidateTypeLabelExpectedTypes,
fileValidateTypeLabelExpectedTypes:
cfg.fileValidateTypeLabelExpectedTypes,
maxFileSize: cfg.maxFileSize,
labelMaxFileSizeExceeded: cfg.labelMaxFileSizeExceeded,
labelMaxFileSize: cfg.labelMaxFileSize,
// ── French labels ──
labelIdle: "Glissez-déposez vos fichiers ou <span class='filepond--label-action'>Parcourir</span>",
labelIdle:
"Glissez-déposez vos fichiers ou <span class='filepond--label-action'>Parcourir</span>",
labelFileProcessing: "Chargement en cours",
labelFileProcessingComplete: "Chargement terminé",
labelFileProcessingAborted: "Chargement annulé",
@@ -258,20 +334,20 @@
// ── Per-extension size validation ──────────────────────────────
// Uses fileValidateSizeFilterItem if the FileValidateSize plugin supports it.
// Falls back to beforeAddFile for silent rejection (the plugin shows the error).
fileValidateSizeFilterItem: function (item) {
fileValidateSizeFilterItem: (item) => {
var ext = getExt(item.filename);
if (ext && perExtMax[ext]) {
return parseSize(perExtMax[ext]); // per-extension cap for this item
}
return parseSize(cfg.maxFileSize); // queue default
return parseSize(cfg.maxFileSize); // queue default
},
// Fallback: if fileValidateSizeFilterItem is not available,
// beforeAddFile enforces per-extension limits (silent rejection).
beforeAddFile: function (item) {
beforeAddFile: (item) => {
// This check is redundant if fileValidateSizeFilterItem works,
// but serves as a fallback.
if (typeof item.file === 'undefined') return true;
if (typeof item.file === "undefined") return true;
var f = item.file;
var ext = getExt(f.name);
if (ext && perExtMax[ext]) {
@@ -284,13 +360,21 @@
},
// ── Order serialization on add/remove/reorder ──
onaddfile: function () { syncOrderInput(queueType, this); },
onremovefile: function () { syncOrderInput(queueType, this); },
onreorderfiles: function () { syncOrderInput(queueType, this); },
onupdatefiles: function () { syncOrderInput(queueType, this); },
onaddfile: function () {
syncOrderInput(queueType, this);
},
onremovefile: function () {
syncOrderInput(queueType, this);
},
onreorderfiles: function () {
syncOrderInput(queueType, this);
},
onupdatefiles: function () {
syncOrderInput(queueType, this);
},
// Re-sync after async upload completes (serverId is now set)
onprocessfile: function (error, item) {
onprocessfile: function (error, _item) {
if (!error) syncOrderInput(queueType, this);
},
};
@@ -302,8 +386,8 @@
* Upgrade .tfe-file-picker inputs to FilePond instances.
* Called on page load and after HTMX swaps.
*/
window.XamxamInitFilePonds = function () {
document.querySelectorAll(".tfe-file-picker").forEach(function (input) {
window.XamxamInitFilePonds = () => {
document.querySelectorAll(".tfe-file-picker").forEach((input) => {
// Canonical duplicate check: FilePond.find() is the authoritative source
if (FilePond.find(input)) return;
@@ -315,7 +399,14 @@
if (!options) return;
var pond = FilePond.create(input, options);
console.log('[filepond] Created instance | queueType=' + queueType + ' | inputId=' + (input.id || 'none') + ' | inputName=' + (input.getAttribute('name') || input.name || '?'));
console.log(
"[filepond] Created instance | queueType=" +
queueType +
" | inputId=" +
(input.id || "none") +
" | inputName=" +
(input.getAttribute("name") || input.name || "?"),
);
// Initial order serialization
syncOrderInput(queueType, pond);
@@ -323,13 +414,13 @@
// ── Edit mode: load existing files ──
var existingFiles = [];
try {
existingFiles = JSON.parse(input.dataset.existingFiles || '[]');
existingFiles = JSON.parse(input.dataset.existingFiles || "[]");
} catch (_) {}
if (existingFiles.length) {
pond.addFiles(existingFiles.map(function (f) {
return { source: f.source, options: f.options };
}));
pond.addFiles(
existingFiles.map((f) => ({ source: f.source, options: f.options })),
);
}
});
};
@@ -340,7 +431,7 @@
*/
function destroyFilePondsIn(el) {
if (!el) return;
el.querySelectorAll(".tfe-file-picker").forEach(function (input) {
el.querySelectorAll(".tfe-file-picker").forEach((input) => {
var pond = FilePond.find(input);
if (pond) {
try {
@@ -349,9 +440,15 @@
if (form) {
var queueType = input.dataset.queueType || null;
if (queueType) {
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
var orderInput = form.querySelector(
`input[name='queue_order[${queueType}]']`,
);
if (orderInput) orderInput.remove();
var hiddenInputs = form.querySelectorAll("input[name='queue_file[" + queueType + "][]'][data-filepond-id]");
var hiddenInputs = form.querySelectorAll(
"input[name='queue_file[" +
queueType +
"][]'][data-filepond-id]",
);
for (var h = 0; h < hiddenInputs.length; h++) {
hiddenInputs[h].remove();
}
@@ -379,26 +476,38 @@
// ── Bootstrap ─────────────────────────────────────────────────────────
// Global FilePond event listeners for debugging
document.addEventListener('FilePond:processfile', function (e) {
console.log('[filepond:event] processfile | id=' + (e.detail.file ? e.detail.file.serverId : '') + ' | error=' + (e.detail.error || 'none'));
document.addEventListener("FilePond:processfile", (e) => {
console.log(
"[filepond:event] processfile | id=" +
(e.detail.file ? e.detail.file.serverId : "") +
" | error=" +
(e.detail.error || "none"),
);
});
document.addEventListener('FilePond:processfilestart', function (e) {
console.log('[filepond:event] processfilestart | filename=' + (e.detail.file ? e.detail.file.filename : '?'));
document.addEventListener("FilePond:processfilestart", (e) => {
console.log(
"[filepond:event] processfilestart | filename=" +
(e.detail.file ? e.detail.file.filename : "?"),
);
});
document.addEventListener('FilePond:processfileprogress', function (e) {
document.addEventListener("FilePond:processfileprogress", (e) => {
var pct = e.detail.progress;
if (pct && (pct === 0 || pct === 1 || Math.floor(pct * 100) % 25 === 0)) {
console.log('[filepond:event] processfileprogress | pct=' + Math.floor(pct * 100) + '%');
console.log(
"[filepond:event] processfileprogress | pct=" +
Math.floor(pct * 100) +
"%",
);
}
});
document.addEventListener('FilePond:processfileabort', function (e) {
console.log('[filepond:event] processfileabort');
document.addEventListener("FilePond:processfileabort", (_e) => {
console.log("[filepond:event] processfileabort");
});
document.addEventListener('FilePond:processfilerevert', function (e) {
console.log('[filepond:event] processfilerevert');
document.addEventListener("FilePond:processfilerevert", (_e) => {
console.log("[filepond:event] processfilerevert");
});
document.addEventListener('FilePond:error', function (e) {
console.error('[filepond:event] error', e.detail);
document.addEventListener("FilePond:error", (e) => {
console.error("[filepond:event] error", e.detail);
});
// Register FilePond plugins (idempotent)
@@ -417,13 +526,13 @@
if (window.htmx) {
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
window.htmx.on("htmx:afterSwap", function () {
window.htmx.on("htmx:afterSwap", () => {
window.XamxamInitFilePonds();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
window.XamxamInitFilePonds();
});
} else {
@@ -431,15 +540,14 @@
}
// ── Mark form dirty on FilePond changes (beforeunload guard) ─────────
document.addEventListener("FilePond:addfile", function () {
document.addEventListener("FilePond:addfile", () => {
window.__xamxamDirty = true;
});
document.addEventListener("submit", function (e) {
document.addEventListener("submit", (e) => {
var form = e.target;
if (form && form.hasAttribute && form.hasAttribute("data-beforeunload-guard")) {
if (form?.hasAttribute?.("data-beforeunload-guard")) {
window.__xamxamDirty = false;
}
});
})();