mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
1. note_intention: Delete old file only when a genuinely new upload arrives
(32-char hex file_id), not when the FilePond pool preserves an existing
file by sending its DB integer ID. Previously the DB integer ID
triggered $hasNewNote=true, which deleted the existing note_intention
from disk+DB, then handleFilePondSingleFile couldn't re-process it
because the regex requires a hex pattern. Same fix applied to cover.
2. All file deletions now use deleteThesisFileToTrash() which renames
files to tmp/_trash/ instead of unlinking. The trash preserves
original filenames prefixed with DB id for traceability. Skips
website URLs and PeerTube refs (no disk file).
3. Storage prefix changed from theses/ to documents/ to reflect that
the folder holds all document types (determined by file_type in DB).
MediaController visibility gate supports both prefixes for backward
compat with existing files.
4. File browser + relink feature for orphaned files:
- /admin/fragments/file-browser.php — HTMX tree browser for
storage/documents/ and storage/theses/
- /admin/actions/filepond/relink.php — POST endpoint that inserts
a thesis_files row pointing to existing on-disk file
- Per-pool "📂 Relier" buttons (edit mode only)
- JS: XamxamOpenFileBrowser / XamxamRelinkFile with FilePond integration
- CSS: .relink-modal dialog + .file-browser tree styles
1258 lines
49 KiB
PHP
1258 lines
49 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Trait ThesisFileHandler
|
||
*
|
||
* Shared file-upload logic used by ThesisCreateController and
|
||
* ThesisEditController. All on-disk files are stored under:
|
||
*
|
||
* documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||
*
|
||
* Filenames follow:
|
||
*
|
||
* {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}.ext (single file)
|
||
* {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}_{XX}.ext (numbered, 01-based)
|
||
*
|
||
* Semantic groups:
|
||
* COUVERTURE – single cover image
|
||
* NOTE_INTENTION – single PDF
|
||
* TFE_{XX} – main thesis files (contiguous numbering)
|
||
* ANNEXE_{XX} – annex files (separate numbering)
|
||
*
|
||
* TFE hierarchy (determines numbering order):
|
||
* 1. PDFs
|
||
* 2. Videos
|
||
* 3. Audio
|
||
* 4. Subtitles (VTT placed right after their video)
|
||
* 5. Images
|
||
* 6. Archives / other
|
||
*
|
||
* PeerTube and website URLs are stored as thesis_files rows with
|
||
* file_path containing the URL; no on-disk file is created.
|
||
*
|
||
* The original user-provided filename is preserved in thesis_files.file_name.
|
||
*/
|
||
trait ThesisFileHandler
|
||
{
|
||
/** @var string[] Warnings collected during file processing (e.g. invalid type, too large). */
|
||
private array $fileWarnings = [];
|
||
|
||
/** Maximum allowed file size for thesis files (bytes). */
|
||
private const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||
|
||
/** Maximum allowed file size for PDF files specifically (bytes). */
|
||
private const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 MB
|
||
|
||
/** Maximum allowed file size for video/audio files (bytes). */
|
||
private const MAX_AV_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB
|
||
|
||
/** Cover image max size. */
|
||
private const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
||
|
||
/** MIME types accepted for thesis files. */
|
||
private const ALLOWED_MIME_TYPES = [
|
||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||
'application/pdf',
|
||
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
|
||
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
|
||
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
|
||
'text/vtt',
|
||
'application/zip', 'application/x-zip-compressed',
|
||
'application/x-tar', 'application/gzip',
|
||
'application/octet-stream',
|
||
];
|
||
|
||
/** File extensions accepted for thesis files. */
|
||
private const ALLOWED_EXTENSIONS = [
|
||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||
'pdf',
|
||
'mp4', 'webm', 'ogv', 'mov',
|
||
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
|
||
'vtt',
|
||
'zip', 'tar', 'gz', 'tgz',
|
||
];
|
||
|
||
// ── Public entry points (called by controllers) ──────────────────────────
|
||
|
||
/**
|
||
* Get warnings collected during file processing (invalid types, too large, etc.).
|
||
* @return string[]
|
||
*/
|
||
public function getFileWarnings(): array
|
||
{
|
||
return $this->fileWarnings;
|
||
}
|
||
|
||
/**
|
||
* Process a cover image upload.
|
||
*
|
||
* @param int $thesisId
|
||
* @param array|null $upload Single-file $_FILES entry.
|
||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "documents/2025/2025_SMITH_Mon_Titre/").
|
||
* @param string $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre").
|
||
*/
|
||
protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void
|
||
{
|
||
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
|
||
return;
|
||
}
|
||
|
||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mimeType = $finfo->file($upload['tmp_name']);
|
||
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
|
||
|
||
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
|
||
$allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
|
||
|
||
if (!in_array($mimeType, $allowedMimes, true)
|
||
|| !in_array($ext, $allowedExts, true)
|
||
|| $upload['size'] > self::MAX_COVER_SIZE) {
|
||
$this->fileWarnings[] = "Couverture « {$upload['name']} » ignorée : format ou taille non accepté.";
|
||
error_log("ThesisFileHandler: invalid cover MIME $mimeType / $ext / {$upload['size']} bytes, skipping");
|
||
return;
|
||
}
|
||
|
||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
$targetName = $filePrefix . '_COUVERTURE.' . $ext;
|
||
$targetPath = $dir . $targetName;
|
||
|
||
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||
error_log("ThesisFileHandler: failed to move cover to $targetPath");
|
||
return;
|
||
}
|
||
|
||
chmod($targetPath, 0644);
|
||
$relPath = $folderPath . $targetName;
|
||
|
||
$this->db->insertThesisFile(
|
||
$thesisId, 'cover',
|
||
$relPath,
|
||
basename($upload['name']),
|
||
$upload['size'],
|
||
$mimeType
|
||
);
|
||
error_log("ThesisFileHandler: cover uploaded → $relPath");
|
||
}
|
||
|
||
/**
|
||
* Process the note d'intention upload (single PDF).
|
||
*
|
||
* @param int $thesisId
|
||
* @param array|null $upload Single-file $_FILES entry.
|
||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
|
||
* @param string $filePrefix The shared file prefix.
|
||
*/
|
||
protected function handleNoteIntentionUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void
|
||
{
|
||
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
|
||
return;
|
||
}
|
||
|
||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mimeType = $finfo->file($upload['tmp_name']);
|
||
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
|
||
|
||
if ($mimeType !== 'application/pdf' || $ext !== 'pdf') {
|
||
$this->fileWarnings[] = "Note d'intention « {$upload['name']} » ignorée : seul le format PDF est accepté.";
|
||
error_log("ThesisFileHandler: invalid note d'intention MIME $mimeType, skipping");
|
||
return;
|
||
}
|
||
if ($upload['size'] > self::MAX_PDF_SIZE) {
|
||
$this->fileWarnings[] = "Note d'intention « {$upload['name']} » ignorée : fichier trop volumineux (max 100 MB).";
|
||
error_log("ThesisFileHandler: note d'intention too large ({$upload['size']} bytes), skipping");
|
||
return;
|
||
}
|
||
|
||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
$targetName = $filePrefix . '_NOTE_INTENTION.pdf';
|
||
$targetPath = $dir . $targetName;
|
||
|
||
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||
error_log("ThesisFileHandler: failed to move note d'intention to $targetPath");
|
||
return;
|
||
}
|
||
|
||
chmod($targetPath, 0644);
|
||
$relPath = $folderPath . $targetName;
|
||
|
||
$this->db->insertThesisFile(
|
||
$thesisId, 'note_intention',
|
||
$relPath,
|
||
basename($upload['name']),
|
||
$upload['size'],
|
||
'application/pdf'
|
||
);
|
||
error_log("ThesisFileHandler: note d'intention uploaded → $relPath");
|
||
}
|
||
|
||
/**
|
||
* Process multiple TFE file uploads (files[] — the main set).
|
||
*
|
||
* Files are stored with TFE_{XX} semantics. Numbering is contiguous
|
||
* across all sub-types in order: PDF → video → audio → subtitles → images → archives.
|
||
* Subtitles (VTT) are placed immediately after their associated video.
|
||
*
|
||
* @param int $thesisId
|
||
* @param array|null $uploads Multi-file $_FILES entry.
|
||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
|
||
* @param string $filePrefix The shared file prefix.
|
||
* @param array $post $_POST for per-file labels/orders.
|
||
* @param int $startNum Starting number for TFE_XX (usually 01).
|
||
*/
|
||
protected function handleTfeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = [], int $startNum = 1): int
|
||
{
|
||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||
return $startNum;
|
||
}
|
||
|
||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
$fileLabels = $post['file_labels'] ?? [];
|
||
$fileOrders = $post['file_orders'] ?? [];
|
||
|
||
// Collect all files, classify, sort by hierarchy
|
||
$files = [];
|
||
$count = count($uploads['name']);
|
||
for ($i = 0; $i < $count; $i++) {
|
||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
||
continue;
|
||
}
|
||
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
|
||
error_log("ThesisFileHandler: TFE upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
|
||
continue;
|
||
}
|
||
|
||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
|
||
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
|
||
|
||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||
$mimeType = 'text/vtt';
|
||
}
|
||
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : format .$ext non accepté.";
|
||
error_log("ThesisFileHandler: TFE extension not allowed {$uploads['name'][$i]} ($ext), skipping");
|
||
continue;
|
||
}
|
||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|
||
&& !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : type non accepté ($mimeType).";
|
||
error_log("ThesisFileHandler: invalid TFE type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
|
||
continue;
|
||
}
|
||
|
||
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
|
||
$isAv = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']);
|
||
$sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE);
|
||
if ($uploads['size'][$i] > $sizeLimit) {
|
||
$limitMb = round($sizeLimit / 1024 / 1024);
|
||
$sizeMb = round($uploads['size'][$i] / 1024 / 1024);
|
||
$this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : trop volumineux ($sizeMb MB, max $limitMb MB).";
|
||
error_log("ThesisFileHandler: TFE file too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping');
|
||
continue;
|
||
}
|
||
|
||
$files[] = [
|
||
'index' => $i,
|
||
'mimeType' => $mimeType,
|
||
'ext' => $ext,
|
||
'size' => $uploads['size'][$i],
|
||
'tmpName' => $uploads['tmp_name'][$i],
|
||
'origName' => $uploads['name'][$i],
|
||
'label' => trim($fileLabels[$i] ?? ''),
|
||
'sortOrder' => isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null,
|
||
'hierarchy' => $this->tfeHierarchyRank($mimeType, $ext),
|
||
'fileType' => $this->detectFileType($mimeType, $ext),
|
||
];
|
||
}
|
||
|
||
// Sort by hierarchy rank
|
||
usort($files, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']);
|
||
|
||
// Assign contiguous TFE_XX numbers
|
||
$videoCount = 0;
|
||
$vttQueue = [];
|
||
|
||
// First pass: count videos to know where to insert VTTs
|
||
foreach ($files as $f) {
|
||
if ($f['fileType'] === 'video') {
|
||
$videoCount++;
|
||
}
|
||
}
|
||
|
||
$num = $startNum;
|
||
$vttIdx = 0;
|
||
|
||
foreach ($files as $f) {
|
||
// VTT files are inserted right after their corresponding video
|
||
if ($f['fileType'] === 'caption') {
|
||
$vttQueue[] = $f;
|
||
continue;
|
||
}
|
||
|
||
if ($f['fileType'] === 'video') {
|
||
// Write the video file
|
||
$this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||
$num++;
|
||
|
||
// Write any waiting VTT for this video
|
||
if (!empty($vttQueue)) {
|
||
$vtt = array_shift($vttQueue);
|
||
$this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||
$num++;
|
||
}
|
||
} else {
|
||
$this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||
$num++;
|
||
}
|
||
}
|
||
|
||
// Any remaining VTTs (orphaned — write at end)
|
||
foreach ($vttQueue as $vtt) {
|
||
$this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||
$num++;
|
||
}
|
||
|
||
return $num;
|
||
}
|
||
|
||
/**
|
||
* Extract a flat $_FILES-style sub-array from PHP's nested upload structure.
|
||
*
|
||
* PHP normalises FormData names like "queue_file[tfe][]" into:
|
||
* $_FILES['queue_file']['name']['tfe'] = [file1, file2, ...]
|
||
* $_FILES['queue_file']['tmp_name']['tfe'] = [/tmp/..., /tmp/...]
|
||
* This helper extracts ['tfe'] → ['name' => [...], 'tmp_name' => [...], ...]
|
||
*/
|
||
protected function extractFilesSubArray(array $parent, string $key): ?array
|
||
{
|
||
if (!isset($parent['name'][$key]) || !is_array($parent['name'][$key])) {
|
||
return null;
|
||
}
|
||
$result = [];
|
||
foreach (['name', 'tmp_name', 'error', 'size', 'type'] as $field) {
|
||
$result[$field] = $parent[$field][$key] ?? [];
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Process TFE file uploads from client-side JS queue (FormData).
|
||
*
|
||
* Files arrive via $_FILES['queue_file'] with PHP-nested key extraction.
|
||
* They are written in the order the user specified (preserved in FormData order).
|
||
*
|
||
* @param int $thesisId
|
||
* @param array|null $uploads Flat $_FILES-style array with 'name', 'tmp_name', etc.
|
||
* @param string $folderPath
|
||
* @param string $filePrefix
|
||
* @param int $startNum
|
||
*/
|
||
protected function handleTfeQueueFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, int $startNum = 1): int
|
||
{
|
||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||
return $startNum;
|
||
}
|
||
|
||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
// Build entries, validate, and classify
|
||
$files = [];
|
||
$vttQueue = [];
|
||
$count = count($uploads['name']);
|
||
|
||
for ($i = 0; $i < $count; $i++) {
|
||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||
continue;
|
||
}
|
||
|
||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
|
||
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
|
||
|
||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||
$mimeType = 'text/vtt';
|
||
}
|
||
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : format .$ext non accepté.";
|
||
error_log("ThesisFileHandler: queue file extension not allowed {$uploads['name'][$i]} ($ext), skipping");
|
||
continue;
|
||
}
|
||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|
||
&& !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : type non accepté ($mimeType).";
|
||
error_log("ThesisFileHandler: invalid queue file type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
|
||
continue;
|
||
}
|
||
|
||
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
|
||
$isAv = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']);
|
||
$sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE);
|
||
if ($uploads['size'][$i] > $sizeLimit) {
|
||
$limitMb = round($sizeLimit / 1024 / 1024);
|
||
$sizeMb = round($uploads['size'][$i] / 1024 / 1024);
|
||
$this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : trop volumineux ($sizeMb MB, max $limitMb MB).";
|
||
error_log("ThesisFileHandler: queue file too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping');
|
||
continue;
|
||
}
|
||
|
||
$entry = [
|
||
'mimeType' => $mimeType,
|
||
'ext' => $ext,
|
||
'size' => $uploads['size'][$i],
|
||
'tmpName' => $uploads['tmp_name'][$i],
|
||
'origName' => $uploads['name'][$i],
|
||
'label' => '',
|
||
'sortOrder' => null,
|
||
'fileType' => $this->detectFileType($mimeType, $ext),
|
||
];
|
||
|
||
// VTTs are collected and paired with their preceding video
|
||
if ($entry['fileType'] === 'caption') {
|
||
$vttQueue[] = $entry;
|
||
} else {
|
||
$files[] = $entry;
|
||
}
|
||
}
|
||
|
||
// Files are written in the order they arrived (user-specified via JS queue order).
|
||
// VTTs are inserted immediately after the video they follow.
|
||
$num = $startNum;
|
||
$videosSeen = 0;
|
||
|
||
foreach ($files as $f) {
|
||
$this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||
$num++;
|
||
|
||
if ($f['fileType'] === 'video' && isset($vttQueue[$videosSeen])) {
|
||
$this->writeTfeFile($vttQueue[$videosSeen], $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||
$num++;
|
||
$videosSeen++;
|
||
} elseif ($f['fileType'] === 'video') {
|
||
$videosSeen++;
|
||
}
|
||
}
|
||
|
||
// Orphaned VTTs (no preceding video in this batch)
|
||
for ($i = $videosSeen; $i < count($vttQueue); $i++) {
|
||
$this->writeTfeFile($vttQueue[$i], $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||
$num++;
|
||
}
|
||
|
||
return $num;
|
||
}
|
||
|
||
/**
|
||
* Process annexe file uploads from client-side JS queue (FormData).
|
||
*
|
||
* Files arrive via $_FILES['queue_file']['annexe'].
|
||
*
|
||
* @param int $thesisId
|
||
* @param array|null $uploads $_FILES['queue_file']['annexe']-style array
|
||
* @param string $folderPath
|
||
* @param string $filePrefix
|
||
*/
|
||
protected function handleAnnexeQueueFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix): void
|
||
{
|
||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||
return;
|
||
}
|
||
|
||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
$num = 1;
|
||
$count = count($uploads['name']);
|
||
for ($i = 0; $i < $count; $i++) {
|
||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||
continue;
|
||
}
|
||
|
||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
|
||
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
|
||
|
||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||
$mimeType = 'text/vtt';
|
||
}
|
||
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : format .$ext non accepté.";
|
||
error_log("ThesisFileHandler: queue annexe extension not allowed {$uploads['name'][$i]} ($ext), skipping");
|
||
continue;
|
||
}
|
||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|
||
&& !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : type non accepté ($mimeType).";
|
||
error_log("ThesisFileHandler: invalid queue annexe type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
|
||
continue;
|
||
}
|
||
|
||
// Annexes: PDF max 100 MB, everything else max 500 MB
|
||
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
|
||
$sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE;
|
||
if ($uploads['size'][$i] > $sizeLimit) {
|
||
$limitMb = round($sizeLimit / 1024 / 1024);
|
||
$sizeMb = round($uploads['size'][$i] / 1024 / 1024);
|
||
$this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB).";
|
||
error_log("ThesisFileHandler: queue annexe too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping');
|
||
continue;
|
||
}
|
||
|
||
$padded = sprintf('%02d', $num);
|
||
$targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext;
|
||
$targetPath = $dir . $targetName;
|
||
|
||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||
error_log("ThesisFileHandler: failed to move queue annexe {$uploads['name'][$i]}");
|
||
continue;
|
||
}
|
||
|
||
chmod($targetPath, 0644);
|
||
$relPath = $folderPath . $targetName;
|
||
|
||
$this->db->insertThesisFile(
|
||
$thesisId, 'annex',
|
||
$relPath,
|
||
basename($uploads['name'][$i]),
|
||
$uploads['size'][$i],
|
||
$mimeType,
|
||
null,
|
||
null
|
||
);
|
||
error_log("ThesisFileHandler: annexe (queue) moved → $targetName");
|
||
$num++;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Process annex file uploads.
|
||
*
|
||
* @param int $thesisId
|
||
* @param array|null $uploads Multi-file $_FILES entry.
|
||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
|
||
* @param string $filePrefix The shared file prefix.
|
||
* @param array $post $_POST for per-file labels/orders.
|
||
*/
|
||
protected function handleAnnexeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = []): void
|
||
{
|
||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||
return;
|
||
}
|
||
|
||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
$fileLabels = $post['annexe_labels'] ?? [];
|
||
$fileOrders = $post['annexe_orders'] ?? [];
|
||
|
||
$num = 1;
|
||
$count = count($uploads['name']);
|
||
for ($i = 0; $i < $count; $i++) {
|
||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
||
continue;
|
||
}
|
||
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
|
||
error_log("ThesisFileHandler: annexe upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
|
||
continue;
|
||
}
|
||
|
||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
|
||
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
|
||
|
||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||
$mimeType = 'text/vtt';
|
||
}
|
||
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : format .$ext non accepté.";
|
||
error_log("ThesisFileHandler: annexe extension not allowed {$uploads['name'][$i]} ($ext), skipping");
|
||
continue;
|
||
}
|
||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|
||
&& !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
$this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : type non accepté ($mimeType).";
|
||
error_log("ThesisFileHandler: invalid annexe type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
|
||
continue;
|
||
}
|
||
|
||
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
|
||
$isAv = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']);
|
||
$sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE);
|
||
if ($uploads['size'][$i] > $sizeLimit) {
|
||
$limitMb = round($sizeLimit / 1024 / 1024);
|
||
$sizeMb = round($uploads['size'][$i] / 1024 / 1024);
|
||
$this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB).";
|
||
error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . $sizeMb . " MB), skipping");
|
||
continue;
|
||
}
|
||
|
||
$padded = sprintf('%02d', $num);
|
||
$targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext;
|
||
$targetPath = $dir . $targetName;
|
||
|
||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||
error_log("ThesisFileHandler: failed to move annexe {$uploads['name'][$i]}");
|
||
continue;
|
||
}
|
||
|
||
chmod($targetPath, 0644);
|
||
$relPath = $folderPath . $targetName;
|
||
$label = trim($fileLabels[$i] ?? '');
|
||
$sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null;
|
||
$fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex';
|
||
|
||
$this->db->insertThesisFile(
|
||
$thesisId, $fileType,
|
||
$relPath,
|
||
basename($uploads['name'][$i]),
|
||
$uploads['size'][$i],
|
||
$mimeType,
|
||
$label !== '' ? $label : null,
|
||
$sortOrder
|
||
);
|
||
error_log("ThesisFileHandler: annexe uploaded → $targetName");
|
||
$num++;
|
||
}
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Write a single TFE file to disk and record in DB.
|
||
*/
|
||
protected function writeTfeFile(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 (!move_uploaded_file($f['tmpName'], $targetPath)) {
|
||
error_log("ThesisFileHandler: failed to move TFE {$f['origName']}");
|
||
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 → $targetName ({$f['fileType']})");
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
/**
|
||
* Assign a hierarchy rank for sorting TFE files.
|
||
* Lower = earlier in the sequence.
|
||
*
|
||
* 0 = PDF
|
||
* 1 = video
|
||
* 2 = audio
|
||
* 3 = caption (VTT)
|
||
* 4 = image
|
||
* 5 = archive / other
|
||
*/
|
||
private function tfeHierarchyRank(string $mimeType, string $ext): int
|
||
{
|
||
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
|
||
return 0;
|
||
}
|
||
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
|
||
return 3;
|
||
}
|
||
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
|
||
return 1;
|
||
}
|
||
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
|
||
return 2;
|
||
}
|
||
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
|
||
return 4;
|
||
}
|
||
return 5;
|
||
}
|
||
|
||
/**
|
||
* Determine the logical file_type from MIME type / extension.
|
||
*/
|
||
protected function detectFileType(string $mimeType, string $ext): string
|
||
{
|
||
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
|
||
return 'caption';
|
||
}
|
||
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
|
||
return 'main';
|
||
}
|
||
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
|
||
return 'video';
|
||
}
|
||
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
|
||
return 'audio';
|
||
}
|
||
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
|
||
return 'image';
|
||
}
|
||
return 'other';
|
||
}
|
||
|
||
// ── String / filesystem helpers ──────────────────────────────────────────
|
||
|
||
/**
|
||
* Generate a filesystem-safe author slug from comma-separated author names.
|
||
* Sorted alphabetically, joined with dash, accent-stripped, uppercase.
|
||
*/
|
||
protected function generateAuthorSlug(string $authorNames): string
|
||
{
|
||
$names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn($n) => $n !== ''));
|
||
sort($names, SORT_NATURAL);
|
||
$joined = implode('-', $names);
|
||
|
||
$normalized = $joined;
|
||
if (function_exists('iconv')) {
|
||
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||
}
|
||
$accents = [
|
||
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||
'î' => 'i', 'ï' => 'i',
|
||
'ô' => 'o', 'ö' => 'o',
|
||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||
'ç' => 'c',
|
||
'À' => 'A', 'Â' => 'A', 'Ä' => 'A',
|
||
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||
'Î' => 'I', 'Ï' => 'I',
|
||
'Ô' => 'O', 'Ö' => 'O',
|
||
'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||
'Ç' => 'C',
|
||
];
|
||
$normalized = strtr($normalized, $accents);
|
||
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||
$slug = trim($slug, '_');
|
||
$slug = strtoupper($slug);
|
||
if ($slug === '') {
|
||
$slug = 'AUTHOR';
|
||
}
|
||
return $slug;
|
||
}
|
||
|
||
/**
|
||
* Generate a filesystem-safe title slug (truncated to 60 chars).
|
||
*/
|
||
protected function generateTitleSlug(string $title): string
|
||
{
|
||
$normalized = $title;
|
||
if (function_exists('iconv')) {
|
||
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||
}
|
||
$accents = [
|
||
'à' => 'a', 'â' => 'a', 'ä' => 'a', 'æ' => 'ae',
|
||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||
'î' => 'i', 'ï' => 'i',
|
||
'ô' => 'o', 'ö' => 'o', 'œ' => 'oe',
|
||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||
'ç' => 'c',
|
||
'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Æ' => 'AE',
|
||
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||
'Î' => 'I', 'Ï' => 'I',
|
||
'Ô' => 'O', 'Ö' => 'O', 'Œ' => 'OE',
|
||
'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||
'Ç' => 'C',
|
||
"'" => '', '"' => '', '«' => '', '»' => '',
|
||
];
|
||
$normalized = strtr($normalized, $accents);
|
||
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||
$slug = trim($slug, '_');
|
||
$slug = strtoupper($slug);
|
||
if (strlen($slug) > 60) {
|
||
$slug = substr($slug, 0, 60);
|
||
}
|
||
if ($slug === '') {
|
||
$slug = 'TFE';
|
||
}
|
||
return $slug;
|
||
}
|
||
|
||
/**
|
||
* Build the folder path and file prefix for a thesis.
|
||
*
|
||
* Folder: documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||
* Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG}
|
||
*
|
||
* @return array{folderPath: string, filePrefix: string, folderName: string}
|
||
*/
|
||
protected function buildThesisFolder(int $year, string $authorsStr, string $title): array
|
||
{
|
||
$authorSlug = $this->generateAuthorSlug($authorsStr);
|
||
$titleSlug = $this->generateTitleSlug($title);
|
||
|
||
$folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
|
||
$filePrefix = $folderName;
|
||
$folderPath = 'documents/' . $year . '/' . $folderName . '/';
|
||
|
||
return [
|
||
'folderPath' => $folderPath,
|
||
'filePrefix' => $filePrefix,
|
||
'folderName' => $folderName,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Ensure the thesis folder exists (create if needed).
|
||
* If the folder already exists, append a numeric suffix.
|
||
*/
|
||
protected function ensureUniqueFolder(string $folderPath): string
|
||
{
|
||
$baseDir = STORAGE_ROOT . '/' . dirname($folderPath) . '/';
|
||
$candidate = basename(rtrim($folderPath, '/'));
|
||
|
||
if (!is_dir($baseDir . $candidate)) {
|
||
mkdir($baseDir . $candidate, 0755, true);
|
||
return $candidate;
|
||
}
|
||
|
||
$suffix = 1;
|
||
while (is_dir($baseDir . $candidate . '_' . $suffix)) {
|
||
$suffix++;
|
||
}
|
||
$candidate .= '_' . $suffix;
|
||
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
|
||
// Format: peertube:video:UUID or peertube:audio:UUID
|
||
if (str_starts_with($fileId, 'peertube:')) {
|
||
$parts = explode(':', $fileId, 3);
|
||
$fileType = ($parts[1] ?? '') === 'video' ? 'video' : 'audio';
|
||
$uuid = $parts[2] ?? '';
|
||
$storedPath = 'peertube_ids:' . $uuid;
|
||
$this->db->insertThesisFile(
|
||
$thesisId, $fileType,
|
||
$storedPath,
|
||
$uuid . ' (PeerTube)',
|
||
0,
|
||
$fileType === '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);
|
||
}
|
||
|
||
/**
|
||
* Delete a thesis_files row, moving the on-disk file to the trash directory
|
||
* instead of unlinking it. Website URLs and PeerTube references are only
|
||
* removed from the database (no disk file).
|
||
*/
|
||
protected function deleteThesisFileToTrash(int $fileId, int $thesisId): void
|
||
{
|
||
if ($fileId <= 0) {
|
||
return;
|
||
}
|
||
|
||
// Fetch the file row to get the path before deleting the DB record
|
||
$existingFiles = $this->db->getThesisFiles($thesisId);
|
||
$fileRow = null;
|
||
foreach ($existingFiles as $f) {
|
||
if ((int)$f['id'] === $fileId) {
|
||
$fileRow = $f;
|
||
break;
|
||
}
|
||
}
|
||
|
||
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
|
||
|
||
if ($filePath && defined('STORAGE_ROOT')) {
|
||
// Skip filesystem for website URLs and PeerTube IDs (not real files)
|
||
if (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) {
|
||
return;
|
||
}
|
||
if (str_starts_with($filePath, 'peertube_ids:')) {
|
||
return;
|
||
}
|
||
|
||
$abs = STORAGE_ROOT . '/' . $filePath;
|
||
if (file_exists($abs)) {
|
||
$trashDir = STORAGE_ROOT . '/tmp/_trash';
|
||
if (!is_dir($trashDir)) {
|
||
mkdir($trashDir, 0755, true);
|
||
}
|
||
// Keep original filename structure for traceability
|
||
$trashName = $fileId . '_' . basename($filePath);
|
||
$trashPath = $trashDir . '/' . $trashName;
|
||
if (!rename($abs, $trashPath)) {
|
||
// Fallback to unlink if rename fails (cross-device)
|
||
@copy($abs, $trashPath);
|
||
@unlink($abs);
|
||
}
|
||
error_log("ThesisFileHandler: file \$fileId moved to trash → \$trashName");
|
||
}
|
||
}
|
||
}
|
||
}
|