From 6fb999435a1a9b717de56c296c77e9fa003b3f25 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 5 May 2026 21:20:40 +0200 Subject: [PATCH] feat(stockholm): add Go backend integration for Stockholm frontend Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP proxy, static serving, config URL rewriting, native state persistence, and device discovery. Mounts under a configurable base path (/stockholm by default) with correct http.StripPrefix routing and apiBase-prefixed bridge API routes matching the patched JS window.__stockholmBase calls. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 18 + .gitignore | 2 + Makefile | 51 +- cmd/soundtouch-service/main.go | 46 +- cmd/soundtouch-service/router_test.go | 2 +- docs/SUMMARY.md | 1 + docs/guides/SOUNDTOUCH-SERVICE.md | 91 +++- docs/stockholm-port-guide.md | 647 ++++++++++++++++++++++++++ pkg/service/stockholm/bridge.go | 267 +++++++++++ pkg/service/stockholm/bridge_test.go | 316 +++++++++++++ pkg/service/stockholm/config.go | 302 ++++++++++++ pkg/service/stockholm/config_test.go | 349 ++++++++++++++ pkg/service/stockholm/discovery.go | 432 +++++++++++++++++ pkg/service/stockholm/handler.go | 140 ++++++ pkg/service/stockholm/proxy.go | 631 +++++++++++++++++++++++++ pkg/service/stockholm/state.go | 229 +++++++++ pkg/service/stockholm/state_test.go | 230 +++++++++ pkg/service/stockholm/static.go | 258 ++++++++++ pkg/service/stockholm/static_test.go | 234 ++++++++++ pkg/service/stockholm/util.go | 13 + scripts/patch-stockholm-bridge.py | 39 ++ 21 files changed, 4271 insertions(+), 27 deletions(-) create mode 100644 docs/stockholm-port-guide.md create mode 100644 pkg/service/stockholm/bridge.go create mode 100644 pkg/service/stockholm/bridge_test.go create mode 100644 pkg/service/stockholm/config.go create mode 100644 pkg/service/stockholm/config_test.go create mode 100644 pkg/service/stockholm/discovery.go create mode 100644 pkg/service/stockholm/handler.go create mode 100644 pkg/service/stockholm/proxy.go create mode 100644 pkg/service/stockholm/state.go create mode 100644 pkg/service/stockholm/state_test.go create mode 100644 pkg/service/stockholm/static.go create mode 100644 pkg/service/stockholm/static_test.go create mode 100644 pkg/service/stockholm/util.go create mode 100644 scripts/patch-stockholm-bridge.py diff --git a/.env.example b/.env.example index 5b3fbd7..5f1178c 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,24 @@ SOUNDTOUCH_HOSTNAME=soundtouch.local SOUNDTOUCH_VERSION=latest +# Stockholm frontend (used by make prepare-stockholm and by the Go service at startup) +# BACKEND_URL is the base URL your speakers and browser can reach the service at. +# Corresponds to SERVER_URL in the Go service. +# BACKEND_URL=http://soundtouch.local:8000 +# +# STREAMING_URL is used for streaming.bose.com rewrites (defaults to BACKEND_URL). +# Set to $(BACKEND_URL)/marge only when routing through a soundcork backend. +# STREAMING_URL=http://soundtouch.local:8000 +# +# AUTH_SERVICE_URL is written into config.json as the auth endpoint (defaults to BACKEND_URL). +# A trailing slash is added automatically; the JS appends paths like "oauth/account/..." directly. +# AUTH_SERVICE_URL=http://soundtouch.local:8000 +# +# STOCKHOLM_BASE_PATH mounts the Stockholm UI under a URL prefix, freeing / for the management UI. +# The bridge API (/api/native/*, /api/http-proxy) remains at root regardless of this setting. +# Defaults to /stockholm. Set to empty to serve at root. +# STOCKHOLM_BASE_PATH=/stockholm + # Discovery Settings DISCOVERY_TIMEOUT=5s UPNP_ENABLED=true diff --git a/.gitignore b/.gitignore index 02d0644..bd9d3fe 100644 --- a/.gitignore +++ b/.gitignore @@ -102,5 +102,7 @@ pids # Stockholm frontend — generated by `make prepare-stockholm`, not committed stockholm/ +!pkg/service/stockholm/ + # Stockholm source zip — large binary, place manually at stockholm_zip/stockholm.zip stockholm_zip/*.zip diff --git a/Makefile b/Makefile index 4b72c4b..de940c8 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ .PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm +# Load .env if present (simple KEY=VALUE format, no shell quoting) +-include .env + # Go parameters GOCMD=go GOBUILD=$(GOCMD) build @@ -33,10 +36,19 @@ BUILDFLAGS=-trimpath -ldflags="-s -w" # Stockholm frontend preparation (see Dockerfile.stockholm and docs/stockholm-port-guide.md) # STOCKHOLM_APP_REF can be overridden to pin a specific commit: make build-stockholm-image STOCKHOLM_APP_REF= -STOCKHOLM_IMAGE ?= soundcork-stockholm-app -STOCKHOLM_APP_REF ?= main -STOCKHOLM_ZIP_DIR ?= $(CURDIR)/stockholm_zip -STOCKHOLM_DIR ?= $(CURDIR)/stockholm +STOCKHOLM_IMAGE ?= soundcork-stockholm-app +STOCKHOLM_APP_REF ?= main +STOCKHOLM_ZIP_DIR ?= $(CURDIR)/stockholm_zip +STOCKHOLM_DIR ?= $(CURDIR)/stockholm +# URLs baked into stockholm/json/config.json during prepare-stockholm. +# The Go service rewrites these again at startup using SERVER_URL / MARGE_URL, +# so these only matter for static-file-only deployments or when pre-baking is desired. +# Default to localhost:8000 (matches the Go service default). +BACKEND_URL ?= http://localhost:8000 +# STREAMING_URL defaults to BACKEND_URL (no /marge suffix — set to $(BACKEND_URL)/marge for soundcork). +STREAMING_URL ?= $(BACKEND_URL) +# AUTH_SERVICE_URL defaults to BACKEND_URL; override to point at a different auth endpoint. +AUTH_SERVICE_URL ?= $(BACKEND_URL) all: check build @@ -357,11 +369,42 @@ prepare-stockholm: echo "Download the Stockholm zip and place it at stockholm_zip/stockholm.zip first."; \ exit 1; } docker run --rm \ + -e BACKEND_URL=$(BACKEND_URL) \ + -e STREAMING_URL=$(STREAMING_URL) \ + -e AUTH_SERVICE_URL=$(AUTH_SERVICE_URL) \ -v "$(STOCKHOLM_ZIP_DIR):/app/stockholm_zip:ro" \ -v "$(STOCKHOLM_DIR):/app/stockholm" \ --entrypoint bash \ $(STOCKHOLM_IMAGE) \ -c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash' + @# Patch update-urls.sh: replace the hardcoded ${BACKEND_URL}/marge with + @# ${STREAMING_URL:-${BACKEND_URL}} so the streaming URL is configurable and + @# defaults to BACKEND_URL (no /marge suffix) rather than the soundcork convention. + @script="$(STOCKHOLM_DIR)/json/update-urls.sh"; \ + awk '{ gsub(/\$$\{BACKEND_URL\}\/marge/, "$${STREAMING_URL:-$${BACKEND_URL}}"); print }' \ + "$$script" > "$$script.tmp" && mv "$$script.tmp" "$$script" + @# Restore config.json from the backup that update-urls.sh created. + @# The Go service rewrites URLs at startup via RewriteConfigURLs, so we start + @# from the original Bose URLs rather than whatever update-urls.sh produced. + @[ ! -f "$(STOCKHOLM_DIR)/json/backup.json" ] || \ + cp "$(STOCKHOLM_DIR)/json/backup.json" "$(STOCKHOLM_DIR)/json/config.json" + @# Patch browse.js: guard against empty browse-path array so that + @# funcObj.browse.getPath() returning undefined does not throw when the user + @# has not browsed yet (causes "Now playing error: topLevel" console spam and + @# aborts the now-playing update handler). + @sed -i.bak \ + -e 's/: (l()\.topLevel/: ((l() || {}).topLevel/' \ + -e 's/var a = l()\.topLevel,/var a = (l() || {}).topLevel,/' \ + -e 's/E() === 0 || funcObj\.browse\.getPath()\.topLevel/E() === 0 || (funcObj.browse.getPath() || {}).topLevel/' \ + "$(STOCKHOLM_DIR)/js/browse.js" && \ + rm -f "$(STOCKHOLM_DIR)/js/browse.js.bak" + @# Patch bridge JS: replace hardcoded /api/* paths with __stockholmBase-prefixed + @# versions so the bridge works when Stockholm is mounted under a base path. + @# Also fix resolveWebviewUrl to include the base path when resolving relative URLs. + @python3 scripts/patch-stockholm-bridge.py \ + "$(STOCKHOLM_DIR)/js/browser_native_bridge.js" \ + "$(STOCKHOLM_DIR)/js/app_comm.js" \ + "$(STOCKHOLM_DIR)/setup/js/app_comm.js" @echo "Stockholm frontend prepared at $(STOCKHOLM_DIR)" docker-run-host: diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 9bc2fce..f0958f6 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -26,6 +26,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/proxy" "github.com/gesellix/bose-soundtouch/pkg/service/setup" "github.com/gesellix/bose-soundtouch/pkg/service/spotify" + "github.com/gesellix/bose-soundtouch/pkg/service/stockholm" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/urfave/cli/v2" @@ -373,6 +374,17 @@ func main() { Value: "local", EnvVars: []string{"PREFERRED_SOURCE"}, }, + &cli.StringFlag{ + Name: "stockholm-dir", + Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)", + EnvVars: []string{"STOCKHOLM_DIR"}, + }, + &cli.StringFlag{ + Name: "stockholm-base-path", + Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.", + Value: "/stockholm", + EnvVars: []string{"STOCKHOLM_BASE_PATH"}, + }, }, Action: func(c *cli.Context) error { config := loadConfig(c) @@ -468,7 +480,20 @@ func main() { startDeviceDiscovery(server) - r := setupRouter(server) + var stockholmHandler *stockholm.Handler + + if config.stockholmDir != "" { + sh, shErr := stockholm.New(config.stockholmDir, config.dataDir, config.serverURL, config.stockholmBasePath) + if shErr != nil { + log.Printf("Warning: Failed to initialise Stockholm handler: %v", shErr) + } else { + stockholmHandler = sh + + log.Printf("Stockholm frontend enabled from %s", config.stockholmDir) + } + } + + r := setupRouter(server, stockholmHandler) log.Printf("Go service starting on %s", config.serverURL) @@ -552,6 +577,8 @@ type serviceConfig struct { migrationEnabled bool migrationDryRun bool preferredSource string + stockholmDir string + stockholmBasePath string } func loadConfig(c *cli.Context) serviceConfig { @@ -628,6 +655,8 @@ func loadConfig(c *cli.Context) serviceConfig { migrationEnabled := c.Bool("migration-enabled") migrationDryRun := c.Bool("migration-dry-run") preferredSource := c.String("preferred-source") + stockholmDir := c.String("stockholm-dir") + stockholmBasePath := c.String("stockholm-base-path") return serviceConfig{ port: port, @@ -666,6 +695,8 @@ func loadConfig(c *cli.Context) serviceConfig { migrationEnabled: migrationEnabled, migrationDryRun: migrationDryRun, preferredSource: preferredSource, + stockholmDir: stockholmDir, + stockholmBasePath: stockholmBasePath, } } @@ -852,7 +883,7 @@ func startDeviceDiscovery(server *handlers.Server) { }() } -func setupRouter(server *handlers.Server) *chi.Mux { +func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux { r := chi.NewRouter() // TrustedRealIP must run before any handler that reads r.RemoteAddr — @@ -1140,8 +1171,19 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries) r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents) + + // Serve Stockholm setup wizard pages for paths not matched by the management API. + // The Stockholm frontend has a setup/ directory that must be accessible at /setup/*. + if stockholmHandler != nil { + r.Get("/*", stockholmHandler.HandleStatic) + r.Get("/", stockholmHandler.HandleStatic) + } }) + if stockholmHandler != nil { + stockholmHandler.Mount(r) + } + r.NotFound(server.HandleNotFound) return r diff --git a/cmd/soundtouch-service/router_test.go b/cmd/soundtouch-service/router_test.go index fc3a735..c41c211 100644 --- a/cmd/soundtouch-service/router_test.go +++ b/cmd/soundtouch-service/router_test.go @@ -19,7 +19,7 @@ import ( func TestPrintRoutes(t *testing.T) { // Initialize a minimal server to get the router server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true) - r := setupRouter(server) + r := setupRouter(server, nil) var routes []string walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index fc0ab86..13a2df2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -105,3 +105,4 @@ * [SCMUDC Events Analysis](scmudc-events-analysis.md) * [Parity Improvements](PARITY-IMPROVEMENTS.md) * [Parity SoundCork](PARITY-SOUNDCORK.md) +* [Stockholm Port Guide](stockholm-port-guide.md) diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md index efb93c4..c8ffe2f 100644 --- a/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/guides/SOUNDTOUCH-SERVICE.md @@ -154,26 +154,30 @@ The service supports multiple ways to configure its behavior. When multiple sour ### Configuration Options -| Variable | Flag | Description | Default | -|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------| -| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` | -| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) | -| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` | -| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://:8000` | -| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` | -| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://:8443` | -| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` | -| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` | -| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` | -| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` | -| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` | -| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` | -| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` | -| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` | -| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` | -| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` | -| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` | -| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` | +| Variable | Flag | Description | Default | +|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` | +| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) | +| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` | +| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://:8000` | +| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` | +| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://:8443` | +| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` | +| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` | +| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` | +| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` | +| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` | +| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` | +| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` | +| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` | +| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` | +| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` | +| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` | +| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` | +| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* | +| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* | +| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* | +| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* | ### Configuration Examples @@ -188,6 +192,53 @@ SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443 LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service ``` +## Stockholm Frontend + +The Stockholm frontend is the patched Bose SoundTouch app UI served directly by the service. When enabled, opening `http://:8000` in a browser shows the full app interface, which communicates with your speakers via the local service instead of Bose's cloud. + +### Getting the Stockholm files + +The Stockholm UI files are not bundled in this repository — you supply them from [krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app). See that project's README for how to obtain the `stockholm.zip`. Once you have it: + +```bash +# 1. Place stockholm.zip in stockholm_zip/ +mkdir -p stockholm_zip +cp /path/to/stockholm.zip stockholm_zip/ + +# 2. Build the Docker image that applies the patches +make build-stockholm-image + +# 3. Extract and patch the frontend into ./stockholm/ +make prepare-stockholm +``` + +The `./stockholm/` directory is now ready to use. + +### Enabling the Stockholm UI + +Pass the directory to the service at startup: + +```bash +# Binary +soundtouch-service --stockholm-dir ./stockholm + +# Environment variable +STOCKHOLM_DIR=./stockholm soundtouch-service + +# Docker Compose — add to the environment section of docker-compose.yml +# STOCKHOLM_DIR=/app/stockholm +# and mount the stockholm/ directory into the container +``` + +### Stockholm environment variables + +| Variable | Description | +|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `STOCKHOLM_DIR` | Path to the extracted Stockholm frontend (enables the UI) | +| `MARGE_URL` | Override the streaming/marge URL written into `config.json`. Defaults to `SERVER_URL`. Only set this to `SERVER_URL/marge` when routing through a soundcork backend. | +| `MARGE_AUTH_TOKEN` | Pre-seed the session auth token so the first app launch skips the login screen | +| `MARGE_ACCOUNT_ID` | Pre-seed the account ID — device discovery will only show speakers on this account | + ## Device Migration ### Understanding Migration diff --git a/docs/stockholm-port-guide.md b/docs/stockholm-port-guide.md new file mode 100644 index 0000000..d8acbf8 --- /dev/null +++ b/docs/stockholm-port-guide.md @@ -0,0 +1,647 @@ +# Stockholm Backend — Port Guide for Bose-SoundTouch (Go) + +This document describes everything needed to integrate the +[krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app) +functionality into the Go service. It is written as a reference; nothing here +implies a specific file layout or package structure. + +--- + +## Table of Contents + +1. [What needs porting](#1-what-needs-porting) +2. [What becomes obsolete](#2-what-becomes-obsolete) +3. [Startup: Stockholm frontend preparation](#3-startup-stockholm-frontend-preparation) +4. [Native bridge — appSend / runQueue](#4-native-bridge--appsend--runqueue) +5. [State persistence — native-state.json](#5-state-persistence--native-statejson) +6. [HTTP proxy — /api/http-proxy](#6-http-proxy--apihttp-proxy) +7. [Browser bootstrap injection](#7-browser-bootstrap-injection) +8. [SSDP discovery](#8-ssdp-discovery) +9. [Config file structure (stockholm/json/config.json)](#9-config-file-structure-stockholmjsonconfigjson) +10. [Backend config (backend-config.json)](#10-backend-config-backend-configjson) +11. [Running as a plain process (no Docker)](#11-running-as-a-plain-process-no-docker) + +--- + +## 1. What needs porting + +| Component | Java class / file | Notes | +|-----------------------------------------------|---------------------------------------|------------------------------------------------------| +| Stockholm zip extraction + patch application | `docker-entrypoint.sh` | Shell; can be Go at startup | +| URL rewriting in `stockholm/json/config.json` | `update-urls.sh` | Shell + `jq`/`sed`; can be Go | +| Native bridge | `NativeBridgeService` | Core; per-tab message queue | +| State persistence | `NativeBridgeService` (file I/O) | JSON file read/written on every `setData` | +| HTTP proxy | `HttpProxyService` | CORS proxy + cloud header injection | +| Browser bootstrap injection | `BackendApplication` (static handler) | Injects ` +`, string(bootstrapJSON)) +} diff --git a/pkg/service/stockholm/static_test.go b/pkg/service/stockholm/static_test.go new file mode 100644 index 0000000..9d7663d --- /dev/null +++ b/pkg/service/stockholm/static_test.go @@ -0,0 +1,234 @@ +package stockholm + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// ---- injectBootstrap ---- + +func TestInjectBootstrap_InjectsBeforeHead(t *testing.T) { + html := []byte(`T`) + state := NewNativeState(t.TempDir()) + cfg := &Config{AppVersion: "27.0"} + + got := string(injectBootstrap(html, state, cfg)) + + if !strings.Contains(got, "window.StockholmBrowserBootstrap") { + t.Error("expected bootstrap script to be injected") + } + + scriptEnd := strings.Index(got, "") + headEnd := strings.Index(got, "") + + if scriptEnd < 0 || headEnd < 0 || scriptEnd > headEnd { + t.Error("expected bootstrap script to appear before ") + } +} + +func TestInjectBootstrap_Idempotent(t *testing.T) { + html := []byte(``) + state := NewNativeState(t.TempDir()) + cfg := &Config{} + + got := injectBootstrap(html, state, cfg) + + if strings.Count(string(got), "StockholmBrowserBootstrap") != 1 { + t.Error("expected bootstrap not to be injected a second time") + } +} + +func TestInjectBootstrap_NoHeadTag_ReturnsUnchanged(t *testing.T) { + html := []byte(`no head here`) + state := NewNativeState(t.TempDir()) + cfg := &Config{} + + got := injectBootstrap(html, state, cfg) + + if string(got) != string(html) { + t.Error("expected html to be returned unchanged when is absent") + } +} + +// ---- isBootstrapTarget ---- + +func TestIsBootstrapTarget(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"index.html", true}, + {"INDEX.HTML", true}, + {"setup/index.html", true}, + {"SETUP/INDEX.HTML", true}, + {"js/app.js", false}, + {"css/main.css", false}, + {"", false}, + } + + for _, tc := range cases { + if got := isBootstrapTarget(tc.path); got != tc.want { + t.Errorf("isBootstrapTarget(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} + +// ---- resolveStaticFile ---- + +func TestResolveStaticFile_Normal(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "app.js"), []byte("js"), 0644) + + file, rel, err := resolveStaticFile("/app.js", dir) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.HasSuffix(file, "app.js") { + t.Errorf("expected file path to end with app.js, got %q", file) + } + + if rel != "app.js" { + t.Errorf("expected rel = %q, got %q", "app.js", rel) + } +} + +func TestResolveStaticFile_RootMapsToIndexHTML(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0644) + + file, rel, err := resolveStaticFile("/", dir) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.HasSuffix(file, "index.html") { + t.Errorf("expected file path to end with index.html, got %q", file) + } + + if rel != "index.html" { + t.Errorf("expected rel = %q, got %q", "index.html", rel) + } +} + +func TestResolveStaticFile_DirectoryMapsToIndexHTML(t *testing.T) { + dir := t.TempDir() + subDir := filepath.Join(dir, "setup") + _ = os.MkdirAll(subDir, 0755) + _ = os.WriteFile(filepath.Join(subDir, "index.html"), []byte(""), 0644) + + file, rel, err := resolveStaticFile("/setup", dir) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.HasSuffix(file, filepath.Join("setup", "index.html")) { + t.Errorf("expected file path to end with setup/index.html, got %q", file) + } + + if rel != "setup/index.html" { + t.Errorf("expected rel = %q, got %q", "setup/index.html", rel) + } +} + +func TestResolveStaticFile_PathTraversalRejected(t *testing.T) { + dir := t.TempDir() + + _, _, err := resolveStaticFile("/../../../etc/passwd", dir) + + if err == nil { + t.Error("expected error for path traversal, got nil") + } +} + +// ---- ServeStatic integration ---- + +func TestServeStatic_ServesHTMLWithBootstrap(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "index.html"), + []byte(``), 0644) + + state := NewNativeState(t.TempDir()) + cfg := &Config{AppVersion: "27.0"} + backendCfg := &BackendConfig{} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + ServeStatic(rec, req, dir, backendCfg, state, cfg) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "StockholmBrowserBootstrap") { + t.Error("expected bootstrap to be injected in served HTML") + } +} + +func TestServeStatic_NotFound(t *testing.T) { + dir := t.TempDir() + state := NewNativeState(t.TempDir()) + cfg := &Config{} + backendCfg := &BackendConfig{} + + req := httptest.NewRequest(http.MethodGet, "/nonexistent.js", nil) + rec := httptest.NewRecorder() + + ServeStatic(rec, req, dir, backendCfg, state, cfg) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", rec.Code) + } +} + +func TestServeStatic_HeadReturnsNoBody(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "index.html"), + []byte(``), 0644) + + state := NewNativeState(t.TempDir()) + cfg := &Config{} + backendCfg := &BackendConfig{} + + req := httptest.NewRequest(http.MethodHead, "/", nil) + rec := httptest.NewRecorder() + + ServeStatic(rec, req, dir, backendCfg, state, cfg) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + + if rec.Body.Len() != 0 { + t.Errorf("expected empty body for HEAD, got %d bytes", rec.Body.Len()) + } +} + +func TestServeStatic_FrontendLoggingCookieSet(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "index.html"), + []byte(``), 0644) + + state := NewNativeState(t.TempDir()) + cfg := &Config{} + backendCfg := &BackendConfig{FrontendLoggingLevel: 2} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + ServeStatic(rec, req, dir, backendCfg, state, cfg) + + cookie := rec.Header().Get("Set-Cookie") + if !strings.Contains(cookie, "stockholmFrontendLoggingLevel=2") { + t.Errorf("expected logging cookie with level 2, got %q", cookie) + } +} diff --git a/pkg/service/stockholm/util.go b/pkg/service/stockholm/util.go new file mode 100644 index 0000000..a5f550b --- /dev/null +++ b/pkg/service/stockholm/util.go @@ -0,0 +1,13 @@ +package stockholm + +import ( + "crypto/rand" + "encoding/hex" +) + +func randomHexUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + + return hex.EncodeToString(b) +} diff --git a/scripts/patch-stockholm-bridge.py b/scripts/patch-stockholm-bridge.py new file mode 100644 index 0000000..ad529dc --- /dev/null +++ b/scripts/patch-stockholm-bridge.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Patch Stockholm bridge JS files to use window.__stockholmBase for API paths. + +Usage: patch-stockholm-bridge.py [ ...] + +Applied once by `make prepare-stockholm`. Idempotent — already-patched files +are left unchanged. +""" +import sys + +REPLACEMENTS = [ + ( + 'xhr.open("POST", "/api/native/appSend"', + 'xhr.open("POST", (window.__stockholmBase||"") + "/api/native/appSend"', + ), + ( + 'xhr.open("GET", "/api/native/runQueue', + 'xhr.open("GET", (window.__stockholmBase||"") + "/api/native/runQueue', + ), + ( + 'var proxyPath = "/api/http-proxy";', + 'var proxyPath = (window.__stockholmBase||"") + "/api/http-proxy";', + ), + ( + 'return new URL(url, window.location.origin + "/").href;', + 'return new URL(url, window.location.origin + (window.__stockholmBase || "") + "/").href;', + ), +] + +for path in sys.argv[1:]: + try: + original = open(path).read() + patched = original + for old, new in REPLACEMENTS: + patched = patched.replace(old, new) + if patched != original: + open(path, "w").write(patched) + except FileNotFoundError: + print(f"warning: {path} not found, skipping", file=sys.stderr) \ No newline at end of file