Skip to content
Open
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
23 changes: 22 additions & 1 deletion packages/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,10 +38,26 @@ 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 <base> tag (filled
# 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 \
&& 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;'
10 changes: 10 additions & 0 deletions packages/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ 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 `<base>` 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.

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
Expand Down
7 changes: 4 additions & 3 deletions packages/app/index.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href='{{ getenv "BASE_PATH" | default "/" }}' />
<title>{{ getenv "VITE_BRAND_NAME" | default "ZKsync" }} Block Explorer</title>
<meta charset="UTF-8" />
<meta name="description" content='{{ getenv "VITE_BRAND_NAME" | default "ZKsync" }} Block Explorer provides all the information to deep dive into transactions, blocks, contracts, and much more. Deep dive into {{ getenv "VITE_BRAND_NAME" | default "ZKsync" }} and explore the network.' />
Expand All @@ -13,12 +14,12 @@
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content='{{ getenv "VITE_BRAND_NAME" | default "ZKsync" }} Block Explorer' />

<link rel="alternate icon" type='{{ getenv "VITE_ALT_FAVICON_IMAGE_TYPE" | default "image/x-icon" }}' href='{{ getenv "VITE_ALT_FAVICON_IMAGE_URL" | default "/favicon.ico" }}' />
<link rel="icon" type='{{ getenv "VITE_FAVICON_IMAGE_TYPE" | default "image/svg+xml" }}' href='{{ getenv "VITE_FAVICON_IMAGE_URL" | default "/favicon.svg" }}' />
<link rel="alternate icon" type='{{ getenv "VITE_ALT_FAVICON_IMAGE_TYPE" | default "image/x-icon" }}' href='{{ getenv "VITE_ALT_FAVICON_IMAGE_URL" | default "favicon.ico" }}' />
<link rel="icon" type='{{ getenv "VITE_FAVICON_IMAGE_TYPE" | default "image/svg+xml" }}' href='{{ getenv "VITE_FAVICON_IMAGE_URL" | default "favicon.svg" }}' />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="true" />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<script src="/config.js"></script>
<script src="config.js"></script>

</head>
<body>
Expand Down
52 changes: 52 additions & 0 deletions packages/app/nginx.conf.subpath.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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. $is_args$args preserves the query string
# (e.g. ?network=...); envsubst leaves both untouched, like $uri below.
location = ${BASE_PATH} {
return 301 ${BASE_PATH}/$is_args$args;
}

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.
}
3 changes: 3 additions & 0 deletions packages/app/nginx.conf.template
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<img src="/images/prividium_logo.svg" alt="Prividium Logo" class="mb-6 h-16 w-auto" />
<img :src="publicAsset('/images/prividium_logo.svg')" alt="Prividium Logo" class="mb-6 h-16 w-auto" />
<div class="text-center">
<h1 class="mb-4 text-2xl font-semibold text-gray-900">Checking permissions...</h1>
<div class="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/Account.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<EmptyState>
<template #image>
<div class="balances-empty-icon">
<img src="/images/empty-state/empty_balance.svg" alt="empty_balance" />
<img :src="publicAsset('/images/empty-state/empty_balance.svg')" alt="empty_balance" />
</div>
</template>
<template #title>
Expand All @@ -41,7 +41,7 @@
<EmptyState>
<template #image>
<div class="balances-empty-icon">
<img src="/images/empty-state/error_balance.svg" alt="empty_balance" />
<img :src="publicAsset('/images/empty-state/error_balance.svg')" alt="empty_balance" />
</div>
</template>
<template #title>
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/components/ConnectMetamaskButton.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="metamask-button" :class="{ disabled: buttonDisabled }">
<img src="/images/metamask.svg" class="metamask-image" />
<img :src="publicAsset('/images/metamask.svg')" class="metamask-image" />
<button v-if="!address" :disabled="buttonDisabled" class="login-button" @click="connect">
{{ buttonText }}
</button>
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/Contract.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<EmptyState>
<template #image>
<div class="balances-empty-icon">
<img src="/images/empty-state/empty_balance.svg" alt="empty_balance" />
<img :src="publicAsset('/images/empty-state/empty_balance.svg')" alt="empty_balance" />
</div>
</template>
<template #title>
Expand All @@ -45,7 +45,7 @@
<EmptyState>
<template #image>
<div class="balances-empty-icon">
<img src="/images/empty-state/error_balance.svg" alt="empty_balance" />
<img :src="publicAsset('/images/empty-state/error_balance.svg')" alt="empty_balance" />
</div>
</template>
<template #title>
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 4 additions & 3 deletions packages/app/src/components/NetworkSwitch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Listbox as="div" :model-value="selected" class="network-switch">
<ListboxButton class="toggle-button">
<span class="network-item">
<img :src="currentNetwork.icon" alt="ZKsync arrows logo" class="network-item-img" />
<img :src="publicAsset(currentNetwork.icon)" alt="ZKsync arrows logo" class="network-item-img" />
<span class="network-item-label">{{ currentNetwork.l2NetworkName }}</span>
</span>
<span class="toggle-button-icon-wrapper">
Expand All @@ -26,7 +26,7 @@
:class="{ selected }"
>
<span class="network-item">
<img :src="network.icon" :alt="`${network.l2NetworkName} logo`" class="network-item-img" />
<img :src="publicAsset(network.icon)" :alt="`${network.l2NetworkName} logo`" class="network-item-img" />
<span class="network-item-label network-list-item-label">{{ network.l2NetworkName }} </span>
</span>
<MinusCircleIcon v-if="network.maintenance" class="maintenance-icon" aria-hidden="true" />
Expand All @@ -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();
Expand All @@ -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;
};
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/components/Token.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<img
v-if="contract?.address && !pending && tokenInfo"
class="token-img"
:src="tokenInfo.iconURL || '/images/currencies/customToken.svg'"
:src="publicAsset(tokenInfo.iconURL || '/images/currencies/customToken.svg')"
:alt="tokenInfo.symbol || t('balances.table.unknownSymbol')"
/>
</div>
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/components/TokenIconLabel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
</script>
Expand Down
11 changes: 8 additions & 3 deletions packages/app/src/components/header/TheHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<div class="logo-container">
<router-link :to="{ name: 'home' }">
<span class="sr-only">ZKsync</span>
<img v-if="currentNetwork.logoUrl" :src="currentNetwork.logoUrl" />
<img v-if="currentNetwork.logoUrl" :src="publicAsset(currentNetwork.logoUrl)" />
<zk-sync-era v-else-if="currentNetwork.groupId === 'era'" />
<zk-sync-arrows-logo v-else />
</router-link>
Expand Down Expand Up @@ -56,7 +56,11 @@
class="hero-banner-container"
:class="[`${currentNetwork.name}`, { 'home-banner': route.path === '/' }]"
>
<img v-if="currentNetwork.heroBannerImageUrl" class="hero-image" :src="currentNetwork.heroBannerImageUrl" />
<img
v-if="currentNetwork.heroBannerImageUrl"
class="hero-image"
:src="publicAsset(currentNetwork.heroBannerImageUrl)"
/>
<hero-arrows v-else class="hero-image" />
</div>
<transition
Expand All @@ -72,7 +76,7 @@
<div class="mobile-header-container">
<div class="mobile-popover-navigation">
<div class="popover-zksync-logo">
<img v-if="currentNetwork.logoInverseUrl" :src="currentNetwork.logoInverseUrl" />
<img v-if="currentNetwork.logoInverseUrl" :src="publicAsset(currentNetwork.logoInverseUrl)" />
<zk-sync v-else class="logo" />
</div>
<div class="-mr-2">
Expand Down Expand Up @@ -159,6 +163,7 @@ import useContext from "@/composables/useContext";
import useLocalization from "@/composables/useLocalization";
import useRuntimeConfig from "@/composables/useRuntimeConfig";

