2020-10-02 10:15:19 -04:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
2024-09-30 20:46:10 -04:00
|
|
|
"context"
|
2020-10-02 10:15:19 -04:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2023-03-26 20:22:18 -04:00
|
|
|
"net/url"
|
2021-03-12 11:06:51 -05:00
|
|
|
"os"
|
2024-09-30 20:46:10 -04:00
|
|
|
"time"
|
2020-10-02 10:15:19 -04:00
|
|
|
|
2023-03-26 20:22:18 -04:00
|
|
|
"github.com/go-chi/chi/v5"
|
2026-09-09 12:03:36 -04:00
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
2024-09-30 20:46:10 -04:00
|
|
|
"github.com/google/uuid"
|
2023-03-26 20:22:18 -04:00
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
|
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
|
|
|
"github.com/navidrome/navidrome/consts"
|
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost
The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.
`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.
Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.
Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.
* fix(publicurl): include the configured port in the localhost fallback
The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.
* fix(publicurl): use https in the localhost fallback when TLS is configured
The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.
* refactor(publicurl): tidy the localhost fallback and its tests
Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.
Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.
* refactor(conf): add TLSEnabled and use it instead of repeating the predicate
Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.
Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00
|
|
|
"github.com/navidrome/navidrome/core/publicurl"
|
2024-09-30 20:46:10 -04:00
|
|
|
"github.com/navidrome/navidrome/model"
|
2023-03-26 20:22:18 -04:00
|
|
|
"github.com/navidrome/navidrome/model/request"
|
2024-09-30 20:46:10 -04:00
|
|
|
"github.com/navidrome/navidrome/tests"
|
2022-07-26 16:47:16 -04:00
|
|
|
. "github.com/onsi/ginkgo/v2"
|
2020-10-02 10:15:19 -04:00
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ = Describe("middlewares", func() {
|
2023-03-26 20:22:18 -04:00
|
|
|
BeforeEach(func() {
|
|
|
|
|
DeferCleanup(configtest.SetupConfig())
|
|
|
|
|
})
|
2020-10-02 10:15:19 -04:00
|
|
|
Describe("robotsTXT", func() {
|
2023-03-26 20:22:18 -04:00
|
|
|
var nextCalled bool
|
|
|
|
|
next := func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
nextCalled = true
|
|
|
|
|
}
|
2020-10-02 10:15:19 -04:00
|
|
|
BeforeEach(func() {
|
|
|
|
|
nextCalled = false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns the robot.txt when requested from root", func() {
|
|
|
|
|
r := httptest.NewRequest("GET", "/robots.txt", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2021-03-12 11:06:51 -05:00
|
|
|
robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r)
|
2020-10-02 10:15:19 -04:00
|
|
|
|
|
|
|
|
Expect(nextCalled).To(BeFalse())
|
|
|
|
|
Expect(w.Body.String()).To(HavePrefix("User-agent:"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("allows prefixes", func() {
|
|
|
|
|
r := httptest.NewRequest("GET", "/app/robots.txt", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2021-03-12 11:06:51 -05:00
|
|
|
robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r)
|
2020-10-02 10:15:19 -04:00
|
|
|
|
|
|
|
|
Expect(nextCalled).To(BeFalse())
|
|
|
|
|
Expect(w.Body.String()).To(HavePrefix("User-agent:"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("passes through requests for other files", func() {
|
|
|
|
|
r := httptest.NewRequest("GET", "/this_is_not_a_robots.txt_file", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
2021-03-12 11:06:51 -05:00
|
|
|
robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r)
|
2020-10-02 10:15:19 -04:00
|
|
|
|
|
|
|
|
Expect(nextCalled).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
})
|
2023-03-26 13:29:57 -04:00
|
|
|
|
|
|
|
|
Describe("serverAddressMiddleware", func() {
|
|
|
|
|
var (
|
|
|
|
|
nextHandler http.Handler
|
|
|
|
|
middleware http.Handler
|
|
|
|
|
recorder *httptest.ResponseRecorder
|
|
|
|
|
req *http.Request
|
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost
The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.
`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.
Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.
Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.
* fix(publicurl): include the configured port in the localhost fallback
The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.
* fix(publicurl): use https in the localhost fallback when TLS is configured
The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.
* refactor(publicurl): tidy the localhost fallback and its tests
Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.
Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.
* refactor(conf): add TLSEnabled and use it instead of repeating the predicate
Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.
Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00
|
|
|
gotScheme string
|
|
|
|
|
gotHost string
|
|
|
|
|
gotOK bool
|
2023-03-26 13:29:57 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost
The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.
`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.
Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.
Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.
* fix(publicurl): include the configured port in the localhost fallback
The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.
* fix(publicurl): use https in the localhost fallback when TLS is configured
The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.
* refactor(publicurl): tidy the localhost fallback and its tests
Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.
Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.
* refactor(conf): add TLSEnabled and use it instead of repeating the predicate
Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.
Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00
|
|
|
gotScheme, gotHost, gotOK = "", "", false
|
2023-03-26 13:29:57 -04:00
|
|
|
nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost
The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.
`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.
Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.
Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.
* fix(publicurl): include the configured port in the localhost fallback
The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.
* fix(publicurl): use https in the localhost fallback when TLS is configured
The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.
* refactor(publicurl): tidy the localhost fallback and its tests
Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.
Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.
* refactor(conf): add TLSEnabled and use it instead of repeating the predicate
Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.
Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00
|
|
|
gotScheme, gotHost, gotOK = request.ServerAddressFrom(r.Context())
|
2023-03-26 13:29:57 -04:00
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
})
|
|
|
|
|
middleware = serverAddressMiddleware(nextHandler)
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with no X-Forwarded headers", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req, _ = http.NewRequest("GET", "http://example.com", nil)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should not modify the request", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(req.Host).To(Equal("example.com"))
|
|
|
|
|
Expect(req.URL.Scheme).To(Equal("http"))
|
|
|
|
|
})
|
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost
The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.
`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.
Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.
Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.
* fix(publicurl): include the configured port in the localhost fallback
The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.
* fix(publicurl): use https in the localhost fallback when TLS is configured
The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.
* refactor(publicurl): tidy the localhost fallback and its tests
Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.
Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.
* refactor(conf): add TLSEnabled and use it instead of repeating the predicate
Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.
Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00
|
|
|
|
|
|
|
|
It("should record the address in the context", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(gotOK).To(BeTrue())
|
|
|
|
|
Expect(gotScheme).To(Equal("http"))
|
|
|
|
|
Expect(gotHost).To(Equal("example.com"))
|
|
|
|
|
})
|
2023-03-26 13:29:57 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with X-Forwarded-Host header", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req, _ = http.NewRequest("GET", "http://example.com", nil)
|
|
|
|
|
req.Header.Set("X-Forwarded-Host", "forwarded.example.com")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should modify the request with the X-Forwarded-Host header value", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(req.Host).To(Equal("forwarded.example.com"))
|
|
|
|
|
Expect(req.URL.Scheme).To(Equal("http"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with X-Forwarded-Proto header", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req, _ = http.NewRequest("GET", "http://example.com", nil)
|
|
|
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should modify the request with the X-Forwarded-Proto header value", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(req.Host).To(Equal("example.com"))
|
|
|
|
|
Expect(req.URL.Scheme).To(Equal("https"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with X-Forwarded-Scheme header", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req, _ = http.NewRequest("GET", "http://example.com", nil)
|
|
|
|
|
req.Header.Set("X-Forwarded-Scheme", "https")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should modify the request with the X-Forwarded-Scheme header value", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(req.Host).To(Equal("example.com"))
|
|
|
|
|
Expect(req.URL.Scheme).To(Equal("https"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with multiple X-Forwarded headers", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req, _ = http.NewRequest("GET", "http://example.com", nil)
|
|
|
|
|
req.Header.Set("X-Forwarded-Host", "forwarded.example.com")
|
|
|
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
|
|
|
req.Header.Set("X-Forwarded-Scheme", "http")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should modify the request with the first non-empty X-Forwarded header value", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(req.Host).To(Equal("forwarded.example.com"))
|
|
|
|
|
Expect(req.URL.Scheme).To(Equal("https"))
|
|
|
|
|
})
|
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost
The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.
`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.
Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.
Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.
* fix(publicurl): include the configured port in the localhost fallback
The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.
* fix(publicurl): use https in the localhost fallback when TLS is configured
The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.
* refactor(publicurl): tidy the localhost fallback and its tests
Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.
Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.
* refactor(conf): add TLSEnabled and use it instead of repeating the predicate
Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.
Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00
|
|
|
|
|
|
|
|
It("should record the forwarded address in the context", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(gotOK).To(BeTrue())
|
|
|
|
|
Expect(gotScheme).To(Equal("https"))
|
|
|
|
|
Expect(gotHost).To(Equal("forwarded.example.com"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("lets a handler build a public URL on the forwarded address", func() {
|
|
|
|
|
var got string
|
|
|
|
|
serverAddressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
got = publicurl.AbsoluteURL(r.Context(), "/share/img/token", nil)
|
|
|
|
|
})).ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
Expect(got).To(Equal("https://forwarded.example.com/share/img/token"))
|
|
|
|
|
})
|
2023-03-26 13:29:57 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with multiple values in X-Forwarded-Host header", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req, _ = http.NewRequest("GET", "http://example.com", nil)
|
|
|
|
|
req.Header.Set("X-Forwarded-Host", "forwarded1.example.com, forwarded2.example.com")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should modify the request with the first value in X-Forwarded-Host header", func() {
|
|
|
|
|
middleware.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(req.Host).To(Equal("forwarded1.example.com"))
|
|
|
|
|
Expect(req.URL.Scheme).To(Equal("http"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
2023-03-26 20:22:18 -04:00
|
|
|
|
|
|
|
|
Describe("clientUniqueIDMiddleware", func() {
|
|
|
|
|
var (
|
|
|
|
|
nextHandler http.Handler
|
|
|
|
|
middleware http.Handler
|
|
|
|
|
req *http.Request
|
|
|
|
|
nextReq *http.Request
|
|
|
|
|
rec *httptest.ResponseRecorder
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
nextReq = r
|
|
|
|
|
})
|
|
|
|
|
middleware = clientUniqueIDMiddleware(nextHandler)
|
|
|
|
|
req, _ = http.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
|
rec = httptest.NewRecorder()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the request header has the unique client ID", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req.Header.Set(consts.UIClientUniqueIDHeader, "123456")
|
|
|
|
|
conf.Server.BasePath = "/music"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("sets the unique client ID as a cookie and adds it to the request context", func() {
|
|
|
|
|
middleware.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Result().Cookies()).To(HaveLen(1))
|
|
|
|
|
Expect(rec.Result().Cookies()[0].Name).To(Equal(consts.UIClientUniqueIDHeader))
|
|
|
|
|
Expect(rec.Result().Cookies()[0].Value).To(Equal("123456"))
|
|
|
|
|
Expect(rec.Result().Cookies()[0].MaxAge).To(Equal(consts.CookieExpiry))
|
|
|
|
|
Expect(rec.Result().Cookies()[0].HttpOnly).To(BeTrue())
|
|
|
|
|
Expect(rec.Result().Cookies()[0].Secure).To(BeTrue())
|
|
|
|
|
Expect(rec.Result().Cookies()[0].SameSite).To(Equal(http.SameSiteStrictMode))
|
|
|
|
|
Expect(rec.Result().Cookies()[0].Path).To(Equal("/music"))
|
|
|
|
|
clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context())
|
|
|
|
|
Expect(clientUniqueId).To(Equal("123456"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the request header does not have the unique client ID", func() {
|
|
|
|
|
Context("when the request has the unique client ID in a cookie", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
req.AddCookie(&http.Cookie{
|
|
|
|
|
Name: consts.UIClientUniqueIDHeader,
|
|
|
|
|
Value: "123456",
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("adds the unique client ID to the request context", func() {
|
|
|
|
|
middleware.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Result().Cookies()).To(HaveLen(0))
|
|
|
|
|
|
|
|
|
|
clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context())
|
|
|
|
|
Expect(clientUniqueId).To(Equal("123456"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the request does not have the unique client ID in a cookie", func() {
|
|
|
|
|
It("does not add the unique client ID to the request context", func() {
|
|
|
|
|
middleware.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
Expect(rec.Result().Cookies()).To(HaveLen(0))
|
|
|
|
|
|
|
|
|
|
clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context())
|
|
|
|
|
Expect(clientUniqueId).To(BeEmpty())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("URLParamsMiddleware", func() {
|
|
|
|
|
var (
|
|
|
|
|
router *chi.Mux
|
|
|
|
|
middleware http.Handler
|
|
|
|
|
recorder *httptest.ResponseRecorder
|
|
|
|
|
testHandler http.HandlerFunc
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
router = chi.NewRouter()
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
testHandler = func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
_, _ = w.Write([]byte("OK"))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when request has no query parameters", func() {
|
|
|
|
|
It("adds URL parameters to the request", func() {
|
|
|
|
|
middleware = URLParamsMiddleware(testHandler)
|
|
|
|
|
router.Mount("/", middleware)
|
|
|
|
|
|
|
|
|
|
req, _ := http.NewRequest("GET", "/?user=1", nil)
|
|
|
|
|
router.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
Expect(recorder.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(recorder.Body.String()).To(Equal("OK"))
|
|
|
|
|
Expect(req.URL.RawQuery).To(ContainSubstring("user=1"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when request has query parameters", func() {
|
|
|
|
|
It("merges URL parameters and query parameters", func() {
|
|
|
|
|
router.Route("/{key}", func(r chi.Router) {
|
|
|
|
|
r.Use(URLParamsMiddleware)
|
|
|
|
|
r.Get("/", testHandler)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
req, _ := http.NewRequest("GET", "/test?key=value", nil)
|
|
|
|
|
router.ServeHTTP(recorder, req)
|
|
|
|
|
Expect(recorder.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(recorder.Body.String()).To(Equal("OK"))
|
|
|
|
|
Expect(req.URL.RawQuery).To(ContainSubstring("key=value"))
|
|
|
|
|
Expect(req.URL.RawQuery).To(ContainSubstring("%3Akey=test"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when URL parameter has wildcard key", func() {
|
|
|
|
|
It("does not include wildcard key in query parameters", func() {
|
|
|
|
|
router.Route("/{t*}", func(r chi.Router) {
|
|
|
|
|
r.Use(URLParamsMiddleware)
|
|
|
|
|
r.Get("/", testHandler)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
req, _ := http.NewRequest("GET", "/test?key=value", nil)
|
|
|
|
|
router.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
Expect(recorder.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(recorder.Body.String()).To(Equal("OK"))
|
|
|
|
|
Expect(req.URL.RawQuery).To(ContainSubstring("key=value"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when URL parameters require encoding", func() {
|
|
|
|
|
It("encodes URL parameters correctly", func() {
|
|
|
|
|
router.Route("/{key}", func(r chi.Router) {
|
|
|
|
|
r.Use(URLParamsMiddleware)
|
|
|
|
|
r.Get("/", testHandler)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
req, _ := http.NewRequest("GET", "/test with space?key=another value", nil)
|
|
|
|
|
router.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
Expect(recorder.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(recorder.Body.String()).To(Equal("OK"))
|
|
|
|
|
queryValues, _ := url.ParseQuery(req.URL.RawQuery)
|
|
|
|
|
Expect(queryValues.Get(":key")).To(Equal("test with space"))
|
|
|
|
|
Expect(queryValues.Get("key")).To(Equal("another value"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when there are multiple URL parameters", func() {
|
|
|
|
|
It("includes all URL parameters in the query string", func() {
|
|
|
|
|
router.Route("/{key}/{value}", func(r chi.Router) {
|
|
|
|
|
r.Use(URLParamsMiddleware)
|
|
|
|
|
r.Get("/", testHandler)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
req, _ := http.NewRequest("GET", "/test/value?key=other_value", nil)
|
|
|
|
|
router.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
Expect(recorder.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(recorder.Body.String()).To(Equal("OK"))
|
|
|
|
|
|
|
|
|
|
queryValues, _ := url.ParseQuery(req.URL.RawQuery)
|
|
|
|
|
Expect(queryValues.Get(":key")).To(Equal("test"))
|
|
|
|
|
Expect(queryValues.Get(":value")).To(Equal("value"))
|
|
|
|
|
Expect(queryValues.Get("key")).To(Equal("other_value"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
2024-09-30 20:46:10 -04:00
|
|
|
|
|
|
|
|
Describe("UpdateLastAccessMiddleware", func() {
|
|
|
|
|
var (
|
|
|
|
|
middleware func(next http.Handler) http.Handler
|
|
|
|
|
req *http.Request
|
|
|
|
|
ctx context.Context
|
|
|
|
|
ds *tests.MockDataStore
|
|
|
|
|
id string
|
|
|
|
|
lastAccessTime time.Time
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
callMiddleware := func(req *http.Request) {
|
|
|
|
|
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(nil, req)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
id = uuid.NewString()
|
|
|
|
|
ds = &tests.MockDataStore{}
|
|
|
|
|
lastAccessTime = time.Now()
|
2026-09-25 18:06:10 -04:00
|
|
|
Expect(ds.User().Put(ctx, &model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})).
|
2024-09-30 20:46:10 -04:00
|
|
|
To(Succeed())
|
|
|
|
|
|
|
|
|
|
middleware = UpdateLastAccessMiddleware(ds)
|
|
|
|
|
ctx = request.WithUser(
|
|
|
|
|
context.Background(),
|
|
|
|
|
model.User{ID: id, UserName: "johndoe"},
|
|
|
|
|
)
|
|
|
|
|
req, _ = http.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("when the request has a user", func() {
|
|
|
|
|
It("does calls the next handler", func() {
|
|
|
|
|
called := false
|
|
|
|
|
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
called = true
|
|
|
|
|
})).ServeHTTP(nil, req)
|
|
|
|
|
Expect(called).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("updates the last access time", func() {
|
|
|
|
|
time.Sleep(3 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
callMiddleware(req)
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe")
|
2024-09-30 20:46:10 -04:00
|
|
|
Expect(*user.LastAccessAt).To(BeTemporally(">", lastAccessTime, time.Second))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("skip fast successive requests", func() {
|
|
|
|
|
// First request
|
|
|
|
|
callMiddleware(req)
|
2026-09-25 18:06:10 -04:00
|
|
|
user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe")
|
2024-09-30 20:46:10 -04:00
|
|
|
lastAccessTime = *user.LastAccessAt // Store the last access time
|
|
|
|
|
|
|
|
|
|
// Second request
|
|
|
|
|
time.Sleep(3 * time.Millisecond)
|
|
|
|
|
callMiddleware(req)
|
|
|
|
|
|
|
|
|
|
// The second request should not have changed the last access time
|
2026-09-25 18:06:10 -04:00
|
|
|
user, _ = ds.MockedUser.FindByUsername(ctx, "johndoe")
|
2024-09-30 20:46:10 -04:00
|
|
|
Expect(user.LastAccessAt).To(Equal(&lastAccessTime))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
Context("when the request has no user", func() {
|
|
|
|
|
It("does not update the last access time", func() {
|
|
|
|
|
req = req.WithContext(context.Background())
|
|
|
|
|
callMiddleware(req)
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
usr, _ := ds.MockedUser.FindByUsername(ctx, "johndoe")
|
2024-09-30 20:46:10 -04:00
|
|
|
Expect(usr.LastAccessAt).To(Equal(&lastAccessTime))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-09-09 12:03:36 -04:00
|
|
|
Describe("realIPMiddleware", func() {
|
|
|
|
|
var resolved, remoteAddr string
|
|
|
|
|
var proxyIP any
|
|
|
|
|
next := func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
resolved = middleware.GetClientIP(r.Context())
|
|
|
|
|
remoteAddr = r.RemoteAddr
|
|
|
|
|
proxyIP = r.Context().Value(request.ReverseProxyIp)
|
|
|
|
|
}
|
|
|
|
|
call := func(peer string, headers map[string]string) {
|
|
|
|
|
resolved, remoteAddr, proxyIP = "", "", nil
|
|
|
|
|
r := httptest.NewRequest("POST", "/auth/login", nil)
|
|
|
|
|
r.RemoteAddr = peer
|
|
|
|
|
for k, v := range headers {
|
|
|
|
|
r.Header.Set(k, v)
|
|
|
|
|
}
|
|
|
|
|
realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Context("without a trusted proxy", func() {
|
|
|
|
|
It("ignores client-supplied forwarding headers", func() {
|
|
|
|
|
call("10.0.0.1:1234", map[string]string{
|
|
|
|
|
"X-Forwarded-For": "203.0.113.5",
|
|
|
|
|
"X-Real-IP": "203.0.113.6",
|
|
|
|
|
"True-Client-IP": "203.0.113.7",
|
|
|
|
|
})
|
|
|
|
|
Expect(resolved).To(Equal("10.0.0.1"))
|
|
|
|
|
})
|
|
|
|
|
It("leaves RemoteAddr untouched", func() {
|
|
|
|
|
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
|
|
|
|
|
Expect(remoteAddr).To(Equal("10.0.0.1:1234"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with a trusted proxy", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
|
|
|
|
|
})
|
|
|
|
|
It("uses the forwarded client IP when the peer is a trusted proxy", func() {
|
|
|
|
|
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"})
|
|
|
|
|
Expect(resolved).To(Equal("203.0.113.5"))
|
|
|
|
|
Expect(remoteAddr).To(Equal("203.0.113.5"))
|
|
|
|
|
})
|
|
|
|
|
It("honours X-Real-IP from a trusted proxy", func() {
|
|
|
|
|
call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"})
|
|
|
|
|
Expect(resolved).To(Equal("203.0.113.6"))
|
|
|
|
|
})
|
|
|
|
|
It("ignores forwarding headers when the peer is not a trusted proxy", func() {
|
|
|
|
|
call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
|
|
|
|
|
Expect(resolved).To(Equal("198.51.100.9"))
|
|
|
|
|
Expect(remoteAddr).To(Equal("198.51.100.9:1234"))
|
|
|
|
|
})
|
|
|
|
|
It("keeps the peer address in the context for external auth", func() {
|
|
|
|
|
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
|
|
|
|
|
Expect(proxyIP).To(Equal("10.0.0.1:1234"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("ClientIPRateLimiter", func() {
|
|
|
|
|
var handler http.Handler
|
|
|
|
|
JustBeforeEach(func() {
|
|
|
|
|
handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)(
|
|
|
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })))
|
|
|
|
|
})
|
|
|
|
|
attempt := func(peer string, header, value string) int {
|
|
|
|
|
r := httptest.NewRequest("POST", "/auth/login", nil)
|
|
|
|
|
r.RemoteAddr = peer
|
|
|
|
|
r.Header.Set(header, value)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(w, r)
|
|
|
|
|
return w.Code
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DescribeTable("keeps one bucket per peer when the forwarding header is rotated",
|
|
|
|
|
func(header string) {
|
|
|
|
|
Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests))
|
|
|
|
|
},
|
|
|
|
|
Entry("X-Forwarded-For", "X-Forwarded-For"),
|
|
|
|
|
Entry("X-Real-IP", "X-Real-IP"),
|
|
|
|
|
Entry("True-Client-IP", "True-Client-IP"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Context("behind a trusted proxy", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
|
|
|
|
|
})
|
|
|
|
|
It("gives each real client its own bucket", func() {
|
|
|
|
|
Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests))
|
|
|
|
|
Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
2020-10-02 10:15:19 -04:00
|
|
|
})
|