Files
sm-sitemap-plugin/controllers/settings.go
Jakub Zych e02d0b173f feat: IndexNow key, key file and pings
- saving with IndexNow on generates a 32-hex key (crypto/rand);
  indexnow_new_key generates a new one, so the old key file answers 404
- GET /_sitemap/indexnow/{file} serves <key>.txt as text/plain while
  IndexNow is on; the SEO layer maps it to the site root
- after each rebuild in production the added, changed and removed
  addresses on the site host are queued as golem15.sitemap.indexnow jobs
  (at most 10 000 per request, five attempts)
- the job posts {host, key, urlList} to api.indexnow.org through a
  fetchguard client limited to that host and records the answer; 429 and
  5xx retry, 400, 403 and 422 do not
- the IndexNow tab shows the S2 status partial: key or no key, last
  notification or never, and the 403, 422 and 429 callouts
2026-11-04 12:26:47 +01:00

423 lines
14 KiB
Go

package controllers
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"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"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
const (
// SettingsControllerID is the cabana controller id of the sitemap
// settings singleton (UI-SPEC C26), linked from the sitemap settings
// tile.
SettingsControllerID = "golem15.sitemap.settings"
// PermissionAccessDefinitions guards the sitemap settings. The code is
// the Phase 17 (and WinterCMS) one, so existing roles keep their access.
PermissionAccessDefinitions = "golem15.sitemap.access_definitions"
)
// 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)
}
// FormVirtualFields names indexnow_new_key, the "generate a new key on
// save" checkbox, which is no column.
func (settingsAdminController) FormVirtualFields() []string {
return []string{"indexnow_new_key"}
}
// FormAfterUpdate generates the IndexNow key (on the first save with
// IndexNow on, or a new one when indexnow_new_key is checked) and
// 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, model any) error {
app := appOf(c.app)
tx, err := database(ctx, c.app)
if err != nil || app == nil {
return nil
}
if s, ok := model.(*models.Settings); ok && s != nil {
values, _ := cabana.VirtualFieldsFromContext(ctx)
if _, err := classes.EnsureKey(tx, s.IndexNowEnabled, boolOf(values["indexnow_new_key"])); err != nil {
return err
}
}
return classes.RequestRebuild(ctx, app, tx)
}
// boolOf reads a submitted checkbox value.
func boolOf(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 float64:
return b != 0
}
return false
}
// 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) {
switch name {
case "sitemap_files":
return c.filesData(ctx)
case "news_sources":
return c.newsSourcesData(ctx)
case "indexnow_status":
return c.indexNowData(ctx)
}
return nil, nil
}
// indexNowView is the view model of the S2 partial indexnow_status.
type indexNowView struct {
Key string
Last string
Callout string
Severity string
}
// indexNowData serves S2 (UI-SPEC C26): indexnow.key or indexnow.no_key,
// indexnow.last or indexnow.never, and the callout of the last answer: 403
// err_key (warning), 422 err_host (warning), 429 err_rate with the next
// attempt (plain) while one is pending.
func (c settingsAdminController) indexNowData(ctx context.Context) (any, error) {
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
}
st := classes.LoadIndexNowStatus(db, app, settings)
tr := translator(c.app)
view := indexNowView{}
if st.Key == "" {
view.Key = Phrase(ctx, tr, "golem15.sitemap::lang.indexnow.no_key", nil)
} else {
view.Key = Phrase(ctx, tr, "golem15.sitemap::lang.indexnow.key", map[string]string{"key": st.Key, "url": st.KeyURL})
}
if st.LastAt == nil {
view.Last = Phrase(ctx, tr, "golem15.sitemap::lang.indexnow.never", nil)
} else {
view.Last = Phrase(ctx, tr, "golem15.sitemap::lang.indexnow.last", map[string]string{
"time": formatTime(st.LastAt), "count": strconv.Itoa(st.LastCount), "status": strconv.Itoa(st.LastStatus),
})
}
switch st.LastStatus {
case http.StatusForbidden:
view.Callout, view.Severity = Phrase(ctx, tr, "golem15.sitemap::lang.indexnow.err_key", map[string]string{"url": st.KeyURL}), "warning"
case http.StatusUnprocessableEntity:
view.Callout, view.Severity = Phrase(ctx, tr, "golem15.sitemap::lang.indexnow.err_host", nil), "warning"
case http.StatusTooManyRequests:
if st.RetryAt != nil {
view.Callout = Phrase(ctx, tr, "golem15.sitemap::lang.indexnow.err_rate", map[string]string{"time": formatTime(st.RetryAt)})
}
}
return view, nil
}
// sourceRow is one row of the news_sources partial: the value to enter and
// what it lists.
type sourceRow struct {
Value string
Label string
}
// newsSourcesView is the view model of the news_sources partial.
type newsSourcesView struct {
Rows []sourceRow
}
// newsSourcesData lists the values the news_sources taglist takes
// (classes.NewsSources): each article type, then the categories its
// listed articles name, labelled "<type>: <title>".
func (c settingsAdminController) newsSourcesData(ctx context.Context) (any, error) {
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
}
var reg *signpost.Registry
if app != nil {
reg, _ = app.Lookup[*signpost.Registry]()
}
sources, err := classes.NewsSources(db, reg, models.Text(settings.DefaultLocale))
if err != nil {
return nil, err
}
tr := translator(c.app)
label := func(key string) string {
if reg != nil {
if typ, ok := reg.ContentType(key); ok && typ.Label != "" {
return Phrase(ctx, tr, typ.Label, nil)
}
}
return key
}
view := newsSourcesView{}
for _, src := range sources {
row := sourceRow{Value: src.Value}
if src.ID == "" {
row.Label = Phrase(ctx, tr, "golem15.sitemap::lang.news.source_type", map[string]string{"type": label(src.Type)})
} else {
title := src.Title
if title == "" {
title = src.ID
}
row.Label = label(src.Type) + ": " + title
}
view.Rows = append(view.Rows, row)
}
return view, nil
}
// filesData is the view of the S1 files partial.
func (c settingsAdminController) filesData(ctx context.Context) (any, error) {
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{}
_ pact.FormVirtualFields = settingsAdminController{}
)