mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: add a desktop build of the webmail that no server build can switch on
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import QRCode from 'qrcode';
|
||||
@@ -1569,8 +1570,12 @@ export function AccountSecuritySettings() {
|
||||
<SettingsSection id="devices" title={t('title')} description={t('description')}>
|
||||
{!IS_LITE ? (
|
||||
<div className="space-y-6">
|
||||
<LinkDeviceSection />
|
||||
<div className="border-t border-border" />
|
||||
{!IS_DESKTOP_BUILD && (
|
||||
<>
|
||||
<LinkDeviceSection />
|
||||
<div className="border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() {
|
||||
description={withoutPassword ? t('description') : t('sections.devices_description')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{!IS_LITE && (
|
||||
{!IS_LITE && !IS_DESKTOP_BUILD && (
|
||||
<>
|
||||
<LinkDeviceSection />
|
||||
<div className="border-t border-border" />
|
||||
|
||||
@@ -31,6 +31,33 @@ afterEach(() => {
|
||||
while (temps.length) rmSync(temps.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the desktop trust path', () => {
|
||||
it('fails a server build that still holds it', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'server.js');
|
||||
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
|
||||
expect(guardStandalone(dir, { desktopBuild: false })).toEqual(['.next/server/chunks/123.js (desktop trust path in a server build)']);
|
||||
});
|
||||
|
||||
it('passes the desktop build itself', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'server.js');
|
||||
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
|
||||
expect(guardStandalone(dir, { desktopBuild: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails the command line check on a server build', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'server.js');
|
||||
put(dir, '.next/server/chunks/123.js', 'bulwark.desktop.trustedJmapOrigins');
|
||||
const env = { ...process.env };
|
||||
delete env.NEXT_PUBLIC_BULWARK_DESKTOP;
|
||||
const run = spawnSync(process.execPath, [SCRIPT, dir], { encoding: 'utf8', env });
|
||||
expect(run.status).toBe(1);
|
||||
expect(run.stderr).toContain('desktop trust path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standalone guard check', () => {
|
||||
it('fails the build on a planted signing key', () => {
|
||||
const dir = tempDir();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
|
||||
/**
|
||||
* Utilities for multi-account support:
|
||||
* - Account ID generation
|
||||
@@ -136,6 +138,9 @@ export function isHttp2Available(): boolean {
|
||||
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
|
||||
*/
|
||||
export function getMaxAccounts(): number {
|
||||
// The cap is for the push streams sharing one http/1.1 origin. The embedded
|
||||
// server speaks http/1.1, but each account streams from its own mail server.
|
||||
if (IS_DESKTOP_BUILD) return MAX_ACCOUNT_SLOTS;
|
||||
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
|
||||
*/
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
|
||||
|
||||
|
||||
export interface JmapServerOAuthConfig {
|
||||
clientId?: string;
|
||||
@@ -223,6 +226,9 @@ export function resolveTrustedJmapUrl(
|
||||
}
|
||||
const matched = servers.find((s) => normalizeUrl(s.url) === target);
|
||||
if (matched) return matched.url;
|
||||
// A server the user approved in the desktop app. Compiled out of every other
|
||||
// build, so nothing at runtime can open this path on a server.
|
||||
if (IS_DESKTOP_BUILD && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl);
|
||||
// No match - caller decides whether to honor the request anyway (e.g. when
|
||||
// allowCustomJmapEndpoint is enabled).
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { build } from 'esbuild';
|
||||
import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../..');
|
||||
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
|
||||
const PRIVATE_SERVER = 'http://192.168.1.5:8080';
|
||||
|
||||
type Holder = Record<symbol, ReadonlySet<string> | undefined>;
|
||||
|
||||
function trust(...origins: string[]) {
|
||||
(globalThis as unknown as Holder)[TRUST_KEY] = new Set(origins);
|
||||
}
|
||||
|
||||
async function loadResolver() {
|
||||
vi.resetModules();
|
||||
return (await import('@/lib/admin/jmap-servers')).resolveTrustedJmapUrl;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as unknown as Holder)[TRUST_KEY];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete (globalThis as unknown as Holder)[TRUST_KEY];
|
||||
});
|
||||
|
||||
describe('the trusted set, server build', () => {
|
||||
it('refuses a private URL even when the set holds it', async () => {
|
||||
trust('http://192.168.1.5:8080');
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the trusted set, desktop build', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
});
|
||||
|
||||
it('accepts a server main approved', async () => {
|
||||
trust('http://192.168.1.5:8080');
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve(`${PRIVATE_SERVER}/`, '', [])).toBe(PRIVATE_SERVER);
|
||||
});
|
||||
|
||||
it('does not trust everything: another private server stays out', async () => {
|
||||
trust('http://192.168.1.5:8080');
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('http://192.168.1.6:8080', '', [])).toBeNull();
|
||||
expect(resolve('http://192.168.1.5:9999', '', [])).toBeNull();
|
||||
expect(resolve('http://169.254.169.254', '', [])).toBeNull();
|
||||
});
|
||||
|
||||
it('trusts nothing before main has sent a set', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a URL with credentials in it', async () => {
|
||||
trust('http://192.168.1.5:8080');
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('http://a:b@192.168.1.5:8080', '', [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the flag is fixed at build time', () => {
|
||||
/** Compiles lib/desktop/flag.ts the way next.config.ts `env` does and runs it. */
|
||||
async function flagOfBuild(buildEnv: NodeJS.ProcessEnv, runtimeEnv: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
const result = await build({
|
||||
entryPoints: [path.join(ROOT, 'lib/desktop/flag.ts')],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(desktopBuildFlag(buildEnv)) },
|
||||
});
|
||||
const run = new Function('module', 'exports', 'process', result.outputFiles[0].text);
|
||||
const module = { exports: {} as { IS_DESKTOP_BUILD?: boolean } };
|
||||
run(module, module.exports, { env: runtimeEnv });
|
||||
return module.exports.IS_DESKTOP_BUILD === true;
|
||||
}
|
||||
|
||||
it('a runtime env var cannot switch a server build on', async () => {
|
||||
expect(await flagOfBuild({}, { NEXT_PUBLIC_BULWARK_DESKTOP: '1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('a desktop build is on without any runtime env var', async () => {
|
||||
expect(await flagOfBuild({ NEXT_PUBLIC_BULWARK_DESKTOP: '1' }, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('next.config.ts always inlines the flag, so an unset one is no runtime read', () => {
|
||||
const config = readFileSync(path.join(ROOT, 'next.config.ts'), 'utf8');
|
||||
expect(config).toMatch(/NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag\(\)/);
|
||||
expect(desktopBuildFlag({})).toBe('');
|
||||
});
|
||||
|
||||
it('flag.ts reads the variable in the one form the compiler replaces', () => {
|
||||
const source = readFileSync(path.join(ROOT, 'lib/desktop/flag.ts'), 'utf8');
|
||||
expect(source).toContain("process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'");
|
||||
});
|
||||
|
||||
it('no image build sets it', () => {
|
||||
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
|
||||
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Routes with no meaning in the desktop build: pairing a phone and the mobile
|
||||
* hand-off both hand out `https://app.bulwark.localhost`, which nothing else
|
||||
* can reach, and the app has no use for impersonation. proxy.ts answers 404.
|
||||
*/
|
||||
const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/;
|
||||
|
||||
export function isDesktopBlockedRoute(pathname: string): boolean {
|
||||
return BLOCKED.test(pathname);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Finds the desktop app's bridge. Everything here is feature detected: in a
|
||||
* browser there's no bridge, and a newer bridge version than this code knows
|
||||
* is treated as absent rather than guessed at.
|
||||
*/
|
||||
|
||||
import { DESKTOP_BRIDGE_VERSION, type BulwarkDesktopBridge } from './types';
|
||||
|
||||
export function getDesktopBridge(): BulwarkDesktopBridge | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const bridge = window.bulwarkDesktop;
|
||||
if (!bridge || typeof bridge !== 'object') return null;
|
||||
if (typeof bridge.version !== 'number' || bridge.version > DESKTOP_BRIDGE_VERSION) return null;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/** True in any window of the desktop app, standalone or on an instance. */
|
||||
export function isDesktopApp(): boolean {
|
||||
return getDesktopBridge() !== null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The desktop app's own webmail build (`npm run build:desktop`).
|
||||
*
|
||||
* A build-time constant, inlined through next.config.ts `env` into client and
|
||||
* server bundles alike, like `IS_LITE`. A server admin can't switch it on: in
|
||||
* any other build the value is compiled in as empty, and the bundler drops
|
||||
* the branches. Never read this flag through a runtime environment variable.
|
||||
*
|
||||
* What it unlocks, and only in that build:
|
||||
* - login routes accept the servers the user approved in the app
|
||||
* (`isDesktopTrustedServer`, the list comes from the Electron main process)
|
||||
* - sign-in through the system browser for any server
|
||||
* - a per-slot record of the upstream for refresh tokens
|
||||
* - a login budget per mail server rather than per client address
|
||||
* - pairing and hand-off routes are gone
|
||||
*/
|
||||
export const IS_DESKTOP_BUILD = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1';
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The mail servers the user approved in the desktop app.
|
||||
*
|
||||
* The Electron main process owns this list and sends it to the embedded server
|
||||
* at start and on every change (src/server/host.ts in the desktop repo sets it
|
||||
* on globalThis, which all of Next's bundles share). Nothing in the renderer
|
||||
* or the environment can write it, so script in a page can't aim the login
|
||||
* routes at the LAN.
|
||||
*
|
||||
* Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the
|
||||
* whole thing away. scripts/standalone-guard.mjs checks that it did.
|
||||
*/
|
||||
|
||||
type TrustedOrigins = ReadonlySet<string>;
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
if (parsed.username || parsed.password) return null;
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDesktopTrustedServer(url: string | null | undefined): boolean {
|
||||
if (!url) return false;
|
||||
const origin = originOf(url);
|
||||
if (!origin) return false;
|
||||
const holder = globalThis as unknown as Record<symbol, TrustedOrigins | undefined>;
|
||||
return holder[Symbol.for('bulwark.desktop.trustedJmapOrigins')]?.has(origin) === true;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The desktop app's preload bridge as the webmail sees it. Feature checks ask
|
||||
* `window.bulwarkDesktop?.<area>`; in a browser the object isn't there.
|
||||
*
|
||||
* The shell side lives in the desktop repo (src/shared/bridge.ts), a copy of
|
||||
* this contract. Add a member here and there together. A breaking change bumps
|
||||
* `version`; adding a member doesn't.
|
||||
*/
|
||||
|
||||
export const DESKTOP_BRIDGE_VERSION = 1;
|
||||
|
||||
export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings';
|
||||
|
||||
export interface DesktopWindowInfo {
|
||||
id: number;
|
||||
type: DesktopWindowType;
|
||||
title: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopWindowsBridge {
|
||||
/**
|
||||
* Opens a pop-out window itself. The name is the window's identity: opening
|
||||
* the same name again focuses the window that has it. Left out, the page uses
|
||||
* window.open with a features string, which the shell's window-open handler
|
||||
* sees too.
|
||||
*/
|
||||
open?: (url: string, options: { name: string; width: number; height: number }) => void;
|
||||
/** One window per message: opening it again focuses it. */
|
||||
openMessage?: (target: { url: string; emailId: string }) => void;
|
||||
openComposer?: (target: { url: string; name?: string }) => void;
|
||||
/** Focuses a window from `list()`, or the main window with no id. */
|
||||
focus?: (id?: number) => void;
|
||||
list?: () => Promise<DesktopWindowInfo[]>;
|
||||
/** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */
|
||||
onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DesktopThemeBridge {
|
||||
/** The page's colours, for title bar buttons and the first frame of new windows. */
|
||||
set: (theme: { dark: boolean; background: string; foreground?: string }) => void;
|
||||
/** Fires when the OS switches between light and dark. Returns the unsubscribe. */
|
||||
onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DesktopMenuBridge {
|
||||
/**
|
||||
* What the app menu shows as enabled or checked, by command id. The shell
|
||||
* keeps the last state per window and applies it when that window has focus.
|
||||
*/
|
||||
setState: (state: Record<string, boolean | { enabled?: boolean; checked?: boolean }>) => void;
|
||||
}
|
||||
|
||||
export interface DesktopShellBridge {
|
||||
/** http, https and mailto only. */
|
||||
openExternal: (url: string) => void;
|
||||
}
|
||||
|
||||
export type DesktopServerPrepareResult =
|
||||
| { ok: true; approved: boolean }
|
||||
| { ok: false; reason: 'declined' | 'invalid' };
|
||||
|
||||
export interface DesktopServersBridge {
|
||||
/**
|
||||
* Call before signing in to a server. Asks the user first when it is on the
|
||||
* LAN or plain http, and lets the app reach it. Standalone mode only.
|
||||
*/
|
||||
prepare: (serverUrl: string) => Promise<DesktopServerPrepareResult>;
|
||||
/** The origins a session names, for the CORS headers the shell adds for them. */
|
||||
noteSession: (
|
||||
serverUrl: string,
|
||||
urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string },
|
||||
) => void;
|
||||
}
|
||||
|
||||
export type DesktopBrowserSignInResult =
|
||||
| { ok: true; code: string; state: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface DesktopAuthBridge {
|
||||
/** Starts a short-lived loopback listener for one sign-in. */
|
||||
beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>;
|
||||
/** Opens the system browser on `authorizeUrl` and waits for the redirect. */
|
||||
completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise<DesktopBrowserSignInResult>;
|
||||
cancelBrowserSignIn: (flowId: string) => void;
|
||||
}
|
||||
|
||||
export type DesktopPluginInstallResult =
|
||||
| { ok: true; id: string; version: string }
|
||||
| { ok: false; error: string; code?: string };
|
||||
|
||||
export interface DesktopPluginsBridge {
|
||||
/**
|
||||
* Installs from the directory through the local admin API, with its signature
|
||||
* and hash checks. The renderer never holds an admin session. Standalone only.
|
||||
*/
|
||||
install: (slug: string, version: string) => Promise<DesktopPluginInstallResult>;
|
||||
}
|
||||
|
||||
export interface BulwarkDesktopBridge {
|
||||
version: number;
|
||||
platform: 'win32' | 'darwin' | 'linux';
|
||||
mode?: 'standalone' | 'instance';
|
||||
windows?: DesktopWindowsBridge;
|
||||
theme?: DesktopThemeBridge;
|
||||
menu?: DesktopMenuBridge;
|
||||
shell?: DesktopShellBridge;
|
||||
servers?: DesktopServersBridge;
|
||||
auth?: DesktopAuthBridge;
|
||||
plugins?: DesktopPluginsBridge;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
bulwarkDesktop?: BulwarkDesktopBridge;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
|
||||
/**
|
||||
* Keeps <meta name="theme-color"> in step with the active theme.
|
||||
*
|
||||
@@ -66,6 +68,7 @@ export function syncThemeColorMeta(): void {
|
||||
const run = () => {
|
||||
scheduled = false;
|
||||
writeMeta();
|
||||
tellDesktopShell();
|
||||
};
|
||||
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
@@ -75,6 +78,15 @@ export function syncThemeColorMeta(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** The desktop app's title bar buttons and new windows' first frame follow the page. */
|
||||
function tellDesktopShell(): void {
|
||||
const theme = getDesktopBridge()?.theme;
|
||||
if (!theme) return;
|
||||
const background = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content;
|
||||
if (!background) return;
|
||||
theme.set({ dark: document.documentElement.classList.contains('dark'), background });
|
||||
}
|
||||
|
||||
/** Turn syncing on and take a first reading. Called by <ThemeColorSync />. */
|
||||
export function enableThemeColorSync(): void {
|
||||
enabled = true;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// register endpoint we hit on the relay.
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { isDesktopApp } from '@/lib/desktop/bridge';
|
||||
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
|
||||
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
@@ -443,6 +444,8 @@ export function isWebPushSupported(): boolean {
|
||||
// No service worker is registered in the static Lite build (see
|
||||
// components/service-worker-registration.tsx), so push cannot be enabled.
|
||||
if (IS_LITE) return false;
|
||||
// Electron has the API, but subscribing always fails: it has no push service.
|
||||
if (isDesktopApp()) return false;
|
||||
return (
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createHash } from "crypto";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { pruneTraces } from "./scripts/standalone-guard.mjs";
|
||||
import { desktopBuildFlag } from "./scripts/desktop-build-flag.mjs";
|
||||
|
||||
// Prefer an explicit build arg (passed in by CI / Docker, where .git is
|
||||
// excluded from the build context) and fall back to `git rev-parse` for
|
||||
@@ -193,6 +194,9 @@ const nextConfig: NextConfig = {
|
||||
NEXT_PUBLIC_BASE_PATH: basePath,
|
||||
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
|
||||
NEXT_PUBLIC_BULWARK_LITE: isLite ? "1" : "",
|
||||
// Always a literal, so setting the variable when starting a server build
|
||||
// does nothing (lib/desktop/flag.ts).
|
||||
NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dev": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack",
|
||||
"build": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs",
|
||||
"build:lite": "node scripts/lite/build.mjs",
|
||||
"build:desktop": "node scripts/build-desktop.mjs",
|
||||
"lite:serve": "node scripts/lite/serve.mjs out 4173",
|
||||
"test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts",
|
||||
"test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts",
|
||||
|
||||
@@ -16,6 +16,8 @@ import { getWopiEditorOrigins } from "./lib/wopi/discovery";
|
||||
import { detectSetupState } from "./lib/setup/state";
|
||||
import { isServedOverHttps } from "./lib/security/request-protocol";
|
||||
import { bootGateResponse } from "./lib/shared-store/boot-gate";
|
||||
import { IS_DESKTOP_BUILD } from "./lib/desktop/flag";
|
||||
import { isDesktopBlockedRoute } from "./lib/desktop/blocked-routes";
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
@@ -268,6 +270,10 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (IS_DESKTOP_BUILD && isDesktopBlockedRoute(pathname)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (PROXY_SKIP_PATTERN.test(pathname) || isStaticAssetPath(pathname)) {
|
||||
// No page CSP here, but what comes back can still be a document: the
|
||||
// not-found shell for a missing /foo.html or an unknown /api route. It
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// The webmail build the desktop app embeds: `npm run build:desktop`. It's the
|
||||
// regular standalone build with NEXT_PUBLIC_BULWARK_DESKTOP compiled in, using
|
||||
// webpack (a third of Turbopack's server size, and its hashed external links
|
||||
// don't relocate). No other build sets the flag: repos/desktop's
|
||||
// scripts/fetch-webmail.mjs refuses a tree without it.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const env = { ...process.env, NEXT_PUBLIC_BULWARK_DESKTOP: '1', NEXT_TELEMETRY_DISABLED: '1' };
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
for (const [command, ...args] of [
|
||||
['node', 'scripts/build-pst-worker.mjs'],
|
||||
['node', 'scripts/build-store-worker.mjs'],
|
||||
['node', 'scripts/build-plugin-sandbox.mjs'],
|
||||
['npx', 'next', 'build', '--webpack'],
|
||||
['node', 'scripts/standalone-guard.mjs'],
|
||||
]) {
|
||||
const run = spawnSync(command, args, { env, stdio: 'inherit', shell: isWindows });
|
||||
if (run.status !== 0) process.exit(run.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// The value next.config.ts inlines as NEXT_PUBLIC_BULWARK_DESKTOP.
|
||||
//
|
||||
// Always defined, so every build compiles `process.env.NEXT_PUBLIC_BULWARK_DESKTOP`
|
||||
// to a literal. Left undefined, Next would leave the read in the server bundle,
|
||||
// and setting the variable when starting a server build would turn the flag on.
|
||||
|
||||
/** The marker scripts/standalone-guard.mjs looks for in a non-desktop build. */
|
||||
export const DESKTOP_TRUST_MARKER = 'bulwark.desktop.trustedJmapOrigins';
|
||||
|
||||
export function desktopBuildFlag(env = process.env) {
|
||||
return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : '';
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from './desktop-build-flag.mjs';
|
||||
|
||||
/** Top-level project entries the running server needs. Traces drop the rest. */
|
||||
const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'package.json', 'VERSION']);
|
||||
@@ -141,10 +142,32 @@ export function removeCopiedEnvFiles(standaloneDir) {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server bundles that still hold the desktop app's trust path. A server build
|
||||
* compiles it away (lib/desktop/flag.ts); finding it means the flag leaked into
|
||||
* this build or stopped being inlined, and a visitor could reach private hosts.
|
||||
*/
|
||||
export function findDesktopTrustPath(standaloneDir) {
|
||||
const found = [];
|
||||
const visit = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules') visit(full);
|
||||
} else if (entry.name.endsWith('.js') && readFileSync(full, 'utf8').includes(DESKTOP_TRUST_MARKER)) {
|
||||
found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(standaloneDir);
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
/** Cleans and checks a standalone tree. Returns what is still wrong with it. */
|
||||
export function guardStandalone(standaloneDir) {
|
||||
export function guardStandalone(standaloneDir, { desktopBuild = desktopBuildFlag() === '1' } = {}) {
|
||||
removeCopiedEnvFiles(standaloneDir);
|
||||
return findForbidden(standaloneDir);
|
||||
const forbidden = findForbidden(standaloneDir);
|
||||
return desktopBuild ? forbidden : [...forbidden, ...findDesktopTrustPath(standaloneDir)];
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
|
||||
Reference in New Issue
Block a user