- 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
130 lines
4.3 KiB
Go
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
|
|
}
|