PHP Content Pruner (Auto-Delete Old Files)
This is a tiny PHP “janitor” you can drop into almost any site to automatically delete old files (logs, cache files, old JSON exports, temporary uploads) after a set number of days.
What it does:
- Deletes files older than X days from one or more folders.
- Supports safe allowed extensions (so you don’t accidentally delete important files).
- Prevents deleting anything outside your chosen folders (path safety checks).
- Works on shared hosting without cron: you can run it “sometimes” by including it on a page that gets traffic.
Install (2 options):
- Manual run (recommended): upload
prune.php, then visit it in your browser with a token:/prune.php?token=YOUR_TOKEN - Auto run (no cron): include it in your footer/header and let it run at most once per day:
require __DIR__.'/prune.php';then callprune_maybe();
Where this helps: keeps your hosting clean, prevents giant log files, limits cache growth, and avoids “disk quota exceeded” surprises.
<?php
declare(strict_types=1);
/**
* Tiny PHP Content Pruner (Drop-in)
* - Deletes files older than N days from specified folders
* - Safe extension allowlist
* - Optional token-protected manual run
* - Optional "maybe" mode: run at most once per day (no cron needed)
*
* QUICK START:
* 1) Edit $PRUNE_DIRS to the folders you want to prune
* 2) Edit $MAX_AGE_DAYS
* 3) Upload as prune.php
* 4) Run manually: /prune.php?token=CHANGE_ME
*
* OPTIONAL (no-cron auto):
* - In a frequently-hit page:
* require __DIR__ . '/prune.php';
* prune_maybe(); // runs at most once per day
*/
// ---------------- CONFIG ----------------
$TOKEN = 'CHANGE_ME'; // set to something random for manual runs
$MAX_AGE_DAYS = 30; // delete files older than this
$ALLOWED_EXTS = ['log','txt','json','cache','tmp']; // only delete these extensions
$PRUNE_DIRS = [
__DIR__ . '/cache',
__DIR__ . '/logs',
__DIR__ . '/tmp',
];
$DRY_RUN = false; // true = report only (no deletes)
$RUN_AT_MOST_EVERY = 86400; // seconds; 86400 = once per day (for prune_maybe)
$STATE_FILE = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'prune_last_run.txt';
// ----------------------------------------
function prune_realpath_safe(string $path): ?string {
$rp = realpath($path);
return $rp === false ? null : $rp;
}
function prune_is_under(string $child, string $parent): bool {
$child = rtrim($child, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$parent = rtrim($parent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
return strncmp($child, $parent, strlen($parent)) === 0;
}
function prune_run(array $dirs, int $maxAgeDays, array $allowedExts, bool $dryRun = false): array {
$now = time();
$cutoff = $now - ($maxAgeDays * 86400);
$allowed = array_flip(array_map('strtolower', $allowedExts));
$deleted = 0;
$scanned = 0;
$bytesFreed = 0;
$errors = [];
foreach ($dirs as $dir) {
$base = prune_realpath_safe($dir);
if ($base === null || !is_dir($base)) {
$errors[] = "Skip (missing dir): {$dir}";
continue;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
$path = (string)$f->getPathname();
$rp = prune_realpath_safe($path);
if ($rp === null) continue;
// Safety: never delete outside the configured base dir
if (!prune_is_under($rp, $base)) continue;
if ($f->isFile()) {
$scanned++;
$ext = strtolower(pathinfo($rp, PATHINFO_EXTENSION));
if ($ext === '' || !isset($allowed[$ext])) continue;
$mtime = (int)$f->getMTime();
if ($mtime > $cutoff) continue;
$size = (int)$f->getSize();
if ($dryRun) {
// report only
$deleted++;
$bytesFreed += $size;
continue;
}
if (@unlink($rp)) {
$deleted++;
$bytesFreed += $size;
} else {
$errors[] = "Failed delete: {$rp}";
}
}
}
}
return [
'scanned' => $scanned,
'deleted' => $deleted,
'bytes_freed' => $bytesFreed,
'errors' => $errors,
'cutoff_iso' => gmdate('c', $cutoff),
'dry_run' => $dryRun,
];
}
/**
* Runs prune at most once per $RUN_AT_MOST_EVERY seconds.
* Use this if you want "no cron" maintenance.
*/
function prune_maybe(): void {
$GLOBALS['__PRUNE_ALREADY_RAN'] = $GLOBALS['__PRUNE_ALREADY_RAN'] ?? false;
if ($GLOBALS['__PRUNE_ALREADY_RAN']) return; // avoid double-run per request
$GLOBALS['__PRUNE_ALREADY_RAN'] = true;
$stateFile = (string)($GLOBALS['STATE_FILE'] ?? '');
$every = (int)($GLOBALS['RUN_AT_MOST_EVERY'] ?? 86400);
$last = 0;
if ($stateFile !== '' && is_file($stateFile)) {
$raw = trim((string)@file_get_contents($stateFile));
if ($raw !== '' && ctype_digit($raw)) $last = (int)$raw;
}
if (time() - $last < $every) return;
$res = prune_run(
(array)$GLOBALS['PRUNE_DIRS'],
(int)$GLOBALS['MAX_AGE_DAYS'],
(array)$GLOBALS['ALLOWED_EXTS'],
(bool)$GLOBALS['DRY_RUN']
);
// Best effort write
if ($stateFile !== '') {
@file_put_contents($stateFile, (string)time(), LOCK_EX);
}
// Optional: log result somewhere
// error_log('Prune: ' . json_encode($res));
}
// ---- Manual run endpoint (optional) ----
if (PHP_SAPI !== 'cli') {
$tok = $_GET['token'] ?? '';
$manual = isset($_GET['run']) || isset($_GET['token']);
if ($manual) {
if (!is_string($tok) || $tok !== $GLOBALS['TOKEN']) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden. Provide ?token=YOUR_TOKEN\n";
exit;
}
$res = prune_run($PRUNE_DIRS, $MAX_AGE_DAYS, $ALLOWED_EXTS, $DRY_RUN);
header('Content-Type: text/plain; charset=utf-8');
echo "Tiny Pruner\n";
echo "Cutoff: {$res['cutoff_iso']}\n";
echo "Scanned files: {$res['scanned']}\n";
echo ($DRY_RUN ? "Would delete: " : "Deleted: ") . "{$res['deleted']}\n";
echo "Bytes " . ($DRY_RUN ? "to free" : "freed") . ": {$res['bytes_freed']}\n";
if (!empty($res['errors'])) {
echo "\nErrors:\n- " . implode("\n- ", $res['errors']) . "\n";
}
exit;
}
}
?>
Comments (0)
No comments yet — be the first.