(page-1)*perPage overflowed for a large page and sliced out of range. Export compares the page with the page count before multiplying, and the list endpoints cap the page number.
246 lines
8.1 KiB
Go
246 lines
8.1 KiB
Go
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, and the highest page number read
|
|
// (a later page is empty anyway).
|
|
const (
|
|
defaultPerPage = 50
|
|
maxPerPage = 200
|
|
maxPage = 1_000_000
|
|
)
|
|
|
|
// 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
|
|
}
|
|
// (Page-1)*PerPage must not overflow into a negative offset (WR-14).
|
|
if in.Page > maxPage {
|
|
in.Page = maxPage
|
|
}
|
|
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)) + "%"
|
|
}
|