import { publicAsset } from "@/utils/basePath";
import { isAddress, isBlockNumber, isTransactionHash } from "@/utils/validators";
const { changeLanguage } = useLocalization();
const { t, locale } = useI18n({ useScope: "global" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<template v-else>
<span class="network-item">
<img
:src="context.currentNetwork.value.icon"
:src="publicAsset(context.currentNetwork.value.icon)"
:alt="`${context.currentNetwork.value.l2NetworkName} logo`"
class="network-item-img"
/>
Expand All @@ -22,6 +22,8 @@ import { computed } from "vue";
import useContext from "@/composables/useContext";
import useWallet from "@/composables/useWallet";

import { publicAsset } from "@/utils/basePath";

const context = useContext();
const { isConnectedWrongNetwork } = useWallet({
...context,
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/components/prividium/WalletButton.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="wallet-button" :class="{ disabled: buttonDisabled }" @click="openModalConditionally">
<img src="/images/metamask.svg" class="wallet-image" />
<img :src="publicAsset('/images/metamask.svg')" class="wallet-image" />
<button v-if="!displayAddress" :disabled="buttonDisabled" class="login-button" @click="handleLogin">
{{ buttonText }}
</button>
Expand Down Expand Up @@ -39,6 +39,7 @@ import useEnvironmentConfig from "@/composables/useEnvironmentConfig";
import useLogin from "@/composables/useLogin";
import { isAuthenticated, default as useWallet } from "@/composables/useWallet";

import { publicAsset } from "@/utils/basePath";
import { formatShortAddress } from "@/utils/formatters";
import logger from "@/utils/logger";

Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/components/prividium/WalletInfoModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import Popup from "@/components/common/Popup.vue";
import useContext from "@/composables/useContext";
import useLogin from "@/composables/useLogin";

import { appUrl } from "@/utils/basePath";

const props = defineProps({
opened: {
type: Boolean,
Expand Down Expand Up @@ -92,7 +94,7 @@ const handleWalletSwitch = async (newAddress: string) => {
try {
isSwitching.value = true;
await switchWallet(newAddress);
window.location.href = "/"; // Triggers full reload of the page
window.location.href = appUrl(); // Triggers full reload of the page
emit("close");
} catch (error) {
console.error("Failed to switch wallet:", error);
Expand Down
Loading
Loading