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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
192
controllers/redirects.go
Normal 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{}
|
||||
)
|
||||
22
controllers/redirects/config_filter.yaml
Normal file
22
controllers/redirects/config_filter.yaml
Normal 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
|
||||
18
controllers/redirects/config_form.yaml
Normal file
18
controllers/redirects/config_form.yaml
Normal 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
|
||||
25
controllers/redirects/config_list.yaml
Normal file
25
controllers/redirects/config_list.yaml
Normal 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
|
||||
@@ -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).
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
models/redirect/columns.yaml
Normal file
33
models/redirect/columns.yaml
Normal 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
|
||||
63
models/redirect/fields.yaml
Normal file
63
models/redirect/fields.yaml
Normal 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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user