maintenance: allow /partage through gate, fix fragment routing, add visibility table in admin

Extract shared filepond logic into src/FilepondHandler.php class.
Admin filepond endpoints delegate to the handler after AdminAuth check.
New partage filepond endpoints at /partage/actions/filepond/ verify
share_active session flag + CSRF token, no admin auth required.

JS reads filepond-base meta tag to determine endpoint path:
- Admin pages: /admin/actions/filepond (via head.php isAdmin check)
- Partage form: /partage/actions/filepond (explicit meta)

partage/index.php sets share_active = true on form render, cleans up on
successful submit. Partage process endpoint rate-limited to 30/5min per
session. No nginx changes needed — /partage/ location already handles
PHP without auth_basic.
This commit is contained in:
Pontoporeia
2026-05-12 15:19:32 +02:00
parent da153fc604
commit 6f7a02244f
22 changed files with 15010 additions and 532 deletions

View File

@@ -1,6 +1,6 @@
<?php
/**
* FilePond load endpoint — streams an existing thesis file back to FilePond.
* FilePond load endpoint — streams an existing thesis file back to FilePond (admin).
*
* GET /admin/actions/filepond/load.php?id={db_id}
*
@@ -9,77 +9,10 @@
require_once __DIR__ . '/../../../../bootstrap.php';
require_once __DIR__ . '/../../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../../src/ErrorHandler.php';
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
// ── Auth (admin only) ────────────────────────────────────────────────────
AdminAuth::requireLogin();
// ── Only accept GET ──────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
die('Méthode non autorisée.');
}
// ── Validate db_id ───────────────────────────────────────────────────────
$dbId = filter_var($_GET['id'] ?? '', FILTER_VALIDATE_INT);
if ($dbId === false || $dbId <= 0) {
http_response_code(400);
die('ID invalide.');
}
// ── Look up file in DB (validation of thesis ownership is implicit via admin auth + DB lookup) ──
require_once APP_ROOT . '/src/Database.php';
$db = new Database();
// Fetch thesis_files row by its own primary key
$pdo = $db->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'] ?? '';
$fileName = $fileRow['file_name'] ?? basename($filePath);
$mimeType = $fileRow['mime_type'] ?? 'application/octet-stream';
// ── PeerTube entries: return a placeholder SVG blob so FilePond can display them ─┐
if (str_starts_with($filePath, 'peertube_ids:')) {
$uuid = substr($filePath, strlen('peertube_ids:'));
$isVideo = ($fileRow['file_type'] ?? '') === 'video';
$svg = $isVideo
? '<svg xmlns="http://www.w3.org/2000/svg" width="180" height="120" viewBox="0 0 180 120"><rect width="180" height="120" fill="#1a1a2e"/><polygon points="70,35 70,85 125,60" fill="#e94560"/><text x="90" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#aaa">PeerTube ' . htmlspecialchars($uuid) . '</text></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="180" height="120" viewBox="0 0 180 120"><rect width="180" height="120" fill="#1a1a2e"/><circle cx="55" cy="60" r="20" fill="none" stroke="#4ecca3" stroke-width="3"/><line x1="72" y1="48" x2="95" y2="38" stroke="#4ecca3" stroke-width="3"/><line x1="72" y1="60" x2="110" y2="60" stroke="#4ecca3" stroke-width="3"/><line x1="72" y1="72" x2="95" y2="82" stroke="#4ecca3" stroke-width="3"/><text x="90" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#aaa">PeerTube ' . htmlspecialchars($uuid) . '</text></svg>';
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://')) {
http_response_code(404);
die('URL — pas de flux direct.');
}
// ── Resolve absolute path ────────────────────────────────────────────────
$absPath = STORAGE_ROOT . '/' . $filePath;
if (!file_exists($absPath) || !is_readable($absPath)) {
http_response_code(404);
die('Fichier absent du disque.');
}
// ── Stream the file ──────────────────────────────────────────────────────
$fileSize = filesize($absPath);
// Content-Disposition: inline so FilePond receives it as a valid file
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . $fileSize);
header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"');
header('Cache-Control: no-cache');
readfile($absPath);
exit;
$handler = new FilepondHandler('[filepond:admin]');
$handler->handleLoad();

View File

@@ -1,6 +1,6 @@
<?php
/**
* FilePond process endpoint — receives one file per request.
* FilePond process endpoint — receives one file per request (admin).
*
* POST /admin/actions/filepond/process.php
* Headers: X-CSRF-Token
@@ -11,262 +11,19 @@
require_once __DIR__ . '/../../../../bootstrap.php';
require_once __DIR__ . '/../../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../../src/ErrorHandler.php';
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
// ── Auth (admin only) ────────────────────────────────────────────────────
AdminAuth::requireLogin();
error_log('[filepond:process] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | post_keys=' . implode(',', array_keys($_POST)));
// ── CSRF via header ──────────────────────────────────────────────────────
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
error_log('[filepond:process] CSRF header present=' . ($csrfHeader !== '' ? 'yes' : 'no') . ' | session_token=' . (isset($_SESSION['csrf_token']) ? 'set' : 'missing'));
if (!isset($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
error_log('[filepond:process] CSRF FAIL — header=' . substr($csrfHeader, 0, 8) . '... session=' . substr($_SESSION['csrf_token'] ?? '', 0, 8) . '...');
error_log('[filepond:admin:process] CSRF FAIL');
http_response_code(403);
die('Token CSRF invalide.');
}
// ── Only accept POST ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
die('Méthode non autorisée.');
}
// ── Validate presence of file ────────────────────────────────────────────
// FilePond sends one file per POST. The field name depends on the input name attribute.
//
// Single-file inputs (cover, note_intention) arrive flat:
// $_FILES = ['couverture' => ['name' => 'img.png', 'tmp_name' => '/tmp/...', ...]]
//
// Multi-file queue inputs (queue_file[tfe][], queue_file[annexe][], etc.) arrive nested:
// $_FILES = ['queue_file' => ['name' => ['tfe' => ['file1.pdf']], 'tmp_name' => ['tfe' => ['/tmp/...']], ...]]
//
// We extract the first available file entry regardless of nesting depth.
$upload = null;
// Try flat structure first (single-file inputs)
foreach ($_FILES as $key => $info) {
if (is_array($info) && isset($info['tmp_name']) && is_string($info['tmp_name'])) {
$upload = $info;
break;
}
}
// Try nested queue structure: $_FILES['queue_file']['tmp_name'][<subkey>][0]
if ($upload === null && isset($_FILES['queue_file']['tmp_name'])) {
foreach ($_FILES['queue_file']['tmp_name'] as $subKey => $subValue) {
if (is_array($subValue) && isset($subValue[0]) && is_string($subValue[0])) {
$upload = [
'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,
];
break;
}
}
}
if ($upload === null) {
error_log('[filepond: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('[filepond: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('[filepond:process] Received file | name=' . $upload['name'] . ' | size=' . $upload['size'] . ' | queue_type=' . $queueType);
// ── MIME / extension whitelist (mirrored from ThesisFileHandler) ─────────
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',
];
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',
];
// Per-queue-type constraints
const QUEUE_MIME_MAP = [
'cover' => ['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'],
];
const QUEUE_SIZE_LIMITS = [
'cover' => 20 * 1024 * 1024, // 20 MB
'note_intention' => 100 * 1024 * 1024, // 100 MB
'tfe' => 500 * 1024 * 1024, // 500 MB (per-file, but per-extension overrides below)
'video' => 500 * 1024 * 1024, // 500 MB
'audio' => 500 * 1024 * 1024, // 500 MB
'annexe' => 500 * 1024 * 1024, // 500 MB
'peertube_video' => 500 * 1024 * 1024, // 500 MB
'peertube_audio' => 500 * 1024 * 1024, // 500 MB
];
// Per-extension overrides for TFE (from ThesisFileHandler constants)
const AV_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a'];
const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 MB
const MAX_AV_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB
// ── MIME detection ───────────────────────────────────────────────────────
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($upload['tmp_name']);
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
error_log('[filepond:process] MIME detected | mime=' . $mimeType . ' | ext=' . $ext);
// Fix common mismatches
if ($mimeType === 'text/plain' && $ext === 'vtt') {
$mimeType = 'text/vtt';
}
if ($mimeType === 'audio/mpeg' && $ext === 'mp3') {
$mimeType = 'audio/mp3';
}
// ── Validate MIME / extension ────────────────────────────────────────────
$allowedMimes = 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 {
// Full whitelist
if (!in_array($mimeType, ALLOWED_MIME_TYPES, true)
&& !in_array($ext, ALLOWED_EXTENSIONS, true)) {
http_response_code(415);
die("Type de fichier non accepté ($mimeType / .$ext).");
}
}
// ── Validate size ────────────────────────────────────────────────────────
$sizeLimit = QUEUE_SIZE_LIMITS[$queueType] ?? MAX_PDF_SIZE;
// Per-extension overrides for TFE queue (PDF=100MB, AV=2GB)
if ($queueType === 'tfe') {
if ($ext === 'pdf' || $mimeType === 'application/pdf') {
$sizeLimit = MAX_PDF_SIZE;
} elseif (in_array($ext, AV_EXTENSIONS, true)
|| str_starts_with($mimeType, 'video/')
|| str_starts_with($mimeType, 'audio/')) {
$sizeLimit = MAX_AV_SIZE;
}
}
if ($upload['size'] > $sizeLimit) {
$limitMb = round($sizeLimit / 1024 / 1024);
$sizeMb = round($upload['size'] / 1024 / 1024);
http_response_code(413);
die("Fichier trop volumineux ($sizeMb MB, max $limitMb MB).");
}
// ── Generate unique file_id ──────────────────────────────────────────────
$fileId = bin2hex(random_bytes(16)); // 32-char hex
// ── Save to tmp/filepond/{file_id}/ ──────────────────────────────────────
$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId;
if (!mkdir($tmpDir, 0755, true)) {
error_log('[filepond: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('[filepond:process] move_uploaded_file FAILED | from=' . $upload['tmp_name'] . ' | to=' . $targetPath);
rmdir($tmpDir); // clean up empty dir
http_response_code(500);
die('Erreur serveur — échec du déplacement du fichier.');
}
chmod($targetPath, 0644);
error_log('[filepond:process] File saved to tmp | file_id=' . $fileId . ' | path=' . $targetPath);
// ── PeerTube: upload immediately (don't wait for form submit) ────────────
// Handles both dedicated peertube_* queues (legacy) and video/audio in the TFE pool
$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, // title — will be overridden by form submit metadata
''
);
// Return a special ID prefix so the controller knows not to look in tmp/
// Format: peertube:video:UUID or peertube:audio:UUID
$fileId = 'peertube:' . $ptFileType . ':' . $result['uuid'];
// Clean up temp file — PeerTube has its own copy now
@unlink($targetPath);
@rmdir($tmpDir);
error_log('[filepond: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('[filepond:process] PeerTube upload FAILED: ' . $e->getMessage());
http_response_code(500);
die('Erreur lors du téléversement vers PeerTube.');
}
} else {
// PeerTube not enabled — save to disk normally (only for tfe pool, not dedicated peertube queues)
if ($isPeerTubeQueue) {
@unlink($targetPath);
@rmdir($tmpDir);
http_response_code(503);
die('PeerTube n\'est pas activé.');
}
// For TFE pool, fall through to normal disk save below
}
}
// ── Write manifest ───────────────────────────────────────────────────────
$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));
// ── Return file_id as plain text ─────────────────────────────────────────
error_log('[filepond:process] SUCCESS | file_id=' . $fileId . ' | queue_type=' . $queueType . ' | name=' . $originalName);
header('Content-Type: text/plain; charset=utf-8');
echo $fileId;
$handler = new FilepondHandler('[filepond:admin]');
$handler->handleProcess();

View File

@@ -1,6 +1,6 @@
<?php
/**
* FilePond remove endpoint — soft-deletes an already-saved thesis_files row.
* FilePond remove endpoint — soft-deletes a thesis_files row (admin).
*
* DELETE /admin/actions/filepond/remove.php
* Body: JSON { "db_id": 123 }
@@ -10,8 +10,9 @@
require_once __DIR__ . '/../../../../bootstrap.php';
require_once __DIR__ . '/../../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../../src/ErrorHandler.php';
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
// ── Auth (admin only) ────────────────────────────────────────────────────
AdminAuth::requireLogin();
// ── CSRF via header ──────────────────────────────────────────────────────
@@ -22,59 +23,5 @@ if (!isset($_SESSION['csrf_token'])
die('Token CSRF invalide.');
}
// ── Only accept DELETE ───────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'DELETE') {
http_response_code(405);
die('Méthode non autorisée.');
}
// ── Parse JSON body ──────────────────────────────────────────────────────
$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.');
}
// ── Look up and soft-delete ──────────────────────────────────────────────
require_once APP_ROOT . '/src/Database.php';
$db = new Database();
$pdo = $db->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.');
}
// ── Move physical file to _trash/ for recovery ───────────────────────────
$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 . '/' . basename($filePath);
// Append db_id to avoid name collisions
$trashPath = $trashDir . '/' . $dbId . '_' . basename($filePath);
rename($absPath, $trashPath);
}
}
// ── Soft-delete the row (set deleted_at timestamp) ───────────────────────
// thesis_files may not have a deleted_at column; delete outright for now.
$delStmt = $pdo->prepare('DELETE FROM thesis_files WHERE id = ?');
$delStmt->execute([$dbId]);
http_response_code(200);
exit;
$handler = new FilepondHandler('[filepond:admin]');
$handler->handleRemove();

View File

@@ -1,6 +1,6 @@
<?php
/**
* FilePond revert endpoint — deletes a just-uploaded tmp file.
* FilePond revert endpoint — deletes a just-uploaded tmp file (admin).
*
* DELETE /admin/actions/filepond/revert.php
* Body: plain text file_id
@@ -10,7 +10,9 @@
require_once __DIR__ . '/../../../../bootstrap.php';
require_once __DIR__ . '/../../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
// ── Auth (admin only) ────────────────────────────────────────────────────
AdminAuth::requireLogin();
// ── CSRF via header ──────────────────────────────────────────────────────
@@ -21,55 +23,5 @@ if (!isset($_SESSION['csrf_token'])
die('Token CSRF invalide.');
}
// ── Only accept DELETE ───────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'DELETE') {
http_response_code(405);
die('Méthode non autorisée.');
}
// ── Read file_id from body ───────────────────────────────────────────────
$fileId = trim(file_get_contents('php://input'));
// PeerTube files have a special prefix; nothing to clean up locally
// Format: peertube:video:UUID or peertube:audio:UUID
if (str_starts_with($fileId, 'peertube:')) {
// PeerTube files are already uploaded; we don't delete them from PeerTube on revert
// (the user might still submit and associate them)
http_response_code(200);
exit;
}
if ($fileId === '' || !preg_match('/^[a-f0-9]{32}$/', $fileId)) {
http_response_code(400);
die('ID de fichier invalide.');
}
// ── Verify tmp directory exists and manifest matches session ─────────────
$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.');
}
// ── Delete directory recursively ─────────────────────────────────────────
$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;
$handler = new FilepondHandler('[filepond:admin]');
$handler->handleRevert();