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)