Files
sm-seo-plugin/controllers/api/notfound.go
Jakub Zych 7677d8d8af fix: the Open/Resolved and Chains filters apply an exact rule's slash, case and query options (WR-22)
coveredSQL and chainSQL compared the source literally, so /missing/ or
/missing?utm=x stayed open after /missing answered it, and /a -> /b/ was
no chain next to /b. Both now use one SQL form of the runtime's exact
match (prefiltered on the indexed source_key). Pattern and regex rules
remain with the per-row badge, which the list_not_found schema text now
says.
2026-10-27 15:41:18 +01:00

216 lines
7.6 KiB
Go

package api
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"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"
)
// 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)
}
}
// ListNotFoundInput is the query of GET /_seo/api/v1/notfound.
type ListNotFoundInput 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 contains"`
Status string `json:"status,omitempty" query:"status" jsonschema:"open or resolved: whether an exact redirect or 410 answers the address, with its case, trailing-slash and query options; an address only a pattern or regex rule answers stays open here but has resolved true in its row"`
}
// NotFoundIDInput names one 404 log entry.
type NotFoundIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the 404 log entry id"`
}
// RedirectNotFoundInput is POST /_seo/api/v1/notfound/{id}/redirect.
type RedirectNotFoundInput struct {
ID uint `json:"id" path:"id" jsonschema:"the 404 log entry id"`
Target string `json:"target" jsonschema:"where to send visitors: a path or an absolute URL"`
}
// NotFoundJSON is a 404 log entry in an answer, with the rule that
// answers it now (resolved) when there is one.
func NotFoundJSON(n models.NotFound, cover *models.Redirect) map[string]any {
out := map[string]any{
"id": n.ID, "url": n.URL, "hits": n.Hits, "first_seen": n.FirstSeen,
"last_hit": n.LastHit, "referer": n.Referer, "resolved": cover != nil,
}
if cover != nil {
out["redirect_id"] = cover.ID
}
return out
}
// ListNotFound serves GET /_seo/api/v1/notfound: the 404 log by hits,
// filtered by search text and open or resolved (an exact redirect or 410
// answers the address).
func ListNotFound(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
in := listParams(r)
q := db.Model(&models.NotFound{}).Order("hits DESC, id ASC")
if in.Search != "" {
q = q.Where("lower(url) LIKE ?", like(in.Search))
}
if s := r.URL.Query().Get("status"); s != "" {
q = (models.NotFound{}).FilterScope(models.FilterNotFoundStatus, q, s)
}
var rows []models.NotFound
total, err := paginate(q, in, &rows)
if err != nil {
return 0, nil, err
}
set, err := classes.LoadRedirectSet(db)
if err != nil {
return 0, nil, err
}
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
cover, err := classes.NotFoundCover(set, row.URL)
if err != nil {
return 0, nil, err
}
out = append(out, NotFoundJSON(row, cover))
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// resolveNotFound runs a 404 log action: a 301 to target, or a 410 when
// target is empty. An address a rule already answers is 409.
func resolveNotFound(service func() *classes.Service, gone bool) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var body struct {
Target string `json:"target"`
}
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
target := strings.TrimSpace(body.Target)
if gone {
target = ""
} else if target == "" {
return 0, nil, &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
"error": "validation_failed", "errors": map[string][]string{"target": {svc.PhraseCtx(ctx, "golem15.seo::lang.redirects.target_required", nil)}},
}}
}
var rule models.Redirect
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
entry, err := classes.LoadNotFound(tx, id)
if err != nil {
return err
}
rule, err = svc.ResolveNotFound(ctx, tx, entry, target)
return err
})
var covered *classes.NotFoundHasRuleError
if errors.As(err, &covered) {
return 0, nil, &apiError{Status: http.StatusConflict, Body: map[string]any{
"error": "has_rule", "message": svc.PhraseCtx(ctx, "golem15.seo::lang.notfound.has_rule", map[string]string{"url": covered.URL}),
}}
}
if err != nil {
return 0, nil, err
}
return http.StatusCreated, map[string]any{"data": RedirectJSON(rule)}, nil
})
}
// RedirectNotFound serves POST /_seo/api/v1/notfound/{id}/redirect: a
// 301 from the logged address to target (origin notfound).
func RedirectNotFound(service func() *classes.Service) http.HandlerFunc {
return resolveNotFound(service, false)
}
// GoneNotFound serves POST /_seo/api/v1/notfound/{id}/gone: a 410 for the
// logged address, so search engines drop it.
func GoneNotFound(service func() *classes.Service) http.HandlerFunc {
return resolveNotFound(service, true)
}
// DeleteNotFound serves DELETE /_seo/api/v1/notfound/{id}: the entry
// leaves the log until its next 404.
func DeleteNotFound(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
entry, err := classes.LoadNotFound(tx, id)
if err != nil {
return err
}
return svc.DeleteNotFound(ctx, tx, entry)
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"deleted": id}, nil
})
}