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:
154
app/Models/MissingUrl.php
Normal file
154
app/Models/MissingUrl.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\MissingUrlStatus;
|
||||
use Database\Factories\MissingUrlFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* An address the frontend was asked for and could not resolve.
|
||||
*
|
||||
* Nobody creates one of these by hand: they arrive from the frontend, which is
|
||||
* the only place that finds out. What the panel does with them is decide which
|
||||
* ones deserve a redirect.
|
||||
*/
|
||||
#[Fillable(['path', 'locale', 'referrer', 'status'])]
|
||||
class MissingUrl extends Model
|
||||
{
|
||||
/** @use HasFactory<MissingUrlFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $attributes = [
|
||||
'status' => MissingUrlStatus::Pending->value,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => MissingUrlStatus::class,
|
||||
'hits' => 'integer',
|
||||
'first_seen_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one sighting of a path.
|
||||
*
|
||||
* The count is an upsert rather than a read-then-write because these
|
||||
* arrive in bursts — a crawler walking a dead section produces dozens in a
|
||||
* second — and two of them racing on the same path would otherwise violate
|
||||
* the unique index or lose a count. What an upsert cannot express portably
|
||||
* follows it as two narrow updates on the same indexed row.
|
||||
*
|
||||
* A *resolved* row comes back to `pending` when it is seen again: the
|
||||
* status was a claim that the address had a destination, and a fresh 404
|
||||
* disproves it — a redirect written and then deleted is exactly this.
|
||||
*
|
||||
* An *ignored* row does not. That status is a decision about attention
|
||||
* rather than a claim about the world, and the addresses it is used on are
|
||||
* the ones being requested constantly. Reviving them on the next sighting
|
||||
* would mean the button never worked twice.
|
||||
*/
|
||||
public static function report(string $path, string $locale, ?string $referrer = null): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
static::query()->upsert(
|
||||
[[
|
||||
'path' => $path,
|
||||
'locale' => $locale,
|
||||
'referrer' => $referrer,
|
||||
'status' => MissingUrlStatus::Pending->value,
|
||||
'hits' => 1,
|
||||
'first_seen_at' => $now,
|
||||
'last_seen_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]],
|
||||
['path', 'locale'],
|
||||
[
|
||||
'hits' => DB::raw('missing_urls.hits + 1'),
|
||||
'last_seen_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
|
||||
// Kept out of the upsert because it must apply to one status and not
|
||||
// the other, which no portable upsert can express.
|
||||
static::query()
|
||||
->where('path', $path)
|
||||
->where('locale', $locale)
|
||||
->where('status', MissingUrlStatus::Resolved)
|
||||
->update([
|
||||
'status' => MissingUrlStatus::Pending,
|
||||
'resolved_at' => null,
|
||||
]);
|
||||
|
||||
// Kept out of the upsert on purpose. Overwriting unconditionally would
|
||||
// erase the referrer of a known bad link the moment the same address
|
||||
// is next requested without one — which is what a bot does. So the
|
||||
// newest referrer wins, but only against another referrer.
|
||||
if (filled($referrer)) {
|
||||
static::query()
|
||||
->where('path', $path)
|
||||
->where('locale', $locale)
|
||||
->update(['referrer' => $referrer]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whatever this redirect now answers as dealt with.
|
||||
*
|
||||
* Called when a redirect is written, from either end — the panel's action
|
||||
* on this list, a rule typed by hand, or a slug rename — so the list never
|
||||
* shows an address that has already been given a destination.
|
||||
*/
|
||||
public static function resolvedBy(Redirect $redirect): void
|
||||
{
|
||||
static::query()
|
||||
->where('path', $redirect->from)
|
||||
->where('status', '!=', MissingUrlStatus::Resolved)
|
||||
// A redirect with no locale applies in every language, so it
|
||||
// answers the sightings of that path in all of them.
|
||||
->when($redirect->locale !== null, fn (Builder $query) => $query
|
||||
->where('locale', $redirect->locale))
|
||||
->update([
|
||||
'status' => MissingUrlStatus::Resolved,
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the link that produced this came from our own site.
|
||||
*
|
||||
* The distinction that decides how urgent it is: an external referrer is
|
||||
* somebody else's stale link, and a redirect is a courtesy. Our own domain
|
||||
* means a page we publish is pointing at an address that does not exist.
|
||||
*/
|
||||
public function isInternalReferrer(): bool
|
||||
{
|
||||
$site = (string) config('site.url');
|
||||
|
||||
return filled($this->referrer) && filled($site) && Str::startsWith($this->referrer, $site);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', MissingUrlStatus::Pending);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user