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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-17 15:05:39 +02:00
co-authored by Claude Sonnet 4.6
parent c64e601df6
commit 6fb999435a
21 changed files with 4271 additions and 27 deletions
+18
View File
@@ -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
+2
View File
@@ -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
+47 -4
View File
@@ -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=<sha>
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:
+44 -2
View File
@@ -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
+1 -1
View File
@@ -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 {
+1
View File
@@ -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)
+71 -20
View File
@@ -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://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>: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://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>: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://<server>: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
+647
View File
@@ -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 `<script>` into `index.html` |
| SSDP speaker + media-server discovery | `SsdpDiscoveryService` | Already partially in Bose-SoundTouch |
| Config reading | `SoundcorkDataService` | Reads `config.json` + `override.json` |
| Backend config | `BackendConfig` | Single JSON file, only `frontendLoggingLevel` so far |
---
## 2. What becomes obsolete
When Bose-SoundTouch serves Stockholm directly, the following env vars and
concepts collapse because the Go service knows its own URLs:
| Variable | Why it disappears |
|------------------------------------|--------------------------------------------|
| `BACKEND_URL` | Go service knows its own base URL |
| `STREAMING_URL` | Same — the marge path is internal |
| `AUTH_SERVICE_URL` | Same — marge is a local handler |
| `BACKEND_BIND_IP` / `BACKEND_PORT` | Replaced by existing `PORT` / `HTTPS_PORT` |
| `update-urls.sh` | Config rewriting becomes Go startup logic |
| Custom CA cert via `keytool` | Replaced by Bose-SoundTouch `certmanager` |
What does **not** disappear:
- `MARGE_AUTH_TOKEN` / `MARGE_ACCOUNT_ID` — seeding initial session state
- Stockholm zip + versioned patch files — still needed as assets
- `PREFERRED_DEVICES` and other existing Bose-SoundTouch config
---
## 3. Startup: Stockholm frontend preparation
### 3a. Zip extraction
Source file: `docker-entrypoint.sh:prepare_stockholm()`
Look for `stockholm/index.html`. If absent:
1. Find the zip in `stockholm_zip/stockholm.zip` (preferred) or `stockholm.zip`
alongside the binary.
2. Extract the zip into `stockholm/`.
### 3b. Versioned patch application
Patch files are named `stockholm-changes_v<N>.patch` and applied in ascending
order. The current patches are v1 (1 153 lines) and v2 (1 475 lines).
A marker file `stockholm/.soundcork-stockholm-app.json` tracks the last applied
version:
```json
{"project":"soundcork-stockholm-app","patchVersion":2}
```
Algorithm:
1. Read `patchVersion` from the marker (default 0).
2. For each `stockholm-changes_v<N>.patch` with N > current version, in order:
- Strip hunks that don't touch `stockholm/` paths (the patch files include
README and self-referential hunks).
- Dry-run `patch -p1 -R` (reverse) to test if it's already applied.
- Dry-run `patch -p1` (forward) to test if it can apply.
- Apply with `patch -p1 --batch`.
- Write the marker for version N.
3. For v1 only, run `prettier --write "stockholm/**/*.js"` before patching
(the patch was generated against formatted source).
The `patch` and `prettier` (npm) binaries are required. In a container image
these are install-time dependencies. For a plain binary distribution they must
be present on the host.
### 3c. Copy update-urls.sh into place
Copy `update-urls.sh` to `stockholm/json/update-urls.sh` after extraction.
The script is called from that directory so relative paths work.
### 3d. Rewrite config.json URLs (replaces update-urls.sh)
`stockholm/json/config.json` stores most values base64-encoded under a
`"default"` key (`d0``d13`). `update-urls.sh` decodes, rewrites with `sed`,
and re-encodes.
When the Go service knows its own URLs at startup, it can do this in-process:
```
fields to rewrite (sed substitutions in the shell script):
streaming.bose.com → STREAMING_URL (default: BACKEND_URL, soundcork: BACKEND_URL/marge)
events.api.bosecm.com → BACKEND_URL
content.api.bose.io → BACKEND_URL
worldwide.bose.com → BACKEND_URL
downloads.bose.com → BACKEND_URL
d6 field → AUTH_SERVICE_URL (set via jq, not sed)
```
The Go equivalent:
1. Read `config.json`, base64-decode each value in `default`.
2. Replace the hostnames above.
3. Set `default.d6` to the auth service URL.
4. Re-encode all values in `default` as base64.
5. Write back.
---
## 4. Native bridge — appSend / runQueue
Source: `NativeBridgeService.java`
Stockholm communicates with the native layer through two HTTP endpoints. The
bridge emulates the Android `Native` object.
### Endpoints
```
POST /api/native/appSend?clientId=<id> (or X-Stockholm-Client-Id header)
GET /api/native/runQueue?clientId=<id>
```
`clientId` is a per-browser-tab identifier. Falls back to `"default"`.
### appSend request body
JSON:
```json
{"method":"<name>","params":{...},"id":<number or null>}
```
### runQueue response body
```json
{"messages": [<message>, ...] | null}
```
Each message is one of:
**Callback result** (response to a `getData`, `getConstant`, etc.):
```json
{"result":<value>,"error":<value or null>,"id":<id from request>}
```
**Push method** (unsolicited, e.g. device discovery results):
```json
{"method":"devices","params":[...],"id":null}
```
### Supported methods
| Method | Action |
|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
| `locale`, `htmlReady`, `stopHrmsUpdates` | No-op |
| `log` | Log `params.msg` at debug level |
| `setData` | Store `params.name``params.value` in state; persist to disk |
| `getData` | Return state value for `params.name`; empty string if absent |
| `getLanStatus` | Return `{"result":true,"error":null,"id":<id>}` |
| `getTimeZone` | Return `{"result":{"timezoneInfo":"<IANA zone>","timeFormat":"TIME_FORMAT_24HOUR_ID"}}` |
| `getLegalDocPath` | Return path string (see below) |
| `getConstant` | Return `state["constant.<name>"]`; default for `"kilo"` is `"a7928d7b43dcd49f0af31e5aeed26458"` |
| `canPerformAutoAPSetup` | Return `{"result":{"permission":false,"location":false}}` |
| `getDeviceList` | Run SSDP renderer discovery async; push incremental `"devices"` messages |
| `getHrmsList` | Run SSDP server discovery async; push `"servers"` message |
| `getNetStats`, `getSSIDList`, `setSSID`, `updateSetting`, `oauth`, `downloadNewGui`, `installNewGui`, `sendLogs`, `socketCreate`, `socketSend`, `socketClose` | Return error `"unsupported"` |
**getLegalDocPath logic:**
```
type=lcns → "legal/platform_license.txt"
type=<blank> → "legal/eula_en.txt"
type=<type>,lang=<lang> → "legal/<type>_<lang>.txt" (lang defaults to "en")
```
### Async discovery pattern
`getDeviceList` and `getHrmsList` are fired asynchronously. Each discovered
device is pushed to the client queue immediately via `"devices"` / `"servers"`
method messages before the discovery is complete. The frontend polls
`/api/native/runQueue` continuously, so results arrive as they come in.
### Queue structure
One deque per `clientId`. `appSend` appends; `runQueue` drains the whole deque
atomically and returns all pending messages.
### State seeding from environment
On startup, read these env vars and write to state if present:
| Env var | State key |
|----------------------------------------|------------------|
| `MARGE_AUTH_TOKEN` or `margeAuthToken` | `margeAuthToken` |
| `MARGE_ACCOUNT_ID` or `margeAccountID` | `margeAccountID` |
Also seed on first run:
| State key | Value |
|----------------------|-----------------------------------------------------------------|
| `guid` | Random UUID (hex, no dashes) |
| `deviceGuid` | Same UUID as `guid` |
| `nativeFrameVersion` | Short version prefix extracted from `bose_app` in `config.json` |
| `frame_version` | Full version from `bose_app` |
| `authServer` | `"0"` |
| `constant.kilo` | `"a7928d7b43dcd49f0af31e5aeed26458"` |
---
## 5. State persistence — native-state.json
Source: `NativeBridgeService.loadState()` / `persistState()`
File path (relative to workspace root): `backend/state/native-state.json`
Format: flat JSON object, all values are strings.
```json
{
"guid": "abc123...",
"deviceGuid": "abc123...",
"frame_version": "27.0.13",
"nativeFrameVersion": "27.0.13",
"authServer": "0",
"margeAuthToken": "<token>",
"margeAccountID": "1234567",
"overrideMargeURL": "https://...",
"overrideUpdateURL": "https://...",
"constant.kilo": "a7928d7b43dcd49f0af31e5aeed26458",
... (arbitrary keys from setData calls)
}
```
Written on every `setData` call and on initial seeding. Read once at startup.
---
## 6. HTTP proxy — /api/http-proxy
Source: `HttpProxyService.java`
Stockholm makes all cloud API calls through this proxy to work around browser
CORS restrictions.
### Endpoint
```
<ANY METHOD> /api/http-proxy?url=<url-encoded target URL>
```
### Header filtering
**Blocked outbound (not forwarded to target):**
```
access-control-request-headers, access-control-request-method, connection,
content-length, cookie, forwarded, host, http2-settings, keep-alive, origin,
proxy-authenticate, proxy-authorization, referer, sec-ch-ua, sec-ch-ua-mobile,
sec-ch-ua-platform, sec-fetch-dest, sec-fetch-mode, sec-fetch-site,
sec-fetch-user, te, trailer, transfer-encoding, upgrade, x-forwarded-for,
x-forwarded-host, x-forwarded-port, x-forwarded-proto, x-real-ip,
x-requested-with
```
**Blocked inbound (not relayed to browser):**
```
access-control-allow-credentials, access-control-allow-headers,
access-control-allow-methods, access-control-allow-origin,
access-control-expose-headers, access-control-max-age, connection,
content-length, keep-alive, proxy-authenticate, proxy-authorization,
set-cookie, set-cookie2, te, trailer, transfer-encoding, upgrade
```
Also block HTTP/2 pseudo-headers (names starting with `:`).
Always add `Cache-Control: no-store` to the response.
### Backend-injected headers
Injected only if not already present in the request.
**BMX targets** (host is `content.api.bose.io`, `*.apigee.net`,
`bose-prod.apigee.net`, `test.content.api.bose.io`):
```
x-bmx-api-key: <encryptedBmxToken from config.json d7>
x-software-version: <bose_app version>
```
**Marge targets** (host ends with `.bose.com` or `.apigee.net` AND path
contains `/streaming/` or `/customer/`):
```
Accept: application/vnd.bose.streaming-v<N>+xml
(or customer variant if path contains /customer/)
Content-Type: same as Accept
ClientType: SOUNDTOUCH_COMPUTER_APP
GUID: <guid from state>
version_NativeFrameVersion: <nativeFrameVersion from state>
version_StockholmVersion: <bose_app version>
version_ProtocolVersion: <bose_protocol version>
<margeServerKeyHeader>: <margeServerKey> (if config d13/d10 non-empty)
Authorization: <margeAuthToken> (not injected on login/environment endpoints)
```
Authorization is **not** injected for these paths:
- `*/streaming/account/login`
- `/streaming/account` or `/streaming/account/`
- `*/streaming/account/email/*/environment`
- `/customer/account/password/email/*`
### Login retry (environment switching)
After a login `POST` to `*/streaming/account/login`:
1. If the response XML contains `<status-code>4033</status-code>` (wrong
region), parse the login request body for `<username>` and `<password>`.
2. Fetch `GET <same-origin><marge-prefix>/streaming/account/email/<email>/environment`
with `Authorization: Basic <base64(email:password)>`.
3. Parse the environment response XML for `<streamingURL>` and `<updateURL>`.
4. Store both as `overrideMargeURL` / `overrideUpdateURL` in state.
5. Retry the original login against the new `streamingURL`.
Subsequent marge requests are automatically redirected to `overrideMargeURL`
via `SoundcorkDataService.overrideTarget()`.
### Session capture
After a successful login response (2xx):
- Extract `<account id="...">` from the response XML body → store as `margeAccountID`.
- Extract `Credentials` response header → store as `margeAuthToken`.
On any marge response:
- If there is a `Refresh` response header, store its value as `margeAuthToken`.
### Proxy loop detection
Reject requests whose target URL resolves to the proxy's own
`/api/http-proxy` endpoint. Considers both the direct bind address and the
externally visible address from `X-Forwarded-Host` / `X-Forwarded-Port` /
`Host` headers.
### Header value sanitisation
Drop header values that are `null`, `undefined`, or empty string (these can
come from the Stockholm JS).
---
## 7. Browser bootstrap injection
Source: `BackendApplication.StaticStockholmHandler`
On every request to `index.html` or `setup/index.html`, inject a `<script>`
block before `</head>`. The script is skipped if `window.StockholmBrowserBootstrap`
already exists.
The injected JSON payload:
```json
{
"authServer": "<03, from state>",
"guid": "<guid from state>",
"nativeVersion": "<frame_version from state>",
"frameConfig": {}
}
```
The script does four things:
1. Patches `window.getURLParams` to return `bootstrap.authServer`, `bootstrap.guid`,
and `bootstrap.nativeVersion` for the keys `authServer`, `guid`, and
`native_version` when the original function returns null.
2. Patches `window.getUserAgentValue` to return `bootstrap.guid` for `_app`
when the original returns empty.
3. Sets `window.guid`, `window.frame_version`, `window.auth_server` from
bootstrap values when they are empty.
4. Patches `window.settingsLoad` to merge `bootstrap.frameConfig` into the
config object (keys `f<N>``d<N>`, base64-encoded, only if currently
empty).
`authServer` is an integer string `"0"``"3"`. The Java code normalises to
`"0"` for any invalid value.
### Static file serving
Serve everything under `stockholm/` for all paths. Content types:
| Extension | MIME type |
|----------------|-----------------------------------------|
| `.html` | `text/html; charset=UTF-8` |
| `.js` | `application/javascript; charset=UTF-8` |
| `.css` | `text/css; charset=UTF-8` |
| `.json` | `application/json; charset=UTF-8` |
| `.xml` | `application/xml; charset=UTF-8` |
| `.svg` | `image/svg+xml` |
| `.png` | `image/png` |
| `.jpg`/`.jpeg` | `image/jpeg` |
| `.gif` | `image/gif` |
| `.ttf` | `font/ttf` |
| `.otf` | `font/otf` |
| `.txt` | `text/plain; charset=UTF-8` |
Set `Cache-Control: no-store` on all responses.
For `HEAD` requests send headers only (no body, status -1 in content-length).
For 204/304 responses send no body.
Path traversal: reject any path that resolves outside `stockholm/`.
### Frontend logging cookie
Set a `Set-Cookie` header on every static response:
- If `frontendLoggingLevel > 0`:
`stockholmFrontendLoggingLevel=<level>; Path=/; SameSite=Lax`
- Otherwise (clear it):
`stockholmFrontendLoggingLevel=; Max-Age=0; Path=/; SameSite=Lax`
---
## 8. SSDP discovery
Source: `SsdpDiscoveryService.java`
Bose-SoundTouch already has SSDP/UPnP discovery in `pkg/discovery`. The
Stockholm bridge needs two specific discovery types with specific result shapes.
### Renderer discovery (speakers) — `getDeviceList`
Search target: `urn:schemas-upnp-org:device:MediaRenderer:1`
For each SSDP response, extract the `Location` header URL, take the `host`
part, then fetch `GET http://<host>:8090/info`.
Parse the XML response:
```xml
<info deviceID="AA:BB:CC:DD:EE:FF">
...
<margeAccountUUID>1234567</margeAccountUUID>
...
</info>
```
- `deviceID` attribute → `uID` (uppercased)
- `margeAccountUUID` element text → `accountId`
Filter: if `margeAccountID` is set in state, only include speakers whose
`margeAccountUUID` matches.
Result payload per speaker:
```json
{"uID": "AA:BB:CC:DD:EE:FF", "ip": "192.168.1.10"}
```
Push incremental results as they arrive (push one device at a time via the
`"devices"` method message). At the end, if the list is empty, push an empty
`"devices"` message.
### Network interface selection for SSDP
Priority order: ethernet/en* > wifi/wl* > others.
Exclude: loopback, virtual, docker, vbox, vmware, hyper-v, bluetooth, teredo,
tunnel interfaces.
Require: IPv4 address, multicast support, interface up.
Try each interface in priority order; return results from the first one that
gets responses.
SSDP probe parameters:
- Multicast: `239.255.255.250:1900`
- 3 probes, 350 ms between probes
- 1 250 ms grace period after last probe
- `MX: 1`
### Media server discovery (HRMS) — `getHrmsList`
Search target: `urn:schemas-upnp-org:device:MediaServer:1`
No HTTP fetch needed — extract from SSDP response headers only.
Result payload per server:
```json
{"uID": "<usn uuid or host:port>", "ip": "<host>", "port": "<port>"}
```
`uID` is the UUID portion of the `USN` header (strip `uuid:` prefix and
anything after `::`). Fall back to `host:port` if USN is absent.
Push all results at once (no incremental push) via `"servers"` method message.
---
## 9. Config file structure (stockholm/json/config.json)
Source: `SoundcorkDataService.java`
The file has three top-level objects: `app_versions`, `api_versions`, `default`.
### app_versions
| Key | Used as |
|-----------------|-------------------------------------------------------------------------------|
| `bose_app` | `soundcorkAppVersion` — also `x-software-version`, `version_StockholmVersion` |
| `bose_protocol` | `protocolVersion` — sent as `version_ProtocolVersion` |
### api_versions
| Key | Used as |
|------------------|--------------------------------------------------------------------------|
| `bose_streaming` | Streaming API version — builds `application/vnd.bose.streaming-v<N>+xml` |
| `bose_customer` | Customer API version — builds `application/vnd.bose.customer-v<N>+xml` |
### default (all values base64-encoded)
| Field | Content | Used as |
|-------|------------------------------|--------------------------------------------------------------|
| `d0` | marge base URL | `defaultMargeUrl` (redirected from `streaming.bose.com`) |
| `d1` | update base URL | `defaultUpdateUrl` (redirected from `events.api.bosecm.com`) |
| `d3` | BMX registry URL | `defaultBmxRegistryUrl` |
| `d6` | auth service URL | Written by `update-urls.sh` / `AUTH_SERVICE_URL` |
| `d7` | BMX API token | `encryptedBmxToken` — injected as `x-bmx-api-key` |
| `d8` | BMX server alt URL | stored but not currently used in header injection |
| `d10` | marge server key | injected as `<margeServerKeyHeader>` value on marge requests |
| `d13` | marge server key header name | the header name for d10 |
### override.json
Sits alongside `config.json` at `stockholm/json/override.json`. Currently only
`kilo` is read from it (not used in any live code path yet).
---
## 10. Backend config (backend-config.json)
Source: `BackendConfig.java`
File path: `backend/config/backend-config.json`
```json
{"frontendLoggingLevel": 2}
```
`frontendLoggingLevel`:
- `0` — disable frontend debug logging (clear the cookie)
- `> 0` — enable at that level (set cookie to the numeric value)
The Stockholm JS reads `stockholmFrontendLoggingLevel` from a cookie on load.
---
## 11. Running as a plain process (no Docker)
The entrypoint script does three things beyond launching the JVM. For a plain
process, do these steps once before running the binary:
### Step 1 — extract and patch Stockholm
```shell
# Requires: unzip, patch, npm/prettier@3.8.3
unzip stockholm_zip/stockholm.zip -d stockholm
npx prettier@3.8.3 --ignore-path /dev/null --write "stockholm/**/*.js"
patch -p1 --batch < stockholm-changes_v1.patch
patch -p1 --batch < stockholm-changes_v2.patch
```
Or run the Docker container once and copy the `stockholm/` directory out.
### Step 2 — rewrite URLs in config.json
```shell
cd stockholm/json
BACKEND_URL=http://localhost:8000 \
STREAMING_URL=http://localhost:8000/marge \ # soundcork only
AUTH_SERVICE_URL=http://localhost:8000/marge/ \
source update-urls.sh
cd ../..
```
For Bose-SoundTouch, this step disappears — the Go service rewrites config.json
in-process at startup.
### Step 3 — create state directory
```shell
mkdir -p backend/state
```
### Step 4 — run
```shell
# Java (current):
./gradlew run
# Go (future):
./soundtouch-service # with appropriate env vars
```
The Java `resolveWorkspaceRoot()` searches for a `stockholm/` directory at CWD
or one level up. Run from the project root.
---
## Patches summary — what the Stockholm JS patches do
**v1** (the main patch, applied after prettier formatting):
- `stockholm/index.html` — adds `<meta>` charset and viewport tags
- `stockholm/js/app_comm.js` — rewrites `AppComm` to use the HTTP native bridge
(`/api/native/appSend` + `/api/native/runQueue`) instead of Android native calls
- `stockholm/js/browser_http_proxy.js`**new file** — implements the
`stHttpProxy` function that routes all cloud API calls through `/api/http-proxy`
- `stockholm/js/browser_native_bridge.js`**new file** — implements
`window.Native` shim that calls the bridge endpoints
- `stockholm/js/main.js` — wires up the browser native bridge on load
- `stockholm/setup/index.html` — same charset/viewport fix
- `stockholm/setup/js/app_comm.js` — same AppComm bridge rewrite for the setup flow
**v2** (incremental fixes on top of v1):
- `stockholm/js/app_comm.js` — additional fixes and multi-tab `clientId` support
- `stockholm/js/browser_native_bridge.js` — minor fix
- `stockholm/js/main.js` — minor fix
- `stockholm/js/marge_comm.js` — fixes marge URL handling
- `stockholm/js/presets.js` — minor fix
- `stockholm/js/sources.js` — minor fix
- `stockholm/setup/js/app_comm.js` — same fixes as main app_comm.js
+267
View File
@@ -0,0 +1,267 @@
// Package stockholm implements the Stockholm frontend backend: native bridge,
// HTTP proxy, static file serving, SSDP discovery, and state persistence.
package stockholm
import (
"encoding/json"
"log"
"net/http"
"net/url"
"sync"
"time"
)
// bridgeMessage is a single message in the runQueue response.
type bridgeMessage struct {
Result interface{} `json:"result,omitempty"`
Error interface{} `json:"error,omitempty"`
Method string `json:"method,omitempty"`
Params interface{} `json:"params,omitempty"`
ID interface{} `json:"id"`
}
// appSendRequest is the JSON body of a POST /api/native/appSend call.
type appSendRequest struct {
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
ID interface{} `json:"id"`
}
// Bridge manages per-clientId message queues for the native bridge.
type Bridge struct {
cfg *Config
state *NativeState
queues sync.Map // clientId -> *clientQueue
}
type clientQueue struct {
mu sync.Mutex
msgs []bridgeMessage
}
func newBridge(cfg *Config, state *NativeState) *Bridge {
return &Bridge{cfg: cfg, state: state}
}
// HandleAppSend serves POST /api/native/appSend.
func (b *Bridge) HandleAppSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
clientID := resolveClientID(r)
var req appSendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
b.enqueueError(clientID, nil, "invalid_request")
w.WriteHeader(http.StatusNoContent)
return
}
b.dispatch(clientID, req)
w.WriteHeader(http.StatusNoContent)
}
// HandleRunQueue serves GET /api/native/runQueue.
func (b *Bridge) HandleRunQueue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
clientID := resolveClientID(r)
q := b.getOrCreateQueue(clientID)
q.mu.Lock()
msgs := q.msgs
q.msgs = nil
q.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.Header().Set("Cache-Control", "no-store")
type runQueueResponse struct {
Messages []bridgeMessage `json:"messages"`
}
if err := json.NewEncoder(w).Encode(runQueueResponse{Messages: msgs}); err != nil {
log.Printf("[Stockholm bridge] Failed to encode runQueue response: %v", err)
}
}
func (b *Bridge) dispatch(clientID string, req appSendRequest) {
method := req.Method
params := req.Params
if params == nil {
params = map[string]interface{}{}
}
id := req.ID
log.Printf("[Stockholm bridge] method=%q client=%q", method, clientID)
switch method {
case "locale", "htmlReady", "stopHrmsUpdates":
// no-op
case "log":
if msg, _ := params["msg"].(string); msg != "" {
log.Printf("[Stockholm:%s] %s", clientID, msg)
}
case "setData":
name, _ := params["name"].(string)
if name != "" {
value := stringifyScalar(params["value"])
b.state.Set(name, value)
}
case "getData":
name, _ := params["name"].(string)
b.enqueueResult(clientID, id, b.state.Get(name), "")
case "getLanStatus":
b.enqueueResult(clientID, id, true, nil)
case "getTimeZone":
b.enqueueResult(clientID, id, map[string]interface{}{
"timezoneInfo": localTimezoneName(),
"timeFormat": "TIME_FORMAT_24HOUR_ID",
}, "")
case "getLegalDocPath":
b.enqueueResult(clientID, id, legalDocPath(params), nil)
case "getConstant":
name, _ := params["name"].(string)
val := b.state.Get("constant." + name)
if val == "" && name == "kilo" {
val = "a7928d7b43dcd49f0af31e5aeed26458"
}
b.enqueueResult(clientID, id, val, "")
case "canPerformAutoAPSetup":
b.enqueueResult(clientID, id, map[string]interface{}{
"permission": false,
"location": false,
}, "")
case "getDeviceList":
go b.runDeviceDiscovery(clientID, id)
case "getHrmsList":
go b.runServerDiscovery(clientID, id)
case "getNetStats", "getSSIDList", "setSSID", "updateSetting", "oauth",
"downloadNewGui", "installNewGui", "sendLogs",
"socketCreate", "socketSend", "socketClose":
b.enqueueError(clientID, id, "unsupported")
default:
b.enqueueError(clientID, id, "unsupported")
}
}
func (b *Bridge) runDeviceDiscovery(clientID string, _ interface{}) {
expectedAccount := b.state.Get("margeAccountID")
// The JS "devices" handler reconciles the full list: it removes any device
// not present in the latest message. Sending one device at a time would
// therefore drop the previous device on each update. Always send the
// cumulative list so existing entries are preserved.
var seen []RendererDevice
devices := DiscoverRenderers(expectedAccount, func(d RendererDevice) {
seen = append(seen, d)
b.enqueueMethod(clientID, "devices", seen)
})
if len(devices) == 0 {
b.enqueueMethod(clientID, "devices", []RendererDevice{})
}
}
func (b *Bridge) runServerDiscovery(clientID string, _ interface{}) {
servers := DiscoverServers()
b.enqueueMethod(clientID, "servers", servers)
}
func (b *Bridge) enqueueResult(clientID string, id, result, errVal interface{}) {
b.enqueue(clientID, bridgeMessage{Result: result, Error: errVal, ID: id})
}
func (b *Bridge) enqueueError(clientID string, id interface{}, errMsg string) {
b.enqueue(clientID, bridgeMessage{Result: nil, Error: errMsg, ID: id})
}
func (b *Bridge) enqueueMethod(clientID, method string, params interface{}) {
b.enqueue(clientID, bridgeMessage{Method: method, Params: params, ID: nil})
}
func (b *Bridge) enqueue(clientID string, msg bridgeMessage) {
q := b.getOrCreateQueue(clientID)
q.mu.Lock()
q.msgs = append(q.msgs, msg)
q.mu.Unlock()
}
func (b *Bridge) getOrCreateQueue(clientID string) *clientQueue {
v, _ := b.queues.LoadOrStore(clientID, &clientQueue{})
q, _ := v.(*clientQueue)
if q == nil {
q = &clientQueue{}
}
return q
}
func resolveClientID(r *http.Request) string {
if v := r.Header.Get("X-Stockholm-Client-Id"); v != "" {
return v
}
if v := r.URL.Query().Get("clientId"); v != "" {
decoded, err := url.QueryUnescape(v)
if err == nil {
return decoded
}
return v
}
return "default"
}
func legalDocPath(params map[string]interface{}) string {
typVal, _ := params["type"].(string)
lang, _ := params["lang"].(string)
// "lcns" = third-party platform/GUI licences; the Stockholm zip ships this
// as gui_licenses_en.txt (no per-language variants exist).
if typVal == "lcns" {
return "legal/gui_licenses_en.txt"
}
if typVal == "" || typVal == "eula" {
if lang == "" {
lang = "en"
}
return "legal/eula_" + lang + ".txt"
}
// "privacy" and any other types: the Stockholm zip does not include these
// files, so fall back to the English EULA.
return "legal/eula_en.txt"
}
func localTimezoneName() string {
return time.Local.String()
}
+316
View File
@@ -0,0 +1,316 @@
package stockholm
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ---- resolveClientID ----
func TestResolveClientID_Header(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Stockholm-Client-Id", "tab-123")
if got := resolveClientID(r); got != "tab-123" {
t.Errorf("expected tab-123, got %q", got)
}
}
func TestResolveClientID_QueryParam(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?clientId=browser-abc", nil)
if got := resolveClientID(r); got != "browser-abc" {
t.Errorf("expected browser-abc, got %q", got)
}
}
func TestResolveClientID_Default(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if got := resolveClientID(r); got != "default" {
t.Errorf("expected default, got %q", got)
}
}
func TestResolveClientID_HeaderTakesPrecedence(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?clientId=from-query", nil)
r.Header.Set("X-Stockholm-Client-Id", "from-header")
if got := resolveClientID(r); got != "from-header" {
t.Errorf("expected from-header, got %q", got)
}
}
// ---- legalDocPath ----
func TestLegalDocPath(t *testing.T) {
cases := []struct {
params map[string]interface{}
want string
}{
{map[string]interface{}{"type": "lcns"}, "legal/gui_licenses_en.txt"},
{map[string]interface{}{}, "legal/eula_en.txt"},
{map[string]interface{}{"type": "eula", "lang": "de"}, "legal/eula_de.txt"},
{map[string]interface{}{"type": "privacy"}, "legal/eula_en.txt"},
}
for _, tc := range cases {
if got := legalDocPath(tc.params); got != tc.want {
t.Errorf("legalDocPath(%v) = %q, want %q", tc.params, got, tc.want)
}
}
}
// ---- Bridge dispatch via HTTP handlers ----
func newTestBridge(t *testing.T) *Bridge {
t.Helper()
state := NewNativeState(t.TempDir())
return newBridge(&Config{}, state)
}
func appSend(t *testing.T, b *Bridge, method string, params map[string]interface{}, id interface{}) {
t.Helper()
body := map[string]interface{}{"method": method, "params": params, "id": id}
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal appSend body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/native/appSend", bytes.NewReader(data))
req.Header.Set("X-Stockholm-Client-Id", "test")
rec := httptest.NewRecorder()
b.HandleAppSend(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("HandleAppSend returned %d, want 204", rec.Code)
}
}
func drainQueue(t *testing.T, b *Bridge) []bridgeMessage {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/native/runQueue", nil)
req.Header.Set("X-Stockholm-Client-Id", "test")
rec := httptest.NewRecorder()
b.HandleRunQueue(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HandleRunQueue returned %d, want 200", rec.Code)
}
var resp struct {
Messages []bridgeMessage `json:"messages"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode runQueue response: %v", err)
}
return resp.Messages
}
// TestBridge_DeviceDiscovery_CumulativeList verifies that each "devices" message
// sent during incremental discovery includes all previously found devices.
// The JS "devices" handler reconciles the full list and would drop earlier
// devices if only the latest one were included.
func TestBridge_DeviceDiscovery_CumulativeList(t *testing.T) {
b := newTestBridge(t)
// Simulate what runDeviceDiscovery now does: build a cumulative slice and
// enqueue it with every new device.
d1 := RendererDevice{UID: "AABBCC112233", IP: "192.168.1.10"}
d2 := RendererDevice{UID: "DDEEFF445566", IP: "192.168.1.11"}
var seen []RendererDevice
for _, d := range []RendererDevice{d1, d2} {
seen = append(seen, d)
b.enqueueMethod("test", "devices", seen)
}
msgs := drainQueue(t, b)
if len(msgs) != 2 {
t.Fatalf("expected 2 queued messages, got %d", len(msgs))
}
// First message: only d1
firstParams, ok := msgs[0].Params.([]interface{})
if !ok || len(firstParams) != 1 {
t.Errorf("first message: expected 1-element params, got %v", msgs[0].Params)
}
// Second message: d1 AND d2
secondParams, ok := msgs[1].Params.([]interface{})
if !ok || len(secondParams) != 2 {
t.Errorf("second message: expected 2-element params, got %v", msgs[1].Params)
}
}
func TestBridge_Locale_IsNoOp(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "locale", nil, 1)
msgs := drainQueue(t, b)
if len(msgs) != 0 {
t.Errorf("expected no messages for locale, got %d", len(msgs))
}
}
func TestBridge_GetLanStatus_ReturnsTrue(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "getLanStatus", nil, 42)
msgs := drainQueue(t, b)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Result != true {
t.Errorf("expected result=true, got %v", msgs[0].Result)
}
}
func TestBridge_SetData_Get_RoundTrip(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "setData", map[string]interface{}{"name": "myKey", "value": "hello"}, nil)
appSend(t, b, "getData", map[string]interface{}{"name": "myKey"}, 7)
msgs := drainQueue(t, b)
if len(msgs) != 1 {
t.Fatalf("expected 1 message (for getData), got %d", len(msgs))
}
if msgs[0].Result != "hello" {
t.Errorf("expected result=hello, got %v", msgs[0].Result)
}
}
func TestBridge_GetConstant_Kilo_Default(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "getConstant", map[string]interface{}{"name": "kilo"}, 1)
msgs := drainQueue(t, b)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Result != "a7928d7b43dcd49f0af31e5aeed26458" {
t.Errorf("unexpected kilo value: %v", msgs[0].Result)
}
}
func TestBridge_GetTimeZone_ContainsTimezoneInfo(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "getTimeZone", nil, 2)
msgs := drainQueue(t, b)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
result, ok := msgs[0].Result.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", msgs[0].Result)
}
if _, hasKey := result["timezoneInfo"]; !hasKey {
t.Error("expected timezoneInfo key in getTimeZone result")
}
}
func TestBridge_CanPerformAutoAPSetup_ReturnsFalse(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "canPerformAutoAPSetup", nil, 3)
msgs := drainQueue(t, b)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
result, ok := msgs[0].Result.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", msgs[0].Result)
}
if result["permission"] != false {
t.Errorf("expected permission=false, got %v", result["permission"])
}
}
func TestBridge_UnsupportedMethod_ReturnsError(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "downloadNewGui", nil, 99)
msgs := drainQueue(t, b)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Error == nil {
t.Error("expected error for unsupported method")
}
}
func TestBridge_Log_IsNoOp(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "log", map[string]interface{}{"msg": "hello from js"}, nil)
msgs := drainQueue(t, b)
if len(msgs) != 0 {
t.Errorf("expected no queued messages for log, got %d", len(msgs))
}
}
func TestBridge_GetLegalDocPath(t *testing.T) {
b := newTestBridge(t)
appSend(t, b, "getLegalDocPath", map[string]interface{}{"type": "lcns"}, 5)
msgs := drainQueue(t, b)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if !strings.HasSuffix(msgs[0].Result.(string), "gui_licenses_en.txt") {
t.Errorf("unexpected legal doc path: %v", msgs[0].Result)
}
}
func TestBridge_RunQueue_EmptyWhenNoPendingMessages(t *testing.T) {
b := newTestBridge(t)
msgs := drainQueue(t, b)
if len(msgs) != 0 {
t.Errorf("expected empty queue, got %d messages", len(msgs))
}
}
func TestBridge_AppSend_WrongMethod_Returns405(t *testing.T) {
b := newTestBridge(t)
req := httptest.NewRequest(http.MethodGet, "/api/native/appSend", nil)
rec := httptest.NewRecorder()
b.HandleAppSend(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rec.Code)
}
}
func TestBridge_RunQueue_WrongMethod_Returns405(t *testing.T) {
b := newTestBridge(t)
req := httptest.NewRequest(http.MethodPost, "/api/native/runQueue", nil)
rec := httptest.NewRecorder()
b.HandleRunQueue(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rec.Code)
}
}
+302
View File
@@ -0,0 +1,302 @@
package stockholm
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Config holds the parsed stockholm/json/config.json values.
type Config struct {
AppVersion string
ProtocolVersion string
StreamingVersion string
CustomerVersion string
DefaultMargeURL string
DefaultUpdateURL string
BmxRegistryURL string
AuthServiceURL string
EncryptedBmxToken string
MargeServerKey string
MargeServerKeyHeader string
// BasePath is an optional URL prefix under which the Stockholm frontend is
// served (e.g. "/stockholm"). Empty means served at "/".
BasePath string
}
// BackendConfig holds the parsed backend/config/backend-config.json values.
type BackendConfig struct {
FrontendLoggingLevel int `json:"frontendLoggingLevel"`
}
var versionPrefix = regexp.MustCompile(`^(\d+(?:\.\d+)+)`)
// LoadConfig reads and parses stockholm/json/config.json from stockholmDir.
func LoadConfig(stockholmDir string) (*Config, error) {
path := filepath.Join(stockholmDir, "json", "config.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config.json: %w", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse config.json: %w", err)
}
appVersions := jsonObject(raw["app_versions"])
apiVersions := jsonObject(raw["api_versions"])
defaults := jsonObject(raw["default"])
cfg := &Config{
AppVersion: jsonString(appVersions["bose_app"]),
ProtocolVersion: jsonString(appVersions["bose_protocol"]),
StreamingVersion: firstNonEmpty(jsonString(apiVersions["bose_streaming"]), "1.0"),
CustomerVersion: firstNonEmpty(jsonString(apiVersions["bose_customer"]), "1.0"),
DefaultMargeURL: normalizeBaseURL(decodeB64(jsonString(defaults["d0"]))),
DefaultUpdateURL: normalizeBaseURL(decodeB64(jsonString(defaults["d1"]))),
BmxRegistryURL: decodeB64(jsonString(defaults["d3"])),
AuthServiceURL: decodeB64(jsonString(defaults["d6"])),
EncryptedBmxToken: decodeB64(jsonString(defaults["d7"])),
MargeServerKey: decodeB64(jsonString(defaults["d10"])),
MargeServerKeyHeader: decodeB64(jsonString(defaults["d13"])),
}
return cfg, nil
}
// RewriteConfigURLs updates the base64-encoded URL fields in stockholm/json/config.json
// to point at backendURL. margeURL is used for streaming.bose.com rewrites; if empty it
// defaults to backendURL. Set margeURL to backendURL+"/marge" when using soundcork.
// authServiceURL is written into d6 (the auth endpoint); if empty it defaults to
// backendURL. A trailing slash is always ensured because the JS concatenates paths like
// "oauth/account/..." directly onto this value.
func RewriteConfigURLs(stockholmDir, backendURL, margeURL, authServiceURL string) error {
if margeURL == "" {
margeURL = backendURL
}
if authServiceURL == "" {
authServiceURL = backendURL
}
// Ensure trailing slash so JS path concatenation (e.g. d6 + "oauth/account/...")
// produces a valid URL.
if authServiceURL != "" && !strings.HasSuffix(authServiceURL, "/") {
authServiceURL += "/"
}
path := filepath.Join(stockholmDir, "json", "config.json")
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config.json: %w", err)
}
var raw map[string]json.RawMessage
if unmarshalErr := json.Unmarshal(data, &raw); unmarshalErr != nil {
return fmt.Errorf("parse config.json: %w", unmarshalErr)
}
defaults := jsonObject(raw["default"])
// Decode each field, substitute known hostnames, re-encode
replacements := map[string]string{
"https://streaming.bose.com": margeURL,
"https://events.api.bosecm.com": backendURL,
"https://content.api.bose.io": backendURL,
"https://worldwide.bose.com": backendURL,
"https://downloads.bose.com": backendURL,
}
for key, rawVal := range defaults {
decoded := decodeB64(jsonString(rawVal))
for old, newVal := range replacements {
decoded = strings.ReplaceAll(decoded, old, newVal)
}
defaults[key] = jsonRawString(base64.StdEncoding.EncodeToString([]byte(decoded)))
}
// d6 = auth service base URL; always overwrite with a full URL so the JS
// does not fall back to treating it as a subdomain prefix.
defaults["d6"] = jsonRawString(base64.StdEncoding.EncodeToString([]byte(authServiceURL)))
// Re-serialize defaults back into the raw map
encoded, err := json.Marshal(defaults)
if err != nil {
return fmt.Errorf("marshal defaults: %w", err)
}
raw["default"] = json.RawMessage(encoded)
out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("marshal config.json: %w", err)
}
return os.WriteFile(path, append(out, '\n'), 0644)
}
// LoadBackendConfig reads backend/config/backend-config.json from workspaceRoot.
// Returns defaults if the file is absent.
func LoadBackendConfig(workspaceRoot string) *BackendConfig {
path := filepath.Join(workspaceRoot, "backend", "config", "backend-config.json")
data, err := os.ReadFile(path)
if err != nil {
return &BackendConfig{}
}
var cfg BackendConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return &BackendConfig{}
}
if cfg.FrontendLoggingLevel < 0 {
cfg.FrontendLoggingLevel = 0
}
return &cfg
}
// StreamingMediaType returns the Accept/Content-Type for streaming API calls.
func (c *Config) StreamingMediaType() string {
return "application/vnd.bose.streaming-v" + c.StreamingVersion + "+xml"
}
// CustomerMediaType returns the Accept/Content-Type for customer API calls.
func (c *Config) CustomerMediaType() string {
return "application/vnd.bose.customer-v" + c.CustomerVersion + "+xml"
}
// MediaTypeForPath returns the appropriate media type based on path.
func (c *Config) MediaTypeForPath(path string) string {
p := strings.ToLower(path)
if strings.Contains(p, "/customer/") {
return c.CustomerMediaType()
}
if strings.Contains(p, "/streaming/") {
return c.StreamingMediaType()
}
return "application/xml"
}
// IsBmxTarget returns true if host is a BMX API target.
func (c *Config) IsBmxTarget(host string) bool {
h := strings.ToLower(host)
return h == "content.api.bose.io" ||
h == "test.content.api.bose.io" ||
h == "bose-prod.apigee.net" ||
strings.HasSuffix(h, ".apigee.net")
}
// IsMargeTarget returns true if host+path is a Marge streaming/customer endpoint.
func (c *Config) IsMargeTarget(host, path string) bool {
h := strings.ToLower(host)
p := strings.ToLower(path)
if !strings.Contains(p, "/streaming/") && !strings.Contains(p, "/customer/") {
return false
}
return strings.HasSuffix(h, ".bose.com") || strings.HasSuffix(h, ".apigee.net")
}
// ExtractVersionPrefix returns the leading version number (e.g. "27.0" from "27.0.13-xyz").
func ExtractVersionPrefix(v string) string {
m := versionPrefix.FindStringSubmatch(v)
if len(m) >= 2 {
return m[1]
}
return ""
}
// ShouldEnableFrontendDebug returns true when logging level > 0.
func (b *BackendConfig) ShouldEnableFrontendDebug() bool {
return b.FrontendLoggingLevel > 0
}
// helpers
func decodeB64(s string) string {
if s == "" {
return ""
}
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
// Some values may not be base64 (already plain), return as-is
return s
}
return string(b)
}
func normalizeBaseURL(s string) string {
if s == "" {
return ""
}
if !strings.HasSuffix(s, "/") {
return s + "/"
}
return s
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func jsonObject(raw json.RawMessage) map[string]json.RawMessage {
if raw == nil {
return nil
}
var obj map[string]json.RawMessage
_ = json.Unmarshal(raw, &obj)
return obj
}
func jsonString(raw json.RawMessage) string {
if raw == nil {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return ""
}
return s
}
func jsonRawString(s string) json.RawMessage {
// json.Marshal on a string never fails
b, err := json.Marshal(s)
if err != nil {
return json.RawMessage(`""`)
}
return json.RawMessage(b)
}
+349
View File
@@ -0,0 +1,349 @@
package stockholm
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// ---- ExtractVersionPrefix ----
func TestExtractVersionPrefix(t *testing.T) {
cases := []struct {
input string
want string
}{
{"27.0.13-release", "27.0.13"},
{"27.0", "27.0"},
{"1.2.3.4", "1.2.3.4"},
{"v27.0", ""},
{"", ""},
{"release-27.0", ""},
}
for _, tc := range cases {
if got := ExtractVersionPrefix(tc.input); got != tc.want {
t.Errorf("ExtractVersionPrefix(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
// ---- helper functions ----
func TestFirstNonEmpty(t *testing.T) {
if got := firstNonEmpty("", "", "third", "fourth"); got != "third" {
t.Errorf("expected third, got %q", got)
}
if got := firstNonEmpty("", ""); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestNormalizeBaseURL(t *testing.T) {
if got := normalizeBaseURL("http://example.com"); got != "http://example.com/" {
t.Errorf("expected trailing slash, got %q", got)
}
if got := normalizeBaseURL("http://example.com/"); got != "http://example.com/" {
t.Errorf("expected no double slash, got %q", got)
}
if got := normalizeBaseURL(""); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestDecodeB64(t *testing.T) {
original := "https://streaming.bose.com"
encoded := base64.StdEncoding.EncodeToString([]byte(original))
if got := decodeB64(encoded); got != original {
t.Errorf("decodeB64 failed: got %q, want %q", got, original)
}
if got := decodeB64(""); got != "" {
t.Errorf("expected empty for empty input, got %q", got)
}
// Not valid base64 → returned as-is
if got := decodeB64("plain text"); got != "plain text" {
t.Errorf("expected plain text returned as-is, got %q", got)
}
}
// ---- LoadConfig / RewriteConfigURLs ----
func makeConfigJSON(t *testing.T, defaults map[string]string) string {
t.Helper()
// Encode each default value as base64
encodedDefaults := make(map[string]interface{})
for k, v := range defaults {
encodedDefaults[k] = base64.StdEncoding.EncodeToString([]byte(v))
}
raw := map[string]interface{}{
"app_versions": map[string]interface{}{
"bose_app": "27.0.13-release",
"bose_protocol": "1.0",
},
"api_versions": map[string]interface{}{
"bose_streaming": "1.2",
"bose_customer": "1.3",
},
"default": encodedDefaults,
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
t.Fatalf("marshal config: %v", err)
}
return string(data)
}
func writeStockholmConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
jsonDir := filepath.Join(dir, "json")
if err := os.MkdirAll(jsonDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(jsonDir, "config.json"), []byte(content), 0644); err != nil {
t.Fatalf("write config.json: %v", err)
}
return dir
}
func TestLoadConfig_ParsesVersions(t *testing.T) {
content := makeConfigJSON(t, map[string]string{
"d0": "https://streaming.bose.com/marge/",
})
dir := writeStockholmConfig(t, content)
cfg, err := LoadConfig(dir)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.AppVersion != "27.0.13-release" {
t.Errorf("AppVersion = %q, want %q", cfg.AppVersion, "27.0.13-release")
}
if cfg.StreamingVersion != "1.2" {
t.Errorf("StreamingVersion = %q, want %q", cfg.StreamingVersion, "1.2")
}
}
func TestLoadConfig_DefaultsForMissingAPIVersions(t *testing.T) {
// api_versions absent → defaults to "1.0"
raw := map[string]interface{}{
"app_versions": map[string]interface{}{
"bose_app": "27.0",
"bose_protocol": "1.0",
},
"api_versions": map[string]interface{}{},
"default": map[string]interface{}{},
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
t.Fatalf("marshal config: %v", err)
}
dir := writeStockholmConfig(t, string(data))
cfg, err := LoadConfig(dir)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.StreamingVersion != "1.0" {
t.Errorf("expected default StreamingVersion=1.0, got %q", cfg.StreamingVersion)
}
}
func TestRewriteConfigURLs_ReplacesHostnames(t *testing.T) {
content := makeConfigJSON(t, map[string]string{
"d0": "https://streaming.bose.com/marge/",
"d1": "https://downloads.bose.com/updates/",
"d3": "https://content.api.bose.io/registry",
})
dir := writeStockholmConfig(t, content)
backendURL := "http://myserver:8000"
if err := RewriteConfigURLs(dir, backendURL, backendURL, backendURL); err != nil {
t.Fatalf("RewriteConfigURLs: %v", err)
}
// Reload and verify
cfg, err := LoadConfig(dir)
if err != nil {
t.Fatalf("LoadConfig after rewrite: %v", err)
}
if !strings.HasPrefix(cfg.DefaultMargeURL, backendURL) {
t.Errorf("DefaultMargeURL = %q, expected prefix %q", cfg.DefaultMargeURL, backendURL)
}
if !strings.HasPrefix(cfg.DefaultUpdateURL, backendURL) {
t.Errorf("DefaultUpdateURL = %q, expected prefix %q", cfg.DefaultUpdateURL, backendURL)
}
}
func TestRewriteConfigURLs_MargeURLUsedForStreaming(t *testing.T) {
content := makeConfigJSON(t, map[string]string{
"d0": "https://streaming.bose.com/",
})
dir := writeStockholmConfig(t, content)
backendURL := "http://backend:8000"
margeURL := "http://backend:8000/marge"
if err := RewriteConfigURLs(dir, backendURL, margeURL, backendURL); err != nil {
t.Fatalf("RewriteConfigURLs: %v", err)
}
cfg, err := LoadConfig(dir)
if err != nil {
t.Fatalf("LoadConfig after rewrite: %v", err)
}
if !strings.HasPrefix(cfg.DefaultMargeURL, margeURL) {
t.Errorf("DefaultMargeURL = %q, expected prefix %q", cfg.DefaultMargeURL, margeURL)
}
}
func TestRewriteConfigURLs_AuthServiceURLHasTrailingSlash(t *testing.T) {
content := makeConfigJSON(t, map[string]string{
"d6": "oauth", // original Bose placeholder
})
dir := writeStockholmConfig(t, content)
backendURL := "http://backend:8000"
// Without trailing slash — function should add it.
if err := RewriteConfigURLs(dir, backendURL, backendURL, backendURL); err != nil {
t.Fatalf("RewriteConfigURLs: %v", err)
}
cfg, err := LoadConfig(dir)
if err != nil {
t.Fatalf("LoadConfig after rewrite: %v", err)
}
if !strings.HasSuffix(cfg.AuthServiceURL, "/") {
t.Errorf("AuthServiceURL = %q, expected trailing slash", cfg.AuthServiceURL)
}
if !strings.HasPrefix(cfg.AuthServiceURL, backendURL) {
t.Errorf("AuthServiceURL = %q, expected prefix %q", cfg.AuthServiceURL, backendURL)
}
}
func TestRewriteConfigURLs_AuthServiceURL_ExplicitValue(t *testing.T) {
content := makeConfigJSON(t, map[string]string{
"d6": "oauth",
})
dir := writeStockholmConfig(t, content)
backendURL := "http://backend:8000"
authURL := "http://auth.backend:8001"
if err := RewriteConfigURLs(dir, backendURL, backendURL, authURL); err != nil {
t.Fatalf("RewriteConfigURLs: %v", err)
}
cfg, err := LoadConfig(dir)
if err != nil {
t.Fatalf("LoadConfig after rewrite: %v", err)
}
if !strings.HasPrefix(cfg.AuthServiceURL, authURL) {
t.Errorf("AuthServiceURL = %q, expected prefix %q", cfg.AuthServiceURL, authURL)
}
if !strings.HasSuffix(cfg.AuthServiceURL, "/") {
t.Errorf("AuthServiceURL = %q, expected trailing slash", cfg.AuthServiceURL)
}
}
// ---- MediaType helpers ----
func TestStreamingMediaType(t *testing.T) {
cfg := &Config{StreamingVersion: "1.2"}
want := "application/vnd.bose.streaming-v1.2+xml"
if got := cfg.StreamingMediaType(); got != want {
t.Errorf("StreamingMediaType() = %q, want %q", got, want)
}
}
func TestMediaTypeForPath(t *testing.T) {
cfg := &Config{StreamingVersion: "1.2", CustomerVersion: "1.3"}
cases := []struct {
path string
want string
}{
{"/customer/login", "application/vnd.bose.customer-v1.3+xml"},
{"/streaming/content", "application/vnd.bose.streaming-v1.2+xml"},
{"/info", "application/xml"},
}
for _, tc := range cases {
if got := cfg.MediaTypeForPath(tc.path); got != tc.want {
t.Errorf("MediaTypeForPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
// ---- LoadBackendConfig ----
func TestLoadBackendConfig_MissingFile_ReturnsDefaults(t *testing.T) {
cfg := LoadBackendConfig(t.TempDir())
if cfg.FrontendLoggingLevel != 0 {
t.Errorf("expected default FrontendLoggingLevel=0, got %d", cfg.FrontendLoggingLevel)
}
}
func TestLoadBackendConfig_ParsesLevel(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "backend", "config")
_ = os.MkdirAll(cfgDir, 0755)
_ = os.WriteFile(filepath.Join(cfgDir, "backend-config.json"),
[]byte(`{"frontendLoggingLevel": 3}`), 0644)
cfg := LoadBackendConfig(dir)
if cfg.FrontendLoggingLevel != 3 {
t.Errorf("expected FrontendLoggingLevel=3, got %d", cfg.FrontendLoggingLevel)
}
if !cfg.ShouldEnableFrontendDebug() {
t.Error("expected ShouldEnableFrontendDebug=true for level 3")
}
}
func TestLoadBackendConfig_NegativeLevel_Clamped(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "backend", "config")
_ = os.MkdirAll(cfgDir, 0755)
_ = os.WriteFile(filepath.Join(cfgDir, "backend-config.json"),
[]byte(`{"frontendLoggingLevel": -1}`), 0644)
cfg := LoadBackendConfig(dir)
if cfg.FrontendLoggingLevel != 0 {
t.Errorf("expected clamped FrontendLoggingLevel=0, got %d", cfg.FrontendLoggingLevel)
}
}
+432
View File
@@ -0,0 +1,432 @@
package stockholm
import (
"encoding/xml"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const (
ssdpAddr = "239.255.255.250:1900"
rendererST = "urn:schemas-upnp-org:device:MediaRenderer:1"
serverST = "urn:schemas-upnp-org:device:MediaServer:1"
ssdpProbes = 3
ssdpProbeIntervalMS = 350
ssdpGraceMS = 1250
ssdpReceiveSliceMS = 250
ssdpMX = 1
)
// RendererDevice is the payload pushed to the browser for a discovered speaker.
type RendererDevice struct {
UID string `json:"uID"`
IP string `json:"ip"`
}
// ServerDevice is the payload pushed for a discovered HRMS media server.
type ServerDevice struct {
UID string `json:"uID"`
IP string `json:"ip"`
Port string `json:"port"`
}
// infoXML is used to unmarshal /info responses from speakers.
type infoXML struct {
DeviceID string `xml:"deviceID,attr"`
MargeAccountUUID string `xml:"margeAccountUUID"`
}
// DiscoverRenderers performs SSDP MediaRenderer:1 discovery, fetches /info from
// each speaker, optionally filters by expectedAccountID, and calls onDevice for
// each accepted speaker incrementally.
func DiscoverRenderers(expectedAccountID string, onDevice func(RendererDevice)) []RendererDevice {
responses := ssdpSearch(rendererST)
seen := make(map[string]bool)
var results []RendererDevice
for _, resp := range responses {
host := hostFromSSDPResponse(resp)
if host == "" || seen[host] {
continue
}
seen[host] = true
info, err := fetchSpeakerInfo(host)
if err != nil {
log.Printf("[Stockholm SSDP] Failed to fetch /info from %s: %v", host, err)
continue
}
if expectedAccountID != "" && info.MargeAccountUUID != expectedAccountID {
log.Printf("[Stockholm SSDP] Skipping %s: account %q != %q", host, info.MargeAccountUUID, expectedAccountID)
continue
}
uid := strings.ToUpper(info.DeviceID)
if uid == "" {
continue
}
d := RendererDevice{UID: uid, IP: host}
results = append(results, d)
if onDevice != nil {
onDevice(d)
}
}
return results
}
// DiscoverServers performs SSDP MediaServer:1 discovery and returns all found servers.
func DiscoverServers() []ServerDevice {
responses := ssdpSearch(serverST)
seen := make(map[string]bool)
var results []ServerDevice
for _, resp := range responses {
location := resp["location"]
if location == "" {
continue
}
u, err := url.Parse(location)
if err != nil || u.Host == "" {
continue
}
host := u.Hostname()
portStr := u.Port()
if portStr == "" {
switch u.Scheme {
case "https":
portStr = "443"
default:
portStr = "80"
}
}
key := host + ":" + portStr
if seen[key] {
continue
}
seen[key] = true
uid := normalizeUSN(resp["usn"], key)
results = append(results, ServerDevice{UID: uid, IP: host, Port: portStr})
}
return results
}
// ssdpSearch sends SSDP M-SEARCH requests and returns raw response header maps.
func ssdpSearch(searchTarget string) []map[string]string {
ifaces := discoveryInterfaces()
if len(ifaces) == 0 {
return searchOnInterface(searchTarget, nil)
}
for _, iface := range ifaces {
results := searchOnInterface(searchTarget, &iface)
if len(results) > 0 {
return results
}
}
return nil
}
func searchOnInterface(searchTarget string, iface *net.Interface) []map[string]string {
mcastAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
if err != nil {
return nil
}
var conn *net.UDPConn
if iface == nil {
conn, err = net.ListenUDP("udp4", &net.UDPAddr{})
} else {
bindAddr := primaryIPv4(iface)
if bindAddr == nil {
return nil
}
conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: bindAddr})
}
if err != nil {
return nil
}
defer func() { _ = conn.Close() }()
payload := []byte(buildMSearch(searchTarget))
seen := make(map[string]map[string]string)
// 3 probes
deadline := time.Now().Add(time.Duration(ssdpProbes*ssdpProbeIntervalMS+ssdpGraceMS) * time.Millisecond)
_ = conn.SetReadDeadline(deadline)
for probe := 0; probe < ssdpProbes; probe++ {
if _, err := conn.WriteToUDP(payload, mcastAddr); err != nil {
log.Printf("[Stockholm SSDP] Send error: %v", err)
break
}
collectUntil(conn, searchTarget, seen, time.Now().Add(time.Duration(ssdpProbeIntervalMS)*time.Millisecond))
}
collectUntil(conn, searchTarget, seen, time.Now().Add(time.Duration(ssdpGraceMS)*time.Millisecond))
result := make([]map[string]string, 0, len(seen))
for _, v := range seen {
result = append(result, v)
}
return result
}
func collectUntil(conn *net.UDPConn, searchTarget string, seen map[string]map[string]string, until time.Time) {
buf := make([]byte, 8192)
for {
remaining := time.Until(until)
if remaining <= 0 {
return
}
slice := time.Duration(ssdpReceiveSliceMS) * time.Millisecond
if slice > remaining {
slice = remaining
}
_ = conn.SetReadDeadline(time.Now().Add(slice))
n, remoteAddr, err := conn.ReadFromUDP(buf)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return
}
return
}
headers := parseSSDPHeaders(buf[:n], remoteAddr.IP.String())
if !matchesST(headers, searchTarget) {
continue
}
key := responseKey(headers)
if _, exists := seen[key]; !exists {
seen[key] = headers
}
}
}
func parseSSDPHeaders(data []byte, remoteIP string) map[string]string {
out := map[string]string{"remote-ip": remoteIP}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimRight(line, "\r")
idx := strings.IndexByte(line, ':')
if idx <= 0 {
continue
}
k := strings.ToLower(strings.TrimSpace(line[:idx]))
v := strings.TrimSpace(line[idx+1:])
if _, exists := out[k]; !exists {
out[k] = v
}
}
return out
}
func matchesST(resp map[string]string, target string) bool {
if st := resp["st"]; strings.EqualFold(st, target) {
return true
}
if usn := resp["usn"]; strings.Contains(strings.ToLower(usn), strings.ToLower(target)) {
return true
}
return false
}
func responseKey(resp map[string]string) string {
return resp["usn"] + "|" + resp["location"] + "|" + resp["remote-ip"]
}
func hostFromSSDPResponse(resp map[string]string) string {
if loc := resp["location"]; loc != "" {
if u, err := url.Parse(loc); err == nil && u.Host != "" {
return u.Hostname()
}
}
return resp["remote-ip"]
}
func fetchSpeakerInfo(host string) (*infoXML, error) {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://%s:8090/info", host))
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var info infoXML
if err := xml.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
}
return &info, nil
}
func normalizeUSN(usn, fallback string) string {
if usn == "" {
return fallback
}
// Strip "::urn:..." suffix
if i := strings.Index(usn, "::"); i >= 0 {
usn = usn[:i]
}
// Strip "uuid:" prefix
if strings.HasPrefix(strings.ToLower(usn), "uuid:") {
usn = usn[5:]
}
if usn == "" {
return fallback
}
return usn
}
func buildMSearch(st string) string {
return strings.Join([]string{
"M-SEARCH * HTTP/1.1",
"Host:239.255.255.250:1900",
`Man:"ssdp:discover"`,
fmt.Sprintf("MX:%d", ssdpMX),
"ST:" + st,
"",
"",
}, "\r\n")
}
// discoveryInterfaces returns suitable network interfaces sorted by priority:
// ethernet/en* first, then wifi/wl*, then others. Loopback/virtual/docker etc. are excluded.
func discoveryInterfaces() []net.Interface {
all, err := net.Interfaces()
if err != nil {
return nil
}
var ifaces []net.Interface
for _, iface := range all {
if !isDiscoveryInterface(iface) {
continue
}
ifaces = append(ifaces, iface)
}
// Sort: ethernet first (priority 0), wifi (1), others (2)
for i := 0; i < len(ifaces); i++ {
for j := i + 1; j < len(ifaces); j++ {
if interfacePriority(ifaces[i]) > interfacePriority(ifaces[j]) {
ifaces[i], ifaces[j] = ifaces[j], ifaces[i]
}
}
}
return ifaces
}
func isDiscoveryInterface(iface net.Interface) bool {
if iface.Flags&net.FlagUp == 0 {
return false
}
if iface.Flags&net.FlagLoopback != 0 {
return false
}
if iface.Flags&net.FlagMulticast == 0 {
return false
}
desc := strings.ToLower(iface.Name + " " + iface.Name)
for _, banned := range []string{"docker", "vbox", "vmware", "hyper-v", "loopback", "bluetooth", "teredo", "tunnel"} {
if strings.Contains(desc, banned) {
return false
}
}
return primaryIPv4(&iface) != nil
}
func interfacePriority(iface net.Interface) int {
name := strings.ToLower(iface.Name)
if strings.HasPrefix(name, "eth") || strings.HasPrefix(name, "en") {
return 0
}
if strings.HasPrefix(name, "wl") || strings.Contains(name, "wifi") || strings.Contains(name, "wlan") {
return 1
}
return 2
}
func primaryIPv4(iface *net.Interface) net.IP {
addrs, err := iface.Addrs()
if err != nil {
return nil
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip4 := ip.To4(); ip4 != nil && !ip4.IsLoopback() {
return ip4
}
}
return nil
}
+140
View File
@@ -0,0 +1,140 @@
package stockholm
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
)
// Handler is the main entry point for the Stockholm frontend backend.
type Handler struct {
cfg *Config
backendCfg *BackendConfig
state *NativeState
bridge *Bridge
stockholmDir string
}
// New initialises and returns a Stockholm Handler.
//
// stockholmDir is the path to the extracted Stockholm frontend (contains index.html).
// workspaceRoot is used to locate backend/state and backend/config directories.
// backendURL is the external URL of this service (used for config URL rewriting).
// basePath is the URL prefix at which the Stockholm UI is mounted (e.g. "/stockholm");
// pass "" to serve at the root.
func New(stockholmDir, workspaceRoot, backendURL, basePath string) (*Handler, error) {
if _, err := os.Stat(stockholmDir); err != nil {
return nil, fmt.Errorf("stockholm dir not found at %q: %w", stockholmDir, err)
}
cfg, err := LoadConfig(stockholmDir)
if err != nil {
return nil, fmt.Errorf("load stockholm config: %w", err)
}
if backendURL != "" {
margeURL := firstNonEmpty(os.Getenv("MARGE_URL"), backendURL)
authServiceURL := firstNonEmpty(os.Getenv("AUTH_SERVICE_URL"), backendURL)
if err := RewriteConfigURLs(stockholmDir, backendURL, margeURL, authServiceURL); err != nil {
log.Printf("[Stockholm] Warning: failed to rewrite config URLs: %v", err)
}
}
backendCfg := LoadBackendConfig(workspaceRoot)
stateDir := filepath.Join(workspaceRoot, "backend", "state")
if err := os.MkdirAll(stateDir, 0755); err != nil {
return nil, fmt.Errorf("create state dir: %w", err)
}
state := NewNativeState(stateDir)
if err := state.Load(); err != nil {
log.Printf("[Stockholm] Warning: failed to load native state: %v", err)
}
// Normalise basePath: no trailing slash, must start with "/" or be empty.
if basePath != "" && !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
}
basePath = strings.TrimRight(basePath, "/")
cfg.BasePath = basePath
state.SeedFromEnv(cfg)
bridge := newBridge(cfg, state)
return &Handler{
cfg: cfg,
backendCfg: backendCfg,
state: state,
bridge: bridge,
stockholmDir: stockholmDir,
}, nil
}
// Mount registers all Stockholm routes on the given chi router.
// API routes (/api/native/*, /api/http-proxy) are registered under cfg.BasePath
// because the patched JS uses window.__stockholmBase as a prefix for all API calls.
// Static content is served under cfg.BasePath (e.g. /stockholm) if set,
// otherwise at the root.
func (h *Handler) Mount(r chi.Router) {
apiBase := h.cfg.BasePath
r.Post(apiBase+"/api/native/appSend", h.bridge.HandleAppSend)
r.Get(apiBase+"/api/native/runQueue", h.bridge.HandleRunQueue)
r.HandleFunc(apiBase+"/api/http-proxy", h.handleProxy)
if h.cfg.BasePath != "" {
// Redirect bare /stockholm to /stockholm/ so the browser sets the correct
// base URL for relative asset references.
r.Get(h.cfg.BasePath, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, h.cfg.BasePath+"/", http.StatusMovedPermanently)
})
// Strip the base path prefix before passing to handleStatic so that
// resolveStaticFile sees paths like "/" or "/index.html", not "/stockholm/".
// r.Route does NOT strip r.URL.Path, so we must use http.StripPrefix explicitly.
stripped := http.StripPrefix(h.cfg.BasePath, http.HandlerFunc(h.handleStatic))
r.Get(h.cfg.BasePath+"/", stripped.ServeHTTP)
r.Head(h.cfg.BasePath+"/", stripped.ServeHTTP)
r.Get(h.cfg.BasePath+"/*", stripped.ServeHTTP)
r.Head(h.cfg.BasePath+"/*", stripped.ServeHTTP)
} else {
// Serve static content at the root (catch-all at the end).
r.Get("/*", h.handleStatic)
r.Head("/*", h.handleStatic)
r.Get("/", h.handleStatic)
}
}
func (h *Handler) handleProxy(w http.ResponseWriter, r *http.Request) {
HandleProxy(w, r, h.cfg, h.state)
}
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
}
// HandleStatic is the exported form of handleStatic, needed when mounting the
// Stockholm static handler inside sub-routers (e.g. to resolve the /setup/ path
// collision between the management API and the Stockholm setup wizard pages).
func (h *Handler) HandleStatic(w http.ResponseWriter, r *http.Request) {
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
}
// Config returns the loaded Stockholm config (for integration with the proxy handler
// that may need to inject BMX/marge headers).
func (h *Handler) Config() *Config {
return h.cfg
}
// State returns the NativeState (for integration with handlers that need to read
// auth tokens or account IDs).
func (h *Handler) State() *NativeState {
return h.state
}
+631
View File
@@ -0,0 +1,631 @@
package stockholm
import (
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
)
var blockedRequestHeaders = map[string]bool{
"access-control-request-headers": true,
"access-control-request-method": true,
"connection": true,
"content-length": true,
"cookie": true,
"forwarded": true,
"host": true,
"http2-settings": true,
"keep-alive": true,
"origin": true,
"proxy-authenticate": true,
"proxy-authorization": true,
"referer": true,
"sec-ch-ua": true,
"sec-ch-ua-mobile": true,
"sec-ch-ua-platform": true,
"sec-fetch-dest": true,
"sec-fetch-mode": true,
"sec-fetch-site": true,
"sec-fetch-user": true,
"te": true,
"trailer": true,
"transfer-encoding": true,
"upgrade": true,
"x-forwarded-for": true,
"x-forwarded-host": true,
"x-forwarded-port": true,
"x-forwarded-proto": true,
"x-real-ip": true,
"x-requested-with": true,
}
var blockedResponseHeaders = map[string]bool{
"access-control-allow-credentials": true,
"access-control-allow-headers": true,
"access-control-allow-methods": true,
"access-control-allow-origin": true,
"access-control-expose-headers": true,
"access-control-max-age": true,
"connection": true,
"content-length": true,
"keep-alive": true,
"proxy-authenticate": true,
"proxy-authorization": true,
"set-cookie": true,
"set-cookie2": true,
"te": true,
"trailer": true,
"transfer-encoding": true,
"upgrade": true,
}
var proxyHTTPClient = &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return nil // follow redirects
},
}
// HandleProxy serves the /api/http-proxy endpoint.
func HandleProxy(w http.ResponseWriter, r *http.Request, cfg *Config, state *NativeState) {
encodedTarget := r.URL.Query().Get("url")
if encodedTarget == "" {
http.Error(w, "Missing url query parameter", http.StatusBadRequest)
return
}
decodedTarget, err := url.QueryUnescape(encodedTarget)
if err != nil {
http.Error(w, "Invalid proxy target encoding", http.StatusBadRequest)
return
}
target, err := url.Parse(decodedTarget)
if err != nil || target.Host == "" {
http.Error(w, "Invalid proxy target", http.StatusBadRequest)
return
}
scheme := strings.ToLower(target.Scheme)
if scheme != "http" && scheme != "https" {
http.Error(w, "Unsupported proxy target", http.StatusBadRequest)
return
}
if isProxyLoop(r, target) {
http.Error(w, "Refusing to proxy proxy endpoint", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadGateway)
return
}
effectiveTarget := applyOverrideURL(target, cfg, state)
resp, err := executeProxyRequest(r, effectiveTarget, body, cfg, state)
if err != nil {
log.Printf("[Stockholm proxy] %s %s failed: %v", r.Method, effectiveTarget, err)
http.Error(w, "Proxy request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Failed to read proxy response", http.StatusBadGateway)
return
}
// Login retry logic
if isLoginRequest(r.Method, effectiveTarget) {
resp, respBody, effectiveTarget = handleLoginRetry(r, effectiveTarget, body, resp, respBody, cfg, state)
captureSuccessfulLogin(resp, respBody, state)
}
captureRefreshedToken(effectiveTarget, resp, cfg, state)
relayProxyResponse(w, r.Method, resp, respBody)
}
func executeProxyRequest(r *http.Request, target *url.URL, body []byte, cfg *Config, state *NativeState) (*http.Response, error) {
method := strings.ToUpper(r.Method)
var bodyReader io.Reader
if method != "GET" && method != "HEAD" && len(body) > 0 {
bodyReader = strings.NewReader(string(body))
}
req, err := http.NewRequestWithContext(r.Context(), method, target.String(), bodyReader)
if err != nil {
return nil, err
}
// Forward allowed request headers
for k, vals := range r.Header {
if blockedRequestHeaders[strings.ToLower(k)] {
continue
}
for _, v := range sanitizeHeaderValues(vals) {
req.Header.Add(k, v)
}
}
// Inject backend headers
injectBackendHeaders(req, target, cfg, state)
return proxyHTTPClient.Do(req)
}
func injectBackendHeaders(req *http.Request, target *url.URL, cfg *Config, state *NativeState) {
host := target.Hostname()
path := target.Path
if cfg.IsBmxTarget(host) {
injectIfMissing(req, "x-bmx-api-key", cfg.EncryptedBmxToken)
injectIfMissing(req, "x-software-version", cfg.AppVersion)
}
if cfg.IsMargeTarget(host, path) {
mediaType := cfg.MediaTypeForPath(path)
injectIfMissing(req, "Accept", mediaType)
injectIfMissing(req, "Content-Type", mediaType)
injectIfMissing(req, "ClientType", "SOUNDTOUCH_COMPUTER_APP")
injectIfMissing(req, "GUID", firstNonEmpty(state.Get("guid"), state.Get("deviceGuid")))
injectIfMissing(req, "version_NativeFrameVersion", state.Get("nativeFrameVersion"))
injectIfMissing(req, "version_StockholmVersion", cfg.AppVersion)
injectIfMissing(req, "version_ProtocolVersion", cfg.ProtocolVersion)
if cfg.MargeServerKeyHeader != "" && cfg.MargeServerKey != "" {
injectIfMissing(req, cfg.MargeServerKeyHeader, cfg.MargeServerKey)
}
if shouldInjectAuth(path) {
injectIfMissing(req, "Authorization", state.Get("margeAuthToken"))
}
}
}
func shouldInjectAuth(path string) bool {
p := strings.ToLower(path)
if strings.HasSuffix(p, "/streaming/account/login") {
return false
}
if p == "/streaming/account" || p == "/streaming/account/" {
return false
}
if strings.Contains(p, "/streaming/account/email/") && strings.HasSuffix(p, "/environment") {
return false
}
if strings.HasPrefix(p, "/customer/account/password/email/") {
return false
}
return true
}
func injectIfMissing(req *http.Request, name, value string) {
if value == "" {
return
}
if req.Header.Get(name) != "" {
return
}
req.Header.Set(name, value)
}
func sanitizeHeaderValues(vals []string) []string {
out := make([]string, 0, len(vals))
for _, v := range vals {
t := strings.TrimSpace(v)
if t == "" || strings.EqualFold(t, "null") || strings.EqualFold(t, "undefined") {
continue
}
out = append(out, t)
}
return out
}
func handleLoginRetry(
r *http.Request,
target *url.URL,
body []byte,
resp *http.Response,
respBody []byte,
cfg *Config,
state *NativeState,
) (*http.Response, []byte, *url.URL) {
if extractXMLStatusCode(respBody) != "4033" {
return resp, respBody, target
}
email, password := parseLoginCredentials(body)
if email == "" {
return resp, respBody, target
}
env := fetchEnvironment(r, target, email, password, cfg, state)
if env == nil || env.streamingURL == "" {
return resp, respBody, target
}
state.PutMany(map[string]string{
"overrideMargeURL": normalizeBaseURL(env.streamingURL),
"overrideUpdateURL": normalizeBaseURL(env.updateURL),
})
retryTarget := buildURIFromBase(env.streamingURL, target.Path, target.RawQuery)
if retryTarget == nil {
return resp, respBody, target
}
_ = resp.Body.Close()
retryResp, err := executeProxyRequest(r, retryTarget, body, cfg, state)
if err != nil {
log.Printf("[Stockholm proxy] Login retry failed: %v", err)
return resp, respBody, target
}
retryBody, err := io.ReadAll(retryResp.Body)
_ = retryResp.Body.Close()
if err != nil {
return resp, respBody, target
}
return retryResp, retryBody, retryTarget
}
func fetchEnvironment(r *http.Request, loginTarget *url.URL, email, password string, cfg *Config, state *NativeState) *environmentInfo {
// Build environment URL
prefix := margePathPrefix(loginTarget.Path)
envPath := prefix + "/streaming/account/email/" + url.PathEscape(email) + "/environment"
envTarget := buildURIFromBase(loginTarget.Scheme+"://"+loginTarget.Host+"/", envPath, "")
if envTarget == nil {
return nil
}
envReq, err := http.NewRequestWithContext(r.Context(), "GET", envTarget.String(), nil)
if err != nil {
return nil
}
// Copy allowed headers from original request
for k, vals := range r.Header {
if blockedRequestHeaders[strings.ToLower(k)] {
continue
}
for _, v := range sanitizeHeaderValues(vals) {
envReq.Header.Add(k, v)
}
}
raw := email + ":" + password
envReq.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
injectBackendHeaders(envReq, envTarget, cfg, state)
envResp, err := proxyHTTPClient.Do(envReq)
if err != nil {
return nil
}
defer func() { _ = envResp.Body.Close() }()
if envResp.StatusCode != 200 {
return nil
}
body, _ := io.ReadAll(envResp.Body)
return extractEnvironment(body)
}
func captureSuccessfulLogin(resp *http.Response, body []byte, state *NativeState) {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return
}
updates := make(map[string]string)
if accountID := extractXMLAccountID(body); accountID != "" {
updates["margeAccountID"] = accountID
}
if creds := resp.Header.Get("Credentials"); creds != "" {
updates["margeAuthToken"] = creds
}
if len(updates) > 0 {
state.PutMany(updates)
}
}
func captureRefreshedToken(target *url.URL, resp *http.Response, cfg *Config, state *NativeState) {
if target == nil || !cfg.IsMargeTarget(target.Hostname(), target.Path) {
return
}
if refreshed := resp.Header.Get("Refresh"); refreshed != "" {
state.Set("margeAuthToken", refreshed)
}
}
func relayProxyResponse(w http.ResponseWriter, method string, resp *http.Response, body []byte) {
for k, vals := range resp.Header {
kl := strings.ToLower(k)
if kl == "" || strings.HasPrefix(kl, ":") || blockedResponseHeaders[kl] {
continue
}
for _, v := range vals {
w.Header().Add(k, v)
}
}
w.Header().Set("Cache-Control", "no-store")
bodyAllowed := method != "HEAD" && resp.StatusCode != 204 && resp.StatusCode != 304
if bodyAllowed {
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
}
w.WriteHeader(resp.StatusCode)
if bodyAllowed {
_, _ = w.Write(body)
}
}
func isLoginRequest(method string, target *url.URL) bool {
return strings.EqualFold(method, "POST") &&
target != nil &&
strings.HasSuffix(strings.ToLower(target.Path), "/streaming/account/login")
}
func isProxyLoop(r *http.Request, target *url.URL) bool {
if !strings.HasPrefix(target.Path, "/api/http-proxy") {
return false
}
host := target.Hostname()
targetPort := target.Port()
localHost, localPort, _ := net.SplitHostPort(r.Host)
if localHost == "" {
localHost = r.Host
}
if targetPort != "" && targetPort == localPort {
if strings.EqualFold(host, localHost) ||
strings.EqualFold(host, "localhost") ||
host == "127.0.0.1" || host == "::1" {
return true
}
}
// Also check forwarded headers
extHost := resolveExternalHost(r)
extPort := resolveExternalPort(r)
if extHost != "" && strings.EqualFold(host, extHost) {
tp, _ := parsePort(target.Port(), target.Scheme)
if tp == extPort {
return true
}
}
return false
}
func resolveExternalHost(r *http.Request) string {
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
return v[:idx]
}
return v
}
if v := r.Host; v != "" {
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
return v[:idx]
}
return v
}
return ""
}
func resolveExternalPort(r *http.Request) int {
if v := r.Header.Get("X-Forwarded-Port"); v != "" {
if p, err := parsePort(v, ""); err == nil {
return p
}
}
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
if p, err := parsePort(v[idx+1:], ""); err == nil {
return p
}
}
}
if v := r.Host; v != "" {
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
if p, err := parsePort(v[idx+1:], ""); err == nil {
return p
}
}
}
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return 443
}
return 80
}
func parsePort(portStr, scheme string) (int, error) {
if portStr != "" {
var p int
_, err := fmt.Sscanf(portStr, "%d", &p)
return p, err
}
switch strings.ToLower(scheme) {
case "https":
return 443, nil
case "http":
return 80, nil
}
return 0, fmt.Errorf("no port")
}
func applyOverrideURL(target *url.URL, cfg *Config, state *NativeState) *url.URL {
if !cfg.IsMargeTarget(target.Hostname(), target.Path) {
return target
}
override := normalizeBaseURL(state.Get("overrideMargeURL"))
if override == "" {
return target
}
result := buildURIFromBase(override, target.Path, target.RawQuery)
if result == nil {
return target
}
return result
}
func buildURIFromBase(base, path, query string) *url.URL {
base = normalizeBaseURL(base)
if base == "" {
return nil
}
u, err := url.Parse(base)
if err != nil {
return nil
}
u.Path = path
u.RawQuery = query
return u
}
func margePathPrefix(path string) string {
p := strings.ToLower(path)
idx := strings.Index(p, "/streaming/")
if idx <= 0 {
return ""
}
return path[:idx]
}
// XML helpers for login retry
type xmlStatusCode struct {
StatusCode string `xml:"status-code"`
}
type xmlAccountAttr struct {
ID string `xml:"id,attr"`
}
type xmlLoginBody struct {
Username string `xml:"username"`
Password string `xml:"password"`
}
type xmlEnvironment struct {
StreamingURL string `xml:"streamingURL"`
UpdateURL string `xml:"updateURL"`
}
type environmentInfo struct {
streamingURL string
updateURL string
}
func extractXMLStatusCode(body []byte) string {
var v xmlStatusCode
if err := xml.Unmarshal(body, &v); err == nil && v.StatusCode != "" {
return v.StatusCode
}
// Try finding in any wrapper element
type wrapper struct {
StatusCode string `xml:"status-code"`
}
var w wrapper
_ = xml.Unmarshal(body, &w)
return w.StatusCode
}
func parseLoginCredentials(body []byte) (email, password string) {
var login xmlLoginBody
if err := xml.Unmarshal(body, &login); err != nil {
return "", ""
}
return strings.TrimSpace(login.Username), login.Password
}
func extractXMLAccountID(body []byte) string {
type accountWrapper struct {
Account xmlAccountAttr `xml:"account"`
}
var w accountWrapper
if err := xml.Unmarshal(body, &w); err == nil && w.Account.ID != "" {
return w.Account.ID
}
return ""
}
func extractEnvironment(body []byte) *environmentInfo {
var env xmlEnvironment
if err := xml.Unmarshal(body, &env); err != nil {
return nil
}
if env.StreamingURL == "" && env.UpdateURL == "" {
return nil
}
return &environmentInfo{streamingURL: env.StreamingURL, updateURL: env.UpdateURL}
}
+229
View File
@@ -0,0 +1,229 @@
package stockholm
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
)
// NativeState is a flat string key→value store persisted to native-state.json.
// Every Set call writes through to disk.
type NativeState struct {
mu sync.RWMutex
data map[string]string
path string
}
// NewNativeState creates a NativeState that persists to stateDir/native-state.json.
func NewNativeState(stateDir string) *NativeState {
return &NativeState{
data: make(map[string]string),
path: filepath.Join(stateDir, "native-state.json"),
}
}
// Load reads the state file from disk. Non-existent file is not an error.
func (s *NativeState) Load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read native-state: %w", err)
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parse native-state: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
for k, v := range raw {
s.data[k] = stringifyScalar(v)
}
return nil
}
// SeedFromEnv seeds Marge auth/account from environment variables and
// sets first-run defaults (guid, nativeFrameVersion, authServer, constant.kilo).
func (s *NativeState) SeedFromEnv(cfg *Config) {
updates := make(map[string]string)
// Marge session from env
if v := firstNonEmpty(os.Getenv("margeAuthToken"), os.Getenv("MARGE_AUTH_TOKEN")); v != "" {
updates["margeAuthToken"] = v
}
if v := firstNonEmpty(os.Getenv("margeAccountID"), os.Getenv("MARGE_ACCOUNT_ID")); v != "" {
updates["margeAccountID"] = v
}
// Default constant
if s.Get("constant.kilo") == "" {
updates["constant.kilo"] = "a7928d7b43dcd49f0af31e5aeed26458"
}
// First-run defaults that require a persisted value
if s.Get("authServer") == "" {
updates["authServer"] = "0"
}
// GUID: use existing or generate new
existingGUID := firstNonEmpty(s.Get("guid"), s.Get("deviceGuid"))
if existingGUID == "" {
existingGUID = randomHexUUID()
}
if s.Get("guid") == "" {
updates["guid"] = existingGUID
}
if s.Get("deviceGuid") == "" {
updates["deviceGuid"] = existingGUID
}
// Version info from config
if cfg != nil {
fullVersion := firstNonEmpty(s.Get("frame_version"), cfg.AppVersion)
shortVersion := firstNonEmpty(ExtractVersionPrefix(s.Get("nativeFrameVersion")),
ExtractVersionPrefix(s.Get("frame_version")),
ExtractVersionPrefix(cfg.AppVersion))
if s.Get("nativeFrameVersion") == "" && shortVersion != "" {
updates["nativeFrameVersion"] = shortVersion
}
if s.Get("frame_version") == "" && fullVersion != "" {
updates["frame_version"] = fullVersion
}
}
if len(updates) > 0 {
s.putMany(updates)
}
}
// Get returns the value for key, or "" if absent.
func (s *NativeState) Get(key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.data[key]
}
// Set stores key→value and persists to disk.
func (s *NativeState) Set(key, value string) {
if key == "" {
return
}
s.mu.Lock()
s.data[key] = value
s.mu.Unlock()
s.persist()
}
// PutMany stores multiple key→value pairs and persists once.
func (s *NativeState) PutMany(updates map[string]string) {
if len(updates) == 0 {
return
}
s.putMany(updates)
}
func (s *NativeState) putMany(updates map[string]string) {
s.mu.Lock()
changed := false
for k, v := range updates {
if k == "" {
continue
}
if prev, ok := s.data[k]; !ok || prev != v {
s.data[k] = v
changed = true
}
}
s.mu.Unlock()
if changed {
s.persist()
}
}
// AuthServer returns the authServer value normalised to "0""3".
func (s *NativeState) AuthServer() string {
v := s.Get("authServer")
switch v {
case "0", "1", "2", "3":
return v
default:
return "0"
}
}
func (s *NativeState) persist() {
s.mu.RLock()
snapshot := make(map[string]string, len(s.data))
for k, v := range s.data {
snapshot[k] = v
}
s.mu.RUnlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
log.Printf("[Stockholm] Failed to create state dir: %v", err)
return
}
b, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
log.Printf("[Stockholm] Failed to marshal native state: %v", err)
return
}
if err := os.WriteFile(s.path, append(b, '\n'), 0644); err != nil {
log.Printf("[Stockholm] Failed to persist native state: %v", err)
}
}
func stringifyScalar(v interface{}) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "true"
}
return "false"
case float64:
// JSON numbers decode to float64
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%g", t)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
+230
View File
@@ -0,0 +1,230 @@
package stockholm
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestNativeState_SetGet(t *testing.T) {
state := NewNativeState(t.TempDir())
state.Set("foo", "bar")
if got := state.Get("foo"); got != "bar" {
t.Errorf("Get(foo) = %q, want bar", got)
}
}
func TestNativeState_GetMissing_ReturnsEmpty(t *testing.T) {
state := NewNativeState(t.TempDir())
if got := state.Get("does-not-exist"); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
func TestNativeState_SetEmpty_IsNoOp(t *testing.T) {
state := NewNativeState(t.TempDir())
state.Set("", "value")
// Empty key should not be stored
if got := state.Get(""); got != "" {
t.Errorf("expected empty string for empty key, got %q", got)
}
}
func TestNativeState_PersistsAndLoads(t *testing.T) {
dir := t.TempDir()
state := NewNativeState(dir)
state.Set("key1", "val1")
state.Set("key2", "val2")
// Reload from disk
state2 := NewNativeState(dir)
if err := state2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := state2.Get("key1"); got != "val1" {
t.Errorf("after reload, key1 = %q, want val1", got)
}
if got := state2.Get("key2"); got != "val2" {
t.Errorf("after reload, key2 = %q, want val2", got)
}
}
func TestNativeState_Load_MissingFile_IsOK(t *testing.T) {
state := NewNativeState(t.TempDir())
if err := state.Load(); err != nil {
t.Errorf("Load on missing file should not error, got %v", err)
}
}
func TestNativeState_Load_CorruptFile_ReturnsError(t *testing.T) {
dir := t.TempDir()
statePath := filepath.Join(dir, "native-state.json")
_ = os.WriteFile(statePath, []byte("not json"), 0644)
state := NewNativeState(dir)
if err := state.Load(); err == nil {
t.Error("expected error for corrupt JSON, got nil")
}
}
func TestNativeState_PutMany(t *testing.T) {
state := NewNativeState(t.TempDir())
state.PutMany(map[string]string{
"a": "1",
"b": "2",
})
if got := state.Get("a"); got != "1" {
t.Errorf("expected a=1, got %q", got)
}
if got := state.Get("b"); got != "2" {
t.Errorf("expected b=2, got %q", got)
}
}
func TestNativeState_PutMany_Empty_IsNoOp(t *testing.T) {
dir := t.TempDir()
state := NewNativeState(dir)
state.PutMany(nil)
state.PutMany(map[string]string{})
// State file should not be created for no-op
_, err := os.Stat(filepath.Join(dir, "native-state.json"))
if err == nil {
t.Error("expected no state file to be created for empty PutMany")
}
}
func TestNativeState_AuthServer_ValidValues(t *testing.T) {
state := NewNativeState(t.TempDir())
for _, v := range []string{"0", "1", "2", "3"} {
state.Set("authServer", v)
if got := state.AuthServer(); got != v {
t.Errorf("AuthServer() = %q, want %q", got, v)
}
}
}
func TestNativeState_AuthServer_InvalidDefault(t *testing.T) {
state := NewNativeState(t.TempDir())
state.Set("authServer", "99")
if got := state.AuthServer(); got != "0" {
t.Errorf("expected default 0 for invalid authServer, got %q", got)
}
}
func TestNativeState_SeedFromEnv_SetsGUIDAndDefaults(t *testing.T) {
state := NewNativeState(t.TempDir())
state.SeedFromEnv(nil)
if got := state.Get("guid"); got == "" {
t.Error("expected guid to be seeded")
}
if got := state.Get("deviceGuid"); got == "" {
t.Error("expected deviceGuid to be seeded")
}
if got := state.Get("authServer"); got != "0" {
t.Errorf("expected authServer=0, got %q", got)
}
if got := state.Get("constant.kilo"); got != "a7928d7b43dcd49f0af31e5aeed26458" {
t.Errorf("unexpected kilo value: %q", got)
}
}
func TestNativeState_SeedFromEnv_GUIDConsistent(t *testing.T) {
state := NewNativeState(t.TempDir())
state.SeedFromEnv(nil)
guid := state.Get("guid")
deviceGuid := state.Get("deviceGuid")
if guid != deviceGuid {
t.Errorf("expected guid == deviceGuid, got %q vs %q", guid, deviceGuid)
}
}
func TestNativeState_SeedFromEnv_DoesNotOverwriteExistingGUID(t *testing.T) {
state := NewNativeState(t.TempDir())
state.Set("guid", "existing-guid")
state.SeedFromEnv(nil)
if got := state.Get("guid"); got != "existing-guid" {
t.Errorf("expected existing guid to be preserved, got %q", got)
}
}
func TestNativeState_SeedFromEnv_SetsVersionFromConfig(t *testing.T) {
state := NewNativeState(t.TempDir())
cfg := &Config{AppVersion: "27.0.13-release"}
state.SeedFromEnv(cfg)
if got := state.Get("nativeFrameVersion"); got != "27.0.13" {
t.Errorf("expected nativeFrameVersion=27.0.13, got %q", got)
}
}
func TestNativeState_PersistFileContainsJSON(t *testing.T) {
dir := t.TempDir()
state := NewNativeState(dir)
state.Set("hello", "world")
data, err := os.ReadFile(filepath.Join(dir, "native-state.json"))
if err != nil {
t.Fatalf("read state file: %v", err)
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("state file is not valid JSON: %v", err)
}
if m["hello"] != "world" {
t.Errorf("expected hello=world in state file, got %q", m["hello"])
}
}
// ---- stringifyScalar ----
func TestStringifyScalar(t *testing.T) {
cases := []struct {
input interface{}
want string
}{
{nil, ""},
{"hello", "hello"},
{true, "true"},
{false, "false"},
{float64(42), "42"},
{float64(3.14), "3.14"},
{[]int{1, 2}, `[1,2]`},
}
for _, tc := range cases {
if got := stringifyScalar(tc.input); got != tc.want {
t.Errorf("stringifyScalar(%v) = %q, want %q", tc.input, got, tc.want)
}
}
}
+258
View File
@@ -0,0 +1,258 @@
package stockholm
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// contentTypeFor returns the MIME type for a file based on its extension.
func contentTypeFor(name string) string {
n := strings.ToLower(name)
switch {
case strings.HasSuffix(n, ".html"):
return "text/html; charset=UTF-8"
case strings.HasSuffix(n, ".js"):
return "application/javascript; charset=UTF-8"
case strings.HasSuffix(n, ".css"):
return "text/css; charset=UTF-8"
case strings.HasSuffix(n, ".json"):
return "application/json; charset=UTF-8"
case strings.HasSuffix(n, ".xml"):
return "application/xml; charset=UTF-8"
case strings.HasSuffix(n, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(n, ".png"):
return "image/png"
case strings.HasSuffix(n, ".jpg"), strings.HasSuffix(n, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(n, ".gif"):
return "image/gif"
case strings.HasSuffix(n, ".ttf"):
return "font/ttf"
case strings.HasSuffix(n, ".otf"):
return "font/otf"
case strings.HasSuffix(n, ".txt"):
return "text/plain; charset=UTF-8"
default:
return "application/octet-stream"
}
}
// ServeStatic handles all static file requests for the Stockholm frontend.
func ServeStatic(w http.ResponseWriter, r *http.Request, stockholmDir string, backendCfg *BackendConfig, state *NativeState, cfg *Config) {
method := strings.ToUpper(r.Method)
if method != http.MethodGet && method != http.MethodHead {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
file, rel, err := resolveStaticFile(r.URL.Path, stockholmDir)
if err != nil {
log.Printf("[Stockholm static] Path traversal rejected: %s", r.URL.Path)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
info, err := os.Stat(file)
if err != nil || info.IsDir() {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
body, err := os.ReadFile(file)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ct := contentTypeFor(file)
if ct == "text/html; charset=UTF-8" && isBootstrapTarget(rel) {
body = injectBootstrap(body, state, cfg)
}
// Frontend logging cookie
if backendCfg.ShouldEnableFrontendDebug() {
w.Header().Add("Set-Cookie", fmt.Sprintf("stockholmFrontendLoggingLevel=%d; Path=/; SameSite=Lax", backendCfg.FrontendLoggingLevel))
} else {
w.Header().Add("Set-Cookie", "stockholmFrontendLoggingLevel=; Max-Age=0; Path=/; SameSite=Lax")
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "no-store")
if method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func resolveStaticFile(rawPath, stockholmDir string) (filePath, relPath string, err error) {
if rawPath == "" || rawPath == "/" {
rawPath = "/index.html"
}
// Strip leading slash, resolve relative to stockholmDir
clean := filepath.Clean(strings.TrimPrefix(rawPath, "/"))
resolved := filepath.Join(stockholmDir, clean)
// Security: reject path traversal
absStockholm, _ := filepath.Abs(stockholmDir)
absResolved, _ := filepath.Abs(resolved)
if !strings.HasPrefix(absResolved+string(filepath.Separator), absStockholm+string(filepath.Separator)) &&
absResolved != absStockholm {
return "", "", fmt.Errorf("path outside stockholm root")
}
rel := strings.TrimPrefix(absResolved, absStockholm+string(filepath.Separator))
rel = strings.ReplaceAll(rel, string(filepath.Separator), "/")
// Directory → try index.html
info, statErr := os.Stat(resolved)
if statErr == nil && info.IsDir() {
resolved = filepath.Join(resolved, "index.html")
rel = strings.TrimPrefix(resolved, absStockholm+string(filepath.Separator))
rel = strings.ReplaceAll(rel, string(filepath.Separator), "/")
}
return resolved, rel, nil
}
func isBootstrapTarget(relPath string) bool {
p := strings.ToLower(relPath)
return p == "index.html" || p == "setup/index.html"
}
func injectBootstrap(html []byte, state *NativeState, cfg *Config) []byte {
content := string(html)
if strings.Contains(content, "window.StockholmBrowserBootstrap") {
return html
}
idx := strings.Index(content, "</head>")
if idx < 0 {
return html
}
script := buildBootstrapScript(state, cfg)
injected := content[:idx] + script + content[idx:]
return []byte(injected)
}
func buildBootstrapScript(state *NativeState, cfg *Config) string {
guid := firstNonEmpty(state.Get("guid"), state.Get("deviceGuid"))
nativeVersion := firstNonEmpty(state.Get("frame_version"), cfg.AppVersion)
authServer := state.AuthServer()
payload := map[string]interface{}{
"authServer": authServer,
"guid": guid,
"nativeVersion": nativeVersion,
"frameConfig": map[string]interface{}{},
"basePath": cfg.BasePath,
}
bootstrapJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("[Stockholm static] Failed to marshal bootstrap payload: %v", err)
bootstrapJSON = []byte("{}")
}
return fmt.Sprintf(`<script>
(function () {
window.StockholmBrowserBootstrap = %s;
// __stockholmBase lets the bridge JS files resolve API URLs when Stockholm
// is mounted under a prefix such as /stockholm.
window.__stockholmBase = window.StockholmBrowserBootstrap.basePath || "";
var bootstrap = window.StockholmBrowserBootstrap || {};
function toBase64(value) {
return window.btoa(unescape(encodeURIComponent(String(value))));
}
function mergeFrameConfig(config) {
if (!bootstrap.frameConfig || typeof bootstrap.frameConfig !== "object") {
return config;
}
config = config || {};
config.default = config.default || {};
Object.keys(bootstrap.frameConfig).forEach(function (key) {
var value = bootstrap.frameConfig[key];
if (!/^f\d+$/.test(key) || value === undefined || value === null) {
return;
}
var targetKey = "d" + key.substring(1);
if (config.default[targetKey] === undefined || config.default[targetKey] === null
|| config.default[targetKey] === "") {
config.default[targetKey] = toBase64(value);
}
});
return config;
}
var originalGetURLParams = window.getURLParams;
if (typeof originalGetURLParams === "function") {
window.getURLParams = function (name, url) {
var value = originalGetURLParams(name, url);
if (value !== null && value !== undefined) {
return value;
}
if (name === "native_version" && bootstrap.nativeVersion) {
return bootstrap.nativeVersion;
}
if (name === "authServer" && bootstrap.authServer !== undefined && bootstrap.authServer !== null) {
return String(bootstrap.authServer);
}
if (name === "guid" && bootstrap.guid) {
return bootstrap.guid;
}
return value;
};
}
var originalGetUserAgentValue = window.getUserAgentValue;
if (typeof originalGetUserAgentValue === "function") {
window.getUserAgentValue = function (name) {
var value = originalGetUserAgentValue(name);
if ((!value || value === "") && name === "_app" && bootstrap.guid) {
return bootstrap.guid;
}
return value;
};
}
if ((!window.guid || window.guid === "") && bootstrap.guid) {
window.guid = bootstrap.guid;
}
if ((!window.frame_version || window.frame_version === "") && bootstrap.nativeVersion) {
window.frame_version = bootstrap.nativeVersion;
}
if ((window.auth_server === undefined || window.auth_server === null || window.auth_server === "")
&& bootstrap.authServer !== undefined && bootstrap.authServer !== null) {
window.auth_server = bootstrap.authServer;
}
var originalSettingsLoad = window.settingsLoad;
if (typeof originalSettingsLoad === "function") {
window.settingsLoad = function (config) {
return originalSettingsLoad(mergeFrameConfig(config));
};
}
})();
</script>
`, string(bootstrapJSON))
}
+234
View File
@@ -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(`<html><head><title>T</title></head><body></body></html>`)
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, "</script>")
headEnd := strings.Index(got, "</head>")
if scriptEnd < 0 || headEnd < 0 || scriptEnd > headEnd {
t.Error("expected bootstrap script to appear before </head>")
}
}
func TestInjectBootstrap_Idempotent(t *testing.T) {
html := []byte(`<html><head><script>window.StockholmBrowserBootstrap = {};</script></head><body></body></html>`)
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(`<html><body>no head here</body></html>`)
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 </head> 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("<html>"), 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("<html>"), 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(`<html><head></head><body></body></html>`), 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(`<html><head></head><body></body></html>`), 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(`<html><head></head><body></body></html>`), 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)
}
}
+13
View File
@@ -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)
}
+39
View File
@@ -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 <file> [<file> ...]
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)