Files
webmail/lib/push-relays.ts
T

91 lines
3.6 KiB
TypeScript

// Web Push relay choices. Users pick from a list the admin curates - only an
// admin can introduce a relay URL, so a user can never point their push
// registration (which carries their address label) at an arbitrary host.
import type { PushRelayOption, SettingsPolicy } from '@/lib/admin/types';
import { relayAllowed } from '@/lib/outgoing-hosts';
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
// The built-in entry can be repointed at build time via NEXT_PUBLIC_PUSH_RELAY_URL.
export const DEFAULT_RELAY_BASE_URL =
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || 'https://notifications.relay.bulwarkmail.org';
export type PushRelayPolicy = Partial<
Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'pushRelayUrlLocked'>
>;
export function normalizeRelayUrl(url: string | null | undefined): string {
return (url ?? '').trim().replace(/\/+$/, '');
}
export function isValidRelayUrl(url: string | null | undefined): boolean {
return /^https?:\/\/[^\s/]+/i.test(normalizeRelayUrl(url));
}
/** Falls back to the host so an admin entry without a label is still readable. */
export function relayDisplayLabel(option: PushRelayOption): string {
const label = option.label?.trim();
if (label) return label;
try {
return new URL(option.url).host;
} catch {
return option.url;
}
}
/**
* The relays a user may choose between: the built-in default first, then the
* admin-configured list. Invalid and duplicate URLs are dropped, and so is
* the built-in relay while third-party traffic is off (#903): push then
* works only with a relay the admin runs.
*/
export function resolvePushRelayOptions(policy: PushRelayPolicy | undefined | null): PushRelayOption[] {
const options: PushRelayOption[] = [];
const seen = new Set<string>();
const add = (label: string | undefined, rawUrl: string | undefined) => {
const url = normalizeRelayUrl(rawUrl);
if (!isValidRelayUrl(url) || seen.has(url) || !relayAllowed(url)) return;
seen.add(url);
options.push({ label: relayDisplayLabel({ label: label ?? '', url }), url });
};
add(undefined, DEFAULT_RELAY_BASE_URL);
for (const relay of policy?.pushRelays ?? []) add(relay?.label, relay?.url);
// Deployments configured before the relay list existed carry a single custom
// URL. Keep it selectable so their users are not silently moved off it.
add(undefined, policy?.pushRelayUrl);
return options;
}
/**
* The relay used when the user has not picked one (or their pick is gone).
* Empty when none may be used: third-party traffic is off and the admin
* configured no relay of their own.
*/
export function resolveDefaultRelayUrl(policy: PushRelayPolicy | undefined | null): string {
const configured = normalizeRelayUrl(policy?.pushRelayUrl);
if (isValidRelayUrl(configured) && relayAllowed(configured)) return configured;
const builtIn = normalizeRelayUrl(DEFAULT_RELAY_BASE_URL);
if (relayAllowed(builtIn)) return builtIn;
return resolvePushRelayOptions(policy)[0]?.url ?? '';
}
/**
* The relay to actually register with: the user's pick when the policy still
* offers it and the admin has not locked the choice, otherwise the default.
*/
export function resolveActiveRelayUrl(
policy: PushRelayPolicy | undefined | null,
userSelection: string | null | undefined,
): string {
const fallback = resolveDefaultRelayUrl(policy);
if (policy?.pushRelayUrlLocked) return fallback;
const selected = normalizeRelayUrl(userSelection);
if (!selected) return fallback;
return resolvePushRelayOptions(policy).some((option) => option.url === selected)
? selected
: fallback;
}