diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 0ef6ccd7..953195f4 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; -import { apiFetch, getPathPrefix } from "@/lib/browser-navigation"; +import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; @@ -722,7 +722,7 @@ export default function LoginPage() {
{appName} @@ -872,7 +872,7 @@ export default function LoginPage() {
{appName} diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index a792703a..19fd9020 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; -import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; +import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation'; // Single-page tab navigation: clicks update a Zustand store. The URL stays // at /admin so React doesn't fire a route transition on every tab switch - @@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); - const logoUrl = resolvedTheme === 'dark' + const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) - : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); + : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl)); // Match the navigation rail: red for security/deprecated, amber for normal. const hasUpdate = useUpdateStore(selectHasUpdate); diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx index 9f57f3b3..018777d4 100644 --- a/app/(main)/admin/login/page.tsx +++ b/app/(main)/admin/login/page.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { Shield } from 'lucide-react'; import { useConfig } from '@/hooks/use-config'; import { useThemeStore } from '@/stores/theme-store'; -import { apiFetch } from '@/lib/browser-navigation'; +import { apiFetch, withBasePath } from '@/lib/browser-navigation'; export default function AdminLoginPage() { const router = useRouter(); @@ -14,7 +14,7 @@ export default function AdminLoginPage() { const [loading, setLoading] = useState(false); const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); - const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl; + const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl); async function handleSubmit(e: FormEvent) { e.preventDefault(); diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index b98b064c..ac5c3b42 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import { getLocale } from "next-intl/server"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { configManager } from "@/lib/admin/config-manager"; +import { withBasePath } from "@/lib/browser-navigation"; import "../globals.css"; const geistSans = Geist({ @@ -38,7 +39,7 @@ export async function generateMetadata(): Promise { formatDetection: { telephone: false, }, - icons: { icon: faviconUrl }, + icons: { icon: withBasePath(faviconUrl) }, }; } diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index 686bb202..c40664f5 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react'; -import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; +import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation'; type State = 'bootstrap' | 'configured' | 'env-managed'; @@ -1329,7 +1329,7 @@ function BrandingAsset({ }} /> {value ? ( - + ) : ( click or drop )} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 364e3b30..1be0569b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; +import { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; @@ -3270,9 +3271,9 @@ export function EmailViewer({ if (!email) { if (isDemoMode) { - const logoSrc = resolvedTheme === 'dark' + const logoSrc = withBasePath(resolvedTheme === 'dark' ? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg' - : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'; + : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'); return (
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 56a721d9..fc6649ff 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils"; import { cn, formatFileSize } from "@/lib/utils"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { apiFetch, getPathPrefix } from "@/lib/browser-navigation"; +import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { Avatar } from "@/components/ui/avatar"; interface NavItem { @@ -444,7 +444,7 @@ export function NavigationRail({ )} > {(() => { - const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl); + const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl)); return logoUrl ? (
Promise; @@ -58,7 +59,8 @@ export function PWAInstallPrompt() { return null; } - const logoSrc = appLogoLightUrl || faviconUrl; + const logoSrc = withBasePath(appLogoLightUrl || faviconUrl); + const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl); return (
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() { )} {logoSrc && ( {appName} diff --git a/components/settings/files-settings.tsx b/components/settings/files-settings.tsx index 1bec3ddd..c686684a 100644 --- a/components/settings/files-settings.tsx +++ b/components/settings/files-settings.tsx @@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section"; import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog"; import { cn } from "@/lib/utils"; +import { withBasePath } from "@/lib/browser-navigation"; interface SampleFile { name: string; @@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) { )} > {settings.showThumbnails && file.thumbnailUrl ? ( - + ) : settings.showIcons ? ( getPreviewIcon(file, settings.coloredIcons, "sm") ) : null} @@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) { )} > {settings.showThumbnails && file.thumbnailUrl ? ( - + ) : settings.showIcons ? ( getPreviewIcon(file, settings.coloredIcons, "lg") ) : ( diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index 13ac7c20..315aa53e 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useConfig } from "@/hooks/use-config"; import { avatarHooks } from "@/lib/plugin-hooks"; +import { withBasePath } from "@/lib/browser-navigation"; const IS_DEV = process.env.NODE_ENV !== "production"; @@ -236,7 +237,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl; const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null; - const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null; + const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null; const imgSrc = disableImages ? null : (photoSrc || faviconSrc); const isFavicon = imgSrc !== null && imgSrc === faviconSrc; diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts index 5df6ff91..15369273 100644 --- a/lib/browser-navigation.ts +++ b/lib/browser-navigation.ts @@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise { return fetch(input, init); } +/** + * Mount-prefix-aware wrapper for URL strings used in ``, ``, + * `window.location.*`, etc. — anything the browser resolves itself, where + * `apiFetch` can't help. + * + * Idempotent: passing an already-prefixed value, an external URL, a + * protocol-relative URL, or an empty/falsy value returns it unchanged. So it's + * safe to wrap admin-configurable values that might be either a local path + * (`/branding/foo.svg`, `/api/admin/branding/...`) or a full URL. + */ +export function withBasePath(url: string | null | undefined): string { + if (!url) return url ?? ''; + if (url.charCodeAt(0) !== 47) return url; // not absolute (e.g. https://, data:, blob:) + if (url.charCodeAt(1) === 47) return url; // protocol-relative //cdn... + const prefix = getPathPrefix(); + if (!prefix) return url; + if (url === prefix || url.startsWith(prefix + '/')) return url; + return prefix + url; +} + /** * Extracts the locale from the current URL, skipping any mount prefix. diff --git a/lib/webdav/client.ts b/lib/webdav/client.ts index 2772d37d..05486798 100644 --- a/lib/webdav/client.ts +++ b/lib/webdav/client.ts @@ -4,6 +4,7 @@ */ import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; +import { apiFetch, withBasePath } from '@/lib/browser-navigation'; export interface WebDAVResource { href: string; @@ -32,7 +33,7 @@ export class WebDAVClient { ...options?.headers, }; - return fetch(this.proxyUrl, { + return apiFetch(this.proxyUrl, { method: 'POST', headers, body: options?.body, @@ -118,7 +119,7 @@ export class WebDAVClient { // Use XMLHttpRequest for progress tracking return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - xhr.open('POST', this.proxyUrl); + xhr.open('POST', withBasePath(this.proxyUrl)); xhr.setRequestHeader('X-WebDAV-Method', 'PUT'); xhr.setRequestHeader('X-WebDAV-Path', path); const slotHeaders = getActiveAccountSlotHeaders();