#!/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); }