Initial commit: Pireon Admin — headless admin panel and public API
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>
This commit is contained in:
228
app/Console/Commands/PruneContentCommand.php
Normal file
228
app/Console/Commands/PruneContentCommand.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user