Files
Jakub Zych 2a05516036 feat: sitemap management API and MCP tools
- GET and PUT /_sitemap/api/v1/settings and POST /_sitemap/api/v1/rebuild
  resolve the administrator from a backend token, a personal API token or
  the MCP dispatch and require the sitemap permission (401/403 otherwise);
  bucket sitemap-api
- PUT changes the fields sent with the settings screen's checks (422
  validation_failed, nothing stored), merges the HTML sitemap addresses,
  writes content types, generates the IndexNow key and queues a rebuild
- GET answers the settings, the content types, the index files with their
  counts, the left-out counts, the IndexNow state and the News source
  values
- MCP tools sitemap_get_settings, sitemap_update_settings and
  sitemap_rebuild bound to those routes, listed with the permission
- the content type settings write and the file listing move to classes,
  shared by the form, S1, the index and the API; S1 and the index now list
  the image and News files
2026-11-04 12:38:35 +01:00

130 lines
4.3 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/wire"
)
// PermissionManage is the permission every management route requires: the
// sitemap settings permission of the admin screen.
const PermissionManage = controllers.PermissionAccessDefinitions
// tokenCover is the scope a personal API token must cover.
const tokenCover = "golem15.sitemap.*"
// maxBody caps a management request body.
const maxBody = 1 << 20
// The answers of the management API.
const (
errAuthenticationRequired = "Authentication required"
errInsufficientPerms = "Insufficient permissions"
errInternal = "Internal Server Error"
errInvalidBody = "The request body is invalid."
)
// ProblemsError is a refused write: phrase keys by field.
type ProblemsError struct {
Problems map[string][]string
}
func (e *ProblemsError) Error() string { return "golem15.sitemap: validation failed" }
// apiError is an error a handler answers with its own status and body.
type apiError struct {
Status int
Body any
}
func (e *apiError) Error() string { return http.StatusText(e.Status) }
// handler is a management endpoint: it returns the status and the body to
// answer, or an error the wrapper maps.
type handler func(ctx context.Context, app *backpack.App, r *http.Request) (int, any, error)
// manage wraps a management endpoint (D-10): it resolves the backend
// administrator from a backend JWT, a personal API token (covering
// golem15.sitemap.*) or the in-process MCP dispatch marker
// (cabana.ResolveBackend, so an MCP call runs as the linked
// administrator), refuses 401 without one and 403 without the sitemap
// permission, and answers a refused write 422
// {"error":"validation_failed","errors":{field:[message]}} in the request
// locale. Every answer carries X-Robots-Tag: noindex.
func manage(app func() *backpack.App, h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex")
a := app()
if a == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "The sitemap is not available"})
return
}
out, principal, err := cabana.ResolveBackend(a, r, tokenCover)
if err != nil || principal == nil {
if errors.Is(err, cabana.ErrTokenScope) {
wire.WriteJSON(w, http.StatusForbidden, map[string]string{"error": errInsufficientPerms})
return
}
wire.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": errAuthenticationRequired})
return
}
if !cabana.Allows(principal, []string{PermissionManage}) {
wire.WriteJSON(w, http.StatusForbidden, map[string]string{"error": errInsufficientPerms})
return
}
status, body, err := h(out.Context(), a, out)
if err != nil {
writeError(w, out.Context(), a, err)
return
}
wire.WriteJSON(w, status, body)
}
}
// writeError maps a handler error to its answer.
func writeError(w http.ResponseWriter, ctx context.Context, app *backpack.App, err error) {
var api *apiError
var problems *ProblemsError
switch {
case errors.As(err, &api):
wire.WriteJSON(w, api.Status, api.Body)
case errors.As(err, &problems):
tr, _ := app.Lookup[*phrasebook.Translator]()
messages := map[string][]string{}
for field, keys := range problems.Problems {
for _, key := range keys {
messages[field] = append(messages[field], controllers.Phrase(ctx, tr, key, nil))
}
}
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "validation_failed", "errors": messages})
default:
log.Error("golem15.sitemap: management API", "error", err)
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": errInternal})
}
}
// decode reads a JSON body of at most maxBody bytes into v; an empty body
// leaves v as it is.
func decode(r *http.Request, v any) error {
if r.Body == nil {
return nil
}
dec := json.NewDecoder(io.LimitReader(r.Body, maxBody+1))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{"error": errInvalidBody}}
}
return nil
}