feat: route report endpoint

- POST /_seo/api/v1/registry/routes (public, bucket seo-notfound): the
  frontend server reports its static routes with the bearer token
  golem15.seo.route_report_token, else notfound_token (constant-time;
  both empty answer 404); at most 2000 paths
- unknown paths become registry pages (origin reported, kind page, a name
  from the last segment), known ones get reported_at; paths another
  content type owns and invalid ones are skipped; nothing is deleted or
  overwritten, and the report's time marks missing pages
This commit is contained in:
Jakub Zych
2026-11-04 13:22:12 +01:00
parent 9e802ec850
commit 26193ae4eb
7 changed files with 357 additions and 0 deletions

View File

@@ -3,16 +3,82 @@ package api
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// maxRouteReportBody caps the route report body: 2000 paths of 2048 bytes
// with their JSON quoting.
const maxRouteReportBody = classes.MaxReportedRoutes*(classes.MaxRouteBytes+8) + 1024
// routeReport is the route report body.
type routeReport struct {
Routes []string `json:"routes"`
}
// RouteReport serves POST /_seo/api/v1/registry/routes (D-33, P33-20): the
// frontend server reports its static routes with "Authorization: Bearer
// <token>", golem15.seo.route_report_token (else notfound_token), compared
// in constant time. Unknown paths become registry pages (origin reported),
// known ones get reported_at; nothing is deleted or overwritten. It answers
// 200 with the counts, 401 for a missing or wrong token, 404 when no token
// is configured (the report is off), and 422 for a malformed body or more
// than 2000 paths.
func RouteReport(service func() *classes.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
svc := service()
if svc == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"})
return
}
want := svc.RouteReportToken()
if want == "" {
wire.WriteJSON(w, http.StatusNotFound, map[string]string{"error": errNotFound})
return
}
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(strings.TrimSpace(got)), []byte(want)) != 1 {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
return
}
var in routeReport
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRouteReportBody))
if err := dec.Decode(&in); err != nil {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": errInvalidBody})
return
}
if len(in.Routes) > classes.MaxReportedRoutes {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "too_many_routes", "max": classes.MaxReportedRoutes})
return
}
var out classes.RouteReport
err := write(r.Context(), svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
out, err = svc.ReportRoutes(ctx, tx, in.Routes)
return err
})
if err != nil {
if errors.Is(err, classes.ErrTooManyRoutes) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "too_many_routes", "max": classes.MaxReportedRoutes})
return
}
log.Error("golem15.seo: route report", "error", err)
wire.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": errInternal})
return
}
wire.WriteJSON(w, http.StatusOK, out)
}
}
// ListPagesInput is the query of GET /_seo/api/v1/pages.
type ListPagesInput struct {
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`