Files
sm-seo-plugin/controllers/api/notfound.go
Jakub Zych 6eb6cb7a4d feat: 404 log and report endpoint
- POST /_seo/api/v1/notfound with the golem15.seo.notfound_token bearer
  (constant-time, off when unset), URL and referer caps, a row cap with
  an hourly warning, throttle bucket seo-notfound
- golem15.seo.notfound list by hits with Resolved rows, a preview with the
  resolved hint, a prompted 301 and a 410 record action, a bulk 410 that
  refuses a selection with a covered address
- every /_seo/api/v1 answer carries X-Robots-Tag: noindex
2026-10-27 11:35:25 +01:00

65 lines
2.2 KiB
Go

package api
import (
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/wire"
)
// maxReportBody caps the 404 report body: the URL and the referer fit
// with room to spare.
const maxReportBody = 8 << 10
// notFoundReport is the 404 report body.
type notFoundReport struct {
URL string `json:"url"`
Referer string `json:"referer"`
}
// NotFound serves POST /_seo/api/v1/notfound (D-20, P-22): the frontend
// server reports a 404 with "Authorization: Bearer <token>", the configured
// golem15.seo.notfound_token, compared in constant time. It answers 204,
// 401 for a missing or wrong token, 404 when no token is configured (the
// endpoint is off), and 422 for a body without a path or http(s) URL of at
// most 2048 bytes.
func NotFound(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.NotFoundToken()
if want == "" {
wire.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "Not Found"})
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 notFoundReport
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReportBody))
if err := dec.Decode(&in); err != nil {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "The request body is invalid."})
return
}
if err := svc.ReportNotFound(r.Context(), in.URL, in.Referer); err != nil {
if errors.Is(err, classes.ErrNotFoundURL) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
log.Error("golem15.seo: 404 report", "error", err)
wire.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal Server Error"})
return
}
w.WriteHeader(http.StatusNoContent)
}
}