Guard no-JS file uploads: disabled filepond_mode by default, server-side fallback

The partage/admin form had a hardcoded filepond_mode=1 hidden input,
so without JavaScript the server always entered the FilePond async
path — which found no hex IDs and silently dropped all files.

Three-layer fix:
1. HTML: filepond_mode input starts disabled with value=0; JS enables
   it and sets value=1 on DOMContentLoaded (and after HTMX swaps).
   Disabled inputs aren't submitted → server gets no filepond_mode
   → naturally falls to legacy  path.
2. JS: enableFilepondMode() called on page load and hx:afterSwap so
   FilePond-enhanced forms always send filepond_mode=1.
3. Server (defense-in-depth): ThesisFileHandler::hasFilePondQueueData()
   scans POST['queue_file'] for 32-char hex IDs; ThesisCreateController
   and ThesisEditController use it alongside filepond_mode, so even if
   the flag somehow arrives without async upload IDs, the  path
   takes over.
This commit is contained in:
Pontoporeia
2026-06-11 10:32:38 +02:00
parent 63e65d9856
commit 4b37a05be3
6 changed files with 61 additions and 7 deletions

View File

@@ -52,8 +52,8 @@ Reference: Assessment against progressive-enhancement / WCAG-AA / "never lose da
**Current state:** `form.php` hardcodes `<input type="hidden" name="filepond_mode" value="1">`. Without JS, no `queue_file[]` hidden inputs are populated → server gets `filepond_mode=1` with empty queue → all files silently dropped. The form is supposed to work without JS. **Current state:** `form.php` hardcodes `<input type="hidden" name="filepond_mode" value="1">`. Without JS, no `queue_file[]` hidden inputs are populated → server gets `filepond_mode=1` with empty queue → all files silently dropped. The form is supposed to work without JS.
**To do:** **To do:**
- [ ] Change the hidden input to `<input type="hidden" name="filepond_mode" value="0" disabled>` by default; JS enables it and sets `value="1"` on DOMContentLoaded - [x] Change the hidden input to `<input type="hidden" name="filepond_mode" value="0" disabled>` by default; JS enables it and sets `value="1"` on DOMContentLoaded
- [ ] Add server-side fallback in `ThesisCreateController::submit()` and `ThesisEditController::save()`: when `filepond_mode=1` but no `queue_file` data is present, fall through to the legacy `$_FILES` path - [x] Add server-side fallback in `ThesisCreateController::submit()` and `ThesisEditController::save()`: when `filepond_mode=1` but no `queue_file` data is present, fall through to the legacy `$_FILES` path
- [ ] Test end-to-end: submit the partage form with JS disabled, verify files arrive via `$_FILES` - [ ] Test end-to-end: submit the partage form with JS disabled, verify files arrive via `$_FILES`
### 3. Autosave text fields on partage form ### 3. Autosave text fields on partage form

View File

@@ -615,11 +615,24 @@
console.log('[filepond] htmx detected, registering swap listeners'); console.log('[filepond] htmx detected, registering swap listeners');
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap); window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
window.htmx.on("htmx:afterSwap", () => { window.htmx.on("htmx:afterSwap", () => {
enableFilepondMode();
_xamxamFilepondReady = false; _xamxamFilepondReady = false;
window.XamxamInitFilePonds(); window.XamxamInitFilePonds();
setTimeout(() => { _xamxamFilepondReady = true; }, 0); setTimeout(() => { _xamxamFilepondReady = true; }, 0);
}); });
} }
// ── Enable filepond_mode hidden input (no-JS safety) ────────────────
// The hidden input starts as disabled / value=0 so the server falls
// back to $_FILES when JS is unavailable. Enable it now that FilePond
// will handle uploads asynchronously.
function enableFilepondMode() {
var inputs = document.querySelectorAll("input[name='filepond_mode']");
for (var i = 0; i < inputs.length; i++) {
inputs[i].disabled = false;
inputs[i].value = "1";
}
}
// Flag set after FilePond instances are fully initialised. // Flag set after FilePond instances are fully initialised.
// Before this flag is set, FilePond:addfile events are from initial load // Before this flag is set, FilePond:addfile events are from initial load
// (e.g. existing files loaded in edit mode) and should not mark the form dirty. // (e.g. existing files loaded in edit mode) and should not mark the form dirty.
@@ -629,10 +642,12 @@
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
enableFilepondMode();
window.XamxamInitFilePonds(); window.XamxamInitFilePonds();
_xamxamFilepondReady = true; _xamxamFilepondReady = true;
}); });
} else { } else {
enableFilepondMode();
window.XamxamInitFilePonds(); window.XamxamInitFilePonds();
_xamxamFilepondReady = true; _xamxamFilepondReady = true;
} }

View File

