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>
155 lines
5.5 KiB
PHP
155 lines
5.5 KiB
PHP
<?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);
|
|
}
|
|
}
|