Files
sm-seo-plugin/controllers/notfound.go
Jakub Zych 6eb6cb7a4d 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
2026-10-27 11:35:25 +01:00

274 lines
9.5 KiB
Go

package controllers
import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// NotFoundControllerID is the 404 log (UI-SPEC C9).
const NotFoundControllerID = "golem15.seo.notfound"
// errNotFoundReadOnly refuses creating or editing 404 log entries: they
// come from the report endpoint only.
var errNotFoundReadOnly = errors.New("golem15.seo: the 404 log is read-only")
// The 404 log's record and bulk action names.
const (
NotFoundActionRedirect = "redirect"
NotFoundActionGone = "gone"
)
// notFoundAdminController lists the 404 log by hits and previews an entry
// with two record actions, a prompted "Create redirect…" (301) and "Mark
// as gone" (410), and the bulk "Mark as gone" (D-20). An entry a redirect
// or a 410 now answers is Resolved: badged, and its actions no longer
// apply.
type notFoundAdminController struct {
app func() *backpack.App
}
func (notFoundAdminController) ID() string { return NotFoundControllerID }
func (notFoundAdminController) ModelName() string { return `Golem15\SEO\Models\NotFound` }
func (notFoundAdminController) ConfigDir() string { return "controllers/notfound" }
func (notFoundAdminController) NewRecord() any { return &models.NotFound{} }
func (notFoundAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate refuses: entries come from the report endpoint.
func (notFoundAdminController) FormBeforeCreate(context.Context, any) error {
return errNotFoundReadOnly
}
// FormBeforeUpdate refuses: entries are never edited.
func (notFoundAdminController) FormBeforeUpdate(context.Context, any) error {
return errNotFoundReadOnly
}
func (c notFoundAdminController) service() *classes.Service {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
return svc
}
// cover returns the rule that answers the entry now, nil while it is
// open.
func (c notFoundAdminController) cover(ctx context.Context, entry *models.NotFound) (*models.Redirect, error) {
db, err := database(ctx, c.app)
if err != nil {
return nil, err
}
set, err := classes.LoadRedirectSet(db)
if err != nil {
return nil, err
}
return classes.NotFoundCover(set, entry.URL)
}
// open reports whether a record is an entry no rule answers yet.
func (c notFoundAdminController) open(ctx context.Context, record any) (bool, error) {
entry, ok := record.(*models.NotFound)
if !ok || entry == nil {
return false, nil
}
rule, err := c.cover(ctx, entry)
return rule == nil, err
}
// AdminRecordActions are the preview's two actions (UI-SPEC C9); both
// apply only while the entry is open.
func (c notFoundAdminController) AdminRecordActions() []pact.AdminRecordAction {
return []pact.AdminRecordAction{
{
Name: NotFoundActionRedirect,
Label: "golem15.seo::lang.notfound.redirect",
Confirm: "golem15.seo::lang.notfound.redirect_confirm",
Permissions: []string{PermissionManage},
Prompt: &pact.AdminActionPrompt{
Label: "golem15.seo::lang.notfound.redirect_prompt",
Placeholder: "golem15.seo::lang.notfound.redirect_placeholder",
Comment: "golem15.seo::lang.notfound.redirect_comment",
MaxLength: models.MaxRedirectURL,
},
Applies: c.open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.resolve(ctx, in.Record, in.Value)
},
},
{
Name: NotFoundActionGone,
Label: "golem15.seo::lang.notfound.gone",
Confirm: "golem15.seo::lang.notfound.gone_confirm",
Permissions: []string{PermissionManage},
Danger: true,
Applies: c.open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.resolve(ctx, in.Record, "")
},
},
}
}
// resolve creates the entry's 301 to target, or its 410 when target is
// empty, inside the action's transaction.
func (c notFoundAdminController) resolve(ctx context.Context, record any, target string) (pact.AdminRecordActionResult, error) {
entry, ok := record.(*models.NotFound)
svc := c.service()
if !ok || entry == nil || svc == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
rule, err := svc.ResolveNotFound(ctx, db, *entry, target)
if err != nil {
return pact.AdminRecordActionResult{}, c.refusal(ctx, svc, err)
}
tr := translator(c.app)
if rule.Status == models.Status410 {
return pact.AdminRecordActionResult{Message: Phrase(ctx, tr, "golem15.seo::lang.notfound.gone_done", map[string]string{"url": entry.URL})}, nil
}
return pact.AdminRecordActionResult{Message: Phrase(ctx, tr, "golem15.seo::lang.notfound.redirected", map[string]string{"url": entry.URL, "target": rule.Target})}, nil
}
// refusal turns a 404 action refusal into the admin's answer: a rule that
// already covers the address and a loop are a 403 whose message the admin
// shows as a toast; a bad target is a 422 on the prompt's payload.
func (c notFoundAdminController) refusal(ctx context.Context, svc *classes.Service, err error) error {
tr := translator(c.app)
var covered *classes.NotFoundHasRuleError
var loop *classes.LoopError
var problems *classes.ProblemsError
switch {
case errors.As(err, &covered):
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.notfound.has_rule", map[string]string{"url": covered.URL})}
case errors.As(err, &loop):
return &cabana.ForbiddenError{Message: svc.LoopMessage(ctx, loop.Check)}
case errors.As(err, &problems):
var msgs []string
for _, list := range problems.Problems {
for _, p := range list {
msgs = append(msgs, Phrase(ctx, tr, p.Key, p.Params))
}
}
return &cabana.ValidationError{Details: map[string]any{"payload": msgs}}
}
return err
}
// AdminBulkActions is the list's bulk "Mark as gone" (UI-SPEC C9): one
// entry a rule already answers refuses the whole selection.
func (c notFoundAdminController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: NotFoundActionGone,
Label: "golem15.seo::lang.notfound.gone",
Confirm: "golem15.seo::lang.notfound.gone_selected_confirm",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
svc := c.service()
if svc == nil {
return pact.AdminBulkActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminBulkActionResult{}, err
}
n := 0
for _, record := range in.Records {
entry, ok := record.(*models.NotFound)
if !ok || entry == nil {
continue
}
if _, err := svc.ResolveNotFound(ctx, db, *entry, ""); err != nil {
return pact.AdminBulkActionResult{}, c.refusal(ctx, svc, err)
}
n++
}
return pact.AdminBulkActionResult{Message: "golem15.seo::lang.notfound.gone_selected_done", Affected: n}, nil
},
}}
}
// ListRowStates marks resolved entries disabled, labelled
// notfound.state_resolved (UI-SPEC C9).
func (notFoundAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
set, err := classes.LoadRedirectSet(db.WithContext(ctx))
if err != nil {
return nil, err
}
out := make([][]pact.RowState, len(records))
for i, record := range records {
entry, ok := record.(*models.NotFound)
if !ok || entry == nil {
continue
}
rule, err := classes.NotFoundCover(set, entry.URL)
if err != nil {
return nil, err
}
if rule != nil {
out[i] = []pact.RowState{pact.RowStateDisabled}
}
}
return out, nil
}
// HintView is the preview's header partial: the resolved hint, empty
// while the entry is open.
type HintView struct {
Text string
}
// PartialData serves the preview's hint (notfound.resolved_hint).
func (c notFoundAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
entry, ok := record.(*models.NotFound)
if name != "hint" || !ok || entry == nil {
return HintView{}, nil
}
rule, err := c.cover(ctx, entry)
if err != nil || rule == nil {
return HintView{}, err
}
label := rule.Source
if rule.Target != "" {
label += " → " + rule.Target
} else {
label += " → 410"
}
return HintView{Text: Phrase(ctx, translator(c.app), "golem15.seo::lang.notfound.resolved_hint", map[string]string{"rule": label})}, nil
}
// FilterOptions serves the status filter's open and resolved choices.
func (notFoundAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterNotFoundStatus {
return nil
}
return []pact.Option{
{Value: models.NotFoundOpen, Label: "golem15.seo::lang.notfound.filter_status_open"},
{Value: models.NotFoundResolved, Label: "golem15.seo::lang.notfound.filter_status_resolved"},
}
}
var (
_ pact.AdminController = notFoundAdminController{}
_ pact.AdminPermissioned = notFoundAdminController{}
_ pact.AdminRecordSource = notFoundAdminController{}
_ pact.FormBeforeCreate = notFoundAdminController{}
_ pact.FormBeforeUpdate = notFoundAdminController{}
_ pact.HasAdminRecordActions = notFoundAdminController{}
_ pact.HasAdminBulkActions = notFoundAdminController{}
_ pact.ListRowStates = notFoundAdminController{}
_ pact.AdminPartialData = notFoundAdminController{}
_ pact.FilterOptions = notFoundAdminController{}
)