diff --git a/backend/_example/memory_store/go.mod b/backend/_example/memory_store/go.mod index 08522df9..acb90065 100644 --- a/backend/_example/memory_store/go.mod +++ b/backend/_example/memory_store/go.mod @@ -18,7 +18,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2/v2 v2.2.2 // indirect - github.com/go-pkgz/rest v1.21.0 // indirect + github.com/go-pkgz/rest v1.22.0 // indirect github.com/go-pkgz/routegroup v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/backend/_example/memory_store/go.sum b/backend/_example/memory_store/go.sum index 9fabb51c..ebebc0cc 100644 --- a/backend/_example/memory_store/go.sum +++ b/backend/_example/memory_store/go.sum @@ -21,8 +21,8 @@ github.com/go-pkgz/jrpc v0.4.0 h1:oD7xiGrzDkndkuCjeHGugQXxbggLSV7O1QmHhoc5pYY= github.com/go-pkgz/jrpc v0.4.0/go.mod h1:JFoY3bRjRyx4M3CbEVDFQStMB1m2gmQ7OjqFK7q3kOo= github.com/go-pkgz/lgr v0.12.3 h1:QDug7kRkEsuQtruT9fNF5PVT2kZUqCDPc4GmsgS3fP8= github.com/go-pkgz/lgr v0.12.3/go.mod h1:lpCDgVvCIxBHZp8+sGCj9MPctIzKZyZ3QdE19ddqd54= -github.com/go-pkgz/rest v1.21.0 h1:Y/C4d/TpclJJDxqnH1RAcS6Hmox0RIReAlkwMcUWXK4= -github.com/go-pkgz/rest v1.21.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE= +github.com/go-pkgz/rest v1.22.0 h1:d3XFKlmAGBiU9MQER9/n46iXpyUr8IQUtfjU8JlqkkY= +github.com/go-pkgz/rest v1.22.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE= github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4= github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= diff --git a/backend/app/rest/api/middleware.go b/backend/app/rest/api/middleware.go index c55f4eff..8746c240 100644 --- a/backend/app/rest/api/middleware.go +++ b/backend/app/rest/api/middleware.go @@ -23,7 +23,7 @@ import ( // Access-Control-Allow-Origin (rather than a literal "*"), which browsers require // for credentialed cross-origin requests. func corsMiddleware() func(http.Handler) http.Handler { - cors := R.CORS( + return R.CORS( R.CorsAllowedOrigins("*"), R.CorsAllowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"), R.CorsAllowedHeaders("Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"), @@ -31,19 +31,6 @@ func corsMiddleware() func(http.Handler) http.Handler { R.CorsAllowCredentials(true), R.CorsMaxAge(300), ) - return func(next http.Handler) http.Handler { - corsHandler := cors(next) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // rest.CORS only sets "Vary: Origin"; for preflight also vary on the requested - // method and headers so caches/proxies don't reuse one preflight response across - // different requests (preserving the prior go-chi/cors behavior). - if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { - w.Header().Add("Vary", "Access-Control-Request-Method") - w.Header().Add("Vary", "Access-Control-Request-Headers") - } - corsHandler.ServeHTTP(w, r) - }) - } } // timeout returns a middleware matching chi's middleware.Timeout: it sets a diff --git a/backend/go.mod b/backend/go.mod index 292996fe..6d88e502 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,7 +13,7 @@ require ( github.com/go-pkgz/lgr v0.12.3 github.com/go-pkgz/notify v1.3.0 github.com/go-pkgz/repeater/v2 v2.2.0 - github.com/go-pkgz/rest v1.21.0 + github.com/go-pkgz/rest v1.22.0 github.com/go-pkgz/routegroup v1.6.0 github.com/go-pkgz/syncs v1.3.2 github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/backend/go.sum b/backend/go.sum index cb4f2d15..cf6ffbe3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -60,8 +60,8 @@ github.com/go-pkgz/repeater v1.2.0 h1:oJFvjyKdTDd5RCzpzxlzYIZFFj6Zfl17rE1aUfu6Uj github.com/go-pkgz/repeater v1.2.0/go.mod h1:vypP6xamA53MFmafnGUucqOmALKk36xgKu2hSG73LHM= github.com/go-pkgz/repeater/v2 v2.2.0 h1:8nZR/NaknmLfx2YMHbr78u9OL4Xj+8+romm9dz4FpMg= github.com/go-pkgz/repeater/v2 v2.2.0/go.mod h1:RgX5vUbLKq7PV82QUDP5pFbQS1os4Z+U9XzKymK23A8= -github.com/go-pkgz/rest v1.21.0 h1:Y/C4d/TpclJJDxqnH1RAcS6Hmox0RIReAlkwMcUWXK4= -github.com/go-pkgz/rest v1.21.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE= +github.com/go-pkgz/rest v1.22.0 h1:d3XFKlmAGBiU9MQER9/n46iXpyUr8IQUtfjU8JlqkkY= +github.com/go-pkgz/rest v1.22.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE= github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4= github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-pkgz/syncs v1.3.2 h1:gmioASlJNy3gNosPlgvWOM2QP0Hdjzn2u+/sUShgd8E= diff --git a/backend/vendor/github.com/go-pkgz/rest/CLAUDE.md b/backend/vendor/github.com/go-pkgz/rest/CLAUDE.md new file mode 100644 index 00000000..cb942c24 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/rest/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`github.com/go-pkgz/rest` is a library of HTTP middlewares and small helpers for REST services. It is not an application — there is no `main`, no server, no CLI. It is consumed by other projects (remark42, etc.). Keep the public API stable and minimal. + +Dependencies are deliberately tiny: only `stretchr/testify` (tests) and `golang.org/x/crypto` (Argon2/bcrypt for BasicAuth). Do not add a routing framework or logging library dependency — middlewares are plain stdlib and must stay router-agnostic. + +## Commands + +- Test everything: `go test ./...` +- Single test: `go test -run TestName ./...` (e.g. `go test -run TestPing ./...`) +- Race + coverage (mirrors CI): `TZ="America/Chicago" go test -timeout=60s -race -covermode=atomic ./...` +- Lint (run from repo root): `golangci-lint run --max-issues-per-linter=0 --max-same-issues=0` + +Time-parsing tests (`ParseFromTo`) are timezone-sensitive; CI runs with `TZ=America/Chicago`. Set it locally if a from/to test behaves oddly. + +## Architecture + +Three packages: +- **`rest`** (root) — all middlewares plus JSON/error/file-server helpers. +- **`logger`** — request-logging middleware, split out so it can be wired to any backend via the `logger.Backend` interface (`Logf(format, args...)`). Configured with functional options (`logger.New(logger.Prefix(...), logger.WithBody, ...)`). +- **`realip`** — `realip.Get(r)` extracts the client IP from proxy headers. Used by both `rest.RealIP` and the `logger` package. Only public IPs are accepted from headers. + +### Middleware conventions + +Every middleware is a `func(http.Handler) http.Handler` (or a `func(...) func(http.Handler) http.Handler` when it takes config). This is the chi/stdlib-compatible shape — match it for any new middleware. Some middlewares short-circuit the chain (`Ping`, `Health`, metrics) by writing a response and returning without calling the next handler. + +Configurable middlewares use the **functional-options pattern**, not option structs: +- `CORS(CorsAllowedOrigins(...), CorsAllowCredentials(true), ...)` — options named `CorsXxx`. +- `Secure(SecFrameOptions(...), SecHSTS(...), ...)` — options named `SecXxx`, plus `SecAllHeaders()` convenience. +- `logger.New(logger.Prefix(...), ...)` — options in `logger/options.go`. + +When adding an option to one of these, follow the existing prefix/naming and keep defaults sensible so calling the constructor with no options is safe. + +### CSRF build-tag split (important) + +CSRF protection has **two implementations behind one identical public API**, selected by Go version: +- `csrf_go125.go` (`//go:build go1.25`) — thin wrapper over stdlib `http.CrossOriginProtection`. +- `csrf.go` (`//go:build !go1.25`) — self-contained equivalent for older Go. + +`NewCrossOriginProtection`, `AddTrustedOrigin`, `AddBypassPattern`, `SetDenyHandler`, `Check`, `Handler` must exist with the **same signatures and behavior in both files**. When you change the CSRF API or behavior, edit both files and keep `csrf_test.go` passing under both build tags. go.mod targets go 1.24, so by default the `!go1.25` path compiles unless building with a 1.25 toolchain. + +### Helpers + +`rest.go` holds the JSON render/encode/decode helpers (`RenderJSON`, `EncodeJSON`/`DecodeJSON` generics, `RenderJSONWithHTML`) and `ParseFromTo`. `httperrors.go` has `SendErrorJSON`/`NewErrorLogger`. `file_server.go` has `FileServer` (directory listing disabled by design). `benchmarks.go` keeps an in-memory ring of up to 900 per-second data points (15 min) queried via `Stats(duration)`. + +## Conventions + +- **One test file per source file**: `foo.go` → `foo_test.go` only. Table-driven with testify. Note `depricattion.go`/`depricattion_test.go` is misspelled but is the real filename — don't "fix" it without intent, it's the established path. +- After changing or adding a middleware/helper, update `README.md` — it documents every middleware and helper and is the primary user-facing doc. +- Lint config (`.golangci.yml`) is strict (`govet enable-all`, revive, gocritic with performance/style/experimental). `modernize` is enabled — prefer `any` over `interface{}`, `slices`/`maps` stdlib, etc. diff --git a/backend/vendor/github.com/go-pkgz/rest/README.md b/backend/vendor/github.com/go-pkgz/rest/README.md index 749e0dac..cd27cf47 100644 --- a/backend/vendor/github.com/go-pkgz/rest/README.md +++ b/backend/vendor/github.com/go-pkgz/rest/README.md @@ -182,6 +182,14 @@ RealIP is a middleware that sets a http.Request's RemoteAddr to the results of p Only public IPs are accepted from headers; private/loopback/link-local IPs are skipped. This makes the middleware compatible with CDN setups like Cloudflare where the leftmost IP in `X-Forwarded-For` is the actual client. +### Timeout middleware + +Timeout bounds a request to the given duration and responds with `StatusGatewayTimeout` (504) at the deadline if the handler has not finished — even for a handler that does not observe the context. The handler runs with a context deadline and its output is buffered; on success the buffered response is written through unchanged, and if the deadline fires first the buffered output is discarded, a 504 is sent, and further writes by the still-running handler return `http.ErrHandlerTimeout`. Because the response is buffered, `http.Flusher` and `http.Hijacker` are not available under `Timeout` (as with `net/http.TimeoutHandler`), so it is not suitable for streaming or connection-hijacking handlers. A non-positive duration disables the middleware (the handler is called directly). + +```go +router.Use(rest.Timeout(5 * time.Second)) +``` + ### CORS middleware Handles Cross-Origin Resource Sharing, allowing controlled access from different origins. @@ -213,6 +221,7 @@ Features: - Origin validation with case-insensitive matching - Credentials support (reflects origin instead of `*`) - Configurable cache duration for preflight results +- Cache-correct `Vary` headers (adds `Access-Control-Request-Method` and `Access-Control-Request-Headers` on preflight) Available options: - `CorsAllowedOrigins(origins...)` - allowed origins (default: `*`) diff --git a/backend/vendor/github.com/go-pkgz/rest/cors.go b/backend/vendor/github.com/go-pkgz/rest/cors.go index df7d8f9c..f8d7edd3 100644 --- a/backend/vendor/github.com/go-pkgz/rest/cors.go +++ b/backend/vendor/github.com/go-pkgz/rest/cors.go @@ -158,6 +158,9 @@ func CORS(opts ...CorsOpt) func(http.Handler) http.Handler { // handle preflight request if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { // preflight request + // the response varies by the requested method and headers, so caches must key on them + w.Header().Add("Vary", "Access-Control-Request-Method") + w.Header().Add("Vary", "Access-Control-Request-Headers") w.Header().Set("Access-Control-Allow-Methods", methodsStr) w.Header().Set("Access-Control-Allow-Headers", headersStr) diff --git a/backend/vendor/github.com/go-pkgz/rest/logger/logger.go b/backend/vendor/github.com/go-pkgz/rest/logger/logger.go index df409320..e2eaa3c1 100644 --- a/backend/vendor/github.com/go-pkgz/rest/logger/logger.go +++ b/backend/vendor/github.com/go-pkgz/rest/logger/logger.go @@ -146,8 +146,8 @@ func (l *Middleware) formatDefault(r *http.Request, p *logParts) string { _, _ = bld.WriteString(" ") } - _, _ = bld.WriteString(fmt.Sprintf("%s - %s - %s - %s - %d (%d) - %v", - p.method, p.rawURL, p.host, p.remoteIP, p.statusCode, p.respSize, p.duration)) + _, _ = fmt.Fprintf(&bld, "%s - %s - %s - %s - %d (%d) - %v", + p.method, p.rawURL, p.host, p.remoteIP, p.statusCode, p.respSize, p.duration) if p.user != "" { _, _ = bld.WriteString(" - ") diff --git a/backend/vendor/github.com/go-pkgz/rest/middleware.go b/backend/vendor/github.com/go-pkgz/rest/middleware.go index 59e9f069..30505073 100644 --- a/backend/vendor/github.com/go-pkgz/rest/middleware.go +++ b/backend/vendor/github.com/go-pkgz/rest/middleware.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "runtime/debug" + "slices" "strings" "github.com/go-pkgz/rest/logger" @@ -13,8 +14,8 @@ import ( // Wrap converts a list of middlewares to nested calls (in reverse order) func Wrap(handler http.Handler, mws ...func(http.Handler) http.Handler) http.Handler { - for i := len(mws) - 1; i >= 0; i-- { - handler = mws[i](handler) + for _, mw := range slices.Backward(mws) { + handler = mw(handler) } return handler } diff --git a/backend/vendor/github.com/go-pkgz/rest/timeout.go b/backend/vendor/github.com/go-pkgz/rest/timeout.go new file mode 100644 index 00000000..883b0a11 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/rest/timeout.go @@ -0,0 +1,133 @@ +package rest + +import ( + "bytes" + "context" + "maps" + "net/http" + "sync" + "time" +) + +// Timeout is a middleware that enforces a maximum duration for handling a request. +// It runs the next handler with a context deadline and, if the handler has not finished +// by the time the deadline is reached, responds with StatusGatewayTimeout (504) at the +// deadline — regardless of whether the handler observes the context. +// +// The handler's output is buffered until it completes: on success the buffered response +// (status, headers and body) is written through unchanged; if the deadline fires first, +// the buffered output is discarded, a 504 is sent, and any further writes by the still +// running handler return http.ErrHandlerTimeout. If the parent request context is +// canceled (rather than the deadline being exceeded) the handler is stopped without a +// 504, since the request is being abandoned, and its later writes return the context's +// error (e.g. context.Canceled). +// +// Because the response is buffered, the wrapped ResponseWriter does not support +// http.Flusher or http.Hijacker (matching net/http.TimeoutHandler); streaming and +// connection hijacking are not available under Timeout. +// +// A non-positive timeout disables the middleware: the handler is called directly, with +// no deadline, buffering or 504. +func Timeout(timeout time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if timeout <= 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + r = r.WithContext(ctx) + + done := make(chan struct{}) + panicChan := make(chan any, 1) + tw := &timeoutWriter{w: w, h: make(http.Header)} + + go func() { + defer func() { + if p := recover(); p != nil { + panicChan <- p + } + }() + next.ServeHTTP(tw, r) + close(done) + }() + + select { + case p := <-panicChan: + panic(p) + case <-done: + tw.mu.Lock() + defer tw.mu.Unlock() + maps.Copy(w.Header(), tw.h) + code := http.StatusOK + if tw.wroteHeader { + code = tw.code + } + w.WriteHeader(code) + _, _ = w.Write(tw.wbuf.Bytes()) + case <-ctx.Done(): + tw.mu.Lock() + defer tw.mu.Unlock() + // discard the buffer and stop further handler writes, recording the cause so a + // late write returns the real error. Only the deadline yields a 504; a canceled + // parent means the request is being abandoned, so there is nobody to send it to. + switch err := ctx.Err(); err { + case context.DeadlineExceeded: + tw.err = http.ErrHandlerTimeout + w.WriteHeader(http.StatusGatewayTimeout) + default: + tw.err = err + } + } + }) + } +} + +// timeoutWriter buffers a handler's response so the Timeout middleware can either flush +// it on success or discard it and send a 504 once the deadline is reached. All fields +// after mu are guarded by mu, which is also held by the middleware while it drains the +// buffer, so it is safe against a handler goroutine that keeps writing after the timeout. +type timeoutWriter struct { + w http.ResponseWriter + h http.Header + + mu sync.Mutex + wbuf bytes.Buffer + code int + wroteHeader bool + err error // set once the response is timed out or canceled; returned by Write +} + +// Header implements http.ResponseWriter and returns the buffered header map. +func (tw *timeoutWriter) Header() http.Header { return tw.h } + +// Write implements http.ResponseWriter, buffering the response body. Once the response has +// timed out or been canceled it buffers nothing and returns the recorded error +// (http.ErrHandlerTimeout on deadline, or the context error on cancellation). +func (tw *timeoutWriter) Write(p []byte) (int, error) { + tw.mu.Lock() + defer tw.mu.Unlock() + if tw.err != nil { + return 0, tw.err + } + if !tw.wroteHeader { + tw.setHeaderLocked(http.StatusOK) + } + return tw.wbuf.Write(p) +} + +// WriteHeader implements http.ResponseWriter, recording the status for the buffered +// response. It is a no-op after the timeout has fired or after the first call. +func (tw *timeoutWriter) WriteHeader(code int) { + tw.mu.Lock() + defer tw.mu.Unlock() + tw.setHeaderLocked(code) +} + +func (tw *timeoutWriter) setHeaderLocked(code int) { + if tw.err != nil || tw.wroteHeader { + return + } + tw.wroteHeader = true + tw.code = code +} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index d4d1df81..20cbcf15 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -82,7 +82,7 @@ github.com/go-pkgz/repeater/strategy # github.com/go-pkgz/repeater/v2 v2.2.0 ## explicit; go 1.23 github.com/go-pkgz/repeater/v2 -# github.com/go-pkgz/rest v1.21.0 +# github.com/go-pkgz/rest v1.22.0 ## explicit; go 1.24.0 github.com/go-pkgz/rest github.com/go-pkgz/rest/logger