From fc7d78701475a7dac8af4a24e375716b15e62545 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Oct 2026 19:01:17 +0200 Subject: [PATCH] Serve the frontend's config.js with the API password The bundled frontend gets a token with API_PASSWORD from config.js. The server now generates config.js from its own config, so setting API_PASSWORD on the server container is enough. --- Dockerfile | 2 -- internal/server/routes.go | 17 +++++++++++++++++ internal/server/routes_test.go | 12 ++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0d87088..be4d9d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,6 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache # Stage 2: Frontend static files (the same for every platform) FROM --platform=$BUILDPLATFORM ${FRONTEND_IMAGE} AS frontend -# Empty API_HOSTNAME makes the frontend use relative paths -RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > /usr/share/nginx/html/config.js # Stage 3: Final image. No RUN steps here, so no emulation is needed for other architectures. FROM alpine:3.22 diff --git a/internal/server/routes.go b/internal/server/routes.go index ba10f09..25de1dd 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "net/http" "music-server/cmd/web" @@ -66,6 +67,7 @@ func (s *Server) routes() (*echo.Echo, apis) { e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets)))) e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm()))) e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search))) + e.GET("/config.js", s.frontendConfig) e.Static("/", s.cfg.FrontendPath) for _, r := range e.Router().Routes() { @@ -74,6 +76,21 @@ func (s *Server) routes() (*echo.Echo, apis) { return e, a } +// frontendConfig serves the runtime config of the bundled frontend. The frontend uses +// the API password to get a token, so anyone who can open the frontend can read it. +func (s *Server) frontendConfig(c *echo.Context) error { + cfg, err := json.Marshal(map[string]string{ + // Empty: the frontend calls this server with relative paths. + "API_HOSTNAME": "", + "API_PASSWORD": s.cfg.APIPassword, + }) + if err != nil { + return err + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.Blob(http.StatusOK, "text/javascript; charset=utf-8", []byte("window.__RUNTIME_CONFIG__ = "+string(cfg)+";\n")) +} + func redirect(to string) echo.HandlerFunc { return func(c *echo.Context) error { return c.Redirect(http.StatusMovedPermanently, to) diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index f7afad1..00e6ec8 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net" "net/http" + "path/filepath" "sync/atomic" "testing" "time" @@ -142,6 +143,17 @@ func TestSearchPageAndFrontend(t *testing.T) { assert.Contains(t, rec.Body.String(), "frontend") } +func TestFrontendConfigHasAPIPassword(t *testing.T) { + e := newTestEnv(t, func(c *config.Config) { c.APIPassword = `pa'ss"word` }) + writeFile(t, filepath.Join(e.cfg.FrontendPath, "config.js"), "stale") + + rec := e.do(http.MethodGet, "/config.js") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "text/javascript; charset=utf-8", rec.Header().Get("Content-Type")) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + assert.Equal(t, `window.__RUNTIME_CONFIG__ = {"API_HOSTNAME":"","API_PASSWORD":"pa'ss\"word"};`+"\n", rec.Body.String()) +} + func TestShutdownWaitsForRequestsBeforeClosingDatabase(t *testing.T) { e := newTestEnv(t) started := make(chan struct{})