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
This commit is contained in:
64
controllers/api/notfound.go
Normal file
64
controllers/api/notfound.go
Normal file
@@ -0,0 +1,64 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user