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:
67
app/Console/Commands/AssignRole.php
Normal file
67
app/Console/Commands/AssignRole.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
use function Laravel\Prompts\select;
|
||||
|
||||
/**
|
||||
* Bootstraps administration.
|
||||
*
|
||||
* Local accounts only appear after their first OAuth login, so there is no
|
||||
* super admin to promote anyone at install time. This command is the way in:
|
||||
* sign in once through the panel, then run
|
||||
*
|
||||
* php artisan user:role you@example.com super_admin
|
||||
*/
|
||||
class AssignRole extends Command
|
||||
{
|
||||
protected $signature = 'user:role {email : Email of the user, as known to the identity server}
|
||||
{role? : Role to grant; prompts when omitted}
|
||||
{--revoke : Remove the role instead of granting it}
|
||||
{--only : Replace all of the user\'s roles with this one}';
|
||||
|
||||
protected $description = 'Grant or revoke a local role for a user';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$user = User::query()->where('email', $this->argument('email'))->first();
|
||||
|
||||
if (! $user) {
|
||||
$this->error("No local user with email [{$this->argument('email')}].");
|
||||
$this->line('Local accounts are created on first login. Ask them to sign in once, then re-run this.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$role = $this->argument('role') ?? select(
|
||||
label: 'Which role?',
|
||||
options: Role::query()->pluck('name', 'name')->all(),
|
||||
);
|
||||
|
||||
if (! Role::query()->where('name', $role)->exists()) {
|
||||
$this->error("No role named [{$role}]. Run `php artisan permissions:sync` first.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
match (true) {
|
||||
(bool) $this->option('revoke') => $user->removeRole($role),
|
||||
(bool) $this->option('only') => $user->syncRoles([$role]),
|
||||
default => $user->assignRole($role),
|
||||
};
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s now has roles: %s',
|
||||
$user->email,
|
||||
$user->fresh()->roles->pluck('name')->implode(', ') ?: '(none)',
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
298
app/Console/Commands/GenerateApiTypesCommand.php
Normal file
298
app/Console/Commands/GenerateApiTypesCommand.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* TypeScript definitions for whoever writes the frontend.
|
||||
*
|
||||
* The specification is already the contract; this is the same contract in the
|
||||
* form an editor can check while somebody types. It matters more than usual
|
||||
* here: the frontend is a separate project in an undecided framework, so
|
||||
* everything about these payloads that is not written down has to be
|
||||
* remembered, and the one thing every candidate framework agrees on is
|
||||
* TypeScript.
|
||||
*
|
||||
* Generated rather than written, and `--check` exists so it can be proved to
|
||||
* be current: a hand-maintained type file drifts exactly like a
|
||||
* hand-maintained specification, which is the problem this started from.
|
||||
*/
|
||||
class GenerateApiTypesCommand extends Command
|
||||
{
|
||||
protected $signature = 'api:types
|
||||
{--output=public/api-types.d.ts : Where to write them}
|
||||
{--check : Fail if the file on disk is out of date, and write nothing}';
|
||||
|
||||
protected $description = 'Generate TypeScript definitions for the public API from openapi.yaml';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $spec = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$source = public_path('openapi.yaml');
|
||||
|
||||
if (! file_exists($source)) {
|
||||
$this->components->error(__('console.types.no_spec'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->spec = (array) Yaml::parseFile($source);
|
||||
|
||||
$output = base_path((string) $this->option('output'));
|
||||
$generated = $this->render();
|
||||
|
||||
if ($this->option('check')) {
|
||||
$current = file_exists($output) ? file_get_contents($output) : null;
|
||||
|
||||
if ($current === $generated) {
|
||||
$this->components->info(__('console.types.current'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->components->error(__('console.types.stale'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
file_put_contents($output, $generated);
|
||||
|
||||
$this->components->info(__('console.types.written', [
|
||||
'path' => $this->option('output'),
|
||||
'count' => count($this->schemas()),
|
||||
]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function render(): string
|
||||
{
|
||||
$lines = [
|
||||
'/**',
|
||||
// From the document, never from the configuration. This file is
|
||||
// generated, committed and checked with `--check`, so anything in
|
||||
// it that varies by environment turns that check into a failure
|
||||
// that depends on where it ran.
|
||||
' * '.($this->spec['info']['title'] ?? 'This application').' — the public payloads.',
|
||||
' *',
|
||||
' * Generated from openapi.yaml by `php artisan api:types`. Do not edit:',
|
||||
' * change the specification instead, and the contract test will hold the',
|
||||
' * API to it.',
|
||||
' */',
|
||||
'',
|
||||
];
|
||||
|
||||
foreach ($this->schemas() as $name => $schema) {
|
||||
$lines[] = $this->renderSchema((string) $name, (array) $schema);
|
||||
}
|
||||
|
||||
$lines[] = $this->renderPaths();
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the shapes the public API answers with. The administration schemas
|
||||
* are in the same document, but they are read by callers holding a token
|
||||
* and writing PHP or curl, not by the frontend.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function schemas(): array
|
||||
{
|
||||
$schemas = (array) ($this->spec['components']['schemas'] ?? []);
|
||||
|
||||
$wanted = array_filter(
|
||||
array_keys($schemas),
|
||||
fn (string $name) => str_starts_with($name, 'Public')
|
||||
|| in_array($name, ['Image', 'Alternates', 'TranslatedField'], true),
|
||||
);
|
||||
|
||||
// Alphabetical, so a schema added upstream never reshuffles the file.
|
||||
sort($wanted);
|
||||
|
||||
return array_replace(array_flip($wanted), array_intersect_key($schemas, array_flip($wanted)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
protected function renderSchema(string $name, array $schema): string
|
||||
{
|
||||
$doc = $this->docBlock($schema['description'] ?? null, '');
|
||||
$doc = $doc === '' ? '' : $doc."\n";
|
||||
|
||||
if (($schema['type'] ?? null) !== 'object' || ! isset($schema['properties'])) {
|
||||
return "{$doc}export type {$name} = ".$this->type($schema).";\n";
|
||||
}
|
||||
|
||||
$lines = ["{$doc}export interface {$name} {"];
|
||||
|
||||
foreach ((array) $schema['properties'] as $property => $definition) {
|
||||
$definition = (array) $definition;
|
||||
|
||||
if ($comment = $this->docBlock($definition['description'] ?? null, ' ')) {
|
||||
$lines[] = $comment;
|
||||
}
|
||||
|
||||
// Nullable is this document's way of saying "may not be there",
|
||||
// which is what the contract test holds the API to as well.
|
||||
$optional = ($definition['nullable'] ?? false) === true ? '?' : '';
|
||||
|
||||
$lines[] = " {$property}{$optional}: ".$this->type($definition).';';
|
||||
}
|
||||
|
||||
$lines[] = "}\n";
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* A map from every public path to what it answers with, so a typed client
|
||||
* can be written without repeating any of this by hand.
|
||||
*/
|
||||
protected function renderPaths(): string
|
||||
{
|
||||
$lines = [
|
||||
'/** What each public endpoint answers with, under `data`. */',
|
||||
'export interface PublicApiPayloads {',
|
||||
];
|
||||
|
||||
foreach ((array) ($this->spec['paths'] ?? []) as $path => $operations) {
|
||||
if (! str_starts_with((string) $path, '/public/v1') || ! isset($operations['get'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$schema = $operations['get']['responses'][200]['content']['application/json']['schema'] ?? null;
|
||||
|
||||
if ($schema === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = (array) ($this->resolve((array) $schema)['properties']['data'] ?? []);
|
||||
|
||||
$lines[] = " '{$path}': ".($data === [] ? 'unknown' : $this->type($data)).';';
|
||||
}
|
||||
|
||||
$lines[] = '}';
|
||||
$lines[] = '';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* One schema as a TypeScript type.
|
||||
*
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
protected function type(array $schema): string
|
||||
{
|
||||
if (isset($schema['$ref'])) {
|
||||
return basename((string) $schema['$ref']);
|
||||
}
|
||||
|
||||
if (isset($schema['allOf'])) {
|
||||
$parts = array_map(fn ($part) => $this->type((array) $part), (array) $schema['allOf']);
|
||||
|
||||
return implode(' & ', $parts);
|
||||
}
|
||||
|
||||
if (isset($schema['enum'])) {
|
||||
return implode(' | ', array_map(
|
||||
fn ($value) => is_string($value) ? "'{$value}'" : json_encode($value),
|
||||
(array) $schema['enum'],
|
||||
));
|
||||
}
|
||||
|
||||
return match ($schema['type'] ?? 'object') {
|
||||
'array' => $this->arrayType((array) ($schema['items'] ?? [])),
|
||||
'integer', 'number' => 'number',
|
||||
'boolean' => 'boolean',
|
||||
'string' => 'string',
|
||||
'object' => $this->objectType($schema),
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
*/
|
||||
protected function arrayType(array $items): string
|
||||
{
|
||||
$type = $items === [] ? 'unknown' : $this->type($items);
|
||||
|
||||
// `(A & B)[]` rather than `A & B[]`, which would mean something else.
|
||||
return str_contains($type, ' ') ? "({$type})[]" : "{$type}[]";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
protected function objectType(array $schema): string
|
||||
{
|
||||
if (! isset($schema['properties'])) {
|
||||
// The one shape this covers is `alternates`: a locale code to a
|
||||
// path, with the locales known only at runtime.
|
||||
$values = isset($schema['additionalProperties']) && is_array($schema['additionalProperties'])
|
||||
? $this->type((array) $schema['additionalProperties'])
|
||||
: 'string';
|
||||
|
||||
return "Record<string, {$values}>";
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
|
||||
foreach ((array) $schema['properties'] as $property => $definition) {
|
||||
$definition = (array) $definition;
|
||||
$optional = ($definition['nullable'] ?? false) === true ? '?' : '';
|
||||
|
||||
$fields[] = "{$property}{$optional}: ".$this->type($definition);
|
||||
}
|
||||
|
||||
return '{ '.implode('; ', $fields).' }';
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions carried across, because half of what this document knows is
|
||||
* in its prose — why a field is only on the detail endpoint, what a null
|
||||
* means — and none of that survives a type on its own.
|
||||
*/
|
||||
protected function docBlock(?string $text, string $indent): string
|
||||
{
|
||||
$text = trim((string) $text);
|
||||
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lines = array_map(
|
||||
fn (string $line) => "{$indent} * ".rtrim($line),
|
||||
explode("\n", $text),
|
||||
);
|
||||
|
||||
return "{$indent}/**\n".implode("\n", $lines)."\n{$indent} */";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schema
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function resolve(array $schema): array
|
||||
{
|
||||
if (isset($schema['$ref'])) {
|
||||
$name = basename((string) $schema['$ref']);
|
||||
|
||||
return $this->resolve((array) ($this->spec['components']['schemas'][$name] ?? []));
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
70
app/Console/Commands/ProcessMediaCommand.php
Normal file
70
app/Console/Commands/ProcessMediaCommand.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Services\Media\ImageProcessor;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* The safety net, and the way back from a change of settings.
|
||||
*
|
||||
* Uploads queue their own processing, but a queue that is not running would
|
||||
* leave images without dimensions and nobody would notice until the page
|
||||
* reflowed. The scheduler runs this every ten minutes so a stalled worker
|
||||
* costs a delay rather than a permanently half-done image.
|
||||
*
|
||||
* `--all` reprocesses everything, which is what you want after changing the
|
||||
* widths or adding a format: the media table is derived, so it can always be
|
||||
* rebuilt from the files themselves.
|
||||
*/
|
||||
class ProcessMediaCommand extends Command
|
||||
{
|
||||
protected $signature = 'media:process
|
||||
{--all : Reprocess every image, not only the pending ones}
|
||||
{--limit=100 : Maximum number of images to handle in one run}';
|
||||
|
||||
protected $description = 'Derive dimensions, responsive versions and placeholders for uploaded images';
|
||||
|
||||
public function handle(ImageProcessor $processor): int
|
||||
{
|
||||
$media = Media::query()
|
||||
->unless($this->option('all'), fn ($q) => $q->pending())
|
||||
->orderBy('id')
|
||||
->limit(max(1, (int) $this->option('limit')))
|
||||
->get();
|
||||
|
||||
if ($media->isEmpty()) {
|
||||
$this->components->info('Nothing to process.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$failed = 0;
|
||||
|
||||
foreach ($media as $item) {
|
||||
$processor->process($item->refresh());
|
||||
|
||||
if ($item->refresh()->error) {
|
||||
$failed++;
|
||||
$this->components->error("{$item->path} — {$item->error}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->components->twoColumnDetail(
|
||||
$item->path,
|
||||
$item->width ? "{$item->width}×{$item->height} · ".count($item->variants ?? []).' versions' : 'no dimensions',
|
||||
);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->components->info("Processed {$media->count()} images, {$failed} failed.");
|
||||
|
||||
// A failure here is usually a missing file, which is worth a non-zero
|
||||
// exit in a deploy script.
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
82
app/Console/Commands/ScheduleContentCommand.php
Normal file
82
app/Console/Commands/ScheduleContentCommand.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\CallStatus;
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Keeps the stored state honest about what the site is actually showing.
|
||||
*
|
||||
* Most of scheduling needs no command at all: the `published` scopes read the
|
||||
* dates, so content appears and disappears on time whether or not anything is
|
||||
* running. What a scope cannot do is change what the panel *says*, and a panel
|
||||
* that reports "Open" for a call the site stopped offering weeks ago is how a
|
||||
* counter stops being read.
|
||||
*
|
||||
* So this closes what has expired and leaves everything else alone. In
|
||||
* particular it does not touch the status of pages or projects: their window
|
||||
* already hides them, and rewriting a status would throw away the one somebody
|
||||
* chose in order to record something the dates already say.
|
||||
*/
|
||||
class ScheduleContentCommand extends Command
|
||||
{
|
||||
protected $signature = 'content:schedule
|
||||
{--dry-run : Report what would change without changing it}';
|
||||
|
||||
protected $description = 'Close calls past their date and report content entering or leaving the site';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$expired = Call::query()
|
||||
->where('status', CallStatus::Open)
|
||||
->whereNotNull('closes_at')
|
||||
->where('closes_at', '<', now())
|
||||
->get();
|
||||
|
||||
foreach ($expired as $call) {
|
||||
$title = $call->getTranslation('title', (string) config('locales.default'), false);
|
||||
|
||||
$this->components->twoColumnDetail(
|
||||
$title ?: "#{$call->id}",
|
||||
'<fg=gray>'.__('console.schedule.closed').'</>',
|
||||
);
|
||||
|
||||
if (! $this->option('dry-run')) {
|
||||
$call->update(['status' => CallStatus::Closed]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->report();
|
||||
|
||||
$this->components->info(
|
||||
trans_choice('console.schedule.summary', $expired->count(), ['count' => $expired->count()])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the dates are about to do on their own. Nothing here needs acting
|
||||
* on; it is here so that a scheduled release is visible in the logs before
|
||||
* it happens rather than only afterwards.
|
||||
*/
|
||||
protected function report(): void
|
||||
{
|
||||
$pending = Page::query()->scheduled()->count() + Project::query()->scheduled()->count();
|
||||
$expiring = Page::query()->expired()->count() + Project::query()->expired()->count();
|
||||
|
||||
if ($pending > 0) {
|
||||
$this->components->twoColumnDetail(__('console.schedule.pending'), (string) $pending);
|
||||
}
|
||||
|
||||
if ($expiring > 0) {
|
||||
$this->components->twoColumnDetail(__('console.schedule.off_site'), (string) $expiring);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
app/Console/Commands/SiteCheckCommand.php
Normal file
101
app/Console/Commands/SiteCheckCommand.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\HealthReport;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* The same checks as the panel page, for a deploy script.
|
||||
*
|
||||
* Exits non-zero when something is wrong, so a release can refuse to finish
|
||||
* on a site that is already broken — or, with `--fail-on=error`, only on the
|
||||
* faults that a visitor would actually hit.
|
||||
*/
|
||||
class SiteCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'site:check
|
||||
{--fail-on=warning : Exit non-zero from this severity up (error|warning|never)}
|
||||
{--json : Machine-readable output}';
|
||||
|
||||
protected $description = 'Check the public site for missing translations, broken URLs, broken images and other faults';
|
||||
|
||||
public function handle(HealthReport $report): int
|
||||
{
|
||||
// Every check: a deploy is not a person, so there is nobody here to
|
||||
// hide a result from.
|
||||
$findings = $report->runAll();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode($findings->map(fn (Finding $f) => [
|
||||
'key' => $f->key,
|
||||
'severity' => $f->severity->value,
|
||||
'count' => $f->count,
|
||||
'samples' => $f->samples,
|
||||
])->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $this->exitCode($findings);
|
||||
}
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$this->components->twoColumnDetail(
|
||||
$finding->title(),
|
||||
$this->badge($finding),
|
||||
);
|
||||
|
||||
foreach ($finding->samples as $sample) {
|
||||
$this->line(" <fg=gray>· {$sample}</>");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$problems = $findings->filter(fn (Finding $f) => $f->severity->isProblem());
|
||||
|
||||
if ($problems->isEmpty()) {
|
||||
$this->components->info(__('health.all_clear'));
|
||||
} else {
|
||||
$this->components->warn(
|
||||
trans_choice('health.problem_summary', $problems->count(), ['count' => $problems->count()])
|
||||
);
|
||||
}
|
||||
|
||||
return $this->exitCode($findings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Finding> $findings
|
||||
*/
|
||||
protected function exitCode($findings): int
|
||||
{
|
||||
$threshold = match ((string) $this->option('fail-on')) {
|
||||
'error' => Severity::Error,
|
||||
'never' => null,
|
||||
default => Severity::Warning,
|
||||
};
|
||||
|
||||
if ($threshold === null) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$failing = $findings->contains(
|
||||
fn (Finding $f) => $f->severity->weight() <= $threshold->weight() && $f->severity->isProblem()
|
||||
);
|
||||
|
||||
return $failing ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function badge(Finding $finding): string
|
||||
{
|
||||
return match ($finding->severity) {
|
||||
Severity::Ok => '<fg=green>ok</>',
|
||||
Severity::Warning => "<fg=yellow>{$finding->count}</>",
|
||||
Severity::Error => "<fg=red>{$finding->count}</>",
|
||||
};
|
||||
}
|
||||
}
|
||||
76
app/Console/Commands/SiteMaintenanceCommand.php
Normal file
76
app/Console/Commands/SiteMaintenanceCommand.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\Maintenance;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Toggles maintenance for the public site from the CLI, for use in deploy
|
||||
* scripts.
|
||||
*
|
||||
* Note this is *not* `artisan down`: the panel and the admin API stay up
|
||||
* throughout, which is the point.
|
||||
*/
|
||||
class SiteMaintenanceCommand extends Command
|
||||
{
|
||||
protected $signature = 'site:maintenance
|
||||
{--up : Bring the public site back}
|
||||
{--message= : Notice shown to visitors, in the default locale}
|
||||
{--until= : When it lifts automatically, e.g. "+2 hours"}
|
||||
{--status : Only report the current state}';
|
||||
|
||||
protected $description = 'Put the public site into maintenance, or bring it back (the panel stays up)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('status')) {
|
||||
return $this->reportStatus();
|
||||
}
|
||||
|
||||
if ($this->option('up')) {
|
||||
Maintenance::disable();
|
||||
|
||||
$this->components->info('The public site is back.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$message = $this->option('message');
|
||||
$until = $this->option('until');
|
||||
|
||||
Maintenance::enable(
|
||||
message: filled($message) ? [config('locales.default') => $message] : null,
|
||||
until: filled($until) ? CarbonImmutable::parse($until) : null,
|
||||
);
|
||||
|
||||
$this->components->warn('The public site is now in maintenance.');
|
||||
$this->components->bulletList(array_filter([
|
||||
'Visitors get 503 with the notice; the panel and admin API are unaffected.',
|
||||
filled($until) ? 'Lifts automatically at '.Maintenance::until()?->toDateTimeString() : null,
|
||||
'The team can still see the real site via the preview token or a trusted IP.',
|
||||
]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function reportStatus(): int
|
||||
{
|
||||
if (! Maintenance::isEnabled()) {
|
||||
$this->components->info('The public site is up.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->components->warn('The public site is in maintenance.');
|
||||
|
||||
$this->components->twoColumnDetail('Since', Maintenance::startedAt()?->diffForHumans() ?? '—');
|
||||
$this->components->twoColumnDetail('Lifts at', Maintenance::until()?->toDateTimeString() ?? 'manually');
|
||||
$this->components->twoColumnDetail('Notice', Maintenance::message());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
22
app/Console/Commands/SyncPermissions.php
Normal file
22
app/Console/Commands/SyncPermissions.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncPermissions extends Command
|
||||
{
|
||||
protected $signature = 'permissions:sync';
|
||||
|
||||
protected $description = 'Sync roles and permissions in the database with App\\Support\\Permissions';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->call('db:seed', ['--class' => RolePermissionSeeder::class, '--force' => true]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
98
app/Console/Commands/SyncRepositories.php
Normal file
98
app/Console/Commands/SyncRepositories.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SyncRepository;
|
||||
use App\Models\Repository;
|
||||
use App\Services\Git\GitException;
|
||||
use App\Services\Git\GitManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SyncRepositories extends Command
|
||||
{
|
||||
protected $signature = 'repos:sync
|
||||
{repository?* : Limit to these "owner/repo" names}
|
||||
{--force : Sync even if refreshed recently}
|
||||
{--sync : Run inline instead of dispatching to the queue}';
|
||||
|
||||
protected $description = 'Refresh repository stats and newcomer-friendly issues from their git provider';
|
||||
|
||||
public function handle(GitManager $git): int
|
||||
{
|
||||
$repositories = Repository::query()
|
||||
->when(
|
||||
$names = $this->argument('repository'),
|
||||
fn ($q) => $q->whereIn('full_name', $names),
|
||||
)
|
||||
->unless(
|
||||
$this->option('force') || $names,
|
||||
// Skip anything refreshed recently so a manual sync is not
|
||||
// immediately redone by the scheduled run.
|
||||
fn ($q) => $q->where(fn ($sub) => $sub
|
||||
->whereNull('last_synced_at')
|
||||
->orWhere('last_synced_at', '<', now()->subMinutes((int) config('git.sync_ttl_minutes', 55)))),
|
||||
)
|
||||
->get();
|
||||
|
||||
if ($repositories->isEmpty()) {
|
||||
$this->components->info('Nothing to sync.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Running inline gives immediate, readable output — useful when
|
||||
// setting things up or debugging a failing repository.
|
||||
if ($this->option('sync')) {
|
||||
return $this->runInline($git, $repositories);
|
||||
}
|
||||
|
||||
$repositories->each(fn (Repository $repo) => SyncRepository::dispatch($repo));
|
||||
|
||||
$this->components->info("Queued {$repositories->count()} repositories for syncing.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Repository> $repositories
|
||||
*/
|
||||
protected function runInline(GitManager $git, $repositories): int
|
||||
{
|
||||
$failed = 0;
|
||||
|
||||
foreach ($repositories as $repository) {
|
||||
try {
|
||||
$synced = $git->sync($repository);
|
||||
|
||||
$this->components->twoColumnDetail(
|
||||
$repository->full_name,
|
||||
sprintf(
|
||||
'<fg=green>%d★</> · %d issues · %s',
|
||||
$synced->stars,
|
||||
$synced->goodFirstIssues()->count(),
|
||||
$synced->pushed_at?->diffForHumans() ?? 'never pushed',
|
||||
),
|
||||
);
|
||||
} catch (GitException $e) {
|
||||
$failed++;
|
||||
|
||||
$this->components->twoColumnDetail(
|
||||
$repository->full_name,
|
||||
'<fg=red>'.$e->getMessage().'</>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->newLine();
|
||||
$this->components->warn("{$failed} repository(ies) failed to sync.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user