@@ -35,6 +36,7 @@ if (!($d['configured'] ?? false)): ?>
= htmlspecialchars(substr($v['createdAt'] ?? '', 0, 10)) ?>
","uuid":"= htmlspecialchars($v['uuid']) ?>"}'
diff --git a/app/public/admin/cleanup.php b/app/public/admin/cleanup.php
index 01a2e05..459d564 100644
--- a/app/public/admin/cleanup.php
+++ b/app/public/admin/cleanup.php
@@ -7,7 +7,7 @@ if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
-$pageTitle = 'Nettoyer les fichiers temporaires';
+$pageTitle = 'Nettoyage';
$isAdmin = true;
$bodyClass = 'admin-body';
diff --git a/app/public/admin/index.php b/app/public/admin/index.php
index 3a34a84..0b388ec 100644
--- a/app/public/admin/index.php
+++ b/app/public/admin/index.php
@@ -1,5 +1,6 @@
getPDO();
if ($_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
- throw new Exception("Erreur lors du téléversement du fichier.");
+ throw new Exception('Erreur lors du téléversement du fichier.');
}
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
- if (!$handle) throw new Exception("Impossible d'ouvrir le fichier CSV.");
+ if (!$handle) {
+ throw new Exception("Impossible d'ouvrir le fichier CSV.");
+ }
// Scan up to 8 rows looking for a header row with known column names.
// Build colIdx[name] → position map; fall back to positional if header not found.
@@ -50,40 +53,66 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$knownHeaders[] = 'license';
for ($scan = 0; $scan < 8; $scan++) {
$hrow = fgetcsv($handle, 0, ',', '"', '');
- if ($hrow === false) break;
+ if ($hrow === false) {
+ break;
+ }
$headerRowNum++;
- $normRow = array_map(fn($s) => strtolower(trim((string)$s)), $hrow);
+ $normRow = array_map(fn ($s) => strtolower(trim((string)$s)), $hrow);
$hits = 0;
$map = [];
$used = [];
foreach ($knownHeaders as $h) {
foreach ($normRow as $pos => $cell) {
- if (isset($used[$pos])) continue;
+ if (isset($used[$pos])) {
+ continue;
+ }
// Exact match
- if ($cell === $h) { $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
+ if ($cell === $h) {
+ $hits++;
+ $map[$h] = $pos;
+ $used[$pos] = true;
+ break;
+ }
// Licence/License cross-match
- if (($h === 'licence' && $cell === 'license') || ($h === 'license' && $cell === 'licence'))
- { $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
+ if (($h === 'licence' && $cell === 'license') || ($h === 'license' && $cell === 'licence')) {
+ $hits++;
+ $map[$h] = $pos;
+ $used[$pos] = true;
+ break;
+ }
// Prefix match (for compound headers like "contact.visible")
$hlen = strlen($h);
if ($hlen >= 4 && str_starts_with($cell, $h)) {
// Avoid short prefixes matching unrelated words
- if ($hlen >= 5 || $cell === $h) { $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
+ if ($hlen >= 5 || $cell === $h) {
+ $hits++;
+ $map[$h] = $pos;
+ $used[$pos] = true;
+ break;
+ }
}
// Substring match for short distinguishers (ulb, externe)
if ($hlen >= 3 && $hlen <= 7 && str_contains($cell, $h)) {
- $hits++; $map[$h] = $pos; $used[$pos] = true; break;
+ $hits++;
+ $map[$h] = $pos;
+ $used[$pos] = true;
+ break;
}
}
}
// Require at least 11 known headers to trust the row.
- if ($hits >= 11) { $colIdx = $map; break; }
+ if ($hits >= 11) {
+ $colIdx = $map;
+ break;
+ }
}
// If no header row found, rewind and fall back to positional (skip 4 rows).
if ($colIdx === null) {
rewind($handle);
$headerRowNum = 4;
- for ($i = 0; $i < 4; $i++) fgetcsv($handle);
+ for ($i = 0; $i < 4; $i++) {
+ fgetcsv($handle);
+ }
} else {
// Consume blank/instruction/template rows between header and data.
// Stops when a row has a non-empty identifiant column that is not a
@@ -92,7 +121,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$peekRow = null;
while (true) {
$peek = fgetcsv($handle, 0, ',', '"', '');
- if ($peek === false) break;
+ if ($peek === false) {
+ break;
+ }
$headerRowNum++;
$val = trim((string)($peek[$idPos] ?? ''));
if ($val === '' || str_starts_with(strtolower($val), 'column')
@@ -117,28 +148,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
// When header was found: use mapped column.
// When header was found but key is missing: use positional fallback.
// When no header found: use positional fallback.
- $cell = function(array $row, string $name) use ($colIdx, $fallbackPositions): string {
+ $cell = function (array $row, string $name) use ($colIdx, $fallbackPositions): string {
if ($colIdx !== null) {
$pos = $colIdx[$name] ?? null;
if ($pos === null) {
// Try licence/license cross-lookup
- if ($name === 'license') $pos = $colIdx['licence'] ?? null;
- elseif ($name === 'licence') $pos = $colIdx['license'] ?? null;
+ if ($name === 'license') {
+ $pos = $colIdx['licence'] ?? null;
+ } elseif ($name === 'licence') {
+ $pos = $colIdx['license'] ?? null;
+ }
}
// Fall back to positional if header didn't have this key
if ($pos === null) {
$pos = $fallbackPositions[$name] ?? null;
}
- if ($pos === null) return '';
+ if ($pos === null) {
+ return '';
+ }
} else {
$pos = $fallbackPositions[$name] ?? null;
- if ($pos === null) return '';
+ if ($pos === null) {
+ return '';
+ }
}
return isset($row[$pos]) ? trim((string)$row[$pos]) : '';
};
// Helper: parse "Oui"/"Non"/1/0 strings to int
- $parseBool = function(string $raw): int {
+ $parseBool = function (string $raw): int {
$raw = strtolower(trim($raw));
return ($raw === 'oui' || $raw === '1' || $raw === 'yes' || $raw === 'true') ? 1 : 0;
};
@@ -149,9 +187,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
// Europe/Brussels timezone and convert to UTC for storage, matching
// the rest of the DB (SQLite CURRENT_TIMESTAMP) and the db_datetime()
// display helper which converts UTC → Brussels on read.
- $parseBulkDate = function(string $raw): ?string {
+ $parseBulkDate = function (string $raw): ?string {
$raw = trim($raw);
- if ($raw === '') return null;
+ if ($raw === '') {
+ return null;
+ }
// Try a set of formats (d/m/Y first — the CSV convention).
// Date-only formats (no time component) are anchored to midnight
@@ -190,11 +230,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
// Code → canonical name (legacy short-code CSV format)
$orientationCodeMap = [
- 'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
- 'IP'=>'Installation-Performance','PE'=>'Peinture','PH'=>'Photographie',
- 'DE'=>'Dessin','AN'=>'Arts Numériques','GR'=>'Graphisme',
- 'TY'=>'Typographie','DN'=>'Design Numérique','IL'=>'Illustration',
- 'BD'=>'Bande-Dessinée','SE'=>'Sérigraphie','GV'=>'Gravure',
+ 'SC' => 'Sculpture','VI' => 'Vidéographie','CA' => "Cinéma d'animation",
+ 'IP' => 'Installation-Performance','PE' => 'Peinture','PH' => 'Photographie',
+ 'DE' => 'Dessin','AN' => 'Arts Numériques','GR' => 'Graphisme',
+ 'TY' => 'Typographie','DN' => 'Design Numérique','IL' => 'Illustration',
+ 'BD' => 'Bande-Dessinée','SE' => 'Sérigraphie','GV' => 'Gravure',
];
// Alias map: normalise known variant spellings → canonical DB name.
@@ -210,9 +250,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
];
// Resolve an orientation string (code or full name) → canonical DB name.
- $resolveOrientation = function(string $raw) use ($orientationCodeMap, $orientationAliases, $importPdo): ?int {
+ $resolveOrientation = function (string $raw) use ($orientationCodeMap, $orientationAliases, $importPdo): ?int {
$raw = trim($raw);
- if ($raw === '') return null;
+ if ($raw === '') {
+ return null;
+ }
// 1. Try legacy short code
if (isset($orientationCodeMap[$raw])) {
@@ -226,13 +268,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
// 3. Exact DB match
- $s = $importPdo->prepare("SELECT id FROM orientations WHERE name = ?");
+ $s = $importPdo->prepare('SELECT id FROM orientations WHERE name = ?');
$s->execute([$raw]);
$r = $s->fetch();
- if ($r) return (int)$r['id'];
+ if ($r) {
+ return (int)$r['id'];
+ }
// 4. Case-insensitive DB match
- $s = $importPdo->prepare("SELECT id FROM orientations WHERE LOWER(name) = LOWER(?)");
+ $s = $importPdo->prepare('SELECT id FROM orientations WHERE LOWER(name) = LOWER(?)');
$s->execute([$raw]);
$r = $s->fetch();
return $r ? (int)$r['id'] : null;
@@ -257,9 +301,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
];
// Resolve an AP string (code or full name) → ap_program id.
- $resolveAP = function(string $raw) use ($apAliases, $importPdo): ?int {
+ $resolveAP = function (string $raw) use ($apAliases, $importPdo): ?int {
$raw = trim($raw);
- if ($raw === '') return null;
+ if ($raw === '') {
+ return null;
+ }
// 1. Try alias map (lowercase key) → canonical code
$key = strtolower($raw);
@@ -268,19 +314,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
// 2. Match by code
- $s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
+ $s = $importPdo->prepare('SELECT id FROM ap_programs WHERE code = ?');
$s->execute([$raw]);
$r = $s->fetch();
- if ($r) return (int)$r['id'];
+ if ($r) {
+ return (int)$r['id'];
+ }
// 3. Code match (legacy)
- $s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
+ $s = $importPdo->prepare('SELECT id FROM ap_programs WHERE code = ?');
$s->execute([$raw]);
$r = $s->fetch();
- if ($r) return (int)$r['id'];
+ if ($r) {
+ return (int)$r['id'];
+ }
// 4. Case-insensitive name match
- $s = $importPdo->prepare("SELECT id FROM ap_programs WHERE LOWER(name) = LOWER(?)");
+ $s = $importPdo->prepare('SELECT id FROM ap_programs WHERE LOWER(name) = LOWER(?)');
$s->execute([$raw]);
$r = $s->fetch();
return $r ? (int)$r['id'] : null;
@@ -294,10 +344,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$usePeek = false;
} else {
$row = fgetcsv($handle, 0, ',', '"', '');
- if ($row === false) break;
+ if ($row === false) {
+ break;
+ }
}
$lineNumber++;
- if (empty($row[0]) && empty($row[1])) continue;
+ if (empty($row[0]) && empty($row[1])) {
+ continue;
+ }
try {
$importDb->beginTransaction();
@@ -355,10 +409,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if ($title === '' || $year === 0) {
$missing = [];
- if ($title === '') $missing[] = 'titre';
- if ($year === 0) $missing[] = 'année';
- throw new Exception("Champ(s) requis manquant(s) : " . implode(', ', $missing)
- . " (id=\"" . ($identifier ?: '?') . "\", titre=\"" . substr($title, 0, 80) . "\")");
+ if ($title === '') {
+ $missing[] = 'titre';
+ }
+ if ($year === 0) {
+ $missing[] = 'année';
+ }
+ throw new Exception('Champ(s) requis manquant(s) : ' . implode(', ', $missing)
+ . ' (id="' . ($identifier ?: '?') . '", titre="' . substr($title, 0, 80) . '")');
}
$orientationId = $resolveOrientation($orientationCode);
@@ -366,26 +424,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$finalityId = null;
if (!empty($finalityName)) {
- $s = $importPdo->prepare("SELECT id FROM finality_types WHERE name = ?");
+ $s = $importPdo->prepare('SELECT id FROM finality_types WHERE name = ?');
$s->execute([$finalityName]);
- $r = $s->fetch(); $finalityId = $r ? $r['id'] : null;
+ $r = $s->fetch();
+ $finalityId = $r ? $r['id'] : null;
}
$accessTypeId = null;
if (!empty($access)) {
- $s = $importPdo->prepare("SELECT id FROM access_types WHERE name = ?");
+ $s = $importPdo->prepare('SELECT id FROM access_types WHERE name = ?');
$s->execute([ucfirst(strtolower($access))]);
- $r = $s->fetch(); $accessTypeId = $r ? $r['id'] : null;
+ $r = $s->fetch();
+ $accessTypeId = $r ? $r['id'] : null;
+ }
+ if ($accessTypeId === null) {
+ $accessTypeId = 1;
}
- if ($accessTypeId === null) $accessTypeId = 1;
if (!empty($identifier)) {
- $s = $importPdo->prepare("SELECT id FROM theses WHERE identifier = ?");
+ $s = $importPdo->prepare('SELECT id FROM theses WHERE identifier = ?');
$s->execute([$identifier]);
if ($s->fetch()) {
$importDb->rollback();
$skippedCount++;
- $importResults[] = ['type'=>'skip', 'msg'=>"Ligne $lineNumber: identifiant \"$identifier\" déjà présent, ignoré."];
+ $importResults[] = ['type' => 'skip', 'msg' => "Ligne $lineNumber: identifiant \"$identifier\" déjà présent, ignoré."];
continue;
}
}
@@ -393,7 +455,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
// Resolve license name → license_id
$licenseId = null;
if (!empty($license)) {
- $ls = $importPdo->prepare("SELECT id FROM license_types WHERE LOWER(name) = LOWER(?)");
+ $ls = $importPdo->prepare('SELECT id FROM license_types WHERE LOWER(name) = LOWER(?)');
$ls->execute([trim($license)]);
$lr = $ls->fetch();
$licenseId = $lr ? (int)$lr['id'] : null;
@@ -418,13 +480,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
");
$s->execute([
!empty($identifier) ? $identifier : null, $title,
- !empty($subtitle) ? $subtitle : null, $year,
+ !empty($subtitle) ? $subtitle : null, $year,
$orientationId, $apProgramId, $finalityId,
- !empty($synopsis) ? $synopsis : null,
- !empty($context) ? $context : null,
- !empty($remarks) ? $remarks : null,
+ !empty($synopsis) ? $synopsis : null,
+ !empty($context) ? $context : null,
+ !empty($remarks) ? $remarks : null,
$juryPoints,
- !empty($baiuLink) ? $baiuLink : null,
+ !empty($baiuLink) ? $baiuLink : null,
$accessTypeId,
$licenseId,
!empty($licenseCustom) ? $licenseCustom : null,
@@ -444,7 +506,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
foreach (array_map('trim', explode(',', $authorsRaw)) as $idx => $name) {
if ($name) {
$aId = $importDb->findOrCreateAuthor($name, $idx === 0 ? $contact : null);
- $s = $importPdo->prepare("INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?,?,?)");
+ $s = $importPdo->prepare('INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?,?,?)');
$s->execute([$thesisId, $aId, $idx + 1]);
}
}
@@ -457,7 +519,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if ($name) {
$juryOrder++;
$sId = $importDb->findOrCreateSupervisor($name);
- $stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
+ $stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
$stmt->execute([$thesisId, $sId, 'promoteur', 0, 0, $juryOrder]);
}
}
@@ -468,7 +530,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if ($name) {
$juryOrder++;
$sId = $importDb->findOrCreateSupervisor($name);
- $stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
+ $stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
$stmt->execute([$thesisId, $sId, 'lecteur', 0, 0, $juryOrder]);
}
}
@@ -479,7 +541,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if ($name) {
$juryOrder++;
$sId = $importDb->findOrCreateSupervisor($name);
- $stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
+ $stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
$stmt->execute([$thesisId, $sId, 'lecteur', 1, 0, $juryOrder]);
}
}
@@ -490,19 +552,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if ($name) {
$juryOrder++;
$sId = $importDb->findOrCreateSupervisor($name);
- $stmt = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)");
+ $stmt = $importPdo->prepare('INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, is_ulb, supervisor_order) VALUES (?,?,?,?,?,?)');
$stmt->execute([$thesisId, $sId, 'promoteur', 1, 1, $juryOrder]);
}
}
}
if (!empty($keywordsRaw)) {
- $normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
+ $normalizeTag = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
$tags = array_values(array_unique(array_map($normalizeTag, explode(',', $keywordsRaw))));
- $tags = array_filter($tags, fn($t) => $t !== '');
+ $tags = array_filter($tags, fn ($t) => $t !== '');
foreach (array_slice($tags, 0, 10) as $kw) {
$tId = $importDb->findOrCreateTag($kw);
if ($tId) {
- $s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
+ $s = $importPdo->prepare('INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)');
$s->execute([$thesisId, $tId]);
}
}
@@ -510,28 +572,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if (!empty($languageRaw)) {
foreach (array_map('trim', explode(',', $languageRaw)) as $langName) {
$langName = strtolower($langName);
- if ($langName === '') continue;
+ if ($langName === '') {
+ continue;
+ }
// Lookup case-insensitively; insert if missing (stored lowercase).
- $s = $importPdo->prepare("SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL");
+ $s = $importPdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL');
$s->execute([$langName]);
$r = $s->fetch();
$langId = $r ? (int)$r['id'] : null;
if ($langId === null) {
- $importPdo->prepare("INSERT INTO languages (name) VALUES (?)")->execute([$langName]);
+ $importPdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$langName]);
$langId = (int)$importPdo->lastInsertId();
}
- $s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
+ $s2 = $importPdo->prepare('INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)');
$s2->execute([$thesisId, $langId]);
}
}
if (!empty($formatsRaw)) {
foreach (array_map('trim', explode(',', $formatsRaw)) as $fmt) {
if ($fmt) {
- $s = $importPdo->prepare("SELECT id FROM format_types WHERE name = ?");
+ $s = $importPdo->prepare('SELECT id FROM format_types WHERE name = ?');
$s->execute([ucfirst(strtolower($fmt))]);
$r = $s->fetch();
if ($r) {
- $s2 = $importPdo->prepare("INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)");
+ $s2 = $importPdo->prepare('INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)');
$s2->execute([$thesisId, $r['id']]);
}
}
@@ -540,12 +604,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$importDb->commit();
$importedCount++;
- $importResults[] = ['type'=>'ok', 'msg'=>"\"$title\" (ID: $thesisId)"];
+ $importResults[] = ['type' => 'ok', 'msg' => "\"$title\" (ID: $thesisId)"];
} catch (Exception $e) {
$importDb->rollback();
$skippedCount++;
- $importResults[] = ['type'=>'error', 'msg'=>"Ligne $lineNumber: " . $e->getMessage()];
+ $importResults[] = ['type' => 'error', 'msg' => "Ligne $lineNumber: " . $e->getMessage()];
error_log("Import error on line $lineNumber: " . $e->getMessage());
}
}
@@ -554,7 +618,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$importDone = true;
} catch (Exception $e) {
$importErrors[] = $e->getMessage();
- error_log("CSV import error: " . $e->getMessage());
+ error_log('CSV import error: ' . $e->getMessage());
}
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
@@ -562,19 +626,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
try {
$db = new Database();
- $searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
- $yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null;
+ $searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
+ $yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null;
$orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null;
- $apFilter = isset($_GET['ap']) ? intval($_GET['ap']) : null;
+ $apFilter = isset($_GET['ap']) ? intval($_GET['ap']) : null;
- $sortCol = isset($_GET['sort']) ? trim($_GET['sort']) : 'submitted_at';
- $sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc';
+ $sortCol = isset($_GET['sort']) ? trim($_GET['sort']) : 'submitted_at';
+ $sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc';
$filters = [];
- if ($searchQuery) $filters['search'] = $searchQuery;
- if ($yearFilter) $filters['year'] = $yearFilter;
- if ($orientationFilter) $filters['orientation'] = $orientationFilter;
- if ($apFilter) $filters['ap'] = $apFilter;
+ if ($searchQuery) {
+ $filters['search'] = $searchQuery;
+ }
+ if ($yearFilter) {
+ $filters['year'] = $yearFilter;
+ }
+ if ($orientationFilter) {
+ $filters['orientation'] = $orientationFilter;
+ }
+ if ($apFilter) {
+ $filters['ap'] = $apFilter;
+ }
$filters['sort'] = $sortCol;
$filters['dir'] = $sortDir;
@@ -589,21 +661,11 @@ try {
$trashedTheses = ($tab === 'trash') ? $db->getTrashedTheses() : [];
// ── Tmp file stats ───────────────────────────────────────────────────
- $filepondDir = STORAGE_ROOT . '/tmp/filepond';
+ // Abandoned FilePond uploads are purged automatically by cron, so the badge
+ // only reflects the manually-managed trash.
$trashDir = STORAGE_ROOT . '/tmp/_trash';
- $tmpFilepondCount = 0;
$tmpTrashCount = 0;
$tmpTotalCount = 0;
- if (is_dir($filepondDir)) {
- $items = @scandir($filepondDir);
- if ($items !== false) {
- foreach ($items as $item) {
- if ($item !== '.' && $item !== '..' && $item !== '.gitkeep') {
- $tmpFilepondCount++;
- }
- }
- }
- }
if (is_dir($trashDir)) {
$items = @scandir($trashDir);
if ($items !== false) {
@@ -614,14 +676,15 @@ try {
}
}
}
- $tmpTotalCount = $tmpFilepondCount + $tmpTrashCount;
+ $tmpTotalCount = $tmpTrashCount;
} catch (Exception $e) {
- error_log("Error loading theses list: " . $e->getMessage());
- die("Erreur lors du chargement de la liste.");
+ error_log('Error loading theses list: ' . $e->getMessage());
+ die('Erreur lors du chargement de la liste.');
}
$isHtmx = ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true';
-$isAdmin = true; $bodyClass = 'admin-body';
+$isAdmin = true;
+$bodyClass = 'admin-body';
if ($isHtmx) {
if ($tab === 'trash') {
include APP_ROOT . '/templates/admin/index-trash.php';
@@ -640,4 +703,3 @@ if ($isHtmx) {
}
require_once APP_ROOT . '/templates/admin/footer.php';
}
-
diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css
index 808bf02..07ee519 100644
--- a/app/public/assets/css/admin.css
+++ b/app/public/assets/css/admin.css
@@ -1142,6 +1142,31 @@ th.admin-ap-col {
margin-left: var(--space-2xs);
}
+/* Stat line rendered as a card under a cleanup-section heading. */
+.cleanup-stat-card {
+ margin: 0 0 var(--space-m);
+ padding: var(--space-s) var(--space-l);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius);
+ font-size: var(--step-1);
+ font-weight: 700;
+ color: var(--accent-primary);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2xs) var(--space-m);
+ align-items: baseline;
+}
+
+/* Short French explanation shown under a cleanup-section heading. */
+.cleanup-section-desc {
+ margin: 0 0 var(--space-m);
+ font-size: var(--step--1);
+ line-height: 1.5;
+ color: var(--text-secondary);
+ max-width: 70ch;
+}
+
.n-grid {
display: block;
}
@@ -2284,6 +2309,15 @@ th.admin-ap-col {
margin-bottom: var(--space-m);
}
+/* Cleanup page: sections are loaded asynchronously into a wrapper div, so
+ give them the same top-level spacing as direct article sections. */
+#tmp-cleanup-stats-wrapper > section[aria-labelledby] {
+ margin-bottom: var(--space-xl);
+ border: none;
+ border-radius: 0;
+ padding: 0;
+}
+
/* Admin TOC: same as public pages, positioned sticky */
#admin-toc .toc-list {
padding-top: var(--space-2xs);
diff --git a/app/public/assets/css/components/buttons.css b/app/public/assets/css/components/buttons.css
index 7cd7942..33cc8bc 100644
--- a/app/public/assets/css/components/buttons.css
+++ b/app/public/assets/css/components/buttons.css
@@ -167,3 +167,59 @@
.btn--red:hover {
filter: brightness(0.9);
}
+
+/* Tooltips — display when a .btn carries a data-tip attribute.
+ Shown below the button on hover/focus, positioned above the
+ toolbar so it never clips inside the scrollable table. */
+.btn[data-tip] {
+ position: relative;
+}
+
+.btn[data-tip]::after {
+ content: attr(data-tip);
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 8px);
+ transform: translateX(-50%);
+ width: max-content;
+ max-width: 260px;
+ padding: var(--space-3xs) var(--space-xs);
+ border-radius: var(--radius);
+ background: var(--text-primary);
+ color: var(--bg-primary);
+ font-size: var(--step--2);
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ line-height: 1.3;
+ text-align: center;
+ opacity: 0;
+ pointer-events: none;
+ z-index: 1000;
+ transition: opacity 0.12s ease;
+}
+
+.btn[data-tip]:hover::after,
+.btn[data-tip]:focus::after {
+ opacity: 1;
+}
+
+/* Small arrow below the bubble pointing at the button */
+.btn[data-tip]::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 4px);
+ transform: translateX(-50%);
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 4px solid var(--text-primary);
+ opacity: 0;
+ pointer-events: none;
+ z-index: 1000;
+ transition: opacity 0.12s ease;
+}
+
+.btn[data-tip]:hover::before,
+.btn[data-tip]:focus::before {
+ opacity: 1;
+}
diff --git a/app/public/assets/js/app/admin-cleanup-bulk.js b/app/public/assets/js/app/admin-cleanup-bulk.js
index d5f21a8..616ce0c 100644
--- a/app/public/assets/js/app/admin-cleanup-bulk.js
+++ b/app/public/assets/js/app/admin-cleanup-bulk.js
@@ -5,12 +5,8 @@
* All functions are attached to `window` for onclick handlers in PHP templates.
*/
(() => {
- function cleanupToggleAll(src, group) {
- const selector =
- group === "filepond"
- ? 'input[name="filepond_dirs[]"]'
- : 'input[name="trash_files[]"]';
- document.querySelectorAll(selector).forEach((cb) => {
+ function cleanupToggleAll(src) {
+ document.querySelectorAll('input[name="trash_files[]"]').forEach((cb) => {
cb.checked = src.checked;
});
cleanupUpdateBulk();
@@ -23,7 +19,7 @@
if (!bar || !countEl) return;
const allChecked = document.querySelectorAll(
- 'input[name="filepond_dirs[]"]:checked, input[name="trash_files[]"]:checked',
+ 'input[name="trash_files[]"]:checked',
);
const n = allChecked.length;
@@ -48,13 +44,11 @@
// Populate hidden inputs from checked checkboxes
container.innerHTML = "";
document
- .querySelectorAll(
- 'input[name="filepond_dirs[]"]:checked, input[name="trash_files[]"]:checked',
- )
+ .querySelectorAll('input[name="trash_files[]"]:checked')
.forEach((cb) => {
const inp = document.createElement("input");
inp.type = "hidden";
- inp.name = cb.name; // "filepond_dirs[]" or "trash_files[]"
+ inp.name = cb.name; // "trash_files[]"
inp.value = cb.value;
container.appendChild(inp);
});
@@ -65,13 +59,17 @@
function cleanupBulkRestore() {
const form = document.getElementById("cleanup-bulk-restore-form");
- const container = document.getElementById("cleanup-bulk-restore-checkboxes");
+ const container = document.getElementById(
+ "cleanup-bulk-restore-checkboxes",
+ );
if (!form || !container) return;
container.innerHTML = "";
let count = 0;
document
- .querySelectorAll('input[name="trash_files[]"]:checked[data-restorable="1"]')
+ .querySelectorAll(
+ 'input[name="trash_files[]"]:checked[data-restorable="1"]',
+ )
.forEach((cb) => {
const inp = document.createElement("input");
inp.type = "hidden";
@@ -83,19 +81,16 @@
if (count === 0) return;
- if (!confirm(`Restaurer ${count} fichier(s) vers leur(s) TFE associé(s) ?`)) return;
+ if (!confirm(`Restaurer ${count} fichier(s) vers leur(s) TFE associé(s) ?`))
+ return;
htmx.trigger(form, "submit");
}
function reattachListeners() {
- document
- .querySelectorAll(
- 'input[name="filepond_dirs[]"], input[name="trash_files[]"]',
- )
- .forEach((cb) => {
- cb.addEventListener("change", cleanupUpdateBulk);
- });
+ document.querySelectorAll('input[name="trash_files[]"]').forEach((cb) => {
+ cb.addEventListener("change", cleanupUpdateBulk);
+ });
cleanupUpdateBulk();
}
diff --git a/app/public/assets/js/app/admin-toc.js b/app/public/assets/js/app/admin-toc.js
index c2a93ff..ac55df4 100644
--- a/app/public/assets/js/app/admin-toc.js
+++ b/app/public/assets/js/app/admin-toc.js
@@ -7,63 +7,92 @@
* section[aria-labelledby] headings inside #main-content.
* Hides the TOC if fewer than 2 sections exist.
*
- * Guarded: only runs once.
+ * Rebuilds whenever HTMX swaps content so sections that are loaded
+ * asynchronously (e.g. cleanup page fragments) appear in the TOC.
*/
(() => {
- if (window.__adminTocBuilt) return;
- window.__adminTocBuilt = true;
+ let observer = null;
+ let items = [];
+
+ // Generate a spread of thresholds for smooth IntersectionObserver firing.
+ function buildThresholds() {
+ const t = [];
+ for (let i = 0; i <= 20; i++) t.push(i / 20);
+ return t;
+ }
+
+ function tearDown() {
+ if (observer) {
+ observer.disconnect();
+ observer = null;
+ }
+ items = [];
+ }
function build() {
- var main = document.getElementById("main-content");
- var toc = document.getElementById("admin-toc");
- var list = document.getElementById("admin-toc-list");
+ const main = document.getElementById("main-content");
+ const toc = document.getElementById("admin-toc");
+ const list = document.getElementById("admin-toc-list");
if (!main || !toc || !list) return;
- // Guard against double population
- if (list.children.length > 0) return;
+ tearDown();
- var sections = main.querySelectorAll("section[aria-labelledby]");
+ // Repopulate from scratch.
+ list.textContent = "";
+
+ const sections = main.querySelectorAll("section[aria-labelledby]");
if (sections.length < 2) {
toc.hidden = true;
return;
}
+ toc.hidden = false;
- var items = [];
sections.forEach((sec) => {
- var headingId = sec.getAttribute("aria-labelledby");
- var heading = document.getElementById(headingId);
+ const headingId = sec.getAttribute("aria-labelledby");
+ const heading = document.getElementById(headingId);
if (!heading) return;
- if (!sec.id) sec.id = headingId;
- var a = document.createElement("a");
- a.href = `#${sec.id}`;
- a.textContent = heading.textContent.trim();
+ // Anchor the link to the heading id. Do NOT assign the heading id to
+ // the itself — that would create a duplicate id on rebuild
+ // (getElementById would then return the section, whose textContent
+ // drags in the whole table).
+ const anchorId = headingId;
- var li = document.createElement("li");
+ // Label excludes .n-meta counters (e.g. “Corbeille 5 fichiers · 2.1 GB”
+ // should read just “Corbeille” in the TOC).
+ let label;
+ if (heading.querySelector?.(".n-meta")) {
+ const clone = heading.cloneNode(true);
+ clone.querySelectorAll(".n-meta").forEach((n) => {
+ n.remove();
+ });
+ label = clone.textContent.trim();
+ } else {
+ label = heading.textContent.trim();
+ }
+
+ const a = document.createElement("a");
+ a.href = `#${anchorId}`;
+ a.textContent = label;
+
+ const li = document.createElement("li");
li.appendChild(a);
list.appendChild(li);
items.push({ section: sec, link: a });
});
- // Generate a spread of thresholds for smooth IntersectionObserver firing.
- function buildThresholds() {
- var t = [];
- for (var i = 0; i <= 20; i++) t.push(i / 20);
- return t;
- }
-
- var observer = new IntersectionObserver(
+ observer = new IntersectionObserver(
() => {
// Active section = whose top is closest to the midpoint of the
// viewport from above. At page bottom, the last section wins
// even if its heading can't reach 50%.
- var line = window.innerHeight * 0.5;
- var best = null;
- var bestDist = Infinity;
+ const line = window.innerHeight * 0.5;
+ let best = null;
+ let bestDist = Infinity;
// Pass 1: sections whose top is above/at the line
items.forEach((item) => {
- var top = item.section.getBoundingClientRect().top;
+ const top = item.section.getBoundingClientRect().top;
if (top <= line && line - top < bestDist) {
bestDist = line - top;
best = item;
@@ -73,8 +102,8 @@
// Pass 2: no section above the line — pick closest from below
if (!best) {
items.forEach((item) => {
- var top = item.section.getBoundingClientRect().top;
- var dist = top - line;
+ const top = item.section.getBoundingClientRect().top;
+ const dist = top - line;
if (dist >= 0 && dist < bestDist) {
bestDist = dist;
best = item;
@@ -85,10 +114,10 @@
// Pass 3: at page bottom, if the last section is visible but
// its heading never crossed 50%, promote it anyway.
if (best) {
- var lastItem = items[items.length - 1];
+ const lastItem = items[items.length - 1];
if (lastItem !== best) {
- var lastTop = lastItem.section.getBoundingClientRect().top;
- var atBottom =
+ const lastTop = lastItem.section.getBoundingClientRect().top;
+ const atBottom =
window.innerHeight + window.scrollY + 5 >=
document.documentElement.scrollHeight;
if (atBottom && lastTop > line && lastTop < window.innerHeight) {
@@ -110,9 +139,28 @@
});
}
+ function run() {
+ if (document.getElementById("admin-toc")) build();
+ }
+
if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", build);
+ document.addEventListener("DOMContentLoaded", run);
} else {
- build();
+ run();
+ }
+
+ // HTMX loads many admin sections asynchronously (cleanup fragments, tables).
+ // Rebuild the TOC whenever the content around it changes.
+ if (window.htmx) {
+ document.body.addEventListener("htmx:afterSwap", (e) => {
+ if (e.target?.closest?.("#main-content")) {
+ run();
+ }
+ });
+ document.body.addEventListener("htmx:afterSettle", (e) => {
+ if (e.target?.closest?.("#main-content")) {
+ run();
+ }
+ });
}
})();
diff --git a/app/templates/admin/cleanup.php b/app/templates/admin/cleanup.php
index d0e76d1..91d8397 100644
--- a/app/templates/admin/cleanup.php
+++ b/app/templates/admin/cleanup.php
@@ -1,20 +1,19 @@
-
+
+
+
-
- Fichiers temporaires
-
-
+
Vidéos PeerTube
diff --git a/app/templates/admin/index-table.php b/app/templates/admin/index-table.php
index 3912ffb..2173a88 100644
--- a/app/templates/admin/index-table.php
+++ b/app/templates/admin/index-table.php
@@ -29,11 +29,11 @@ $sortArrow = function(string $col) use ($sortCol, $sortDir): string {
0 TFE(s) sélectionné(s)
- Publier
- Dépublier
- Exporter CSV
- Exporter fichiers
- Supprimer
+ Publier
+ Dépublier
+ Exporter CSV
+ Exporter fichiers
+ Supprimer
diff --git a/app/templates/admin/index.php b/app/templates/admin/index.php
index d5c1d8d..abcbf9b 100644
--- a/app/templates/admin/index.php
+++ b/app/templates/admin/index.php
@@ -55,20 +55,21 @@
- ✕ Réinitialiser
+ ✕ Réinitialiser
diff --git a/deploy/xamxam-tmp-cleanup.cron b/deploy/xamxam-tmp-cleanup.cron
new file mode 100644
index 0000000..d3c4239
--- /dev/null
+++ b/deploy/xamxam-tmp-cleanup.cron
@@ -0,0 +1,10 @@
+# XAMXAM — abandoned upload garbage-collection cron job
+# Installed to /etc/cron.d/xamxam-tmp-cleanup (system cron format: minute hour dom month dow user command)
+#
+# Deletes staging upload dirs in /tmp/filepond/ that are abandoned:
+# - whose referencing PHP session no longer exists, or
+# - older than the 2h safety fallback.
+# These are never linked to a published TFE, so removing them is risk-free GC.
+# Script is a dry-run unless --no-dry-run is passed.
+# Age threshold overridable via TMP_UPLOAD_MAX_AGE_SECONDS (default 7200).
+5 * * * * www-data php /tmp/cleanup-tmp-uploads.php --no-dry-run >> /var/log/xamxam-tmp-cleanup-$(date +\%Y-\%m-\%d).log 2>&1
diff --git a/docs/deployment.md b/docs/deployment.md
index b6340c8..9206b67 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -32,8 +32,8 @@ This chains (each step is also runnable individually):
`/var/www/xamxam/.env` (idempotent; see below).
2. `just deploy` — code + Composer deps + migrations + permissions.
3. `just deploy-nginx` — install + apply the nginx config and fix permissions.
-4. `just deploy-backup`, `just deploy-cleanup-cron`, `just deploy-logrotate` —
- install backup + cleanup cron jobs and log rotation.
+4. `just deploy-backup`, `just deploy-cleanup-cron`, `just deploy-tmp-cleanup-cron`, `just deploy-logrotate` —
+ install backup + cleanup (drafts + abandoned uploads) cron jobs and log rotation.
It finishes by telling you what's left to do in `/admin/account` (set the admin
password — a fresh DB starts unauthenticated — and configure SMTP/PeerTube
@@ -50,7 +50,7 @@ For a full initial rollout *without* the env/nginx steps (backup + cleanup cron
only), when those are already handled:
```bash
-just deploy-all-first # deploy + deploy-backup + deploy-cleanup-cron + deploy-logrotate
+just deploy-all-first # deploy + deploy-backup + deploy-cleanup-cron + deploy-tmp-cleanup-cron + deploy-logrotate
```
### Server `APP_KEY` — idempotent, never overwrites
@@ -89,8 +89,9 @@ If you ever rotate `APP_KEY`, re-encrypt the SMTP password with
| `just deploy-db` | Push local `xamxam.db` → remote (**refuses** if a remote DB already exists) |
| `just deploy-verify-permissions` | Check ownership / permissions on the server |
-> ℹ️ **First deploy?** After `just deploy`, run `just deploy-backup` and
-> `just deploy-cleanup-cron` to install the backup/cleanup cron jobs, and
+> ℹ️ **First deploy?** After `just deploy`, run `just deploy-backup`,
+> `just deploy-cleanup-cron` and `just deploy-tmp-cleanup-cron` to install the
+> backup/cleanup cron jobs, and
> `just deploy-logrotate` to install log rotation. A single `just deploy-all-first`
> chains all of these together.
> The app log directory `/var/log/xamxam/` is provisioned automatically by
@@ -136,6 +137,18 @@ Draft cleanup is handled by a separate cron (`/etc/cron.d/xamxam-cleanup`),
installed via `just deploy-cleanup-cron`, logging to
`/var/log/xamxam-cleanup-YYYY-MM-DD.log`. Verify with `just deploy-check-cleanup-log`.
+Abandoned FilePond uploads (staging dirs never linked to a published TFE) are
+garbage-collected hourly by `/etc/cron.d/xamxam-tmp-cleanup`, installed via
+`just deploy-tmp-cleanup-cron`, logging to
+`/var/log/xamxam-tmp-cleanup-YYYY-MM-DD.log`. Verify with
+`just deploy-check-tmp-cleanup-log`.
+
+Because abandoned uploads are removed automatically, the admin cleanup page
+(`/admin/cleanup.php`, titled *Nettoyage*) now only surfaces the manually-managed
+**Corbeille** (trashed files, with per-row restore/delete and bulk actions). The
+manual "Téléversements abandonnés" table was removed from the UI; those files
+are purged by the cron above.
+
---
## Permissions model
@@ -226,7 +239,8 @@ just deploy-verify-permissions # expect "All permissions OK"
unchanged; if that still fails, force a reinstall.
- **Backup/schedule not running**
Confirm the cron files are installed (`/etc/cron.d/xamxam-backup`,
- `/etc/cron.d/xamxam-cleanup`) and the log files are writable by `www-data`.
+ `/etc/cron.d/xamxam-cleanup`, `/etc/cron.d/xamxam-tmp-cleanup`) and the log
+ files are writable by `www-data`.
---
@@ -238,6 +252,7 @@ just deploy-verify-permissions # expect "All permissions OK"
| `just deploy-nginx` | Apply nginx config |
| `just deploy-backup` | Install backup script + cron |
| `just deploy-cleanup-cron` | Install orphaned-draft cleanup cron |
+| `just deploy-tmp-cleanup-cron` | Install abandoned-upload GC cron |
| `just deploy-list-backups` | List server backups |
| `just trigger-backup` | Run backup now |
| `just reencrypt-password ` | Re-encrypt SMTP password after key rotation |
diff --git a/docs/development.md b/docs/development.md
index cf4d3e9..6aa0841 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -185,6 +185,8 @@ just backup # SQL dump into app/storage/backup_.sql
just backup-snapshot # WAL-safe hot backup + gzip into storage/backups/
just cleanup-drafts [--no-dry-run] # remove orphaned drafts > 7 days (default)
# OLDER_THAN_HOURS=24 to change the threshold
+just cleanup-tmp-uploads [--no-dry-run] # GC abandoned FilePond uploads (>2h or missing session)
+ # TMP_UPLOAD_MAX_AGE_SECONDS to change the threshold
```
See [database.md](database.md) for the full schema reference.
diff --git a/justfile b/justfile
index c394f7c..5364e99 100644
--- a/justfile
+++ b/justfile
@@ -452,6 +452,26 @@ deploy-cleanup-cron:
deploy-check-cleanup-log:
ssh xamxam "tail -20 /var/log/xamxam-cleanup-\$(date +%Y-%m-%d).log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'"
+deploy-tmp-cleanup-cron:
+ # Install cron job for abandoned-upload garbage collection (hourly, 2h threshold).
+ # Creates /etc/cron.d/xamxam-tmp-cleanup and log file on the server.
+ @echo "📋 Installing abandoned-upload cleanup cron job…"
+ rsync -v scripts/cleanup-tmp-uploads.php xamxam:/tmp/cleanup-tmp-uploads.php
+ ssh xamxam "chmod 755 /tmp/cleanup-tmp-uploads.php"
+ rsync -v deploy/xamxam-tmp-cleanup.cron xamxam:/tmp/xamxam-tmp-cleanup.cron
+ ssh -t xamxam "sudo install -o root -g root -m 644 /tmp/xamxam-tmp-cleanup.cron /etc/cron.d/xamxam-tmp-cleanup && rm -f /tmp/xamxam-tmp-cleanup.cron"
+ ssh -t xamxam "sudo touch /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log && sudo chown www-data:www-data /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log && sudo chmod 644 /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log"
+ @echo "✅ Abandoned-upload cleanup cron installed."
+ @echo " Cron file: /etc/cron.d/xamxam-tmp-cleanup"
+ @echo " Script: /tmp/cleanup-tmp-uploads.php"
+ @echo " Log file: /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log"
+ @echo ""
+ @echo "Verify with: just deploy-check-tmp-cleanup-log"
+
+[group('deploy')]
+deploy-check-tmp-cleanup-log:
+ ssh xamxam "tail -20 /var/log/xamxam-tmp-cleanup-\$(date +%Y-%m-%d).log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'"
+
[group('deploy')]
deploy-logrotate:
# Install /etc/logrotate.d/xamxam for app + nginx + cron logs.
@@ -493,7 +513,7 @@ deploy-migrate-storage dry_run='' target_host='xamxam':
ssh {{target_host}} 'rm -f /var/www/xamxam/migrate-storage-paths.php'
[group('deploy')]
-deploy-all-first: deploy deploy-backup deploy-cleanup-cron deploy-logrotate
+deploy-all-first: deploy deploy-backup deploy-cleanup-cron deploy-tmp-cleanup-cron deploy-logrotate
# One-shot: full initial deploy including backup and cleanup cron jobs.
# One-shot remote provisioning for a fresh xamxam server.
@@ -638,3 +658,10 @@ cleanup-drafts dry_run='':
# just cleanup-drafts --no-dry-run
# OLDER_THAN_HOURS=24 just cleanup-drafts
@php scripts/cleanup-drafts.php {{dry_run}}
+
+cleanup-tmp-uploads dry_run='':
+ # List (dry-run) or delete abandoned FilePond uploads (>2h or missing session).
+ # Pass --no-dry-run to actually delete. Override the age with
+ # TMP_UPLOAD_MAX_AGE_SECONDS:
+ # just cleanup-tmp-uploads --no-dry-run
+ @php scripts/cleanup-tmp-uploads.php {{dry_run}}
diff --git a/scripts/cleanup-tmp-uploads.php b/scripts/cleanup-tmp-uploads.php
new file mode 100644
index 0000000..db8d4ec
--- /dev/null
+++ b/scripts/cleanup-tmp-uploads.php
@@ -0,0 +1,162 @@
+#!/usr/bin/env php
+/tmp/filepond/ (or a private per-user
+ * subfolder when the uploader uses a named uploader directory). A finished,
+ * valid upload is moved out of this staging area as soon as its thesis is
+ * saved, so anything still sitting there is either:
+ * - an upload whose PHP session no longer exists (importer/editor abandoned
+ * mid-file, browser closed, form abandoned), or
+ * - any leftover older than the 2h safety fallback.
+ *
+ * These staging dirs are never referenced by any published TFE, so removing
+ * them is risk-free garbage collection — much like clearing a recycle bin.
+ *
+ * The eligibility logic mirrors the admin "cleanup" page so behaviour is
+ * identical:
+ * - Strategy 1: a manifest.json that references a now-missing PHP session.
+ * - Strategy 2: dir older than 2 hours (time-based fallback).
+ *
+ * Usage (mirrors cleanup-drafts.php):
+ * php /tmp/cleanup-tmp-uploads.php # dry-run (list candidates)
+ * php /tmp/cleanup-tmp-uploads.php --no-dry-run # actually delete
+ *
+ * The 2h fallback threshold is overridable via TMP_UPLOAD_MAX_AGE_SECONDS.
+ *
+ * Exit codes: 0 on success, 1 on error.
+ */
+
+declare(strict_types=1);
+
+// Resolve APP_ROOT robustly. In production the app code is deployed flat under
+// /var/www/xamxam/ (src/, storage/, templates/ at the root, no app/ subdir),
+// and this script may itself live in /tmp. Point at /var/www/xamxam in
+// non-CLI-SAPI contexts; local dev (cli-server) keeps the app/ subdir layout.
+$prodRoot = '/var/www/xamxam';
+if (is_dir($prodRoot . '/src') && is_file($prodRoot . '/src/Database.php')) {
+ define('APP_ROOT', $prodRoot);
+} else {
+ define('APP_ROOT', dirname(__DIR__) . '/app');
+}
+
+// No app classes needed: abandoned-upload GC is purely filesystem-based,
+// mirroring the filepond section of the admin cleanup endpoint.
+$dryRun = !in_array('--no-dry-run', $argv, true);
+
+// Storage root: APP_ROOT/storage in both dev (app/storage) and prod
+// (/var/www/xamxam/storage), mirroring bootstrap.php's STORAGE_ROOT.
+$storageRoot = APP_ROOT . '/storage';
+$filepondDir = $storageRoot . '/tmp/filepond';
+
+// Stability threshold (mirrors cleanup-tmp.php): 2 hours by default.
+$maxAgeSeconds = (int) (getenv('TMP_UPLOAD_MAX_AGE_SECONDS') ?: 7200);
+if ($maxAgeSeconds < 60) {
+ $maxAgeSeconds = 7200;
+}
+
+// PHP session save path — the web process and cron (both www-data) share it.
+$sessionSavePath = session_save_path();
+if (!$sessionSavePath || $sessionSavePath === '') {
+ $sessionSavePath = sys_get_temp_dir();
+}
+
+$now = time();
+$removed = 0;
+$details = [];
+
+if (!is_dir($filepondDir)) {
+ exit(0); // nothing to collect — quiet exit
+}
+
+$items = @scandir($filepondDir);
+if ($items === false) {
+ error_log('[cleanup-tmp-uploads] Unable to read ' . $filepondDir);
+ exit(1);
+}
+
+foreach ($items as $item) {
+ if ($item === '.' || $item === '..' || $item === '.gitkeep') {
+ continue;
+ }
+ $dirPath = $filepondDir . '/' . $item;
+ if (!is_dir($dirPath)) {
+ continue;
+ }
+
+ $shouldDelete = false;
+ $reason = '';
+
+ $manifestPath = $dirPath . '/manifest.json';
+ $ageSeconds = $now - filemtime($dirPath);
+
+ // Strategy 1: session-based (preferred) — the uploader's PHP session is gone.
+ if (file_exists($manifestPath)) {
+ $manifest = json_decode((string) file_get_contents($manifestPath), true);
+ if (is_array($manifest) && !empty($manifest['session_id'])) {
+ $sessionFile = $sessionSavePath . '/sess_' . $manifest['session_id'];
+ if (!file_exists($sessionFile)) {
+ $shouldDelete = true;
+ $reason = 'session expirée (' . $manifest['session_id'] . ')';
+ }
+ }
+ }
+
+ // Strategy 2: time-based fallback (no manifest, or session still alive but old).
+ if (!$shouldDelete && $ageSeconds > $maxAgeSeconds) {
+ $shouldDelete = true;
+ $reason = 'plus de ' . intdiv($maxAgeSeconds, 3600) . 'h';
+ }
+
+ if (!$shouldDelete) {
+ continue;
+ }
+
+ // Candidate found — report it in dry-run, delete it otherwise.
+ if ($dryRun) {
+ printf("DRY-RUN → %s (%s)\n", $item, $reason);
+ continue;
+ }
+
+ rmdirRecursive($dirPath);
+ $details[] = "filepond/$item: $reason";
+ $removed++;
+}
+
+if ($dryRun) {
+ exit(0); // nothing deleted — candidates already listed above
+}
+
+if ($removed > 0) {
+ foreach ($details as $line) {
+ echo "Deleted {$line}\n";
+ }
+ printf("Garbage-collected %d abandoned upload(s).\n", $removed);
+}
+// Nothing to collect — quiet exit.
+exit(0);
+
+function rmdirRecursive(string $dir): void
+{
+ if (!is_dir($dir)) {
+ return;
+ }
+ $entries = @scandir($dir);
+ if ($entries === false) {
+ return;
+ }
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ if (is_dir($path)) {
+ rmdirRecursive($path);
+ } else {
+ @unlink($path);
+ }
+ }
+ @rmdir($dir);
+}