package web
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"craftdesign.group/quarry/internal/errlog"
)
// clientError is what the browser posts. Every field is untrusted text
// from a page anybody can load, so every field is bounded here as well
// as in the script — the script's caps are a courtesy, this one is the
// guarantee.
type clientError struct {
Kind string `json:"kind"`
Message string `json:"message"`
Source string `json:"source"`
Line int `json:"line"`
Col int `json:"col"`
Stack string `json:"stack"`
URL string `json:"url"`
UA string `json:"ua"`
}
// clientErrorBudget rate-limits the ingest. The endpoint is anonymous by
// necessity — a script error on a public page happens to readers who
// have never signed in — which makes it the one write path a stranger
// can reach, so it gets a ceiling rather than trust. A fixed window is
// enough: the goal is to bound the damage, not to meter it precisely.
type clientErrorBudget struct {
mu sync.Mutex
window time.Time
n int
}
const (
// Per minute, across everybody. A genuinely broken page reports
// once per signature per load, so this is a lot of readers before
// it bites; a script pointed at the endpoint hits it immediately.
clientErrorsPerMinute = 120
maxClientErrorBody = 8 << 10
)
func (b *clientErrorBudget) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
if now := time.Now().Truncate(time.Minute); now.After(b.window) {
b.window, b.n = now, 0
}
if b.n >= clientErrorsPerMinute {
return false
}
b.n++
return true
}
// clip1 bounds one untrusted field and strips the control characters
// that would let a report forge extra lines in the plain-text journal.
func clip1(s string, n int) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return ' '
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
if len(s) > n {
return s[:n] + "…"
}
return s
}
// clientErrorHandler receives browser exceptions and journals them
// beside the server's own, so "what is broken" is one question with one
// answer rather than two surfaces nobody correlates.
//
// Anonymous by design, and therefore: same-origin only, size-capped,
// rate-limited, and every field bounded before it is stored.
// Exported because the API subtree is mounted whole ("/api/" ->
// apiRouter) by the binary, which shadows anything the web mux
// registers under it. The outer mux registers this directly, where a
// more specific pattern wins.
func ClientErrorHandler(self string) http.HandlerFunc {
return clientErrorHandler(self)
}
func clientErrorHandler(self string) http.HandlerFunc {
var budget clientErrorBudget
return func(w http.ResponseWriter, req *http.Request) {
// Same-origin. sendBeacon sends no custom headers, so this is
// the check available: it turns the endpoint away from being a
// general-purpose write anybody can drive from anywhere. It is
// a speed bump, not authentication — which is why the budget
// above exists too.
if o := req.Header.Get("Origin"); o != "" && !sameOrigin(o, req, self) {
w.WriteHeader(http.StatusNoContent)
return
}
if !budget.allow() {
// 204 rather than 429: the page must not react, retry, or
// report the failure of its own error reporter.
w.WriteHeader(http.StatusNoContent)
return
}
body, err := io.ReadAll(io.LimitReader(req.Body, maxClientErrorBody))
if err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
var in clientError
if json.Unmarshal(body, &in) != nil || strings.TrimSpace(in.Message) == "" {
w.WriteHeader(http.StatusNoContent)
return
}
kind := clip1(in.Kind, 24)
if kind == "" {
kind = "error"
}
msg := clip1(in.Message, 300)
// The page the reader was on is the "path" — it is what a
// maintainer needs to reproduce. The script's own file and line
// go in the trace, where a stack belongs.
path := clip1(in.URL, 200)
if path == "" {
path = "/"
}
var trace strings.Builder
if in.Source != "" {
fmt.Fprintf(&trace, "%s:%d:%d\n", clip1(in.Source, 300), in.Line, in.Col)
}
if in.Stack != "" {
trace.WriteString(clip1(in.Stack, 4000))
}
if in.UA != "" {
fmt.Fprintf(&trace, "\n\n%s", clip1(in.UA, 200))
}
// Surfaced as "browser" so the panel can tell at a glance which
// side of the wire a failure came from.
errlog.Default.ReportIn("browser", path, "", trace.String(),
fmt.Errorf("%s: %s", kind, msg))
w.WriteHeader(http.StatusNoContent)
}
}
// sameOrigin says whether an Origin header names this server. self is
// the configured public base URL when there is one; without it the
// request's own Host is the best available answer.
func sameOrigin(origin string, req *http.Request, self string) bool {
want := strings.TrimSuffix(self, "/")
if want == "" {
scheme := "http"
if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
want = scheme + "://" + req.Host
}
return strings.EqualFold(strings.TrimSuffix(origin, "/"), want)
}