fix: prefix remaining <img>, favicon, and WebDAV URLs with basePath #319

This commit is contained in:
Linus Rath
2026-05-25 16:45:13 +02:00
parent 42ec34be21
commit e2abc8dee9
12 changed files with 49 additions and 22 deletions
+3 -3
View File
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config"; 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 { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; 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"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
@@ -722,7 +722,7 @@ export default function LoginPage() {
<div className="px-8 pt-12 pb-4 text-center"> <div className="px-8 pt-12 pb-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 mb-6"> <div className="inline-flex items-center justify-center w-20 h-20 mb-6">
<img <img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl} src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName} alt={appName}
className="max-w-20 max-h-20 object-contain" className="max-w-20 max-h-20 object-contain"
/> />
@@ -872,7 +872,7 @@ export default function LoginPage() {
<div className="px-8 pt-10 pb-6 text-center"> <div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5"> <div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<img <img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl} src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName} alt={appName}
className="max-w-16 max-h-16 object-contain" className="max-w-16 max-h-16 object-contain"
/> />
+3 -3
View File
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; 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 // 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 - // 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 { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' const logoUrl = withBasePath(resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
// Match the navigation rail: red for security/deprecated, amber for normal. // Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate); const hasUpdate = useUpdateStore(selectHasUpdate);
+2 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export default function AdminLoginPage() { export default function AdminLoginPage() {
const router = useRouter(); const router = useRouter();
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl; const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
+2 -1
View File
@@ -5,6 +5,7 @@ import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager"; import { configManager } from "@/lib/admin/config-manager";
import { withBasePath } from "@/lib/browser-navigation";
import "../globals.css"; import "../globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -38,7 +39,7 @@ export async function generateMetadata(): Promise<Metadata> {
formatDetection: { formatDetection: {
telephone: false, telephone: false,
}, },
icons: { icon: faviconUrl }, icons: { icon: withBasePath(faviconUrl) },
}; };
} }
+2 -2
View File
@@ -3,7 +3,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react'; 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'; type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -1329,7 +1329,7 @@ function BrandingAsset({
}} }}
/> />
{value ? ( {value ? (
<img src={value} alt="" className="max-w-full max-h-full object-contain" /> <img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
) : ( ) : (
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span> <span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
)} )}
+3 -2
View File
@@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
@@ -3270,9 +3271,9 @@ export function EmailViewer({
if (!email) { if (!email) {
if (isDemoMode) { 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_White_and_Color.svg'
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'; : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
return ( return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}> <div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8 max-w-md"> <div className="text-center p-8 max-w-md">
+2 -2
View File
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; 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"; import { Avatar } from "@/components/ui/avatar";
interface NavItem { 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 ? ( return logoUrl ? (
<div className="flex items-center justify-center py-3 px-1"> <div className="flex items-center justify-center py-3 px-1">
<img <img
+4 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X, Download } from "lucide-react"; import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
interface BeforeInstallPromptEvent extends Event { interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>; prompt: () => Promise<void>;
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
return null; return null;
} }
const logoSrc = appLogoLightUrl || faviconUrl; const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
return ( return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4"> <div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
)} )}
{logoSrc && ( {logoSrc && (
<img <img
src={appLogoDarkUrl || faviconUrl} src={darkLogoSrc}
alt={appName} alt={appName}
className="w-8 h-8 shrink-0 object-contain hidden dark:block" className="w-8 h-8 shrink-0 object-contain hidden dark:block"
/> />
+3 -2
View File
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section"; import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog"; import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { withBasePath } from "@/lib/browser-navigation";
interface SampleFile { interface SampleFile {
name: string; name: string;
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)} )}
> >
{settings.showThumbnails && file.thumbnailUrl ? ( {settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" /> <img src={withBasePath(file.thumbnailUrl)} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? ( ) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm") getPreviewIcon(file, settings.coloredIcons, "sm")
) : null} ) : null}
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)} )}
> >
{settings.showThumbnails && file.thumbnailUrl ? ( {settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" /> <img src={withBasePath(file.thumbnailUrl)} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? ( ) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg") getPreviewIcon(file, settings.coloredIcons, "lg")
) : ( ) : (
+2 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks"; import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
const IS_DEV = process.env.NODE_ENV !== "production"; 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 customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl; const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null; 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 imgSrc = disableImages ? null : (photoSrc || faviconSrc);
const isFavicon = imgSrc !== null && imgSrc === faviconSrc; const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
+20
View File
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
return fetch(input, init); return fetch(input, init);
} }
/**
* Mount-prefix-aware wrapper for URL strings used in `<img src>`, `<link href>`,
* `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. * Extracts the locale from the current URL, skipping any mount prefix.
+3 -2
View File
@@ -4,6 +4,7 @@
*/ */
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export interface WebDAVResource { export interface WebDAVResource {
href: string; href: string;
@@ -32,7 +33,7 @@ export class WebDAVClient {
...options?.headers, ...options?.headers,
}; };
return fetch(this.proxyUrl, { return apiFetch(this.proxyUrl, {
method: 'POST', method: 'POST',
headers, headers,
body: options?.body, body: options?.body,
@@ -118,7 +119,7 @@ export class WebDAVClient {
// Use XMLHttpRequest for progress tracking // Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); 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-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path); xhr.setRequestHeader('X-WebDAV-Path', path);
const slotHeaders = getActiveAccountSlotHeaders(); const slotHeaders = getActiveAccountSlotHeaders();