filepond: implement async server-ID upload architecture with nested queue support + PeerTube integration

Replace `storeAsFile:true` with a full async FilePond round-trip pipeline using opaque server-side file IDs.

* Added 4 new PHP endpoints under `/admin/actions/filepond/`:

  * `process.php` — upload/process single file and return opaque `file_id`
  * `revert.php` — delete pending tmp uploads before form submit
  * `load.php` — stream existing files by DB ID for FilePond preload
  * `remove.php` — soft-delete `thesis_files` rows
* `process.php` improvements:

  * accept arbitrary FilePond field names instead of hardcoded `file`
  * support PHP-nested multi-file queue inputs (`queue_file[tfe][]`)
  * explicit unwrapping of nested `$_FILES` structures
  * add `audio/mp3` to audio + `peertube_audio` MIME whitelists
  * immediate upload of `peertube_*` files to PeerTube, returning `peertube:{uuid}` IDs
  * extensive `error_log()` instrumentation for request, CSRF, MIME, upload, and save stages
* `revert.php` now accepts `peertube:` IDs without local cleanup
* `ThesisFileHandler`:

  * add `handleFilePondQueueFiles()` + `handleFilePondSingleFile()`
  * process async uploads from `storage/tmp/filepond/` via opaque `file_id`
  * inline handling of `peertube:{uuid}` IDs with direct `thesis_files` insertion
  * remove obsolete deferred PeerTube queue-processing flow
* `ThesisCreateController` + `ThesisEditController`:

  * gate async path behind `filepond_mode=1`
  * preserve legacy multipart flow as fallback
* `file-upload-filepond.js`:

  * remove `storeAsFile:true`
  * add `buildServerConfig()` for async endpoint wiring
  * fix `syncOrderInput()` to use `serverId`
  * add `onprocessfile` hook
  * add `fileValidateSizeFilterItem` for per-extension size caps
  * preload existing uploads via `data-existing-files` + `server.load`
  * replace static `INPUT_ID_TO_TYPE` map with `data-queue-type`
  * add extensive `console.log()` debugging across upload pipeline stages
* `upload-progress.js`:

  * block form submission while uploads are pending
  * update `collectFileNames()` to read processed FilePond items
* Templates/layout:

  * add `data-queue-type`
  * add `data-existing-files`
  * add global CSRF meta tag outside admin-only context
  * add `filepond_mode` hidden input
  * add CSRF token/meta support for partage pages
  * move website URL field below file upload block
* `.gitignore`: exclude `storage/tmp/` from version control
This commit is contained in:
Pontoporeia
2026-05-11 20:11:31 +02:00
parent b56d073210
commit 2e9ebfc684
18 changed files with 1342 additions and 261 deletions

View File

