mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
filepond: implement async server-ID upload architecture with nested queue support + PeerTube integration
Replace `storeAsFile:true` with a full async FilePond round-trip pipeline using opaque server-side file IDs.
* Added 4 new PHP endpoints under `/admin/actions/filepond/`:
* `process.php` — upload/process single file and return opaque `file_id`
* `revert.php` — delete pending tmp uploads before form submit
* `load.php` — stream existing files by DB ID for FilePond preload
* `remove.php` — soft-delete `thesis_files` rows
* `process.php` improvements:
* accept arbitrary FilePond field names instead of hardcoded `file`
* support PHP-nested multi-file queue inputs (`queue_file[tfe][]`)
* explicit unwrapping of nested `$_FILES` structures
* add `audio/mp3` to audio + `peertube_audio` MIME whitelists
* immediate upload of `peertube_*` files to PeerTube, returning `peertube:{uuid}` IDs
* extensive `error_log()` instrumentation for request, CSRF, MIME, upload, and save stages
* `revert.php` now accepts `peertube:` IDs without local cleanup
* `ThesisFileHandler`:
* add `handleFilePondQueueFiles()` + `handleFilePondSingleFile()`
* process async uploads from `storage/tmp/filepond/` via opaque `file_id`
* inline handling of `peertube:{uuid}` IDs with direct `thesis_files` insertion
* remove obsolete deferred PeerTube queue-processing flow
* `ThesisCreateController` + `ThesisEditController`:
* gate async path behind `filepond_mode=1`
* preserve legacy multipart flow as fallback
* `file-upload-filepond.js`:
* remove `storeAsFile:true`
* add `buildServerConfig()` for async endpoint wiring
* fix `syncOrderInput()` to use `serverId`
* add `onprocessfile` hook
* add `fileValidateSizeFilterItem` for per-extension size caps
* preload existing uploads via `data-existing-files` + `server.load`
* replace static `INPUT_ID_TO_TYPE` map with `data-queue-type`
* add extensive `console.log()` debugging across upload pipeline stages
* `upload-progress.js`:
* block form submission while uploads are pending
* update `collectFileNames()` to read processed FilePond items
* Templates/layout:
* add `data-queue-type`
* add `data-existing-files`
* add global CSRF meta tag outside admin-only context
* add `filepond_mode` hidden input
* add CSRF token/meta support for partage pages
* move website URL field below file upload block
* `.gitignore`: exclude `storage/tmp/` from version control
This commit is contained in:
76
app/public/admin/actions/filepond/load.php
Normal file
76
app/public/admin/actions/filepond/load.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond load endpoint — streams an existing thesis file back to FilePond.
|
||||
*
|
||||
* GET /admin/actions/filepond/load.php?id={db_id}
|
||||
*
|
||||
* Used in edit mode to restore saved files into the FilePond UI.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/AdminAuth.php';
|
||||
require_once __DIR__ . '/../../../../src/ErrorHandler.php';
|
||||
|
||||
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';
|
||||
|
||||
// ── Skip PeerTube and website entries (no actual file) ───────────────────
|
||||
if (str_starts_with($filePath, 'peertube_ids:')) {
|
||||
http_response_code(404);
|
||||
die('Fichier PeerTube — pas de flux direct.');
|
||||
}
|
||||
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;
|
||||
265
app/public/admin/actions/filepond/process.php
Normal file
265
app/public/admin/actions/filepond/process.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond process endpoint — receives one file per request.
|
||||
*
|
||||
* POST /admin/actions/filepond/process.php
|
||||
* Headers: X-CSRF-Token
|
||||
* Fields: file (multipart), queue_type (string)
|
||||
*
|
||||
* Returns plain text file_id on success (200), or error message on failure (4xx).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/AdminAuth.php';
|
||||
require_once __DIR__ . '/../../../../src/ErrorHandler.php';
|
||||
|
||||
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) . '...');
|
||||
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) ────────────
|
||||
$isPeerTube = str_starts_with($queueType, 'peertube_');
|
||||
if ($isPeerTube) {
|
||||
require_once APP_ROOT . '/src/PeerTubeService.php';
|
||||
if (PeerTubeService::isEnabled(new Database())) {
|
||||
$ptFileType = ($queueType === 'peertube_video') ? '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/
|
||||
$fileId = 'peertube:' . $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 — reject the upload
|
||||
@unlink($targetPath);
|
||||
@rmdir($tmpDir);
|
||||
http_response_code(503);
|
||||
die('PeerTube n\'est pas activé.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
80
app/public/admin/actions/filepond/remove.php
Normal file
80
app/public/admin/actions/filepond/remove.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond remove endpoint — soft-deletes an already-saved thesis_files row.
|
||||
*
|
||||
* DELETE /admin/actions/filepond/remove.php
|
||||
* Body: JSON { "db_id": 123 }
|
||||
*
|
||||
* Called when a user removes an existing file in edit mode via FilePond UI.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/AdminAuth.php';
|
||||
require_once __DIR__ . '/../../../../src/ErrorHandler.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
// ── 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.');
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
74
app/public/admin/actions/filepond/revert.php
Normal file
74
app/public/admin/actions/filepond/revert.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* FilePond revert endpoint — deletes a just-uploaded tmp file.
|
||||
*
|
||||
* DELETE /admin/actions/filepond/revert.php
|
||||
* Body: plain text file_id
|
||||
*
|
||||
* Called when the user removes a file before form submit.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
// ── 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.');
|
||||
}
|
||||
|
||||
// ── 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
|
||||
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;
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* file-upload-filepond.js
|
||||
*
|
||||
* Thin FilePond wrapper — replaces the old custom file-upload-queue.js.
|
||||
* FilePond wrapper with async server round-trip architecture.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Each <input type="file" class="tfe-file-picker"> is upgraded to a FilePond instance.
|
||||
* 2. FilePond handles drag-to-reorder, thumbnails, remove, validation — zero custom DOM.
|
||||
* 3. storeAsFile: true preserves native multipart form submission.
|
||||
* Server receives files via $_FILES indexed by each input's name attribute
|
||||
* (e.g. queue_file[tfe][], queue_file[video][], etc.).
|
||||
* 4. Type + size validation: via native FilePond options + FileValidateType/Size plugins.
|
||||
* beforeAddFile is used ONLY for hybrid validation (e.g. per-type size limits)
|
||||
* and returns true/false per the FilePond API contract.
|
||||
* 5. Order serialization: hidden inputs track file order from pond.getFiles().
|
||||
* 6. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs.
|
||||
* 3. Async upload: files are POSTed to /admin/actions/filepond/process.php immediately.
|
||||
* The server returns a file_id stored as item.serverId.
|
||||
* 4. Form submit sends only file_ids (tiny payload), not the files themselves.
|
||||
* 5. Type + size validation: via native FilePond options + FileValidateType/Size plugins
|
||||
* plus fileValidateSizeFilterItem for per-extension size caps.
|
||||
* 6. Order serialization: hidden inputs track file order using serverId (not filename).
|
||||
* 7. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs.
|
||||
* 8. Edit mode: loads existing files via data-existing-files JSON + server.load.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
@@ -119,19 +119,6 @@
|
||||
},
|
||||
};
|
||||
|
||||
// Map input id → queue type
|
||||
var INPUT_ID_TO_TYPE = {
|
||||
"tfe-files-input": "tfe",
|
||||
"tfe-files-input-2": "tfe",
|
||||
"video-files-input": "video",
|
||||
"audio-files-input": "audio",
|
||||
"annexe-files-input": "annexe",
|
||||
"couverture": "cover",
|
||||
"note_intention": "note_intention",
|
||||
"peertube-video-input": "peertube_video",
|
||||
"peertube-audio-input": "peertube_audio",
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -154,37 +141,124 @@
|
||||
return m ? m[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSRF token from the meta tag.
|
||||
*/
|
||||
function getCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
// ── Order serialization ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create/update a hidden input that serializes the file order for a queue.
|
||||
* Name: queue_order[<queueType>]
|
||||
* Value: pipe-separated list of file names.
|
||||
* Name: queue_file[<queueType>][] for each file_id.
|
||||
* Name: queue_order[<queueType>] for the pipe-separated order.
|
||||
*/
|
||||
function syncOrderInput(queueType, pond) {
|
||||
if (!pond || !pond.element) return;
|
||||
var form = pond.element.closest("form");
|
||||
if (!form) return;
|
||||
|
||||
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
|
||||
var files = pond.getFiles();
|
||||
if (files.length === 0) {
|
||||
if (orderInput) orderInput.remove();
|
||||
return;
|
||||
|
||||
// Remove old order input and all queue_file hidden inputs for this 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]");
|
||||
for (var h = 0; h < oldHidden.length; h++) {
|
||||
oldHidden[h].remove();
|
||||
}
|
||||
|
||||
var names = [];
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Create hidden inputs per file: queue_file[<queueType>][] = serverId
|
||||
var ids = [];
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
names.push(files[i].filename || files[i].file.name);
|
||||
var f = files[i];
|
||||
// Only include files that have been uploaded and have a serverId
|
||||
var id = f.serverId || null;
|
||||
if (id) {
|
||||
ids.push(id);
|
||||
var hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = "queue_file[" + queueType + "][]";
|
||||
hidden.value = id;
|
||||
hidden.setAttribute("data-filepond-id", "1");
|
||||
form.appendChild(hidden);
|
||||
}
|
||||
}
|
||||
|
||||
if (!orderInput) {
|
||||
orderInput = document.createElement("input");
|
||||
// Create order input
|
||||
if (ids.length > 0) {
|
||||
var orderInput = document.createElement("input");
|
||||
orderInput.type = "hidden";
|
||||
orderInput.name = "queue_order[" + queueType + "]";
|
||||
orderInput.value = ids.join("|");
|
||||
form.appendChild(orderInput);
|
||||
}
|
||||
orderInput.value = names.join("|");
|
||||
}
|
||||
|
||||
// ── Server config builder ─────────────────────────────────────────────
|
||||
|
||||
function buildServerConfig(queueType) {
|
||||
var csrfToken = getCsrfToken();
|
||||
console.log('[filepond] buildServerConfig | queueType=' + queueType + ' | csrfToken=' + (csrfToken ? csrfToken.substring(0, 8) + '...' : 'MISSING'));
|
||||
|
||||
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);
|
||||
return formData;
|
||||
},
|
||||
onload: function (response) {
|
||||
var id = response.trim();
|
||||
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);
|
||||
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); },
|
||||
},
|
||||
|
||||
load: '/admin/actions/filepond/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',
|
||||
headers: {
|
||||
'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');
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── FilePond configuration per queue type ─────────────────────────────
|
||||
@@ -208,8 +282,9 @@
|
||||
return {
|
||||
allowMultiple: cfg.allowMultiple,
|
||||
allowReorder: true,
|
||||
allowProcess: false,
|
||||
storeAsFile: true,
|
||||
|
||||
// ── Async server model (replaces storeAsFile + allowProcess: false) ──
|
||||
server: buildServerConfig(queueType),
|
||||
|
||||
// ── Native FilePond validation ──
|
||||
acceptedFileTypes: acceptedFileTypes,
|
||||
@@ -233,17 +308,28 @@
|
||||
labelButtonRetryItemLoad: "Réessayer",
|
||||
labelButtonProcessItem: "Charger",
|
||||
|
||||
// ── Per-extension size validation (hybrid: FilePond validates global maxFileSize,
|
||||
// beforeAddFile enforces per-extension limits via false return) ──
|
||||
// ── 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) {
|
||||
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
|
||||
},
|
||||
|
||||
// Fallback: if fileValidateSizeFilterItem is not available,
|
||||
// beforeAddFile enforces per-extension limits (silent rejection).
|
||||
beforeAddFile: function (item) {
|
||||
// This check is redundant if fileValidateSizeFilterItem works,
|
||||
// but serves as a fallback.
|
||||
if (typeof item.file === 'undefined') return true;
|
||||
var f = item.file;
|
||||
var ext = getExt(f.name);
|
||||
if (ext && perExtMax[ext]) {
|
||||
var limit = parseSize(perExtMax[ext]);
|
||||
if (limit > 0 && f.size > limit) {
|
||||
// Return false per FilePond API contract — the FileValidateSize
|
||||
// plugin sets the error state via maxFileSize, but per-extension
|
||||
// cap violations must be rejected here.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -255,13 +341,14 @@
|
||||
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) {
|
||||
if (!error) syncOrderInput(queueType, this);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Instance tracking ────────────────────────────────────────────────
|
||||
|
||||
var _ponds = {};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -273,25 +360,30 @@
|
||||
// Canonical duplicate check: FilePond.find() is the authoritative source
|
||||
if (FilePond.find(input)) return;
|
||||
|
||||
var id = input.id;
|
||||
var queueType = INPUT_ID_TO_TYPE[id];
|
||||
if (!queueType) {
|
||||
queueType = input.dataset.queueType || null;
|
||||
}
|
||||
// Queue type: always from data-queue-type attribute
|
||||
var queueType = input.dataset.queueType || null;
|
||||
if (!queueType) return;
|
||||
|
||||
var options = buildFilePondOptions(queueType, input);
|
||||
if (!options) return;
|
||||
|
||||
options.name = input.getAttribute("name") || input.name || "";
|
||||
|
||||
var pond = FilePond.create(input, options);
|
||||
console.log('[filepond] Created instance | queueType=' + queueType + ' | inputId=' + (input.id || 'none') + ' | inputName=' + (input.getAttribute('name') || input.name || '?'));
|
||||
|
||||
var key = id || queueType;
|
||||
_ponds[key] = pond;
|
||||
|
||||
// Initial order serialization (for existing files in edit mode — none expected)
|
||||
// Initial order serialization
|
||||
syncOrderInput(queueType, pond);
|
||||
|
||||
// ── Edit mode: load existing files ──
|
||||
var existingFiles = [];
|
||||
try {
|
||||
existingFiles = JSON.parse(input.dataset.existingFiles || '[]');
|
||||
} catch (_) {}
|
||||
|
||||
if (existingFiles.length) {
|
||||
pond.addFiles(existingFiles.map(function (f) {
|
||||
return { source: f.source, options: f.options };
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -305,23 +397,22 @@
|
||||
var pond = FilePond.find(input);
|
||||
if (pond) {
|
||||
try {
|
||||
// Remove order input before destroying
|
||||
// Remove order/hidden inputs before destroying
|
||||
var form = input.closest("form");
|
||||
if (form) {
|
||||
var id = input.id;
|
||||
var queueType = INPUT_ID_TO_TYPE[id] || input.dataset.queueType || null;
|
||||
var queueType = input.dataset.queueType || null;
|
||||
if (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]");
|
||||
for (var h = 0; h < hiddenInputs.length; h++) {
|
||||
hiddenInputs[h].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
pond.destroy();
|
||||
} catch (_) {}
|
||||
}
|
||||
// Clean up tracking
|
||||
if (input.id && _ponds[input.id]) {
|
||||
delete _ponds[input.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -340,6 +431,29 @@
|
||||
|
||||
// ── 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:processfilestart', function (e) {
|
||||
console.log('[filepond:event] processfilestart | filename=' + (e.detail.file ? e.detail.file.filename : '?'));
|
||||
});
|
||||
document.addEventListener('FilePond:processfileprogress', function (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) + '%');
|
||||
}
|
||||
});
|
||||
document.addEventListener('FilePond:processfileabort', function (e) {
|
||||
console.log('[filepond:event] processfileabort');
|
||||
});
|
||||
document.addEventListener('FilePond:processfilerevert', function (e) {
|
||||
console.log('[filepond:event] processfilerevert');
|
||||
});
|
||||
document.addEventListener('FilePond:error', function (e) {
|
||||
console.error('[filepond:event] error', e.detail);
|
||||
});
|
||||
|
||||
// Register FilePond plugins (idempotent)
|
||||
if (typeof FilePondPluginFileValidateType !== "undefined") {
|
||||
FilePond.registerPlugin(FilePondPluginFileValidateType);
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
function collectFileNames() {
|
||||
const names = [];
|
||||
// Check raw <input type="file"> elements (non-FilePond or FilePond-managed with storeAsFile)
|
||||
// Check raw <input type="file"> elements (non-FilePond)
|
||||
const inputs = form.querySelectorAll('input[type="file"]');
|
||||
for (const fi of inputs) {
|
||||
if (fi.files) {
|
||||
@@ -42,8 +42,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check FilePond instances directly (their storeAsFile hidden inputs may not
|
||||
// have .files populated yet when the submit event fires)
|
||||
// Read processed file names from FilePond instances (async mode)
|
||||
if (typeof FilePond !== 'undefined') {
|
||||
const pondInputs = form.querySelectorAll('.tfe-file-picker');
|
||||
for (const pi of pondInputs) {
|
||||
@@ -51,8 +50,11 @@
|
||||
if (pond) {
|
||||
const pondFiles = pond.getFiles();
|
||||
for (const pf of pondFiles) {
|
||||
const name = pf.filename || (pf.file && pf.file.name);
|
||||
if (name) names.push(name);
|
||||
// Only count successfully uploaded files (have serverId)
|
||||
if (pf.serverId) {
|
||||
const name = pf.filename || (pf.file && pf.file.name) || pf.serverId;
|
||||
if (name) names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +63,32 @@
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
// ── Guard: block submit if any FilePond item is still uploading ──
|
||||
if (typeof FilePond !== 'undefined') {
|
||||
let stillUploading = false;
|
||||
const pondInputs = form.querySelectorAll('.tfe-file-picker');
|
||||
for (const pi of pondInputs) {
|
||||
const pond = FilePond.find(pi);
|
||||
if (pond) {
|
||||
const pondFiles = pond.getFiles();
|
||||
for (const pf of pondFiles) {
|
||||
if (pf.status === FilePond.FileStatus.PROCESSING ||
|
||||
pf.status === FilePond.FileStatus.IDLE) {
|
||||
stillUploading = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stillUploading) break;
|
||||
}
|
||||
if (stillUploading) {
|
||||
e.preventDefault();
|
||||
progressLabel.textContent = 'Veuillez attendre la fin du téléversement…';
|
||||
progressWrap.style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fileNames = collectFileNames();
|
||||
if (!fileNames.length) return;
|
||||
|
||||
|
||||
@@ -221,8 +221,10 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
||||
name="queue_file[tfe][]"
|
||||
multiple
|
||||
class="tfe-file-picker"
|
||||
data-queue-type="tfe"
|
||||
<?= !$adminMode ? 'required' : '' ?>
|
||||
data-peertube-active="<?= $peerTubeEnabled ? '1' : '0' ?>">
|
||||
data-peertube-active="<?= $peerTubeEnabled ? '1' : '0' ?>"
|
||||
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForTfe ?? []), ENT_QUOTES) ?>'>
|
||||
<small class="admin-file-hint">
|
||||
<?php if ($peerTubeEnabled): ?>
|
||||
PDF (max 100 MB) · Images (max 500 MB) · VTT · Archives (max 500 MB).
|
||||
@@ -245,24 +247,14 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
||||
<input type="file" id="annexe-files-input"
|
||||
name="queue_file[annexe][]"
|
||||
multiple
|
||||
class="tfe-file-picker">
|
||||
class="tfe-file-picker"
|
||||
data-queue-type="annexe">
|
||||
<small class="admin-file-hint">PDF ou archives ZIP/TAR. Max 500 MB. Glissez pour réordonner.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 5. Site web url ── -->
|
||||
<div id="slot-siteweb" class="admin-form-group">
|
||||
<label for="website_url">URL du site (optionnel)</label>
|
||||
<div class="admin-file-input">
|
||||
<input type="url" id="website_url" name="website_url"
|
||||
value="<?= $websiteUrl ?>"
|
||||
placeholder="https://mon-tfe.erg.be">
|
||||
<small>Le TFE sera affiché comme un site embarqué sur sa page publique.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 6. Vidéo / PeerTube ── -->
|
||||
<!-- ── 5. Vidéo / PeerTube ── -->
|
||||
<?php if ($peerTubeEnabled): ?>
|
||||
<div id="slot-video" class="admin-form-group admin-files-fieldgroup">
|
||||
<label for="peertube-video-input">Vidéo (optionnel)</label>
|
||||
@@ -271,7 +263,8 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
||||
name="queue_file[peertube_video][]"
|
||||
multiple
|
||||
accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogv,.mov"
|
||||
class="tfe-file-picker">
|
||||
class="tfe-file-picker"
|
||||
data-queue-type="peertube_video">
|
||||
<small class="admin-file-hint">MP4, WebM ou MOV. Max 500 MB. Hébergé sur <a href="<?= htmlspecialchars($peerTubeSettings['instance_url']) ?>" target="_blank" rel="noopener">PeerTube</a>.</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,13 +276,14 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
||||
name="queue_file[video][]"
|
||||
multiple
|
||||
accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogv,.mov"
|
||||
class="tfe-file-picker">
|
||||
class="tfe-file-picker"
|
||||
data-queue-type="video">
|
||||
<small class="admin-file-hint">MP4, WebM ou MOV. Max 500 MB.</small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ── 7. Audio / PeerTube ── -->
|
||||
<!-- ── 6. Audio / PeerTube ── -->
|
||||
<?php if ($peerTubeEnabled): ?>
|
||||
<div id="slot-audio" class="admin-form-group admin-files-fieldgroup">
|
||||
<label for="peertube-audio-input">Audio (optionnel)</label>
|
||||
@@ -298,7 +292,8 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
||||
name="queue_file[peertube_audio][]"
|
||||
multiple
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/mp4,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a"
|
||||
class="tfe-file-picker">
|
||||
class="tfe-file-picker"
|
||||
data-queue-type="peertube_audio">
|
||||
<small class="admin-file-hint">MP3, OGG, WAV, FLAC ou AAC. Max 500 MB. Hébergé sur <a href="<?= htmlspecialchars($peerTubeSettings['instance_url']) ?>" target="_blank" rel="noopener">PeerTube</a>.</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,11 +305,23 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
||||
name="queue_file[audio][]"
|
||||
multiple
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/mp4,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a"
|
||||
class="tfe-file-picker">
|
||||
class="tfe-file-picker"
|
||||
data-queue-type="audio">
|
||||
<small class="admin-file-hint">MP3, OGG, WAV, FLAC ou AAC. Max 500 MB.</small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ── 7. Site web url ── -->
|
||||
<div id="slot-siteweb" class="admin-form-group">
|
||||
<label for="website_url">URL du site (optionnel)</label>
|
||||
<div class="admin-file-input">
|
||||
<input type="url" id="website_url" name="website_url"
|
||||
value="<?= $websiteUrl ?>"
|
||||
placeholder="https://mon-tfe.erg.be">
|
||||
<small>Le TFE sera affiché comme un site embarqué sur sa page publique.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset><!-- /Fichiers -->
|
||||
</div><!-- #format-fichiers-block -->
|
||||
|
||||
@@ -290,6 +290,11 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
}
|
||||
$shareCsrfToken = $_SESSION[$shareCsrfKey];
|
||||
|
||||
// Also set a global CSRF token for FilePond async uploads (read from <meta name="csrf-token">)
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = $shareCsrfToken;
|
||||
}
|
||||
|
||||
$pageTitle = 'Soumettre un TFE';
|
||||
$isVerified = !empty($_SESSION['share_verified_' . $slug]);
|
||||
|
||||
@@ -392,6 +397,9 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="/assets/favicon/favicon.ico">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<?php if (!empty($_SESSION['csrf_token'])): ?>
|
||||
<meta name="csrf-token" content="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<?php endif; ?>
|
||||
<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') ?>">
|
||||
|
||||
Reference in New Issue
Block a user