From 1490c992680080cb84cc58cfc113345df87e0062 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Tue, 9 Jun 2026 17:41:31 +0200 Subject: [PATCH] Fix FilePond: maxFileSize as bytes + temp files survive page reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. maxFileSize bug: FileValidateSize plugin overrides core's maxFileSize setter. Core uses toBytes('1GB') = 1073741824, but plugin registers maxFileSize as [null, Type.INT] which calls toInt('1GB') = 1. Fix: all maxFileSize and perExtensionMaxSize values as raw bytes. Also fix option name: fileValidateSizeFilterItem → fileValidateSizeFilter. 2. Temp file persistence: files uploaded via FilePond went to tmp/filepond/ and vanished from the UI on page reload because data-existing-files only included DB-persisted files. Fix: session-track temp file_ids in handleProcess, inject via getSessionTempFiles() into data-existing-files, teach handleLoad to stream temp files from disk, and route JS remove → revert for hex IDs. --- TODO.md | 2 + app/public/admin/fragments/fichiers.php | 8 + .../assets/js/app/file-upload-filepond.js | 71 +++++--- app/src/FilepondHandler.php | 169 +++++++++++++++++- app/storage/logs/admin-2026-06-09.log | 2 + app/templates/partials/form/form.php | 8 + 6 files changed, 234 insertions(+), 26 deletions(-) diff --git a/TODO.md b/TODO.md index f32fc35..a666b5d 100644 --- a/TODO.md +++ b/TODO.md @@ -24,3 +24,5 @@ - [x] Formulaire étudiant : préciser "un seul contact" dans le label, mise à jour du hint pour le format le plus court (site sans https://www., insta/mastodon avec @), adaptation de l'affichage public pour supporter ces formats courts (liens automatiques pour @pseudo → Instagram, @pseudo@instance → Mastodon, domaine nu → https://) - [x] Déplacer "Contact visible" du Backoffice vers "Informations du TFE" dans le formulaire admin edit, renommer "Identité" → "Informations du TFE" dans le récapitulatif admin - [x] Rework contenus-edit: auto-save (debounce 1.5s) sur tous les formulaires (page, form-help, contacts, sidebar links), toolbar OverType sur tous les éditeurs markdown, sidebar links dynamiques (add/remove) remplaçant les 2 liens fixes erg_site_url/source_code_url par un seul key sidebar_links avec fallback de migration +- [x] Fix FilePond "Fichier trop volumineux Taille max: 1byte" — le plugin FileValidateSize surcharge le setter core de maxFileSize et parse la string "1GB" via toInt → 1 au lieu de toBytes → 1073741824. Passage de toutes les valeurs maxFileSize et perExtensionMaxSize en nombres bruts (bytes). Correction du nom d'option fileValidateSizeFilterItem → fileValidateSizeFilter. Adaptation de parseSize pour accepter les nombres. +- [x] Fix FilePond: fichiers perdus après reload — les uploads temporaires (tmp/filepond/) disparaissaient car data-existing-files ne contenait que les fichiers en DB. Ajout tracking session ($_SESSION['filepond_tmp']) dans handleProcess, injection des fichiers temporaires de la session dans data-existing-files via getSessionTempFiles(), loadTempFile() dans handleLoad pour streamer depuis tmp/, et routage remove → revert pour les hex IDs. diff --git a/app/public/admin/fragments/fichiers.php b/app/public/admin/fragments/fichiers.php index 1115fd6..b5c6a63 100644 --- a/app/public/admin/fragments/fichiers.php +++ b/app/public/admin/fragments/fichiers.php @@ -45,6 +45,14 @@ if ($thesisId) { ], ]; } + + // Include session temp files so uploads survive page reload + require_once APP_ROOT . '/src/FilepondHandler.php'; + $tempFiles = FilepondHandler::getSessionTempFiles($queueType); + foreach ($tempFiles as $tf) { + $result[] = $tf; + } + return $result; }; diff --git a/app/public/assets/js/app/file-upload-filepond.js b/app/public/assets/js/app/file-upload-filepond.js index 33a32be..8804174 100644 --- a/app/public/assets/js/app/file-upload-filepond.js +++ b/app/public/assets/js/app/file-upload-filepond.js @@ -10,7 +10,7 @@ * 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. + * plus fileValidateSizeFilter 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. @@ -47,24 +47,27 @@ labelFileTypeNotAllowed: "Format non accepté", fileValidateTypeLabelExpectedTypes: "PDF, Images, Vidéos, Audio, VTT, Archives", - maxFileSize: "1GB", + maxFileSize: 1073741824, // 1 GB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: true, // Per-extension size limits: certain types get higher caps. + // Values in bytes; FileValidateSize plugin reads maxFileSize as INT, + // so numeric literals are required (string suffixes like "1GB" become + // parseInt("1GB") = 1 byte inside the plugin). perExtensionMaxSize: { - pdf: "100MB", - mp4: "8GB", - webm: "8GB", - ogv: "8GB", - mov: "8GB", - mp3: "8GB", - ogg: "8GB", - oga: "8GB", - wav: "8GB", - flac: "8GB", - aac: "8GB", - m4a: "8GB", + pdf: 104857600, // 100 MB + mp4: 8589934592, // 8 GB + webm: 8589934592, + ogv: 8589934592, + mov: 8589934592, + mp3: 8589934592, + ogg: 8589934592, + oga: 8589934592, + wav: 8589934592, + flac: 8589934592, + aac: 8589934592, + m4a: 8589934592, }, }, annexe: { @@ -76,7 +79,7 @@ ], labelFileTypeNotAllowed: "Format non accepté", fileValidateTypeLabelExpectedTypes: "PDF, ZIP, TAR, GZ", - maxFileSize: "1GB", + maxFileSize: 1073741824, // 1 GB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: true, @@ -85,7 +88,7 @@ acceptedFileTypes: ["image/jpeg", "image/png", "image/webp"], labelFileTypeNotAllowed: "Seulement JPG, PNG ou WEBP", fileValidateTypeLabelExpectedTypes: "JPG, PNG, WEBP", - maxFileSize: "20MB", + maxFileSize: 20971520, // 20 MB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: false, @@ -94,7 +97,7 @@ acceptedFileTypes: ["application/pdf"], labelFileTypeNotAllowed: "Seulement PDF", fileValidateTypeLabelExpectedTypes: "PDF", - maxFileSize: "100MB", + maxFileSize: 104857600, // 100 MB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: false, @@ -103,7 +106,7 @@ acceptedFileTypes: ["text/csv"], labelFileTypeNotAllowed: "Seulement CSV", fileValidateTypeLabelExpectedTypes: "CSV", - maxFileSize: "50MB", + maxFileSize: 52428800, // 50 MB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: false, @@ -119,6 +122,8 @@ * Parse a size string like "500MB" or "2GB" to bytes. */ function parseSize(str) { + // Already a number (bytes) — pass through + if (typeof str === 'number') return str; var m = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i); if (!m) return 0; var val = parseFloat(m[1]); @@ -277,7 +282,25 @@ // FilePond appends the source value (db_id) automatically remove: (source, load, error) => { - console.log(`[filepond] remove called | db_id=${source}`); + console.log(`[filepond] remove called | id=${source}`); + // Hex IDs (32 chars) → temp files → use revert endpoint + if (/^[a-f0-9]{32}$/.test(source)) { + fetch(`${base}/revert.php`, { + method: "DELETE", + headers: { "X-CSRF-Token": csrfToken }, + body: source, + }) + .then((r) => { + console.log("[filepond] revert (from remove) response | ok=" + r.ok + " | status=" + r.status); + r.ok ? load() : error("Erreur suppression"); + }) + .catch((e) => { + console.error("[filepond] revert (from remove) fetch error", e); + error("Erreur réseau"); + }); + return; + } + // Numeric IDs → DB files → use remove endpoint fetch(`${base}/remove.php`, { method: "DELETE", headers: { @@ -341,10 +364,9 @@ labelButtonRetryItemLoad: "Réessayer", labelButtonProcessItem: "Charger", - // ── Per-extension size validation ────────────────────────────── - // Uses fileValidateSizeFilterItem if the FileValidateSize plugin supports it. + // Per-extension size validation via FileValidateSize plugin hook. // Falls back to beforeAddFile for silent rejection (the plugin shows the error). - fileValidateSizeFilterItem: (item) => { + fileValidateSizeFilter: (item) => { var ext = getExt(item.filename); if (ext && perExtMax[ext]) { return parseSize(perExtMax[ext]); // per-extension cap for this item @@ -352,10 +374,9 @@ return parseSize(cfg.maxFileSize); // queue default }, - // Fallback: if fileValidateSizeFilterItem is not available, - // beforeAddFile enforces per-extension limits (silent rejection). + // Fallback: beforeAddFile enforces per-extension limits (silent rejection). beforeAddFile: (item) => { - // This check is redundant if fileValidateSizeFilterItem works, + // This check is redundant if fileValidateSizeFilter works, // but serves as a fallback. if (typeof item.file === "undefined") return true; var f = item.file; diff --git a/app/src/FilepondHandler.php b/app/src/FilepondHandler.php index 06ca7f5..ab06f6a 100644 --- a/app/src/FilepondHandler.php +++ b/app/src/FilepondHandler.php @@ -140,6 +140,12 @@ class FilepondHandler chmod($targetPath, 0644); error_log($this->logPrefix . ':process File saved to tmp | file_id=' . $fileId . ' | path=' . $targetPath); + // Track temp file in session so it survives page reloads + if (session_status() === PHP_SESSION_ACTIVE) { + $_SESSION['filepond_tmp'][$queueType] = $_SESSION['filepond_tmp'][$queueType] ?? []; + $_SESSION['filepond_tmp'][$queueType][] = $fileId; + } + $isPeerTubeQueue = str_starts_with($queueType, 'peertube_'); $isTfeAv = ($queueType === 'tfe' && preg_match('/^(video|audio)\//', $mimeType)); $shouldPeerTube = $isPeerTubeQueue || $isTfeAv; @@ -207,7 +213,16 @@ class FilepondHandler die('Méthode non autorisée.'); } - $dbId = filter_var($_GET['id'] ?? '', FILTER_VALIDATE_INT); + $fileId = trim($_GET['id'] ?? ''); + + // Hex IDs (32 chars) → temp files from tmp/filepond/ + if (preg_match('/^[a-f0-9]{32}$/', $fileId)) { + $this->loadTempFile($fileId); + // loadTempFile exits; never returns + } + + // Numeric IDs → DB files + $dbId = filter_var($fileId, FILTER_VALIDATE_INT); if ($dbId === false || $dbId <= 0) { http_response_code(400); die('ID invalide.'); @@ -355,6 +370,11 @@ class FilepondHandler die('Session invalide.'); } + // Remove from session tracking + if (session_status() === PHP_SESSION_ACTIVE) { + $this->removeFromSessionTmp($fileId); + } + $it = new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS); $files_it = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); foreach ($files_it as $file) { @@ -372,6 +392,153 @@ class FilepondHandler // ── Internal helpers ────────────────────────────────────────────────────── + /** + * Get temp files for the current session and a specific queue type. + * + * Returns an array suitable for injection into FilePond's data-existing-files + * JSON attribute, so temp files survive page reloads. + */ + public static function getSessionTempFiles(string $queueType): array + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return []; + } + + $fileIds = $_SESSION['filepond_tmp'][$queueType] ?? []; + if (empty($fileIds)) { + return []; + } + + $result = []; + $missing = []; + foreach ($fileIds as $fileId) { + $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; + $manifestPath = $tmpDir . '/manifest.json'; + + if (!is_dir($tmpDir) || !file_exists($manifestPath)) { + $missing[] = $fileId; + continue; + } + + $manifest = json_decode(file_get_contents($manifestPath), true); + if (!is_array($manifest)) { + $missing[] = $fileId; + continue; + } + + if (($manifest['session_id'] ?? '') !== session_id()) { + $missing[] = $fileId; + continue; + } + + // Find the actual file + $actualFile = null; + $dh = opendir($tmpDir); + while (($entry = readdir($dh)) !== false) { + if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { + continue; + } + $actualFile = $tmpDir . '/' . $entry; + break; + } + closedir($dh); + + if ($actualFile === null || !file_exists($actualFile)) { + $missing[] = $fileId; + continue; + } + + $result[] = [ + 'source' => $fileId, + 'options' => [ + 'type' => 'local', + 'file' => [ + 'name' => $manifest['original_name'] ?? basename($actualFile), + 'size' => (int)($manifest['size'] ?? filesize($actualFile)), + 'type' => $manifest['mime'] ?? 'application/octet-stream', + ], + ], + ]; + } + + // Clean up session entries for missing files + if (!empty($missing)) { + $_SESSION['filepond_tmp'][$queueType] = array_values( + array_diff($fileIds, $missing) + ); + } + + return $result; + } + + /** + * Load a temp file (hex file_id) — streams the file from tmp/filepond/. + */ + private function loadTempFile(string $fileId): never + { + $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; + $manifestPath = $tmpDir . '/manifest.json'; + + if (!is_dir($tmpDir) || !file_exists($manifestPath)) { + http_response_code(404); + die('Fichier temporaire introuvable.'); + } + + $manifest = json_decode(file_get_contents($manifestPath), true); + if (!is_array($manifest) || ($manifest['session_id'] ?? '') !== session_id()) { + http_response_code(403); + die('Session invalide.'); + } + + $actualFile = null; + $dh = opendir($tmpDir); + while (($entry = readdir($dh)) !== false) { + if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { + continue; + } + $actualFile = $tmpDir . '/' . $entry; + break; + } + closedir($dh); + + if ($actualFile === null || !file_exists($actualFile)) { + http_response_code(404); + die('Fichier temporaire introuvable.'); + } + + $mimeType = $manifest['mime'] ?? mime_content_type($actualFile); + $fileSize = filesize($actualFile); + $fileName = $manifest['original_name'] ?? basename($actualFile); + + error_log($this->logPrefix . ':load TEMP | file_id=' . $fileId . ' | name=' . $fileName . ' | size=' . $fileSize); + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . $fileSize); + header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"'); + header('Cache-Control: no-cache'); + readfile($actualFile); + exit; + } + + /** + * Remove a file_id from the session temp tracking array. + */ + private function removeFromSessionTmp(string $fileId): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return; + } + foreach ($_SESSION['filepond_tmp'] ?? [] as $queueType => $ids) { + $idx = array_search($fileId, $ids, true); + if ($idx !== false) { + array_splice($_SESSION['filepond_tmp'][$queueType], $idx, 1); + if (empty($_SESSION['filepond_tmp'][$queueType])) { + unset($_SESSION['filepond_tmp'][$queueType]); + } + break; + } + } + } + /** * Extract the first available file from $_FILES regardless of nesting depth. */ diff --git a/app/storage/logs/admin-2026-06-09.log b/app/storage/logs/admin-2026-06-09.log index cc3c294..72ee02a 100644 --- a/app/storage/logs/admin-2026-06-09.log +++ b/app/storage/logs/admin-2026-06-09.log @@ -14,3 +14,5 @@ {"timestamp":"2026-06-09T15:19:03+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"page","action":"edit","status":"success","context":{"slug":"about"}} {"timestamp":"2026-06-09T15:20:57+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"apropos","action":"edit","status":"success","context":{"key":"contacts"}} {"timestamp":"2026-06-09T15:21:03+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"apropos","action":"edit","status":"success","context":{"key":"contacts"}} +{"timestamp":"2026-06-09T15:23:37+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"apropos","action":"edit","status":"success","context":{"key":"sidebar_links"}} +{"timestamp":"2026-06-09T15:23:39+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"apropos","action":"edit","status":"success","context":{"key":"sidebar_links"}} diff --git a/app/templates/partials/form/form.php b/app/templates/partials/form/form.php index 69d640e..9b45610 100644 --- a/app/templates/partials/form/form.php +++ b/app/templates/partials/form/form.php @@ -335,6 +335,14 @@ $_buildQueueFilesJson = function (array $files, string $queueType): array { ], ]; } + + // Include session temp files so uploads survive page reload + require_once APP_ROOT . '/src/FilepondHandler.php'; + $tempFiles = FilepondHandler::getSessionTempFiles($queueType); + foreach ($tempFiles as $tf) { + $result[] = $tf; + } + return $result; }; if ($filesMode === 'add'): ?>