Return and pass on request IDs, and give /v1/e/ ETag, 304 and HEAD (closes #84)
check / check (push) Failing after 2s
check / check (push) Failing after 2s
Every response carries X-Request-Id, the upstream fetch sends it, and the "upstream fetched", "image converted" and "image served" lines log it as request_id. pixa's own RequestID middleware keeps a request's own ID only when it is at most 64 letters, digits, '-', '_' or '.', and otherwise makes a random one with crypto/rand, so nothing a client chooses freely and nothing about the host reaches upstream. /v1/e/ now sets ETag, answers a matching If-None-Match with 304 and is routed for HEAD, through notModified, which both image handlers call. No Vary is added: go-chi/cors already sends Vary: Origin. Model: opus-5-5
This commit was merged in pull request #179.
This commit is contained in:
@@ -2,9 +2,12 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
@@ -115,6 +118,32 @@ func (s *Middleware) RateLimit(
|
||||
})
|
||||
}
|
||||
|
||||
// requestIDPattern is what a request's own X-Request-Id must look like to be
|
||||
// kept as its ID: 1 to 64 letters, digits, '-', '_' or '.'.
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
|
||||
|
||||
// RequestID returns a middleware that gives each request an ID and sends it as
|
||||
// the X-Request-Id response header, so a client can quote it when reporting a
|
||||
// problem. The ID is the request's own X-Request-Id when that matches
|
||||
// requestIDPattern, and otherwise a random one, which tells nothing about the
|
||||
// machine or the traffic. It is stored in the request context under chi's
|
||||
// RequestIDKey, where the logging middleware, the handlers and the upstream
|
||||
// fetch read it.
|
||||
func (s *Middleware) RequestID() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get(middleware.RequestIDHeader)
|
||||
if !requestIDPattern.MatchString(id) {
|
||||
id = rand.Text()
|
||||
}
|
||||
|
||||
w.Header().Set(middleware.RequestIDHeader, id)
|
||||
ctx := context.WithValue(r.Context(), middleware.RequestIDKey, id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// sendRequestID sends a request through the RequestID middleware, carrying
|
||||
// incoming as its X-Request-Id unless that is empty. It returns the ID the
|
||||
// next handler found in the request context, which the upstream fetch sends
|
||||
// and the log lines carry, and the X-Request-Id of the response.
|
||||
func sendRequestID(t *testing.T, incoming string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
mw := &Middleware{log: slog.Default(), config: &config.Config{}}
|
||||
|
||||
var inContext string
|
||||
|
||||
handler := mw.RequestID()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
inContext = middleware.GetReqID(r.Context())
|
||||
}))
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
if incoming != "" {
|
||||
req.Header.Set("X-Request-Id", incoming)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
return inContext, rec.Header().Get("X-Request-Id")
|
||||
}
|
||||
|
||||
// TestRequestIDKeepsShortPlainID verifies that a request's own X-Request-Id of
|
||||
// at most 64 letters, digits, '-', '_' or '.' is kept as its ID.
|
||||
func TestRequestIDKeepsShortPlainID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, incoming := range []string{
|
||||
"client-request-id",
|
||||
"A1_b2.c3-d4",
|
||||
strings.Repeat("a", 64),
|
||||
} {
|
||||
inContext, inResponse := sendRequestID(t, incoming)
|
||||
|
||||
if inContext != incoming || inResponse != incoming {
|
||||
t.Errorf("incoming %q: context has %q, response %q, want both %q",
|
||||
incoming, inContext, inResponse, incoming)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestIDReplacesLongOrUnusualID verifies that a request's own
|
||||
// X-Request-Id that is over 64 characters or holds anything but letters,
|
||||
// digits, '-', '_' or '.' is neither sent back nor sent upstream: the request
|
||||
// gets a fresh ID instead.
|
||||
func TestRequestIDReplacesLongOrUnusualID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, incoming := range []string{
|
||||
strings.Repeat("a", 65),
|
||||
strings.Repeat("a", 9000),
|
||||
"has space",
|
||||
"a/b",
|
||||
"a,b",
|
||||
"<script>",
|
||||
"ünicode",
|
||||
} {
|
||||
inContext, inResponse := sendRequestID(t, incoming)
|
||||
t.Logf("incoming %.20q: made up %q", incoming, inResponse)
|
||||
|
||||
if inResponse == "" || inResponse == incoming {
|
||||
t.Errorf("incoming %.20q: response has %q, want a fresh ID",
|
||||
incoming, inResponse)
|
||||
}
|
||||
|
||||
if inContext != inResponse {
|
||||
t.Errorf("incoming %.20q: context has %q, want the response's %q",
|
||||
incoming, inContext, inResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestIDMadeUpTellsNothing verifies that the ID made up for a request
|
||||
// that sent none differs for every request and does not hold the host name.
|
||||
func TestRequestIDMadeUpTellsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Hostname() error = %v", err)
|
||||
}
|
||||
|
||||
firstInContext, first := sendRequestID(t, "")
|
||||
_, second := sendRequestID(t, "")
|
||||
t.Logf("host %q, made up %q and %q", hostname, first, second)
|
||||
|
||||
if first == "" || first == second {
|
||||
t.Errorf("made up %q and %q, want two different IDs", first, second)
|
||||
}
|
||||
|
||||
if firstInContext != first {
|
||||
t.Errorf("context has %q, want the response's %q", firstInContext, first)
|
||||
}
|
||||
|
||||
if strings.Contains(first, hostname) {
|
||||
t.Errorf("made-up ID %q holds the host name %q", first, hostname)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user