*/ 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); } }