feat: support subpath deployment with NEXT_PUBLIC_BASE_PATH environment variable
This commit is contained in:
@@ -4,6 +4,10 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
|
||||||
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
RUN npx next build --webpack
|
RUN npx next build --webpack
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|||||||
@@ -217,6 +217,26 @@ LOG_LEVEL=info # error | warn | info | debug
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Subpath / reverse proxy mount</summary>
|
||||||
|
|
||||||
|
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_BASE_PATH=/webmail
|
||||||
|
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|||||||
@@ -411,7 +411,8 @@ export default function LoginPage() {
|
|||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
const challenge = await generateCodeChallenge(verifier);
|
const challenge = await generateCodeChallenge(verifier);
|
||||||
const state = generateState();
|
const state = generateState();
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
|
||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
|
|||||||
+20
-14
@@ -2,6 +2,12 @@ import type { MetadataRoute } from "next";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Manifest paths must include the deployment subpath - browsers resolve them
|
||||||
|
// against the document origin, not the manifest's location, and Next.js does
|
||||||
|
// not auto-prefix string literals inside MetadataRoute payloads.
|
||||||
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||||
|
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
const appName =
|
const appName =
|
||||||
process.env.APP_NAME ||
|
process.env.APP_NAME ||
|
||||||
@@ -21,26 +27,26 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
|
|
||||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
? [
|
? [
|
||||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
{ src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
{ src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-dark-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: appName,
|
name: appName,
|
||||||
short_name: shortName,
|
short_name: shortName,
|
||||||
description,
|
description,
|
||||||
start_url: "/",
|
start_url: withBase("/"),
|
||||||
scope: "/",
|
scope: withBase("/"),
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
orientation: "portrait-primary",
|
orientation: "portrait-primary",
|
||||||
theme_color: themeColor,
|
theme_color: themeColor,
|
||||||
@@ -48,8 +54,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
icons,
|
icons,
|
||||||
categories: ["productivity"],
|
categories: ["productivity"],
|
||||||
screenshots: [
|
screenshots: [
|
||||||
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||||
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -2,34 +2,43 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Don't redirect admin routes to the webmail login page
|
const prefix = getPathPrefix();
|
||||||
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
|
// Don't redirect admin routes to the webmail login page. Admin paths
|
||||||
|
// are mounted relative to the deployment prefix, so account for it.
|
||||||
|
const adminBase = `${prefix}/admin`;
|
||||||
|
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||||
if (!isAdminRoute) {
|
if (!isAdminRoute) {
|
||||||
window.location.href = "/login";
|
window.location.href = `${prefix}/login`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Allow admin routes to render the 404 without redirecting
|
let isAdmin = false;
|
||||||
const isAdmin = typeof window !== 'undefined' &&
|
if (typeof window !== 'undefined') {
|
||||||
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
|
const prefix = getPathPrefix();
|
||||||
|
const adminBase = `${prefix}/admin`;
|
||||||
|
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||||
|
}
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="text-center max-w-md px-4">
|
<div className="text-center max-w-md px-4">
|
||||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||||
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={`${prefix}/`}
|
||||||
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
Go home
|
Go home
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||||
|
|
||||||
export function ServiceWorkerRegistration() {
|
export function ServiceWorkerRegistration() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
@@ -23,7 +25,7 @@ export function ServiceWorkerRegistration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register("/sw.js")
|
.register(`${BASE_PATH}/sw.js`, { scope: `${BASE_PATH}/` })
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
console.log("Service Worker registered successfully:", registration);
|
console.log("Service Worker registered successfully:", registration);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useConfig } from '@/hooks/use-config';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { Mail, X } from 'lucide-react';
|
import { Mail, X } from 'lucide-react';
|
||||||
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import {
|
import {
|
||||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||||
isSupportedSubAddressDelimiter,
|
isSupportedSubAddressDelimiter,
|
||||||
@@ -32,7 +33,7 @@ export function ComposingSettings() {
|
|||||||
const handleSetDefaultMailProgram = useCallback(() => {
|
const handleSetDefaultMailProgram = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
|
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
|
||||||
setDefaultMailStatus('success');
|
setDefaultMailStatus('success');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
|
|||||||
import { useToastStore } from '@/stores/toast-store';
|
import { useToastStore } from '@/stores/toast-store';
|
||||||
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
||||||
import { playNotificationSound } from '@/lib/notification-sound';
|
import { playNotificationSound } from '@/lib/notification-sound';
|
||||||
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000;
|
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||||
@@ -68,7 +69,7 @@ export function useCalendarAlerts() {
|
|||||||
message,
|
message,
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.location.href = `/${locale}/calendar`;
|
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ export function useCalendarAlerts() {
|
|||||||
message: taskMsg,
|
message: taskMsg,
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.location.href = `/${locale}/calendar`;
|
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,27 @@ export function replaceWindowLocation(url: string): void {
|
|||||||
window.location.replace(url);
|
window.location.replace(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build-time constant injected by next.config.ts. When the app is built with
|
||||||
|
// NEXT_PUBLIC_BASE_PATH=/webmail, Next.js itself prefixes routes and assets;
|
||||||
|
// helpers below use the same value so client code stays consistent.
|
||||||
|
const STATIC_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the mount prefix from the current URL.
|
* Returns the mount prefix the app is served at.
|
||||||
* When the app is served behind a reverse proxy at e.g. /bulwark,
|
|
||||||
* the browser sees /bulwark/en/login while Next.js sees /en/login.
|
|
||||||
*
|
*
|
||||||
* If a locale is supplied (e.g. from route params) it is used directly;
|
* Resolution order:
|
||||||
* otherwise the first path segment that matches a known locale is used.
|
* 1. The build-time `NEXT_PUBLIC_BASE_PATH` constant (set in next.config.ts).
|
||||||
|
* 2. Runtime detection from `window.location.pathname` for legacy deploys
|
||||||
|
* where the reverse proxy mounts the app at a subpath without rebuilding.
|
||||||
|
*
|
||||||
|
* If a locale is supplied (e.g. from route params) it anchors the runtime
|
||||||
|
* detection; otherwise the first path segment that matches a known locale is
|
||||||
|
* used.
|
||||||
*
|
*
|
||||||
* Returns '' when there is no prefix.
|
* Returns '' when there is no prefix.
|
||||||
*/
|
*/
|
||||||
export function getPathPrefix(locale?: string): string {
|
export function getPathPrefix(locale?: string): string {
|
||||||
|
if (STATIC_BASE_PATH) return STATIC_BASE_PATH;
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
|
|
||||||
const segments = window.location.pathname.split('/').filter(Boolean);
|
const segments = window.location.pathname.split('/').filter(Boolean);
|
||||||
|
|||||||
+8
-4
@@ -9,6 +9,10 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|||||||
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
|
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
|
||||||
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
|
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
|
||||||
|
|
||||||
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
|
||||||
|
const SW_SCOPE = `${BASE_PATH}/`;
|
||||||
|
const SW_URL = `${BASE_PATH}/sw.js`;
|
||||||
|
|
||||||
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
|
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
|
||||||
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
|
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
|
||||||
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
|
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
|
||||||
@@ -139,9 +143,9 @@ async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
|||||||
// The webmail's PWA already registers /sw.js for installability. If it
|
// The webmail's PWA already registers /sw.js for installability. If it
|
||||||
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
|
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
|
||||||
// push handler is in place.
|
// push handler is in place.
|
||||||
let registration = await navigator.serviceWorker.getRegistration('/');
|
let registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
registration = await navigator.serviceWorker.register('/sw.js');
|
registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE });
|
||||||
}
|
}
|
||||||
await navigator.serviceWorker.ready;
|
await navigator.serviceWorker.ready;
|
||||||
return registration;
|
return registration;
|
||||||
@@ -336,7 +340,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
||||||
const sub = await registration?.pushManager.getSubscription();
|
const sub = await registration?.pushManager.getSubscription();
|
||||||
if (sub) await sub.unsubscribe().catch(() => undefined);
|
if (sub) await sub.unsubscribe().catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -345,7 +349,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
|||||||
export async function isWebPushEnabled(): Promise<boolean> {
|
export async function isWebPushEnabled(): Promise<boolean> {
|
||||||
if (!isWebPushSupported()) return false;
|
if (!isWebPushSupported()) return false;
|
||||||
if (Notification.permission !== 'granted') return false;
|
if (Notification.permission !== 'granted') return false;
|
||||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
||||||
if (!registration) return false;
|
if (!registration) return false;
|
||||||
const sub = await registration.pushManager.getSubscription();
|
const sub = await registration.pushManager.getSubscription();
|
||||||
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
|
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
|
||||||
|
|||||||
@@ -18,15 +18,28 @@ try {
|
|||||||
// VERSION file not found
|
// VERSION file not found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subpath deployment, e.g. NEXT_PUBLIC_BASE_PATH=/webmail. Read at build time
|
||||||
|
// because Next.js bakes basePath into emitted asset URLs and route metadata.
|
||||||
|
// Trailing slash is stripped; an empty/missing value disables the feature.
|
||||||
|
const rawBasePath = process.env.NEXT_PUBLIC_BASE_PATH?.trim() ?? "";
|
||||||
|
const basePath = rawBasePath.replace(/\/+$/, "");
|
||||||
|
if (basePath && !basePath.startsWith("/")) {
|
||||||
|
throw new Error(
|
||||||
|
`NEXT_PUBLIC_BASE_PATH must start with "/" (got: ${JSON.stringify(rawBasePath)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
allowedDevOrigins: ["192.168.1.51"],
|
allowedDevOrigins: ["192.168.1.51"],
|
||||||
|
basePath: basePath || undefined,
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: import.meta.dirname,
|
root: import.meta.dirname,
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||||
NEXT_PUBLIC_APP_VERSION: appVersion,
|
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||||
|
NEXT_PUBLIC_BASE_PATH: basePath,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+21
-7
@@ -10,6 +10,20 @@
|
|||||||
// task: relay sends only a state-change ping, the client fetches the
|
// task: relay sends only a state-change ping, the client fetches the
|
||||||
// newest unread email itself so the relay never sees mail content.
|
// newest unread email itself so the relay never sees mail content.
|
||||||
|
|
||||||
|
// When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the
|
||||||
|
// SW is served at /webmail/sw.js and registered with scope /webmail/. Derive
|
||||||
|
// the prefix from the SW's own URL so push fetches and notification clicks
|
||||||
|
// land on the right path - service workers can't read process.env.
|
||||||
|
function getBasePath() {
|
||||||
|
const path = new URL(self.location.href).pathname;
|
||||||
|
// self.location is .../sw.js; strip the trailing filename to get the dir,
|
||||||
|
// then strip the trailing slash so it concatenates cleanly with `/foo`.
|
||||||
|
const dir = path.replace(/[^/]*$/, "");
|
||||||
|
return dir.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_PATH = getBasePath();
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
@@ -48,7 +62,7 @@ async function handlePush(event) {
|
|||||||
let preview = null;
|
let preview = null;
|
||||||
let previewOk = false;
|
let previewOk = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/push/preview", {
|
const res = await fetch(`${BASE_PATH}/api/push/preview`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@@ -99,8 +113,8 @@ async function handlePush(event) {
|
|||||||
await self.registration.showNotification(title, {
|
await self.registration.showNotification(title, {
|
||||||
body,
|
body,
|
||||||
tag,
|
tag,
|
||||||
icon: "/icon-192x192.png",
|
icon: `${BASE_PATH}/icon-192x192.png`,
|
||||||
badge: "/icon-192x192.png",
|
badge: `${BASE_PATH}/icon-192x192.png`,
|
||||||
data,
|
data,
|
||||||
renotify: true,
|
renotify: true,
|
||||||
});
|
});
|
||||||
@@ -132,17 +146,17 @@ async function handleNotificationClick(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (self.clients.openWindow) {
|
if (self.clients.openWindow) {
|
||||||
return self.clients.openWindow(targetUrl || "/");
|
return self.clients.openWindow(targetUrl || `${BASE_PATH}/`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClickUrl(data) {
|
function buildClickUrl(data) {
|
||||||
if (!data) return "/";
|
if (!data) return `${BASE_PATH}/`;
|
||||||
if (data.kind === "email" && data.emailId) {
|
if (data.kind === "email" && data.emailId) {
|
||||||
return `/?email=${encodeURIComponent(data.emailId)}`;
|
return `${BASE_PATH}/?email=${encodeURIComponent(data.emailId)}`;
|
||||||
}
|
}
|
||||||
// Generic "New mail" toast (preview API failed or returned no email): land
|
// Generic "New mail" toast (preview API failed or returned no email): land
|
||||||
// the user on the latest unread message in their Inbox rather than just the
|
// the user on the latest unread message in their Inbox rather than just the
|
||||||
// app shell, so the click still feels purposeful.
|
// app shell, so the click still feels purposeful.
|
||||||
return "/?openLatestUnread=1";
|
return `${BASE_PATH}/?openLatestUnread=1`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user