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

@@ -845,4 +845,360 @@ trait ThesisFileHandler
mkdir($baseDir . $candidate, 0755, true);
return $candidate;
}
// ── FilePond async file processing ──────────────────────────────────────
/**
* Process a single file from the FilePond async flow (cover, note_intention).
*
* Unlike queue files, these arrive as a single file_id in $post['queue_file'][$queueKey]
* (which will be a string, not an array — PHP normalizes single-value inputs).
*/
protected function handleFilePondSingleFile(
int $thesisId,
array $post,
string $queueKey,
string $folderPath,
string $filePrefix
): void {
$raw = $post['queue_file'][$queueKey] ?? null;
if ($raw === null || (is_array($raw) && empty($raw))) {
return;
}
// PHP may send a single value as scalar or single-element array
$fileId = is_array($raw) ? $raw[0] : $raw;
$fileId = trim($fileId);
if ($fileId === '' || !preg_match('/^[a-f0-9]{32}$/', $fileId)) {
return;
}
$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId;
$manifestPath = $tmpDir . '/manifest.json';
if (!is_dir($tmpDir) || !file_exists($manifestPath)) {
error_log("ThesisFileHandler: single file_id $fileId not found in tmp/");
return;
}
$manifest = json_decode(file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
error_log("ThesisFileHandler: invalid manifest for $fileId");
return;
}
// 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)) {
error_log("ThesisFileHandler: no file found in tmp dir for $fileId");
return;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$ext = $manifest['ext'];
$mimeType = $manifest['mime'];
$originalName = $manifest['original_name'];
$size = $manifest['size'];
if ($queueKey === 'cover') {
$targetName = $filePrefix . '_COUVERTURE.' . $ext;
$fileType = 'cover';
} elseif ($queueKey === 'note_intention') {
$targetName = $filePrefix . '_NOTE_INTENTION.pdf';
$fileType = 'note_intention';
} else {
error_log("ThesisFileHandler: unknown single file queue key $queueKey");
return;
}
$targetPath = $dir . $targetName;
if (!rename($actualFile, $targetPath)) {
error_log("ThesisFileHandler: failed to move $queueKey from tmp");
return;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, $fileType,
$relPath,
$originalName,
$size,
$mimeType
);
error_log("ThesisFileHandler: $queueKey uploaded (filepond) → $targetName");
$this->cleanupFilePondTmp($fileId);
}
/**
* Process queue files that were uploaded asynchronously via FilePond.
*
* Instead of receiving $_FILES, this method reads ad-hoc `queue_file[tfe][]`
* style hidden inputs containing opaque file_ids. Each file_id maps to
* a directory under tmp/filepond/ with a manifest.json and the actual file.
*
* This is the new path (Step 2 of the refactor). The old $_FILES path
* (handleTfeQueueFiles) is kept for backwards compatibility and can be
* removed once the new flow is stable.
*
* @param int $thesisId
* @param array $post $_POST array (contains queue_file[tfe][] etc.)
* @param string $queueKey Queue sub-key ('tfe', 'video', 'audio', 'annexe', 'peertube_video', 'peertube_audio')
* @param string $folderPath Relative path to the thesis folder.
* @param string $filePrefix The shared file prefix.
* @param int $startNum Starting number for TFE_XX (only used for tfe/video/audio queues).
* @param string|null $progressToken Optional progress token for PeerTube uploads.
* @return int The next TFE number (for tfe/video/audio queues).
*/
protected function handleFilePondQueueFiles(
int $thesisId,
array $post,
string $queueKey,
string $folderPath,
string $filePrefix,
int $startNum = 1,
?string $progressToken = null
): int {
$fileIds = $post['queue_file'][$queueKey] ?? [];
if (!is_array($fileIds) || empty($fileIds)) {
return $startNum;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$isPeerTube = false; // peerTube handled inline per fileId (peertube: prefix)
$isAnnexe = $queueKey === 'annexe';
$isTfeLike = in_array($queueKey, ['tfe', 'video', 'audio'], true);
// ── Collect files from tmp/ ──────────────────────────────────────────
$files = [];
$vttQueue = [];
foreach ($fileIds as $fileId) {
$fileId = trim($fileId);
if ($fileId === '') {
continue;
}
// PeerTube files have been uploaded already; just insert DB row
if (str_starts_with($fileId, 'peertube:')) {
$uuid = substr($fileId, strlen('peertube:'));
$fileType = ($queueKey === 'peertube_video') ? 'video' : 'audio';
$storedPath = 'peertube_ids:' . $uuid;
$this->db->insertThesisFile(
$thesisId, $fileType,
$storedPath,
$uuid . ' (PeerTube)',
0,
($queueKey === 'peertube_video') ? 'video/mp4' : 'audio/mpeg',
null, null
);
error_log("ThesisFileHandler: PeerTube file associated → $uuid");
continue;
}
// Regular tmp files (hex file_id)
if (!preg_match('/^[a-f0-9]{32}$/', $fileId)) {
continue;
}
$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId;
$manifestPath = $tmpDir . '/manifest.json';
if (!is_dir($tmpDir) || !file_exists($manifestPath)) {
error_log("ThesisFileHandler: file_id $fileId not found in tmp/");
continue;
}
$manifest = json_decode(file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
error_log("ThesisFileHandler: invalid manifest for $fileId");
continue;
}
// Find the actual file in the tmp dir (there should be exactly one non-manifest 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)) {
error_log("ThesisFileHandler: no file found in tmp dir for $fileId");
continue;
}
$entry = [
'fileId' => $fileId,
'tmpDir' => $tmpDir,
'mimeType' => $manifest['mime'],
'ext' => $manifest['ext'],
'size' => $manifest['size'],
'origName' => $manifest['original_name'],
'label' => '',
'sortOrder' => null,
'fileType' => $this->detectFileType($manifest['mime'], $manifest['ext']),
'actualFile' => $actualFile,
];
if ($isTfeLike && $entry['fileType'] === 'caption') {
$vttQueue[] = $entry;
} else {
$files[] = $entry;
}
}
// ── Handle annexe queue ──────────────────────────────────────────────
if ($isAnnexe) {
$num = 1;
foreach ($files as $f) {
$padded = sprintf('%02d', $num);
$targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $f['ext'];
$targetPath = $dir . $targetName;
if (!rename($f['actualFile'], $targetPath)) {
error_log("ThesisFileHandler: failed to move annexe {$f['origName']}");
continue;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, 'annex',
$relPath,
basename($f['origName']),
$f['size'],
$f['mimeType'],
null, null
);
error_log("ThesisFileHandler: annexe (filepond) → $targetName");
$num++;
$this->cleanupFilePondTmp($f['fileId']);
}
return $startNum;
}
// ── Handle TFE/video/audio queues ────────────────────────────────────
// Sort by hierarchy rank (PDF → video → audio → caption → image → archive)
$filesWithRank = [];
foreach ($files as $f) {
$f['hierarchy'] = $this->tfeHierarchyRank($f['mimeType'], $f['ext']);
$filesWithRank[] = $f;
}
usort($filesWithRank, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']);
$num = $startNum;
$vttIdx = 0;
$videoCount = 0;
foreach ($filesWithRank as $f) {
if ($f['fileType'] === 'video') {
$videoCount++;
}
}
foreach ($filesWithRank as $f) {
if ($f['fileType'] === 'caption') {
$vttQueue[] = $f;
continue;
}
if ($f['fileType'] === 'video') {
$this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
if (!empty($vttQueue)) {
$vtt = array_shift($vttQueue);
$this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
} else {
$this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
}
// Orphaned VTTs
foreach ($vttQueue as $vtt) {
$this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
return $num;
}
/**
* Write a single TFE file from tmp/filepond to the thesis directory.
*/
private function writeTfeFileFromTmp(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void
{
$padded = sprintf('%02d', $num);
$targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext'];
$targetPath = $dir . $targetName;
if (!rename($f['actualFile'], $targetPath)) {
error_log("ThesisFileHandler: failed to move TFE {$f['origName']} from tmp");
return;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, $f['fileType'],
$relPath,
basename($f['origName']),
$f['size'],
$f['mimeType'],
$f['label'] !== '' ? $f['label'] : null,
$f['sortOrder']
);
error_log("ThesisFileHandler: TFE uploaded (filepond) → $targetName ({$f['fileType']})");
$this->cleanupFilePondTmp($f['fileId']);
}
/**
* Clean up a tmp/filepond directory after processing.
*/
private function cleanupFilePondTmp(string $fileId): void
{
$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId;
if (!is_dir($tmpDir)) {
return;
}
$it = new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS);
$files_it = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files_it as $file) {
if ($file->isDir()) {
@rmdir($file->getRealPath());
} else {
@unlink($file->getRealPath());
}
}
@rmdir($tmpDir);
}
}