@@ -1,19 +1,19 @@
/**
* file-upload-filepond.js
*
* Thin FilePond wrapper — replaces the old custom file-upload-queue.js.
* FilePond wrapper with async server round-trip architecture.
*
* Architecture:
* 1. Each <input type="file" class="tfe-file-picker"> is upgraded to a FilePond instance.
* 2. FilePond handles drag-to-reorder, thumbnails, remove, validation — zero custom DOM.
* 3. storeAsFile: true preserves native multipart form submission.
* Server receives files via $_FILES indexed by each input's name attribute
* (e.g. queue_file[tfe][], queue_file[video][], etc.).
* 4. Type + size validation: via native FilePond options + FileValidateType/Size plugins.
* beforeAddFile is used ONLY for hybrid validation (e.g. per-type size limits)
* and returns true/false per the FilePond API contract.
* 5. Order serialization: hidden inputs track file order from pond.getFiles().
* 6. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs.
* 3. Async upload: files are POSTed to /admin/actions/filepond/process.php immediately.
* The server returns a file_id stored as item.serverId.
* 4. Form submit sends only file_ids (tiny payload), not the files themselves.
* 5. Type + size validation: via native FilePond options + FileValidateType/Size plugins
* plus fileValidateSizeFilterItem for per-extension size caps.
* 6. Order serialization: hidden inputs track file order using serverId (not filename).
* 7. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs.
* 8. Edit mode: loads existing files via data-existing-files JSON + server.load.
*/
(function () {
@@ -119,19 +119,6 @@
},
};
// Map input id → queue type
var INPUT_ID_TO_TYPE = {
"tfe-files-input": "tfe",
"tfe-files-input-2": "tfe",
"video-files-input": "video",
"audio-files-input": "audio",
"annexe-files-input": "annexe",
"couverture": "cover",
"note_intention": "note_intention",
"peertube-video-input": "peertube_video",
"peertube-audio-input": "peertube_audio",
};
// ── Helpers ───────────────────────────────────────────────────────────
/**
@@ -154,37 +141,124 @@
return m ? m[1].toLowerCase() : "";
}
/**
* Get the CSRF token from the meta tag.
*/
function getCsrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
// ── Order serialization ───────────────────────────────────────────────
/**
* Create/update a hidden input that serializes the file order for a queue.
* Name: queue_order[<queueType>]
* Value: pipe-separated list of file names.
* Name: queue_file[<queueType>][] for each file_id.
* Name: queue_order[<queueType>] for the pipe-separated order.
*/
function syncOrderInput(queueType, pond) {
if (!pond || !pond.element) return;
var form = pond.element.closest("form");
if (!form) return;
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
var files = pond.getFiles();
if (files.length === 0) {
if (orderInput) orderInput.remove();
return;
// Remove old order input and all queue_file hidden inputs for this 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]");
for (var h = 0; h < oldHidden.length; h++) {
oldHidden[h].remove();
}
var names = [];
if (files.length === 0) return;
// Create hidden inputs per file: queue_file[<queueType>][] = serverId
var ids = [];
for (var i = 0; i < files.length; i++) {
names.push(files[i].filename || files[i].file.name);
var f = files[i];
// Only include files that have been uploaded and have a serverId
var id = f.serverId || null;
if (id) {
ids.push(id);
var hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "queue_file[" + queueType + "][]";
hidden.value = id;
hidden.setAttribute("data-filepond-id", "1");
form.appendChild(hidden);
}
}
if (!orderInput) {
orderInput = document.createElement("input");
// Create order input
if (ids.length > 0) {
var orderInput = document.createElement("input");
orderInput.type = "hidden";
orderInput.name = "queue_order[" + queueType + "]";
orderInput.value = ids.join("|");
form.appendChild(orderInput);
}
orderInput.value = names.join("|");
}
// ── Server config builder ─────────────────────────────────────────────
function buildServerConfig(queueType) {
var csrfToken = getCsrfToken();
console.log('[filepond] buildServerConfig | queueType=' + queueType + ' | csrfToken=' + (csrfToken ? csrfToken.substring(0, 8) + '...' : 'MISSING'));
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);
return formData;
},
onload: function (response) {
var id = response.trim();
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);
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); },
},
load: '/admin/actions/filepond/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',
headers: {
'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');
});
},
};
}
// ── FilePond configuration per queue type ─────────────────────────────
@@ -208,8 +282,9 @@
return {
allowMultiple: cfg.allowMultiple,
allowReorder: true,
allowProcess: false,
storeAsFile: true,
// ── Async server model (replaces storeAsFile + allowProcess: false) ──
server: buildServerConfig(queueType),
// ── Native FilePond validation ──
acceptedFileTypes: acceptedFileTypes,
@@ -233,17 +308,28 @@
labelButtonRetryItemLoad: "Réessayer",
labelButtonProcessItem: "Charger",
// ── Per-extension size validation (hybrid: FilePond validates global maxFileSize,
// beforeAddFile enforces per-extension limits via false return) ──
// ── 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) {
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
},
// Fallback: if fileValidateSizeFilterItem is not available,
// beforeAddFile enforces per-extension limits (silent rejection).
beforeAddFile: function (item) {
// This check is redundant if fileValidateSizeFilterItem works,
// but serves as a fallback.
if (typeof item.file === 'undefined') return true;
var f = item.file;
var ext = getExt(f.name);
if (ext && perExtMax[ext]) {
var limit = parseSize(perExtMax[ext]);
if (limit > 0 && f.size > limit) {
// Return false per FilePond API contract — the FileValidateSize
// plugin sets the error state via maxFileSize, but per-extension
// cap violations must be rejected here.
return false;
}
}
@@ -255,13 +341,14 @@
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) {
if (!error) syncOrderInput(queueType, this);
},
};
}
// ── Instance tracking ────────────────────────────────────────────────
var _ponds = {};
// ── Public API ────────────────────────────────────────────────────────
/**
@@ -273,25 +360,30 @@
// Canonical duplicate check: FilePond.find() is the authoritative source
if (FilePond.find(input)) return;
var id = input.id;
var queueType = INPUT_ID_TO_TYPE[id];
if (!queueType) {
queueType = input.dataset.queueType || null;
}
// Queue type: always from data-queue-type attribute
var queueType = input.dataset.queueType || null;
if (!queueType) return;
var options = buildFilePondOptions(queueType, input);
if (!options) return;
options.name = input.getAttribute("name") || input.name || "";
var pond = FilePond.create(input, options);
console.log('[filepond] Created instance | queueType=' + queueType + ' | inputId=' + (input.id || 'none') + ' | inputName=' + (input.getAttribute('name') || input.name || '?'));
var key = id || queueType;
_ponds[key] = pond;
// Initial order serialization (for existing files in edit mode — none expected)
// Initial order serialization
syncOrderInput(queueType, pond);
// ── Edit mode: load existing files ──
var existingFiles = [];
try {
existingFiles = JSON.parse(input.dataset.existingFiles || '[]');
} catch (_) {}
if (existingFiles.length) {
pond.addFiles(existingFiles.map(function (f) {
return { source: f.source, options: f.options };
}));
}
});
};
@@ -305,23 +397,22 @@
var pond = FilePond.find(input);
if (pond) {
try {
// Remove order input before destroying
// Remove order/hidden inputs before destroying
var form = input.closest("form");
if (form) {
var id = input.id;
var queueType = INPUT_ID_TO_TYPE[id] || input.dataset.queueType || null;
var queueType = input.dataset.queueType || null;
if (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]");
for (var h = 0; h < hiddenInputs.length; h++) {
hiddenInputs[h].remove();
}
}
}
pond.destroy();
} catch (_) {}
}
// Clean up tracking
if (input.id && _ponds[input.id]) {
delete _ponds[input.id];
}
});
}
@@ -340,6 +431,29 @@
// ── 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:processfilestart', function (e) {
console.log('[filepond:event] processfilestart | filename=' + (e.detail.file ? e.detail.file.filename : '?'));
});
document.addEventListener('FilePond:processfileprogress', function (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) + '%');
}
});
document.addEventListener('FilePond:processfileabort', function (e) {
console.log('[filepond:event] processfileabort');
});
document.addEventListener('FilePond:processfilerevert', function (e) {
console.log('[filepond:event] processfilerevert');
});
document.addEventListener('FilePond:error', function (e) {
console.error('[filepond:event] error', e.detail);
});
// Register FilePond plugins (idempotent)
if (typeof FilePondPluginFileValidateType !== "undefined") {
FilePond.registerPlugin(FilePondPluginFileValidateType);

View File

@@ -33,7 +33,7 @@
function collectFileNames() {
const names = [];
// Check raw <input type="file"> elements (non-FilePond or FilePond-managed with storeAsFile)
// Check raw <input type="file"> elements (non-FilePond)
const inputs = form.querySelectorAll('input[type="file"]');
for (const fi of inputs) {
if (fi.files) {
@@ -42,8 +42,7 @@
}
}
}
// Also check FilePond instances directly (their storeAsFile hidden inputs may not
// have .files populated yet when the submit event fires)
// Read processed file names from FilePond instances (async mode)
if (typeof FilePond !== 'undefined') {
const pondInputs = form.querySelectorAll('.tfe-file-picker');
for (const pi of pondInputs) {
@@ -51,8 +50,11 @@
if (pond) {
const pondFiles = pond.getFiles();
for (const pf of pondFiles) {
const name = pf.filename || (pf.file && pf.file.name);
if (name) names.push(name);
// Only count successfully uploaded files (have serverId)
if (pf.serverId) {
const name = pf.filename || (pf.file && pf.file.name) || pf.serverId;
if (name) names.push(name);
}
}
}
}
@@ -61,6 +63,32 @@
}
form.addEventListener('submit', function (e) {
// ── Guard: block submit if any FilePond item is still uploading ──
if (typeof FilePond !== 'undefined') {
let stillUploading = false;
const pondInputs = form.querySelectorAll('.tfe-file-picker');
for (const pi of pondInputs) {
const pond = FilePond.find(pi);
if (pond) {
const pondFiles = pond.getFiles();
for (const pf of pondFiles) {
if (pf.status === FilePond.FileStatus.PROCESSING ||
pf.status === FilePond.FileStatus.IDLE) {
stillUploading = true;
break;
}
}
}
if (stillUploading) break;
}
if (stillUploading) {
e.preventDefault();
progressLabel.textContent = 'Veuillez attendre la fin du téléversement…';
progressWrap.style.display = '';
return;
}
}
const fileNames = collectFileNames();
if (!fileNames.length) return;