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 }