feat(admin): cleanup page — remove 'Fichiers temporaires' level, promote sections to h2 TOC entries

This commit is contained in:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent 1ed69a2c1a
commit 3f352b0d26
18 changed files with 650 additions and 252 deletions
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env php
<?php
/**
* cleanup-tmp-uploads.php — Garbage-collect abandoned FilePond uploads.
*
* Abandoned uploads live in <storage>/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);
}