feat: redirects screen

- golem15.seo.redirects list and form (UI-SPEC C8): chain rows marked,
  status, match, origin and chain filters, the navigation item lands here
- a field-less extension on the controller refuses a loop with
  redirects.loop on target, answers the chain notice and records history
- the status dropdown posts text, read through RedirectStatus
This commit is contained in:
2026-10-27 11:30:56 +01:00
parent 34a65b846e
commit 163b0aaf98
15 changed files with 677 additions and 24 deletions

View File

@@ -6,8 +6,8 @@ import (
)
// Navigation registers the SEO main item (UI-SPEC C2) behind
// golem15.seo.manage. It lands on URL rules until the redirects screen
// exists; each side-menu entry is added when its controller exists.
// golem15.seo.manage. It lands on Redirects; each side-menu entry is added
// when its controller exists, in the C2 order.
func (*Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "seo",
@@ -15,8 +15,15 @@ func (*Plugin) Navigation() []pact.NavigationItem {
Icon: "search",
Permissions: []string{controllers.PermissionManage},
Order: 600,
Controller: controllers.RulesControllerID,
Controller: controllers.RedirectsControllerID,
SideMenu: []pact.NavigationItem{
{
Code: "redirects",
Label: "golem15.seo::lang.nav.redirects",
Icon: "arrow-right",
Permissions: []string{controllers.PermissionManage},
Controller: controllers.RedirectsControllerID,
},
{
Code: "rules",
Label: "golem15.seo::lang.nav.rules",

View File

@@ -452,7 +452,7 @@ type Chain struct {
// Follow follows the set from path and rawQuery, at most max hops. site is
// the site URL: an absolute target on it is followed like a path.
func (s *RedirectSet) Follow(site, path, rawQuery string, max int) (Chain, error) {
ch := Chain{Status: models.Status301}
ch := Chain{Status: int(models.Status301)}
cur, query := path, rawQuery
seen := map[string]bool{joinQuery(cur, query): true}
used := map[uint]bool{}
@@ -472,7 +472,7 @@ func (s *RedirectSet) Follow(site, path, rawQuery string, max int) (Chain, error
used[hit.Rule.ID] = true
ch.Hops = append(ch.Hops, hit)
if hit.Rule.Status == models.Status302 {
ch.Status = models.Status302
ch.Status = int(models.Status302)
}
if hit.Rule.Status == models.Status410 {
ch.Gone = true

View File

@@ -582,7 +582,7 @@ func (r *Resolver) redirectAnswer(self string, ch Chain) Result {
logRedirectCycle(self, ch)
res.Action, res.Status = ActionNotFound, 404
case ch.Gone:
res.Action, res.Status = ActionGone, models.Status410
res.Action, res.Status = ActionGone, int(models.Status410)
default:
res.Action, res.Status, res.Location = ActionRedirect, ch.Status, AbsoluteURL(r.Service.SiteURL(), ch.Final)
}

View File

@@ -14,7 +14,7 @@ import (
)
// PermissionManage is the one SEO permission (D-15): the SEO tab, URL
// rules, settings and history.
// rules, redirects, the 404 log, robots.txt, settings and history.
const PermissionManage = "golem15.seo.manage"
// The admin controller IDs.
@@ -29,6 +29,7 @@ const (
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
settingsAdminController{app: app},
redirectsAdminController{app: app},
rulesAdminController{app: app},
historyAdminController{app: app},
}

192
controllers/redirects.go Normal file
View File

@@ -0,0 +1,192 @@
package controllers
import (
"context"
"strconv"
"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/pact"
"gorm.io/gorm"
)
// RedirectsControllerID is the redirects screen (UI-SPEC C8).
const RedirectsControllerID = "golem15.seo.redirects"
// redirectsAdminController lists and edits redirects (D-16 to D-19). The
// loop refusal, the chain notice and history come from the plugin's own
// field-less form extension on this controller, which runs inside the
// save transaction once the row is written.
type redirectsAdminController struct {
app func() *backpack.App
}
func (redirectsAdminController) ID() string { return RedirectsControllerID }
func (redirectsAdminController) ModelName() string { return `Golem15\SEO\Models\Redirect` }
func (redirectsAdminController) ConfigDir() string { return "controllers/redirects" }
func (redirectsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// NewRecord is a redirect with the form's defaults, so a save that omits
// an option keeps its default.
func (redirectsAdminController) NewRecord() any {
r := models.NewRedirect()
return &r
}
// FormBeforeCreate normalises the redirect and refuses an invalid source,
// target or regex, and a source and match another redirect has.
func (c redirectsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
return c.check(ctx, model)
}
// FormBeforeUpdate checks an update like a create.
func (c redirectsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
return c.check(ctx, model)
}
func (c redirectsAdminController) check(ctx context.Context, model any) error {
r, ok := model.(*models.Redirect)
if !ok || r == nil {
return nil
}
r.Normalize()
db, err := database(ctx, c.app)
if err != nil {
return err
}
return problemsError(ctx, translator(c.app), r.Problems(db))
}
// FormAfterDelete records the deleted redirect in history inside the
// delete's transaction.
func (c redirectsAdminController) FormAfterDelete(ctx context.Context, model any) error {
r, ok := model.(*models.Redirect)
if !ok || r == nil {
return nil
}
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return err
}
db, err := database(ctx, c.app)
if err != nil {
return err
}
return svc.Record(ctx, db, classes.Change{
Kind: models.SubjectRedirect,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: svc.Phrase("golem15.seo::lang.history.what_redirect", map[string]string{"url": r.Source}),
Before: classes.RedirectValues(*r),
})
}
// ListRowStates marks a chain (UI-SPEC C8): a redirect whose target is
// another redirect's source, labelled redirects.state_chain.
func (redirectsAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
var targets []string
for _, record := range records {
if r, ok := record.(*models.Redirect); ok && r != nil && r.Target != "" {
targets = append(targets, r.Target)
}
}
sources := map[string]bool{}
if len(targets) > 0 {
var rows []string
if err := db.WithContext(ctx).Model(&models.Redirect{}).Where("source IN ?", targets).Pluck("source", &rows).Error; err != nil {
return nil, err
}
for _, s := range rows {
sources[s] = true
}
}
out := make([][]pact.RowState, len(records))
for i, record := range records {
if r, ok := record.(*models.Redirect); ok && r != nil && r.Target != "" && sources[r.Target] {
out[i] = []pact.RowState{pact.RowStateNegative}
}
}
return out, nil
}
// matchLabels and originLabels are the phrase keys of the match kinds and
// origins.
var (
matchLabels = map[string]string{
models.RedirectExact: "golem15.seo::lang.redirects.match_exact",
models.RedirectPattern: "golem15.seo::lang.redirects.match_pattern",
models.RedirectRegex: "golem15.seo::lang.redirects.match_regex",
}
originLabels = map[string]string{
models.OriginManual: "golem15.seo::lang.redirects.origin_manual",
models.OriginSlug: "golem15.seo::lang.redirects.origin_slug",
models.OriginImport: "golem15.seo::lang.redirects.origin_import",
models.OriginMCP: "golem15.seo::lang.redirects.origin_mcp",
models.OriginNotFound: "golem15.seo::lang.redirects.origin_notfound",
}
)
// FilterOptions serves the choices of the status, match, origin and chain
// filters.
func (redirectsAdminController) FilterOptions(scope string) []pact.Option {
switch scope {
case models.FilterRedirectStatus:
return []pact.Option{
{Value: "301", Label: "golem15.seo::lang.redirects.status_301"},
{Value: "302", Label: "golem15.seo::lang.redirects.status_302"},
{Value: "410", Label: "golem15.seo::lang.redirects.status_410"},
}
case models.FilterRedirectMatch:
out := make([]pact.Option, 0, len(matchLabels))
for _, m := range []string{models.RedirectExact, models.RedirectPattern, models.RedirectRegex} {
out = append(out, pact.Option{Value: m, Label: matchLabels[m]})
}
return out
case models.FilterRedirectOrigin:
out := make([]pact.Option, 0, len(models.RedirectOrigins))
for _, o := range models.RedirectOrigins {
out = append(out, pact.Option{Value: o, Label: originLabels[o]})
}
return out
case models.FilterRedirectChain:
return []pact.Option{
{Value: models.ChainOnly, Label: "golem15.seo::lang.redirects.filter_chain_only"},
{Value: models.ChainHide, Label: "golem15.seo::lang.redirects.filter_chain_hide"},
}
}
return nil
}
// ListExtendRecords shows the match and the origin of the list's rows as
// their labels in the request locale. The rows are list values only and
// never saved; the columns still sort by the stored values.
func (c redirectsAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
tr := translator(c.app)
for _, record := range records {
r, ok := record.(*models.Redirect)
if !ok || r == nil {
continue
}
if key, ok := matchLabels[r.Match]; ok {
r.Match = Phrase(ctx, tr, key, nil)
}
if key, ok := originLabels[r.Origin]; ok {
r.Origin = Phrase(ctx, tr, key, nil)
}
}
return nil
}
var (
_ pact.AdminController = redirectsAdminController{}
_ pact.AdminPermissioned = redirectsAdminController{}
_ pact.AdminRecordSource = redirectsAdminController{}
_ pact.FormBeforeCreate = redirectsAdminController{}
_ pact.FormBeforeUpdate = redirectsAdminController{}
_ pact.FormAfterDelete = redirectsAdminController{}
_ pact.ListRowStates = redirectsAdminController{}
_ pact.FilterOptions = redirectsAdminController{}
_ pact.ListExtendRecords = redirectsAdminController{}
)

View File

@@ -0,0 +1,22 @@
# Redirects filters (UI-SPEC C8): status, match, origin and chains.
scopes:
status:
label: golem15.seo::lang.redirects.status
modelClass: Golem15\SEO\Models\Redirect
nameFrom: status
scope: FilterRedirectStatus
match:
label: golem15.seo::lang.redirects.match
modelClass: Golem15\SEO\Models\Redirect
nameFrom: match
scope: FilterRedirectMatch
origin:
label: golem15.seo::lang.redirects.origin
modelClass: Golem15\SEO\Models\Redirect
nameFrom: origin
scope: FilterRedirectOrigin
chain:
label: golem15.seo::lang.redirects.filter_chain
modelClass: Golem15\SEO\Models\Redirect
nameFrom: target
scope: FilterRedirectChain

View File

@@ -0,0 +1,18 @@
# Redirect form (UI-SPEC C8).
name: golem15.seo::lang.redirects.name
form: ~/plugins/golem15/seo/models/redirect/fields.yaml
modelClass: Golem15\SEO\Models\Redirect
defaultRedirect: golem15/seo/redirects
create:
redirect: golem15/seo/redirects/update/:id
redirectClose: golem15/seo/redirects
update:
redirect: golem15/seo/redirects
redirectClose: golem15/seo/redirects
messages:
create: golem15.seo::lang.redirects.new
update: golem15.seo::lang.redirects.name
deleteConfirm: golem15.seo::lang.redirects.delete_confirm

View File

@@ -0,0 +1,25 @@
# Redirects list (UI-SPEC C8): newest change first, chains marked.
list: ~/plugins/golem15/seo/models/redirect/columns.yaml
modelClass: Golem15\SEO\Models\Redirect
title: golem15.seo::lang.redirects.list_title
recordUrl: golem15/seo/redirects/update/:id
noRecordsMessage: golem15.seo::lang.redirects.list_empty
recordsPerPage: 20
showCheckboxes: true
showSetup: true
filter: config_filter.yaml
defaultSort:
column: updated_at
direction: desc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
create: golem15.seo::lang.redirects.new
empty: golem15.seo::lang.redirects.list_empty
deleteConfirm: golem15.seo::lang.redirects.delete_selected_confirm
rowStateNegative: golem15.seo::lang.redirects.state_chain

View File

@@ -38,9 +38,52 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
out = append(out, pact.AdminActionExtension{Controller: controllers.SettingsControllerID, Form: form})
}
out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()})
out = append(out, pact.AdminActionExtension{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()})
return out
}
// redirectChecks is a field-less extension on the plugin's own redirects
// controller (P-18). Its Save runs inside the save transaction with the
// row written: it simulates the rule in place, refuses a loop with
// redirects.loop on target (D-19, the save rolls back), answers a chain
// with redirects.chain_notice on target, and records the save in history.
func (p *Plugin) redirectChecks() *pact.AdminFormExtension {
return &pact.AdminFormExtension{
Permissions: []string{controllers.PermissionManage},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
svc := p.Service()
if svc == nil {
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
}
r, ok := in.Record.(*models.Redirect)
if !ok || r == nil {
return pact.AdminFormSaveResult{}, nil
}
check, err := svc.CheckRedirect(ctx, in.DB, *r)
if err != nil {
return pact.AdminFormSaveResult{}, err
}
if check.Loop {
return pact.AdminFormSaveResult{}, pact.AdminFormErrors{"target": {svc.LoopMessage(ctx, check)}}
}
before, after := classes.ChangesValues(in.Changes, in.Created)
if err := svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectRedirect,
SubjectID: in.ID,
What: svc.Phrase("golem15.seo::lang.history.what_redirect", map[string]string{"url": r.Source}),
Before: before,
After: after,
}); err != nil {
return pact.AdminFormSaveResult{}, err
}
if notices := classes.ChainNotice(check); len(notices) > 0 {
return pact.AdminFormSaveResult{Notices: map[string][]pact.FormFieldNotice{"target": notices}}, nil
}
return pact.AdminFormSaveResult{}, nil
},
}
}
// ruleHistory is a field-less extension on the plugin's own rules
// controller: its Save sees the save's changes inside the save transaction
// and records them in history (deletes are recorded by the controller).

View File

@@ -2,6 +2,7 @@ plugin:
name: SEO
description: Search engine metadata for every content type, URL rules and global SEO settings.
nav:
redirects: Redirects
seo: SEO
history: History
rules: URL rules
@@ -199,3 +200,40 @@ redirects:
regex_invalid: "This is not a valid regular expression: :error"
source_invalid: "Start the address with /. Use * only in a pattern."
source_taken: "Another redirect already uses this address and match."
new: "New redirect"
list_title: "Redirects"
name: "Redirect"
source: "From"
source_comment: "Exact: /old-post. Pattern: /old/* (the matched part replaces * in the target). Regex: ^/p/(\\d+)$ (use $1 in the target)."
match: "Match"
match_exact: "Exact"
match_pattern: "Pattern"
match_regex: "Regex"
target: "To"
target_comment: "A path or a full URL. Leave empty for 410 Gone."
status: "Status"
status_301: "301 Moved permanently"
status_302: "302 Found (temporary)"
status_410: "410 Gone"
query: "Query string"
query_ignore: "Ignore it"
query_pass: "Pass it to the target"
ignore_case: "Ignore letter case"
trailing_slash: "Match with and without a trailing slash"
note: "Note"
origin: "Origin"
origin_manual: "Manual"
origin_slug: "Address change"
origin_import: "Import"
origin_mcp: "MCP"
origin_notfound: "404 log"
updated: "Updated"
state_chain: "Chain"
filter_chain: "Chains"
filter_chain_only: "Only chains"
filter_chain_hide: "Hide chains"
list_empty: "No redirects yet. Create one, import a CSV file, or create one from the 404 log."
delete_confirm: "Delete this redirect? Its source address will return 404 again. You can undo it in History."
delete_selected_confirm:
one: "Delete :count redirect? Its source address will return 404 again. You can undo it in History."
other: "Delete :count redirects? Their source addresses will return 404 again. You can undo it in History."

View File

@@ -2,6 +2,7 @@ plugin:
name: SEO
description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO.
nav:
redirects: Przekierowania
seo: SEO
history: Historia zmian
rules: Reguły adresów
@@ -203,3 +204,42 @@ redirects:
regex_invalid: "To nie jest poprawne wyrażenie regularne: :error"
source_invalid: "Zacznij adres od /. Używaj * tylko we wzorcu."
source_taken: "Inne przekierowanie już używa tego adresu i dopasowania."
new: "Nowe przekierowanie"
list_title: "Przekierowania"
name: "Przekierowanie"
source: "Z adresu"
source_comment: "Dokładny: /stary-wpis. Wzorzec: /stare/* (dopasowana część zastępuje * w celu). Regex: ^/p/(\\d+)$ (użyj $1 w celu)."
match: "Dopasowanie"
match_exact: "Dokładne"
match_pattern: "Wzorzec"
match_regex: "Regex"
target: "Na adres"
target_comment: "Ścieżka lub pełny adres. Zostaw puste dla 410 Gone."
status: "Kod"
status_301: "301 Przeniesiono na stałe"
status_302: "302 Przeniesiono tymczasowo"
status_410: "410 Usunięto"
query: "Parametry adresu"
query_ignore: "Ignoruj"
query_pass: "Przekaż do celu"
ignore_case: "Ignoruj wielkość liter"
trailing_slash: "Dopasuj z ukośnikiem na końcu i bez"
note: "Notatka"
origin: "Pochodzenie"
origin_manual: "Ręcznie"
origin_slug: "Zmiana adresu"
origin_import: "Import"
origin_mcp: "MCP"
origin_notfound: "Dziennik 404"
updated: "Zaktualizowano"
state_chain: "Łańcuch"
filter_chain: "Łańcuchy"
filter_chain_only: "Tylko łańcuchy"
filter_chain_hide: "Ukryj łańcuchy"
list_empty: "Nie ma jeszcze przekierowań. Utwórz je, zaimportuj plik CSV lub utwórz je z dziennika 404."
delete_confirm: "Usunąć to przekierowanie? Jego adres źródłowy znowu zwróci 404. Możesz to cofnąć w Historii zmian."
delete_selected_confirm:
one: "Usunąć :count przekierowanie? Jego adres źródłowy znowu zwróci 404. Możesz to cofnąć w Historii zmian."
few: "Usunąć :count przekierowania? Ich adresy źródłowe znowu zwrócą 404. Możesz to cofnąć w Historii zmian."
many: "Usunąć :count przekierowań? Ich adresy źródłowe znowu zwrócą 404. Możesz to cofnąć w Historii zmian."
other: "Usunąć :count przekierowania? Ich adresy źródłowe znowu zwrócą 404. Możesz to cofnąć w Historii zmian."

View File

@@ -1,8 +1,10 @@
package models
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -16,11 +18,25 @@ const (
RedirectRegex = "regex"
)
// RedirectStatus is a redirect's HTTP status. The admin form's dropdown
// posts it as text ("301"), which UnmarshalText reads.
type RedirectStatus int
// UnmarshalText reads a status written as text.
func (s *RedirectStatus) UnmarshalText(b []byte) error {
n, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil {
return fmt.Errorf("models: redirect status %q is not a number", b)
}
*s = RedirectStatus(n)
return nil
}
// The statuses of a redirect (D-16).
const (
Status301 = 301
Status302 = 302
Status410 = 410
Status301 RedirectStatus = 301
Status302 RedirectStatus = 302
Status410 RedirectStatus = 410
)
// The query string options of a redirect (D-17).
@@ -46,18 +62,18 @@ const (
// trailing slash; Query ignores the request's query string or passes it to
// the target. Origin records who created the rule.
type Redirect struct {
ID uint `gorm:"column:id;primaryKey"`
Source string `gorm:"column:source"`
Match string `gorm:"column:match"`
Target string `gorm:"column:target"`
Status int `gorm:"column:status"`
Query string `gorm:"column:query"`
IgnoreCase bool `gorm:"column:ignore_case"`
TrailingSlash bool `gorm:"column:trailing_slash"`
Origin string `gorm:"column:origin"`
Note string `gorm:"column:note"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
ID uint `gorm:"column:id;primaryKey"`
Source string `gorm:"column:source"`
Match string `gorm:"column:match"`
Target string `gorm:"column:target"`
Status RedirectStatus `gorm:"column:status"`
Query string `gorm:"column:query"`
IgnoreCase bool `gorm:"column:ignore_case"`
TrailingSlash bool `gorm:"column:trailing_slash"`
Origin string `gorm:"column:origin"`
Note string `gorm:"column:note"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
}
func (Redirect) TableName() string { return "golem15_seo_redirects" }
@@ -189,3 +205,63 @@ func regexError(err error) string {
func init() {
Register(Redirect{})
}
// RedirectOrigins are the origins in filter order.
var RedirectOrigins = []string{OriginManual, OriginSlug, OriginImport, OriginMCP, OriginNotFound}
// The redirects list's filter scopes and the chain filter's values.
const (
FilterRedirectStatus = "FilterRedirectStatus"
FilterRedirectMatch = "FilterRedirectMatch"
FilterRedirectOrigin = "FilterRedirectOrigin"
FilterRedirectChain = "FilterRedirectChain"
ChainOnly = "only"
ChainHide = "hide"
)
// FilterScopes lists the redirects list's filter scopes.
func (Redirect) FilterScopes() []string {
return []string{FilterRedirectStatus, FilterRedirectMatch, FilterRedirectOrigin, FilterRedirectChain}
}
// chainSQL is true for a redirect whose target is another redirect's
// source.
const chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.source = golem15_seo_redirects.target AND next.id <> golem15_seo_redirects.id)`
// FilterScope narrows the redirects list; an unknown value matches
// nothing.
func (Redirect) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
s, _ := value.(string)
switch name {
case FilterRedirectStatus:
n, err := strconv.Atoi(s)
if err != nil || (n != 301 && n != 302 && n != 410) {
return db.Where("1 = 0")
}
return db.Where("golem15_seo_redirects.status = ?", n)
case FilterRedirectMatch:
if s != RedirectExact && s != RedirectPattern && s != RedirectRegex {
return db.Where("1 = 0")
}
return db.Where("golem15_seo_redirects.match = ?", s)
case FilterRedirectOrigin:
for _, o := range RedirectOrigins {
if o == s {
return db.Where("golem15_seo_redirects.origin = ?", s)
}
}
return db.Where("1 = 0")
case FilterRedirectChain:
switch s {
case ChainOnly:
return db.Where(chainSQL)
case ChainHide:
return db.Where("NOT " + chainSQL)
}
return db.Where("1 = 0")
}
return db
}

View File

@@ -0,0 +1,33 @@
# Redirects list columns (UI-SPEC C8). match and origin show their labels
# (the controller's ListExtendRecords) and sort by the stored values.
columns:
source:
label: golem15.seo::lang.redirects.source
type: text
searchable: true
sortable: true
target:
label: golem15.seo::lang.redirects.target
type: text
searchable: true
status:
label: golem15.seo::lang.redirects.status
type: text
sortable: true
match:
label: golem15.seo::lang.redirects.match
type: text
sortable: true
origin:
label: golem15.seo::lang.redirects.origin
type: text
sortable: true
updated_at:
label: golem15.seo::lang.redirects.updated
type: datetime
sortable: true
note:
label: golem15.seo::lang.redirects.note
type: text
searchable: true
invisible: true

View File

@@ -0,0 +1,63 @@
# Redirect form (UI-SPEC C8). The loop refusal and the chain notice land on
# target; they come from the plugin's own form extension on the redirects
# controller.
fields:
source:
label: golem15.seo::lang.redirects.source
comment: golem15.seo::lang.redirects.source_comment
type: text
span: left
required: true
match:
label: golem15.seo::lang.redirects.match
type: dropdown
span: right
default: exact
options:
exact: golem15.seo::lang.redirects.match_exact
pattern: golem15.seo::lang.redirects.match_pattern
regex: golem15.seo::lang.redirects.match_regex
target:
label: golem15.seo::lang.redirects.target
comment: golem15.seo::lang.redirects.target_comment
type: text
span: full
status:
label: golem15.seo::lang.redirects.status
type: dropdown
span: left
default: "301"
options:
"301": golem15.seo::lang.redirects.status_301
"302": golem15.seo::lang.redirects.status_302
"410": golem15.seo::lang.redirects.status_410
query:
label: golem15.seo::lang.redirects.query
type: dropdown
span: right
default: ignore
options:
ignore: golem15.seo::lang.redirects.query_ignore
pass: golem15.seo::lang.redirects.query_pass
ignore_case:
label: golem15.seo::lang.redirects.ignore_case
type: checkbox
span: left
default: false
trailing_slash:
label: golem15.seo::lang.redirects.trailing_slash
type: checkbox
span: right
default: true
note:
label: golem15.seo::lang.redirects.note
type: textarea
size: tiny
span: full

View File

@@ -143,7 +143,7 @@ func (e *testEnv) saveRedirect(t *testing.T, r models.Redirect) (classes.Redirec
func (e *testEnv) mustRedirect(t *testing.T, source, match, target string, status int, opts ...func(*models.Redirect)) classes.RedirectCheck {
t.Helper()
r := models.NewRedirect()
r.Source, r.Match, r.Target, r.Status = source, match, target, status
r.Source, r.Match, r.Target, r.Status = source, match, target, models.RedirectStatus(status)
for _, o := range opts {
o(&r)
}
@@ -236,7 +236,7 @@ func TestRedirectEngine(t *testing.T) {
newRule := func(source, match, target string, status int) models.Redirect {
r := models.NewRedirect()
r.Source, r.Match, r.Target, r.Status = source, match, target, status
r.Source, r.Match, r.Target, r.Status = source, match, target, models.RedirectStatus(status)
return r
}
env.refusedOn(t, newRule("/g2", "exact", "/x", 410), "target", "golem15.seo::lang.redirects.target_gone")
@@ -321,3 +321,98 @@ func TestRedirectEngine(t *testing.T) {
t.Fatalf("passed query = %v, want it appended to the query only", got)
}
}
// redirectCount is the number of stored redirects.
func (e *testEnv) redirectCount(t *testing.T) int64 {
t.Helper()
var n int64
if err := e.db.Raw(`SELECT COUNT(*) FROM golem15_seo_redirects`).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
// createdID reads the id of a create response.
func createdID(t *testing.T, body []byte) string {
t.Helper()
var out struct {
Data struct {
ID json.Number `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil || out.Data.ID == "" {
t.Fatalf("create body %s: %v", body, err)
}
return out.Data.ID.String()
}
// TestLoopRefused checks the redirects screen (SC-3, D-19, UI-SPEC C8): a
// loop is refused from the admin with redirects.loop on target and nothing
// saved, on create and on update; a chain is saved with the chain notice
// on target and marked in the list; field refusals answer 422.
func TestLoopRefused(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
api := adminAPI("/golem15/seo/redirects")
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/la", "match": "exact", "target": "/lb", "status": "301"})
before := env.redirectCount(t)
rec := env.call(t, http.MethodPost, api, token, map[string]any{"source": "/lb", "match": "exact", "target": "/la", "status": "301"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"target"`) ||
!strings.Contains(rec.Body.String(), "This redirect would create a loop: /lb → /la → /lb. Nothing was saved.") {
t.Fatalf("loop create = %d %s, want 422 redirects.loop on target", rec.Code, rec.Body.String())
}
if after := env.redirectCount(t); after != before {
t.Fatalf("redirects after a refused loop = %d, want %d", after, before)
}
other := createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/lc", "target": "/elsewhere", "status": "302"}).Body.Bytes())
rec = env.call(t, http.MethodPut, api+"/"+other, token, map[string]any{"source": "/lb", "match": "exact", "target": "/la", "status": "301"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "would create a loop") {
t.Fatalf("loop update = %d %s, want 422", rec.Code, rec.Body.String())
}
var stored redirectRow
if err := env.db.Raw(`SELECT id, source, target, status FROM golem15_seo_redirects WHERE id = ?`, other).Scan(&stored).Error; err != nil || stored.Source != "/lc" || stored.Status != 302 {
t.Fatalf("redirect after a refused update = %+v err=%v, want it unchanged", stored, err)
}
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/cb", "target": "/cc"})
rec = env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/ca", "target": "/cb"})
chainID := createdID(t, rec.Body.Bytes())
var notice bool
for _, n := range saveNotices(t, rec.Body.Bytes()) {
if n.Field == "target" && strings.Contains(n.Message, "/ca → /cb → /cc") && strings.Contains(n.Message, "“/cc”") {
notice = true
}
}
if !notice {
t.Fatalf("chain save notices = %s, want redirects.chain_notice on target naming /cc", rec.Body.String())
}
var list struct {
Meta struct {
RowStates map[string][]string `json:"row_states"`
} `json:"meta"`
}
if err := json.Unmarshal(env.expect(t, http.StatusOK, http.MethodGet, api, token, nil).Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
if states := list.Meta.RowStates[chainID]; len(states) != 1 || states[0] != "negative" {
t.Fatalf("row states = %v, want the chain row %s negative", list.Meta.RowStates, chainID)
}
rec = env.call(t, http.MethodPost, api, token, map[string]any{"source": "/g", "status": "301"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Enter the address to redirect to") {
t.Fatalf("missing target = %d %s, want 422 target_required", rec.Code, rec.Body.String())
}
rec = env.call(t, http.MethodPost, api, token, map[string]any{"source": `^/(`, "match": "regex", "target": "/x"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "This is not a valid regular expression: missing closing )") {
t.Fatalf("invalid regex = %d %s, want 422 regex_invalid with the error", rec.Code, rec.Body.String())
}
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/gone-page", "status": "410"})
if got := env.resolve(t, "/gone-page"); got["action"] != "gone" {
t.Fatalf("410 from the admin = %v, want gone", got)
}
if rows := env.revisions(t, "redirect"); len(rows) < 4 {
t.Fatalf("redirect history rows = %d, want every admin save recorded", len(rows))
}
}