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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View 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);
}
}