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:
49
controllers/api/indexnow.go
Normal file
49
controllers/api/indexnow.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
)
|
||||
|
||||
15
controllers/settings/_indexnow_status.htm
Normal file
15
controllers/settings/_indexnow_status.htm
Normal 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 -}}
|
||||
Reference in New Issue
Block a user