- an additional address whose absolute URL a listed record already has is left out with the new reason duplicate (models.ReasonDuplicate) - the settings screen counts it under Already listed by a content type - a record left out by the filter or the type switch does not count
411 lines
13 KiB
Go
411 lines
13 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, models.ReasonDuplicate}
|
|
|
|
// 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())}
|
|
files, err := classes.SitemapFiles(db, app, settings, time.Now().UTC())
|
|
if 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 {
|
|
// The index counts addresses; the image and News files list
|
|
// some of them again.
|
|
if !strings.HasPrefix(f.Name, "sitemap-"+classes.ImagesGroup+"-") && f.Name != classes.NewsFile {
|
|
index.Count += f.Count
|
|
}
|
|
if f.LastMod != nil && (newest == nil || f.LastMod.After(*newest)) {
|
|
newest = f.LastMod
|
|
}
|
|
view.Files = append(view.Files, fileRow{Name: f.Name, URL: link(f.Name), Count: f.Count, LastMod: formatTime(f.LastMod)})
|
|
}
|
|
index.LastMod = formatTime(newest)
|
|
view.Files = append([]fileRow{index}, view.Files...)
|
|
}
|
|
byReason, err := classes.LeftOut(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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{}
|
|
)
|