From 3b104be7ccea2763a3f2f5daab54b83c4ad2d37d Mon Sep 17 00:00:00 2001
From: chai-guy-7 <265797038+chai-guy-7@users.noreply.github.com>
Date: Wed, 3 Jun 2026 14:45:40 +0000
Subject: [PATCH 1/2] feat(app): support serving the explorer from a
configurable base path
Allow the frontend to be served under a URL subpath (e.g. /explorer)
instead of only at the domain root, for deployments behind
prefix-preserving reverse proxies.
- vite.config.ts: base from VITE_BASE_PATH (build time); Docker builds
use a relative base so one image works at any subpath
- index.html: tag templated from BASE_PATH (gomplate at
container start or the html-transform plugin locally); config.js and
favicon defaults are now base-relative
- new src/utils/basePath.ts: getBasePath/basePathPrefix/publicAsset/
appUrl/appRootUrl/currentAppPath helpers reading the live tag
- router uses createWebHistory() so vue-router picks up the tag
- absolute /images/... references and window.location redirects
(401 login redirect, wallet switch reload, OAuth redirect_uri and
post_logout_redirect_uri, MetaMask blockExplorerUrls) now resolve
through the helpers; root-deployment output is unchanged
- nginx.conf.subpath.template: serves under ${BASE_PATH} prefix when
the BASE_PATH env var is set; the existing template and rendered
config remain untouched when it is unset
- Dockerfile: VITE_BASE_PATH build arg; CMD normalizes BASE_PATH and
selects the nginx template at container start
Verified at root and under /explorer (direct and behind a
prefix-preserving proxy) against a real nginx with the rendered
configs: assets, deep links, client-side navigation, config.js and
images all resolve; default deployment output is functionally
identical to before.
---
packages/app/Dockerfile | 20 ++-
packages/app/README.md | 8 +
packages/app/index.html | 7 +-
packages/app/nginx.conf.subpath.template | 51 ++++++
packages/app/src/App.vue | 3 +-
packages/app/src/components/Account.vue | 5 +-
.../src/components/ConnectMetamaskButton.vue | 4 +-
packages/app/src/components/Contract.vue | 5 +-
packages/app/src/components/NetworkSwitch.vue | 7 +-
packages/app/src/components/Token.vue | 3 +-
.../app/src/components/TokenIconLabel.vue | 4 +-
.../app/src/components/header/TheHeader.vue | 11 +-
.../components/prividium/NetworkIndicator.vue | 4 +-
.../src/components/prividium/WalletButton.vue | 3 +-
.../components/prividium/WalletInfoModal.vue | 4 +-
.../app/src/composables/useFetchInstance.ts | 3 +-
packages/app/src/composables/useLogin.ts | 3 +-
packages/app/src/composables/useWallet.ts | 3 +-
packages/app/src/lib/prividium-auth/index.ts | 4 +-
packages/app/src/router/index.ts | 4 +-
packages/app/src/utils/basePath.ts | 90 ++++++++++
packages/app/src/views/AuthCallbackView.vue | 8 +-
packages/app/src/views/LoginView.vue | 8 +-
packages/app/src/views/NotAuthorizedView.vue | 8 +-
packages/app/tests/utils/basePath.spec.ts | 162 ++++++++++++++++++
packages/app/vite.config.ts | 21 +++
26 files changed, 424 insertions(+), 29 deletions(-)
create mode 100644 packages/app/nginx.conf.subpath.template
create mode 100644 packages/app/src/utils/basePath.ts
create mode 100644 packages/app/tests/utils/basePath.spec.ts
diff --git a/packages/app/Dockerfile b/packages/app/Dockerfile
index 7f34a5d2cd..f4b32afa2b 100644
--- a/packages/app/Dockerfile
+++ b/packages/app/Dockerfile
@@ -23,6 +23,11 @@ ARG VITE_VERSION=
ENV VITE_VERSION=$VITE_VERSION
ARG VITE_APP_ENVIRONMENT=default
ENV VITE_APP_ENVIRONMENT=$VITE_APP_ENVIRONMENT
+# Optional explicit base path baked in at build time. When empty (default),
+# the build uses a relative base so the image can be served from any subpath
+# configured at container start via the BASE_PATH env var. See vite.config.ts.
+ARG VITE_BASE_PATH=
+ENV VITE_BASE_PATH=$VITE_BASE_PATH
ENV DOCKER_BUILD=true
RUN npm run build
@@ -33,10 +38,23 @@ ENV CSP_REPORT_ONLY="default-src 'self'; child-src 'self' blob:; script-src 'sel
RUN apk add --no-cache gomplate
COPY --from=build-stage /usr/src/app/packages/app/nginx.conf.template /etc/nginx/conf.d/default.conf.template
+COPY --from=build-stage /usr/src/app/packages/app/nginx.conf.subpath.template /etc/nginx/conf.d/subpath.conf.template
COPY --from=build-stage /usr/src/app/packages/app/dist /usr/share/nginx/html
RUN cp /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.tpl
-CMD envsubst '$PORT $CSP_REPORT_ONLY' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf \
+# BASE_PATH (e.g. "/explorer") serves the app under a subpath for
+# prefix-preserving reverse proxies. When unset, behavior is unchanged.
+# nginx needs the prefix without a trailing slash, the tag (filled
+# by gomplate from BASE_PATH) needs it with one.
+CMD BP="${BASE_PATH#/}" && BP="${BP%/}" \
+ && if [ -n "$BP" ]; then \
+ export BASE_PATH="/$BP" \
+ && envsubst '$PORT $CSP_REPORT_ONLY $BASE_PATH' < /etc/nginx/conf.d/subpath.conf.template > /etc/nginx/conf.d/default.conf \
+ && export BASE_PATH="/$BP/"; \
+ else \
+ export BASE_PATH="/" \
+ && envsubst '$PORT $CSP_REPORT_ONLY' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf; \
+ fi \
&& gomplate -f /usr/share/nginx/html/index.html.tpl -o /usr/share/nginx/html/index.html \
&& exec nginx -g 'daemon off;'
diff --git a/packages/app/README.md b/packages/app/README.md
index adf724906a..c2e245af93 100644
--- a/packages/app/README.md
+++ b/packages/app/README.md
@@ -47,6 +47,14 @@ settlement chains:
For a complete example of network configuration including settlement chains, refer to [`production.config.json`](./src/configs/production.config.json).
+### Serving the app from a subpath
+By default the app is served from the root of the domain (e.g. `https://explorer.example.com/`). To serve it from a subpath (e.g. `https://example.com/explorer/`), there are two options:
+
+- **Build time**: set the `VITE_BASE_PATH` env variable when building (e.g. `VITE_BASE_PATH=/explorer/ npm run build`). This bakes the base path into the build output. It also works for the dev server: `VITE_BASE_PATH=/explorer/ npm run dev`.
+- **Container runtime**: the published Docker image is built with a relative base, so the base path can be configured at container start by setting the `BASE_PATH` env variable (e.g. `docker run -e BASE_PATH=/explorer ...`). This injects a `` tag into `index.html` and configures nginx to serve the app under the prefix. No rebuild is needed. `BASE_PATH` must be a plain path prefix consisting of URL path segments (letters, digits, `-`, `_`, `/`), since it is interpolated into an nginx location and rewrite rule.
+
+When the app sits behind a reverse proxy that forwards the subpath prefix to the container (prefix-preserving, e.g. `proxy.example.com/explorer/*` -> `container/explorer/*`), set `BASE_PATH` to that prefix. If the proxy strips the prefix before forwarding, leave `BASE_PATH` unset and note that the app would then generate root-relative URLs which will not work under the proxied subpath, so prefix-preserving proxying is the supported setup.
+
### Compile and Hot-Reload for Development
```sh
diff --git a/packages/app/index.html b/packages/app/index.html
index b4a391c0ad..7c3d6d3372 100644
--- a/packages/app/index.html
+++ b/packages/app/index.html
@@ -1,6 +1,7 @@
+ {{ getenv "VITE_BRAND_NAME" | default "ZKsync" }} Block Explorer
@@ -13,12 +14,12 @@
-
-
+
+
-
+
diff --git a/packages/app/nginx.conf.subpath.template b/packages/app/nginx.conf.subpath.template
new file mode 100644
index 0000000000..dbb9be176b
--- /dev/null
+++ b/packages/app/nginx.conf.subpath.template
@@ -0,0 +1,51 @@
+# Used instead of nginx.conf.template when the BASE_PATH env var is set
+# (see the Dockerfile CMD). Serves the app under the ${BASE_PATH} prefix
+# for prefix-preserving reverse proxies. BASE_PATH must have a leading
+# slash and no trailing slash (e.g. /explorer); the Dockerfile normalizes it.
+server {
+ listen ${PORT};
+ listen [::]:${PORT} default;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ server_name _; # all hostnames
+
+ # Emit relative Location headers so redirects survive https-terminating
+ # reverse proxies in front of this container.
+ absolute_redirect off;
+
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+ gzip_min_length 256;
+ gzip_proxied any;
+ gzip_vary on;
+
+ # Redirect the bare prefix to its trailing-slash form so relative
+ # asset URLs resolve correctly.
+ location = ${BASE_PATH} {
+ return 301 ${BASE_PATH}/;
+ }
+
+ location ${BASE_PATH}/ {
+ # Static files live at the filesystem root, strip the prefix.
+ rewrite ^${BASE_PATH}/(.*)$ /$1 break;
+ # "=404" makes "/index.html" a file check (SPA fallback) instead of an
+ # internal redirect, which would re-match "location /" and return 404.
+ try_files $uri /index.html =404;
+ # Add headers to all responses
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header Referrer-Policy "no-referrer, strict-origin-when-cross-origin" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "DENY" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+ add_header Content-Security-Policy-Report-Only "${CSP_REPORT_ONLY}" always;
+ }
+
+ # Anything outside the prefix is not served.
+ location / {
+ return 404;
+ }
+
+ # TODO: add cache for static files, avoid caching config.js, index.html etc.
+}
diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue
index 808cb02fad..1263db36ce 100644
--- a/packages/app/src/App.vue
+++ b/packages/app/src/App.vue
@@ -4,7 +4,7 @@
v-if="isPrividiumAuthChecking"
class="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-slate-50 via-white to-blue-50 px-4 py-12 sm:px-6 lg:px-8"
>
-
+
Checking permissions...
@@ -46,6 +46,7 @@ import useLocalization from "@/composables/useLocalization";
import useLogin from "@/composables/useLogin";
import useRouteTitle from "@/composables/useRouteTitle";
+import { publicAsset } from "@/utils/basePath";
import MaintenanceView from "@/views/MaintenanceView.vue";
const { setup } = useLocalization();
diff --git a/packages/app/src/components/Account.vue b/packages/app/src/components/Account.vue
index 54f07fa851..20a720be73 100644
--- a/packages/app/src/components/Account.vue
+++ b/packages/app/src/components/Account.vue
@@ -16,7 +16,7 @@
-
+
@@ -41,7 +41,7 @@
-
+
@@ -91,6 +91,7 @@ import useRuntimeConfig from "@/composables/useRuntimeConfig";
import type { BreadcrumbItem } from "@/components/common/Breadcrumbs.vue";
import type { Account } from "@/composables/useAddress";
+import { publicAsset } from "@/utils/basePath";
import { shortValue } from "@/utils/formatters";
const runtimeConfig = useRuntimeConfig();
diff --git a/packages/app/src/components/ConnectMetamaskButton.vue b/packages/app/src/components/ConnectMetamaskButton.vue
index 0e97cc5855..edf4af84e7 100644
--- a/packages/app/src/components/ConnectMetamaskButton.vue
+++ b/packages/app/src/components/ConnectMetamaskButton.vue
@@ -1,6 +1,6 @@
-
+
@@ -36,6 +36,8 @@ import HashLabel from "@/components/common/HashLabel.vue";
import useContext from "@/composables/useContext";
import { default as useWallet } from "@/composables/useWallet";
+import { publicAsset } from "@/utils/basePath";
+
const { t } = useI18n();
const context = useContext();
diff --git a/packages/app/src/components/Contract.vue b/packages/app/src/components/Contract.vue
index e6c85eae73..8c8008f7c5 100644
--- a/packages/app/src/components/Contract.vue
+++ b/packages/app/src/components/Contract.vue
@@ -22,7 +22,7 @@
-
+
@@ -45,7 +45,7 @@
-
+
@@ -110,6 +110,7 @@ import useRuntimeConfig from "@/composables/useRuntimeConfig";
import type { BreadcrumbItem } from "@/components/common/Breadcrumbs.vue";
import type { Contract } from "@/composables/useAddress";
+import { publicAsset } from "@/utils/basePath";
import { shortValue } from "@/utils/formatters";
const { t } = useI18n();
diff --git a/packages/app/src/components/NetworkSwitch.vue b/packages/app/src/components/NetworkSwitch.vue
index cc30a11c8b..bb0d6f888c 100644
--- a/packages/app/src/components/NetworkSwitch.vue
+++ b/packages/app/src/components/NetworkSwitch.vue
@@ -2,7 +2,7 @@
-
+ {{ currentNetwork.l2NetworkName }}
@@ -26,7 +26,7 @@
:class="{ selected }"
>
-
+ {{ network.l2NetworkName }}
@@ -50,6 +50,7 @@ import useContext from "@/composables/useContext";
import type { NetworkConfig } from "@/configs";
+import { basePathPrefix, publicAsset } from "@/utils/basePath";
import { getWindowLocation } from "@/utils/helpers";
const { networks: allNetworks, currentNetwork } = useContext();
@@ -65,7 +66,7 @@ const getNetworkUrl = (network: NetworkConfig) => {
const hostname = getWindowLocation().hostname;
if (hostname === "localhost" || hostname.endsWith("web.app") || !network.hostnames?.length) {
- return `${route.path}?network=${network.name}`;
+ return `${basePathPrefix()}${route.path}?network=${network.name}`;
}
return network.hostnames[0] + route.path;
};
diff --git a/packages/app/src/components/Token.vue b/packages/app/src/components/Token.vue
index 3959409402..6fd67ae2ae 100644
--- a/packages/app/src/components/Token.vue
+++ b/packages/app/src/components/Token.vue
@@ -8,7 +8,7 @@
@@ -88,6 +88,7 @@ import useTokenOverview from "@/composables/useTokenOverview";
import type { BreadcrumbItem } from "@/components/common/Breadcrumbs.vue";
import type { Contract } from "@/composables/useAddress";
+import { publicAsset } from "@/utils/basePath";
import { shortValue } from "@/utils/formatters";
const { t } = useI18n();
diff --git a/packages/app/src/components/TokenIconLabel.vue b/packages/app/src/components/TokenIconLabel.vue
index fa37ec9ba9..9f59c7e918 100644
--- a/packages/app/src/components/TokenIconLabel.vue
+++ b/packages/app/src/components/TokenIconLabel.vue
@@ -60,6 +60,8 @@ import useRuntimeConfig from "@/composables/useRuntimeConfig";
import type { Hash } from "@/types";
+import { publicAsset } from "@/utils/basePath";
+
export type IconSize = "sm" | "md" | "lg" | "xl";
const { t } = useI18n();
@@ -97,7 +99,7 @@ const props = defineProps({
});
const imgSource = computed(() => {
- return props.iconUrl || "/images/currencies/customToken.svg";
+ return publicAsset(props.iconUrl || "/images/currencies/customToken.svg");
});
const { isReady: isImageLoaded } = useImage({ src: imgSource.value });
diff --git a/packages/app/src/components/header/TheHeader.vue b/packages/app/src/components/header/TheHeader.vue
index bb93c717bd..d5183d1bfc 100644
--- a/packages/app/src/components/header/TheHeader.vue
+++ b/packages/app/src/components/header/TheHeader.vue
@@ -5,7 +5,7 @@
@@ -38,6 +42,8 @@ import useContext from "@/composables/useContext";
import useLogin from "@/composables/useLogin";
import useRuntimeConfig from "@/composables/useRuntimeConfig";
+import { publicAsset } from "@/utils/basePath";
+
const { t } = useI18n();
const { brandName } = useRuntimeConfig();
const context = useContext();
diff --git a/packages/app/src/views/NotAuthorizedView.vue b/packages/app/src/views/NotAuthorizedView.vue
index e68f0a9517..35024e7622 100644
--- a/packages/app/src/views/NotAuthorizedView.vue
+++ b/packages/app/src/views/NotAuthorizedView.vue
@@ -2,7 +2,11 @@
-
+
@@ -32,6 +36,8 @@ import { LockClosedIcon } from "@heroicons/vue/outline";
import useContext from "@/composables/useContext";
+import { publicAsset } from "@/utils/basePath";
+
const { t } = useI18n();
const router = useRouter();
const { currentNetwork } = useContext();
diff --git a/packages/app/tests/utils/basePath.spec.ts b/packages/app/tests/utils/basePath.spec.ts
new file mode 100644
index 0000000000..75d2afb210
--- /dev/null
+++ b/packages/app/tests/utils/basePath.spec.ts
@@ -0,0 +1,162 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { appRootUrl, appUrl, basePathPrefix, currentAppPath, getBasePath, publicAsset } from "@/utils/basePath";
+
+const setBaseTag = (href: string) => {
+ const base = document.createElement("base");
+ base.setAttribute("href", href);
+ document.head.appendChild(base);
+};
+
+const removeBaseTag = () => {
+ document.querySelector("base")?.remove();
+};
+
+describe("basePath:", () => {
+ afterEach(() => {
+ removeBaseTag();
+ });
+
+ describe("getBasePath", () => {
+ it("returns '/' when no base tag is present", () => {
+ expect(getBasePath()).toBe("/");
+ });
+
+ it("returns '/' for a root base tag", () => {
+ setBaseTag("/");
+ expect(getBasePath()).toBe("/");
+ });
+
+ it("returns the subpath from the base tag", () => {
+ setBaseTag("/explorer/");
+ expect(getBasePath()).toBe("/explorer/");
+ });
+
+ it("adds a trailing slash when the base tag has none", () => {
+ setBaseTag("/explorer");
+ // Per URL resolution rules "/explorer" resolves to the "/" directory,
+ // so an explicitly configured base must include the trailing slash.
+ // getBasePath still normalizes its own output to end with "/".
+ expect(getBasePath().endsWith("/")).toBe(true);
+ });
+
+ it("resolves an absolute base tag href to its pathname", () => {
+ setBaseTag("https://example.com/explorer/");
+ expect(getBasePath()).toBe("/explorer/");
+ });
+ });
+
+ describe("publicAsset", () => {
+ it("keeps root-absolute paths unchanged without a base tag", () => {
+ expect(publicAsset("/images/metamask.svg")).toBe("/images/metamask.svg");
+ });
+
+ it("prefixes root-absolute paths with the base path", () => {
+ setBaseTag("/explorer/");
+ expect(publicAsset("/images/metamask.svg")).toBe("/explorer/images/metamask.svg");
+ });
+
+ it("prefixes bare paths with the base path", () => {
+ setBaseTag("/explorer/");
+ expect(publicAsset("images/metamask.svg")).toBe("/explorer/images/metamask.svg");
+ });
+
+ it("keeps fully qualified urls unchanged", () => {
+ setBaseTag("/explorer/");
+ expect(publicAsset("https://example.com/icon.svg")).toBe("https://example.com/icon.svg");
+ expect(publicAsset("http://example.com/icon.svg")).toBe("http://example.com/icon.svg");
+ });
+
+ it("keeps protocol-relative urls unchanged", () => {
+ setBaseTag("/explorer/");
+ expect(publicAsset("//example.com/icon.svg")).toBe("//example.com/icon.svg");
+ });
+
+ it("keeps data urls unchanged", () => {
+ setBaseTag("/explorer/");
+ expect(publicAsset("data:image/svg+xml;base64,abc")).toBe("data:image/svg+xml;base64,abc");
+ });
+
+ it("passes through nullish and empty values unchanged", () => {
+ setBaseTag("/explorer/");
+ expect(publicAsset(undefined)).toBe(undefined);
+ expect(publicAsset(null)).toBe(undefined);
+ expect(publicAsset("")).toBe("");
+ });
+ });
+
+ describe("appUrl", () => {
+ it("returns the origin root url without a base tag", () => {
+ expect(appUrl()).toBe(`${window.location.origin}/`);
+ });
+
+ it("appends the app path to origin without a base tag", () => {
+ expect(appUrl("login")).toBe(`${window.location.origin}/login`);
+ expect(appUrl("/login")).toBe(`${window.location.origin}/login`);
+ });
+
+ it("includes the base path", () => {
+ setBaseTag("/explorer/");
+ expect(appUrl()).toBe(`${window.location.origin}/explorer/`);
+ expect(appUrl("auth/callback")).toBe(`${window.location.origin}/explorer/auth/callback`);
+ });
+ });
+
+ describe("basePathPrefix", () => {
+ it("returns an empty string without a base tag", () => {
+ expect(basePathPrefix()).toBe("");
+ });
+
+ it("returns the base path without a trailing slash", () => {
+ setBaseTag("/explorer/");
+ expect(basePathPrefix()).toBe("/explorer");
+ });
+ });
+
+ describe("appRootUrl", () => {
+ it("returns the bare origin without a base tag", () => {
+ expect(appRootUrl()).toBe(window.location.origin);
+ });
+
+ it("returns origin plus prefix without a trailing slash", () => {
+ setBaseTag("/explorer/");
+ expect(appRootUrl()).toBe(`${window.location.origin}/explorer`);
+ });
+ });
+
+ describe("currentAppPath", () => {
+ it("returns the pathname unchanged without a base tag", () => {
+ expect(currentAppPath()).toBe(window.location.pathname);
+ });
+
+ it("strips the base path from the pathname", () => {
+ setBaseTag("/explorer/");
+ history.replaceState(null, "", "/explorer/blocks");
+ try {
+ expect(currentAppPath()).toBe("/blocks");
+ } finally {
+ history.replaceState(null, "", "/");
+ }
+ });
+
+ it("returns '/' for the bare prefix without a trailing slash", () => {
+ setBaseTag("/explorer/");
+ history.replaceState(null, "", "/explorer");
+ try {
+ expect(currentAppPath()).toBe("/");
+ } finally {
+ history.replaceState(null, "", "/");
+ }
+ });
+
+ it("returns the pathname unchanged when it does not start with the base path", () => {
+ setBaseTag("/explorer/");
+ history.replaceState(null, "", "/blocks");
+ try {
+ expect(currentAppPath()).toBe("/blocks");
+ } finally {
+ history.replaceState(null, "", "/");
+ }
+ });
+ });
+});
diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts
index 318467c958..f36c12c38a 100644
--- a/packages/app/vite.config.ts
+++ b/packages/app/vite.config.ts
@@ -3,8 +3,29 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "url";
+// Base public path the app is served from.
+// - VITE_BASE_PATH: explicit base path (e.g. "/explorer/") baked in at build time.
+// - Docker builds (DOCKER_BUILD=true) use a relative base so the same image can be
+// served from any subpath, configured at container start via the BASE_PATH env var
+// (injected as a tag in index.html, see Dockerfile and index.html).
+// - Local dev/builds keep the default root base.
+function resolveBase(): string {
+ if (process.env.VITE_BASE_PATH) {
+ const trimmed = process.env.VITE_BASE_PATH.replace(/^\/+|\/+$/g, "");
+ return trimmed ? `/${trimmed}/` : "/";
+ }
+ return process.env.DOCKER_BUILD === "true" ? "./" : "/";
+}
+
+// Keep the tag in index.html (filled from BASE_PATH by the html-transform
+// plugin below) in sync with the build-time base for non-Docker dev/builds.
+if (!process.env.BASE_PATH && process.env.VITE_BASE_PATH) {
+ process.env.BASE_PATH = resolveBase();
+}
+
// https://vitejs.dev/config/
export default defineConfig({
+ base: resolveBase(),
server: {
port: 3010,
},
From 90276f3d9d0068e276237376ac20bec00565759e Mon Sep 17 00:00:00 2001
From: bxpana
Date: Wed, 3 Jun 2026 17:26:18 -0400
Subject: [PATCH 2/2] fix(app): address base-path review findings
- prepend the base path to the not-found URL rewrite so the restored
address stays inside the app prefix (refresh no longer 404s under
a subpath deployment), with regression tests for root and subpath
- preserve the query string on the bare-prefix 301 redirect
(e.g. /explorer?network=... no longer loses its params)
- fail fast at container start when BASE_PATH contains characters
outside the documented charset instead of rendering a broken or
unsafe nginx config
- cross-reference the nginx templates so header/gzip changes do not
silently miss subpath deployments, and document the publicAsset
convention for contributors
---
packages/app/Dockerfile | 5 ++-
packages/app/README.md | 2 ++
packages/app/nginx.conf.subpath.template | 5 +--
packages/app/nginx.conf.template | 3 ++
packages/app/src/composables/useNotFound.ts | 6 +++-
.../app/tests/composables/useNotFound.spec.ts | 34 +++++++++++++++++--
6 files changed, 49 insertions(+), 6 deletions(-)
diff --git a/packages/app/Dockerfile b/packages/app/Dockerfile
index f4b32afa2b..f3ff7f188b 100644
--- a/packages/app/Dockerfile
+++ b/packages/app/Dockerfile
@@ -46,8 +46,11 @@ RUN cp /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.tpl
# BASE_PATH (e.g. "/explorer") serves the app under a subpath for
# prefix-preserving reverse proxies. When unset, behavior is unchanged.
# nginx needs the prefix without a trailing slash, the tag (filled
-# by gomplate from BASE_PATH) needs it with one.
+# by gomplate from BASE_PATH) needs it with one. The charset check fails
+# fast on values that would be unsafe to interpolate into the nginx
+# location/rewrite rules (see nginx.conf.subpath.template).
CMD BP="${BASE_PATH#/}" && BP="${BP%/}" \
+ && case "$BP" in *[!a-zA-Z0-9_/-]*) echo "Invalid BASE_PATH: only letters, digits, '-', '_' and '/' are allowed" >&2 && exit 1;; esac \
&& if [ -n "$BP" ]; then \
export BASE_PATH="/$BP" \
&& envsubst '$PORT $CSP_REPORT_ONLY $BASE_PATH' < /etc/nginx/conf.d/subpath.conf.template > /etc/nginx/conf.d/default.conf \
diff --git a/packages/app/README.md b/packages/app/README.md
index c2e245af93..b5ad278af6 100644
--- a/packages/app/README.md
+++ b/packages/app/README.md
@@ -55,6 +55,8 @@ By default the app is served from the root of the domain (e.g. `https://explorer
When the app sits behind a reverse proxy that forwards the subpath prefix to the container (prefix-preserving, e.g. `proxy.example.com/explorer/*` -> `container/explorer/*`), set `BASE_PATH` to that prefix. If the proxy strips the prefix before forwarding, leave `BASE_PATH` unset and note that the app would then generate root-relative URLs which will not work under the proxied subpath, so prefix-preserving proxying is the supported setup.
+For contributors: when referencing files from `public/` (e.g. `/images/...`) or building full-page redirect URLs in app code, resolve them through the helpers in [`src/utils/basePath.ts`](./src/utils/basePath.ts) (`publicAsset`, `appUrl`, etc.) instead of hardcoding root-absolute paths. Hardcoded `/...` URLs work at the domain root but silently break subpath deployments.
+
### Compile and Hot-Reload for Development
```sh
diff --git a/packages/app/nginx.conf.subpath.template b/packages/app/nginx.conf.subpath.template
index dbb9be176b..0401777f62 100644
--- a/packages/app/nginx.conf.subpath.template
+++ b/packages/app/nginx.conf.subpath.template
@@ -22,9 +22,10 @@ server {
gzip_vary on;
# Redirect the bare prefix to its trailing-slash form so relative
- # asset URLs resolve correctly.
+ # asset URLs resolve correctly. $is_args$args preserves the query string
+ # (e.g. ?network=...); envsubst leaves both untouched, like $uri below.
location = ${BASE_PATH} {
- return 301 ${BASE_PATH}/;
+ return 301 ${BASE_PATH}/$is_args$args;
}
location ${BASE_PATH}/ {
diff --git a/packages/app/nginx.conf.template b/packages/app/nginx.conf.template
index 1974bd6fa4..6856a0a952 100644
--- a/packages/app/nginx.conf.template
+++ b/packages/app/nginx.conf.template
@@ -1,3 +1,6 @@
+# NOTE: keep gzip and header settings in sync with nginx.conf.subpath.template,
+# which is used instead of this file when the BASE_PATH env var is set
+# (see the Dockerfile CMD).
server {
listen ${PORT};
listen [::]:${PORT} default;
diff --git a/packages/app/src/composables/useNotFound.ts b/packages/app/src/composables/useNotFound.ts
index e60358b984..67dedc6486 100644
--- a/packages/app/src/composables/useNotFound.ts
+++ b/packages/app/src/composables/useNotFound.ts
@@ -1,6 +1,8 @@
import { type Ref, watch } from "vue";
import { useRouter } from "vue-router";
+import { basePathPrefix } from "@/utils/basePath";
+
export default () => {
const router = useRouter();
const notFoundRoute = router.resolve({ name: "not-found" });
@@ -8,7 +10,9 @@ export default () => {
async function setNotFoundView() {
const fullPath = router.currentRoute.value.fullPath;
await router.replace(notFoundRoute);
- history.replaceState({}, notFoundRoute.meta.title as string, fullPath);
+ // fullPath is router-relative, so prepend the base path to keep the
+ // restored URL inside the app prefix when served from a subpath.
+ history.replaceState({}, notFoundRoute.meta.title as string, basePathPrefix() + fullPath);
}
async function useNotFoundView(...refs: Ref[]) {
diff --git a/packages/app/tests/composables/useNotFound.spec.ts b/packages/app/tests/composables/useNotFound.spec.ts
index 67b4b37593..496607a07e 100644
--- a/packages/app/tests/composables/useNotFound.spec.ts
+++ b/packages/app/tests/composables/useNotFound.spec.ts
@@ -9,7 +9,7 @@ const router = {
resolve: vi.fn(() => notFoundRoute),
replace: vi.fn(),
currentRoute: {
- value: {},
+ value: { fullPath: "/" },
},
};
@@ -26,11 +26,41 @@ describe("UseNotFound:", () => {
});
it("sets not found view", async () => {
- composable.setNotFoundView();
+ await composable.setNotFoundView();
expect(router.replace).toHaveBeenCalledWith(notFoundRoute);
router.replace.mockReset();
});
+ it("restores the original URL after replacing the route", async () => {
+ const replaceStateSpy = vi.spyOn(history, "replaceState").mockImplementation(() => undefined);
+ router.currentRoute.value = { fullPath: "/address/0x123" };
+ try {
+ await composable.setNotFoundView();
+ expect(replaceStateSpy).toHaveBeenCalledWith({}, "404 Not Found", "/address/0x123");
+ } finally {
+ replaceStateSpy.mockRestore();
+ router.currentRoute.value = { fullPath: "/" };
+ router.replace.mockReset();
+ }
+ });
+
+ it("prepends the base path to the restored URL when served from a subpath", async () => {
+ const base = document.createElement("base");
+ base.setAttribute("href", "/explorer/");
+ document.head.appendChild(base);
+ const replaceStateSpy = vi.spyOn(history, "replaceState").mockImplementation(() => undefined);
+ router.currentRoute.value = { fullPath: "/address/0x123?network=mainnet" };
+ try {
+ await composable.setNotFoundView();
+ expect(replaceStateSpy).toHaveBeenCalledWith({}, "404 Not Found", "/explorer/address/0x123?network=mainnet");
+ } finally {
+ replaceStateSpy.mockRestore();
+ base.remove();
+ router.currentRoute.value = { fullPath: "/" };
+ router.replace.mockReset();
+ }
+ });
+
it("sets not found view when passed refs are all falsy", async () => {
const isPending = ref(true);
const isFailed = ref(false);