feat: 404 log and report endpoint

- POST /_seo/api/v1/notfound with the golem15.seo.notfound_token bearer
  (constant-time, off when unset), URL and referer caps, a row cap with
  an hourly warning, throttle bucket seo-notfound
- golem15.seo.notfound list by hits with Resolved rows, a preview with the
  resolved hint, a prompted 301 and a 410 record action, a bulk 410 that
  refuses a selection with a covered address
- every /_seo/api/v1 answer carries X-Robots-Tag: noindex
This commit is contained in:
2026-10-27 11:35:25 +01:00
parent 163b0aaf98
commit 6eb6cb7a4d
18 changed files with 997 additions and 15 deletions

65
models/notfound.go Normal file
View File

@@ -0,0 +1,65 @@
package models
import (
"time"
"gorm.io/gorm"
)
// NotFound is one address of the 404 log on golem15_seo_notfound (D-20):
// the path (with its query) that answered 404, how often, when first and
// last, and the referer of the last hit. The frontend reports each 404
// through the authenticated report endpoint.
type NotFound struct {
ID uint `gorm:"column:id;primaryKey"`
URL string `gorm:"column:url"`
Hits int64 `gorm:"column:hits"`
FirstSeen *time.Time `gorm:"column:first_seen"`
LastHit *time.Time `gorm:"column:last_hit"`
Referer string `gorm:"column:referer"`
}
func (NotFound) TableName() string { return "golem15_seo_notfound" }
// MorphName names a 404 log entry in history.
func (NotFound) MorphName() string { return `Golem15\SEO\Models\NotFound` }
// Fillable is empty: entries come from the report endpoint only, and the
// preview edits nothing.
func (NotFound) Fillable() []string { return []string{} }
// Rules is empty: nothing is edited.
func (NotFound) Rules() map[string]string { return map[string]string{} }
// The 404 log's status filter scope and its values.
const (
FilterNotFoundStatus = "FilterNotFoundStatus"
NotFoundOpen = "open"
NotFoundResolved = "resolved"
)
// FilterScopes lists the 404 log's filter scopes.
func (NotFound) FilterScopes() []string { return []string{FilterNotFoundStatus} }
// coveredSQL is true for an entry an exact redirect or 410 answers now
// (the list filter; the Resolved badge also counts patterns and regex).
const coveredSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS r WHERE r.match = 'exact' AND (r.source = golem15_seo_notfound.url OR (r.ignore_case AND lower(r.source) = lower(golem15_seo_notfound.url))))`
// FilterScope narrows the 404 log to open or resolved entries; an unknown
// value matches nothing.
func (NotFound) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != FilterNotFoundStatus {
return db
}
switch s, _ := value.(string); s {
case NotFoundOpen:
return db.Where("NOT " + coveredSQL)
case NotFoundResolved:
return db.Where(coveredSQL)
}
return db.Where("1 = 0")
}
func init() {
Register(NotFound{})
}

View File

@@ -0,0 +1,23 @@
# 404 log columns (UI-SPEC C9).
columns:
url:
label: golem15.seo::lang.notfound.url
type: text
searchable: true
sortable: true
hits:
label: golem15.seo::lang.notfound.hits
type: text
sortable: true
last_hit:
label: golem15.seo::lang.notfound.last_hit
type: datetime
sortable: true
referer:
label: golem15.seo::lang.notfound.referer
type: text
first_seen:
label: golem15.seo::lang.notfound.first_seen
type: datetime
sortable: true
invisible: true

View File

@@ -0,0 +1,29 @@
# 404 log preview (UI-SPEC C9): every field is read-only.
fields:
url:
label: golem15.seo::lang.notfound.url
type: text
span: full
context: preview
hits:
label: golem15.seo::lang.notfound.hits
type: number
span: left
context: preview
last_hit:
label: golem15.seo::lang.notfound.last_hit
type: datepicker
mode: datetime
span: right
context: preview
first_seen:
label: golem15.seo::lang.notfound.first_seen
type: datepicker
mode: datetime
span: left
context: preview
referer:
label: golem15.seo::lang.notfound.referer
type: text
span: right
context: preview