mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
@@ -39,11 +39,12 @@ require_once APP_ROOT . '/src/App.php';
|
||||
// The flag file lives in storage/ (outside webroot) to avoid web exposure.
|
||||
define('MAINTENANCE_FLAG', APP_ROOT . '/storage/maintenance.flag');
|
||||
if (file_exists(MAINTENANCE_FLAG)) {
|
||||
// Allow admin panel through (by path prefix) and the maintenance page itself
|
||||
// Allow admin panel, partage pages (path prefix), and the maintenance page itself
|
||||
$requestPath = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$isAdmin = str_starts_with($requestPath, '/admin');
|
||||
$isAdmin = str_starts_with($requestPath, '/admin');
|
||||
$isPartage = str_starts_with($requestPath, '/partage');
|
||||
$isMaintenance = str_contains($requestPath, 'maintenance.php');
|
||||
if (!$isAdmin && !$isMaintenance) {
|
||||
if (!$isAdmin && !$isPartage && !$isMaintenance) {
|
||||
require APP_ROOT . '/public/maintenance.php';
|
||||
exit();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1135,6 +1135,69 @@ th.admin-ap-col {
|
||||
.admin-import-log__item--error::before { content: '✗'; color: var(--error); }
|
||||
|
||||
/* ── Paramètres page (flat, semantic) ──────────────────────────────────── */
|
||||
.param-access-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin-bottom: var(--space-m);
|
||||
font-size: var(--step--1);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.param-access-table caption {
|
||||
caption-side: top;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-2xs);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.param-access-table th,
|
||||
.param-access-table td {
|
||||
padding: var(--space-2xs) var(--space-s);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.param-access-table th:not(:last-child),
|
||||
.param-access-table td:not(:last-child) {
|
||||
border-right: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.param-access-table thead th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.param-access-table thead tr:first-child th:first-child {
|
||||
border-top-left-radius: var(--radius);
|
||||
}
|
||||
|
||||
.param-access-table thead tr:first-child th:last-child {
|
||||
border-top-right-radius: var(--radius);
|
||||
}
|
||||
|
||||
.param-access-table tbody tr:last-child td:first-child {
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
|
||||
.param-access-table tbody tr:last-child td:last-child {
|
||||
border-bottom-right-radius: var(--radius);
|
||||
}
|
||||
|
||||
.param-access-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.param-access-yes {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.param-access-no {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.param-maintenance-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
* 8. Edit mode: loads existing files via data-existing-files JSON + server.load.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
// ── Per-queue-type configuration ────────────────────────────────────
|
||||
// Single source of truth for validation. These specificatons are also
|
||||
// reflected in the PHP-synthesised accept attributes on inputs.
|
||||
@@ -26,15 +24,29 @@
|
||||
var QUEUE_CONFIG = {
|
||||
tfe: {
|
||||
acceptedFileTypes: [
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"video/mp4", "video/webm", "video/ogg", "video/quicktime",
|
||||
"audio/mpeg", "audio/ogg", "audio/flac", "audio/x-wav", "audio/aac", "audio/mp4",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/x-wav",
|
||||
"audio/aac",
|
||||
"audio/mp4",
|
||||
"text/vtt",
|
||||
"application/zip", "application/x-tar", "application/gzip"
|
||||
"application/zip",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "PDF, Images, Vidéos, Audio, VTT, Archives",
|
||||
fileValidateTypeLabelExpectedTypes:
|
||||
"PDF, Images, Vidéos, Audio, VTT, Archives",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
@@ -42,18 +54,32 @@
|
||||
// Per-extension size limits: certain types get higher caps.
|
||||
perExtensionMaxSize: {
|
||||
pdf: "100MB",
|
||||
mp4: "2GB", webm: "2GB", ogv: "2GB", mov: "2GB",
|
||||
mp3: "2GB", ogg: "2GB", oga: "2GB", wav: "2GB", flac: "2GB", aac: "2GB", m4a: "2GB"
|
||||
}
|
||||
mp4: "2GB",
|
||||
webm: "2GB",
|
||||
ogv: "2GB",
|
||||
mov: "2GB",
|
||||
mp3: "2GB",
|
||||
ogg: "2GB",
|
||||
oga: "2GB",
|
||||
wav: "2GB",
|
||||
flac: "2GB",
|
||||
aac: "2GB",
|
||||
m4a: "2GB",
|
||||
},
|
||||
},
|
||||
annexe: {
|
||||
acceptedFileTypes: ["application/pdf", "application/zip", "application/x-tar", "application/gzip"],
|
||||
acceptedFileTypes: [
|
||||
"application/pdf",
|
||||
"application/zip",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "PDF, ZIP, TAR, GZ",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: true
|
||||
allowMultiple: true,
|
||||
},
|
||||
cover: {
|
||||
acceptedFileTypes: ["image/jpeg", "image/png", "image/webp"],
|
||||
@@ -62,7 +88,7 @@
|
||||
maxFileSize: "20MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: false
|
||||
allowMultiple: false,
|
||||
},
|
||||
note_intention: {
|
||||
acceptedFileTypes: ["application/pdf"],
|
||||
@@ -71,7 +97,7 @@
|
||||
maxFileSize: "100MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: false
|
||||
allowMultiple: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -85,7 +111,13 @@
|
||||
if (!m) return 0;
|
||||
var val = parseFloat(m[1]);
|
||||
var unit = m[2].toUpperCase();
|
||||
var mult = {B: 1, KB: 1024, MB: 1024*1024, GB: 1024*1024*1024, TB: 1024*1024*1024*1024};
|
||||
var mult = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024,
|
||||
TB: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
return Math.round(val * (mult[unit] || 1));
|
||||
}
|
||||
|
||||
@@ -102,7 +134,16 @@
|
||||
*/
|
||||
function getCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
return meta ? meta.getAttribute("content") : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the FilePond endpoint base URL from meta tag.
|
||||
* Defaults to /admin/actions/filepond/ for backward compat.
|
||||
*/
|
||||
function getFilepondBase() {
|
||||
var meta = document.querySelector('meta[name="filepond-base"]');
|
||||
return meta ? meta.getAttribute("content") : "/admin/actions/filepond";
|
||||
}
|
||||
|
||||
// ── Order serialization ───────────────────────────────────────────────
|
||||
@@ -120,10 +161,14 @@
|
||||
var files = pond.getFiles();
|
||||
|
||||
// Remove old order input and all queue_file hidden inputs for this queueType
|
||||
var oldOrder = form.querySelector("input[name='queue_order[" + queueType + "]']");
|
||||
var oldOrder = form.querySelector(
|
||||
`input[name='queue_order[${queueType}]']`,
|
||||
);
|
||||
if (oldOrder) oldOrder.remove();
|
||||
|
||||
var oldHidden = form.querySelectorAll("input[name='queue_file[" + queueType + "][]'][data-filepond-id]");
|
||||
var oldHidden = form.querySelectorAll(
|
||||
`input[name='queue_file[${queueType}][]'][data-filepond-id]`,
|
||||
);
|
||||
for (var h = 0; h < oldHidden.length; h++) {
|
||||
oldHidden[h].remove();
|
||||
}
|
||||
@@ -140,7 +185,7 @@
|
||||
ids.push(id);
|
||||
var hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = "queue_file[" + queueType + "][]";
|
||||
hidden.name = `queue_file[${queueType}][]`;
|
||||
hidden.value = id;
|
||||
hidden.setAttribute("data-filepond-id", "1");
|
||||
form.appendChild(hidden);
|
||||
@@ -151,7 +196,7 @@
|
||||
if (ids.length > 0) {
|
||||
var orderInput = document.createElement("input");
|
||||
orderInput.type = "hidden";
|
||||
orderInput.name = "queue_order[" + queueType + "]";
|
||||
orderInput.name = `queue_order[${queueType}]`;
|
||||
orderInput.value = ids.join("|");
|
||||
form.appendChild(orderInput);
|
||||
}
|
||||
@@ -161,65 +206,94 @@
|
||||
|
||||
function buildServerConfig(queueType) {
|
||||
var csrfToken = getCsrfToken();
|
||||
console.log('[filepond] buildServerConfig | queueType=' + queueType + ' | csrfToken=' + (csrfToken ? csrfToken.substring(0, 8) + '...' : 'MISSING'));
|
||||
console.log(
|
||||
"[filepond] buildServerConfig | queueType=" +
|
||||
queueType +
|
||||
" | csrfToken=" +
|
||||
(csrfToken ? `${csrfToken.substring(0, 8)}...` : "MISSING"),
|
||||
);
|
||||
|
||||
var base = getFilepondBase();
|
||||
console.log(
|
||||
"[filepond] buildServerConfig | queueType=" +
|
||||
queueType +
|
||||
" | csrfToken=" +
|
||||
(csrfToken ? `${csrfToken.substring(0, 8)}...` : "MISSING") +
|
||||
" | base=" +
|
||||
base,
|
||||
);
|
||||
|
||||
return {
|
||||
process: {
|
||||
url: '/admin/actions/filepond/process.php',
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
ondata: function (formData) {
|
||||
formData.append('queue_type', queueType);
|
||||
console.log('[filepond] process ondata | queueType=' + queueType);
|
||||
url: `${base}/process.php`,
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrfToken },
|
||||
ondata: (formData) => {
|
||||
formData.append("queue_type", queueType);
|
||||
console.log(`[filepond] process ondata | queueType=${queueType}`);
|
||||
return formData;
|
||||
},
|
||||
onload: function (response) {
|
||||
onload: (response) => {
|
||||
var id = response.trim();
|
||||
console.log('[filepond] process onload | serverId=' + id);
|
||||
console.log(`[filepond] process onload | serverId=${id}`);
|
||||
return id; // file_id stored as serverId
|
||||
},
|
||||
onerror: function (response) {
|
||||
console.error('[filepond] process onerror | status=' + response.status + ' | body=' + response);
|
||||
onerror: (response) => {
|
||||
console.error(
|
||||
"[filepond] process onerror | status=" +
|
||||
response.status +
|
||||
" | body=" +
|
||||
response,
|
||||
);
|
||||
return response;
|
||||
},
|
||||
},
|
||||
|
||||
revert: {
|
||||
url: '/admin/actions/filepond/revert.php',
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
onload: function () { console.log('[filepond] revert OK'); },
|
||||
onerror: function (r) { console.error('[filepond] revert ERROR | body=' + r); },
|
||||
url: `${base}/revert.php`,
|
||||
method: "DELETE",
|
||||
headers: { "X-CSRF-Token": csrfToken },
|
||||
onload: () => {
|
||||
console.log("[filepond] revert OK");
|
||||
},
|
||||
onerror: (r) => {
|
||||
console.error(`[filepond] revert ERROR | body=${r}`);
|
||||
},
|
||||
},
|
||||
|
||||
load: '/admin/actions/filepond/load.php?id=',
|
||||
load: `${base}/load.php?id=`,
|
||||
// FilePond appends the source value (db_id) automatically
|
||||
|
||||
remove: function (source, load, error) {
|
||||
console.log('[filepond] remove called | db_id=' + source);
|
||||
fetch('/admin/actions/filepond/remove.php', {
|
||||
method: 'DELETE',
|
||||
remove: (source, load, error) => {
|
||||
console.log(`[filepond] remove called | db_id=${source}`);
|
||||
fetch(`${base}/remove.php`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ db_id: source }),
|
||||
})
|
||||
.then(function (r) {
|
||||
console.log('[filepond] remove response | ok=' + r.ok + ' | status=' + r.status);
|
||||
r.ok ? load() : error('Erreur suppression');
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('[filepond] remove fetch error', e);
|
||||
error('Erreur réseau');
|
||||
});
|
||||
.then((r) => {
|
||||
console.log(
|
||||
"[filepond] remove response | ok=" +
|
||||
r.ok +
|
||||
" | status=" +
|
||||
r.status,
|
||||
);
|
||||
r.ok ? load() : error("Erreur suppression");
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[filepond] remove fetch error", e);
|
||||
error("Erreur réseau");
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── FilePond configuration per queue type ─────────────────────────────
|
||||
|
||||
function buildFilePondOptions(queueType, input) {
|
||||
function buildFilePondOptions(queueType, _input) {
|
||||
var cfg = QUEUE_CONFIG[queueType];
|
||||
if (!cfg) return null;
|
||||
|
||||
@@ -236,13 +310,15 @@
|
||||
// ── Native FilePond validation ──
|
||||
acceptedFileTypes: cfg.acceptedFileTypes,
|
||||
labelFileTypeNotAllowed: cfg.labelFileTypeNotAllowed,
|
||||
fileValidateTypeLabelExpectedTypes: cfg.fileValidateTypeLabelExpectedTypes,
|
||||
fileValidateTypeLabelExpectedTypes:
|
||||
cfg.fileValidateTypeLabelExpectedTypes,
|
||||
maxFileSize: cfg.maxFileSize,
|
||||
labelMaxFileSizeExceeded: cfg.labelMaxFileSizeExceeded,
|
||||
labelMaxFileSize: cfg.labelMaxFileSize,
|
||||
|
||||
// ── French labels ──
|
||||
labelIdle: "Glissez-déposez vos fichiers ou <span class='filepond--label-action'>Parcourir</span>",
|
||||
labelIdle:
|
||||
"Glissez-déposez vos fichiers ou <span class='filepond--label-action'>Parcourir</span>",
|
||||
labelFileProcessing: "Chargement en cours",
|
||||
labelFileProcessingComplete: "Chargement terminé",
|
||||
labelFileProcessingAborted: "Chargement annulé",
|
||||
@@ -258,20 +334,20 @@
|
||||
// ── Per-extension size validation ──────────────────────────────
|
||||
// Uses fileValidateSizeFilterItem if the FileValidateSize plugin supports it.
|
||||
// Falls back to beforeAddFile for silent rejection (the plugin shows the error).
|
||||
fileValidateSizeFilterItem: function (item) {
|
||||
fileValidateSizeFilterItem: (item) => {
|
||||
var ext = getExt(item.filename);
|
||||
if (ext && perExtMax[ext]) {
|
||||
return parseSize(perExtMax[ext]); // per-extension cap for this item
|
||||
}
|
||||
return parseSize(cfg.maxFileSize); // queue default
|
||||
return parseSize(cfg.maxFileSize); // queue default
|
||||
},
|
||||
|
||||
// Fallback: if fileValidateSizeFilterItem is not available,
|
||||
// beforeAddFile enforces per-extension limits (silent rejection).
|
||||
beforeAddFile: function (item) {
|
||||
beforeAddFile: (item) => {
|
||||
// This check is redundant if fileValidateSizeFilterItem works,
|
||||
// but serves as a fallback.
|
||||
if (typeof item.file === 'undefined') return true;
|
||||
if (typeof item.file === "undefined") return true;
|
||||
var f = item.file;
|
||||
var ext = getExt(f.name);
|
||||
if (ext && perExtMax[ext]) {
|
||||
@@ -284,13 +360,21 @@
|
||||
},
|
||||
|
||||
// ── Order serialization on add/remove/reorder ──
|
||||
onaddfile: function () { syncOrderInput(queueType, this); },
|
||||
onremovefile: function () { syncOrderInput(queueType, this); },
|
||||
onreorderfiles: function () { syncOrderInput(queueType, this); },
|
||||
onupdatefiles: function () { syncOrderInput(queueType, this); },
|
||||
onaddfile: function () {
|
||||
syncOrderInput(queueType, this);
|
||||
},
|
||||
onremovefile: function () {
|
||||
syncOrderInput(queueType, this);
|
||||
},
|
||||
onreorderfiles: function () {
|
||||
syncOrderInput(queueType, this);
|
||||
},
|
||||
onupdatefiles: function () {
|
||||
syncOrderInput(queueType, this);
|
||||
},
|
||||
|
||||
// Re-sync after async upload completes (serverId is now set)
|
||||
onprocessfile: function (error, item) {
|
||||
onprocessfile: function (error, _item) {
|
||||
if (!error) syncOrderInput(queueType, this);
|
||||
},
|
||||
};
|
||||
@@ -302,8 +386,8 @@
|
||||
* Upgrade .tfe-file-picker inputs to FilePond instances.
|
||||
* Called on page load and after HTMX swaps.
|
||||
*/
|
||||
window.XamxamInitFilePonds = function () {
|
||||
document.querySelectorAll(".tfe-file-picker").forEach(function (input) {
|
||||
window.XamxamInitFilePonds = () => {
|
||||
document.querySelectorAll(".tfe-file-picker").forEach((input) => {
|
||||
// Canonical duplicate check: FilePond.find() is the authoritative source
|
||||
if (FilePond.find(input)) return;
|
||||
|
||||
@@ -315,7 +399,14 @@
|
||||
if (!options) return;
|
||||
|
||||
var pond = FilePond.create(input, options);
|
||||
console.log('[filepond] Created instance | queueType=' + queueType + ' | inputId=' + (input.id || 'none') + ' | inputName=' + (input.getAttribute('name') || input.name || '?'));
|
||||
console.log(
|
||||
"[filepond] Created instance | queueType=" +
|
||||
queueType +
|
||||
" | inputId=" +
|
||||
(input.id || "none") +
|
||||
" | inputName=" +
|
||||
(input.getAttribute("name") || input.name || "?"),
|
||||
);
|
||||
|
||||
// Initial order serialization
|
||||
syncOrderInput(queueType, pond);
|
||||
@@ -323,13 +414,13 @@
|
||||
// ── Edit mode: load existing files ──
|
||||
var existingFiles = [];
|
||||
try {
|
||||
existingFiles = JSON.parse(input.dataset.existingFiles || '[]');
|
||||
existingFiles = JSON.parse(input.dataset.existingFiles || "[]");
|
||||
} catch (_) {}
|
||||
|
||||
if (existingFiles.length) {
|
||||
pond.addFiles(existingFiles.map(function (f) {
|
||||
return { source: f.source, options: f.options };
|
||||
}));
|
||||
pond.addFiles(
|
||||
existingFiles.map((f) => ({ source: f.source, options: f.options })),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -340,7 +431,7 @@
|
||||
*/
|
||||
function destroyFilePondsIn(el) {
|
||||
if (!el) return;
|
||||
el.querySelectorAll(".tfe-file-picker").forEach(function (input) {
|
||||
el.querySelectorAll(".tfe-file-picker").forEach((input) => {
|
||||
var pond = FilePond.find(input);
|
||||
if (pond) {
|
||||
try {
|
||||
@@ -349,9 +440,15 @@
|
||||
if (form) {
|
||||
var queueType = input.dataset.queueType || null;
|
||||
if (queueType) {
|
||||
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
|
||||
var orderInput = form.querySelector(
|
||||
`input[name='queue_order[${queueType}]']`,
|
||||
);
|
||||
if (orderInput) orderInput.remove();
|
||||
var hiddenInputs = form.querySelectorAll("input[name='queue_file[" + queueType + "][]'][data-filepond-id]");
|
||||
var hiddenInputs = form.querySelectorAll(
|
||||
"input[name='queue_file[" +
|
||||
queueType +
|
||||
"][]'][data-filepond-id]",
|
||||
);
|
||||
for (var h = 0; h < hiddenInputs.length; h++) {
|
||||
hiddenInputs[h].remove();
|
||||
}
|
||||
@@ -379,26 +476,38 @@
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────
|
||||
|
||||
// Global FilePond event listeners for debugging
|
||||
document.addEventListener('FilePond:processfile', function (e) {
|
||||
console.log('[filepond:event] processfile | id=' + (e.detail.file ? e.detail.file.serverId : '') + ' | error=' + (e.detail.error || 'none'));
|
||||
document.addEventListener("FilePond:processfile", (e) => {
|
||||
console.log(
|
||||
"[filepond:event] processfile | id=" +
|
||||
(e.detail.file ? e.detail.file.serverId : "") +
|
||||
" | error=" +
|
||||
(e.detail.error || "none"),
|
||||
);
|
||||
});
|
||||
document.addEventListener('FilePond:processfilestart', function (e) {
|
||||
console.log('[filepond:event] processfilestart | filename=' + (e.detail.file ? e.detail.file.filename : '?'));
|
||||
document.addEventListener("FilePond:processfilestart", (e) => {
|
||||
console.log(
|
||||
"[filepond:event] processfilestart | filename=" +
|
||||
(e.detail.file ? e.detail.file.filename : "?"),
|
||||
);
|
||||
});
|
||||
document.addEventListener('FilePond:processfileprogress', function (e) {
|
||||
document.addEventListener("FilePond:processfileprogress", (e) => {
|
||||
var pct = e.detail.progress;
|
||||
if (pct && (pct === 0 || pct === 1 || Math.floor(pct * 100) % 25 === 0)) {
|
||||
console.log('[filepond:event] processfileprogress | pct=' + Math.floor(pct * 100) + '%');
|
||||
console.log(
|
||||
"[filepond:event] processfileprogress | pct=" +
|
||||
Math.floor(pct * 100) +
|
||||
"%",
|
||||
);
|
||||
}
|
||||
});
|
||||
document.addEventListener('FilePond:processfileabort', function (e) {
|
||||
console.log('[filepond:event] processfileabort');
|
||||
document.addEventListener("FilePond:processfileabort", (_e) => {
|
||||
console.log("[filepond:event] processfileabort");
|
||||
});
|
||||
document.addEventListener('FilePond:processfilerevert', function (e) {
|
||||
console.log('[filepond:event] processfilerevert');
|
||||
document.addEventListener("FilePond:processfilerevert", (_e) => {
|
||||
console.log("[filepond:event] processfilerevert");
|
||||
});
|
||||
document.addEventListener('FilePond:error', function (e) {
|
||||
console.error('[filepond:event] error', e.detail);
|
||||
document.addEventListener("FilePond:error", (e) => {
|
||||
console.error("[filepond:event] error", e.detail);
|
||||
});
|
||||
|
||||
// Register FilePond plugins (idempotent)
|
||||
@@ -417,13 +526,13 @@
|
||||
|
||||
if (window.htmx) {
|
||||
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
|
||||
window.htmx.on("htmx:afterSwap", function () {
|
||||
window.htmx.on("htmx:afterSwap", () => {
|
||||
window.XamxamInitFilePonds();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
window.XamxamInitFilePonds();
|
||||
});
|
||||
} else {
|
||||
@@ -431,15 +540,14 @@
|
||||
}
|
||||
|
||||
// ── Mark form dirty on FilePond changes (beforeunload guard) ─────────
|
||||
document.addEventListener("FilePond:addfile", function () {
|
||||
document.addEventListener("FilePond:addfile", () => {
|
||||
window.__xamxamDirty = true;
|
||||
});
|
||||
|
||||
document.addEventListener("submit", function (e) {
|
||||
document.addEventListener("submit", (e) => {
|
||||
var form = e.target;
|
||||
if (form && form.hasAttribute && form.hasAttribute("data-beforeunload-guard")) {
|
||||
if (form?.hasAttribute?.("data-beforeunload-guard")) {
|
||||
window.__xamxamDirty = false;
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
// This page is served directly by nginx / bootstrap when maintenance mode is active.
|
||||
// It is also served by the public gate in bootstrap.php.
|
||||
// Admin and partage pages remain accessible during maintenance.
|
||||
http_response_code(503);
|
||||
header('Retry-After: 3600');
|
||||
?><!DOCTYPE html>
|
||||
|
||||
35
app/public/partage/actions/filepond/load.php
Normal file
35
app/public/partage/actions/filepond/load.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond load endpoint — streams an existing thesis file back to FilePond (partage).
|
||||
*
|
||||
* GET /partage/actions/filepond/load.php?id={db_id}
|
||||
*
|
||||
* Auth: requires an active partage session (share_active flag).
|
||||
*
|
||||
* Used in edit mode to restore saved files into the FilePond UI.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
|
||||
|
||||
// ── Start session ────────────────────────────────────────────────────────
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$isSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// ── Auth: must have an active partage session ────────────────────────────
|
||||
if (empty($_SESSION['share_active'])) {
|
||||
http_response_code(403);
|
||||
die('Accès refusé.');
|
||||
}
|
||||
|
||||
$handler = new FilepondHandler('[filepond:partage]');
|
||||
$handler->handleLoad();
|
||||
56
app/public/partage/actions/filepond/process.php
Normal file
56
app/public/partage/actions/filepond/process.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond process endpoint — receives one file per request (partage).
|
||||
*
|
||||
* POST /partage/actions/filepond/process.php
|
||||
* Headers: X-CSRF-Token
|
||||
* Fields: file (multipart), queue_type (string)
|
||||
*
|
||||
* Auth: requires an active partage session (share_active flag) + CSRF token.
|
||||
*
|
||||
* Returns plain text file_id on success (200), or error message on failure (4xx).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
|
||||
|
||||
// ── Start session ────────────────────────────────────────────────────────
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$isSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// ── Auth: must have an active partage session ────────────────────────────
|
||||
if (empty($_SESSION['share_active'])) {
|
||||
http_response_code(403);
|
||||
die('Accès refusé.');
|
||||
}
|
||||
|
||||
// ── Rate limit: 30 uploads per 5 min per session ────────────────────────
|
||||
require_once APP_ROOT . '/src/RateLimit.php';
|
||||
$rateLimit = new RateLimit(30, 300, STORAGE_ROOT . '/cache/rate_limit');
|
||||
$rateLimitId = 'fp_share_process_' . session_id();
|
||||
if (!$rateLimit->checkKey($rateLimitId)) {
|
||||
error_log('[filepond:partage:process] Rate limit hit');
|
||||
http_response_code(429);
|
||||
die('Trop de requêtes. Veuillez patienter.');
|
||||
}
|
||||
|
||||
// ── CSRF via header ──────────────────────────────────────────────────────
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
|
||||
error_log('[filepond:partage:process] CSRF FAIL');
|
||||
http_response_code(403);
|
||||
die('Token CSRF invalide.');
|
||||
}
|
||||
|
||||
$handler = new FilepondHandler('[filepond:partage]');
|
||||
$handler->handleProcess();
|
||||
42
app/public/partage/actions/filepond/remove.php
Normal file
42
app/public/partage/actions/filepond/remove.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond remove endpoint — soft-deletes a thesis_files row (partage).
|
||||
*
|
||||
* DELETE /partage/actions/filepond/remove.php
|
||||
* Body: JSON { "db_id": 123 }
|
||||
*
|
||||
* Auth: requires an active partage session (share_active flag) + CSRF token.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
|
||||
|
||||
// ── Start session ────────────────────────────────────────────────────────
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$isSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// ── Auth: must have an active partage session ────────────────────────────
|
||||
if (empty($_SESSION['share_active'])) {
|
||||
http_response_code(403);
|
||||
die('Accès refusé.');
|
||||
}
|
||||
|
||||
// ── CSRF via header ──────────────────────────────────────────────────────
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
|
||||
http_response_code(403);
|
||||
die('Token CSRF invalide.');
|
||||
}
|
||||
|
||||
$handler = new FilepondHandler('[filepond:partage]');
|
||||
$handler->handleRemove();
|
||||
42
app/public/partage/actions/filepond/revert.php
Normal file
42
app/public/partage/actions/filepond/revert.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond revert endpoint — deletes a just-uploaded tmp file (partage).
|
||||
*
|
||||
* DELETE /partage/actions/filepond/revert.php
|
||||
* Body: plain text file_id
|
||||
*
|
||||
* Auth: requires an active partage session (share_active flag) + CSRF token.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/FilepondHandler.php';
|
||||
|
||||
// ── Start session ────────────────────────────────────────────────────────
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$isSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// ── Auth: must have an active partage session ────────────────────────────
|
||||
if (empty($_SESSION['share_active'])) {
|
||||
http_response_code(403);
|
||||
die('Accès refusé.');
|
||||
}
|
||||
|
||||
// ── CSRF via header ──────────────────────────────────────────────────────
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
|
||||
http_response_code(403);
|
||||
die('Token CSRF invalide.');
|
||||
}
|
||||
|
||||
$handler = new FilepondHandler('[filepond:partage]');
|
||||
$handler->handleRevert();
|
||||
@@ -23,10 +23,11 @@ $slug = $parts[0] ?? '';
|
||||
$action = $parts[1] ?? '';
|
||||
|
||||
// Special route: /partage/fragments/* (HTMX fragments under fragments/ subdirectory)
|
||||
if (str_starts_with($slug, 'fragments/') && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($slug === 'fragments' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
App::boot();
|
||||
$fragmentFile = __DIR__ . '/' . basename($slug);
|
||||
if (file_exists($fragmentFile)) {
|
||||
$fragmentBase = $action;
|
||||
$fragmentFile = __DIR__ . '/fragments/' . $fragmentBase;
|
||||
if ($fragmentBase !== '' && file_exists($fragmentFile)) {
|
||||
require_once $fragmentFile;
|
||||
} else {
|
||||
http_response_code(404);
|
||||
@@ -133,6 +134,7 @@ if (!$validationResult['valid']) {
|
||||
// If already verified in session, skip the gate and render the form directly
|
||||
if (!empty($_SESSION['share_verified_' . $slug])) {
|
||||
error_log('[ShareLink] Session already verified for slug=' . $slug . ', rendering form');
|
||||
$_SESSION['share_active'] = true;
|
||||
$link = $validationResult['link'];
|
||||
renderShareLinkForm($slug, $link);
|
||||
exit;
|
||||
@@ -151,6 +153,7 @@ if (!$validationResult['valid']) {
|
||||
}
|
||||
|
||||
// Link is valid - render the form
|
||||
$_SESSION['share_active'] = true;
|
||||
$link = $validationResult['link'];
|
||||
renderShareLinkForm($slug, $link);
|
||||
|
||||
@@ -217,6 +220,7 @@ function requirePasswordGate(array $link, string $slug): void
|
||||
if ($shareLinkModel->verifyPassword($link, $_POST['share_password'])) {
|
||||
// Store verified status in session
|
||||
$_SESSION['share_verified_' . $slug] = true;
|
||||
$_SESSION['share_active'] = true;
|
||||
error_log('[ShareLink] Password verified OK for slug=' . $slug . ', redirecting to form');
|
||||
// Redirect to clear POST data
|
||||
header('Location: /partage/' . $slug);
|
||||
@@ -417,6 +421,7 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
<?php if (!empty($_SESSION['csrf_token'])): ?>
|
||||
<meta name="csrf-token" content="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<?php endif; ?>
|
||||
<meta name="filepond-base" content="/partage/actions/filepond">
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/common.css') ?>">
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond.min.css') ?>">
|
||||
@@ -511,6 +516,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
if (isset($_POST['share_password_submit'])) {
|
||||
if ($shareLinkModel->verifyPassword($link, $_POST['share_password_submit'])) {
|
||||
$_SESSION['share_verified_' . $slug] = true;
|
||||
$_SESSION['share_active'] = true;
|
||||
} else {
|
||||
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
|
||||
header('Location: /partage/' . urlencode($slug));
|
||||
@@ -565,6 +571,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
// Clean up share-specific session data
|
||||
unset($_SESSION[$shareCsrfKey]);
|
||||
unset($_SESSION['share_verified_' . $slug]);
|
||||
unset($_SESSION['share_active']);
|
||||
|
||||
// Send confirmation e-mail - on delivery failure, redirect to retry page
|
||||
$emailError = null;
|
||||
|
||||
@@ -391,7 +391,7 @@ class SystemController
|
||||
$checks['maintenance'] = [
|
||||
'label' => 'Mode maintenance',
|
||||
'status' => $maintenanceOn ? 'warn' : 'active',
|
||||
'detail' => $maintenanceOn ? 'Activé — site public inaccessible' : 'Désactivé',
|
||||
'detail' => $maintenanceOn ? 'Activé — site public inaccessible (sauf /admin et /partage)' : 'Désactivé',
|
||||
];
|
||||
|
||||
return $checks;
|
||||
|
||||
436
app/src/FilepondHandler.php
Normal file
436
app/src/FilepondHandler.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* FilePond async upload handler — shared logic.
|
||||
*
|
||||
* The four FilePond server endpoints (process, load, remove, revert) are
|
||||
* called from both the admin panel and the student partage form.
|
||||
*
|
||||
* Auth is checked by the caller before invoking these methods:
|
||||
* - Admin endpoints: nginx auth_basic + AdminAuth::requireLogin()
|
||||
* - Partagé endpoints: session_start() + verify share_active + CSRF
|
||||
*
|
||||
* All paths in this file assume the session is already started and CSRF is
|
||||
* verified by the caller.
|
||||
*/
|
||||
|
||||
class FilepondHandler
|
||||
{
|
||||
// ── MIME / extension whitelist (mirrored from ThesisFileHandler) ─────────
|
||||
|
||||
public 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',
|
||||
];
|
||||
|
||||
public 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
|
||||
public 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'],
|
||||
];
|
||||
|
||||
public const QUEUE_SIZE_LIMITS = [
|
||||
'cover' => 20 * 1024 * 1024, // 20 MB
|
||||
'note_intention' => 100 * 1024 * 1024, // 100 MB
|
||||
'tfe' => 500 * 1024 * 1024, // 500 MB
|
||||
'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
|
||||
];
|
||||
|
||||
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 = 2 * 1024 * 1024 * 1024; // 2 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
|
||||
{
|
||||
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);
|
||||
|
||||
$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);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $fileId;
|
||||
exit;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LOAD — stream an existing thesis file back to FilePond
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public function handleLoad(): never
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
die('Méthode non autorisée.');
|
||||
}
|
||||
|
||||
$dbId = filter_var($_GET['id'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($dbId === false || $dbId <= 0) {
|
||||
http_response_code(400);
|
||||
die('ID 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'] ?? '';
|
||||
$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
|
||||
? '<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.');
|
||||
}
|
||||
|
||||
$absPath = STORAGE_ROOT . '/' . $filePath;
|
||||
if (!file_exists($absPath) || !is_readable($absPath)) {
|
||||
http_response_code(404);
|
||||
die('Fichier absent du disque.');
|
||||
}
|
||||
|
||||
$fileSize = filesize($absPath);
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
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.');
|
||||
}
|
||||
|
||||
$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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
[1778581157]
|
||||
[1778596263,1778596264]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -0,0 +1 @@
|
||||
{"queue_type":"cover","original_name":"2026-05-06-105104_hyprshot.png","mime":"image/png","ext":"png","size":170350,"session_id":"60cb0e3107c8795cc3c0d2b8c222953e","uploaded_at":"2026-05-13T12:18:50+00:00"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,42 @@
|
||||
<section aria-labelledby="settings-maintenance-title">
|
||||
<h2 id="settings-maintenance-title">Maintenance</h2>
|
||||
|
||||
<table class="param-access-table">
|
||||
<caption>Visibilité des pages selon le mode</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Page</th>
|
||||
<th scope="col">Normal</th>
|
||||
<th scope="col">Maintenance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Accueil, recherche, répertoire, TFE, à propos, licence</td>
|
||||
<td class="param-access-yes">✓ Visible</td>
|
||||
<td class="param-access-no">✗ 503</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Formulaire étudiant (partage)</td>
|
||||
<td class="param-access-yes">✓ Visible</td>
|
||||
<td class="param-access-yes">✓ Visible</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Administration</td>
|
||||
<td class="param-access-yes">✓ Visible</td>
|
||||
<td class="param-access-yes">✓ Visible</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="param-maintenance-row">
|
||||
<?php if ($maintenanceOn): ?>
|
||||
<p>
|
||||
<strong>⚠ Mode maintenance activé</strong> — le site public est inaccessible.
|
||||
</p>
|
||||
<p class="param-note">
|
||||
Le formulaire étudiant (partage) et l'administration restent accessibles.
|
||||
</p>
|
||||
<form method="post" action="actions/maintenance.php">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<input type="hidden" name="action" value="disable_maintenance">
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
<?php if (!empty($_SESSION['csrf_token'])): ?>
|
||||
<meta name="csrf-token" content="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($isAdmin) || !empty($filepondBase)): ?>
|
||||
<meta name="filepond-base" content="<?= htmlspecialchars($filepondBase ?? '/admin/actions/filepond') ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/modern-normalize.min.css') ?>">
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/common.css') ?>">
|
||||
<?php foreach ($extraCss ?? [] as $css): ?>
|
||||
|
||||
Reference in New Issue
Block a user