Middleware Design
This document is based on the following source files:
- internal/app/routers.go -- middleware stack assembly and route registration
- internal/app/compress.go -- precompressed static asset serving
- internal/app/access_log.go -- slog access log bridge
- internal/middleware/auth.go -- JWT authentication middleware
- internal/httputil/proxy.go -- global HTTP proxy
- internal/httputil/basicauth.go -- URL-embedded credential extraction
- internal/services/whitelist.go -- SSRF protection allowlist
Table of Contents
- Middleware Stack Overview
- Compression Middleware
- Access Log
- Panic Capture and Recovery
- Request ID
- CORS Configuration
- JWT Authentication Middleware
- Global HTTP Proxy
- SSRF Protection
1. Middleware Stack Overview
The Songloft backend is based on the Chi v5 routing framework. Middleware is registered in order within setupBaseRouter; requests pass through each middleware layer top to bottom, and responses return in reverse.
┌─ Inbound request ─┐
│ │
┌────▼────────────▼────┐
│ Compress (gzip) │ ← Response compression
├──────────────────────┤
│ AccessLog (slog) │ ← Structured access log
├──────────────────────┤
│ Tracely Panic Report │ ← Panic exception reporting
├──────────────────────┤
│ Recoverer │ ← Panic recovery, return 500
├──────────────────────┤
│ RequestID │ ← Inject unique request ID
├──────────────────────┤
│ CORS │ ← Cross-origin policy validation
└────────┬─────────────┘
│
┌─────────────┼─────────────┐
│ │ │
Public routes Auth route group Plugin route group
(login/health) (AuthMiddleware) (AuthMiddleware
+ PublicPathChecker)Global middleware applies to all requests; AuthMiddleware is injected only into route groups that require authentication, via r.Group + r.Use.
Section sources
- internal/app/routers.go:237-349 --
setupBaseRoutermiddleware registration order
2. Compression Middleware
Compression is split into two layers: Chi's built-in gzip middleware handles dynamic responses, and precompressedFS handles static assets that were precompressed at build time.
2.1 Chi gzip Middleware
Registered via chi_middleware.Compress(5, ...), with compression level 5 (a balance of speed and compression ratio), and only applies to the following MIME types:
text/html, text/css, text/plain, text/javascript, application/javascript, application/json, application/wasm, image/svg+xml, font/otf
Audio files (audio/*) and already-compressed images (image/png, etc.) are not in the list, avoiding ineffective compression.
2.2 Precompressed Static Assets (precompressedFS)
If the brotli CLI is installed at build time, the Makefile generates .br and .gz precompressed files for the frontend static assets. newPrecompressedFS loads these files into memory from embed.FS at startup.
Request handling logic:
- First check whether
Accept-Encodingcontainsbr; on a hit, return the brotli-compressed version - Next check
gzip; on a hit, return the gzip version - When neither is supported, read the raw file from embed.FS
- When the precompressed cache misses, fall back to
http.FileServer+ Chi gzip middleware
ETags are generated from a CRC32 checksum and support If-None-Match conditional requests returning 304. addCustomEntry is used for files modified at runtime (such as index.html after base-path injection) to recompress and replace the cache.
Section sources
- internal/app/routers.go:239-249 -- Compress middleware registration and MIME type list
- internal/app/compress.go:32-78 --
newPrecompressedFSloading logic - internal/app/compress.go:106-146 --
servemethod: encoding negotiation and ETag
3. Access Log
The access log is bridged to the standard library slog via slogLogFormatter, replacing Chi's default chi_middleware.Logger (which writes directly to the log package and is not controlled by the runtime log level).
Log Fields
| Field | Description |
|---|---|
method | HTTP method (GET/POST, etc.) |
path | Request path |
status | Response status code |
bytes | Response body byte count |
dur_ms | Request duration (milliseconds, microsecond precision) |
remote | Client remote address |
request_id | The unique ID injected by the Chi RequestID middleware (optional) |
The log level is Info; when an administrator raises the runtime level to Warn or Error via /settings/log-level, the access log automatically goes silent. Panic events are logged at the Error level with a full call stack.
Section sources
- internal/app/access_log.go:1-49 -- complete implementation
4. Panic Capture and Recovery
Panic handling is split into two layers, and the registration order is critical:
- Tracely reporting (outer) -- captures the panic in a
defer recover, reports anErrorPayload(containing type, message, stack, URL) to the Tracely error-tracking service, and then re-panics - Chi Recoverer (inner) -- captures the re-thrown panic, returns a 500 response to the client, and prevents the process from crashing
This design ensures the exception is both reported to the monitoring system and not leaked to the client.
Section sources
- internal/app/routers.go:255-275 -- Tracely reporting + Recoverer registration
5. Request ID
chi_middleware.RequestID injects a unique identifier for each request into the request context. The access log middleware extracts this ID from the context and writes it to the request_id field, used for distributed tracing and log correlation.
6. CORS Configuration
The CORS middleware uses the go-chi/cors package, customizing origin validation via AllowOriginFunc:
| Origin rule | Match scope |
|---|---|
http://localhost:* | Local development (any port) |
http://127.0.0.1:* | Local loopback (any port) |
http://192.168.* / http://10.* / http://172.16.* | LAN ranges (HTTP only) |
http(s)://hanxi.cc(:port) | Project main domain (HTTP/HTTPS, any port) |
http(s)://*.hanxi.cc(:port) | All subdomains (HTTP/HTTPS, any port) |
Other configuration options:
- AllowedMethods: GET, HEAD, POST, PUT, DELETE, OPTIONS
- AllowedHeaders: Accept, Authorization, Content-Type
- AllowCredentials: true (allows carrying Cookie/Authorization)
- MaxAge: 300 seconds (preflight request cached for 5 minutes)
Section sources
- internal/app/routers.go:279-337 -- complete CORS middleware configuration
7. JWT Authentication Middleware
AuthMiddleware is a route-group-level authentication guard that protects all API endpoints requiring login.
7.1 Token Extraction Strategy
Extracts the JWT from two locations, in priority order:
- Authorization Header --
Bearer <token>format, the standard HTTP authentication method - Query Parameter fallback --
?access_token=<token>, for scenarios where a custom request header cannot be set (<img>tags,CachedNetworkImage, audio stream URLs, etc.)
7.2 XiaoAi Quirk
The XiaoAi speaker firmware has a known defect: it replaces & in the URL with a space, causing query parameters after access_token to be merged into the token value. The middleware handles this compatibility:
- Detect whether the
access_tokenvalue contains a space (a valid JWT contains no spaces) - Split by space, taking the first segment as the real token
- Parse subsequent segments in
key=valueformat and restore them intor.URL.Query() - Re-encode
r.URL.RawQuery, ensuring downstream handlers receive the correct query parameters
7.3 Public Path Bypass (PublicPathChecker)
PublicPathChecker is an interface; implementers declare which paths require no authentication via IsPublicPath(path) bool. At the middleware entry, all registered checkers examine the request path in turn; if any returns true, JWT validation is skipped and the request passes through.
Current use case: the JS plugin manager implements this interface, and the publicPaths declared in a plugin manifest (such as Subsonic /rest/* compatibility endpoints) are registered at runtime as public paths, with hot-update support requiring no restart.
7.4 Authentication Failure Response
Returns the unified JSON format {"error": "<message>", "detail": "<err>"} together with an HTTP 401 status code, conforming to the project's API response conventions.
Section sources
- internal/middleware/auth.go:23-26 --
PublicPathCheckerinterface definition - internal/middleware/auth.go:28-88 --
AuthMiddlewarecomplete implementation
8. Global HTTP Proxy
internal/httputil/proxy.go provides process-level HTTP proxy configuration; all clients created via httputil.NewClient automatically use the proxy.
Core Components
| Component | Description |
|---|---|
proxyConfig | Thread-safe holder of the proxy URL, protected for read/write by sync.RWMutex |
sharedTransport | The globally shared *http.Transport, with connection pool config: max idle connections 100, max 10 per host, idle timeout 90 seconds |
SetGlobalProxy(rawURL) | Set the proxy address (supports HTTP/HTTPS/SOCKS5); an empty string clears the proxy; after the call, CloseIdleConnections cleans up old connections |
GetGlobalProxy() | Get the current proxy address |
NewClient(timeout) | Create an *http.Client that uses the global proxy |
Loopback Auto-Bypass
proxyFunc checks the target hostname on each request: the three loopback addresses localhost, 127.0.0.1, and ::1 directly return nil (no proxy), preventing internal requests from being intercepted by the proxy.
URL Basic Auth Extraction
httputil.ApplyBasicAuthFromURL is a helper function: it extracts the username and password from req.URL.User, sets them into the Authorization request header, and then clears req.URL.User, preventing credentials from leaking into logs. Used for scenarios where authentication info is embedded in the proxy URL.
Section sources
- internal/httputil/proxy.go:1-84 -- complete global proxy implementation
- internal/httputil/basicauth.go:1-15 -- URL credential extraction
9. SSRF Protection
services.IsHostnameAllowed adopts an intranet-blocking policy: it blocks access to all intranet addresses to prevent SSRF attacks, while allowing all external domains. This function is used in scenarios such as the HLS proxy where the server needs to initiate external requests.
Check Flow
- Hostname blacklist --
localhost,*.local, and empty strings are rejected directly - DNS resolution -- run
net.LookupIPon the domain; on resolution failure, let it through (leaving the subsequent HTTP request to report the error itself) - IP address classification check -- examine each resolved IP one by one:
| Check | Coverage |
|---|---|
IsLoopback | 127.0.0.0/8, ::1 |
IsPrivate | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 |
IsLinkLocalUnicast/Multicast | 169.254.0.0/16, fe80::/10 |
IsUnspecified | 0.0.0.0, :: |
If any IP falls within an intranet/reserved address range it is rejected; only when all pass is the request allowed.
Section sources
- internal/services/whitelist.go:1-53 -- complete SSRF protection implementation
