- 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
274 lines
9.5 KiB
Go
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{}
|
|
)
|