From e6687f774bdc68e762996dc9caaf18a4bd14d825 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 20 Sep 2026 10:14:09 +0000 Subject: [PATCH] fix(workspaces): derive dashboard API host from PORTAL_API_URL Older portal deployments inject the bare core domain or the plugin's own subdomain as PORTAL_API_URL, but the workspace-init key-exchange routes (POST /api/auth/key) exist only behind the dashboard API's host router, so the exchange fails with 405 and first-boot automatic install is skipped. workspace-init now rewrites such URLs onto account. before exchanging, preserving scheme, port, and path. Hosts already rooted at the dashboard subdomain, IPs, and dotless dev hosts pass through unchanged. --- images/wordpress/workspace-init/main.go | 53 ++++++++++++++++++++ images/wordpress/workspace-init/main_test.go | 30 ++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/images/wordpress/workspace-init/main.go b/images/wordpress/workspace-init/main.go index ab7ac27..72c7e03 100644 --- a/images/wordpress/workspace-init/main.go +++ b/images/wordpress/workspace-init/main.go @@ -17,6 +17,8 @@ package main import ( "context" "fmt" + "net" + "net/url" "os" "strings" "time" @@ -27,8 +29,54 @@ import ( const ( envAPIURL = "PORTAL_API_URL" envAPIKey = "PORTAL_API_KEY" + + // dashboardAPISubdomain is the host subdomain behind which the dashboard + // API's key-exchange routes (POST /api/auth/key, GET /api/account) are + // routed. It mirrors the value pinned in portal-plugin-ipfs; the dashboard + // core package does not export it. + dashboardAPISubdomain = "account" ) +// deriveDashboardAPIURL normalizes the injected PORTAL_API_URL so requests +// always target the dashboard API host. Older portal deployments inject the +// bare core domain (e.g. https://pinner.xyz) or this plugin's subdomain +// (e.g. https://ipfs.pinner.xyz), but the key-exchange routes only exist +// behind the dashboard's host router (account.); hitting any +// other host yields 405 and skips automatic install. Loopback/IP hosts and +// hosts already rooted at the dashboard subdomain are returned unchanged. +// An unparseable URL is returned as-is; the failure then surfaces from the +// client with full context. +func deriveDashboardAPIURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return rawURL + } + + host := strings.ToLower(u.Hostname()) + // IPs and dotless hosts (e.g. "localhost") have no derivable core domain; + // treat them as already-final dev/test targets. + if net.ParseIP(host) != nil || !strings.Contains(host, ".") { + return rawURL + } + + labels := strings.Split(host, ".") + if labels[0] == dashboardAPISubdomain { + return rawURL + } + + // Drop any leftmost plugin subdomain so an apex URL and a subdomain URL + // both reduce to the core domain, then prepend the dashboard subdomain: + // pinner.xyz / ipfs.pinner.xyz -> account.pinner.xyz. + core := strings.Join(labels[len(labels)-2:], ".") + + schemeHost := dashboardAPISubdomain + "." + core + if port := u.Port(); port != "" { + schemeHost = net.JoinHostPort(schemeHost, port) + } + u.Host = schemeHost + return u.String() +} + func main() { email, err := fetchEmail(context.Background()) if err != nil { @@ -53,6 +101,11 @@ func fetchEmail(ctx context.Context) (string, error) { return "", fmt.Errorf("%s is required", envAPIKey) } + // Older portal deployments inject the bare core domain or the plugin's own + // subdomain as PORTAL_API_URL; the key-exchange routes only exist behind + // the dashboard API's host, so point the client there. + apiURL = deriveDashboardAPIURL(apiURL) + // Bound the whole portal round-trip so a hung/unreachable portal cannot // stall the container's init forever; wp-init treats a failure here as // "email unavailable" and safely skips automatic install. diff --git a/images/wordpress/workspace-init/main_test.go b/images/wordpress/workspace-init/main_test.go index 0b9fc11..90fb0b8 100644 --- a/images/wordpress/workspace-init/main_test.go +++ b/images/wordpress/workspace-init/main_test.go @@ -141,8 +141,34 @@ func TestFetchEmailBadCredentials(t *testing.T) { } } -// writeJSON sets the JSON content type the oapi-codegen client expects when it -// decodes a JSON200 body, then encodes v. +// TestDeriveDashboardAPIURL pins the normalization of the injected +// PORTAL_API_URL: apex and plugin-subdomain hosts must be rewritten onto the +// dashboard API host, hosts already at the dashboard subdomain and dev/test +// loopback targets pass through, and scheme/port are preserved. +func TestDeriveDashboardAPIURL(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"apex core domain", "https://pinner.xyz", "https://account.pinner.xyz"}, + {"plugin subdomain", "https://ipfs.pinner.xyz", "https://account.pinner.xyz"}, + {"already dashboard host", "https://account.pinner.xyz", "https://account.pinner.xyz"}, + {"port preserved", "https://ipfs.pinner.xyz:8443", "https://account.pinner.xyz:8443"}, + {"uppercase host", "https://Pinner.XYZ", "https://account.pinner.xyz"}, + {"ipv4 passthrough", "http://127.0.0.1:8080", "http://127.0.0.1:8080"}, + {"localhost passthrough", "http://localhost:3000", "http://localhost:3000"}, + {"trailing path preserved", "https://pinner.xyz/api", "https://account.pinner.xyz/api"}, + {"unparseable passthrough", "::::", "::::"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := deriveDashboardAPIURL(tc.in); got != tc.want { + t.Fatalf("deriveDashboardAPIURL(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v)