Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
7.6 KiB
PHP
229 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Filament\Pages\Trash;
|
|
use App\Models\ContentView;
|
|
use App\Models\Media;
|
|
use App\Models\MissingUrl;
|
|
use App\Models\Revision;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* Throws away what nothing points at any more.
|
|
*
|
|
* Five things now accumulate for ever and none of them is visible: the change
|
|
* history, the bin, image files whose record is gone, and the two things the
|
|
* frontend reports — the 404s and the daily reading figures. Individually they
|
|
* are rounding errors; over a couple of years they are most of the database
|
|
* and most of the disk, and by then nobody remembers what any of it was.
|
|
*
|
|
* The two the frontend writes are the ones that grow without anybody in the
|
|
* collective doing anything, which is why they are pruned as part of the
|
|
* routine rather than behind a flag.
|
|
*
|
|
* Deliberately conservative and deliberately explicit: nothing here runs
|
|
* without being asked, `--dry-run` says what would go, and the windows are
|
|
* generous. Deleting somebody's work by accident is much worse than a database
|
|
* that is larger than it needs to be.
|
|
*/
|
|
class PruneContentCommand extends Command
|
|
{
|
|
protected $signature = 'content:prune
|
|
{--history-days=365 : Keep revisions younger than this}
|
|
{--trash-days=90 : Erase soft-deleted records older than this}
|
|
{--missing-days=30 : Forget one-off 404s not seen for this long}
|
|
{--views-days=400 : Keep daily reading figures for this long}
|
|
{--media : Also delete image files nothing points at}
|
|
{--dry-run : Report what would go without deleting anything}';
|
|
|
|
protected $description = 'Prune old revisions, long-deleted records, stale telemetry and orphaned image files';
|
|
|
|
protected bool $pretend = false;
|
|
|
|
public function handle(): int
|
|
{
|
|
$this->pretend = (bool) $this->option('dry-run');
|
|
|
|
if ($this->pretend) {
|
|
$this->components->warn(__('console.prune.dry_run'));
|
|
}
|
|
|
|
$this->pruneRevisions();
|
|
$this->pruneTrash();
|
|
$this->pruneMissingUrls();
|
|
$this->pruneViews();
|
|
|
|
// Files are the one thing here that cannot be undone by restoring a
|
|
// row, so they are opt-in rather than part of the routine.
|
|
if ($this->option('media')) {
|
|
$this->pruneMedia();
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Old history, except the record's first and last entries.
|
|
*
|
|
* Keeping those two costs almost nothing and preserves the two facts
|
|
* anybody ever asks a history for years later: when it appeared, and who
|
|
* touched it last.
|
|
*/
|
|
protected function pruneRevisions(): void
|
|
{
|
|
$days = max(1, (int) $this->option('history-days'));
|
|
$cutoff = now()->subDays($days);
|
|
|
|
$keep = Revision::query()
|
|
->selectRaw('MIN(id) as id')
|
|
->groupBy('revisionable_type', 'revisionable_id')
|
|
->pluck('id')
|
|
->merge(Revision::query()
|
|
->selectRaw('MAX(id) as id')
|
|
->groupBy('revisionable_type', 'revisionable_id')
|
|
->pluck('id'));
|
|
|
|
$query = Revision::query()
|
|
->where('created_at', '<', $cutoff)
|
|
->whereNotIn('id', $keep);
|
|
|
|
$count = $query->count();
|
|
|
|
if (! $this->pretend && $count > 0) {
|
|
$query->delete();
|
|
}
|
|
|
|
$this->report(__('console.prune.revisions'), $count);
|
|
}
|
|
|
|
/**
|
|
* Records that have been in the bin long enough that nobody is coming back
|
|
* for them.
|
|
*/
|
|
protected function pruneTrash(): void
|
|
{
|
|
$days = max(1, (int) $this->option('trash-days'));
|
|
$cutoff = now()->subDays($days);
|
|
$total = 0;
|
|
|
|
foreach (array_keys(Trash::RECOVERABLE) as $model) {
|
|
/** @var class-string<Model> $model */
|
|
$records = $model::onlyTrashed()->where('deleted_at', '<', $cutoff)->get();
|
|
|
|
$total += $records->count();
|
|
|
|
if ($this->pretend) {
|
|
continue;
|
|
}
|
|
|
|
// One at a time rather than a mass delete: the models have events
|
|
// — the history records the erasure, the content version moves —
|
|
// and a bulk query would skip every one of them.
|
|
$records->each(fn (Model $record) => $record->forceDelete());
|
|
}
|
|
|
|
$this->report(__('console.prune.trash'), $total);
|
|
}
|
|
|
|
/**
|
|
* Addresses that were asked for once and never again.
|
|
*
|
|
* The table is written by strangers, so it is the one thing here that can
|
|
* grow without anybody in the collective doing anything: a scanner walking
|
|
* random paths leaves a row per path it invents. What survives is what a
|
|
* person could act on — anything hit more than once, and anything already
|
|
* resolved or deliberately ignored, because forgetting those would bring
|
|
* the same decision back next week as though it had never been made.
|
|
*/
|
|
protected function pruneMissingUrls(): void
|
|
{
|
|
$days = max(1, (int) $this->option('missing-days'));
|
|
|
|
$query = MissingUrl::query()
|
|
->pending()
|
|
->where('hits', '<', 2)
|
|
->where('last_seen_at', '<', now()->subDays($days));
|
|
|
|
$count = $query->count();
|
|
|
|
if (! $this->pretend && $count > 0) {
|
|
$query->delete();
|
|
}
|
|
|
|
$this->report(__('console.prune.missing_urls'), $count);
|
|
}
|
|
|
|
/**
|
|
* Reading figures old enough that nobody compares against them.
|
|
*
|
|
* A row per record, per language, per day adds up quietly: it is the only
|
|
* table here that grows every day the site is up, whatever anybody does.
|
|
* The window is over a year on purpose — "more than last spring" is a
|
|
* question people ask, and answering it costs a few thousand rows.
|
|
*/
|
|
protected function pruneViews(): void
|
|
{
|
|
$days = max(1, (int) $this->option('views-days'));
|
|
|
|
$query = ContentView::query()
|
|
->where('day', '<', now()->subDays($days)->toDateString());
|
|
|
|
$count = $query->count();
|
|
|
|
if (! $this->pretend && $count > 0) {
|
|
$query->delete();
|
|
}
|
|
|
|
$this->report(__('console.prune.views'), $count);
|
|
}
|
|
|
|
/**
|
|
* Image files no record mentions any more.
|
|
*
|
|
* Changing a project's cover leaves the old file on disk for ever, along
|
|
* with every derived version of it. Nothing breaks; the disk just fills up
|
|
* with images nobody can reach.
|
|
*/
|
|
protected function pruneMedia(): void
|
|
{
|
|
$referenced = Media::referencedPaths();
|
|
$freed = 0;
|
|
$count = 0;
|
|
|
|
foreach (Media::query()->get() as $media) {
|
|
if (in_array($media->path, $referenced, true)) {
|
|
continue;
|
|
}
|
|
|
|
$count++;
|
|
$freed += (int) $media->size;
|
|
|
|
if ($this->pretend) {
|
|
continue;
|
|
}
|
|
|
|
$media->deleteDerived();
|
|
Storage::disk($media->disk)->delete($media->path);
|
|
$media->delete();
|
|
}
|
|
|
|
$this->report(
|
|
__('console.prune.media'),
|
|
$count,
|
|
$count > 0 ? round($freed / 1024 / 1024, 1).' MB' : null,
|
|
);
|
|
}
|
|
|
|
protected function report(string $label, int $count, ?string $extra = null): void
|
|
{
|
|
$value = $count === 0 ? '<fg=gray>—</>' : "<fg=yellow>{$count}</>";
|
|
|
|
$this->components->twoColumnDetail($label, $extra ? "{$value} · {$extra}" : $value);
|
|
}
|
|
}
|