A decoded path such as /%00 or /%FF reached Postgres as text and answered 500 with an error log line per request. parseTarget refuses a path or query that is not valid UTF-8 or holds NUL (422), NotFoundPath does the same for the logged address, and a referer is cleaned of both.
186 lines
5.6 KiB
Go
186 lines
5.6 KiB
Go
package classes
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// Config keys of the 404 log (P-22).
|
||
const (
|
||
// NotFoundTokenConfigKey is the shared secret the frontend server
|
||
// sends as "Authorization: Bearer <token>" with each 404 report. Empty
|
||
// turns the report endpoint off (it answers 404).
|
||
NotFoundTokenConfigKey = "golem15.seo.notfound_token"
|
||
// NotFoundMaxRowsConfigKey caps the 404 log; new addresses beyond it
|
||
// are dropped (default DefaultNotFoundMaxRows).
|
||
NotFoundMaxRowsConfigKey = "golem15.seo.notfound_max_rows"
|
||
)
|
||
|
||
// The 404 report limits (P-22).
|
||
const (
|
||
DefaultNotFoundMaxRows = 10000
|
||
MaxNotFoundURLBytes = 2048
|
||
MaxRefererBytes = 1024
|
||
)
|
||
|
||
// ErrNotFoundURL refuses a reported address that is not a path or an
|
||
// http(s) URL, or is longer than MaxNotFoundURLBytes.
|
||
var ErrNotFoundURL = errors.New("golem15.seo: url must be a path or an absolute http(s) URL of at most 2048 bytes")
|
||
|
||
// NotFoundToken is the configured report token, "" when unset.
|
||
func (s *Service) NotFoundToken() string {
|
||
cfg := s.Config()
|
||
if cfg == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(cfg.String(NotFoundTokenConfigKey))
|
||
}
|
||
|
||
// NotFoundMaxRows is the configured row cap of the 404 log.
|
||
func (s *Service) NotFoundMaxRows() int64 {
|
||
if cfg := s.Config(); cfg != nil {
|
||
if n := cfg.Int(NotFoundMaxRowsConfigKey); n > 0 {
|
||
return int64(n)
|
||
}
|
||
}
|
||
return DefaultNotFoundMaxRows
|
||
}
|
||
|
||
// NotFoundPath turns a reported address into the logged one: the path
|
||
// with its query; an absolute URL loses its scheme and host.
|
||
func NotFoundPath(raw string) (string, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" || len(raw) > MaxNotFoundURLBytes {
|
||
return "", ErrNotFoundURL
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return "", ErrNotFoundURL
|
||
}
|
||
if u.IsAbs() {
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return "", ErrNotFoundURL
|
||
}
|
||
} else if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||
return "", ErrNotFoundURL
|
||
}
|
||
p := u.Path
|
||
if p == "" {
|
||
p = "/"
|
||
}
|
||
out := joinQuery(p, u.RawQuery)
|
||
if !safeText(out) {
|
||
return "", ErrNotFoundURL
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// capWarned keeps when the row-cap warning was last logged.
|
||
var capWarned struct {
|
||
sync.Mutex
|
||
at time.Time
|
||
}
|
||
|
||
// ReportNotFound records one 404 of rawURL (D-20, P-22): a known address
|
||
// gets one more hit, its last hit time and, when given, the referer; a new
|
||
// one is added unless the log holds NotFoundMaxRows rows, which is logged
|
||
// at most once an hour.
|
||
func (s *Service) ReportNotFound(ctx context.Context, rawURL, referer string) error {
|
||
path, err := NotFoundPath(rawURL)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
referer = strings.TrimSpace(strings.ToValidUTF8(strings.ReplaceAll(referer, "\x00", ""), ""))
|
||
if len(referer) > MaxRefererBytes {
|
||
referer = referer[:MaxRefererBytes]
|
||
for len(referer) > 0 && !utf8ValidTail(referer) {
|
||
referer = referer[:len(referer)-1]
|
||
}
|
||
}
|
||
db, err := s.DB(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := time.Now().UTC()
|
||
updates := map[string]any{"hits": gorm.Expr("hits + 1"), "last_hit": now}
|
||
if referer != "" {
|
||
updates["referer"] = referer
|
||
}
|
||
res := db.Model(&models.NotFound{}).Where("url = ?", path).Updates(updates)
|
||
if res.Error != nil || res.RowsAffected > 0 {
|
||
return res.Error
|
||
}
|
||
var count int64
|
||
if err := db.Model(&models.NotFound{}).Count(&count).Error; err != nil {
|
||
return err
|
||
}
|
||
if count >= s.NotFoundMaxRows() {
|
||
capWarned.Lock()
|
||
if time.Since(capWarned.at) >= time.Hour {
|
||
capWarned.at = time.Now()
|
||
log.Warn("golem15.seo: the 404 log is full, new addresses are dropped", "max_rows", s.NotFoundMaxRows(), "url", path)
|
||
}
|
||
capWarned.Unlock()
|
||
return nil
|
||
}
|
||
row := models.NotFound{URL: path, Hits: 1, FirstSeen: &now, LastHit: &now, Referer: referer}
|
||
return db.Clauses(clause.OnConflict{
|
||
Columns: []clause.Column{{Name: "url"}},
|
||
DoUpdates: clause.Assignments(map[string]any{"hits": gorm.Expr("golem15_seo_notfound.hits + 1"), "last_hit": now}),
|
||
}).Create(&row).Error
|
||
}
|
||
|
||
// utf8ValidTail reports whether s does not end inside a UTF-8 sequence.
|
||
func utf8ValidTail(s string) bool {
|
||
return strings.ToValidUTF8(s, "<22>") == s
|
||
}
|
||
|
||
// NotFoundCover returns the rule that answers url now (a redirect or a
|
||
// 410), nil when the address still answers 404: the Resolved state.
|
||
func NotFoundCover(set *RedirectSet, url string) (*models.Redirect, error) {
|
||
return CoveringRedirect(set, url)
|
||
}
|
||
|
||
// NotFoundHasRuleError refuses a 404 action on an address a rule already
|
||
// answers.
|
||
type NotFoundHasRuleError struct{ URL string }
|
||
|
||
func (e *NotFoundHasRuleError) Error() string {
|
||
return "golem15.seo: a redirect for " + e.URL + " already exists"
|
||
}
|
||
|
||
// ResolveNotFound creates the rule of a 404 log action inside tx (D-20): a
|
||
// 301 to target when target is set, else a 410, with origin notfound. It
|
||
// refuses an address a rule already answers (*NotFoundHasRuleError), and
|
||
// the redirect's own refusals (*ProblemsError, *LoopError).
|
||
func (s *Service) ResolveNotFound(ctx context.Context, tx *gorm.DB, entry models.NotFound, target string) (models.Redirect, error) {
|
||
set, err := LoadRedirectSet(tx)
|
||
if err != nil {
|
||
return models.Redirect{}, err
|
||
}
|
||
if rule, err := NotFoundCover(set, entry.URL); err != nil {
|
||
return models.Redirect{}, err
|
||
} else if rule != nil {
|
||
return models.Redirect{}, &NotFoundHasRuleError{URL: entry.URL}
|
||
}
|
||
r := models.NewRedirect()
|
||
r.Source, r.Origin = entry.URL, models.OriginNotFound
|
||
if strings.TrimSpace(target) == "" {
|
||
r.Status = models.Status410
|
||
} else {
|
||
r.Target = strings.TrimSpace(target)
|
||
}
|
||
if _, err := s.SaveRedirect(ctx, tx, &r); err != nil {
|
||
return r, err
|
||
}
|
||
return r, nil
|
||
}
|