Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Billtap can run behind a shared browser origin such as
`https://localhost:8081/billtap` while keeping internal service-to-service calls
on the unprefixed container URL, such as `http://billtap:8080`.

Set one of these before building or starting Billtap:
Set one of these before starting Billtap:

```bash
PUBLIC_BASE_PATH=/billtap
Expand Down Expand Up @@ -164,6 +164,9 @@ calls are prefix-aware:
/billtap/v1/customers
```

The published GHCR image is runtime-prefix safe. You do not need to rebuild the
frontend for each mount path.

## Fixture And Assertion APIs

Billtap includes local integration-test helpers:
Expand Down
9 changes: 5 additions & 4 deletions docs/decisions/0003-public-base-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ use URLs such as `http://billtap:8080/v1`.

## Consequences

The Vite app is built with `/app/` under the public base path, so assets resolve
without proxy rewrites. Server routes accept both prefixed and unprefixed paths
when a configured public base path is present, which preserves internal compose
traffic while allowing browser requests under the prefix.
The Vite app uses relative static asset paths and derives the runtime public
prefix from the current browser URL. The published image therefore does not need
to be rebuilt for each mount path. Server routes accept both prefixed and
unprefixed paths when a configured public base path is present, which preserves
internal compose traffic while allowing browser requests under the prefix.

The public base path is validated as a URL path. Full URLs, query strings,
fragments, dot path segments, and empty path segments are rejected.
4 changes: 3 additions & 1 deletion docs/runbooks/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ go run ./cmd/billtap

`BILLTAP_PUBLIC_BASE_PATH` is also supported and takes precedence over
`PUBLIC_BASE_PATH` when a multi-app stack needs a Billtap-specific override.
The path must be a URL path, not a full URL.
The path must be a URL path, not a full URL. Published container images are
runtime-prefix safe, so the same image can be mounted at `/`, `/billtap`, or a
different proxy path without rebuilding frontend assets.

With `PUBLIC_BASE_PATH=/billtap`, browser-facing paths are:

