Files
xamxam/app/src/Controllers/ThesisFileHandler.php
Pontoporeia a2092b58a7 fix: supprimer les vidéos PeerTube lors de la suppression d'un TFE
- Ajout de PeerTubeService::deleteVideo() qui appelle DELETE /api/v1/videos/{uuid}
- deleteThesisFileToTrash() appelle maintenant deleteVideo() pour les fichiers peertube_ids:
- hardDeleteThesis() supprime aussi les vidéos PeerTube associées
2026-06-10 00:16:22 +02:00

1272 lines
49 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Trait ThesisFileHandler
*
* Shared file-upload logic used by ThesisCreateController and
* ThesisEditController. All on-disk files are stored under:
*
* {objet}/{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. "tfe/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: {objet}/{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, string $objet = 'tfe'): array
{
$authorSlug = $this->generateAuthorSlug($authorsStr);
$titleSlug = $this->generateTitleSlug($title);
$folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
$filePrefix = $folderName;
$folderPath = $objet . '/' . $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:')) {
$uuid = substr($filePath, strlen('peertube_ids:'));
require_once __DIR__ . '/../PeerTubeService.php';
PeerTubeService::deleteVideo($this->db, $uuid);
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}");
}
}
}
}