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
This commit is contained in:
Jakub Zych
2026-11-04 12:26:47 +01:00
parent a10ce2d9de
commit e02d0b173f
13 changed files with 780 additions and 18 deletions

View File

@@ -0,0 +1,49 @@
// Package api holds the sitemap plugin's HTTP handlers outside the admin:
// the IndexNow key file, the HTML sitemap API and the management API.
package api
import (
"net/http"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"gorm.io/gorm"
)
// KeyFilePattern is the {file} constraint of the key file route: an
// IndexNow key file name, <key>.txt.
const KeyFilePattern = `[A-Za-z0-9-]{8,128}\.txt`
// KeyFile serves GET /_sitemap/indexnow/{file}: the IndexNow key as
// text/plain when IndexNow is on and file is <key>.txt; any other name, a
// replaced key or IndexNow off answers 404 (D-31). The SEO layer serves it
// at the site root.
func KeyFile(app func() *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a := app()
if a == nil {
http.NotFound(w, r)
return
}
db, ok := a.Lookup[*gorm.DB]()
if !ok || db == nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
settings, err := classes.LoadSettings(db.WithContext(r.Context()))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
key := classes.ActiveKey(settings)
if key == "" || r.PathValue("file") != classes.KeyFileName(key) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, private")
w.Header().Set("X-Robots-Tag", "noindex")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(key))
}
}

View File

@@ -3,6 +3,9 @@ package controllers
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
@@ -84,18 +87,44 @@ func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any
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 {
// 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)
@@ -135,10 +164,62 @@ func (c settingsAdminController) PartialData(ctx context.Context, name string, _
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 {
@@ -337,4 +418,5 @@ var (
_ pact.FormBeforeUpdate = settingsAdminController{}
_ pact.FormAfterCreate = settingsAdminController{}
_ pact.FormAfterUpdate = settingsAdminController{}
_ pact.FormVirtualFields = settingsAdminController{}
)

View File

@@ -0,0 +1,15 @@
<div class="summer-callout" role="status">
<p class="summer-callout__text">{{ .Data.Key }}</p>
<p class="summer-callout__text">{{ .Data.Last }}</p>
</div>
{{- if .Data.Callout -}}
{{- if eq .Data.Severity "warning" -}}
<div class="summer-callout summer-callout--warning" role="alert">
<p class="summer-callout__text">{{ .Data.Callout }}</p>
</div>
{{- else -}}
<div class="summer-callout" role="status">
<p class="summer-callout__text">{{ .Data.Callout }}</p>
</div>
{{- end -}}
{{- end -}}