mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user