package server
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
"github.com/unrolled/secure"
)
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
status := ww.Status()
message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI)
logArgs := []any{
r.Context(),
message,
"remoteAddr", r.RemoteAddr,
"elapsedTime", time.Since(start),
"httpStatus", ww.Status(),
"responseSize", ww.BytesWritten(),
}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
headers, _ := json.Marshal(r.Header)
logArgs = append(logArgs, "header", string(headers))
} else if log.IsGreaterOrEqualTo(log.LevelDebug) {
logArgs = append(logArgs, "userAgent", r.UserAgent())
}
switch {
case status >= 500:
log.Error(logArgs...)
case status >= 400:
log.Warn(logArgs...)
default:
log.Debug(logArgs...)
}
})
}
func loggerInjector(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = log.NewContext(r.Context(), "requestId", middleware.GetReqID(ctx))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func robotsTXT(fs fs.FS) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/robots.txt") {
r.URL.Path = "/robots.txt"
http.FileServerFS(fs).ServeHTTP(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
}
func corsHandler() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
ExposedHeaders: []string{"x-content-duration", "x-total-count", "x-nd-authorization"},
})
}
func secureMiddleware() func(http.Handler) http.Handler {
sec := secure.New(secure.Options{
ContentTypeNosniff: true,
FrameDeny: true,
ReferrerPolicy: "same-origin",
PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()",
CustomFrameOptionsValue: conf.Server.HTTPHeaders.FrameOptions,
})
return sec.Handler
}
func compressMiddleware() func(http.Handler) http.Handler {
return middleware.Compress(
5,
"application/xml",
"application/json",
"application/javascript",
"text/html",
"text/plain",
"text/css",
"text/javascript",
"text/event-stream",
)
}
func clientUniqueIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
clientUniqueId := r.Header.Get(consts.UIClientUniqueIDHeader)
if clientUniqueId != "" {
c := &http.Cookie{
Name: consts.UIClientUniqueIDHeader,
Value: clientUniqueId,
MaxAge: consts.CookieExpiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: cmp.Or(conf.Server.BasePath, "/"),
}
http.SetCookie(w, c)
} else {
c, err := r.Cookie(consts.UIClientUniqueIDHeader)
if !errors.Is(err, http.ErrNoCookie) {
clientUniqueId = c.Value
}
}
if clientUniqueId != "" {
ctx = request.WithClientUniqueId(ctx, clientUniqueId)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
func realIPMiddleware(next http.Handler) http.Handler {
trusted := conf.Server.ExtAuth.TrustedSources
fromPeer := middleware.ClientIPFromRemoteAddr(next)
if trusted == "" {
return fromPeer
}
fromProxy := chi.Chain(
middleware.ClientIPFromRemoteAddr,
middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...),
middleware.ClientIPFromHeader("X-Real-IP"),
middleware.ClientIPFromHeader("True-Client-IP"),
).Handler(mirrorClientIP(next))
dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if validateIPAgainstList(r.RemoteAddr, trusted) {
fromProxy.ServeHTTP(w, r)
return
}
log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr)
fromPeer.ServeHTTP(w, r)
})
return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch)
}
func mirrorClientIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func trustedProxyPrefixes(list string) []string {
var prefixes []string
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if _, err := netip.ParsePrefix(entry); err == nil {
prefixes = append(prefixes, entry)
}
}
return prefixes
}
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
return ClientIP(r), nil
})
}
func ClientIP(r *http.Request) string {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)))
}
func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Context().Value(key) == nil {
ctx := context.WithValue(r.Context(), key, fn(r))
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}
func serverAddressMiddleware(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if rScheme, rHost := ServerAddress(r); rHost != "" {
r.Host = rHost
r.URL.Scheme = rScheme
r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost))
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
var (
xForwardedHost = http.CanonicalHeaderKey("X-Forwarded-Host")
xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme")
)
func ServerAddress(r *http.Request) (scheme, host string) {
origHost := r.Host
protocol := "http"
if r.TLS != nil {
protocol = "https"
}
xfh := r.Header.Get(xForwardedHost)
if xfh != "" {
i := strings.Index(xfh, ",")
if i == -1 {
i = len(xfh)
}
xfh = xfh[:i]
}
host = cmp.Or(xfh, r.Host)
scheme = cmp.Or(
r.Header.Get(xForwardedProto),
r.Header.Get(xForwardedScheme),
r.URL.Scheme,
protocol,
)
if host != origHost {
log.Trace(r.Context(), "Request host has changed", "origHost", origHost, "host", host, "scheme", scheme, "url", r.URL)
}
return scheme, host
}
func URLParamsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := chi.RouteContext(r.Context())
params, _ := url.ParseQuery(r.URL.RawQuery)
for i, key := range ctx.URLParams.Keys {
if strings.Contains(key, "*") {
continue
}
params.Add(":"+key, ctx.URLParams.Values[i])
}
r.URL.RawQuery = params.Encode()
next.ServeHTTP(w, r)
})
}
func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http.Handler {
userAccessLimiter := utils.Limiter{Interval: consts.UpdateLastAccessFrequency}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
usr, ok := request.UserFrom(ctx)
if ok {
userAccessLimiter.Do(usr.ID, func() {
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
err := ds.User().UpdateLastAccessAt(ctx, usr.ID)
if err != nil {
log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "Update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start))
}
})
}
next.ServeHTTP(w, r)
})
}
}