@@ -198,7 +198,13 @@ class ThesisCreateController
$folderPath = $objet . '/' . $data['annee'] . '/' . $folderName . '/'; $folderPath = $objet . '/' . $data['annee'] . '/' . $folderName . '/';
$filePrefix = $folderName; $filePrefix = $folderName;
if (!empty($post['filepond_mode'])) { // Determine upload path: FilePond async (JS enabled, hex IDs present)
// vs. legacy multipart (no JS, or JS failed). The hidden filepond_mode
// input starts disabled (value=0, not submitted); JS enables it on load.
// Defense-in-depth: if filepond_mode=1 but no hex IDs, fall back to $_FILES.
$useFilePond = !empty($post['filepond_mode']) && $this->hasFilePondQueueData($post);
if ($useFilePond) {
// New path: files already on server via async FilePond uploads // New path: files already on server via async FilePond uploads
// Cover and note_intention also go through FilePond async flow // Cover and note_intention also go through FilePond async flow
$this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix); $this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix);

View File

@@ -359,8 +359,12 @@ class ThesisEditController
mkdir($dirAbs, 0755, true); mkdir($dirAbs, 0755, true);
} }
// Determine upload path: FilePond async (JS enabled, hex IDs present)
// vs. legacy multipart. Defense-in-depth fallback for no-JS scenarios.
$useFilePond = !empty($post['filepond_mode']) && $this->hasFilePondQueueData($post);
// ── Cover image (outside transaction — filesystem op) ───────────────── // ── Cover image (outside transaction — filesystem op) ─────────────────
if (!empty($post['filepond_mode'])) { if ($useFilePond) {
// Delete old cover only if a genuinely new cover was uploaded (hex file_id). // Delete old cover only if a genuinely new cover was uploaded (hex file_id).
// Existing cover preserved in FilePond sends its DB integer ID — skip. // Existing cover preserved in FilePond sends its DB integer ID — skip.
$coverIdRaw = ($post['queue_file']['cover'] ?? null); $coverIdRaw = ($post['queue_file']['cover'] ?? null);
@@ -387,7 +391,7 @@ class ThesisEditController
} }
// ── Note d'intention (replace if uploaded) ──────────────────────────── // ── Note d'intention (replace if uploaded) ────────────────────────────
if (!empty($post['filepond_mode'])) { if ($useFilePond) {
// Only delete + replace if a genuinely new file was uploaded (hex file_id). // Only delete + replace if a genuinely new file was uploaded (hex file_id).
// Existing files preserved in the FilePond pool send their DB integer ID; // Existing files preserved in the FilePond pool send their DB integer ID;
// we must NOT delete them — they're already stored. // we must NOT delete them — they're already stored.
@@ -454,7 +458,7 @@ class ThesisEditController
} }
} }
if (!empty($post['filepond_mode'])) { if ($useFilePond) {
// New path: files already on server via async FilePond uploads // New path: files already on server via async FilePond uploads
$nextNum = $tfeCount + 1; $nextNum = $tfeCount + 1;
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, $nextNum); $nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, $nextNum);

View File

@@ -853,6 +853,33 @@ trait ThesisFileHandler
// ── FilePond async file processing ────────────────────────────────────── // ── FilePond async file processing ──────────────────────────────────────
/**
* Check whether the POST data contains actual FilePond hex IDs (32-char hex)
* rather than standard file upload data.
*
* Without JS the hidden filepond_mode input is disabled and not submitted;
* this is a defense-in-depth fallback: if filepond_mode=1 somehow arrives but
* no async upload IDs are present, we treat it as a legacy $_FILES submission.
*/
private function hasFilePondQueueData(array $post): bool
{
$queueKeys = ['cover', 'note_intention', 'tfe', 'annexe'];
foreach ($queueKeys as $key) {
$raw = $post['queue_file'][$key] ?? null;
if ($raw === null || $raw === '') {
continue;
}
$ids = is_array($raw) ? $raw : [$raw];
foreach ($ids as $id) {
$id = is_string($id) ? trim($id) : '';
if ($id !== '' && preg_match('/^[a-f0-9]{32}$/', $id)) {
return true;
}
}
}
return false;
}
/** /**
* Process a single file from the FilePond async flow (cover, note_intention). * Process a single file from the FilePond async flow (cover, note_intention).
* *

View File

@@ -147,7 +147,9 @@ $errorFieldName = $errorFieldName ?? null;
<?php endif; ?> <?php endif; ?>
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard> <form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard>
<input type="hidden" name="filepond_mode" value="1"> <!-- Default: JS-disabled mode (disabled → not submitted → server uses $_FILES path).
On DOMContentLoaded, JS enables this input and sets value="1" → server uses FilePond path. -->
<input type="hidden" name="filepond_mode" value="0" disabled>
<?= $hiddenFields ?> <?= $hiddenFields ?>
<?php if (!$adminMode): ?> <?php if (!$adminMode): ?>