Lots more changes
This commit is contained in:
+44
-26
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user