Expand Down
6 changes: 0 additions & 6 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,6 @@ func (s *Server) handleHostedCheckout(w http.ResponseWriter, r *http.Request) {
methodNotAllowed(w)
return
}
if s.cfg.StaticDir != "" && serveFileIfExists(w, r, filepath.Join(s.cfg.StaticDir, "checkout", "index.html")) {
return
}
sessionID := strings.Trim(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/checkout"), "/"), "/")
target := s.prefixedPath(r, "/app/checkout/")
if sessionID != "" {
Expand All @@ -186,9 +183,6 @@ func (s *Server) handleHostedPortal(w http.ResponseWriter, r *http.Request) {
methodNotAllowed(w)
return
}
if s.cfg.StaticDir != "" && serveFileIfExists(w, r, filepath.Join(s.cfg.StaticDir, "portal", "index.html")) {
return
}
customerID := strings.Trim(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/portal"), "/"), "/")
target := s.prefixedPath(r, "/app/portal/")
if customerID != "" {
Expand Down
10 changes: 10 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ func TestBuiltReactAppServing(t *testing.T) {
t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusOK)
}
}

req := httptest.NewRequest(http.MethodGet, "/checkout?session_id=cs_test_123", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("built checkout status = %d, want %d", rec.Code, http.StatusFound)
}
if got := rec.Header().Get("Location"); got != "/app/checkout/?session_id=cs_test_123" {
t.Fatalf("built checkout Location = %q, want app redirect", got)
}
}

func TestPublicBasePathPrefixesAPISessionURLs(t *testing.T) {
Expand Down
16 changes: 16 additions & 0 deletions tests/web-smoke/smoke-web.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async function main() {
try {
await waitForReady(`${baseURL}/healthz`, server);
const seed = await seedBillingData(baseURL);
await verifyHostedRedirect(seed.checkoutSession.url, withPublicBasePath(`/app/checkout/?session_id=${encodeURIComponent(seed.checkoutSession.id)}`));
const checks = smokeChecks(seed);
await runBrowserSmoke(baseURL, checks);
console.log(`web smoke passed: ${checks.map((check) => check.path).join(", ")}`);
Expand Down Expand Up @@ -126,6 +127,21 @@ async function seedBillingData(baseURL) {
return { customer, product, price, checkoutSession, portalSession };
}

async function verifyHostedRedirect(url, expectedPathAndQuery) {
const response = await fetch(url, { redirect: "manual" });
if (response.status !== 302) {
throw new Error(`${url} returned ${response.status}, want hosted redirect`);
}
const location = response.headers.get("location");
if (!location) {
throw new Error(`${url} did not return a Location header`);
}
const resolved = new URL(location, url);
if (`${resolved.pathname}${resolved.search}` !== expectedPathAndQuery) {
throw new Error(`${url} redirected to ${resolved.pathname}${resolved.search}, want ${expectedPathAndQuery}`);
}
}

async function postForm(url, values) {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
Expand Down
13 changes: 3 additions & 10 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { defineConfig, type Plugin } from "vite";
const appRoutes = new Set(["checkout", "dashboard", "portal"]);
const projectRoot = fileURLToPath(new URL(".", import.meta.url));
const publicBasePath = normalizePublicBasePath(firstNonEmpty(process.env.BILLTAP_PUBLIC_BASE_PATH, process.env.PUBLIC_BASE_PATH));
const appBasePath = joinBasePath(publicBasePath, "/app/");

function appPathDevFallback(): Plugin {
return {
Expand All @@ -19,9 +18,9 @@ function appPathDevFallback(): Plugin {
const match = appPath.match(/^\/app\/(checkout|dashboard|portal)\/?$/);

if (match && appRoutes.has(match[1])) {
req.url = `${appBasePath}${match[1]}/index.html${query ? `?${query}` : ""}`;
req.url = `/${match[1]}/index.html${query ? `?${query}` : ""}`;
} else if (appPath === "/app/" || appPath === "/app") {
req.url = `${appBasePath}dashboard/index.html${query ? `?${query}` : ""}`;
req.url = `/dashboard/index.html${query ? `?${query}` : ""}`;
}

next();
Expand All @@ -44,12 +43,6 @@ function normalizePublicBasePath(value: string): string {
return withLeading.replace(/\/+$/, "");
}

function joinBasePath(basePath: string, path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (!basePath) return normalizedPath;
return `${basePath}${normalizedPath}`;
}

function stripBasePath(pathname: string, basePath: string): string {
if (!basePath) return pathname;
if (pathname === basePath) return "/";
Expand All @@ -59,7 +52,7 @@ function stripBasePath(pathname: string, basePath: string): string {

export default defineConfig({
root: "web",
base: appBasePath,
base: "./",
plugins: [react(), appPathDevFallback()],
server: {
host: "127.0.0.1",
Expand Down
2 changes: 1 addition & 1 deletion web/checkout/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="%BASE_URL%billtap-mark.svg" />
<link rel="icon" href="../billtap-mark.svg" />
<title>Billtap Checkout</title>
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion web/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="%BASE_URL%billtap-mark.svg" />
<link rel="icon" href="../billtap-mark.svg" />
<title>Billtap Dashboard</title>
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion web/portal/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="%BASE_URL%billtap-mark.svg" />
<link rel="icon" href="../billtap-mark.svg" />
<title>Billtap Portal</title>
</head>
<body>
Expand Down
19 changes: 11 additions & 8 deletions web/shared/basePath.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
const appBase = normalizeBase(import.meta.env.BASE_URL || "/app/");
const publicBasePath = appBase.endsWith("/app/") ? appBase.slice(0, -"/app/".length) : appBase.replace(/\/$/, "");

export function appHref(path = ""): string {
return joinBase(appBase, path);
return joinBase(joinBase(publicBasePath(), "/app/"), path);
}

export function apiHref(path: string): string {
return joinBase(publicBasePath, path);
return joinBase(publicBasePath(), path);
}

function publicBasePath(pathname = globalThis.location?.pathname ?? "/"): string {
const match = pathname.match(/^(.*?)(?:\/app(?:\/|$)|\/checkout(?:\/|$)|\/portal(?:\/|$))/);
if (!match) return "";
return normalizeBasePath(match[1]);
}

function normalizeBase(value: string): string {
function normalizeBasePath(value: string): string {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/app/";
if (!trimmed || trimmed === "/") return "";
const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
return withLeading.replace(/\/+$/, "");
}

function joinBase(base: string, path: string): string {
Expand Down
Loading