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.
216 lines
7.6 KiB
Go
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
|
|
})
|
|
}
|