Lots more changes

This commit is contained in:
2026-06-01 01:29:47 -05:00
parent 8bec7f0dda
commit 050ec138f3
20 changed files with 1773 additions and 689 deletions
+44 -26
View File
@@ -18,46 +18,63 @@ import (
)
type Server struct {
cfg config.Config
store *storage.FilesystemStore
logger *log.Logger
rawCache *rawContentCache
cfg config.Config
store *storage.FilesystemStore
logger *log.Logger
accessLogger *log.Logger
rawCache *rawContentCache
}
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) {
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger, accessLogger *log.Logger) (*Server, error) {
return &Server{
cfg: cfg,
store: store,
logger: logger,
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
cfg: cfg,
store: store,
logger: logger,
accessLogger: accessLogger,
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
}, nil
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /{$}", http.HandlerFunc(s.serveSPA))
mux.Handle("GET /u", http.HandlerFunc(s.serveSPA))
mux.Handle("GET /api/config", http.HandlerFunc(s.getUIConfig))
uiMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
apiReadMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
apiWriteMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
if s.cfg.Security.RateLimitUI.Enabled {
uiLimiter := NewRateLimiter(s.cfg.Security.RateLimitUI.RequestsPerMinute, s.cfg.Security.RateLimitUI.Burst)
uiMiddlewares = append(uiMiddlewares, RateLimitMiddleware(uiLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
if s.cfg.Security.RateLimitAPIRead.Enabled {
apiReadLimiter := NewRateLimiter(s.cfg.Security.RateLimitAPIRead.RequestsPerMinute, s.cfg.Security.RateLimitAPIRead.Burst)
apiReadMiddlewares = append(apiReadMiddlewares, RateLimitMiddleware(apiReadLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
if s.cfg.Security.RateLimitAPIWrite.Enabled {
apiWriteLimiter := NewRateLimiter(s.cfg.Security.RateLimitAPIWrite.RequestsPerMinute, s.cfg.Security.RateLimitAPIWrite.Burst)
apiWriteMiddlewares = append(apiWriteMiddlewares, RateLimitMiddleware(apiWriteLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
mux.Handle("GET /{$}", Chain(http.HandlerFunc(s.serveSPA), uiMiddlewares...))
mux.Handle("GET /u", Chain(http.HandlerFunc(s.serveSPA), uiMiddlewares...))
mux.Handle("GET /s/{id}", Chain(http.HandlerFunc(s.scratchPage), uiMiddlewares...))
mux.Handle("GET /api/config", Chain(http.HandlerFunc(s.getUIConfig), apiReadMiddlewares...))
mux.Handle("GET /api/scratch/{id}", Chain(http.HandlerFunc(s.getScratch), apiReadMiddlewares...))
mux.Handle("GET /api/raw/{id}", Chain(http.HandlerFunc(s.rawScratch), apiReadMiddlewares...))
writeHandler := http.HandlerFunc(s.createScratch)
middlewares := make([]func(http.Handler) http.Handler, 0, 2)
middlewares = append(middlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes, s.logger))
readMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
if s.cfg.Security.RateLimit.Enabled {
writeLimiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst)
readLimiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst)
middlewares = append(middlewares, RateLimitMiddleware(writeLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
readMiddlewares = append(readMiddlewares, RateLimitMiddleware(readLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...))
mux.Handle("GET /api/scratch/{id}", Chain(http.HandlerFunc(s.getScratch), readMiddlewares...))
mux.Handle("GET /s/{id}", Chain(http.HandlerFunc(s.scratchPage), readMiddlewares...))
mux.Handle("GET /api/raw/{id}", Chain(http.HandlerFunc(s.rawScratch), readMiddlewares...))
writeMiddlewares := make([]func(http.Handler) http.Handler, 0, 2)
writeMiddlewares = append(writeMiddlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes, s.logger))
writeMiddlewares = append(writeMiddlewares, apiWriteMiddlewares...)
mux.Handle("POST /api/scratch", Chain(writeHandler, writeMiddlewares...))
mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS()))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS())))
mux.Handle("GET /{path...}", http.HandlerFunc(s.notFoundPage))
return mux
return AccessLogMiddleware(
s.accessLogger,
s.cfg.Security.TrustProxyHeaders,
s.cfg.Security.TrustedProxyPrefixes,
)(mux)
}
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
@@ -68,6 +85,7 @@ func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes,
"upload_allowed": s.isUploadAllowedForRequest(r),
"default_ttl": s.cfg.Limits.DefaultTTL,
}
writeJSON(w, http.StatusOK, payload)
}
+13 -3
View File
@@ -162,7 +162,7 @@ func TestReadRoutesAreRateLimitedWhenEnabled(t *testing.T) {
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:9111"
createReq.RemoteAddr = "127.0.0.2:9111"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
@@ -1041,7 +1041,7 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
t.Helper()
dataDir := filepath.Join(t.TempDir(), "data")
store, err := storage.NewFilesystemStore(dataDir)
store, err := storage.NewFilesystemStore(dataDir, testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -1060,7 +1060,17 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
DataDir: dataDir,
},
Security: config.SecurityConfig{
RateLimit: config.RateLimitConfig{
RateLimitUI: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
},
RateLimitAPIRead: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
},
RateLimitAPIWrite: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
+12 -6
View File
@@ -15,6 +15,8 @@ import (
"scratchbox/internal/storage"
)
var testStorageKey = []byte("0123456789abcdef0123456789abcdef")
func TestMultipartFileCount(t *testing.T) {
t.Parallel()
@@ -49,18 +51,20 @@ func TestWriteCreateErrorBranches(t *testing.T) {
func TestNewServerInitializes(t *testing.T) {
tmp := t.TempDir()
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
Security: config.SecurityConfig{
RateLimit: config.RateLimitConfig{Enabled: false},
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
},
}
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0))
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}
@@ -118,17 +122,19 @@ func TestGetUIConfigUploadDenied(t *testing.T) {
func TestWriteCreateErrorBranchesWithConfig(t *testing.T) {
tmp := t.TempDir()
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
Security: config.SecurityConfig{
RateLimit: config.RateLimitConfig{Enabled: false},
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
},
}
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err != nil {
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0)); err != nil {
t.Fatalf("NewServer() error = %v", err)
}
}
+67
View File
@@ -107,6 +107,53 @@ func RateLimitMiddleware(limiter *RateLimiter, trustProxyHeaders bool, trustedPr
}
}
func AccessLogMiddleware(logger *log.Logger, trustProxyHeaders bool, trustedProxies []netip.Prefix) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if logger == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !shouldLogAccessRequest(r.Method, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
start := time.Now()
recorder := &accessLogResponseWriter{ResponseWriter: w}
next.ServeHTTP(recorder, r)
status := recorder.status
if status == 0 {
status = http.StatusOK
}
clientIP := "-"
if ip, ok := extractClientIP(r, trustProxyHeaders, trustedProxies); ok {
clientIP = ip.String()
}
logger.Printf(
"method=%s path=%s status=%d bytes=%d duration_ms=%d ip=%s ua=%q",
r.Method,
r.URL.RequestURI(),
status,
recorder.bytes,
time.Since(start).Milliseconds(),
clientIP,
r.UserAgent(),
)
})
}
}
func shouldLogAccessRequest(method string, path string) bool {
if method == http.MethodPost && path == "/api/scratch" {
return true
}
if method == http.MethodGet && strings.HasPrefix(path, "/api/raw/") && len(path) > len("/api/raw/") {
return true
}
return false
}
func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
wrapped := handler
for i := len(middlewares) - 1; i >= 0; i-- {
@@ -115,6 +162,26 @@ func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler)
return wrapped
}
type accessLogResponseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *accessLogResponseWriter) WriteHeader(statusCode int) {
w.status = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *accessLogResponseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(p)
w.bytes += n
return n, err
}
func extractClientIP(r *http.Request, trustProxyHeaders bool, trustedProxies []netip.Prefix) (netip.Addr, bool) {
remoteIP, ok := remoteIPFromRequest(r)
if !ok {
+93
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"log"
@@ -228,3 +229,95 @@ func TestExtractClientIPIgnoresHeadersFromUntrustedProxy(t *testing.T) {
t.Fatalf("extractClientIP() = (%v,%v), want (203.0.113.10,true)", ip, ok)
}
}
func TestAccessLogMiddlewareWritesStructuredLog(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("ok"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/scratch?ttl=1h", nil)
req.RemoteAddr = "198.51.100.12:4444"
req.Header.Set("User-Agent", "middleware-test")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
got := buf.String()
if !strings.Contains(got, "method=POST") {
t.Fatalf("missing method field in log: %q", got)
}
if !strings.Contains(got, "path=/api/scratch?ttl=1h") {
t.Fatalf("missing path field in log: %q", got)
}
if !strings.Contains(got, "status=201") {
t.Fatalf("missing status field in log: %q", got)
}
if !strings.Contains(got, "bytes=2") {
t.Fatalf("missing bytes field in log: %q", got)
}
if !strings.Contains(got, "ip=198.51.100.12") {
t.Fatalf("missing ip field in log: %q", got)
}
}
func TestAccessLogMiddlewareSkipsConfigAndStaticPaths(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
reqConfig := httptest.NewRequest(http.MethodGet, "/api/config", nil)
reqConfig.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqConfig)
reqStatic := httptest.NewRequest(http.MethodGet, "/static/app.js", nil)
reqStatic.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqStatic)
reqAsset := httptest.NewRequest(http.MethodGet, "/assets/logo.svg", nil)
reqAsset.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqAsset)
reqScratchView := httptest.NewRequest(http.MethodGet, "/s/abc123", nil)
reqScratchView.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqScratchView)
reqUpload := httptest.NewRequest(http.MethodGet, "/u", nil)
reqUpload.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqUpload)
if buf.Len() != 0 {
t.Fatalf("expected no access logs for skipped paths, got %q", buf.String())
}
}
func TestAccessLogMiddlewareOnlyAllowsConfiguredRoutes(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
disallowed := httptest.NewRequest(http.MethodGet, "/api/scratch/some-id", nil)
disallowed.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), disallowed)
if buf.Len() != 0 {
t.Fatalf("expected no access log for disallowed route, got %q", buf.String())
}
allowed := httptest.NewRequest(http.MethodGet, "/api/raw/some-id", nil)
allowed.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), allowed)
if !strings.Contains(buf.String(), "method=GET path=/api/raw/some-id") {
t.Fatalf("expected access log for allowed route, got %q", buf.String())
}
}