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, "/") // Defence in depth: basePath is operator-provided (CLI flag / // STOCKHOLM_BASE_PATH env var), not request input — but if it // were ever set to "//evil.com" (typo or hostile env injection) // the bare-path redirect below would go scheme-relative to // evil.com. Reject any leading-double-slash and any backslash // so the redirect target can only ever be an absolute local // path. CodeQL go/bad-redirect-check raised the original // concern. if strings.HasPrefix(basePath, "//") || strings.HasPrefix(basePath, "/\\") || strings.ContainsAny(basePath, "\\") { return nil, fmt.Errorf("invalid stockholm base path %q: must be an absolute path starting with a single '/'", 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 }