feat: sitemap settings singleton with content types and additional addresses
- one settings tile opening the golem15.sitemap.settings singleton - Files tab (site address, file size, S1 files partial), Additional addresses repeater, Content types tab built from signpost at boot - golem15_sitemap_types; a type switched off leaves the files (reason type) - a save validates (errors.*) and dispatches a rebuild after commit
This commit is contained in:
51
admin.go
51
admin.go
@@ -5,29 +5,28 @@ import (
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// adminFS holds the admin YAML file by file: the controllers directory also
|
||||
// holds Go sources.
|
||||
// adminFS holds the admin YAML and partial templates by pattern (the
|
||||
// controllers directory also holds Go sources), so a new screen or partial
|
||||
// needs no edit here.
|
||||
//
|
||||
//go:embed controllers/definitions/config_form.yaml models/definition/fields.yaml models/settings/fields.yaml
|
||||
//go:embed controllers/*/*.yaml controllers/*/_*.htm models/*/*.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
// AdminFS serves the admin YAML.
|
||||
// AdminFS serves the admin YAML and partials.
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
// LangFS is the embedded English and Polish phrasebooks.
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
|
||||
// AdminControllers hands the controllers a lazy accessor of the booted app,
|
||||
// so they read the database and the menu item type registry at request
|
||||
// time.
|
||||
// so they read the database and the content type registry at request time.
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return controllers.AdminControllers(func() *backpack.App {
|
||||
if p == nil {
|
||||
@@ -37,31 +36,21 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
})
|
||||
}
|
||||
|
||||
// Settings registers the two PHP settings entries in the CMS category: the
|
||||
// sitemap definition (a link to the singleton definitions controller) and
|
||||
// the sitemap settings form. Both require golem15.sitemap.access_definitions.
|
||||
// Settings registers the one sitemap settings tile (UI-SPEC C26): a link to
|
||||
// the settings singleton controller in the CMS category, behind
|
||||
// golem15.sitemap.access_definitions. It replaces the Phase 17 definitions
|
||||
// and settings tiles (D-32).
|
||||
func (p *Plugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{
|
||||
{
|
||||
Code: "sitemap",
|
||||
Label: "golem15.sitemap::lang.plugin.name",
|
||||
Description: "golem15.sitemap::lang.plugin.description",
|
||||
Category: "golem15.sitemap::lang.settings.category",
|
||||
Icon: "network",
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Controller: controllers.DefinitionsControllerID,
|
||||
},
|
||||
{
|
||||
Code: "sitemap_settings",
|
||||
Label: "golem15.sitemap::lang.settings.label",
|
||||
Description: "golem15.sitemap::lang.settings.description",
|
||||
Category: "golem15.sitemap::lang.settings.category",
|
||||
Icon: "settings",
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
},
|
||||
}
|
||||
return []pact.SettingsItem{{
|
||||
Code: "sitemap",
|
||||
Label: "golem15.sitemap::lang.settings.label",
|
||||
Description: "golem15.sitemap::lang.settings.description",
|
||||
Category: "golem15.sitemap::lang.settings.category",
|
||||
Icon: "network",
|
||||
Keywords: []string{"sitemap", "sitemap.xml", "lastmod", "hreflang"},
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Controller: controllers.SettingsControllerID,
|
||||
}}
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
66
classes/dispatch.go
Normal file
66
classes/dispatch.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RebuildLabelKey is the phrase key of the rebuild job's label.
|
||||
const RebuildLabelKey = "golem15.sitemap::lang.jobs.rebuild"
|
||||
|
||||
// buildingWindow bounds the "rebuilding now" state of the settings screen:
|
||||
// a request older than this without a finished rebuild (no worker runs)
|
||||
// is no longer shown as running.
|
||||
const buildingWindow = time.Hour
|
||||
|
||||
// RequestRebuild records that a rebuild was asked for (the settings
|
||||
// screen's files.building state) and dispatches the rebuild job once db's
|
||||
// transaction commits, so the job reads what the transaction wrote. A
|
||||
// queue error is logged and never fails the caller.
|
||||
func RequestRebuild(ctx context.Context, app *backpack.App, db *gorm.DB) error {
|
||||
if app == nil || db == nil {
|
||||
return ErrNoDatabase
|
||||
}
|
||||
if err := db.Exec(`UPDATE golem15_sitemap_settings SET rebuild_requested_at = ? WHERE id = 1`, time.Now().UTC()).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
|
||||
if _, err := DispatchRebuild(ctx, app); err != nil {
|
||||
pluginLog.Warn("golem15.sitemap: queue rebuild", "error", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DispatchRebuild dispatches the rebuild job now, with a summer_jobs row
|
||||
// labelled in the request locale, and returns the row id.
|
||||
func DispatchRebuild(ctx context.Context, app *backpack.App) (uint, error) {
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return 0, ErrNoDatabase
|
||||
}
|
||||
m, err := conga.From(app)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
label := RebuildLabelKey
|
||||
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
|
||||
label = tr.Get(ctx, RebuildLabelKey, nil)
|
||||
}
|
||||
return m.Dispatch(ctx, db.WithContext(ctx), RebuildArgs{}, conga.DispatchOpts{Label: label})
|
||||
}
|
||||
|
||||
// Building reports whether a rebuild asked for from the admin has not
|
||||
// finished yet (within buildingWindow).
|
||||
func Building(requested, built *time.Time, now time.Time) bool {
|
||||
if requested == nil || now.Sub(*requested) > buildingWindow {
|
||||
return false
|
||||
}
|
||||
return built == nil || built.Before(*requested)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
@@ -124,8 +125,12 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
types, err := LoadTypeSettings(db)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
found, err := collect(ctx, reg, settings)
|
||||
found, err := collect(ctx, reg, settings, types)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
@@ -145,8 +150,10 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
|
||||
}
|
||||
|
||||
// collect walks the content types page by page and adds the additional
|
||||
// addresses.
|
||||
func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings) (collected, error) {
|
||||
// addresses. The records of a type switched off in the settings are kept
|
||||
// in the table with the reason contract.ReasonType, so the settings screen
|
||||
// counts them.
|
||||
func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings, types map[string]models.TypeSetting) (collected, error) {
|
||||
out := collected{unpublished: map[slotKey]bool{}}
|
||||
seen := map[slotKey]bool{}
|
||||
add := func(w wanted) {
|
||||
@@ -159,7 +166,11 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
|
||||
unprefixed := models.Text(settings.DefaultLocale)
|
||||
if reg != nil {
|
||||
for _, typ := range reg.ContentTypes() {
|
||||
slug := DefaultSlug(typ.Key)
|
||||
cfg := EffectiveType(typ, types)
|
||||
reason := ""
|
||||
if !cfg.Include {
|
||||
reason = contract.ReasonType
|
||||
}
|
||||
after := ""
|
||||
for {
|
||||
page, err := reg.Records(ctx, typ.Key, after, recordsPage, unprefixed)
|
||||
@@ -177,10 +188,11 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
|
||||
}
|
||||
add(wanted{
|
||||
key: slotKey{typ.Key, rec.ID, l.Locale},
|
||||
grp: slug,
|
||||
grp: cfg.Slug,
|
||||
url: l.URL,
|
||||
title: l.Title,
|
||||
firstMod: rec.LastMod,
|
||||
reason: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -196,7 +208,7 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
|
||||
if !models.AllowedAddress(u) {
|
||||
continue
|
||||
}
|
||||
mod := ParseDate(e.LastMod)
|
||||
mod := models.ParseDate(e.LastMod)
|
||||
add(wanted{
|
||||
key: slotKey{models.ExtraTypeKey, ExtraRecordID(u), ""},
|
||||
grp: models.ExtraGroup,
|
||||
@@ -209,23 +221,6 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParseDate reads a date as the admin's date picker or an import sends it
|
||||
// (RFC 3339, "2006-01-02 15:04:05" or "2006-01-02", UTC); nil when empty or
|
||||
// unreadable.
|
||||
func ParseDate(s string) *time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
t = t.UTC()
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply writes the found addresses into the slot table and numbers the
|
||||
// files, inside the rebuild's transaction.
|
||||
func apply(tx *gorm.DB, found collected, settings models.Settings, site string) (RebuildResult, error) {
|
||||
|
||||
54
classes/types.go
Normal file
54
classes/types.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReservedSlugs are file name parts no content type may use: the image
|
||||
// and News sitemaps own them.
|
||||
var ReservedSlugs = map[string]bool{"images": true, "news": true}
|
||||
|
||||
// TypeConfig is a content type's effective sitemap setting: its key and
|
||||
// label, whether its records are listed, its group slug and its HTML
|
||||
// sitemap headings (nil when none was saved).
|
||||
type TypeConfig struct {
|
||||
Key string
|
||||
Label string
|
||||
Include bool
|
||||
Slug string
|
||||
Titles map[string]string
|
||||
}
|
||||
|
||||
// LoadTypeSettings reads the saved content type settings by type key.
|
||||
func LoadTypeSettings(db *gorm.DB) (map[string]models.TypeSetting, error) {
|
||||
var rows []models.TypeSetting
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]models.TypeSetting, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.TypeKey] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EffectiveType is the setting of typ: the saved row, else listed under
|
||||
// DefaultSlug. A saved slug that is not a ValidSlug falls back to the
|
||||
// default.
|
||||
func EffectiveType(typ pact.MenuItemType, rows map[string]models.TypeSetting) TypeConfig {
|
||||
cfg := TypeConfig{Key: typ.Key, Label: typ.Label, Include: true, Slug: DefaultSlug(typ.Key)}
|
||||
row, ok := rows[typ.Key]
|
||||
if !ok {
|
||||
return cfg
|
||||
}
|
||||
cfg.Include = row.Include
|
||||
if ValidSlug(row.Slug) {
|
||||
cfg.Slug = row.Slug
|
||||
}
|
||||
if titles := row.Titles.Get(); len(titles) > 0 {
|
||||
cfg.Titles = titles
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -11,5 +11,6 @@ import (
|
||||
func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
definitionsAdminController{app: app},
|
||||
settingsAdminController{app: app},
|
||||
}
|
||||
}
|
||||
|
||||
266
controllers/settings.go
Normal file
266
controllers/settings.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SettingsControllerID is the cabana controller id of the sitemap settings
|
||||
// singleton (UI-SPEC C26), linked from the sitemap settings tile.
|
||||
const SettingsControllerID = "golem15.sitemap.settings"
|
||||
|
||||
// The reasons the files partial counts as "left out", in display order
|
||||
// (P33-11). A deleted or unpublished address is a hole, not left out.
|
||||
var leftOutReasons = []string{contract.ReasonNoindex, contract.ReasonCanonical, contract.ReasonRedirect, contract.ReasonType}
|
||||
|
||||
// settingsAdminController edits the sitemap settings singleton: Files,
|
||||
// Content types (added by the plugin's own form extension) and Additional
|
||||
// addresses. A save dispatches a rebuild.
|
||||
type settingsAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (settingsAdminController) ID() string { return SettingsControllerID }
|
||||
func (settingsAdminController) ModelName() string { return `Golem15\Sitemap\Models\Settings` }
|
||||
func (settingsAdminController) ConfigDir() string { return "controllers/settings" }
|
||||
func (settingsAdminController) NewRecord() any { return &models.Settings{} }
|
||||
func (settingsAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionAccessDefinitions}
|
||||
}
|
||||
|
||||
// SingletonRecord returns settings row 1, creating it with the defaults
|
||||
// when the migration's row is gone (safe against a concurrent first
|
||||
// request through ON CONFLICT DO NOTHING).
|
||||
func (settingsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("golem15.sitemap: database is not configured")
|
||||
}
|
||||
var rows []models.Settings
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return &rows[0], nil
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO golem15_sitemap_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return nil, errors.New("golem15.sitemap: settings row is missing")
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// FormBeforeUpdate refuses the settings Problems finds (UI-SPEC C26:
|
||||
// errors.frontend_url on site_url, errors.file_size, errors.url_scheme and
|
||||
// errors.lastmod on an additional address).
|
||||
func (settingsAdminController) FormBeforeUpdate(_ context.Context, model any) error {
|
||||
s, ok := model.(*models.Settings)
|
||||
if !ok || s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Validate()
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks a created row like an update.
|
||||
func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
return c.FormBeforeUpdate(ctx, model)
|
||||
}
|
||||
|
||||
// FormAfterUpdate dispatches a rebuild once the save commits (C26: saving
|
||||
// the form rebuilds the files now). A queue error is logged and never fails
|
||||
// the save.
|
||||
func (c settingsAdminController) FormAfterUpdate(ctx context.Context, _ any) error {
|
||||
app := appOf(c.app)
|
||||
tx, err := database(ctx, c.app)
|
||||
if err != nil || app == nil {
|
||||
return nil
|
||||
}
|
||||
return classes.RequestRebuild(ctx, app, tx)
|
||||
}
|
||||
|
||||
// FormAfterCreate dispatches a rebuild like an update.
|
||||
func (c settingsAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||
return c.FormAfterUpdate(ctx, model)
|
||||
}
|
||||
|
||||
// filesView is the view model of the S1 partial sitemap_files.
|
||||
type filesView struct {
|
||||
Building bool
|
||||
Empty bool
|
||||
Files []fileRow
|
||||
LeftOut []reasonRow
|
||||
Note string
|
||||
}
|
||||
|
||||
// fileRow is one row of the S1 files table: the index first, then each
|
||||
// file. URL is the backend route that serves it.
|
||||
type fileRow struct {
|
||||
Name string
|
||||
URL string
|
||||
Count int
|
||||
LastMod string
|
||||
}
|
||||
|
||||
// reasonRow is one row of the S1 left-out table.
|
||||
type reasonRow struct {
|
||||
Label string
|
||||
Count int
|
||||
}
|
||||
|
||||
// PartialData serves the S1 files partial: the index and its files with
|
||||
// their address counts and newest lastmod, the left-out counts by reason
|
||||
// and the index note; files.empty before the first build and
|
||||
// files.building while a rebuild asked for from the admin runs.
|
||||
func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
|
||||
if name != "sitemap_files" {
|
||||
return nil, nil
|
||||
}
|
||||
app := appOf(c.app)
|
||||
db, err := database(ctx, c.app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := classes.LoadSettings(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
site := classes.SiteURL(app, settings)
|
||||
view := filesView{Building: classes.Building(settings.RebuildRequestedAt, settings.BuiltAt, time.Now())}
|
||||
var files []struct {
|
||||
Grp string `gorm:"column:grp"`
|
||||
FileNo int `gorm:"column:file_no"`
|
||||
N int `gorm:"column:n"`
|
||||
LastMod *time.Time `gorm:"column:lastmod"`
|
||||
}
|
||||
if err := db.Raw(`SELECT grp, file_no, COUNT(*) AS n, MAX(lastmod) AS lastmod FROM golem15_sitemap_urls
|
||||
WHERE excluded_reason = '' AND file_no IS NOT NULL
|
||||
GROUP BY grp, file_no ORDER BY grp, file_no`).Scan(&files).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The partial kit links only root-relative addresses, so a file links
|
||||
// to the backend route that serves it; the note names the public index.
|
||||
link := func(name string) string {
|
||||
if name == "sitemap.xml" {
|
||||
return "/sitemap.xml"
|
||||
}
|
||||
return "/_sitemap/files/" + name
|
||||
}
|
||||
if len(files) == 0 {
|
||||
view.Empty = true
|
||||
} else {
|
||||
index := fileRow{Name: "sitemap.xml", URL: link("sitemap.xml")}
|
||||
var newest *time.Time
|
||||
for _, f := range files {
|
||||
index.Count += f.N
|
||||
if f.LastMod != nil && (newest == nil || f.LastMod.After(*newest)) {
|
||||
newest = f.LastMod
|
||||
}
|
||||
name := classes.FileName(f.Grp, f.FileNo)
|
||||
view.Files = append(view.Files, fileRow{Name: name, URL: link(name), Count: f.N, LastMod: formatTime(f.LastMod)})
|
||||
}
|
||||
index.LastMod = formatTime(newest)
|
||||
view.Files = append([]fileRow{index}, view.Files...)
|
||||
}
|
||||
var counts []struct {
|
||||
Reason string `gorm:"column:excluded_reason"`
|
||||
N int `gorm:"column:n"`
|
||||
}
|
||||
if err := db.Raw(`SELECT excluded_reason, COUNT(*) AS n FROM golem15_sitemap_urls
|
||||
WHERE excluded_reason NOT IN ('', ?, ?) GROUP BY excluded_reason`, models.ReasonDeleted, models.ReasonUnpublished).Scan(&counts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byReason := map[string]int{}
|
||||
for _, row := range counts {
|
||||
byReason[row.Reason] = row.N
|
||||
}
|
||||
tr := translator(c.app)
|
||||
for _, reason := range leftOutReasons {
|
||||
if n := byReason[reason]; n > 0 {
|
||||
view.LeftOut = append(view.LeftOut, reasonRow{Label: Phrase(ctx, tr, "golem15.sitemap::lang.files.reason_"+reason, nil), Count: n})
|
||||
delete(byReason, reason)
|
||||
}
|
||||
}
|
||||
for reason, n := range byReason {
|
||||
view.LeftOut = append(view.LeftOut, reasonRow{Label: reason, Count: n})
|
||||
}
|
||||
indexURL := "/sitemap.xml"
|
||||
if site != "" {
|
||||
indexURL = site + "/sitemap.xml"
|
||||
}
|
||||
view.Note = Phrase(ctx, tr, "golem15.sitemap::lang.files.index_note", map[string]string{"url": indexURL})
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// formatTime renders a lastmod for the files table, "" when unknown.
|
||||
func formatTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
// appOf calls the lazy app accessor.
|
||||
func appOf(app func() *backpack.App) *backpack.App {
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
return app()
|
||||
}
|
||||
|
||||
// translator returns the application's translator, nil when none.
|
||||
func translator(app func() *backpack.App) *phrasebook.Translator {
|
||||
a := appOf(app)
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
tr, _ := a.Lookup[*phrasebook.Translator]()
|
||||
return tr
|
||||
}
|
||||
|
||||
// database returns the write transaction of ctx, else the app database.
|
||||
func database(ctx context.Context, app func() *backpack.App) (*gorm.DB, error) {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
|
||||
return tx, nil
|
||||
}
|
||||
a := appOf(app)
|
||||
if a == nil {
|
||||
return nil, errors.New("golem15.sitemap: app is not booted")
|
||||
}
|
||||
db, ok := a.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("golem15.sitemap: database is not configured")
|
||||
}
|
||||
return db.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
// Phrase translates key with params in the request locale; without a
|
||||
// translator it returns the key.
|
||||
func Phrase(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string {
|
||||
if tr == nil {
|
||||
return key
|
||||
}
|
||||
return tr.Get(ctx, key, params)
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = settingsAdminController{}
|
||||
_ pact.AdminPermissioned = settingsAdminController{}
|
||||
_ pact.AdminRecordSource = settingsAdminController{}
|
||||
_ pact.AdminSingleton = settingsAdminController{}
|
||||
_ pact.AdminPartialData = settingsAdminController{}
|
||||
_ pact.FormBeforeCreate = settingsAdminController{}
|
||||
_ pact.FormBeforeUpdate = settingsAdminController{}
|
||||
_ pact.FormAfterCreate = settingsAdminController{}
|
||||
_ pact.FormAfterUpdate = settingsAdminController{}
|
||||
)
|
||||
42
controllers/settings/_sitemap_files.htm
Normal file
42
controllers/settings/_sitemap_files.htm
Normal file
@@ -0,0 +1,42 @@
|
||||
{{- if .Data.Building -}}
|
||||
<div class="summer-callout" role="status">
|
||||
<p class="summer-callout__text">{{ trans "golem15.sitemap::lang.files.building" }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- if .Data.Empty -}}
|
||||
<div class="summer-callout" role="status">
|
||||
<p class="summer-callout__text">{{ trans "golem15.sitemap::lang.files.empty" }}</p>
|
||||
</div>
|
||||
{{- else -}}
|
||||
<section>
|
||||
<h4>{{ trans "golem15.sitemap::lang.files.label" }}</h4>
|
||||
<div class="summer-table-wrap">
|
||||
<table class="summer-table">
|
||||
<thead><tr><th>{{ trans "golem15.sitemap::lang.files.file" }}</th><th class="summer-table__num">{{ trans "golem15.sitemap::lang.files.file_urls" }}</th><th>{{ trans "golem15.sitemap::lang.files.file_lastmod" }}</th></tr></thead>
|
||||
<tbody>
|
||||
{{- range .Data.Files -}}
|
||||
<tr><td>{{ if .URL }}<a href="{{ .URL }}"><code>{{ .Name }}</code></a>{{ else }}<code>{{ .Name }}</code>{{ end }}</td><td class="summer-table__num">{{ .Count }}</td><td>{{ .LastMod }}</td></tr>
|
||||
{{- end -}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{- end -}}
|
||||
{{- if .Data.LeftOut -}}
|
||||
<section>
|
||||
<h4>{{ trans "golem15.sitemap::lang.files.left_out" }}</h4>
|
||||
<div class="summer-table-wrap">
|
||||
<table class="summer-table">
|
||||
<thead><tr><th>{{ trans "golem15.sitemap::lang.files.left_out_reason" }}</th><th class="summer-table__num">{{ trans "golem15.sitemap::lang.files.left_out_count" }}</th></tr></thead>
|
||||
<tbody>
|
||||
{{- range .Data.LeftOut -}}
|
||||
<tr><td>{{ .Label }}</td><td class="summer-table__num">{{ .Count }}</td></tr>
|
||||
{{- end -}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{- end -}}
|
||||
<div class="summer-callout" role="note">
|
||||
<p class="summer-callout__text">{{ .Data.Note }}</p>
|
||||
</div>
|
||||
20
controllers/settings/config_form.yaml
Normal file
20
controllers/settings/config_form.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# ===================================
|
||||
# Form Behavior Config
|
||||
# ===================================
|
||||
#
|
||||
# The sitemap settings singleton (UI-SPEC C26), linked from the sitemap
|
||||
# settings tile. The plugin's form extension adds the Content types tab
|
||||
# after Files.
|
||||
|
||||
name: golem15.sitemap::lang.settings.label
|
||||
form: ~/plugins/golem15/sitemap/models/settings/fields.yaml
|
||||
modelClass: Golem15\Sitemap\Models\Settings
|
||||
defaultRedirect: system/settings
|
||||
|
||||
update:
|
||||
redirect: golem15/sitemap/settings
|
||||
redirectClose: system/settings
|
||||
|
||||
messages:
|
||||
update: golem15.sitemap::lang.settings.label
|
||||
saved: backend::lang.messages.form.saved
|
||||
287
extensions_settings.go
Normal file
287
extensions_settings.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package sitemap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"github.com/goccy/go-yaml"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// The settings form's tab keys: the Content types tab follows Files.
|
||||
const (
|
||||
TabFiles = "golem15.sitemap::lang.settings.tab_files"
|
||||
TabTypes = "golem15.sitemap::lang.settings.tab_types"
|
||||
)
|
||||
|
||||
// The phrase keys of the Content types refusals (UI-SPEC C26).
|
||||
const (
|
||||
errSlug = "golem15.sitemap::lang.errors.slug"
|
||||
errSlugTaken = "golem15.sitemap::lang.errors.slug_taken"
|
||||
)
|
||||
|
||||
var typeFieldUnsafe = regexp.MustCompile(`[^a-z0-9_]`)
|
||||
|
||||
// typeFieldKey turns a content type key into the part of a field name:
|
||||
// every character outside [a-z0-9_] becomes "_".
|
||||
func typeFieldKey(key string) string {
|
||||
return typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_")
|
||||
}
|
||||
|
||||
// AdminActionExtensions adds the Content types tab to the plugin's own
|
||||
// settings singleton: one block per signpost content type, in registration
|
||||
// order. Nothing is added when no content type is registered.
|
||||
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
reg := p.registry()
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
types := reg.ContentTypes()
|
||||
if len(types) == 0 {
|
||||
return nil
|
||||
}
|
||||
form := p.typeSettingsTab(types)
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
return []pact.AdminActionExtension{{Controller: controllers.SettingsControllerID, Form: form}}
|
||||
}
|
||||
|
||||
func (p *Plugin) registry() *signpost.Registry {
|
||||
if p == nil || p.app == nil {
|
||||
return nil
|
||||
}
|
||||
reg, _ := p.app.Lookup[*signpost.Registry]()
|
||||
return reg
|
||||
}
|
||||
|
||||
// typeSettingsDocument is the fields document of the Content types tab
|
||||
// (UI-SPEC C26): per type, the include switch (labelled with the type's
|
||||
// label), the file name and the HTML sitemap heading.
|
||||
func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
|
||||
fields := yaml.MapSlice{}
|
||||
for _, typ := range types {
|
||||
base := "type_" + typeFieldKey(typ.Key) + "_"
|
||||
label := typ.Label
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = typ.Key
|
||||
}
|
||||
fields = append(fields,
|
||||
yaml.MapItem{Key: base + "include", Value: yaml.MapSlice{
|
||||
{Key: "label", Value: label},
|
||||
{Key: "comment", Value: "golem15.sitemap::lang.settings.type_include"},
|
||||
{Key: "type", Value: "switch"},
|
||||
{Key: "span", Value: "left"},
|
||||
{Key: "tab", Value: TabTypes},
|
||||
}},
|
||||
yaml.MapItem{Key: base + "slug", Value: yaml.MapSlice{
|
||||
{Key: "label", Value: "golem15.sitemap::lang.settings.type_slug"},
|
||||
{Key: "comment", Value: "golem15.sitemap::lang.settings.type_slug_comment"},
|
||||
{Key: "type", Value: "text"},
|
||||
{Key: "span", Value: "right"},
|
||||
{Key: "tab", Value: TabTypes},
|
||||
}},
|
||||
yaml.MapItem{Key: base + "title", Value: yaml.MapSlice{
|
||||
{Key: "label", Value: "golem15.sitemap::lang.settings.type_title"},
|
||||
{Key: "type", Value: "mltext"},
|
||||
{Key: "span", Value: "full"},
|
||||
{Key: "tab", Value: TabTypes},
|
||||
}},
|
||||
)
|
||||
}
|
||||
return yaml.Marshal(yaml.MapSlice{
|
||||
{Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}},
|
||||
{Key: "fields", Value: fields},
|
||||
})
|
||||
}
|
||||
|
||||
// typeSettingsTab is the plugin's form extension on its own settings
|
||||
// controller: the Content types tab, loaded from and saved to
|
||||
// golem15_sitemap_types.
|
||||
func (p *Plugin) typeSettingsTab(types []pact.MenuItemType) *pact.AdminFormExtension {
|
||||
doc, err := typeSettingsDocument(types)
|
||||
if err != nil {
|
||||
pluginLog.Error("golem15.sitemap: content types tab", "error", err)
|
||||
return nil
|
||||
}
|
||||
return &pact.AdminFormExtension{
|
||||
FieldsYAML: doc,
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
|
||||
rows, err := classes.LoadTypeSettings(in.DB.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
def := p.defaultLocale(ctx, in.DB)
|
||||
tr := p.translator()
|
||||
out := map[string]any{}
|
||||
for _, typ := range types {
|
||||
cfg := classes.EffectiveType(typ, rows)
|
||||
base := "type_" + typeFieldKey(typ.Key) + "_"
|
||||
out[base+"include"] = cfg.Include
|
||||
out[base+"slug"] = cfg.Slug
|
||||
titles := cfg.Titles
|
||||
if len(titles) == 0 {
|
||||
titles = map[string]string{def: controllers.Phrase(ctx, tr, typ.Label, nil)}
|
||||
}
|
||||
out[base+"title"] = titles
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
||||
return pact.AdminFormSaveResult{}, saveTypeSettings(ctx, in, types)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// saveTypeSettings stores the submitted Content types values. A slug
|
||||
// outside [a-z0-9-] is refused with errors.slug, one that another type (or
|
||||
// the image and News sitemaps) uses with errors.slug_taken; an empty slug
|
||||
// is the type's default.
|
||||
func saveTypeSettings(ctx context.Context, in pact.AdminFormSave, types []pact.MenuItemType) error {
|
||||
rows, err := classes.LoadTypeSettings(in.DB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refusals := pact.AdminFormErrors{}
|
||||
next := make([]models.TypeSetting, 0, len(types))
|
||||
slugOwner := map[string]string{}
|
||||
for _, typ := range types {
|
||||
base := "type_" + typeFieldKey(typ.Key) + "_"
|
||||
cfg := classes.EffectiveType(typ, rows)
|
||||
row, ok := rows[typ.Key]
|
||||
if !ok {
|
||||
row = models.TypeSetting{TypeKey: typ.Key, Include: true}
|
||||
}
|
||||
if raw, sent := in.Values[base+"include"]; sent {
|
||||
row.Include = truthy(raw)
|
||||
}
|
||||
slug := cfg.Slug
|
||||
if raw, sent := in.Values[base+"slug"]; sent {
|
||||
slug = strings.TrimSpace(stringOf(raw))
|
||||
if slug == "" {
|
||||
slug = classes.DefaultSlug(typ.Key)
|
||||
} else if !classes.ValidSlug(slug) {
|
||||
refusals[base+"slug"] = append(refusals[base+"slug"], errSlug)
|
||||
}
|
||||
}
|
||||
if classes.ReservedSlugs[slug] {
|
||||
refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken)
|
||||
} else if owner, taken := slugOwner[slug]; taken && owner != typ.Key {
|
||||
refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken)
|
||||
}
|
||||
slugOwner[slug] = typ.Key
|
||||
if slug == classes.DefaultSlug(typ.Key) {
|
||||
row.Slug = ""
|
||||
} else {
|
||||
row.Slug = slug
|
||||
}
|
||||
if raw, sent := in.Values[base+"title"]; sent {
|
||||
row.Titles = lagoon.Jsonable[map[string]string]{Data: titlesOf(raw), Valid: true}
|
||||
}
|
||||
next = append(next, row)
|
||||
}
|
||||
if len(refusals) > 0 {
|
||||
return refusals
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, row := range next {
|
||||
row.UpdatedAt = now
|
||||
if !row.Titles.Valid {
|
||||
row.Titles = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
||||
}
|
||||
if err := in.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "type_key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"include", "slug", "titles", "updated_at"}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// titlesOf reads a submitted translatable value (a map of locale to text);
|
||||
// blank texts are dropped.
|
||||
func titlesOf(v any) map[string]string {
|
||||
out := map[string]string{}
|
||||
switch m := v.(type) {
|
||||
case map[string]string:
|
||||
for k, s := range m {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
out[k] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for k, s := range m {
|
||||
if text, ok := s.(string); ok && strings.TrimSpace(text) != "" {
|
||||
out[k] = strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// truthy reads a submitted switch value.
|
||||
func truthy(v any) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case string:
|
||||
return b == "1" || strings.EqualFold(b, "true") || strings.EqualFold(b, "on")
|
||||
case fmt.Stringer:
|
||||
return b.String() == "1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringOf reads a submitted text value.
|
||||
func stringOf(v any) string {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
// translator returns the application's translator, nil when none.
|
||||
func (p *Plugin) translator() *phrasebook.Translator {
|
||||
if p == nil || p.app == nil {
|
||||
return nil
|
||||
}
|
||||
tr, _ := p.app.Lookup[*phrasebook.Translator]()
|
||||
return tr
|
||||
}
|
||||
|
||||
// defaultLocale is the site's default content locale: the translation
|
||||
// writer's default, else app.locale, else en.
|
||||
func (p *Plugin) defaultLocale(ctx context.Context, db *gorm.DB) string {
|
||||
if p != nil && p.app != nil {
|
||||
if w, ok := p.app.Lookup[cabana.TranslationWriter](); ok && w != nil {
|
||||
if code, err := w.DefaultLocale(ctx, db); err == nil && code != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
if p.app.Config != nil {
|
||||
if code := strings.TrimSpace(p.app.Config.String("app.locale")); code != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
var _ pact.HasAdminActionExtensions = (*Plugin)(nil)
|
||||
2
go.mod
2
go.mod
@@ -5,6 +5,7 @@ go 1.27.0
|
||||
require (
|
||||
git.golem15.com/golem15/summercms v0.6.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
@@ -75,7 +76,6 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
plugin:
|
||||
name: Sitemap
|
||||
description: Generate a sitemap.xml file for your website.
|
||||
description: XML sitemaps per content type, with stable files, real lastmod and hreflang.
|
||||
permissions:
|
||||
access_settings: Access sitemap configuration settings
|
||||
access_definitions: Access sitemap definitions page
|
||||
@@ -41,29 +41,80 @@ errors:
|
||||
url_scheme: Use a path such as /about, or a link that starts with http:// or https://.
|
||||
url_pattern: Use a path that starts with a single /, for example /blog/:slug.
|
||||
frontend_url: 'Use a full address that starts with http:// or https://, without spaces, quotes or the characters < > &. Leave it empty to keep the backend URL.'
|
||||
file_size: Enter a whole number from 1 to 50 000.
|
||||
slug: Use lowercase letters, digits and hyphens, e.g. posts.
|
||||
slug_taken: Another content type already uses this file name.
|
||||
html_path: Start the address with / and use a path only, e.g. /mapa-strony.
|
||||
page_size: Enter a whole number from 50 to 500.
|
||||
lastmod: 'Enter a date as YYYY-MM-DD, e.g. 2026-10-28, or leave it empty.'
|
||||
definition:
|
||||
not_found: No sitemap definition was found. Try creating one first.
|
||||
title: Sitemap definition
|
||||
view: View sitemap.xml
|
||||
settings:
|
||||
label: Sitemap settings
|
||||
description: Frontend URL, locale and priority options.
|
||||
label: Sitemaps
|
||||
description: XML sitemaps per content type, images, News, IndexNow and the HTML sitemap.
|
||||
category: CMS
|
||||
frontend_url: Frontend URL
|
||||
frontend_url_comment: The public URL of the frontend app (e.g. https://www.example.com). When set, sitemap URLs use this domain instead of the backend URL.
|
||||
frontend_default_locale: Frontend Default Locale
|
||||
frontend_default_locale_comment: The unprefixed locale on the frontend (e.g. pl for a prefix_except_default strategy). Other locales get a /{code}/ prefix in sitemap URLs.
|
||||
updates_manifest_path: Updates Manifest Path
|
||||
updates_manifest_path_comment: Absolute path to a JSON file mapping URL path => ISO 8601 last-modified date, produced by the frontend build. Used as the <lastmod> for URL items that have no date of their own (e.g. static pages). When empty or unreadable, those items omit <lastmod>. Dynamic sets keep their own dates.
|
||||
enable_priority_tiers: Enable Priority Tiers
|
||||
enable_priority_tiers_comment: When enabled, priorities are auto-assigned based on the URL path rules in the config, overriding per-item priorities. When disabled, per-item priorities are used.
|
||||
priority_homepage: Homepage Priority
|
||||
priority_homepage_comment: Used for URLs that match the homepage tier rules in the config. Default 1.0. Applies only when priority tiers are enabled.
|
||||
priority_services: Services & Work Priority
|
||||
priority_services_comment: Used for URLs that match the services tier rules in the config. Default 0.8. Applies only when priority tiers are enabled.
|
||||
priority_blog: Blog Posts Priority
|
||||
priority_blog_comment: Used for URLs that match the blog tier rules in the config. Default 0.6. Applies only when priority tiers are enabled.
|
||||
priority_categories: Categories & Hub Pages Priority
|
||||
priority_categories_comment: Used for URLs that match the categories tier rules in the config. Default 0.4. Applies only when priority tiers are enabled.
|
||||
priority_legal: Legal Pages Priority
|
||||
priority_legal_comment: Used for URLs that match the legal tier rules in the config. Default 0.2. Applies only when priority tiers are enabled.
|
||||
tab_files: Files
|
||||
tab_types: Content types
|
||||
tab_extra: Additional addresses
|
||||
tab_media: Images and News
|
||||
tab_indexnow: IndexNow
|
||||
tab_html: HTML sitemap
|
||||
site_url: Site address
|
||||
site_url_comment: The public address of the site, e.g. https://www.example.com. Every address in the sitemaps starts with it.
|
||||
file_size: Addresses per file
|
||||
file_size_comment: 1000 by default, at most 50 000. Smaller files are easier to monitor one by one in Google Search Console. A change renumbers every file.
|
||||
type_include: Include this content type in the sitemaps.
|
||||
type_slug: File name
|
||||
type_slug_comment: Files are named sitemap-<name>-1.xml, sitemap-<name>-2.xml and so on.
|
||||
type_title: Heading in the HTML sitemap
|
||||
extra_urls: Additional addresses
|
||||
extra_urls_add: Add address
|
||||
extra_urls_comment: Addresses that no content type lists, e.g. the site's static pages. They go into the sitemap-pages files.
|
||||
extra_url: Address
|
||||
extra_url_lastmod: Last modified
|
||||
extra_url_lastmod_comment: 'Optional, as YYYY-MM-DD, e.g. 2026-10-28.'
|
||||
images_enabled: Image sitemap
|
||||
images_enabled_comment: Lists the featured image and the images in the content of every listed page, in the sitemap-images files.
|
||||
news_enabled: Google News sitemap
|
||||
news_enabled_comment: Only for sites accepted in Google News. Lists articles published in the last two days.
|
||||
news_publication: Publication name
|
||||
news_publication_comment: 'As registered in Google News. Empty: the site name.'
|
||||
news_sources: Types and categories
|
||||
news_sources_comment: 'Articles of these types or categories go into the News sitemap. Empty: every article type.'
|
||||
indexnow_enabled: IndexNow
|
||||
indexnow_enabled_comment: Tells Bing, Yandex and other IndexNow engines about new, changed and deleted addresses right away. Google does not use IndexNow.
|
||||
indexnow_new_key: Generate a new key on save
|
||||
indexnow_new_key_comment: The old key file stops working at once. Use it only when the key leaked.
|
||||
html_enabled: HTML sitemap
|
||||
html_enabled_comment: A page for visitors that lists every indexable address, linked from the site footer.
|
||||
html_path: Address
|
||||
html_path_comment: The site's sitemap page uses this address. Change it in the site as well.
|
||||
html_page_size: Links per page
|
||||
html_page_size_comment: Larger groups are split into numbered pages. Default 150.
|
||||
files:
|
||||
label: Files
|
||||
file: File
|
||||
file_urls: Addresses
|
||||
file_lastmod: Last modified
|
||||
left_out: Left out of the sitemaps
|
||||
left_out_reason: Reason
|
||||
left_out_count: Addresses
|
||||
reason_noindex: noindex
|
||||
reason_canonical: Canonical URL points elsewhere
|
||||
reason_redirect: Address redirects
|
||||
reason_type: Content type turned off
|
||||
index_note: Search engines read the index at :url, and robots.txt points to it. The files are rebuilt after content changes and every night; saving this form rebuilds them now.
|
||||
empty: No files yet. They are built within a few minutes after the first save.
|
||||
building: Rebuilding now. Reload the page in a minute.
|
||||
indexnow:
|
||||
key: 'Key: :key, served at :url'
|
||||
no_key: A key is generated when you turn IndexNow on and save.
|
||||
last: 'Last notification: :time, :count addresses, answer :status.'
|
||||
never: No notification sent yet.
|
||||
err_key: The engines could not read the key file at :url. Check that the site serves it.
|
||||
err_host: The engines refused the addresses because they do not match the site address.
|
||||
err_rate: 'Too many notifications; the next attempt is at :time.'
|
||||
jobs:
|
||||
rebuild: Rebuild the sitemaps
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
plugin:
|
||||
name: Mapa strony
|
||||
description: Generuje plik sitemap.xml dla Twojej strony.
|
||||
name: Mapy witryny
|
||||
description: Mapy XML według typów treści, ze stałymi plikami, prawdziwym lastmod i hreflang.
|
||||
permissions:
|
||||
access_settings: Zarządzaj ustawieniami wtyczki
|
||||
access_definitions: Zarządzaj definicją mapy strony
|
||||
@@ -41,29 +41,80 @@ errors:
|
||||
url_scheme: Podaj ścieżkę, np. /o-nas, albo link zaczynający się od http:// lub https://.
|
||||
url_pattern: Podaj ścieżkę zaczynającą się od jednego /, np. /blog/:slug.
|
||||
frontend_url: 'Podaj pełny adres zaczynający się od http:// lub https://, bez spacji, cudzysłowów i znaków < > &. Zostaw puste, aby zachować adres backendu.'
|
||||
file_size: Wpisz liczbę całkowitą od 1 do 50 000.
|
||||
slug: Użyj małych liter, cyfr i łączników, np. posts.
|
||||
slug_taken: Inny typ treści już używa tej nazwy pliku.
|
||||
html_path: Zacznij adres od / i użyj samej ścieżki, np. /mapa-strony.
|
||||
page_size: Wpisz liczbę całkowitą od 50 do 500.
|
||||
lastmod: 'Wpisz datę w formacie RRRR-MM-DD, np. 2026-10-28, albo zostaw puste.'
|
||||
definition:
|
||||
not_found: Nie znaleziono definicji mapy strony. Musisz ją utworzyć.
|
||||
title: Definicja mapy strony
|
||||
view: Zobacz sitemap.xml
|
||||
settings:
|
||||
label: Ustawienia mapy strony
|
||||
description: Adres frontendu, język i priorytety.
|
||||
label: Mapy witryny
|
||||
description: Mapy XML według typów treści, obrazy, News, IndexNow i mapa HTML.
|
||||
category: CMS
|
||||
frontend_url: Adres URL frontendu
|
||||
frontend_url_comment: Publiczny adres aplikacji frontendowej (np. https://www.example.com). Gdy jest ustawiony, adresy w mapie strony używają tej domeny zamiast adresu backendu.
|
||||
frontend_default_locale: Domyślny język frontendu
|
||||
frontend_default_locale_comment: Język bez prefiksu na frontendzie (np. pl przy strategii prefix_except_default). Pozostałe języki dostają w mapie strony prefiks /{kod}/.
|
||||
updates_manifest_path: Ścieżka manifestu aktualizacji
|
||||
updates_manifest_path_comment: Bezwzględna ścieżka do pliku JSON, który mapuje ścieżkę URL na datę ostatniej zmiany ISO 8601, tworzonego przez build frontendu. Służy jako <lastmod> dla pozycji URL bez własnej daty (np. stron statycznych). Gdy jest pusta lub plik jest nieczytelny, te pozycje nie mają <lastmod>. Zestawy dynamiczne zachowują własne daty.
|
||||
enable_priority_tiers: Włącz poziomy priorytetów
|
||||
enable_priority_tiers_comment: Po włączeniu priorytety są nadawane według reguł ścieżek URL z konfiguracji i zastępują priorytety pozycji. Po wyłączeniu używane są priorytety pozycji.
|
||||
priority_homepage: Priorytet strony głównej
|
||||
priority_homepage_comment: Dla adresów pasujących do reguł poziomu strony głównej z konfiguracji. Domyślnie 1.0. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_services: Priorytet usług i realizacji
|
||||
priority_services_comment: Dla adresów pasujących do reguł poziomu usług z konfiguracji. Domyślnie 0.8. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_blog: Priorytet wpisów bloga
|
||||
priority_blog_comment: Dla adresów pasujących do reguł poziomu bloga z konfiguracji. Domyślnie 0.6. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_categories: Priorytet kategorii i stron zbiorczych
|
||||
priority_categories_comment: Dla adresów pasujących do reguł poziomu kategorii z konfiguracji. Domyślnie 0.4. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_legal: Priorytet stron prawnych
|
||||
priority_legal_comment: Dla adresów pasujących do reguł poziomu stron prawnych z konfiguracji. Domyślnie 0.2. Działa tylko przy włączonych poziomach priorytetów.
|
||||
tab_files: Pliki
|
||||
tab_types: Typy treści
|
||||
tab_extra: Dodatkowe adresy
|
||||
tab_media: Obrazy i News
|
||||
tab_indexnow: IndexNow
|
||||
tab_html: Mapa HTML
|
||||
site_url: Adres witryny
|
||||
site_url_comment: Publiczny adres witryny, np. https://www.example.com. Od niego zaczyna się każdy adres w mapach.
|
||||
file_size: Adresów w pliku
|
||||
file_size_comment: Domyślnie 1000, najwyżej 50 000. Mniejsze pliki łatwiej monitorować pojedynczo w Google Search Console. Zmiana przenumerowuje wszystkie pliki.
|
||||
type_include: Uwzględniaj ten typ treści w mapach witryny.
|
||||
type_slug: Nazwa pliku
|
||||
type_slug_comment: Pliki nazywają się sitemap-<nazwa>-1.xml, sitemap-<nazwa>-2.xml i tak dalej.
|
||||
type_title: Nagłówek grupy w mapie HTML
|
||||
extra_urls: Dodatkowe adresy
|
||||
extra_urls_add: Dodaj adres
|
||||
extra_urls_comment: Adresy, których nie podaje żaden typ treści, np. statyczne strony witryny. Trafiają do plików sitemap-pages.
|
||||
extra_url: Adres
|
||||
extra_url_lastmod: Ostatnia zmiana
|
||||
extra_url_lastmod_comment: 'Opcjonalnie, w formacie RRRR-MM-DD, np. 2026-10-28.'
|
||||
images_enabled: Mapa obrazów
|
||||
images_enabled_comment: Wypisuje obraz wyróżniający i obrazy w treści każdej podanej strony, w plikach sitemap-images.
|
||||
news_enabled: Mapa Google News
|
||||
news_enabled_comment: Tylko dla witryn przyjętych do Google News. Wypisuje artykuły opublikowane w ciągu ostatnich dwóch dni.
|
||||
news_publication: Nazwa publikacji
|
||||
news_publication_comment: 'Tak jak w Google News. Puste: nazwa witryny.'
|
||||
news_sources: Typy i kategorie
|
||||
news_sources_comment: 'Artykuły tych typów lub kategorii trafiają do mapy News. Puste: każdy typ artykułów.'
|
||||
indexnow_enabled: IndexNow
|
||||
indexnow_enabled_comment: Od razu informuje Bing, Yandex i inne wyszukiwarki IndexNow o nowych, zmienionych i usuniętych adresach. Google nie korzysta z IndexNow.
|
||||
indexnow_new_key: Wygeneruj nowy klucz przy zapisie
|
||||
indexnow_new_key_comment: Stary plik klucza od razu przestaje działać. Używaj tylko, gdy klucz wyciekł.
|
||||
html_enabled: Mapa witryny HTML
|
||||
html_enabled_comment: Strona dla odwiedzających z listą wszystkich indeksowalnych adresów, linkowana ze stopki witryny.
|
||||
html_path: Adres
|
||||
html_path_comment: Strona mapy witryny używa tego adresu. Zmień go także w witrynie.
|
||||
html_page_size: Linków na stronie
|
||||
html_page_size_comment: Większe grupy są dzielone na numerowane strony. Domyślnie 150.
|
||||
files:
|
||||
label: Pliki
|
||||
file: Plik
|
||||
file_urls: Adresy
|
||||
file_lastmod: Ostatnia zmiana
|
||||
left_out: Pominięte w mapach witryny
|
||||
left_out_reason: Powód
|
||||
left_out_count: Adresy
|
||||
reason_noindex: noindex
|
||||
reason_canonical: Adres kanoniczny wskazuje inny adres
|
||||
reason_redirect: Adres przekierowuje
|
||||
reason_type: Wyłączony typ treści
|
||||
index_note: Wyszukiwarki czytają indeks pod :url, a robots.txt go wskazuje. Pliki są odbudowywane po zmianach treści i co noc; zapisanie tego formularza odbudowuje je od razu.
|
||||
empty: Nie ma jeszcze plików. Powstaną w ciągu kilku minut po pierwszym zapisie.
|
||||
building: Trwa odbudowa. Odśwież stronę za minutę.
|
||||
indexnow:
|
||||
key: 'Klucz: :key, udostępniany pod :url'
|
||||
no_key: Klucz zostanie wygenerowany po włączeniu IndexNow i zapisie.
|
||||
last: 'Ostatnie powiadomienie: :time, adresy: :count, odpowiedź :status.'
|
||||
never: Nie wysłano jeszcze powiadomienia.
|
||||
err_key: Wyszukiwarki nie mogły odczytać pliku klucza pod :url. Sprawdź, czy witryna go udostępnia.
|
||||
err_host: Wyszukiwarki odrzuciły adresy, bo nie pasują do adresu witryny.
|
||||
err_rate: 'Zbyt wiele powiadomień; następna próba: :time.'
|
||||
jobs:
|
||||
rebuild: Odbudowa map witryny
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestFillable(t *testing.T) {
|
||||
if rules := (models.Definition{}).Rules(); len(rules) != 0 {
|
||||
t.Fatalf("Rules = %v", rules)
|
||||
}
|
||||
if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 9 {
|
||||
if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 3 {
|
||||
t.Fatalf("Settings.Fillable = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -9,7 +10,6 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// errFrontendURL is the phrase key of the frontend_url refusal.
|
||||
@@ -95,28 +95,62 @@ func (s Settings) Extra() []ExtraURL { return s.ExtraURLs.Get() }
|
||||
|
||||
func (Settings) TableName() string { return "golem15_sitemap_settings" }
|
||||
|
||||
// Fillable lists only the settings columns.
|
||||
// Fillable lists the columns the settings form edits.
|
||||
func (Settings) Fillable() []string {
|
||||
return []string{
|
||||
"frontend_url", "frontend_default_locale", "updates_manifest_path",
|
||||
"enable_priority_tiers", PriorityHomepage, PriorityServices,
|
||||
PriorityBlog, PriorityCategories, PriorityLegal,
|
||||
}
|
||||
return []string{"site_url", "file_size", "extra_urls"}
|
||||
}
|
||||
|
||||
// Rules is empty: the PHP settings form validated nothing. The one check of
|
||||
// the port, on frontend_url, is in BeforeValidate.
|
||||
func (Settings) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// BeforeValidate is the lagoon.HasBeforeValidate hook cabana calls on a
|
||||
// settings save, after the fill and before the row is written: a
|
||||
// frontend_url that AllowedFrontendURL refuses is a validation error on
|
||||
// frontend_url, so the save answers 422 and the row is left as it was.
|
||||
func (s Settings) BeforeValidate(*gorm.DB) error {
|
||||
if AllowedFrontendURL(Text(s.FrontendURL)) {
|
||||
// The phrase keys of the other settings refusals.
|
||||
const (
|
||||
errFileSize = "golem15.sitemap::lang.errors.file_size"
|
||||
errURLScheme = "golem15.sitemap::lang.errors.url_scheme"
|
||||
errLastmod = "golem15.sitemap::lang.errors.lastmod"
|
||||
)
|
||||
|
||||
// Validate answers every problem Problems finds as a validation error on
|
||||
// its field; the settings controller calls it before a save, so the save
|
||||
// answers 422 and the row is left as it was.
|
||||
func (s Settings) Validate() error {
|
||||
problems := s.Problems()
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &cabana.ValidationError{Details: map[string]any{"frontend_url": []string{errFrontendURL}}}
|
||||
details := make(map[string]any, len(problems))
|
||||
for field, keys := range problems {
|
||||
details[field] = keys
|
||||
}
|
||||
return &cabana.ValidationError{Details: details}
|
||||
}
|
||||
|
||||
// Problems lists the settings' refusals by field (UI-SPEC C26): a site
|
||||
// address that AllowedFrontendURL refuses (site_url and the Phase 17
|
||||
// frontend_url), a file size outside 1..MaxFileSize and an additional
|
||||
// address that is not a path or an http(s) URL (extra_urls.<i>.url) or
|
||||
// whose lastmod is not a date (extra_urls.<i>.lastmod).
|
||||
func (s Settings) Problems() map[string][]string {
|
||||
out := map[string][]string{}
|
||||
if !AllowedFrontendURL(Text(s.SiteURL)) {
|
||||
out["site_url"] = []string{errFrontendURL}
|
||||
}
|
||||
if !AllowedFrontendURL(Text(s.FrontendURL)) {
|
||||
out["frontend_url"] = []string{errFrontendURL}
|
||||
}
|
||||
if s.FileSize < 1 || s.FileSize > MaxFileSize {
|
||||
out["file_size"] = []string{errFileSize}
|
||||
}
|
||||
for i, e := range s.Extra() {
|
||||
if !AllowedAddress(strings.TrimSpace(e.URL)) {
|
||||
out["extra_urls."+strconv.Itoa(i)+".url"] = []string{errURLScheme}
|
||||
}
|
||||
if mod := strings.TrimSpace(e.LastMod); mod != "" && !ValidDate(mod) {
|
||||
out["extra_urls."+strconv.Itoa(i)+".lastmod"] = []string{errLastmod}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AllowedFrontendURL reports whether u may be the frontend_url setting: the
|
||||
@@ -180,3 +214,26 @@ func (s Settings) Priority(key string) string {
|
||||
func init() {
|
||||
Register(Settings{})
|
||||
}
|
||||
|
||||
// dateLayouts are the lastmod forms an additional address may carry.
|
||||
var dateLayouts = []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"}
|
||||
|
||||
// ParseDate reads a lastmod as the settings form or an import gives it
|
||||
// (YYYY-MM-DD, "YYYY-MM-DD hh:mm:ss" or RFC 3339; UTC unless it names a
|
||||
// zone); nil when empty or unreadable.
|
||||
func ParseDate(s string) *time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range dateLayouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
t = t.UTC()
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidDate reports whether s is a lastmod ParseDate reads.
|
||||
func ValidDate(s string) bool { return ParseDate(s) != nil }
|
||||
|
||||
@@ -2,67 +2,49 @@
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
#
|
||||
# The WinterCMS form's nginx hint and Priority Tiers section are field
|
||||
# comments and README text here, and the tier fields always show (the
|
||||
# trigger on enable_priority_tiers is not ported).
|
||||
# Sitemap settings (UI-SPEC C26): Files, Content types (one block per
|
||||
# signpost content type, added by the plugin's form extension after Files)
|
||||
# and Additional addresses. A repeater row takes no date picker, so an
|
||||
# additional address's lastmod is a text field read as YYYY-MM-DD.
|
||||
|
||||
fields:
|
||||
frontend_url:
|
||||
label: golem15.sitemap::lang.settings.frontend_url
|
||||
comment: golem15.sitemap::lang.settings.frontend_url_comment
|
||||
site_url:
|
||||
label: golem15.sitemap::lang.settings.site_url
|
||||
comment: golem15.sitemap::lang.settings.site_url_comment
|
||||
type: text
|
||||
span: left
|
||||
tab: golem15.sitemap::lang.settings.tab_files
|
||||
|
||||
frontend_default_locale:
|
||||
label: golem15.sitemap::lang.settings.frontend_default_locale
|
||||
comment: golem15.sitemap::lang.settings.frontend_default_locale_comment
|
||||
type: text
|
||||
file_size:
|
||||
label: golem15.sitemap::lang.settings.file_size
|
||||
comment: golem15.sitemap::lang.settings.file_size_comment
|
||||
type: number
|
||||
default: 1000
|
||||
span: right
|
||||
tab: golem15.sitemap::lang.settings.tab_files
|
||||
|
||||
updates_manifest_path:
|
||||
label: golem15.sitemap::lang.settings.updates_manifest_path
|
||||
comment: golem15.sitemap::lang.settings.updates_manifest_path_comment
|
||||
type: text
|
||||
_files:
|
||||
type: partial
|
||||
path: sitemap_files
|
||||
span: full
|
||||
tab: golem15.sitemap::lang.settings.tab_files
|
||||
|
||||
enable_priority_tiers:
|
||||
label: golem15.sitemap::lang.settings.enable_priority_tiers
|
||||
comment: golem15.sitemap::lang.settings.enable_priority_tiers_comment
|
||||
type: checkbox
|
||||
default: true
|
||||
extra_urls:
|
||||
label: golem15.sitemap::lang.settings.extra_urls
|
||||
comment: golem15.sitemap::lang.settings.extra_urls_comment
|
||||
type: repeater
|
||||
prompt: golem15.sitemap::lang.settings.extra_urls_add
|
||||
titleFrom: url
|
||||
span: full
|
||||
|
||||
priority_homepage:
|
||||
label: golem15.sitemap::lang.settings.priority_homepage
|
||||
comment: golem15.sitemap::lang.settings.priority_homepage_comment
|
||||
type: text
|
||||
default: '1.0'
|
||||
span: auto
|
||||
|
||||
priority_services:
|
||||
label: golem15.sitemap::lang.settings.priority_services
|
||||
comment: golem15.sitemap::lang.settings.priority_services_comment
|
||||
type: text
|
||||
default: '0.8'
|
||||
span: auto
|
||||
|
||||
priority_blog:
|
||||
label: golem15.sitemap::lang.settings.priority_blog
|
||||
comment: golem15.sitemap::lang.settings.priority_blog_comment
|
||||
type: text
|
||||
default: '0.6'
|
||||
span: auto
|
||||
|
||||
priority_categories:
|
||||
label: golem15.sitemap::lang.settings.priority_categories
|
||||
comment: golem15.sitemap::lang.settings.priority_categories_comment
|
||||
type: text
|
||||
default: '0.4'
|
||||
span: auto
|
||||
|
||||
priority_legal:
|
||||
label: golem15.sitemap::lang.settings.priority_legal
|
||||
comment: golem15.sitemap::lang.settings.priority_legal_comment
|
||||
type: text
|
||||
default: '0.2'
|
||||
span: auto
|
||||
tab: golem15.sitemap::lang.settings.tab_extra
|
||||
form:
|
||||
fields:
|
||||
url:
|
||||
label: golem15.sitemap::lang.settings.extra_url
|
||||
type: text
|
||||
span: left
|
||||
lastmod:
|
||||
label: golem15.sitemap::lang.settings.extra_url_lastmod
|
||||
comment: golem15.sitemap::lang.settings.extra_url_lastmod_comment
|
||||
type: text
|
||||
span: right
|
||||
|
||||
@@ -10,9 +10,6 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// cabana calls the hook on a settings save through this interface.
|
||||
var _ lagoon.HasBeforeValidate = (*models.Settings)(nil)
|
||||
|
||||
// TestAllowedFrontendURL pins the one definition of a frontend_url that may
|
||||
// be saved, imported and applied (T-17-15): empty, or an absolute http(s)
|
||||
// URL with a host and without whitespace, control characters, a double
|
||||
@@ -67,31 +64,59 @@ func TestAllowedFrontendURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsBeforeValidate pins the save check of T-17-15: the hook cabana
|
||||
// calls on a settings save passes a null, empty or accepted frontend_url and
|
||||
// answers a validation error keyed frontend_url for any other value.
|
||||
func TestSettingsBeforeValidate(t *testing.T) {
|
||||
// TestSettingsValidate pins the save checks of the settings singleton
|
||||
// (UI-SPEC C26, T-33-12): the check the controller runs before a save passes
|
||||
// a null, empty or accepted site_url, a file size from 1 to 50 000 and
|
||||
// additional addresses that are paths or http(s) URLs, and answers a
|
||||
// validation error keyed by the field for any other value.
|
||||
func TestSettingsValidate(t *testing.T) {
|
||||
text := func(s string) *string { return &s }
|
||||
for _, url := range []*string{nil, text(""), text("https://www.ex.pl/")} {
|
||||
if err := (&models.Settings{FrontendURL: url}).BeforeValidate(nil); err != nil {
|
||||
t.Errorf("BeforeValidate(%q) = %v, want nil", models.Text(url), err)
|
||||
ok := func(s models.Settings) models.Settings {
|
||||
if s.FileSize == 0 {
|
||||
s.FileSize = 1000
|
||||
}
|
||||
return s
|
||||
}
|
||||
extra := func(urls ...string) lagoon.Jsonable[[]models.ExtraURL] {
|
||||
rows := []models.ExtraURL{}
|
||||
for _, u := range urls {
|
||||
rows = append(rows, models.ExtraURL{URL: u})
|
||||
}
|
||||
return lagoon.Jsonable[[]models.ExtraURL]{Data: rows, Valid: true}
|
||||
}
|
||||
for name, s := range map[string]models.Settings{
|
||||
"null site_url": ok(models.Settings{}),
|
||||
"empty site_url": ok(models.Settings{SiteURL: text("")}),
|
||||
"site_url": ok(models.Settings{SiteURL: text("https://www.ex.pl/")}),
|
||||
"file size 1": {FileSize: 1},
|
||||
"file size 50 000": {FileSize: 50000},
|
||||
"extra addresses": ok(models.Settings{ExtraURLs: extra("/", "/o-nas?x=1&y=2", "https://www.ex.pl/kontakt")}),
|
||||
} {
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Errorf("%s: Validate = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
want := map[string]any{"frontend_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}
|
||||
for _, url := range []string{
|
||||
"javascript:alert(document.domain)//",
|
||||
`https://x"/><y a="`,
|
||||
"0",
|
||||
" https://www.ex.pl",
|
||||
for name, c := range map[string]struct {
|
||||
s models.Settings
|
||||
want map[string]any
|
||||
}{
|
||||
"javascript site_url": {ok(models.Settings{SiteURL: text("javascript:alert(document.domain)//")}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
|
||||
"attribute site_url": {ok(models.Settings{SiteURL: text(`https://x"/><y a="`)}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
|
||||
"path site_url": {ok(models.Settings{SiteURL: text("/x")}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
|
||||
"file size 0": {models.Settings{}, map[string]any{"file_size": []string{"golem15.sitemap::lang.errors.file_size"}}},
|
||||
"file size 50 001": {models.Settings{FileSize: 50001}, map[string]any{"file_size": []string{"golem15.sitemap::lang.errors.file_size"}}},
|
||||
"javascript address": {ok(models.Settings{ExtraURLs: extra("/", "javascript:alert(1)")}), map[string]any{"extra_urls.1.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
|
||||
"protocol-relative": {ok(models.Settings{ExtraURLs: extra("//evil.test/x")}), map[string]any{"extra_urls.0.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
|
||||
"address with a space": {ok(models.Settings{ExtraURLs: extra("/a b")}), map[string]any{"extra_urls.0.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
|
||||
} {
|
||||
err := (&models.Settings{FrontendURL: &url}).BeforeValidate(nil)
|
||||
err := c.s.Validate()
|
||||
var invalid *cabana.ValidationError
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Errorf("BeforeValidate(%q) = %v, want a *cabana.ValidationError", url, err)
|
||||
t.Errorf("%s: Validate = %v, want a *cabana.ValidationError", name, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(invalid.Details, want) {
|
||||
t.Errorf("BeforeValidate(%q) details = %v, want %v", url, invalid.Details, want)
|
||||
if !reflect.DeepEqual(invalid.Details, c.want) {
|
||||
t.Errorf("%s: details = %v, want %v", name, invalid.Details, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
models/type_setting.go
Normal file
27
models/type_setting.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// TypeSetting is the sitemap's setting of one signpost content type on
|
||||
// golem15_sitemap_types, edited on the settings singleton's Content types
|
||||
// tab: whether its records are listed, the file name part of its group
|
||||
// (sitemap-<slug>-<n>.xml; "" means the default) and its heading per locale
|
||||
// in the HTML sitemap. A type without a row is listed under its default
|
||||
// slug.
|
||||
type TypeSetting struct {
|
||||
TypeKey string `gorm:"column:type_key;primaryKey"`
|
||||
Include bool `gorm:"column:include"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
Titles lagoon.Jsonable[map[string]string] `gorm:"column:titles"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (TypeSetting) TableName() string { return "golem15_sitemap_types" }
|
||||
|
||||
func init() {
|
||||
Register(TypeSetting{})
|
||||
}
|
||||
@@ -131,3 +131,176 @@ func TestSitemapIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permSitemap is the permission of the sitemap settings.
|
||||
const permSitemap = "golem15.sitemap.access_definitions"
|
||||
|
||||
// riverJobs counts the queued jobs of kind.
|
||||
func (e *sitemapEnv) riverJobs(t *testing.T, kind string) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := e.gdb.Raw(`SELECT COUNT(*) FROM river_job WHERE kind = ?`, kind).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestSettingsSingleton drives the settings singleton (UI-SPEC C26): one
|
||||
// tile opens it, the form has the Files, Content types and Additional
|
||||
// addresses tabs, a save stores the values and dispatches a rebuild, the
|
||||
// errors.* refusals answer 422 and leave the row as it was, a type switched
|
||||
// off leaves the files with reason type and an additional address is listed
|
||||
// in the pages group with its lastmod; S1 shows files.empty before the
|
||||
// first build, files.building while the asked-for rebuild has not run, and
|
||||
// the files with the left-out counts after it.
|
||||
func TestSettingsSingleton(t *testing.T) {
|
||||
e := bootIndex(t)
|
||||
token := e.admin(t, permSitemap)
|
||||
const base = "/golem15/sitemap/settings"
|
||||
base0 := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
|
||||
contentStore.put(pageType, page1(1, "a", base0))
|
||||
contentStore.put(offerType, fakeRecord{ID: 1, Published: true, LastMod: base0, Locales: []pact.ContentLocale{{Locale: "en", URL: "/offers/x", Title: "Offer"}}})
|
||||
|
||||
// One tile, linked to the singleton; the Phase 17 tiles are gone.
|
||||
rec := e.call(t, http.MethodGet, "/settings", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"controller":"golem15.sitemap.settings"`) ||
|
||||
strings.Contains(rec.Body.String(), "golem15.sitemap.definitions") || strings.Contains(rec.Body.String(), "sitemap_settings") {
|
||||
t.Fatalf("settings index: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = e.call(t, http.MethodGet, base+"/singleton", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"id":1`) {
|
||||
t.Fatalf("GET singleton: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = e.call(t, http.MethodGet, base+"/schema/form", token, nil)
|
||||
schema := rec.Body.String()
|
||||
if rec.Code != http.StatusOK || !strings.Contains(schema, `"singleton":true`) {
|
||||
t.Fatalf("form schema: %d %s", rec.Code, schema)
|
||||
}
|
||||
for _, want := range []string{`"site_url"`, `"file_size"`, `"extra_urls"`, `"type_acme_page_include"`, `"type_acme_page_slug"`, `"type_acme_page_title"`, `"type_acme_offer_include"`, "Files", "Content types", "Additional addresses"} {
|
||||
if !strings.Contains(schema, want) {
|
||||
t.Errorf("form schema lacks %s", want)
|
||||
}
|
||||
}
|
||||
files := func() string {
|
||||
t.Helper()
|
||||
rec := e.call(t, http.MethodGet, base+"/partials/sitemap_files", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("S1: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
if s1 := files(); !strings.Contains(s1, "No files yet.") || strings.Contains(s1, "Rebuilding now.") {
|
||||
t.Fatalf("S1 before the first build:\n%s", s1)
|
||||
}
|
||||
|
||||
// Refusals leave the row as it was.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
field string
|
||||
}{
|
||||
{"file size 0", map[string]any{"file_size": 0}, "file_size"},
|
||||
{"file size 50 001", map[string]any{"file_size": 50001}, "file_size"},
|
||||
{"javascript site", map[string]any{"site_url": "javascript:alert(1)//"}, "site_url"},
|
||||
{"relative site", map[string]any{"site_url": "/x"}, "site_url"},
|
||||
{"bad address", map[string]any{"extra_urls": []map[string]any{{"url": "javascript:alert(1)", "lastmod": ""}}}, "extra_urls.0.url"},
|
||||
{"bad lastmod", map[string]any{"extra_urls": []map[string]any{{"url": "/x", "lastmod": "yesterday"}}}, "extra_urls.0.lastmod"},
|
||||
{"bad slug", map[string]any{"type_acme_page_slug": "Bad Slug"}, "type_acme_page_slug"},
|
||||
{"duplicate slug", map[string]any{"type_acme_page_slug": "things", "type_acme_offer_slug": "things"}, "type_acme_offer_slug"},
|
||||
{"reserved slug", map[string]any{"type_acme_page_slug": "images"}, "type_acme_page_slug"},
|
||||
} {
|
||||
rec := e.call(t, http.MethodPut, base+"/1", token, c.body)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) {
|
||||
t.Errorf("%s: %d %s", c.name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
var types int64
|
||||
e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_types`).Scan(&types)
|
||||
if types != 0 || e.riverJobs(t, classes.RebuildKind) != 0 {
|
||||
t.Fatalf("a refused save wrote %d type rows and queued %d rebuilds", types, e.riverJobs(t, classes.RebuildKind))
|
||||
}
|
||||
|
||||
// A save stores everything and dispatches one rebuild.
|
||||
rec = e.call(t, http.MethodPut, base+"/1", token, map[string]any{
|
||||
"site_url": "https://www.example.test/",
|
||||
"file_size": 500,
|
||||
"extra_urls": []map[string]any{{"url": "/kontakt", "lastmod": "2026-09-30"}, {"url": "/o-nas", "lastmod": ""}},
|
||||
"type_acme_page_include": true,
|
||||
"type_acme_page_slug": "",
|
||||
"type_acme_page_title": "Our pages",
|
||||
"type_acme_offer_include": false,
|
||||
"type_acme_offer_slug": "offers",
|
||||
"type_acme_offer_title": "",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if n := e.riverJobs(t, classes.RebuildKind); n != 1 {
|
||||
t.Fatalf("the save queued %d rebuild jobs, want 1", n)
|
||||
}
|
||||
var row struct {
|
||||
SiteURL string `gorm:"column:site_url"`
|
||||
FileSize int `gorm:"column:file_size"`
|
||||
}
|
||||
e.gdb.Raw(`SELECT site_url, file_size FROM golem15_sitemap_settings WHERE id = 1`).Scan(&row)
|
||||
if row.SiteURL != "https://www.example.test/" || row.FileSize != 500 {
|
||||
t.Fatalf("stored settings = %+v", row)
|
||||
}
|
||||
var stored []struct {
|
||||
TypeKey string `gorm:"column:type_key"`
|
||||
Include bool `gorm:"column:include"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
Titles string `gorm:"column:titles"`
|
||||
}
|
||||
e.gdb.Raw(`SELECT type_key, include, slug, titles::text AS titles FROM golem15_sitemap_types ORDER BY type_key`).Scan(&stored)
|
||||
if len(stored) != 2 || stored[0].TypeKey != offerType || stored[0].Include || stored[0].Slug != "" ||
|
||||
stored[1].TypeKey != pageType || !stored[1].Include || !strings.Contains(stored[1].Titles, "Our pages") {
|
||||
t.Fatalf("type rows = %+v", stored)
|
||||
}
|
||||
if s1 := files(); !strings.Contains(s1, "Rebuilding now.") {
|
||||
t.Fatalf("S1 while the rebuild is queued:\n%s", s1)
|
||||
}
|
||||
rec = e.call(t, http.MethodGet, base+"/1", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"type_acme_page_title"`) || !strings.Contains(rec.Body.String(), "Our pages") {
|
||||
t.Fatalf("GET settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
e.rebuild(t)
|
||||
index := e.get(t, "/sitemap.xml").Body.String()
|
||||
if got := locs(index); strings.Join(got, " ") != "https://www.example.test/sitemap-pages-1.xml" {
|
||||
t.Fatalf("index after the save = %v\n%s", got, index)
|
||||
}
|
||||
pages := e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()
|
||||
if got := locs(pages); strings.Join(got, " ") != "https://www.example.test/pages/a https://www.example.test/kontakt https://www.example.test/o-nas" {
|
||||
t.Fatalf("pages file = %v\n%s", got, pages)
|
||||
}
|
||||
if !strings.Contains(pages, "<loc>https://www.example.test/kontakt</loc><lastmod>2026-09-30T00:00:00Z</lastmod>") || !strings.Contains(pages, "<url><loc>https://www.example.test/o-nas</loc></url>") {
|
||||
t.Fatalf("additional addresses lack their lastmod:\n%s", pages)
|
||||
}
|
||||
var reason string
|
||||
e.gdb.Raw(`SELECT excluded_reason FROM golem15_sitemap_urls WHERE type_key = ?`, offerType).Scan(&reason)
|
||||
if reason != "type" {
|
||||
t.Fatalf("the switched-off type's row has reason %q", reason)
|
||||
}
|
||||
s1 := files()
|
||||
for _, want := range []string{"sitemap.xml", "sitemap-pages-1.xml", `"href":"/_sitemap/files/sitemap-pages-1.xml"`, `"href":"/sitemap.xml"`, "Left out of the sitemaps", "Content type turned off", "https://www.example.test/sitemap.xml"} {
|
||||
if !strings.Contains(s1, want) {
|
||||
t.Errorf("S1 lacks %q:\n%s", want, s1)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s1, "Rebuilding now.") || strings.Contains(s1, "No files yet.") {
|
||||
t.Errorf("S1 after the rebuild:\n%s", s1)
|
||||
}
|
||||
|
||||
// Without the permission every route answers 403.
|
||||
other := e.admin(t)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{http.MethodGet, base + "/singleton"},
|
||||
{http.MethodPut, base + "/1"},
|
||||
{http.MethodGet, base + "/partials/sitemap_files"},
|
||||
} {
|
||||
if rec := e.call(t, c.method, c.path, other, map[string]any{}); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s without permission: %d %s", c.method, c.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,153 +264,3 @@ func (e *sitemapEnv) admin(t *testing.T, permissions ...string) string {
|
||||
}
|
||||
return body.Data.AccessToken
|
||||
}
|
||||
|
||||
// TestSitemapAdminSmoke drives the definition singleton, the item repeater
|
||||
// with its dependent reference, the settings form and the permission.
|
||||
func TestSitemapAdminSmoke(t *testing.T) {
|
||||
e := bootSitemap(t)
|
||||
const perm = "golem15.sitemap.access_definitions"
|
||||
token := e.admin(t, perm)
|
||||
const base = "/golem15/sitemap/definitions"
|
||||
|
||||
// The singleton route creates the definition once.
|
||||
var ids []float64
|
||||
for range 2 {
|
||||
rec := e.call(t, http.MethodGet, base+"/singleton", token, nil)
|
||||
var body struct {
|
||||
Data struct {
|
||||
ID float64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.ID == 0 {
|
||||
t.Fatalf("GET singleton: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ids = append(ids, body.Data.ID)
|
||||
}
|
||||
var n int64
|
||||
e.gdb.Model(&models.Definition{}).Count(&n)
|
||||
if ids[0] != ids[1] || n != 1 {
|
||||
t.Fatalf("singleton ids %v, %d rows", ids, n)
|
||||
}
|
||||
id := fmt.Sprintf("%d", int(ids[0]))
|
||||
|
||||
// The form schema compiles: a singleton with the type options and the
|
||||
// dependent reference.
|
||||
rec := e.call(t, http.MethodGet, base+"/schema/form", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"singleton":true`) || !strings.Contains(rec.Body.String(), `"acme-post"`) || !strings.Contains(rec.Body.String(), `"dependsOn":"type"`) {
|
||||
t.Fatalf("form schema: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = e.call(t, http.MethodGet, base+"/fields/data.reference/dependent-options?values%5Btype%5D=acme-post", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"value":"7"`) {
|
||||
t.Fatalf("dependent options: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Saving rows stores PHP-keyed item JSON.
|
||||
rows := []map[string]any{
|
||||
{"type": "url", "url": "/", "reference": "", "urlPattern": "", "priority": "1.0", "changefreq": "always", "nesting": false},
|
||||
{"type": "acme-post", "url": "", "reference": "7", "urlPattern": "/n/:slug", "priority": "0.5", "changefreq": "weekly", "nesting": true},
|
||||
}
|
||||
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT rows: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var raw string
|
||||
if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, id).Scan(&raw).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const wantData = `[{"changefreq":"always","priority":"1.0","nesting":false,"type":"url","url":"/","reference":null,"cmsPage":null,"urlPattern":""},{"changefreq":"weekly","priority":"0.5","nesting":true,"type":"acme-post","url":"","reference":"7","cmsPage":null,"urlPattern":"/n/:slug"}]`
|
||||
if raw != wantData {
|
||||
t.Fatalf("stored data = %s", raw)
|
||||
}
|
||||
|
||||
// A reference not offered for its row type is a 422 on the row.
|
||||
rows[1]["reference"] = "99"
|
||||
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "data.1.reference") {
|
||||
t.Fatalf("PUT unknown reference: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A missing reference for a type that takes one (WR-03), a javascript:
|
||||
// URL and a protocol-relative pattern (CR-01) are 422s on their rows.
|
||||
for _, c := range []struct {
|
||||
row int
|
||||
key, value string
|
||||
field string
|
||||
message string
|
||||
}{
|
||||
{1, "reference", "", "data.1.reference", "golem15.sitemap::lang.item.reference_required"},
|
||||
{1, "reference", " ", "data.1.reference", ""},
|
||||
{0, "url", "javascript://%0aalert(1)%0a//x", "data.0.url", "golem15.sitemap::lang.errors.url_scheme"},
|
||||
{1, "urlPattern", "//evil.test/:slug", "data.1.urlPattern", "golem15.sitemap::lang.errors.url_pattern"},
|
||||
} {
|
||||
bad := []map[string]any{{}, {}}
|
||||
for i := range rows {
|
||||
for k, v := range rows[i] {
|
||||
bad[i][k] = v
|
||||
}
|
||||
}
|
||||
bad[1]["reference"] = "7"
|
||||
bad[c.row][c.key] = c.value
|
||||
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": bad})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) {
|
||||
t.Fatalf("PUT %s=%q: %d %s", c.key, c.value, rec.Code, rec.Body.String())
|
||||
}
|
||||
if c.message != "" && !strings.Contains(rec.Body.String(), c.message) {
|
||||
t.Fatalf("PUT %s=%q: want %s: %s", c.key, c.value, c.message, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, id).Scan(&raw).Error; err != nil || raw != wantData {
|
||||
t.Fatalf("a refused save changed the data: %s %v", raw, err)
|
||||
}
|
||||
|
||||
// The settings form saves the frontend URL.
|
||||
rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": "https://www.example.test", "enable_priority_tiers": true})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var st models.Settings
|
||||
if err := e.gdb.First(&st, 1).Error; err != nil || models.Text(st.FrontendURL) != "https://www.example.test" {
|
||||
t.Fatalf("settings = %+v %v", st, err)
|
||||
}
|
||||
|
||||
// T-17-15: a frontend URL that is not a plain http(s) URL is a 422 on
|
||||
// frontend_url and the stored row keeps its value.
|
||||
for _, value := range []string{"javascript:alert(document.domain)//", `https://x"/><y a="`, "https://www.example.test "} {
|
||||
rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": value, "enable_priority_tiers": true})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"frontend_url"`) || !strings.Contains(rec.Body.String(), "golem15.sitemap::lang.errors.frontend_url") {
|
||||
t.Fatalf("PUT settings frontend_url=%q: %d %s", value, rec.Code, rec.Body.String())
|
||||
}
|
||||
var kept models.Settings
|
||||
if err := e.gdb.First(&kept, 1).Error; err != nil || models.Text(kept.FrontendURL) != "https://www.example.test" {
|
||||
t.Fatalf("a refused save of frontend_url=%q changed the settings: %+v %v", value, kept, err)
|
||||
}
|
||||
}
|
||||
|
||||
rec = e.call(t, http.MethodGet, "/settings", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"controller":"golem15.sitemap.definitions"`) {
|
||||
t.Fatalf("settings index: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// An empty frontend URL saves: the sitemap keeps the backend URL.
|
||||
rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": "", "enable_priority_tiers": true})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT settings with an empty frontend_url: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var cleared models.Settings
|
||||
if err := e.gdb.First(&cleared, 1).Error; err != nil || models.Text(cleared.FrontendURL) != "" {
|
||||
t.Fatalf("settings after an empty frontend_url = %+v %v", cleared, err)
|
||||
}
|
||||
|
||||
// Without the permission every route answers 403.
|
||||
other := e.admin(t)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{http.MethodGet, base + "/singleton"},
|
||||
{http.MethodPut, base + "/" + id},
|
||||
{http.MethodGet, "/settings/sitemap_settings"},
|
||||
{http.MethodPut, "/settings/sitemap_settings"},
|
||||
} {
|
||||
if rec := e.call(t, c.method, c.path, other, map[string]any{}); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s without permission: %d %s", c.method, c.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
updates/202610280003_create_golem15_sitemap_types.go
Normal file
31
updates/202610280003_create_golem15_sitemap_types.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// createTypes creates golem15_sitemap_types: one row per signpost content
|
||||
// type the settings' Content types tab saved (include, file slug, HTML
|
||||
// sitemap heading per locale). A type without a row uses the defaults.
|
||||
var createTypes = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610280003_create_golem15_sitemap_types",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE golem15_sitemap_types (
|
||||
type_key TEXT PRIMARY KEY,
|
||||
include BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
slug TEXT NOT NULL DEFAULT '',
|
||||
titles JSONB NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS golem15_sitemap_types`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(createTypes...)
|
||||
}
|
||||
Reference in New Issue
Block a user