diff --git a/admin_navigation.go b/admin_navigation.go index 0388348..101650f 100644 --- a/admin_navigation.go +++ b/admin_navigation.go @@ -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", diff --git a/classes/redirects.go b/classes/redirects.go index 93c5deb..83bccd8 100644 --- a/classes/redirects.go +++ b/classes/redirects.go @@ -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 diff --git a/classes/resolver.go b/classes/resolver.go index 8afff0e..5f3150c 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -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) } diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index c671b01..fa12432 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -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}, } diff --git a/controllers/redirects.go b/controllers/redirects.go new file mode 100644 index 0000000..66d2c0f --- /dev/null +++ b/controllers/redirects.go @@ -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{} +) diff --git a/controllers/redirects/config_filter.yaml b/controllers/redirects/config_filter.yaml new file mode 100644 index 0000000..9784138 --- /dev/null +++ b/controllers/redirects/config_filter.yaml @@ -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 diff --git a/controllers/redirects/config_form.yaml b/controllers/redirects/config_form.yaml new file mode 100644 index 0000000..6374597 --- /dev/null +++ b/controllers/redirects/config_form.yaml @@ -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 diff --git a/controllers/redirects/config_list.yaml b/controllers/redirects/config_list.yaml new file mode 100644 index 0000000..7810e51 --- /dev/null +++ b/controllers/redirects/config_list.yaml @@ -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 diff --git a/extensions.go b/extensions.go index b45a52c..81026e9 100644 --- a/extensions.go +++ b/extensions.go @@ -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). diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 50791d5..4a1c6c2 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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." diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 72188fe..5bf72a7 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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." diff --git a/models/redirect.go b/models/redirect.go index 18c7fee..3cfb721 100644 --- a/models/redirect.go +++ b/models/redirect.go @@ -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 +} diff --git a/models/redirect/columns.yaml b/models/redirect/columns.yaml new file mode 100644 index 0000000..1758d69 --- /dev/null +++ b/models/redirect/columns.yaml @@ -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 diff --git a/models/redirect/fields.yaml b/models/redirect/fields.yaml new file mode 100644 index 0000000..57cc4aa --- /dev/null +++ b/models/redirect/fields.yaml @@ -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 diff --git a/redirects_test.go b/redirects_test.go index 72a4375..43c4e44 100644 --- a/redirects_test.go +++ b/redirects_test.go @@ -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)) + } +}