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:
Pontoporeia
2026-05-11 20:11:31 +02:00
parent b56d073210
commit 2e9ebfc684
18 changed files with 1342 additions and 261 deletions

2
.gitignore vendored
View File

@@ -18,6 +18,8 @@ app/storage/theses/*
!app/storage/theses/.gitkeep
app/storage/banners/*
!app/storage/banners/.gitkeep
app/storage/tmp/*
!app/storage/tmp/.gitkeep
# Nix
.direnv/

130
TODO.md
View File

@@ -1,123 +1,9 @@
# TODO
# FilePond Server-ID Refactor
## Move Restrictions d'accès aux fichiers to acces.php
- [x] Remove fieldset from templates/admin/contenus.php
- [x] Add fieldset to templates/admin/acces.php
- [x] Load $siteSettings in admin/acces.php controller
- [x] Update redirect in settings.php for formulaire_restrictions → /admin/acces.php
## Fix PeerTube upload — Google-resumable protocol adherence
- [x] Use Location header from init response (not reconstruct URL from JSON body)
- [x] Switch chunk method from PUT → PATCH (Google-resumable variant)
- [x] Use actual file MIME type in chunk Content-Type (not application/octet-stream)
- [x] Ensure chunk size is multiple of 256 KB
- [x] Add PATCH/HEAD methods to httpRequest()
- [x] Add CURLOPT_HEADERFUNCTION to capture response headers
- [x] Disable CURLOPT_FOLLOWLOCATION to preserve Location header
- [x] Add cancelUpload() helper for Delete-on-error cleanup
- [x] PeerTube upload fixed — simple multipart POST /api/v1/videos/upload works
- [x] Upload progress: 0-25% browser upload, 25-99% server polling via /admin/actions/upload-progress.php
- [x] Decorelate formats from fichiers: no HTMX toggling; Site web/Vidéo/Audio always visible
- [x] Sticky formats fieldset inside parent container
- [x] Server-side progress: PeerTubeService writes to temp file, client polls progress endpoint
- [x] Fix cover deletion bug: !empty() instead of isset()
- [x] Remove old duplicate file list CSS; unified recap+edit file figure styling
- [x] Standardise uploaded/preexisting files styling: recap now matches edit (classes, icons, meta row, display_label)
- [x] Refactor licence fieldset: Libre→CC2r+licence, Interne→opt-in licence, Interdit→none
## HTMX Toast Feedback for Settings Checkboxes (contenus.php)
- [x] Add `hx-target` response divs to the three fieldsets in contenus.php
- [x] Update settings.php to return HTML toast on HTMX requests
## JS Refactoring — Extract inline scripts into app/ files
- [x] Remove overtype-webcomponent.min.js (unused)
- [x] Extract copyLogContent + fallbackCopy + tab-updater → app/admin-logs.js
- [x] Extract copyUrl → app/clipboard.js
- [x] Extract tag-search inline script → app/pill-search.js (generalized for tag + language)
- [x] Extract tfe.php access-request form → app/access-request.js
- [x] Update all templates to use new external JS files
## Production Error Fixes (2026-05-11 remote logs)
- [x] **413 Request Entity Too Large** — bumped `client_max_body_size` to 256M, PHP post/upload to 256M, timeouts to 300s
- [x] **Missing `v_smtp_active` view** on server — made all `CREATE VIEW` statements idempotent with `IF NOT EXISTS` in schema.sql
- [x] **`bars.svg` 404** — created `app/public/assets/img/bars.svg` (animated SVG spinner)
- [x] **Nginx rate limiting too aggressive** — increased admin zone to 300r/m, burst=30 to handle ~11 concurrent HTMX fragment requests on contenus.php page load
- [x] **Migration idempotency**`CREATE INDEX` / `CREATE TRIGGER` / `CREATE VIEW` now use `IF NOT EXISTS` in schema.sql and generate-schema.py; migrate.sh no longer fails on re-run
- [ ] **Database readonly** — intermittent permission issue after deploy (added deploy-nginx recipe; permissions should be fixed by --chown + deploy-server.sh)
- [x] **Upload progress bar not visible**`collectFileNames()` now also checks FilePond instances directly (not just `input.files`); `upload-progress.php` no longer requires admin auth (blocked partage form polling)
- [x] **Validation error messages hidden by generic fallback**`ErrorHandler::userMessage` doesn't handle plain `Exception`, so all `throw new Exception(...)` validation messages fall through to "Une erreur inattendue…". Fix: add `Exception` pass-through before the generic fallback.
- [x] **Validation messages use wrong field names** — "Nom/Prénom/Pseudo" → "Auteur·ice(s)", "Titre du mémoire" → "Titre du TFE" to match form labels. Updated autofocusFieldForError in both controllers.
## PeerTube Alternate Labels & FilePond Pools
- [x] Add `peertube_video_label` and `peertube_audio_label` columns (migration 029)
- [x] Update PeerTubeService getSettings/updateSettings for new fields
- [x] Add label fields to parametres.php admin form
- [x] Handle label saving in admin/actions/settings.php
- [x] Uncomment video/audio slots in fichiers-fragment.php with FilePond pools when PeerTube enabled
- [x] Register `peertube_video` / `peertube_audio` queue types in file-upload-filepond.js
- [x] Update handlePeerTubeUpload → handlePeerTubeQueueFiles in both create/edit controllers
- [x] When PeerTube active, restrict TFE pool to PDF/images/VTT/archives only (no video/audio)
- [x] Add HTMX swap attributes to Vidéo/Audio format checkboxes for live toggling
- [x] Store PeerTube uploads as `peertube_ids:{uuid}` in thesis_files.file_path
- [x] Create `templates/partials/peertube-embed.php` iframe embed template
- [x] Render PeerTube embeds in public thesis view (tfe.php)
- [x] Handle PeerTube files in admin recapitulatif.php and fichiers-fragment.php
- [x] Shared SMTP credentials — remove username/password from peertube_settings (migration 031)
- [x] PeerTubeService reads credentials from SmtpRelay
- [x] OAuth client_id/secret fetched on-demand and cached in-memory (no DB storage)
- [x] Resumable upload protocol (POST init + PUT chunks) in PeerTubeService::upload()
- [x] Admin recapitulatif: show real PeerTube watch links (public/unlisted only)
- [x] Optimize public thesis view: load PeerTube instance URL once before file loop
- [ ] Test end-to-end: activate PeerTube, set labels, submit form with video/audio files
## SQLite Backup & Data Integrity (docs/backup-plan.md)
### Phase 1 — WAL Mode
- [x] WAL mode already active (`PRAGMA journal_mode``wal`) — set in Database constructor
- [ ] Verify `-wal` and `-shm` sidecar files exist after writes
- [ ] Verify nginx/PHP write access to sidecar files on server
- [x] Add deploy-verify-permissions recipe that checks ownership, directory perms, file perms, and writability after rsync
- [x] deploy recipe now uploads and runs deploy-server.sh to fix permissions, then verifies them
- [x] deploy recipe now runs migrations (scripts/migrate.sh) after ensuring DB exists
- [x] fix migrate.sh to detect server vs local layout (no app/ subdir on server)
- [x] regenerate schema.sql from local DB via generate-schema.py (includes v_smtp_active, all 28 migrations)
- [x] fix generate-schema.py to include v_smtp_active (was explicitly excluded)
### Phase 2 — Audit Log
- [x] `admin_audit_log` table already exists (migration 009), `AdminLogger` already writes to it
- [x] Create the `audit_log` table for data-level audit (before/after row snapshots)
- [x] Create `Audit.php` helper class
- [x] Instrument all DELETE, UPDATE, INSERT operations on core tables (theses, tags, languages, thesis_files)
- [ ] Verify by triggering a test delete and querying `SELECT * FROM audit_log ORDER BY id DESC LIMIT 5`
### Phase 3 — Soft Deletes
- [x] Add `deleted_at` columns to `languages`, `tags`, `theses`
- [x] Rebuild views `v_theses_full` and `v_theses_public` with `deleted_at IS NULL` filters
- [x] Update `schema.sql` for fresh installs
- [x] Replace all hard DELETEs with soft deletes (`DELETE``UPDATE ... SET deleted_at = ...`)
- [x] Add `deleted_at IS NULL` to all SELECT queries touching these tables
- [x] Add admin "Corbeille" view for soft-deleted theses with Restore and Hard Delete actions
- [ ] Test each htmx-driven element (language search, tag search, repertoire filters) to confirm deleted entries don't appear
- [ ] Admin: add soft-deleted tags/languages view with restore option
### Phase 4 — Hourly Snapshots via Cronjob
- [x] Create `scripts/backup-sqlite.sh` (hot backup via `sqlite3 .backup`, gzip, retention pruning)
- [x] Test locally — backup created, restores correctly
- [x] Add `just backup-snapshot` command for local ad-hoc backups
- [x] Deploy backup script to server (`/usr/local/bin/backup-sqlite.sh`) — `just deploy-backup-script`
- [x] Create `/var/backups/xamxam/` directory on server — part of `just deploy-backup-cron`
- [x] Add cron jobs (hourly 30d + daily 90d) — `just deploy-backup-cron`
- [x] Test restore from production backup — `just test-restore <remote-gz-path>`
- [x] Manual backup trigger — `just trigger-backup`
- [x] Check backup log — `just deploy-check-backup-log`
- [x] List remote backups — `just deploy-list-backups`
- [x] One-shot deploy — `just deploy-backup` (script + cron)
### Phase 5 — Remote Sync *(for later)*
- [ ] (Deferred)
- [x] Step 1 — Build 4 PHP endpoints (process.php, revert.php, load.php, remove.php)
- [x] Step 2 — Update ThesisFileHandler to accept file_ids instead of $_FILES
- [x] Step 3 — Update file-upload-filepond.js (async server model + all fixes)
- [x] Step 4 — Update templates (data-queue-type on all inputs, data-existing-files in edit)
- [x] Step 5 — Update upload-progress.js (new collectFileNames, pending-uploads guard)
- [ ] Step 6 — QA / integration testing
- [ ] Step 7 — Cleanup: remove transition flags, remove INPUT_ID_TO_TYPE

View 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;

View 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;

View 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;

View 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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 -->

View File

@@ -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') ?>">

View File

@@ -194,26 +194,38 @@ class ThesisCreateController
$folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/';
$filePrefix = $folderName;
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
if (!empty($post['filepond_mode'])) {
// New path: files already on server via async FilePond uploads
// Cover and note_intention also go through FilePond async flow
$this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix);
$this->handleFilePondSingleFile($thesisId, $post, 'note_intention', $folderPath, $filePrefix);
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, 1);
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'video', $folderPath, $filePrefix, $nextNum);
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'audio', $folderPath, $filePrefix, $nextNum);
$this->handleFilePondQueueFiles($thesisId, $post, 'annexe', $folderPath, $filePrefix, 0);
$this->handleFilePondQueueFiles($thesisId, $post, 'peertube_video', $folderPath, $filePrefix, 0, null);
$this->handleFilePondQueueFiles($thesisId, $post, 'peertube_audio', $folderPath, $filePrefix, 0, null);
} else {
// Legacy path: files arrive via multipart $_FILES
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
$queueFiles = $files['queue_file'] ?? [];
$qTfe = $this->extractFilesSubArray($queueFiles, 'tfe');
$qVideo = $this->extractFilesSubArray($queueFiles, 'video');
$qAudio = $this->extractFilesSubArray($queueFiles, 'audio');
$qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe');
// TFE files from client-side JS queue (FormData)
$queueFiles = $files['queue_file'] ?? [];
$qTfe = $this->extractFilesSubArray($queueFiles, 'tfe');
$qVideo = $this->extractFilesSubArray($queueFiles, 'video');
$qAudio = $this->extractFilesSubArray($queueFiles, 'audio');
$qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe');
$nextNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, 1);
$nextNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $nextNum);
$nextNum = $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $nextNum);
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
$nextNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, 1);
$nextNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $nextNum);
$nextNum = $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $nextNum);
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
// ── 5b. PeerTube video / audio uploads (from FilePond queue) ──────────
$qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
$qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
$this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTVideo, 'video');
$this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTAudio, 'audio');
// ── 5b. PeerTube video / audio uploads (from FilePond queue) ──────────
$qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
$qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
$this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTVideo, 'video');
$this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTAudio, 'audio');
}
// ── 6. Website URL — stored as thesis_files row ──────────────────────
$this->handleWebsiteUrl($thesisId, $post);

View File

@@ -340,7 +340,24 @@ class ThesisEditController
}
// ── Cover image (outside transaction — filesystem op) ─────────────────
if (!empty($post['remove_cover'])) {
if (!empty($post['filepond_mode'])) {
// Async path: cover file_id arrives in post, not $_FILES
if (!empty($post['remove_cover'])) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
break;
}
}
}
$this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix);
} elseif (!empty($post['remove_cover'])) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
@@ -358,22 +375,43 @@ class ThesisEditController
}
// ── Note d'intention (replace if uploaded) ────────────────────────────
// Remove old note_intention row+file if new one is uploaded
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
if (!empty($post['filepond_mode'])) {
// Remove old note_intention if new one is uploaded via async path
$newNoteId = ($post['queue_file']['note_intention'] ?? null);
$hasNewNote = $newNoteId !== null && (is_array($newNoteId) ? !empty($newNoteId) : $newNoteId !== '');
if ($hasNewNote) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
break;
}
break;
}
}
$this->handleFilePondSingleFile($thesisId, $post, 'note_intention', $folderPath, $filePrefix);
} else {
// Legacy path
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
break;
}
}
}
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
}
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
// ── Delete individual thesis files ────────────────────────────────────
$deleteIds = isset($post['delete_files']) && is_array($post['delete_files'])
@@ -411,38 +449,52 @@ class ThesisEditController
}
}
// ── New TFE/video/audio files upload (from client-side JS queue) ──
$queueFiles = $files['queue_file'] ?? [];
$qTfe = $this->extractFilesSubArray($queueFiles, 'tfe');
$qVideo = $this->extractFilesSubArray($queueFiles, 'video');
$qAudio = $this->extractFilesSubArray($queueFiles, 'audio');
$qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe');
// ── New TFE/video/audio files upload (choose path based on filepond_mode)
// Count existing TFE files (not cover, note_intention, website, annex, caption, PeerTube)
$tfeCount = 0;
foreach ($existingFiles as $f) {
if (!in_array($f['file_type'] ?? '', ['cover', 'note_intention', 'website', 'annex', 'caption'], true)
&& !str_starts_with($f['file_path'] ?? '', 'http')) {
&& !str_starts_with($f['file_path'] ?? '', 'http')
&& !str_starts_with($f['file_path'] ?? '', 'peertube_ids:')) {
$tfeCount++;
}
}
$startNum = $tfeCount + 1;
$startNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, $startNum);
$startNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $startNum);
$this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $startNum);
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
if (!empty($post['filepond_mode'])) {
// New path: files already on server via async FilePond uploads
$nextNum = $tfeCount + 1;
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, $nextNum);
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'video', $folderPath, $filePrefix, $nextNum);
$this->handleFilePondQueueFiles($thesisId, $post, 'audio', $folderPath, $filePrefix, $nextNum);
$this->handleFilePondQueueFiles($thesisId, $post, 'annexe', $folderPath, $filePrefix, 0);
$this->handleFilePondQueueFiles($thesisId, $post, 'peertube_video', $folderPath, $filePrefix, 0, $progressToken);
$this->handleFilePondQueueFiles($thesisId, $post, 'peertube_audio', $folderPath, $filePrefix, 0, $progressToken);
} else {
// Legacy path: files arrive via multipart $_FILES
$queueFiles = $files['queue_file'] ?? [];
$qTfe = $this->extractFilesSubArray($queueFiles, 'tfe');
$qVideo = $this->extractFilesSubArray($queueFiles, 'video');
$qAudio = $this->extractFilesSubArray($queueFiles, 'audio');
$qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe');
// Legacy annexe files (direct upload, non-queue path — kept for backwards compat)
if (isset($files['annexes']) && is_array($files['annexes']['name'] ?? null)) {
$this->handleAnnexeFiles($thesisId, $files['annexes'], $folderPath, $filePrefix, $post);
$startNum = $tfeCount + 1;
$startNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, $startNum);
$startNum = $this->handleTfeQueueFiles($thesisId, $qVideo, $folderPath, $filePrefix, $startNum);
$this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $startNum);
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
// Legacy annexe files (direct upload, non-queue path — kept for backwards compat)
if (isset($files['annexes']) && is_array($files['annexes']['name'] ?? null)) {
$this->handleAnnexeFiles($thesisId, $files['annexes'], $folderPath, $filePrefix, $post);
}
// ── PeerTube video / audio uploads (from FilePond queue) ──────────────
$qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
$qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
$this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTVideo, 'video', $progressToken);
$this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTAudio, 'audio', $progressToken);
}
// ── PeerTube video / audio uploads (from FilePond queue) ──────────────
$qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
$qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
$this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTVideo, 'video', $progressToken);
$this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTAudio, 'audio', $progressToken);
// ── Website URL — add or update ──────────────────────────────────────
$this->handleWebsiteUrl($thesisId, $post);
}

View File

@@ -845,4 +845,360 @@ trait ThesisFileHandler
mkdir($baseDir . $candidate, 0755, true);
return $candidate;
}
// ── FilePond async file processing ──────────────────────────────────────
/**
* Process a single file from the FilePond async flow (cover, note_intention).
*
* Unlike queue files, these arrive as a single file_id in $post['queue_file'][$queueKey]
* (which will be a string, not an array — PHP normalizes single-value inputs).
*/
protected function handleFilePondSingleFile(
int $thesisId,
array $post,
string $queueKey,
string $folderPath,
string $filePrefix
): void {
$raw = $post['queue_file'][$queueKey] ?? null;
if ($raw === null || (is_array($raw) && empty($raw))) {
return;
}
// PHP may send a single value as scalar or single-element array
$fileId = is_array($raw) ? $raw[0] : $raw;
$fileId = trim($fileId);
if ($fileId === '' || !preg_match('/^[a-f0-9]{32}$/', $fileId)) {
return;
}
$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId;
$manifestPath = $tmpDir . '/manifest.json';
if (!is_dir($tmpDir) || !file_exists($manifestPath)) {
error_log("ThesisFileHandler: single file_id $fileId not found in tmp/");
return;
}
$manifest = json_decode(file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
error_log("ThesisFileHandler: invalid manifest for $fileId");
return;
}
// Find the actual file
$actualFile = null;
$dh = opendir($tmpDir);
while (($entry = readdir($dh)) !== false) {
if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') {
continue;
}
$actualFile = $tmpDir . '/' . $entry;
break;
}
closedir($dh);
if ($actualFile === null || !file_exists($actualFile)) {
error_log("ThesisFileHandler: no file found in tmp dir for $fileId");
return;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$ext = $manifest['ext'];
$mimeType = $manifest['mime'];
$originalName = $manifest['original_name'];
$size = $manifest['size'];
if ($queueKey === 'cover') {
$targetName = $filePrefix . '_COUVERTURE.' . $ext;
$fileType = 'cover';
} elseif ($queueKey === 'note_intention') {
$targetName = $filePrefix . '_NOTE_INTENTION.pdf';
$fileType = 'note_intention';
} else {
error_log("ThesisFileHandler: unknown single file queue key $queueKey");
return;
}
$targetPath = $dir . $targetName;
if (!rename($actualFile, $targetPath)) {
error_log("ThesisFileHandler: failed to move $queueKey from tmp");
return;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, $fileType,
$relPath,
$originalName,
$size,
$mimeType
);
error_log("ThesisFileHandler: $queueKey uploaded (filepond) → $targetName");
$this->cleanupFilePondTmp($fileId);
}
/**
* Process queue files that were uploaded asynchronously via FilePond.
*
* Instead of receiving $_FILES, this method reads ad-hoc `queue_file[tfe][]`
* style hidden inputs containing opaque file_ids. Each file_id maps to
* a directory under tmp/filepond/ with a manifest.json and the actual file.
*
* This is the new path (Step 2 of the refactor). The old $_FILES path
* (handleTfeQueueFiles) is kept for backwards compatibility and can be
* removed once the new flow is stable.
*
* @param int $thesisId
* @param array $post $_POST array (contains queue_file[tfe][] etc.)
* @param string $queueKey Queue sub-key ('tfe', 'video', 'audio', 'annexe', 'peertube_video', 'peertube_audio')
* @param string $folderPath Relative path to the thesis folder.
* @param string $filePrefix The shared file prefix.
* @param int $startNum Starting number for TFE_XX (only used for tfe/video/audio queues).
* @param string|null $progressToken Optional progress token for PeerTube uploads.
* @return int The next TFE number (for tfe/video/audio queues).
*/
protected function handleFilePondQueueFiles(
int $thesisId,
array $post,
string $queueKey,
string $folderPath,
string $filePrefix,
int $startNum = 1,
?string $progressToken = null
): int {
$fileIds = $post['queue_file'][$queueKey] ?? [];
if (!is_array($fileIds) || empty($fileIds)) {
return $startNum;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$isPeerTube = false; // peerTube handled inline per fileId (peertube: prefix)
$isAnnexe = $queueKey === 'annexe';
$isTfeLike = in_array($queueKey, ['tfe', 'video', 'audio'], true);
// ── Collect files from tmp/ ──────────────────────────────────────────
$files = [];
$vttQueue = [];
foreach ($fileIds as $fileId) {
$fileId = trim($fileId);
if ($fileId === '') {
continue;
}
// PeerTube files have been uploaded already; just insert DB row
if (str_starts_with($fileId, 'peertube:')) {
$uuid = substr($fileId, strlen('peertube:'));
$fileType = ($queueKey === 'peertube_video') ? 'video' : 'audio';
$storedPath = 'peertube_ids:' . $uuid;
$this->db->insertThesisFile(
$thesisId, $fileType,
$storedPath,
$uuid . ' (PeerTube)',
0,
($queueKey === 'peertube_video') ? 'video/mp4' : 'audio/mpeg',
null, null
);
error_log("ThesisFileHandler: PeerTube file associated → $uuid");
continue;
}
// Regular tmp files (hex file_id)
if (!preg_match('/^[a-f0-9]{32}$/', $fileId)) {
continue;
}
$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId;
$manifestPath = $tmpDir . '/manifest.json';
if (!is_dir($tmpDir) || !file_exists($manifestPath)) {
error_log("ThesisFileHandler: file_id $fileId not found in tmp/");
continue;
}
$manifest = json_decode(file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
error_log("ThesisFileHandler: invalid manifest for $fileId");
continue;
}
// Find the actual file in the tmp dir (there should be exactly one non-manifest file)
$actualFile = null;
$dh = opendir($tmpDir);
while (($entry = readdir($dh)) !== false) {
if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') {
continue;
}
$actualFile = $tmpDir . '/' . $entry;
break;
}
closedir($dh);
if ($actualFile === null || !file_exists($actualFile)) {
error_log("ThesisFileHandler: no file found in tmp dir for $fileId");
continue;
}
$entry = [
'fileId' => $fileId,
'tmpDir' => $tmpDir,
'mimeType' => $manifest['mime'],
'ext' => $manifest['ext'],
'size' => $manifest['size'],
'origName' => $manifest['original_name'],
'label' => '',
'sortOrder' => null,
'fileType' => $this->detectFileType($manifest['mime'], $manifest['ext']),
'actualFile' => $actualFile,
];
if ($isTfeLike && $entry['fileType'] === 'caption') {
$vttQueue[] = $entry;
} else {
$files[] = $entry;
}
}
// ── Handle annexe queue ──────────────────────────────────────────────
if ($isAnnexe) {
$num = 1;
foreach ($files as $f) {
$padded = sprintf('%02d', $num);
$targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $f['ext'];
$targetPath = $dir . $targetName;
if (!rename($f['actualFile'], $targetPath)) {
error_log("ThesisFileHandler: failed to move annexe {$f['origName']}");
continue;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, 'annex',
$relPath,
basename($f['origName']),
$f['size'],
$f['mimeType'],
null, null
);
error_log("ThesisFileHandler: annexe (filepond) → $targetName");
$num++;
$this->cleanupFilePondTmp($f['fileId']);
}
return $startNum;
}
// ── Handle TFE/video/audio queues ────────────────────────────────────
// Sort by hierarchy rank (PDF → video → audio → caption → image → archive)
$filesWithRank = [];
foreach ($files as $f) {
$f['hierarchy'] = $this->tfeHierarchyRank($f['mimeType'], $f['ext']);
$filesWithRank[] = $f;
}
usort($filesWithRank, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']);
$num = $startNum;
$vttIdx = 0;
$videoCount = 0;
foreach ($filesWithRank as $f) {
if ($f['fileType'] === 'video') {
$videoCount++;
}
}
foreach ($filesWithRank as $f) {
if ($f['fileType'] === 'caption') {
$vttQueue[] = $f;
continue;
}
if ($f['fileType'] === 'video') {
$this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
if (!empty($vttQueue)) {
$vtt = array_shift($vttQueue);
$this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
} else {
$this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
}
// Orphaned VTTs
foreach ($vttQueue as $vtt) {
$this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
return $num;
}
/**
* Write a single TFE file from tmp/filepond to the thesis directory.
*/
private function writeTfeFileFromTmp(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void
{
$padded = sprintf('%02d', $num);
$targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext'];
$targetPath = $dir . $targetName;
if (!rename($f['actualFile'], $targetPath)) {
error_log("ThesisFileHandler: failed to move TFE {$f['origName']} from tmp");
return;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, $f['fileType'],
$relPath,
basename($f['origName']),
$f['size'],
$f['mimeType'],
$f['label'] !== '' ? $f['label'] : null,
$f['sortOrder']
);
error_log("ThesisFileHandler: TFE uploaded (filepond) → $targetName ({$f['fileType']})");
$this->cleanupFilePondTmp($f['fileId']);
}
/**
* Clean up a tmp/filepond directory after processing.
*/
private function cleanupFilePondTmp(string $fileId): void
{
$tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId;
if (!is_dir($tmpDir)) {
return;
}
$it = new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS);
$files_it = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files_it as $file) {
if ($file->isDir()) {
@rmdir($file->getRealPath());
} else {
@unlink($file->getRealPath());
}
}
@rmdir($tmpDir);
}
}

0
app/storage/tmp/.gitkeep Normal file
View File

View File

@@ -1468,6 +1468,84 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: okvqnxxl 4c0538fc "refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: okvqnxxl 4c0538fc "refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: lmomnqus a26fbab2 "filepond: implement async server-ID architecture" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: lmomnqus 0a7007c5 "filepond: implement async server-ID architecture" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: lmomnqus 0a7007c5 "filepond: implement async server-ID architecture" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: opqqqptm 1701f1f1 "filepond: implement async server-ID architecture" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: opqqqptm 25762c66 "filepond: implement async server-ID architecture" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: opqqqptm 25762c66 "filepond: implement async server-ID architecture" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: ttylnzqt 64ded48a "filepond: fix CSRF token, partage meta tag, file field detection, add debug logging" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: ttylnzqt 2c31453c "filepond: fix CSRF token, partage meta tag, file field detection, add debug logging" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: ttylnzqt 2c31453c "filepond: fix CSRF token, partage meta tag, file field detection, add debug logging" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: rotylxwp 93dbea67 "filepond: fix nested $_FILES extraction for multi-file queues" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: rotylxwp 25144d51 "filepond: fix nested $_FILES extraction for multi-file queues" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: rotylxwp 25144d51 "filepond: fix nested $_FILES extraction for multi-file queues" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: vmxqlrmt 1806a2cd "filepond: fix $_FILES unwrapping for PHP-nested multi-file queue inputs" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: vmxqlrmt fa81358d "filepond: fix $_FILES unwrapping for PHP-nested multi-file queue inputs" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: vmxqlrmt fa81358d "filepond: fix $_FILES unwrapping for PHP-nested multi-file queue inputs" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: uzsxkykq af017a38 "filepond: fix audio/mp3 MIME, immediate PeerTube upload, handle nested queue $_FILES" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: uzsxkykq c23ac0f6 "filepond: fix audio/mp3 MIME, immediate PeerTube upload, handle nested queue $_FILES" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
?>
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">

View File

@@ -64,6 +64,9 @@
<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/modern-normalize.min.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/common.css') ?>">
<?php foreach ($extraCss ?? [] as $css): ?>

View File

@@ -40,7 +40,8 @@ $adminMode = $adminMode ?? false;
<input type="file" id="tfe-files-input"
name="queue_file[tfe][]" multiple
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.mp4,.webm,.mov,.ogv,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a,.vtt"
class="tfe-file-picker">
class="tfe-file-picker"
data-queue-type="tfe">
<small class="admin-file-hint">
Types acceptés : PDF · JPG/PNG/GIF/WEBP · MP4/WebM/MOV (vidéo) · MP3/OGG/WAV/FLAC (audio) · ZIP/TAR (archives). Max 500 MB par fichier.
Les fichiers <code>.vtt</code> sont des sous-titres et seront associés automatiquement à la vidéo précédente.

View File

@@ -145,6 +145,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard data-upload-progress>
<input type="hidden" name="progress_token" value="<?= bin2hex(random_bytes(8)) ?>">
<input type="hidden" name="filepond_mode" value="1">
<?= $hiddenFields ?>
<?php if (!$adminMode): ?>
@@ -322,6 +323,25 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$_POST['website_label'] = $existingWebsiteLabel;
$_POST['admin_mode'] = $adminMode ? '1' : '0';
$_POST['has_annexes'] = $formData['has_annexes'] ?? null;
// Build existing-files JSON for FilePond edit mode
$existingFilesJsonForTfe = [];
if (!empty($currentFiles)) {
foreach ($currentFiles as $f) {
$ft = $f['file_type'] ?? '';
$fp = $f['file_path'] ?? '';
// Skip cover (handled separately) and website/peertube (no actual file)
if ($ft === 'cover' || str_starts_with($fp, 'http://') || str_starts_with($fp, 'https://') || str_starts_with($fp, 'peertube_ids:')) {
continue;
}
// Only include files that can be streamed back via load.php
$existingFilesJsonForTfe[] = [
'source' => (string)((int)$f['id']),
'options' => ['type' => 'local'],
];
}
}
include APP_ROOT . '/public/partage/fichiers-fragment.php';
$_POST = $_savedPost;
unset($_savedPost);
@@ -338,6 +358,25 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$_POST['edit_mode'] = '1';
$_POST['_cover'] = $currentCover['file_path'] ?? null;
$_POST['has_annexes'] = $formData['has_annexes'] ?? null;
// Build existing-files JSON for FilePond edit mode
$existingFilesJsonForTfe = [];
if (!empty($currentFiles)) {
foreach ($currentFiles as $f) {
$ft = $f['file_type'] ?? '';
$fp = $f['file_path'] ?? '';
// Skip cover (handled separately) and website/peertube (no actual file)
if ($ft === 'cover' || str_starts_with($fp, 'http://') || str_starts_with($fp, 'https://') || str_starts_with($fp, 'peertube_ids:')) {
continue;
}
// Only include files that can be streamed back via load.php
$existingFilesJsonForTfe[] = [
'source' => (string)((int)$f['id']),
'options' => ['type' => 'local'],
];
}
}
include APP_ROOT . '/public/partage/fichiers-fragment.php';
$_POST = $_savedPost;
unset($_savedPost);