- 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
65 lines
2.2 KiB
Go
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)
|
|
}
|
|
}
|