// Shared, side-effect-free helpers for the Lite build scripts. Kept separate
// so lib/__tests__/lite-scripts.test.ts can exercise them without touching
// the filesystem or spawning `next build`.
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { crc32, deflateRawSync, inflateRawSync } from "node:zlib";
/**
* Repository root for a script under scripts/lite/. Falls back to the current
* directory when `import.meta.url` is not a file URL (vitest's transform).
*/
export function resolveRepoRoot(metaUrl) {
try {
return resolve(fileURLToPath(new URL("../..", metaUrl)));
} catch {
return process.cwd();
}
}
/** True when the module was started directly (`node scripts/lite/x.mjs`). */
export function isMainModule(metaUrl) {
try {
return !!process.argv[1] && resolve(process.argv[1]) === fileURLToPath(metaUrl);
} catch {
return false;
}
}
/** Server-only trees the static export cannot contain. */
export const LITE_REMOVED_PATHS = [
"proxy.ts",
"app/api",
"app/(main)/admin",
"app/(main)/setup",
"app/(sandbox)",
"app/manifest.ts",
"instrumentation.ts",
"instrumentation.node.ts",
"app/(main)/[...rest]",
"app/(main)/[locale]/[...rest]",
"app/(main)/protocol",
"e2e",
"integration",
];
/** Test trees are pruned too: they import the routes removed above. */
export const LITE_TEST_DIR_NAME = "__tests__";
export const LITE_TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/;
export const LITE_PRUNE_SKIP_DIRS = new Set(["node_modules", ".git", ".next", "out", "repos", "scripts"]);
/** The client shells a deployer's host must SPA-fallback to. */
export const LITE_SURFACES = ["mail", "calendar", "contacts", "files", "settings"];
/** Locales written right-to-left; the root shim sets `dir` for them. */
export const RTL_LOCALES = ["ar", "fa", "he"];
/**
* sessionStorage key the 404 shim parks a deep link under. Mirrors
* LITE_PENDING_PATH_KEY in lib/lite.ts (a TS module this script cannot
* import); lib/__tests__/lite-scripts.test.ts pins the two together.
*/
export const LITE_PENDING_PATH_KEY = "bulwark-lite:pending-path";
/** zustand persist key of stores/locale-store.ts (the user's language choice). */
export const LOCALE_STORAGE_KEY = "locale-storage";
/**
* `/api/` strings that legitimately survive in the Lite client chunks. Each
* one is either gated at call time (`IS_LITE`, a policy flag, a config flag
* the Lite config pins off) or tolerates a 404 by design. Anything else is a
* new server dependency and fails `verify.mjs`.
*/
export const LITE_API_STRING_ALLOWLIST = [
// Path-independent generic prefix used by `apiFetch` docs/comments.
"/api/",
// stores/update-store.ts - startPolling() is a no-op in Lite.
"/api/system/update-status",
// stores/plugin-store.ts + theme-store.ts - initializePlugins()/syncServerThemes() return early in Lite.
"/api/plugins",
"/api/plugin-approval-status",
"/api/plugin-signing-pubkey",
"/api/admin/plugins",
"/api/admin/themes",
// lib/plugin-sandbox/proxy-client.ts + connect-dialog.ts - only called by
// loaded plugins and the plugins settings tab; pluginsEnabled is pinned off.
"/api/plugin-proxy",
"/api/plugin-secrets",
// stores/settings-store.ts - settingsSyncEnabled is pinned off.
"/api/settings",
// Stalwart's own account discovery (lib/auth/lite-oauth.ts), not a route of this app.
"/api/discover",
// OAuth / SSO / pairing - Lite discovers and redeems codes in the browser
// (lib/auth/lite-oauth.ts); stalwartFeaturesEnabled is pinned off.
"/api/auth/token",
"/api/auth/oauth/metadata",
"/api/auth/sso/start",
"/api/auth/sso/complete",
"/api/auth/reauth/sso/complete",
"/api/auth/pair/create",
"/api/auth/pair/status",
"/api/auth/totp-token-exchange",
"/api/auth/session",
"/api/auth/verify",
"/api/auth/stalwart-context",
"/api/account/stalwart/jmap",
// Admin shield probe - skipped in Lite (components/layout/navigation-rail.tsx).
"/api/admin/auth",
// Settings/policy - replaced by config.json / policy.json.
"/api/config",
"/api/admin/policy",
// lib/account-archive-client.ts - the feature is pinned off in Lite.
"/api/account-archive",
// WOPI (#425) - use-wopi-status returns disabled in Lite.
"/api/wopi/status",
"/api/wopi/launch",
// Calendar: ICS URL import/subscriptions + CalDAV discovery are hidden in Lite.
"/api/fetch-ical",
"/api/caldav/discover",
"/api/webdav",
// Sender favicons - disabled in Lite (initials fallback).
"/api/favicon",
// Web push relay paths (relative to the *relay* origin, not this host).
"/api/push/vapid-public-key",
"/api/push/register",
"/api/push/active",
"/api/push/verify",
"/api/push/jmap",
// Dev-only JMAP mock.
"/api/jmap",
"/api/dev-jmap",
];
export function parseBool(value, fallback = false) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
export function normalizeBasePath(raw) {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (!trimmed.startsWith("/")) throw new Error(`base path must start with "/" (got ${JSON.stringify(raw)})`);
return trimmed;
}
/**
* config.json contents from the LITE_* build inputs. The Stalwart target is
* served by the mail server itself: an empty server URL means "this origin"
* there, so the server field stays hidden unless a build asks for it.
*
* The static target leaves `allowCustomJmapEndpoint` out unless the build sets
* it: the app then derives it from `jmapServerUrl` at runtime. Writing the
* derived `true` of an empty server URL kept the server field on the login
* page after a deployer filled in `jmapServerUrl` and nothing else (#1087).
*/
export function buildLiteConfig(env = {}, { target = "static" } = {}) {
const jmapServerUrl = (env.LITE_JMAP_SERVER_URL ?? "").trim().replace(/\/+$/, "");
const stalwart = target === "stalwart";
const customEndpointSet = (env.LITE_ALLOW_CUSTOM_ENDPOINT ?? "") !== "";
return {
_comment: stalwart
? "Bulwark Lite for Stalwart. Read-only inside the Application bundle: to change it, build your own zip (see LITE-README.md). An empty jmapServerUrl means the Stalwart server that serves this page."
: "Bulwark Lite runtime configuration. Edit and re-upload; no rebuild needed. See LITE-README.md.",
appName: (env.LITE_APP_NAME ?? "").trim() || "Bulwark Webmail",
jmapServerUrl,
...(stalwart || customEndpointSet
? { allowCustomJmapEndpoint: parseBool(env.LITE_ALLOW_CUSTOM_ENDPOINT, false) }
: {}),
rememberMeEnabled: parseBool(env.LITE_REMEMBER_ME, true),
demoMode: parseBool(env.LITE_DEMO_MODE, false),
loginShowTotp: true,
loginShowVersion: true,
};
}
/** policy.json: only the gates Lite must pin; the app merges the rest over its defaults. */
/**
* What `/api/connector/capabilities` answers on a server build, as a file.
*
* Lite is a static export with no route handlers, so a connector probing this
* instance gets this instead - same document, same meaning. The connector
* tries the API path first and falls back to `/connector.json`.
*
* The target names are read out of lib/connector/registry.ts rather than
* duplicated here: a list that drifts would have this build telling connectors
* it can resolve links it cannot.
*/
export function buildConnectorCapabilities({ appName, version, basePath = "" }) {
const source = readFileSync(
join(resolveRepoRoot(import.meta.url), "lib", "connector", "registry.ts"),
"utf8",
);
const targets = [...source.matchAll(/^\s{4}name: '([a-z0-9_]+)',$/gm)].map((m) => m[1]);
if (targets.length === 0) {
throw new Error("no connector targets found in lib/connector/registry.ts");
}
return (
JSON.stringify(
{
product: "bulwark-webmail",
appName: appName || "Bulwark",
version: version || null,
connectorPath: `${basePath || ""}/connector`,
targets,
},
null,
2,
) + "\n"
);
}
export function buildLitePolicy() {
return {
_comment: "Optional admin policy for Bulwark Lite (same shape as the admin dashboard's policy). Plugins and sidebar apps stay off in Lite.",
features: {
pluginsEnabled: false,
sidebarAppsEnabled: false,
},
};
}
export function buildManifest({ appName, basePath = "" }) {
const p = (path) => `${basePath}${path}`;
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for JMAP servers`,
start_url: p("/"),
scope: p("/"),
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: p("/icon-192x192.png"), sizes: "192x192", type: "image/png" },
{ src: p("/icon-512x512.png"), sizes: "512x512", type: "image/png" },
{ src: p("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: p("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
/** Root index.html: pick the visitor's locale in the browser and jump to it. */
export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en" }) {
const list = JSON.stringify(locales);
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
return `
Loading
`;
}
/**
* 404.html for hosts without rewrite rules (GitHub Pages, S3 website hosting).
*
* `next build` exports Next's default not-found page as 404.html: the
* `[...rest]` catch-all that renders app/(main)/not-found.tsx in the server
* build is deleted for the export, and a route group's not-found.tsx only
* serves `notFound()` calls inside that group. So the park-and-replay logic
* lives in this dependency-free shim instead: a deep link below a known
* surface is parked in sessionStorage and the surface shell is loaded, which
* hands the link over (hooks/use-lite-link-segments.ts). Only `` and
* `` from the allowlists ever reach `location.replace`, so a crafted
* URL cannot turn this into an open redirect.
*
* A path without a locale (/login, /mail/...) goes to the same path under
* the reader's locale, picked as the root index.html picks it; the target
* stays on this origin and starts with an allowlisted locale.
*/
export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES, defaultLocale = "en" }) {
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
return `
404
404
This page could not be found.
Go home
`;
}
/** Netlify / Cloudflare Pages rewrite rules. */
export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) {
const lines = [
"# Bulwark Lite SPA fallback (Netlify / Cloudflare Pages). Deep links below a",
"# surface serve that surface's shell; everything else keeps its own file.",
];
for (const locale of locales) {
for (const surface of surfaces) {
lines.push(`${basePath}/${locale}/${surface}/* ${basePath}/${locale}/${surface}/index.html 200`);
}
// //login etc. resolve directly; a bare locale root goes to mail.
}
return lines.join("\n") + "\n";
}
/** The Content-Security-Policy every static host should send (`_headers`, the container image). */
export function buildCsp({ connectSrc = "*" } = {}) {
return [
"default-src 'self'",
// The static export ships inline hydration scripts, so no nonce is possible
// and one header cannot list every page's hashes. Each page of the static
// target carries its own policy that allows exactly its inline
// scripts (withScriptHashCsp); both policies apply, so this only matters
// where that meta is missing.
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' https: data:",
`connect-src 'self' ${connectSrc}`,
"frame-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
// 'self' because the PDF preview frames a blob: document, and Safari
// applies this policy to that document as well (#1301).
"frame-ancestors 'self'",
"media-src 'self' blob:",
].join("; ");
}
/** The `'sha256-...'` source for every inline script in an HTML document. */
export function inlineScriptHashes(html) {
const hashes = new Set();
for (const [, attrs, body] of html.matchAll(/` breakout). */
export function inlineJson(value) {
return JSON.stringify(value)
.replace(/` and the client id from the rewritten meta tag, fetches the
* matching prerendered shell (build-id URL, since shells are cached
* immutably), points it at the mount and writes it into the page in place:
* the address bar keeps the deep link and the shell hydrates exactly as it
* would behind an nginx SPA fallback.
*/
export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buildId, appName = "Bulwark Webmail" }) {
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
const script = `
(function () {
var BUILD_ID = ${inlineJson(buildId)};
var LOCALES = ${inlineJson(locales)};
var SHELLS = ${inlineJson(shells)};
var FALLBACK = ${inlineJson(fallback)};
var MOUNT_GLOBAL = "__BULWARK_LITE_MOUNT__";
var CLIENT_ID_GLOBAL = "__BULWARK_LITE_OAUTH_CLIENT_ID__";
var ENTRY_MARKER = ${inlineJson(STALWART_ENTRY_MARKER)};
var resolveStalwartBoot = ${resolveStalwartBoot.toString()};
var rewriteShellForMount = ${rewriteShellForMount.toString()};
var rewriteFlightForMount = ${rewriteFlightForMount.toString()};
var installStalwartFetchShim = ${installStalwartFetchShim.toString()};
try {
var theme = JSON.parse(localStorage.getItem("theme-storage") || "null");
var chosen = theme && theme.state && theme.state.theme;
if (chosen === "dark" || chosen === "light") document.documentElement.className = chosen;
} catch (e) {}
// Stalwart sends no X-Frame-Options or frame-ancestors for an Application:
// inside another origin's frame the app would be open to clickjacking.
var foreignFrame = false;
try { foreignFrame = window.top !== window.self && window.top.location.origin !== location.origin; } catch (e) { foreignFrame = true; }
if (foreignFrame) return fail("Bulwark Webmail cannot be shown inside another site's page. Open it directly.");
function fail(message) {
function show() {
var box = document.getElementById("bulwark-lite-boot");
if (!box) return;
var p = document.createElement("p");
p.textContent = message;
box.innerHTML = "";
box.appendChild(p);
box.removeAttribute("aria-busy");
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", show); else show();
}
function pick(tag) {
if (!tag) return null;
tag = String(tag);
if (LOCALES.indexOf(tag) !== -1) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
if (/zh-(tw|hk|mo|hant)/.test(lower) && LOCALES.indexOf("zh-TW") !== -1) return "zh-TW";
if (LOCALES.indexOf("zh") !== -1) return "zh";
}
var short = lower.split("-")[0];
for (var i = 0; i < LOCALES.length; i++) if (LOCALES[i].toLowerCase() === short) return LOCALES[i];
return null;
}
var preferred = null;
try {
var stored = JSON.parse(localStorage.getItem(${inlineJson(LOCALE_STORAGE_KEY)}) || "null");
preferred = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !preferred && j < langs.length; j++) preferred = pick(langs[j]);
preferred = preferred || FALLBACK;
// Stalwart rewrote the base element to point at the Application's prefix.
var mount = new URL(document.baseURI).pathname.replace(/[/]+$/, "");
if (!/^([/][A-Za-z0-9._~-]+)?$/.test(mount)) {
return fail("Bulwark Webmail cannot run under " + mount + "/: the mount prefix must be a single path segment such as /webmail.");
}
var metaTag = document.querySelector('meta[name="oauth-client-id"]');
var clientId = (metaTag && metaTag.getAttribute("content")) || "";
var boot = resolveStalwartBoot(location.pathname, mount, LOCALES, SHELLS, preferred);
if (!boot) {
return fail("This page was opened at " + location.pathname + ", but the bundle is mounted at " + (mount || "/") + ".");
}
if (boot.canonical) {
try { history.replaceState(history.state, "", boot.canonical + location.search + location.hash); } catch (e) {}
}
window[MOUNT_GLOBAL] = mount;
window[CLIENT_ID_GLOBAL] = clientId;
window.TURBOPACK_CHUNK_BASE_PATH = mount + "/_next/";
installStalwartFetchShim(window, mount, BUILD_ID, rewriteFlightForMount);
// Repeated inside the written shell, in case a browser gives it a fresh global.
var bootScript = "(function(w){w[" + JSON.stringify(MOUNT_GLOBAL) + "]=" + JSON.stringify(mount) +
";w[" + JSON.stringify(CLIENT_ID_GLOBAL) + "]=" + JSON.stringify(clientId).replace(/";
fetch(mount + "/" + boot.shell + "?v=" + encodeURIComponent(BUILD_ID), { credentials: "same-origin" })
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})
.then(function (html) {
// A shell missing from the zip is answered with this very document.
if (html.indexOf(ENTRY_MARKER) !== -1) throw new Error(boot.shell + " is not part of this bundle");
var page = rewriteShellForMount(html, mount, BUILD_ID, bootScript);
document.open();
document.write(page);
document.close();
})
.catch(function (err) {
fail("Bulwark Webmail could not start (" + (err && err.message ? err.message : err) + "). Reload the page; if this keeps happening, the installed bundle may be incomplete.");
});
})();
`;
return `
${STALWART_BASE_HREF_LITERAL} />
${STALWART_OAUTH_META_LITERAL} />
${esc(appName)}
`;
}
/**
* The bundle manifest Stalwart 1.0 requires at the root of an Application
* zip (`stalwart-app.json`, checked before it unpacks anything): without it,
* it refuses the bundle with "Application bundle does not contain a manifest"
* and serves nothing. 0.16 serves the file like any other.
*/
export function buildStalwartAppManifest({ version }) {
return { id: "bulwark-webmail", version };
}
/** manifest.json with relative URLs: they resolve against the manifest's own (mounted) URL. */
export function buildStalwartManifest({ appName }) {
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for Stalwart`,
start_url: "./",
scope: "./",
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: "icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512x512.png", sizes: "512x512", type: "image/png" },
{ src: "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" },
],
};
}
/** Problems with the root index.html of a Stalwart bundle (empty = fine). */
export function stalwartEntryProblems(html) {
const problems = [];
const count = (needle) => html.split(needle).length - 1;
if (count(STALWART_BASE_HREF_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_BASE_HREF_LITERAL} exactly once (found ${count(STALWART_BASE_HREF_LITERAL)})`);
if (count(STALWART_OAUTH_META_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_OAUTH_META_LITERAL} exactly once (found ${count(STALWART_OAUTH_META_LITERAL)})`);
if (!html.includes(STALWART_ENTRY_MARKER)) problems.push("index.html is not the Stalwart entry document");
// Root-absolute URLs would escape the mount prefix Stalwart put into .
for (const attr of html.replace(STALWART_BASE_HREF_LITERAL, "").match(/\b(?:src|href)="\/(?!\/)[^"]*"/g) ?? []) {
problems.push(`index.html has a root-absolute ${attr}; it must follow the runtime mount`);
}
for (const absolute of ['"/_next/static', '"/config.json', '"/policy.json']) {
if (html.includes(absolute)) problems.push(`index.html references ${absolute.slice(1)} absolutely; it must follow the runtime mount`);
}
return problems;
}
/** Problems with the entry list of a Stalwart zip (empty = fine). */
export function stalwartZipEntryProblems(names) {
const problems = [];
if (!names.includes("index.html")) problems.push("index.html is not at the archive root (no wrapping folder allowed)");
if (names.length >= 0xffff) problems.push(`${names.length} entries need ZIP64, which this writer does not produce`);
const reserved = new Set();
for (const name of names) {
const first = name.split("/")[0];
if (isReservedStalwartSegment(first)) reserved.add(first);
if (STALWART_EXCLUDED_FILES.includes(name) || (name.includes("/") && STALWART_EXCLUDED_DIRS.includes(first))) {
problems.push(`${name} belongs to the static-host target and must not ship in the Stalwart bundle`);
}
if (name.includes("\\") || name.startsWith("/") || name.split("/").includes("..")) problems.push(`unsafe entry name ${JSON.stringify(name)}`);
}
for (const first of reserved) problems.push(`top-level entry ${first} shares its name with a Stalwart route; keep top-level names app-specific`);
return problems;
}
// --- zip ---------------------------------------------------------------------
function dosDateTime(date) {
const d = new Date(date);
const year = Math.max(1980, d.getFullYear());
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
day: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
/**
* Minimal zip writer (no dependencies, no ZIP64): deflate when it helps,
* store otherwise, UTF-8 names, entries in the given order, a fixed
* timestamp so identical inputs give identical archives.
*/
export function buildZip(entries, { date = new Date(2000, 0, 1) } = {}) {
const { time, day } = dosDateTime(date);
const locals = [];
const centrals = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, "utf8");
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
const deflated = deflateRawSync(raw, { level: 9 });
const useDeflate = deflated.length < raw.length;
const body = useDeflate ? deflated : raw;
const crc = crc32(raw) >>> 0;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(day, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, nameBuf, body);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE((3 << 8) | 20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(day, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
centrals.push(central, nameBuf);
offset += 30 + nameBuf.length + body.length;
}
const centralDir = Buffer.concat(centrals);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, centralDir, end]);
}
/** Reads a zip's central directory: `[{ name, method, compressedSize, size, offset }]`. */
export function listZipEntries(buffer) {
let eocd = -1;
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 22 - 0xffff); i--) {
if (buffer.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd === -1) throw new Error("not a zip archive (no end-of-central-directory record)");
const count = buffer.readUInt16LE(eocd + 10);
let at = buffer.readUInt32LE(eocd + 16);
const entries = [];
for (let n = 0; n < count; n++) {
if (buffer.readUInt32LE(at) !== 0x02014b50) throw new Error("corrupt central directory");
const nameLen = buffer.readUInt16LE(at + 28);
const extraLen = buffer.readUInt16LE(at + 30);
const commentLen = buffer.readUInt16LE(at + 32);
entries.push({
name: buffer.toString("utf8", at + 46, at + 46 + nameLen),
method: buffer.readUInt16LE(at + 10),
compressedSize: buffer.readUInt32LE(at + 20),
size: buffer.readUInt32LE(at + 24),
offset: buffer.readUInt32LE(at + 42),
});
at += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** Extracts one entry listed by `listZipEntries`. */
export function readZipEntry(buffer, entry) {
const nameLen = buffer.readUInt16LE(entry.offset + 26);
const extraLen = buffer.readUInt16LE(entry.offset + 28);
const start = entry.offset + 30 + nameLen + extraLen;
const body = buffer.subarray(start, start + entry.compressedSize);
if (entry.method === 0) return Buffer.from(body);
if (entry.method === 8) return inflateRawSync(body);
throw new Error(`unsupported compression method ${entry.method} for ${entry.name}`);
}
/** Every file below `dir` as a zip entry, `/`-separated and sorted. */
export function collectZipEntries(dir) {
const out = [];
const walk = (full, rel) => {
for (const entry of readdirSync(full).sort()) {
const path = join(full, entry);
const name = rel ? `${rel}/${entry}` : entry;
if (statSync(path).isDirectory()) walk(path, name);
else out.push({ name, data: readFileSync(path) });
}
};
walk(dir, "");
return out;
}
/** LITE-README.md for the Stalwart bundle. */
export function buildStalwartReadme({ version, commit, locales, buildId, demoMode = false }) {
// Release tags carry no "v" prefix (since 1.3.0); a v-URL 404s.
const url = `https://github.com/bulwarkmail/webmail/releases/download/${version}/${STALWART_ZIP_NAME}`;
const latestUrl = `https://github.com/bulwarkmail/webmail/releases/latest/download/${STALWART_ZIP_NAME}`;
return `# Bulwark Lite ${version} (${commit}) for Stalwart
Bulwark Webmail as a Stalwart \`Application\`: Stalwart downloads this zip,
serves it under a path of its own HTTP listener, and the webmail talks to the
same server's JMAP, login and token endpoints. There is nothing to configure:
no server URL, no CORS, no second web server.
Build: ${buildId}
Locales: ${locales.join(", ")}
Requires: Stalwart 0.16.6 or later (0.16.19 for \`oauthClientId\`), or 1.0.
${demoMode ? "\nDemo mode is ON in this build: the login page offers the built-in demo account.\n" : ""}
## Install
An administrator creates one Application and then runs the \`UpdateApps\`
action: Stalwart only downloads and mounts Applications on that action or at
startup, so creating the object alone serves nothing. Permissions:
\`sysApplicationCreate\` (plus \`sysApplicationGet\`/\`sysApplicationQuery\` to
list, \`sysApplicationUpdate\` to change and \`sysApplicationDestroy\` to remove
it) and \`actionUpdateApps\`. Any of these is equivalent:
- Web admin: **Settings > Web Applications > Create**, description
\`Bulwark Webmail\`, resource URL \`${url}\`, URL prefix \`/webmail\`; then run
**Actions > Update applications** (or restart Stalwart).
- CLI:
\`\`\`bash
stalwart-cli create Application \\
--field description='Bulwark Webmail' \\
--field resourceUrl='${url}' \\
--field 'urlPrefix={"/webmail":true}'
stalwart-cli create Action/UpdateApps
\`\`\`
- JMAP (\`urn:stalwart:jmap\`), as an administrator:
\`\`\`json
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [
["x:Application/set", { "create": { "webmail": {
"description": "Bulwark Webmail",
"resourceUrl": "${url}",
"urlPrefix": { "/webmail": true }
} } }, "0"],
["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"]
]
}
\`\`\`
Then open \`https:///webmail/\` and sign in with a mail
account.
## Mount prefix
\`urlPrefix\` is matched against the first path segment only (\`/webmail\`,
not \`/apps/webmail\`), and several prefixes may serve the same bundle. The
bundle works under any prefix: it reads it from the \`\` Stalwart
writes into index.html. Stalwart routes these first segments itself, so an
Application mounted there is never reached: \`jmap\`, \`dav\`, \`.well-known\`,
\`auth\`, \`api\`, \`scim\`, \`mail\`, \`calendar\`, \`autodiscover\`, \`login\`,
\`device\`, \`metrics\`, \`healthz\`, \`logo\`, \`form\`, \`robots.txt\`, and the web
admin's own \`admin\` and \`account\`.
## Updates
The resource URL above names one release, so what Stalwart serves only
changes when you change it: to update, point \`resourceUrl\` at the new
release's tag and run \`UpdateApps\`, which always downloads again.
What a failed download does depends on the Stalwart version. On 0.16.23 the
running bundle stays online, as on later 0.16 releases and on 1.0.
Older 0.16 releases and the 1.0 pre-releases
unmount the Application and drop the cached copy. The next restart,
\`UpdateApps\` or reload of the Applications (the other nodes of a cluster
reload them after \`UpdateApps\`) downloads \`resourceUrl\` again and mounts the
bundle once that works. On every version, a restart after the cached copy
has expired, with \`resourceUrl\` unreachable, leaves the Application
unmounted. So check that \`resourceUrl\` downloads before running
\`UpdateApps\` or restarting. Every release also carries
\`${STALWART_ZIP_NAME}.sha256\`; check a download with
\`sha256sum -c ${STALWART_ZIP_NAME}.sha256\` before you switch.
The bundle runs as script on your Stalwart origin, next to the web admin.
Stalwart checks no hash or signature when it downloads, so a floating URL
such as \`${latestUrl}\` would run whatever the newest release holds, at the
next restart or \`UpdateApps\` after \`autoUpdateFrequency\` (on 1.0
\`autoUpdateInterval\`, default 90 days). Use it only if you accept that in exchange for automatic updates.
Open tabs move to the new build on their next navigation (one full page
load); a freshly opened tab gets it immediately. Nothing stays stuck on old
files although Stalwart sends a year-long cache header for every file but
index.html: every file the app fetches without a content hash carries the
build id.
## Sign-in
Passwords (and TOTP codes) go to Stalwart's \`/api/auth\` and are exchanged at
\`/auth/token\` for an access token and a refresh token, all on the same
origin. "Remember me" keeps the refresh token in localStorage, otherwise it
lives with the tab.
Serve Stalwart over HTTPS. On a plain \`http://\` address other than
\`localhost\`, browsers withhold the Web Crypto API this sign-in needs, so the
webmail falls back to a password sign-in that lasts only as long as the tab:
"Remember me" has no effect and single sign-on does not work.
The OAuth client id is the Application's \`oauthClientId\` (Stalwart writes it
into index.html) or \`bulwark-webmail\` when that field is empty. If your
server requires registered OAuth clients, register that client id with the
redirect URI \`https:////\` (for example
\`https://mail.example.com/webmail/\`), once per prefix you mount.
Stalwart 1.0 always requires registered clients. When it knows no client by
the default name, the app registers one of its own and signs in with that:
mail, contacts, calendars and files work, but the account's Stalwart
settings (password, two-factor authentication, app passwords) do not, since
1.0 only lets a first-party client manage those. For them, create an
\`OAuthClient\` with client id \`bulwark-webmail\` (or the \`oauthClientId\`
you set), \`firstParty\` on, and the redirect URI above.
### Single sign-on (OpenID Connect)
An account whose directory is an external OpenID provider has no password
Stalwart could check, so the form above cannot sign it in. The app asks
Stalwart who signs an address in (\`/api/discover/\`, the lookup
Stalwart's own web admin uses): for such an account the password field gives
way to **Sign in with SSO**, which sends the user to the provider
(authorization code with PKCE) and redeems the code in the browser. Accounts
Stalwart authenticates itself keep the password form.
At the provider, register a **public** client (no secret, PKCE \`S256\`) with:
- the client id you put into the Application's \`oauthClientId\` (or
\`bulwark-webmail\` if you leave that empty);
- the redirect URI \`https:////oauth/callback\`
(for example \`https://mail.example.com/webmail/oauth/callback\`). It names
no language, so one URI per prefix covers every locale;
- CORS on its token endpoint for \`https://\`, because the
browser redeems and renews tokens there directly. Stalwart's web admin needs
the same, so a provider already set up for it only needs the extra client.
Setting \`oauthClientId\` also shows **Sign in with SSO** next to the password
form for accounts Stalwart authenticates itself; that route goes through
Stalwart's own login page and needs the \`.../oauth/callback\` URI registered
as well if your server requires registered clients. Per-domain directories
are a Stalwart Enterprise feature; the community edition has one directory
for every account.
## Branding and settings
The bundle is read-only once Stalwart has unpacked it. To change the app
name, turn "remember me" off or build any other variant, run the
**Build Static Lite** workflow (or, in a throwaway checkout,
\`npm run build:lite -- --in-place --target=stalwart\`) with your settings, publish the resulting \`${STALWART_ZIP_NAME}\` at a URL
you control, and use that URL as \`resourceUrl\`. \`config.json\` and
\`policy.json\` inside the zip take the same keys as in the static-host build.
## Differences from the static-host zip
- One entry document (\`index.html\`) boots every route: Stalwart has no
rewrite rules and answers every unknown path with it.
- No \`_redirects\`, \`_headers\`, nginx/Caddy examples or \`404.html\`.
- \`config.json\` defaults to the serving Stalwart and hides the server field;
no CORS settings are needed.
- The mount prefix is detected at runtime instead of being baked in.
- \`manifest.json\` replaces \`manifest.webmanifest\` (Stalwart would serve the
latter as \`application/octet-stream\`).
Everything else the static-host build leaves out (admin console, plugins,
settings sync, web push, office editing, ICS subscriptions) is also missing
here. Single sign-on is the exception: it works in this bundle, as described
above.
`;
}
/** Stalwart's escaping of the client id for the meta attribute (application.rs). */
export function stalwartOauthMeta(clientId) {
const escaped = String(clientId).replace(/[&"<>]/g, (ch) => ({ "&": "&", '"': """, "<": "<", ">": ">" })[ch]);
return ` base);
if (oauthClientId) out = out.replace(STALWART_OAUTH_META_LITERAL, () => stalwartOauthMeta(oauthClientId));
return out;
}
/**
* One request as Stalwart's HTTP listener answers it for Applications
* (the `external =>` arm of crates/http/src/request.rs plus
* WebApplications::serve). `entries` maps zip entry names to contents,
* `prefixes` are the mounted urlPrefix values.
*
* @param {{ pathname: string, entries: Map, prefixes: string[], oauthClientId?: string, legacy?: boolean }} request
* @returns {{ status: number, headers: Record, body: Buffer }}
*/
export function emulateStalwartRequest({ pathname, entries, prefixes, oauthClientId = "", legacy = false }) {
const mounted = prefixes.map((p) => String(p).replace(/^\/+|\/+$/g, ""));
const segments = pathname.split("/");
const first = segments[1] ?? "";
const notFound = { status: 404, headers: { "content-type": "application/problem+json" }, body: Buffer.from('{"type":"about:blank","status":404,"title":"Not Found"}') };
if (!first || isReservedStalwartSegment(first) || !mounted.includes(first)) return notFound;
// HttpResponse::redirect answers 302 (verified against 0.16.22).
if (segments.length === 2) return { status: 302, headers: { location: `/${first}/` }, body: Buffer.alloc(0) };
const path = pathname.slice(first.length + 2);
const direct = entries.get(path);
const isIndex = direct ? path === "index.html" : true;
const name = direct ? path : "index.html";
const contents = direct ?? entries.get("index.html");
if (!contents) return notFound;
const body = isIndex ? Buffer.from(stalwartRewriteIndex(contents.toString("utf8"), first, oauthClientId, { legacy })) : contents;
return {
status: 200,
headers: {
"content-type": stalwartContentType(name),
"cache-control": isIndex ? "no-cache" : "public, max-age=31536000, immutable",
},
body,
};
}
/**
* RSC text rows (`:T,...`) that contain a string the runtime
* mount rewrite replaces. Those rows are length-prefixed, so
* rewriteFlightForMount leaves them alone; a hit here would mean a URL that
* is never pointed at the mount. Returns the offending row ids.
*/
export function flightTextRowsWithNeedles(text) {
const needles = ['"/_next/', '"/manifest.webmanifest"', '"/branding/'];
const hits = [];
const buffer = Buffer.from(text, "utf8");
const pattern = /(?:^|\n)([0-9a-f]+):T([0-9a-f]+),/g;
let match;
while ((match = pattern.exec(text))) {
const start = Buffer.byteLength(text.slice(0, match.index + match[0].length), "utf8");
const body = buffer.subarray(start, start + parseInt(match[2], 16)).toString("utf8");
if (needles.some((needle) => body.includes(needle))) hits.push(match[1]);
}
return hits;
}
/**
* Next 16.3 exports segment-prefetch payloads as nested directories
* (`/__next.//__PAGE__.txt`) but requests them with dots
* (`/__next...__PAGE__.txt`). On Stalwart the dotted request is
* answered with the entry index.html, which the router ignores before
* fetching `index.txt`, so the nested trees are never read: the Stalwart
* target drops them (about 40% of the zip). `__next._tree.txt` and
* `__next._full.txt` are plain files and stay.
*/
export function findSegmentPrefetchDirs(outDir) {
const found = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (!statSync(full).isDirectory()) continue;
if (entry.startsWith("__next.")) found.push(full);
else if (entry !== "_next") walk(full);
}
};
if (existsSync(outDir)) walk(outDir);
return found;
}