Point from the dashboard to Stalwart's own administration
A line under the cards says where the rest is: detailed metrics, the
delivery queue, logs and server settings are in Stalwart's own
administration. It links there when the operator sets STALWART_ADMIN_URL,
and stays plain text otherwise, because STALWART_URL is how this server
reaches Stalwart and is often an address no browser can open.
Several servers: a servers file entry may now be an object,
{"url": ..., "adminUrl": ...}, and a session routed to that server gets its
adminUrl. A routed domain without one gets no link rather than the default
server's, for the same reason routing never falls back. The URL is sent
only to a session that may administer.
The shipped example file stopped the server at startup: its "_comment"
key was read as a domain and refused as not a URL, while the test that
checks the example skipped it. Keys starting with an underscore are notes
now -- no mail domain starts with one -- and the example is also loaded
through the real parser in a test, so the two cannot disagree again.
Two new strings, in all nine catalogues.
This commit is contained in:
+46
-15
@@ -202,9 +202,9 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
|
||||
* having an outage would take the other four down with it. What happens when
|
||||
* one is unreachable is a sign-in question, answered in #239.
|
||||
*/
|
||||
function readStalwartServers(): Record<string, string> {
|
||||
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
||||
const file = process.env.STALWART_SERVERS_FILE;
|
||||
if (!file) return {};
|
||||
if (!file) return { urls: {}, adminUrls: {} };
|
||||
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
|
||||
|
||||
let raw: unknown;
|
||||
@@ -213,33 +213,55 @@ function readStalwartServers(): Record<string, string> {
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
|
||||
}
|
||||
return parseStalwartServers(raw, file);
|
||||
}
|
||||
|
||||
/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */
|
||||
export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [rawDomain, rawUrl] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const adminUrls: Record<string, string> = {};
|
||||
for (const [rawDomain, rawValue] of Object.entries(raw as Record<string, unknown>)) {
|
||||
/* The example file explains itself in a `_comment` key, and a copy of it
|
||||
used to stop the server as "not a URL". No mail domain starts with an
|
||||
underscore, so a key that does is a note, not a mapping. */
|
||||
if (rawDomain.startsWith("_")) continue;
|
||||
/* Lower-cased and stripped of the root dot, because that is how a domain
|
||||
taken off a username will arrive and comparing them any other way means
|
||||
a mapping that silently never matches. */
|
||||
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
||||
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
|
||||
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
|
||||
if (typeof rawUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`);
|
||||
/* A domain's value is its server's URL, or an object that also names where
|
||||
that server's own administration is: `{"url": …, "adminUrl": …}`. */
|
||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
|
||||
if (typeof value.url !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||
out[domain] = httpUrl(value.url, `STALWART_SERVERS_FILE (${file}): "${domain}"`);
|
||||
if (value.adminUrl !== undefined) {
|
||||
if (typeof value.adminUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
|
||||
adminUrls[domain] = httpUrl(value.adminUrl, `STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`);
|
||||
}
|
||||
out[domain] = rawUrl.replace(/\/+$/, "");
|
||||
}
|
||||
return out;
|
||||
return { urls: out, adminUrls };
|
||||
}
|
||||
|
||||
/** An absolute http(s) URL without its trailing slash, or a startup error naming where it came from. */
|
||||
function httpUrl(raw: string, where: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid ${where}: not an absolute URL`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${where}: must be http or https`);
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
const stalwartServers = readStalwartServers();
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
@@ -275,7 +297,16 @@ export const config = {
|
||||
*/
|
||||
basePath: normalizeBasePath(process.env.BASE_PATH),
|
||||
stalwartUrl,
|
||||
stalwartServers: readStalwartServers(),
|
||||
stalwartServers: stalwartServers.urls,
|
||||
/**
|
||||
* Where an administrator reaches Stalwart's own administration, for the
|
||||
* pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL,
|
||||
* which is how *this server* reaches Stalwart -- often an address no browser
|
||||
* can open. Unset, the dashboard names Stalwart's administration without a
|
||||
* link. A domain routed elsewhere takes its server's `adminUrl` instead.
|
||||
*/
|
||||
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
|
||||
stalwartAdminUrls: stalwartServers.adminUrls,
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user