package api import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) // PermissionManage is the one SEO permission every management route // requires (D-15). const PermissionManage = "golem15.seo.manage" // tokenCover is the scope a personal API token must cover. const tokenCover = "golem15.seo.*" // maxBody caps a management request body; an import has its own cap. const maxBody = 1 << 20 // The answers of the management API. const ( errAuthenticationRequired = "Authentication required" errInsufficientPerms = "Insufficient permissions" errNotFound = "Not Found" errInternal = "Internal Server Error" errInvalidBody = "The request body is invalid." ) // apiError is an error a management 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) } // Fail answers status with {"error": message}. func Fail(status int, message string) error { return &apiError{Status: status, Body: map[string]any{"error": message}} } // 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, svc *classes.Service, r *http.Request) (int, any, error) // manage wraps a management endpoint (D-10, D-15, P-24): it resolves the // backend administrator from a backend JWT, a personal API token 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 // golem15.seo.manage, and maps the service's typed refusals: a redirect // loop is 422 {"error":"redirect_loop","path":[...]}, field problems 422 // {"error":"validation_failed","errors":{...}}, a missing row 404. Every // answer carries X-Robots-Tag: noindex. func manage(service func() *classes.Service, h handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Robots-Tag", "noindex") svc := service() if svc == nil { wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"}) return } out, principal, err := cabana.ResolveBackend(svc.App(), 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(), svc, out) if err != nil { writeError(w, out.Context(), svc, err) return } if status == http.StatusNoContent { w.WriteHeader(status) return } wire.WriteJSON(w, status, body) } } // writeError maps a handler error to its answer. func writeError(w http.ResponseWriter, ctx context.Context, svc *classes.Service, err error) { var api *apiError var loop *classes.LoopError var problems *classes.ProblemsError switch { case errors.As(err, &api): wire.WriteJSON(w, api.Status, api.Body) case errors.As(err, &loop): wire.WriteJSON(w, http.StatusUnprocessableEntity, LoopBody(ctx, svc, loop)) case errors.As(err, &problems): wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{ "error": "validation_failed", "errors": ProblemMessages(ctx, svc, problems), }) case errors.Is(err, classes.ErrBadURL): wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": err.Error()}) case errors.Is(err, classes.ErrNoRecord), errors.Is(err, gorm.ErrRecordNotFound): wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": errNotFound}) default: log.Error("golem15.seo: management API", "error", err) wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": errInternal}) } } // LoopBody is the answer of a refused redirect loop (D-19): the error // code, the loop's path and the message in the request locale. func LoopBody(ctx context.Context, svc *classes.Service, loop *classes.LoopError) map[string]any { return map[string]any{ "error": "redirect_loop", "path": loop.Check.Path, "message": svc.LoopMessage(ctx, loop.Check), } } // ProblemMessages translates field problems in the request locale. func ProblemMessages(ctx context.Context, svc *classes.Service, problems *classes.ProblemsError) map[string][]string { out := map[string][]string{} for field, list := range problems.Problems { for _, p := range list { out[field] = append(out[field], svc.PhraseCtx(ctx, p.Key, p.Params)) } } return out } // decode reads a JSON body of at most limit bytes into v; an empty body // leaves v as it is. func decode(r *http.Request, limit int64, v any) error { if r.Body == nil { return nil } dec := json.NewDecoder(io.LimitReader(r.Body, limit+1)) if err := dec.Decode(v); err != nil { if errors.Is(err, io.EOF) { return nil } return Fail(http.StatusUnprocessableEntity, errInvalidBody) } return nil } // pathID reads the {name} segment as a positive id. func pathID(r *http.Request, name string) (uint, error) { n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue(name)), 10, 64) if err != nil || n == 0 { return 0, Fail(http.StatusNotFound, errNotFound) } return uint(n), nil } // write runs fn in one transaction on the application's database, with // the transaction on ctx (classes.WithTx) so every read of the service // sees the write. func write(ctx context.Context, svc *classes.Service, fn func(ctx context.Context, tx *gorm.DB) error) error { gdb, err := svc.GormDB() if err != nil { return err } return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error { return fn(classes.WithTx(ctx, tx), tx) }) } // read returns the database for a read handler. func read(ctx context.Context, svc *classes.Service) (*gorm.DB, error) { return svc.DB(ctx) } // The page sizes of the list endpoints. const ( defaultPerPage = 50 maxPerPage = 200 ) // ListInput is the paging and search query every list tool shares. type ListInput struct { Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"` PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"` Search string `json:"search,omitempty" query:"search" jsonschema:"text the address or value contains"` } // listParams reads page, per_page and search. func listParams(r *http.Request) ListInput { q := r.URL.Query() in := ListInput{Search: strings.TrimSpace(q.Get("search"))} in.Page, _ = strconv.Atoi(q.Get("page")) in.PerPage, _ = strconv.Atoi(q.Get("per_page")) if in.Page < 1 { in.Page = 1 } if in.PerPage < 1 { in.PerPage = defaultPerPage } if in.PerPage > maxPerPage { in.PerPage = maxPerPage } return in } // paginate counts q and loads one page of it into dest. func paginate(q *gorm.DB, in ListInput, dest any) (int64, error) { var total int64 if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil { return 0, err } if err := q.Offset((in.Page - 1) * in.PerPage).Limit(in.PerPage).Find(dest).Error; err != nil { return 0, err } return total, nil } // listBody is a list answer: the rows and the paging. func listBody(rows any, in ListInput, total int64) map[string]any { return map[string]any{"data": rows, "page": in.Page, "per_page": in.PerPage, "total": total} } // like is a case-insensitive LIKE pattern of text. func like(text string) string { r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) return "%" + strings.ToLower(r.Replace(text)) + "%" }