['image/jpeg', 'image/png', 'image/webp'], 'note_intention' => ['application/pdf'], 'tfe' => null, // full whitelist 'video' => ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'], 'audio' => ['audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/flac', 'audio/x-wav', 'audio/aac', 'audio/mp4'], 'annexe' => ['application/pdf', 'application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/gzip', 'application/octet-stream'], 'peertube_video' => ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'], 'peertube_audio' => ['audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/flac', 'audio/x-wav', 'audio/aac', 'audio/mp4'], ]; public const QUEUE_SIZE_LIMITS = [ 'cover' => 20 * 1024 * 1024, // 20 MB 'note_intention' => 100 * 1024 * 1024, // 100 MB 'tfe' => 1024 * 1024 * 1024, // 1 GB (default for non-AV, non-PDF) 'video' => 8 * 1024 * 1024 * 1024, // 8 GB 'audio' => 8 * 1024 * 1024 * 1024, // 8 GB 'annexe' => 1024 * 1024 * 1024, // 1 GB 'peertube_video' => 8 * 1024 * 1024 * 1024, // 8 GB 'peertube_audio' => 8 * 1024 * 1024 * 1024, // 8 GB ]; public const AV_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a']; public const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 MB public const MAX_AV_SIZE = 8 * 1024 * 1024 * 1024; // 8 GB // ── Log prefix for distinguishing admin vs partage ─────────────────────── private string $logPrefix; public function __construct(string $logPrefix = '[filepond]') { $this->logPrefix = $logPrefix; } // ═══════════════════════════════════════════════════════════════════════════ // PROCESS — receive one file, validate, store to tmp, return file_id // ═══════════════════════════════════════════════════════════════════════════ public function handleProcess(): never { // All responses from this endpoint must be text/plain // (PHP defaults to text/html, which confuses FilePond on error). header('Content-Type: text/plain; charset=utf-8'); error_log($this->logPrefix . ':process ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | post_keys=' . implode(',', array_keys($_POST))); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); die('Méthode non autorisée.'); } $upload = $this->extractUpload(); if ($upload === null) { error_log($this->logPrefix . ':process No usable file found. _FILES: ' . substr(json_encode($_FILES, JSON_PARTIAL_OUTPUT_ON_ERROR), 0, 500)); http_response_code(400); die('Aucun fichier reçu.'); } $err = $upload['error'] ?? -1; if ($err !== UPLOAD_ERR_OK) { error_log($this->logPrefix . ':process Upload error ' . $err . ' for ' . ($upload['name'] ?? '?')); http_response_code(400); die('Erreur de téléversement (code ' . $err . ').'); } $queueType = trim($_POST['queue_type'] ?? ''); error_log($this->logPrefix . ':process Received file | name=' . $upload['name'] . ' | size=' . $upload['size'] . ' | queue_type=' . $queueType); $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($upload['tmp_name']); $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); error_log($this->logPrefix . ':process MIME detected | mime=' . $mimeType . ' | ext=' . $ext); if ($mimeType === 'text/plain' && $ext === 'vtt') { $mimeType = 'text/vtt'; } if ($mimeType === 'audio/mpeg' && $ext === 'mp3') { $mimeType = 'audio/mp3'; } $this->validateMimeExt($queueType, $mimeType, $ext); $this->validateSize($queueType, $mimeType, $ext, $upload['size']); $fileId = bin2hex(random_bytes(16)); $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; if (!mkdir($tmpDir, 0755, true)) { error_log($this->logPrefix . ':process Failed to create tmp dir: ' . $tmpDir); http_response_code(500); die('Erreur serveur — impossible de stocker le fichier.'); } $originalName = basename($upload['name']); $targetPath = $tmpDir . '/' . $originalName; if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { error_log($this->logPrefix . ':process move_uploaded_file FAILED | from=' . $upload['tmp_name'] . ' | to=' . $targetPath); rmdir($tmpDir); http_response_code(500); die('Erreur serveur — échec du déplacement du fichier.'); } chmod($targetPath, 0644); error_log($this->logPrefix . ':process File saved to tmp | file_id=' . $fileId . ' | path=' . $targetPath); // Track temp file in session so it survives page reloads if (session_status() === PHP_SESSION_ACTIVE) { $_SESSION['filepond_tmp'][$queueType] ??= []; $_SESSION['filepond_tmp'][$queueType][] = $fileId; } $isPeerTubeQueue = str_starts_with($queueType, 'peertube_'); $isTfeAv = ($queueType === 'tfe' && preg_match('/^(video|audio)\//', $mimeType)); $shouldPeerTube = $isPeerTubeQueue || $isTfeAv; if ($shouldPeerTube) { require_once APP_ROOT . '/src/PeerTubeService.php'; if (PeerTubeService::isEnabled(new Database())) { $ptFileType = preg_match('/^video\//', $mimeType) ? 'video' : 'audio'; try { $result = PeerTubeService::upload( new Database(), $targetPath, $originalName, $originalName, '' ); $fileId = 'peertube:' . $ptFileType . ':' . $result['uuid']; @unlink($targetPath); @rmdir($tmpDir); error_log($this->logPrefix . ':process PeerTube upload OK | uuid=' . $result['uuid'] . ' | url=' . $result['watchUrl']); header('Content-Type: text/plain; charset=utf-8'); echo $fileId; exit; } catch (\Throwable $e) { @unlink($targetPath); @rmdir($tmpDir); error_log($this->logPrefix . ':process PeerTube upload FAILED: ' . $e->getMessage()); http_response_code(500); die('Erreur lors du téléversement vers PeerTube.'); } } else { if ($isPeerTubeQueue) { @unlink($targetPath); @rmdir($tmpDir); http_response_code(503); die('PeerTube n\'est pas activé.'); } } } $manifest = [ 'queue_type' => $queueType, 'original_name' => $originalName, 'mime' => $mimeType, 'ext' => $ext, 'size' => $upload['size'], 'session_id' => session_id(), 'uploaded_at' => date('c'), ]; file_put_contents($tmpDir . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_SLASHES)); error_log($this->logPrefix . ':process SUCCESS | file_id=' . $fileId . ' | queue_type=' . $queueType . ' | name=' . $originalName); echo $fileId; exit; } // ═══════════════════════════════════════════════════════════════════════════ // LOAD — stream an existing thesis file back to FilePond // ═══════════════════════════════════════════════════════════════════════════ public function handleLoad(): never { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { header('Content-Type: text/plain; charset=utf-8'); http_response_code(405); die('Méthode non autorisée.'); } $fileId = trim($_GET['id'] ?? ''); // Hex IDs (32 chars) → temp files from tmp/filepond/ if (preg_match('/^[a-f0-9]{32}$/', $fileId)) { $this->loadTempFile($fileId); // loadTempFile exits; never returns } // Numeric IDs → DB files $dbId = filter_var($fileId, FILTER_VALIDATE_INT); if ($dbId === false || $dbId <= 0) { header('Content-Type: text/plain; charset=utf-8'); http_response_code(400); die('ID invalide.'); } error_log($this->logPrefix . ':load ENTRY | db_id=' . $dbId); $pdo = Database::getInstance()->getConnection(); $stmt = $pdo->prepare('SELECT * FROM thesis_files WHERE id = ?'); $stmt->execute([$dbId]); $fileRow = $stmt->fetch(); if (!$fileRow) { error_log($this->logPrefix . ':load DB NOT FOUND | db_id=' . $dbId); header('Content-Type: text/plain; charset=utf-8'); http_response_code(404); die('Fichier introuvable.'); } $filePath = $fileRow['file_path'] ?? ''; $fileName = $fileRow['file_name'] ?? basename($filePath); $mimeType = $fileRow['mime_type'] ?? 'application/octet-stream'; if (str_starts_with($filePath, 'peertube_ids:')) { $uuid = substr($filePath, strlen('peertube_ids:')); $isVideo = ($fileRow['file_type'] ?? '') === 'video'; $svg = $isVideo ? 'PeerTube ' . htmlspecialchars($uuid) . '' : 'PeerTube ' . htmlspecialchars($uuid) . ''; header('Content-Type: image/svg+xml'); header('Content-Length: ' . strlen($svg)); header('Content-Disposition: inline; filename="peertube.svg"'); header('Cache-Control: no-cache'); echo $svg; exit; } if (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) { header('Content-Type: text/plain; charset=utf-8'); http_response_code(404); die('URL — pas de flux direct.'); } $absPath = STORAGE_ROOT . '/' . $filePath; if (!file_exists($absPath) || !is_readable($absPath)) { error_log($this->logPrefix . ':load DISK MISSING | db_id=' . $dbId . ' | absPath=' . $absPath); header('Content-Type: text/plain; charset=utf-8'); http_response_code(404); die('Fichier absent du disque.'); } $fileSize = filesize($absPath); error_log($this->logPrefix . ':load OK | db_id=' . $dbId . ' | path=' . $filePath . ' | mime=' . $mimeType . ' | size=' . $fileSize); header('Content-Type: ' . $mimeType); header('Content-Length: ' . $fileSize); header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"'); header('Cache-Control: no-cache'); readfile($absPath); exit; } // ═══════════════════════════════════════════════════════════════════════════ // REMOVE — soft-delete a thesis_files row (edit mode) // ═══════════════════════════════════════════════════════════════════════════ public function handleRemove(): never { header('Content-Type: text/plain; charset=utf-8'); if ($_SERVER['REQUEST_METHOD'] !== 'DELETE') { http_response_code(405); die('Méthode non autorisée.'); } $body = json_decode(file_get_contents('php://input'), true); $dbId = filter_var($body['db_id'] ?? '', FILTER_VALIDATE_INT); if ($dbId === false || $dbId <= 0) { http_response_code(400); die('ID de fichier invalide.'); } $pdo = Database::getInstance()->getConnection(); $stmt = $pdo->prepare('SELECT * FROM thesis_files WHERE id = ?'); $stmt->execute([$dbId]); $fileRow = $stmt->fetch(); if (!$fileRow) { http_response_code(404); die('Fichier introuvable.'); } $filePath = $fileRow['file_path'] ?? ''; if ($filePath !== '' && !str_starts_with($filePath, 'peertube_ids:') && !str_starts_with($filePath, 'http://') && !str_starts_with($filePath, 'https://')) { $absPath = STORAGE_ROOT . '/' . $filePath; if (file_exists($absPath)) { $trashDir = STORAGE_ROOT . '/tmp/_trash'; if (!is_dir($trashDir)) { mkdir($trashDir, 0755, true); } $trashPath = $trashDir . '/' . $dbId . '_' . basename($filePath); rename($absPath, $trashPath); } } $delStmt = $pdo->prepare('DELETE FROM thesis_files WHERE id = ?'); $delStmt->execute([$dbId]); http_response_code(200); exit; } // ═══════════════════════════════════════════════════════════════════════════ // REVERT — delete a tmp file (user removes before form submit) // ═══════════════════════════════════════════════════════════════════════════ public function handleRevert(): never { header('Content-Type: text/plain; charset=utf-8'); if ($_SERVER['REQUEST_METHOD'] !== 'DELETE') { http_response_code(405); die('Méthode non autorisée.'); } $fileId = trim(file_get_contents('php://input')); if (str_starts_with($fileId, 'peertube:')) { http_response_code(200); exit; } if ($fileId === '' || !preg_match('/^[a-f0-9]{32}$/', $fileId)) { http_response_code(400); die('ID de fichier invalide.'); } $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; $manifestPath = $tmpDir . '/manifest.json'; if (!is_dir($tmpDir) || !file_exists($manifestPath)) { http_response_code(404); exit; } $manifest = json_decode(file_get_contents($manifestPath), true); if (!is_array($manifest) || ($manifest['session_id'] ?? '') !== session_id()) { http_response_code(403); die('Session invalide.'); } // Remove from session tracking if (session_status() === PHP_SESSION_ACTIVE) { $this->removeFromSessionTmp($fileId); } $it = new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS); $files_it = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); foreach ($files_it as $file) { if ($file->isDir()) { rmdir($file->getRealPath()); } else { unlink($file->getRealPath()); } } rmdir($tmpDir); http_response_code(200); exit; } // ── Internal helpers ────────────────────────────────────────────────────── /** * Get temp files for the current session and a specific queue type. * * Returns an array suitable for injection into FilePond's data-existing-files * JSON attribute, so temp files survive page reloads. */ public static function getSessionTempFiles(string $queueType): array { if (session_status() !== PHP_SESSION_ACTIVE) { return []; } $fileIds = $_SESSION['filepond_tmp'][$queueType] ?? []; if (empty($fileIds)) { return []; } $result = []; $missing = []; foreach ($fileIds as $fileId) { $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; $manifestPath = $tmpDir . '/manifest.json'; if (!is_dir($tmpDir) || !file_exists($manifestPath)) { $missing[] = $fileId; continue; } $manifest = json_decode(file_get_contents($manifestPath), true); if (!is_array($manifest)) { $missing[] = $fileId; continue; } if (($manifest['session_id'] ?? '') !== session_id()) { $missing[] = $fileId; continue; } // Find the actual file $actualFile = null; $dh = opendir($tmpDir); while (($entry = readdir($dh)) !== false) { if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { continue; } $actualFile = $tmpDir . '/' . $entry; break; } closedir($dh); if ($actualFile === null || !file_exists($actualFile)) { $missing[] = $fileId; continue; } $result[] = [ 'source' => $fileId, 'options' => [ 'type' => 'local', 'file' => [ 'name' => $manifest['original_name'] ?? basename($actualFile), 'size' => (int)($manifest['size'] ?? filesize($actualFile)), 'type' => $manifest['mime'] ?? 'application/octet-stream', ], ], ]; } // Clean up session entries for missing files if (!empty($missing)) { $_SESSION['filepond_tmp'][$queueType] = array_values( array_diff($fileIds, $missing) ); } return $result; } /** * Load a temp file (hex file_id) — streams the file from tmp/filepond/. */ private function loadTempFile(string $fileId): never { $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; $manifestPath = $tmpDir . '/manifest.json'; if (!is_dir($tmpDir) || !file_exists($manifestPath)) { header('Content-Type: text/plain; charset=utf-8'); http_response_code(404); die('Fichier temporaire introuvable.'); } $manifest = json_decode(file_get_contents($manifestPath), true); if (!is_array($manifest) || ($manifest['session_id'] ?? '') !== session_id()) { header('Content-Type: text/plain; charset=utf-8'); http_response_code(403); die('Session invalide.'); } $actualFile = null; $dh = opendir($tmpDir); while (($entry = readdir($dh)) !== false) { if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { continue; } $actualFile = $tmpDir . '/' . $entry; break; } closedir($dh); if ($actualFile === null || !file_exists($actualFile)) { header('Content-Type: text/plain; charset=utf-8'); http_response_code(404); die('Fichier temporaire introuvable.'); } $mimeType = $manifest['mime'] ?? mime_content_type($actualFile); $fileSize = filesize($actualFile); $fileName = $manifest['original_name'] ?? basename($actualFile); error_log($this->logPrefix . ':load TEMP | file_id=' . $fileId . ' | name=' . $fileName . ' | size=' . $fileSize); header('Content-Type: ' . $mimeType); header('Content-Length: ' . $fileSize); header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"'); header('Cache-Control: no-cache'); readfile($actualFile); exit; } /** * Remove a file_id from the session temp tracking array. */ private function removeFromSessionTmp(string $fileId): void { if (session_status() !== PHP_SESSION_ACTIVE) { return; } foreach ($_SESSION['filepond_tmp'] ?? [] as $queueType => $ids) { $idx = array_search($fileId, $ids, true); if ($idx !== false) { array_splice($_SESSION['filepond_tmp'][$queueType], $idx, 1); if (empty($_SESSION['filepond_tmp'][$queueType])) { unset($_SESSION['filepond_tmp'][$queueType]); } break; } } } /** * Extract the first available file from $_FILES regardless of nesting depth. */ private function extractUpload(): ?array { foreach ($_FILES as $info) { if (is_array($info) && isset($info['tmp_name']) && is_string($info['tmp_name'])) { return $info; } } if (isset($_FILES['queue_file']['tmp_name'])) { foreach ($_FILES['queue_file']['tmp_name'] as $subValue) { if (is_array($subValue) && isset($subValue[0]) && is_string($subValue[0])) { $subKey = array_key_first($_FILES['queue_file']['tmp_name']); return [ 'name' => $_FILES['queue_file']['name'][$subKey][0] ?? '', 'tmp_name' => $_FILES['queue_file']['tmp_name'][$subKey][0] ?? '', 'error' => $_FILES['queue_file']['error'][$subKey][0] ?? UPLOAD_ERR_NO_FILE, 'size' => $_FILES['queue_file']['size'][$subKey][0] ?? 0, ]; } } } return null; } private function validateMimeExt(string $queueType, string $mimeType, string $ext): void { // Content-Type already set by handleProcess() header $allowedMimes = self::QUEUE_MIME_MAP[$queueType] ?? null; if ($allowedMimes !== null) { if (!in_array($mimeType, $allowedMimes, true)) { http_response_code(415); die("Type de fichier non accepté ($mimeType)."); } } else { if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true) && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { http_response_code(415); die("Type de fichier non accepté ($mimeType / .$ext)."); } } } private function validateSize(string $queueType, string $mimeType, string $ext, int $size): void { $sizeLimit = self::QUEUE_SIZE_LIMITS[$queueType] ?? self::MAX_PDF_SIZE; if ($queueType === 'tfe') { if ($ext === 'pdf' || $mimeType === 'application/pdf') { $sizeLimit = self::MAX_PDF_SIZE; } elseif (in_array($ext, self::AV_EXTENSIONS, true) || str_starts_with($mimeType, 'video/') || str_starts_with($mimeType, 'audio/')) { $sizeLimit = self::MAX_AV_SIZE; } } if ($size > $sizeLimit) { $limitMb = round($sizeLimit / 1024 / 1024); $sizeMb = round($size / 1024 / 1024); http_response_code(413); die("Fichier trop volumineux ($sizeMb MB, max $limitMb MB)."); } } }