mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers register per device, the server encrypts per RFC 8291 and signs per RFC 8292 with node:crypto and sends only to known push services through the SSRF guard. The event matrix decides per category what arrives by push; admins switch the channel on under Notifications. VAPID keys come from the environment or are generated once, only while neither stored row exists, and kept encrypted. A pair that does not decrypt or match is never overwritten and never swapped for another one: push stays off with an error in the log and every device stays registered until the key is fixed. The subject is VAPID_SUBJECT, then an https APP_URL, then the project URL. Only chat messages collapse into one notification per trip, and a replacement still alerts. A 403 removes a device only after three failures in a row; 404 and 410 remove it at once. Password change, reset and an admin setting a password drop the user's devices with the other credentials. The request log redacts subscriptions and endpoints, boot errors print *** for every secret variable, and logout forgets the device with a plain fetch so an expired session cannot redirect midway. Addresses discussion #894.
This commit is contained in:
@@ -31,6 +31,10 @@ tokens, `RuntimeEnvService`) and consumes the SAME derive functions.
|
||||
together wrong. It is empty without the switch, so a self-hoster never meets
|
||||
it. Put a rule there only if booting anyway would be a security or data
|
||||
problem — not to enforce a preference.
|
||||
The report prints each malformed value, because that is what makes a typo
|
||||
findable, except for the credentials in `SECRET_ENV_KEYS` (`env.ts`), which
|
||||
print as `***`. A new password, token, key or secret variable goes into that
|
||||
set; `validate.test.ts` fails on a credential-sounding name missing from it.
|
||||
3. **Parity is law — with one deliberate exception.** Every derived field pins
|
||||
the exact coercion of the call site(s) it replaced (`Number(x) || d`
|
||||
treating `"0"` as unset, per-site defaults for the same variable, …). Do not
|
||||
@@ -82,7 +86,8 @@ TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_
|
||||
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
|
||||
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
|
||||
LLM_TIMEOUT_MS, NOMINATIM_URL.
|
||||
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
|
||||
service, which the notification suites build without the container).
|
||||
|
||||
## Exemptions — raw `process.env` stays
|
||||
|
||||
|
||||
@@ -334,6 +334,16 @@ export function deriveNet(raw: RawEnv) {
|
||||
};
|
||||
}
|
||||
|
||||
export function derivePush(raw: RawEnv) {
|
||||
return {
|
||||
// Trimmed, and blank counts as unset: the schema validated the trimmed value,
|
||||
// so a padded key must not reach the crypto as a different string.
|
||||
vapidPublicKey: raw.VAPID_PUBLIC_KEY?.trim() || undefined,
|
||||
vapidPrivateKey: raw.VAPID_PRIVATE_KEY?.trim() || undefined,
|
||||
vapidSubject: raw.VAPID_SUBJECT?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveAll(raw: RawEnv) {
|
||||
return {
|
||||
app: deriveApp(raw),
|
||||
@@ -353,6 +363,7 @@ export function deriveAll(raw: RawEnv) {
|
||||
db: deriveDb(raw),
|
||||
paths: derivePaths(raw),
|
||||
net: deriveNet(raw),
|
||||
push: derivePush(raw),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
|
||||
import { parseDurationMs, parseLinkLocalAllowList } from './parsers';
|
||||
import {
|
||||
isP256PrivateKey,
|
||||
isUncompressedP256Key,
|
||||
isVapidSubject,
|
||||
parseDurationMs,
|
||||
parseLinkLocalAllowList,
|
||||
} from './parsers';
|
||||
|
||||
/** Present-but-malformed fails; unset/blank always passes (defaults apply). */
|
||||
function optionalWith(test: (v: string) => boolean, message: string) {
|
||||
@@ -104,6 +110,18 @@ export const envSchema = z.object({
|
||||
WEBAUTHN_RP_ID: anyString,
|
||||
WEBAUTHN_ORIGINS: anyString,
|
||||
|
||||
// Web Push (VAPID). All optional: without them the server keeps a pair of its
|
||||
// own in the database. Only the shape is checked here; whether the two keys
|
||||
// belong together is checked where they are used, which turns push off while
|
||||
// they do not (never signing with a stored pair in their place) and says so
|
||||
// in the log instead of refusing to boot.
|
||||
VAPID_PUBLIC_KEY: optionalWith(
|
||||
isUncompressedP256Key,
|
||||
'must be a base64url-encoded P-256 public key (65 bytes, uncompressed, starting with 0x04)',
|
||||
),
|
||||
VAPID_PRIVATE_KEY: optionalWith(isP256PrivateKey, 'must be a base64url-encoded P-256 private key (32 bytes)'),
|
||||
VAPID_SUBJECT: optionalWith(isVapidSubject, 'must be a mailto: address or an https:// URL'),
|
||||
|
||||
// MCP
|
||||
MCP_SESSION_TTL: positiveNumber,
|
||||
MCP_MAX_SESSION_PER_USER: positiveNumber,
|
||||
|
||||
@@ -14,6 +14,31 @@ export function readEnv(): AppEnv {
|
||||
return deriveAll(process.env as RawEnv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables whose value never goes into the boot report, even when it is the
|
||||
* malformed one: the report lands in container logs, and a key that is merely
|
||||
* mistyped is still a real key. The name and the problem are enough to fix it.
|
||||
*/
|
||||
export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
|
||||
'ENCRYPTION_KEY',
|
||||
'OIDC_CLIENT_SECRET',
|
||||
'SMTP_PASS',
|
||||
'VAPID_PRIVATE_KEY',
|
||||
'UNSPLASH_ACCESS_KEY',
|
||||
'ADMIN_PASSWORD',
|
||||
'PLACES_API_KEY',
|
||||
'AMAP_API_KEY',
|
||||
'AMAP_API_SECRET',
|
||||
'MAPBOX_ACCESS_TOKEN',
|
||||
'CARTO_API_KEY',
|
||||
'DEMO_ADMIN_PASS',
|
||||
]);
|
||||
|
||||
/** How a variable's value appears in the boot report. */
|
||||
function reportedValue(key: string, value: string | undefined): string {
|
||||
return SECRET_ENV_KEYS.has(key) ? '***' : JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-fast startup validation: a variable that is PRESENT but malformed aborts
|
||||
* boot with an aggregated report; unset/blank variables always pass (their
|
||||
@@ -28,7 +53,7 @@ export function validateEnvAtBoot(raw: RawEnv = process.env as RawEnv): void {
|
||||
? []
|
||||
: result.error.issues.map((issue) => {
|
||||
const key = String(issue.path[0]);
|
||||
return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`;
|
||||
return ` - ${key}=${reportedValue(key, raw[key])}: ${issue.message}`;
|
||||
});
|
||||
lines.push(...managedPreconditions(raw));
|
||||
if (lines.length === 0) return;
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
deriveDb,
|
||||
derivePaths,
|
||||
deriveNet,
|
||||
derivePush,
|
||||
} from './derive';
|
||||
export * from './parsers';
|
||||
export { envSchema } from './env.schema';
|
||||
|
||||
@@ -209,3 +209,46 @@ export function parseLinkLocalAllowList(raw: string | undefined): { ips: string[
|
||||
}
|
||||
return { ips, invalid };
|
||||
}
|
||||
|
||||
// ── Web Push key material ──────────────────────────────────────────────────
|
||||
|
||||
const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/;
|
||||
|
||||
/**
|
||||
* Strict base64url decode, null for anything that is not base64url. Web Push
|
||||
* keys arrive in this alphabet from the browser and from the VAPID_* variables.
|
||||
* Buffer.from(value, 'base64url') alone skips characters it does not know, so a
|
||||
* mangled key would decode to fewer bytes instead of being refused, and the size
|
||||
* checks below would then report the wrong problem.
|
||||
*/
|
||||
export function decodeBase64Url(value: string): Buffer | null {
|
||||
const trimmed = value.trim();
|
||||
if (!BASE64URL.test(trimmed)) return null;
|
||||
return Buffer.from(trimmed, 'base64url');
|
||||
}
|
||||
|
||||
/** An uncompressed P-256 point: 65 bytes, the first one 0x04 (VAPID public key, subscription p256dh). */
|
||||
export function isUncompressedP256Key(value: string): boolean {
|
||||
const bytes = decodeBase64Url(value);
|
||||
return bytes?.length === 65 && bytes[0] === 0x04;
|
||||
}
|
||||
|
||||
/** A P-256 private scalar as the Web Push tools print it: 32 bytes. */
|
||||
export function isP256PrivateKey(value: string): boolean {
|
||||
return decodeBase64Url(value)?.length === 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* The VAPID `sub` claim (RFC 8292 section 2.1): a mailto: address or an https:
|
||||
* URL. Apple refuses anything else, and so does this check, so a subject that
|
||||
* boots is one every push service accepts.
|
||||
*/
|
||||
export function isVapidSubject(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (/^mailto:[^\s@]+@[^\s@]+$/i.test(trimmed)) return true;
|
||||
try {
|
||||
return new URL(trimmed).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5257,6 +5257,36 @@ function runMigrations(db: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Web Push (#894): one row per browser a user switched push on in.
|
||||
*
|
||||
* The endpoint is the push service URL that browser handed out, so it is
|
||||
* unique: a browser shared by two accounts belongs to whoever subscribed on
|
||||
* it last. p256dh and auth are the browser's keys for the RFC 8291 message
|
||||
* encryption. vapid_public_key is the server key the subscription was made
|
||||
* against, so a changed key pair shows up as a mismatch the sender can clean
|
||||
* up, rather than as a push service refusing every message. failure_count
|
||||
* and last_success_at are bookkeeping for the sender; the rows go with the
|
||||
* user.
|
||||
*/
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
vapid_public_key TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_success_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -28,6 +28,11 @@ export const SENSITIVE_KEYS = new Set([
|
||||
'code',
|
||||
'smtp_pass',
|
||||
'secretaccesskey',
|
||||
// A Web Push subscription's keys: whoever holds them together with the
|
||||
// endpoint can encrypt messages for that browser. No other request body has
|
||||
// either field, and an `auth` anywhere else would be a credential as well.
|
||||
'p256dh',
|
||||
'auth',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -43,7 +48,26 @@ function isSensitiveName(name: string): boolean {
|
||||
return SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
}
|
||||
|
||||
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */
|
||||
/**
|
||||
* The fields PushSubscription.toJSON() puts next to `endpoint`. Either one is
|
||||
* enough to know the object is a subscription, so a body that arrives without
|
||||
* its keys (which the route then refuses) still keeps its endpoint out of the log.
|
||||
*/
|
||||
const PUSH_SUBSCRIPTION_FIELDS = new Set(['keys', 'expirationtime']);
|
||||
|
||||
/**
|
||||
* A Web Push endpoint is a capability URL: anyone holding it can post to that
|
||||
* browser's push service. It arrives inside a subscription, or on its own in
|
||||
* the body that forgets a device. Anywhere else `endpoint` is an ordinary
|
||||
* setting (the S3 backend's URL, for one) and stays readable.
|
||||
*/
|
||||
function isPushEndpoint(entries: Record<string, unknown>, name: string): boolean {
|
||||
if (name.toLowerCase() !== 'endpoint') return false;
|
||||
const siblings = Object.keys(entries).filter((k) => k !== name);
|
||||
return siblings.length === 0 || siblings.some((k) => PUSH_SUBSCRIPTION_FIELDS.has(k.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) and every Web Push endpoint from a request-log value. */
|
||||
export function redact(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||
@@ -54,7 +78,8 @@ export function redact(value: unknown): unknown {
|
||||
const namedSecret = typeof entries.key === 'string' && isSensitiveName(entries.key);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
out[k] = isSensitiveName(k) || (namedSecret && k === 'value') ? '[REDACTED]' : redact(v);
|
||||
const hidden = isSensitiveName(k) || (namedSecret && k === 'value') || isPushEndpoint(entries, k);
|
||||
out[k] = hidden ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,9 @@ export class AdminService {
|
||||
id,
|
||||
);
|
||||
} catch { /* very old installs predate oauth_tokens */ }
|
||||
// Push devices outlive every session, so the intruder's browser would
|
||||
// keep receiving this account's notifications. They go with the rest.
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', id);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
@@ -285,6 +286,7 @@ export class AuthService {
|
||||
const notifChannelsRaw = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channels'")?.value || notifChannel;
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const hasPushEnabled = activeChannels.includes(WEB_PUSH_CHANNEL_ID);
|
||||
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||
const placesPhotosSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'")?.value;
|
||||
const placesPhotosEnabled = placesPhotosSetting !== 'false';
|
||||
@@ -343,7 +345,7 @@ export class AuthService {
|
||||
timezone: readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
notification_channel: notifChannel,
|
||||
notification_channels: activeChannels,
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, push: hasPushEnabled, inapp: true },
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
places_photos_enabled: placesPhotosEnabled,
|
||||
places_autocomplete_enabled: placesAutocompleteEnabled,
|
||||
@@ -596,6 +598,11 @@ export class AuthService {
|
||||
try {
|
||||
this.db.run("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", userId);
|
||||
} catch { /* oauth_tokens table may not exist in very old installs */ }
|
||||
// Push devices keep receiving notifications without any session, so they
|
||||
// go too. The device the change was made on registers again right away:
|
||||
// the client reloads the user after the change, and that re-syncs its
|
||||
// subscription. Other devices do so after their next sign-in.
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', userId);
|
||||
});
|
||||
|
||||
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
|
||||
@@ -1056,6 +1063,9 @@ export class AuthService {
|
||||
user.id
|
||||
);
|
||||
} catch { /* oauth_tokens table may not exist in very old installs */ }
|
||||
// Push devices are a delivery channel that outlives every session, so an
|
||||
// intruder's browser would keep reading this account's notifications.
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', user.id);
|
||||
});
|
||||
|
||||
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
|
||||
import { registerChannel } from '../channel-registry';
|
||||
import type { ChannelMessage, ExternalChannel } from '../notification-events';
|
||||
import { ADMIN_SCOPED_EVENTS, type ChannelMessage, type ExternalChannel } from '../notification-events';
|
||||
import type { MailerService } from '../mailer/mailer.service';
|
||||
import { resolveAdminNtfyUrl, resolveNtfyToken, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service';
|
||||
import type { WebhookService } from '../transports/webhook.service';
|
||||
import type { WebPushService } from '../transports/web-push.service';
|
||||
|
||||
// The three built-in external channels, wrapping the transports that were free
|
||||
// functions in services/notifications.ts before the fold. No delivery logic is
|
||||
// rewritten here - it is only relocated behind the ExternalChannel interface so
|
||||
// NotificationsService.send() can iterate instead of branching.
|
||||
// The built-in external channels. Email, webhook and ntfy wrap the transports
|
||||
// that were free functions in services/notifications.ts before the fold; no
|
||||
// delivery logic is rewritten here, it is only relocated behind the
|
||||
// ExternalChannel interface so NotificationsService.send() can iterate instead
|
||||
// of branching. Web Push (#894) joined later on the same terms.
|
||||
|
||||
function supportsAllButSynology(event: string): boolean {
|
||||
return event !== 'synology_session_cleared';
|
||||
@@ -17,10 +20,11 @@ export interface BuiltinChannelDeps {
|
||||
mailer: MailerService;
|
||||
webhook: WebhookService;
|
||||
ntfy: NtfyService;
|
||||
push: WebPushService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the three built-in channels over the given transports.
|
||||
* Build the built-in channels over the given transports.
|
||||
*
|
||||
* They take their dependencies as an argument rather than importing them,
|
||||
* because the registry they land in is a module singleton while the transports
|
||||
@@ -32,7 +36,7 @@ export interface BuiltinChannelDeps {
|
||||
* preferences; dropping that import would have silenced email, webhook and ntfy
|
||||
* without a single error.
|
||||
*/
|
||||
export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDeps): ExternalChannel[] {
|
||||
export function buildBuiltinChannels({ mailer, webhook, ntfy, push }: BuiltinChannelDeps): ExternalChannel[] {
|
||||
const emailChannel: ExternalChannel = {
|
||||
id: 'email',
|
||||
source: 'builtin',
|
||||
@@ -114,7 +118,26 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
|
||||
},
|
||||
};
|
||||
|
||||
return [emailChannel, webhookChannel, ntfyChannel];
|
||||
// Per user and per browser. It carries no admin-scoped events: those have
|
||||
// their own admin-global copy on the channels above, and a push column in
|
||||
// the admin matrix could never be switched on. There is no sendGlobal for the
|
||||
// same reason, and no credentials to check beyond the user having turned it
|
||||
// on in at least one browser. Subscribing is a browser-only act, so there is
|
||||
// no MCP twin for the routes behind it. While the server's key pair cannot be
|
||||
// used, push is not sent and not offered, and every device stays subscribed.
|
||||
const pushChannel: ExternalChannel = {
|
||||
id: WEB_PUSH_CHANNEL_ID,
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.push',
|
||||
hiddenWhileInstanceUnconfigured: true,
|
||||
supportsEvent: (event) => supportsAllButSynology(event) && !ADMIN_SCOPED_EVENTS.has(event),
|
||||
isInstanceConfigured: () => push.isAvailable(),
|
||||
isConfiguredFor: (userId) => push.hasDevices(userId),
|
||||
sendToUser: (userId, msg) => push.sendToUser(userId, msg),
|
||||
test: (userId) => push.sendTest(userId),
|
||||
};
|
||||
|
||||
return [emailChannel, webhookChannel, ntfyChannel, pushChannel];
|
||||
}
|
||||
|
||||
/** Idempotent - safe to call from every entry point that needs the registry populated. */
|
||||
|
||||
@@ -127,6 +127,13 @@ export interface ExternalChannel {
|
||||
readonly bypassesActiveToggleForAdminEvents?: boolean;
|
||||
/** Delivers the one admin-scoped global copy (not per-recipient). */
|
||||
readonly supportsAdminGlobal?: boolean;
|
||||
/**
|
||||
* The user's matrix marks this channel inactive (no column, no settings card)
|
||||
* while isInstanceConfigured() says no. Web Push sets it: without a usable key
|
||||
* pair a browser could not even subscribe. Email does not, and keeps its
|
||||
* column while SMTP is not set up, as it always has.
|
||||
*/
|
||||
readonly hiddenWhileInstanceUnconfigured?: boolean;
|
||||
|
||||
supportsEvent(event: NotifEventType): boolean;
|
||||
/**
|
||||
|
||||
@@ -80,6 +80,16 @@ export class NotificationPreferencesService {
|
||||
return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the channel offered to users at all right now? Switched on, and for a
|
||||
* channel that says so, ready on this instance too: Web Push without a usable
|
||||
* key pair is off for everyone, whatever the admin switch says.
|
||||
*/
|
||||
private isChannelOffered(channel: ExternalChannel): boolean {
|
||||
if (!this.isChannelActive(channel)) return false;
|
||||
return !channel.hiddenWhileInstanceUnconfigured || channel.isInstanceConfigured?.() !== false;
|
||||
}
|
||||
|
||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -123,8 +133,10 @@ export class NotificationPreferencesService {
|
||||
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
|
||||
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
|
||||
for (const channel of listChannels()) {
|
||||
// Plugin channels are user-scoped only — they never carry admin-global events.
|
||||
if (channel.source !== 'builtin') continue;
|
||||
// Only the channels with an admin-global copy. Plugin channels and push are
|
||||
// user-scoped: they never carry an admin-scoped event, so a column for them
|
||||
// here could never be switched on.
|
||||
if (channel.source !== 'builtin' || !isAdminGlobalChannel(channel.id)) continue;
|
||||
const active = adminActive[channel.id] ?? false;
|
||||
out.push({
|
||||
id: channel.id,
|
||||
@@ -147,7 +159,7 @@ export class NotificationPreferencesService {
|
||||
settingsPath: channel.settingsPath,
|
||||
// A live plugin channel is always a column. `configured` tells the user whether
|
||||
// they still need to enter credentials — it does not hide the channel from them.
|
||||
active: this.isChannelActive(channel),
|
||||
active: this.isChannelOffered(channel),
|
||||
configured: channel.isConfiguredFor(userId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
testWebhookRequestSchema,
|
||||
testNtfyRequestSchema,
|
||||
notificationRespondRequestSchema,
|
||||
pushSubscribeRequestSchema,
|
||||
pushUnsubscribeRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -19,3 +21,5 @@ export class TestSmtpDto extends createZodDto(testSmtpRequestSchema) {}
|
||||
export class TestWebhookDto extends createZodDto(testWebhookRequestSchema) {}
|
||||
export class TestNtfyDto extends createZodDto(testNtfyRequestSchema) {}
|
||||
export class NotificationRespondDto extends createZodDto(notificationRespondRequestSchema) {}
|
||||
export class PushSubscribeDto extends createZodDto(pushSubscribeRequestSchema) {}
|
||||
export class PushUnsubscribeDto extends createZodDto(pushUnsubscribeRequestSchema) {}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { ReminderJobsService } from './reminder-jobs.service';
|
||||
import { StorageHealthNotifierService } from './storage-health-notifier.service';
|
||||
import { NtfyService } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { WebPushService } from './transports/web-push.service';
|
||||
import { PushController } from './push/push.controller';
|
||||
import { PushSubscriptionsService } from './push/push-subscriptions.service';
|
||||
import { VapidKeysService } from './push/vapid-keys.service';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
@@ -19,17 +23,23 @@ import { StorageModule } from '../storage/storage.module';
|
||||
* StorageModule feeds StorageHealthNotifierService, which bridges replica
|
||||
* failures into admin notifications — this direction has no cycle (Storage
|
||||
* imports only AppConfig+Audit+Scheduling).
|
||||
* Web Push (#894) lives in push/: VapidKeysService holds the server's key
|
||||
* pair, PushSubscriptionsService the browsers, PushController the routes
|
||||
* a browser signs up through; the transport is transports/web-push.service.ts.
|
||||
* NotificationsService and NotificationPreferencesService are exported for
|
||||
* in-container consumers (AdminController's dev test send and preferences tab,
|
||||
* the plugin RPC surface, HostSurfaceRpc). */
|
||||
@Module({
|
||||
imports: [AuthModule, MailerModule, SchedulingModule, StorageModule],
|
||||
controllers: [NotificationsController, AdminNotificationPreferencesController],
|
||||
controllers: [NotificationsController, AdminNotificationPreferencesController, PushController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
NotificationPreferencesService,
|
||||
WebhookService,
|
||||
NtfyService,
|
||||
WebPushService,
|
||||
VapidKeysService,
|
||||
PushSubscriptionsService,
|
||||
NotificationsMcp,
|
||||
ReminderJobsService,
|
||||
StorageHealthNotifierService,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getEventText } from './mailer/email-html';
|
||||
import { MailerService } from './mailer/mailer.service';
|
||||
import { NtfyService, type NtfyConfig } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { WebPushService } from './transports/web-push.service';
|
||||
import { NotificationPreferencesService, type PreferencesMatrix } from './notification-preferences.service';
|
||||
import { registerBuiltinChannels } from './channels/builtins';
|
||||
import {
|
||||
@@ -272,7 +273,6 @@ function shouldSendToUser(
|
||||
prefs: NotificationPreferencesService,
|
||||
): boolean {
|
||||
if (!channel.supportsEvent(event)) return false;
|
||||
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
|
||||
|
||||
if (ADMIN_SCOPED_EVENTS.has(event)) {
|
||||
if (!channel.bypassesActiveToggleForAdminEvents) return false;
|
||||
@@ -285,7 +285,10 @@ function shouldSendToUser(
|
||||
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
|
||||
}
|
||||
|
||||
return channel.isConfiguredFor(recipientId);
|
||||
if (!channel.isConfiguredFor(recipientId)) return false;
|
||||
// Last, since it is the costly check: Web Push reads and verifies the server's
|
||||
// key pair. Every check here is a plain AND, so the order changes no answer.
|
||||
return !channel.isInstanceConfigured || channel.isInstanceConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,12 +319,13 @@ export class NotificationsService {
|
||||
private readonly webhook: WebhookService,
|
||||
private readonly ntfy: NtfyService,
|
||||
private readonly prefs: NotificationPreferencesService,
|
||||
push: WebPushService,
|
||||
) {
|
||||
// The registry is a module singleton shared with the plugin runtime and any
|
||||
// separately-constructed instance (which never runs onModuleInit), so
|
||||
// registering from here means every path that can dispatch has the
|
||||
// built-ins. registerChannel is an idempotent Map.set.
|
||||
registerBuiltinChannels({ mailer, webhook, ntfy });
|
||||
registerBuiltinChannels({ mailer, webhook, ntfy, push });
|
||||
}
|
||||
|
||||
getPreferences(userId: number, role: string): PreferencesMatrix {
|
||||
@@ -779,7 +783,7 @@ export class NotificationsService {
|
||||
);
|
||||
}
|
||||
|
||||
// ── External channels (email, webhook, ntfy, plugin:*) ───────────────
|
||||
// ── External channels (email, webhook, ntfy, push, plugin:*) ─────────
|
||||
// One loop over the registry. The message is rendered once per recipient, in
|
||||
// their language, and handed to every channel that wants it — so a plugin
|
||||
// channel never touches i18n.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { MAX_PUSH_ENDPOINT_LENGTH, type PushSubscriptionInput } from '@trek/shared';
|
||||
import { decodeBase64Url } from '../../../app-config';
|
||||
import { isP256Point } from './web-push-crypto';
|
||||
|
||||
/**
|
||||
* The browser hands us a URL and the server POSTs to it for as long as the row
|
||||
* lives, so the URL is checked like any other user-supplied target, and more
|
||||
* narrowly: it has to be one of the push services browsers actually use. The
|
||||
* SSRF guard still runs on every send; this list is what keeps a subscription
|
||||
* from turning the server into a POST relay towards some other public host.
|
||||
*/
|
||||
export const PUSH_SERVICE_HOSTS: readonly string[] = [
|
||||
// Chrome, Edge on Android, and every Chromium browser that uses Google's push.
|
||||
'fcm.googleapis.com',
|
||||
'android.googleapis.com',
|
||||
// Firefox (autopush).
|
||||
'updates.push.services.mozilla.com',
|
||||
'push.services.mozilla.com',
|
||||
// Safari on macOS, and Home Screen apps on iOS and iPadOS 16.4+.
|
||||
'web.push.apple.com',
|
||||
];
|
||||
|
||||
/** Services that hand out per-region or per-cluster hosts under one domain. */
|
||||
export const PUSH_SERVICE_HOST_SUFFIXES: readonly string[] = [
|
||||
'.push.apple.com',
|
||||
// Edge on Windows (WNS).
|
||||
'.notify.windows.com',
|
||||
];
|
||||
|
||||
/** The bespoke 400 bodies for a subscription the browser should not have sent. */
|
||||
export const PUSH_SUBSCRIPTION_ERRORS = {
|
||||
endpoint: 'Invalid push endpoint',
|
||||
service: 'Push endpoint is not a known push service',
|
||||
p256dh: 'Invalid push subscription key',
|
||||
auth: 'Invalid push subscription auth secret',
|
||||
} as const;
|
||||
|
||||
export interface CheckedPushSubscription {
|
||||
endpoint: string;
|
||||
/** Canonical base64url, no padding. */
|
||||
p256dh: string;
|
||||
/** Canonical base64url, no padding. */
|
||||
auth: string;
|
||||
}
|
||||
|
||||
export type PushSubscriptionCheck = { ok: true; value: CheckedPushSubscription } | { ok: false; error: string };
|
||||
|
||||
function isKnownPushServiceHost(host: string): boolean {
|
||||
return (
|
||||
PUSH_SERVICE_HOSTS.includes(host) ||
|
||||
PUSH_SERVICE_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix) && host.length > suffix.length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint half of the check, shared with the sender so a row that no
|
||||
* longer passes (a host dropped from the list) is not posted to again.
|
||||
* Answers 'ok', or which of the errors applies: 'endpoint' for anything that is
|
||||
* not a plain https URL on the default port without credentials, 'service' for
|
||||
* a host that is not on the list.
|
||||
*/
|
||||
export function checkPushEndpoint(endpoint: string): 'ok' | 'endpoint' | 'service' {
|
||||
if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) return 'endpoint';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return 'endpoint';
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port) return 'endpoint';
|
||||
return isKnownPushServiceHost(url.hostname) ? 'ok' : 'service';
|
||||
}
|
||||
|
||||
export function isPushServiceEndpoint(endpoint: string): boolean {
|
||||
return checkPushEndpoint(endpoint) === 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the Zod contract cannot say about a subscription: where it points,
|
||||
* and that its keys have the sizes RFC 8291 needs (and the p256dh a point on
|
||||
* the curve), so the sender never meets a row it cannot encrypt for.
|
||||
*/
|
||||
export function checkPushSubscription(input: PushSubscriptionInput): PushSubscriptionCheck {
|
||||
const endpointVerdict = checkPushEndpoint(input.endpoint);
|
||||
if (endpointVerdict !== 'ok') return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS[endpointVerdict] };
|
||||
|
||||
const p256dh = decodeBase64Url(input.keys.p256dh);
|
||||
if (!p256dh || !isP256Point(p256dh)) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.p256dh };
|
||||
|
||||
const auth = decodeBase64Url(input.keys.auth);
|
||||
if (auth?.length !== 16) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.auth };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: { endpoint: input.endpoint, p256dh: p256dh.toString('base64url'), auth: auth.toString('base64url') },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import type { CheckedPushSubscription } from './push-subscription.helpers';
|
||||
|
||||
export interface PushSubscriptionRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
vapid_public_key: string;
|
||||
user_agent: string | null;
|
||||
created_at: string;
|
||||
last_success_at: string | null;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many browsers one account can receive push on. Generous for phones,
|
||||
* tablets and a few desktop profiles; the cap only exists so a client that
|
||||
* subscribes in a loop cannot grow the table or the fan-out without bound.
|
||||
*/
|
||||
export const MAX_PUSH_DEVICES_PER_USER = 20;
|
||||
|
||||
/** Only there to tell one user's devices apart in the table, never parsed, so a cut-off one does no harm. */
|
||||
const USER_AGENT_MAX_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* The push_subscriptions table: one row per browser a user switched push on
|
||||
* in. Only rows the checks in push-subscription.helpers.ts accepted get here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PushSubscriptionsService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Register a browser, or refresh it when it subscribes again. The endpoint
|
||||
* is unique, so a browser shared by two accounts moves to whoever subscribed
|
||||
* on it last instead of notifying both. A refresh renews created_at, which
|
||||
* makes the cap drop the device that registered longest ago rather than one
|
||||
* that is in daily use. Answers how many devices the user now has.
|
||||
*/
|
||||
upsert(
|
||||
userId: number,
|
||||
subscription: CheckedPushSubscription,
|
||||
vapidPublicKey: string,
|
||||
userAgent?: string | null,
|
||||
): number {
|
||||
const agent = userAgent ? userAgent.slice(0, USER_AGENT_MAX_LENGTH) : null;
|
||||
return this.db.transaction(() => {
|
||||
this.db.run(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
vapid_public_key = excluded.vapid_public_key,
|
||||
user_agent = excluded.user_agent,
|
||||
created_at = CURRENT_TIMESTAMP,
|
||||
failure_count = 0`,
|
||||
userId,
|
||||
subscription.endpoint,
|
||||
subscription.p256dh,
|
||||
subscription.auth,
|
||||
vapidPublicKey,
|
||||
agent,
|
||||
);
|
||||
const overflow = this.countForUser(userId) - MAX_PUSH_DEVICES_PER_USER;
|
||||
if (overflow > 0) {
|
||||
this.db.run(
|
||||
`DELETE FROM push_subscriptions WHERE id IN (
|
||||
SELECT id FROM push_subscriptions
|
||||
WHERE user_id = ? AND endpoint <> ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
)`,
|
||||
userId,
|
||||
subscription.endpoint,
|
||||
overflow,
|
||||
);
|
||||
}
|
||||
return this.countForUser(userId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget one of the caller's own browsers. Someone else's endpoint is never touched. */
|
||||
removeForUser(userId: number, endpoint: string): boolean {
|
||||
return (
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?', userId, endpoint).changes > 0
|
||||
);
|
||||
}
|
||||
|
||||
listForUser(userId: number): PushSubscriptionRow[] {
|
||||
return this.db.all<PushSubscriptionRow>('SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY id', userId);
|
||||
}
|
||||
|
||||
countForUser(userId: number): number {
|
||||
return this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?', userId)?.n ?? 0;
|
||||
}
|
||||
|
||||
hasAny(userId: number): boolean {
|
||||
return !!this.db.get('SELECT 1 FROM push_subscriptions WHERE user_id = ? LIMIT 1', userId);
|
||||
}
|
||||
|
||||
/** The sender's clean-up: the push service said the browser is gone, or the row can no longer be sent to. */
|
||||
deleteById(id: number): void {
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE id = ?', id);
|
||||
}
|
||||
|
||||
recordSuccess(id: number): void {
|
||||
this.db.run(
|
||||
'UPDATE push_subscriptions SET last_success_at = CURRENT_TIMESTAMP, failure_count = 0 WHERE id = ?',
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts one more failed send since the last success and answers the new
|
||||
* count, read in the same statement so two sends at once cannot both see
|
||||
* the old value. 0 when the row is gone already.
|
||||
*/
|
||||
recordFailure(id: number): number {
|
||||
return (
|
||||
this.db.get<{ failure_count: number }>(
|
||||
'UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ? RETURNING failure_count',
|
||||
id,
|
||||
)?.failure_count ?? 0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Post, UseGuards } from '@nestjs/common';
|
||||
import type { PushPublicKeyResult, PushSubscribeResult, PushUnsubscribeResult } from '@trek/shared';
|
||||
import type { User } from '../../../types';
|
||||
import { CurrentUser } from '../../auth/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { PushSubscribeDto, PushUnsubscribeDto } from '../notifications.dto';
|
||||
import { checkPushSubscription } from './push-subscription.helpers';
|
||||
import { PushSubscriptionsService } from './push-subscriptions.service';
|
||||
import { PUSH_UNAVAILABLE_ERROR, PushUnavailableError, VapidKeysService } from './vapid-keys.service';
|
||||
|
||||
/**
|
||||
* /api/notifications/push: how a browser signs up for Web Push (#894).
|
||||
*
|
||||
* The browser fetches the server's public key, subscribes with its push
|
||||
* service, and hands the subscription over here; logging out or switching push
|
||||
* off in that browser deletes it again. Which events arrive is the ordinary
|
||||
* preference matrix ('push' column), and the test send is the generic
|
||||
* POST /api/notifications/test/push.
|
||||
*
|
||||
* No MCP tool mirrors these routes on purpose: a subscription is created by a
|
||||
* browser's push manager for that one browser, and there is nothing an MCP
|
||||
* client could subscribe with.
|
||||
*/
|
||||
@Controller('api/notifications/push')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PushController {
|
||||
constructor(
|
||||
private readonly keys: VapidKeysService,
|
||||
private readonly subscriptions: PushSubscriptionsService,
|
||||
) {}
|
||||
|
||||
@Get('public-key')
|
||||
publicKey(): PushPublicKeyResult {
|
||||
return { publicKey: this.currentPublicKey() };
|
||||
}
|
||||
|
||||
@Post('subscriptions')
|
||||
@HttpCode(200)
|
||||
subscribe(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: PushSubscribeDto,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
): PushSubscribeResult {
|
||||
const checked = checkPushSubscription(body.subscription);
|
||||
if ('error' in checked) throw new HttpException({ error: checked.error }, 400);
|
||||
const devices = this.subscriptions.upsert(user.id, checked.value, this.currentPublicKey(), userAgent);
|
||||
return { success: true, devices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: the push service may already have expired the row, and a
|
||||
* browser logging out must not get an error for that. Only the caller's own
|
||||
* endpoint can go; the same URL on someone else's account is left alone.
|
||||
* Works while push is unavailable too, so a device can always be forgotten.
|
||||
*/
|
||||
@Delete('subscriptions')
|
||||
@HttpCode(200)
|
||||
unsubscribe(@CurrentUser() user: User, @Body() body: PushUnsubscribeDto): PushUnsubscribeResult {
|
||||
this.subscriptions.removeForUser(user.id, body.endpoint);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The key browsers subscribe with. While the key pair cannot be used, stored
|
||||
* or from VAPID_* (the log says why), the answer is a 503: nothing could be
|
||||
* signed for a new subscription, and the devices already subscribed stay as
|
||||
* they are.
|
||||
*/
|
||||
private currentPublicKey(): string {
|
||||
try {
|
||||
return this.keys.getPublicKey();
|
||||
} catch (err) {
|
||||
if (err instanceof PushUnavailableError) throw new HttpException({ error: PUSH_UNAVAILABLE_ERROR }, 503);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { decodeBase64Url, isVapidSubject, readEnv, stripTrailingSlashes } from '../../../app-config';
|
||||
import { logError, logInfo } from '../../audit/audit-log.logger';
|
||||
import { decrypt_api_key, encrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { generateVapidKeyPair, isVapidKeyPair, type VapidKeyPair } from './web-push-crypto';
|
||||
|
||||
/** app_settings rows holding the generated pair. The private half is stored encrypted. */
|
||||
export const VAPID_PUBLIC_KEY_SETTING = 'web_push_vapid_public_key';
|
||||
export const VAPID_PRIVATE_KEY_SETTING = 'web_push_vapid_private_key';
|
||||
|
||||
/** The VAPID contact when nothing better is configured: a real https URL every push service accepts. */
|
||||
export const FALLBACK_VAPID_SUBJECT = 'https://github.com/liketrek/TREK';
|
||||
|
||||
/** What the push routes answer while the key pair cannot be used. */
|
||||
export const PUSH_UNAVAILABLE_ERROR = 'Web Push is currently unavailable on this server';
|
||||
|
||||
export interface ResolvedVapidKeys extends VapidKeyPair {
|
||||
source: 'env' | 'database';
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by getKeys() while push is off because the pair it would sign with
|
||||
* cannot be used: the VAPID_* variables name one that cannot, or, with them
|
||||
* unset, the stored one cannot. The reason is in the server log (once, without
|
||||
* key material); this error only says that nothing can be signed right now.
|
||||
*/
|
||||
export class PushUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('there is no usable VAPID key pair; see the earlier Web Push error in the log');
|
||||
this.name = 'PushUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
type UnusableReason = 'incomplete' | 'undecryptable' | 'mismatched';
|
||||
|
||||
type StoredPair =
|
||||
| { state: 'absent' }
|
||||
| { state: 'usable'; keys: VapidKeyPair }
|
||||
| { state: UnusableReason; fingerprint: string };
|
||||
|
||||
type PresentPair = Exclude<StoredPair, { state: 'absent' }>;
|
||||
|
||||
type EnvPair =
|
||||
| { state: 'unset' }
|
||||
| { state: 'usable'; keys: VapidKeyPair }
|
||||
| { state: 'unusable'; fingerprint: string; problem: string };
|
||||
|
||||
const DELETE_BOTH_ROWS =
|
||||
`delete both app_settings rows ${VAPID_PUBLIC_KEY_SETTING} and ${VAPID_PRIVATE_KEY_SETTING} ` +
|
||||
'to generate a new pair (every device then has to turn push on again)';
|
||||
|
||||
/** The admin-facing explanation for each unusable state. Never contains key material. */
|
||||
const UNUSABLE_MESSAGES: Record<UnusableReason, string> = {
|
||||
incomplete: `only one half of the stored VAPID key pair exists. To start over, ${DELETE_BOTH_ROWS}.`,
|
||||
undecryptable:
|
||||
'the stored VAPID private key cannot be decrypted with the current ENCRYPTION_KEY (for example after a ' +
|
||||
'restore under a different key). Start the server with the ENCRYPTION_KEY it was encrypted with to bring ' +
|
||||
`push back as it was, or ${DELETE_BOTH_ROWS}.`,
|
||||
mismatched: `the stored VAPID private key does not belong to the stored public key. To start over, ${DELETE_BOTH_ROWS}.`,
|
||||
};
|
||||
|
||||
/** Re-encoded without padding, so the key the browser gets and the one rows are compared with is one string. */
|
||||
function canonical(value: string): string {
|
||||
return decodeBase64Url(value)?.toString('base64url') ?? value;
|
||||
}
|
||||
|
||||
/** An https URL on a host other than localhost: what APP_URL has to be to serve as the VAPID contact. */
|
||||
function isPublicHttpsUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && url.hostname !== 'localhost';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's VAPID identity: the key pair every subscription is bound to, and
|
||||
* the contact (`sub`) sent with each request.
|
||||
*
|
||||
* VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY win when both are set and belong
|
||||
* together. Otherwise the pair lives in app_settings, created once and then
|
||||
* kept: it travels with the database into every backup, where a key file in
|
||||
* data/ would not, and a restore that minted a new pair would silently cut off
|
||||
* every device.
|
||||
*
|
||||
* When the pair push should sign with cannot be used, push is off: it never
|
||||
* falls back on another pair. A pair in the environment that does not fit
|
||||
* together (or only one half of it) is logged rather than refusing to boot,
|
||||
* and push stays off until the variables are fixed, even while a usable pair
|
||||
* is stored: the devices subscribed with the environment key, so the first
|
||||
* send under any other pair would drop every one of them. A stored pair that
|
||||
* is there but cannot be used (a private key the current ENCRYPTION_KEY cannot
|
||||
* open, halves that do not match, one half missing) is never replaced either.
|
||||
* Either way the log says why, every device stays subscribed, and fixing the
|
||||
* variables or starting with the right ENCRYPTION_KEY again brings everything
|
||||
* back unchanged. A new pair is only ever generated while neither row exists
|
||||
* and neither VAPID_* variable is set.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VapidKeysService implements OnApplicationBootstrap {
|
||||
/** The last environment problem logged, so a bad pair is reported once and not on every send. */
|
||||
private reportedEnvProblem: string | null = null;
|
||||
/** The last stored-pair problem logged, for the same reason. */
|
||||
private reportedStoredProblem: string | null = null;
|
||||
/** The decrypted stored pair, keyed by the ciphertext it came from. */
|
||||
private stored: { ciphertext: string; keys: VapidKeyPair } | null = null;
|
||||
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Make sure the pair exists before the first request. The public-key GET is
|
||||
* the first thing a browser asks for, and a GET must not write; creating the
|
||||
* pair here keeps that route a read. The lazy path in getKeys() covers
|
||||
* instances built without the Nest lifecycle and a database restored under a
|
||||
* running server.
|
||||
*/
|
||||
onApplicationBootstrap(): void {
|
||||
this.succeeds(() => this.getKeys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is a pair to sign with right now. Only reads, so the
|
||||
* dispatcher and the preference matrix GET can ask on every call: while
|
||||
* neither row exists the answer is yes and nothing is written, because the
|
||||
* next send or subscribe creates the pair through getKeys(). Never throws;
|
||||
* the reason for a no is in the log.
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.succeeds(() => this.checkUsable());
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair to sign with, creating the stored one first while neither row
|
||||
* exists. Throws PushUnavailableError while the pair it would use cannot be.
|
||||
*/
|
||||
getKeys(): ResolvedVapidKeys {
|
||||
const fromEnv = this.envKeys();
|
||||
if (fromEnv) return { ...fromEnv, source: 'env' };
|
||||
return { ...this.usableStored(this.readOrCreateStored()), source: 'database' };
|
||||
}
|
||||
|
||||
getPublicKey(): string {
|
||||
return this.getKeys().publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `sub` claim: VAPID_SUBJECT, else APP_URL when it is public https, else
|
||||
* the project URL. Push services use it to reach whoever runs the sender, and
|
||||
* Apple refuses a token without a valid one. Only APP_URL itself counts, not
|
||||
* getAppUrl()'s stand-ins: the first ALLOWED_ORIGINS entry can be another
|
||||
* site that is merely allowed to call this API, and http://localhost is no
|
||||
* valid subject.
|
||||
*/
|
||||
getSubject(): string {
|
||||
const env = readEnv();
|
||||
const configured = env.push.vapidSubject;
|
||||
if (configured && isVapidSubject(configured)) return configured;
|
||||
const appUrl = env.app.appUrl?.trim();
|
||||
return appUrl && isPublicHttpsUrl(appUrl) ? stripTrailingSlashes(appUrl) : FALLBACK_VAPID_SUBJECT;
|
||||
}
|
||||
|
||||
/** getKeys() without the create: the same refusals, and a missing stored pair counts as usable. */
|
||||
private checkUsable(): void {
|
||||
if (this.envKeys()) return;
|
||||
const stored = this.readStored();
|
||||
if (stored.state !== 'absent') this.usableStored(stored);
|
||||
}
|
||||
|
||||
private succeeds(attempt: () => unknown): boolean {
|
||||
try {
|
||||
attempt();
|
||||
return true;
|
||||
} catch (err) {
|
||||
// An unusable pair has already been explained where it was found.
|
||||
if (!(err instanceof PushUnavailableError)) {
|
||||
logError(`Web Push: could not prepare the VAPID key pair: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair from VAPID_*, or null while neither variable is set. A pair that
|
||||
* is set but cannot be used turns push off rather than handing out the
|
||||
* stored pair (or a new one) in its place: the devices subscribed with the
|
||||
* environment key, and the first send under another pair would drop them
|
||||
* all. Push comes back unchanged once the variables are fixed.
|
||||
*/
|
||||
private envKeys(): VapidKeyPair | null {
|
||||
const env = this.readEnvPair();
|
||||
if (env.state !== 'unusable') {
|
||||
this.reportedEnvProblem = null;
|
||||
return env.state === 'usable' ? env.keys : null;
|
||||
}
|
||||
if (this.reportedEnvProblem !== env.fingerprint) {
|
||||
this.reportedEnvProblem = env.fingerprint;
|
||||
logError(
|
||||
`Web Push is off: ${env.problem}. Set both variables to the two halves of one pair to turn it back on. ` +
|
||||
'Until then nothing is sent, every device stays subscribed, and no pair stored in the database is ' +
|
||||
'used in its place.',
|
||||
);
|
||||
}
|
||||
throw new PushUnavailableError();
|
||||
}
|
||||
|
||||
/** What the VAPID_* variables hold. The fingerprint only tells one broken state from the next. */
|
||||
private readEnvPair(): EnvPair {
|
||||
const { vapidPublicKey, vapidPrivateKey } = readEnv().push;
|
||||
if (!vapidPublicKey && !vapidPrivateKey) return { state: 'unset' };
|
||||
if (!vapidPublicKey || !vapidPrivateKey) {
|
||||
return {
|
||||
state: 'unusable',
|
||||
fingerprint: `half:${vapidPublicKey ?? ''}`,
|
||||
problem: `${vapidPublicKey ? 'VAPID_PUBLIC_KEY' : 'VAPID_PRIVATE_KEY'} is set without its other half`,
|
||||
};
|
||||
}
|
||||
if (!isVapidKeyPair(vapidPublicKey, vapidPrivateKey)) {
|
||||
return {
|
||||
state: 'unusable',
|
||||
fingerprint: `pair:${vapidPublicKey}`,
|
||||
problem: 'VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY',
|
||||
};
|
||||
}
|
||||
return { state: 'usable', keys: { publicKey: canonical(vapidPublicKey), privateKey: canonical(vapidPrivateKey) } };
|
||||
}
|
||||
|
||||
private setting(key: string): string | null {
|
||||
return this.db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
private writeSetting(key: string, value: string): void {
|
||||
this.db.run(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/** What app_settings holds right now. The fingerprint only tells one broken state from the next. */
|
||||
private readStored(): StoredPair {
|
||||
const publicKey = this.setting(VAPID_PUBLIC_KEY_SETTING);
|
||||
const ciphertext = this.setting(VAPID_PRIVATE_KEY_SETTING);
|
||||
if (!publicKey && !ciphertext) return { state: 'absent' };
|
||||
const fingerprint = `${publicKey ?? ''}\n${ciphertext ?? ''}`;
|
||||
if (!publicKey || !ciphertext) return { state: 'incomplete', fingerprint };
|
||||
if (this.stored?.ciphertext === ciphertext && this.stored.keys.publicKey === publicKey) {
|
||||
return { state: 'usable', keys: this.stored.keys };
|
||||
}
|
||||
const privateKey = decrypt_api_key(ciphertext);
|
||||
if (!privateKey) return { state: 'undecryptable', fingerprint };
|
||||
if (!isVapidKeyPair(publicKey, privateKey)) return { state: 'mismatched', fingerprint };
|
||||
const keys = { publicKey, privateKey };
|
||||
this.stored = { ciphertext, keys };
|
||||
return { state: 'usable', keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored pair, created first only when neither row exists: checked again
|
||||
* inside the transaction and written in it, both rows or neither, so the
|
||||
* pair is created exactly once.
|
||||
*/
|
||||
private readOrCreateStored(): PresentPair {
|
||||
const first = this.readStored();
|
||||
if (first.state !== 'absent') return first;
|
||||
return this.db.transaction((): PresentPair => {
|
||||
const again = this.readStored();
|
||||
if (again.state !== 'absent') return again;
|
||||
return { state: 'usable', keys: this.generateAndStore() };
|
||||
});
|
||||
}
|
||||
|
||||
/** The keys of a stored pair, or PushUnavailableError (logged once per state) when it cannot be used. */
|
||||
private usableStored(found: PresentPair): VapidKeyPair {
|
||||
if (found.state === 'usable') {
|
||||
this.reportedStoredProblem = null;
|
||||
return found.keys;
|
||||
}
|
||||
this.reportStoredProblem(found.state, found.fingerprint);
|
||||
throw new PushUnavailableError();
|
||||
}
|
||||
|
||||
private reportStoredProblem(reason: UnusableReason, fingerprint: string): void {
|
||||
const key = `${reason}:${fingerprint}`;
|
||||
if (this.reportedStoredProblem === key) return;
|
||||
this.reportedStoredProblem = key;
|
||||
logError(`Web Push is off: ${UNUSABLE_MESSAGES[reason]}`);
|
||||
}
|
||||
|
||||
private generateAndStore(): VapidKeyPair {
|
||||
const keys = generateVapidKeyPair();
|
||||
const ciphertext = encrypt_api_key(keys.privateKey);
|
||||
this.writeSetting(VAPID_PUBLIC_KEY_SETTING, keys.publicKey);
|
||||
this.writeSetting(VAPID_PRIVATE_KEY_SETTING, ciphertext);
|
||||
this.stored = { ciphertext, keys };
|
||||
logInfo('Web Push: generated the VAPID key pair for this instance.');
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createECDH,
|
||||
createPrivateKey,
|
||||
ECDH,
|
||||
generateKeyPairSync,
|
||||
hkdfSync,
|
||||
randomBytes,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { decodeBase64Url } from '../../../app-config';
|
||||
|
||||
/**
|
||||
* Web Push message encryption (RFC 8291, `aes128gcm` from RFC 8188) and the
|
||||
* VAPID Authorization header (RFC 8292), on node:crypto and the jsonwebtoken
|
||||
* the server already signs its sessions with.
|
||||
*
|
||||
* Pure: no database, no network, no DI. The `web-push` package does the same
|
||||
* arithmetic, but it posts through its own https.request and so past the SSRF
|
||||
* guard; the request is the part this domain has to own, and what is left of
|
||||
* the package is this file.
|
||||
*/
|
||||
|
||||
/** Record size announced in the header. One record carries the whole message. */
|
||||
export const PUSH_RECORD_SIZE = 4096;
|
||||
|
||||
const P256_POINT_BYTES = 65;
|
||||
const AUTH_SECRET_BYTES = 16;
|
||||
const SALT_BYTES = 16;
|
||||
const GCM_TAG_BYTES = 16;
|
||||
/** salt (16) + rs (4) + idlen (1) + keyid (the 65-byte sender key). */
|
||||
const HEADER_BYTES = SALT_BYTES + 4 + 1 + P256_POINT_BYTES;
|
||||
|
||||
const KEY_INFO_PREFIX = Buffer.from('WebPush: info\0', 'latin1');
|
||||
const CEK_INFO = Buffer.from('Content-Encoding: aes128gcm\0', 'latin1');
|
||||
const NONCE_INFO = Buffer.from('Content-Encoding: nonce\0', 'latin1');
|
||||
/** Padding delimiter of the last (here: only) record, RFC 8188 section 2. */
|
||||
const LAST_RECORD_DELIMITER = Buffer.from([0x02]);
|
||||
|
||||
/** Largest plaintext that still fits one record next to its delimiter and tag. */
|
||||
export const MAX_PUSH_PLAINTEXT_BYTES = PUSH_RECORD_SIZE - HEADER_BYTES - 1 - GCM_TAG_BYTES;
|
||||
|
||||
/**
|
||||
* How long a VAPID token is good for. RFC 8292 allows up to 24 hours and Apple
|
||||
* refuses anything longer, so this stays well inside it.
|
||||
*/
|
||||
export const VAPID_TOKEN_TTL_SECONDS = 12 * 60 * 60;
|
||||
|
||||
/** A VAPID key pair in the form browsers and the Web Push tools use: base64url, raw. */
|
||||
export interface VapidKeyPair {
|
||||
/** Uncompressed P-256 point, 65 bytes. What the browser subscribes with. */
|
||||
publicKey: string;
|
||||
/** The 32-byte private scalar. */
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
/** The two secrets a browser hands over with its subscription. */
|
||||
export interface PushEncryptionKeys {
|
||||
/** The browser's P-256 public key, uncompressed. */
|
||||
p256dh: Buffer;
|
||||
/** The 16-byte authentication secret. */
|
||||
auth: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed inputs for the RFC 8291 test vector. Leave both unset for real
|
||||
* messages: every message needs a fresh salt and a fresh sender key, or the
|
||||
* same key and nonce would encrypt two plaintexts.
|
||||
*/
|
||||
export interface PushEncryptionOptions {
|
||||
salt?: Buffer;
|
||||
senderPrivateKey?: Buffer;
|
||||
}
|
||||
|
||||
function hkdf(ikm: Buffer, salt: Buffer, info: Buffer, length: number): Buffer {
|
||||
return Buffer.from(hkdfSync('sha256', ikm, salt, info, length));
|
||||
}
|
||||
|
||||
/** True for a point that is actually on P-256, not just 65 bytes that start with 0x04. */
|
||||
export function isP256Point(bytes: Buffer): boolean {
|
||||
if (bytes.length !== P256_POINT_BYTES || bytes[0] !== 0x04) return false;
|
||||
try {
|
||||
ECDH.convertKey(bytes, 'prime256v1');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh P-256 pair. Exported as JWK so both halves come out at full length. */
|
||||
export function generateVapidKeyPair(): VapidKeyPair {
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const { x, y, d } = privateKey.export({ format: 'jwk' });
|
||||
if (!x || !y || !d) throw new Error('P-256 key export did not produce x, y and d');
|
||||
return {
|
||||
publicKey: Buffer.concat([Buffer.from([0x04]), Buffer.from(x, 'base64url'), Buffer.from(y, 'base64url')]).toString(
|
||||
'base64url',
|
||||
),
|
||||
privateKey: d,
|
||||
};
|
||||
}
|
||||
|
||||
/** The public key a private scalar belongs to, or null when the scalar is not a usable P-256 key. */
|
||||
export function derivePublicKey(privateKey: string): Buffer | null {
|
||||
const scalar = decodeBase64Url(privateKey);
|
||||
if (scalar?.length !== 32) return null;
|
||||
try {
|
||||
const ecdh = createECDH('prime256v1');
|
||||
ecdh.setPrivateKey(scalar);
|
||||
return ecdh.getPublicKey();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the two halves belong together. Compared as bytes, so padding and the like do not matter. */
|
||||
export function isVapidKeyPair(publicKey: string, privateKey: string): boolean {
|
||||
const expected = derivePublicKey(privateKey);
|
||||
const given = decodeBase64Url(publicKey);
|
||||
return !!expected && !!given && given.equals(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt one push message for one browser (RFC 8291 section 3.4): ECDH with
|
||||
* the browser key, HKDF over the shared secret and the auth secret, then a
|
||||
* single AES-128-GCM record behind the aes128gcm header, whose key id is the
|
||||
* sender's public key.
|
||||
*/
|
||||
export function encryptPushMessage(
|
||||
plaintext: Buffer,
|
||||
keys: PushEncryptionKeys,
|
||||
options: PushEncryptionOptions = {},
|
||||
): Buffer {
|
||||
if (!isP256Point(keys.p256dh)) throw new Error('p256dh is not an uncompressed P-256 public key');
|
||||
if (keys.auth.length !== AUTH_SECRET_BYTES) throw new Error('auth secret must be 16 bytes');
|
||||
if (plaintext.length > MAX_PUSH_PLAINTEXT_BYTES) {
|
||||
throw new Error(`push payload of ${plaintext.length} bytes does not fit one record`);
|
||||
}
|
||||
|
||||
const salt = options.salt ?? randomBytes(SALT_BYTES);
|
||||
if (salt.length !== SALT_BYTES) throw new Error('salt must be 16 bytes');
|
||||
const sender = createECDH('prime256v1');
|
||||
if (options.senderPrivateKey) sender.setPrivateKey(options.senderPrivateKey);
|
||||
else sender.generateKeys();
|
||||
const senderPublicKey = sender.getPublicKey();
|
||||
|
||||
const ecdhSecret = sender.computeSecret(keys.p256dh);
|
||||
const keyInfo = Buffer.concat([KEY_INFO_PREFIX, keys.p256dh, senderPublicKey]);
|
||||
const ikm = hkdf(ecdhSecret, keys.auth, keyInfo, 32);
|
||||
const contentKey = hkdf(ikm, salt, CEK_INFO, 16);
|
||||
const nonce = hkdf(ikm, salt, NONCE_INFO, 12);
|
||||
|
||||
const cipher = createCipheriv('aes-128-gcm', contentKey, nonce);
|
||||
const record = Buffer.concat([
|
||||
cipher.update(plaintext),
|
||||
cipher.update(LAST_RECORD_DELIMITER),
|
||||
cipher.final(),
|
||||
cipher.getAuthTag(),
|
||||
]);
|
||||
|
||||
const header = Buffer.alloc(SALT_BYTES + 4 + 1);
|
||||
salt.copy(header, 0);
|
||||
header.writeUInt32BE(PUSH_RECORD_SIZE, SALT_BYTES);
|
||||
header.writeUInt8(senderPublicKey.length, SALT_BYTES + 4);
|
||||
return Buffer.concat([header, senderPublicKey, record]);
|
||||
}
|
||||
|
||||
/** The signing key, and the public key re-encoded the way the `k=` parameter wants it (no padding). */
|
||||
function vapidSigningKey(keys: VapidKeyPair): { signingKey: KeyObject; publicKey: string } {
|
||||
const point = decodeBase64Url(keys.publicKey);
|
||||
const scalar = decodeBase64Url(keys.privateKey);
|
||||
if (point?.length !== P256_POINT_BYTES || scalar?.length !== 32) throw new Error('VAPID key pair is malformed');
|
||||
const signingKey = createPrivateKey({
|
||||
key: {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
x: point.subarray(1, 33).toString('base64url'),
|
||||
y: point.subarray(33).toString('base64url'),
|
||||
d: scalar.toString('base64url'),
|
||||
},
|
||||
format: 'jwk',
|
||||
});
|
||||
return { signingKey, publicKey: point.toString('base64url') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function that builds the Authorization header for an endpoint
|
||||
* (RFC 8292): `vapid t=<ES256 JWT>, k=<public key>`, the token's audience being
|
||||
* the endpoint's origin. Tokens are reused per origin for the life of the
|
||||
* signer, which is one delivery, so a user with three Chrome devices signs once.
|
||||
*/
|
||||
export function createVapidSigner(keys: VapidKeyPair, subject: string, now: () => number = Date.now) {
|
||||
const { signingKey, publicKey } = vapidSigningKey(keys);
|
||||
const byOrigin = new Map<string, string>();
|
||||
return (endpoint: string): string => {
|
||||
const audience = new URL(endpoint).origin;
|
||||
let header = byOrigin.get(audience);
|
||||
if (!header) {
|
||||
const token = jwt.sign(
|
||||
{ aud: audience, exp: Math.floor(now() / 1000) + VAPID_TOKEN_TTL_SECONDS, sub: subject },
|
||||
signingKey,
|
||||
// The three claims RFC 8292 names and nothing else: an extra iat buys
|
||||
// nothing and is one more thing a strict push service could object to.
|
||||
{ algorithm: 'ES256', noTimestamp: true },
|
||||
);
|
||||
header = `vapid t=${token}, k=${publicKey}`;
|
||||
byOrigin.set(audience, header);
|
||||
}
|
||||
return header;
|
||||
};
|
||||
}
|
||||
|
||||
export type VapidSigner = ReturnType<typeof createVapidSigner>;
|
||||
@@ -0,0 +1,292 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ChannelTestResult, PushPayload } from '@trek/shared';
|
||||
import { decodeBase64Url } from '../../../app-config';
|
||||
import { discardBody, readCappedText } from '../../../utils/cappedFetch';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard';
|
||||
import { logError, logInfo } from '../../audit/audit-log.logger';
|
||||
import type { ChannelMessage } from '../notification-events';
|
||||
import { isPushServiceEndpoint } from '../push/push-subscription.helpers';
|
||||
import { PushSubscriptionsService, type PushSubscriptionRow } from '../push/push-subscriptions.service';
|
||||
import { PUSH_UNAVAILABLE_ERROR, VapidKeysService } from '../push/vapid-keys.service';
|
||||
import { createVapidSigner, encryptPushMessage, type VapidSigner } from '../push/web-push-crypto';
|
||||
|
||||
/**
|
||||
* Ceiling for the JSON the service worker receives. A single aes128gcm record
|
||||
* can carry just under 4 KB and some push services refuse anything larger, so
|
||||
* the payload stays well clear of that and the body gives way first.
|
||||
*/
|
||||
export const MAX_PUSH_PAYLOAD_BYTES = 3000;
|
||||
|
||||
const TITLE_MAX_CHARS = 120;
|
||||
/** A navigate target is an app path like /trips/12; anything longer is not one. */
|
||||
const URL_MAX_LENGTH = 256;
|
||||
const ELLIPSIS = '…';
|
||||
|
||||
const PUSH_TIMEOUT_MS = 10_000;
|
||||
/** A day: a reminder that reaches a phone after a longer flight is still worth showing, a week-old one is not. */
|
||||
const PUSH_TTL_SECONDS = 24 * 60 * 60;
|
||||
const ERROR_BODY_MAX_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* How many failed sends in a row (with no success between them) a device gets
|
||||
* before a 403 removes it. A 403 is not proof the subscription is dead: Apple
|
||||
* also answers it for a token it rejects (BadJwtToken, a bad subject, clock
|
||||
* skew), which is the server's problem and must not cost the user their device
|
||||
* on the first try.
|
||||
*/
|
||||
export const FORBIDDEN_FAILURES_BEFORE_REMOVAL = 3;
|
||||
|
||||
/**
|
||||
* The one event whose notifications replace each other: a burst of chat
|
||||
* messages in one trip shows as one notification that keeps updating. Every
|
||||
* other event stands on its own, so two todos, two bookings or two plugin
|
||||
* notices never hide one another.
|
||||
*/
|
||||
const COLLAPSING_EVENT = 'collab_message';
|
||||
|
||||
type Urgency = 'normal' | 'high';
|
||||
/** The events that are about a date, where arriving late defeats the point (RFC 8030 section 5.3). */
|
||||
const HIGH_URGENCY_EVENTS: ReadonlySet<string> = new Set(['trip_reminder', 'todo_due']);
|
||||
|
||||
/** No tag either: two presses of Send test are two notifications, like two of anything but chat. */
|
||||
const TEST_PAYLOAD: PushPayload = {
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from TREK. If you received this, push notifications are working on this device.',
|
||||
url: '/settings?tab=notifications',
|
||||
};
|
||||
|
||||
/**
|
||||
* The target the worker may open: a path on this origin only. Absolute URLs
|
||||
* and protocol-relative ones (`//host`, `/\host`) would take the click to
|
||||
* another site, so they are dropped rather than passed on.
|
||||
*/
|
||||
export function samePagePath(target: string | undefined): string | undefined {
|
||||
if (!target || target.length > URL_MAX_LENGTH) return undefined;
|
||||
if (!/^\/(?![/\\])[\x21-\x7e]*$/.test(target)) return undefined;
|
||||
return target;
|
||||
}
|
||||
|
||||
function truncateChars(value: string, max: number): string {
|
||||
const chars = Array.from(value);
|
||||
return chars.length <= max ? value : `${chars.slice(0, max - 1).join('')}${ELLIPSIS}`;
|
||||
}
|
||||
|
||||
function payloadBytes(payload: PushPayload): number {
|
||||
return Buffer.byteLength(JSON.stringify(payload), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered notification as the service worker gets it. Title and body are
|
||||
* already in the recipient's language; the worker has no translations. Only
|
||||
* chat messages carry a tag, `collab_message:<trip path>`, so a burst of them in
|
||||
* one trip shows as one notification that keeps updating instead of a pile of
|
||||
* them. Everything else has no tag and never replaces an earlier notification.
|
||||
*
|
||||
* The body is cut, at a character boundary and with an ellipsis, until the
|
||||
* JSON fits MAX_PUSH_PAYLOAD_BYTES. The title and the path are capped first, so
|
||||
* whatever they are, the body always keeps room of its own.
|
||||
*/
|
||||
export function buildPushPayload(
|
||||
msg: Pick<ChannelMessage, 'event' | 'title' | 'body' | 'navigateTarget'>,
|
||||
): PushPayload {
|
||||
const url = samePagePath(msg.navigateTarget);
|
||||
const base: PushPayload = {
|
||||
title: truncateChars(msg.title, TITLE_MAX_CHARS),
|
||||
body: '',
|
||||
...(url ? { url } : {}),
|
||||
...(url && msg.event === COLLAPSING_EVENT ? { tag: `${COLLAPSING_EVENT}:${url}` } : {}),
|
||||
};
|
||||
const full = { ...base, body: msg.body };
|
||||
if (payloadBytes(full) <= MAX_PUSH_PAYLOAD_BYTES) return full;
|
||||
|
||||
const chars = Array.from(msg.body);
|
||||
let fits = 0;
|
||||
let tooLong = chars.length;
|
||||
// Largest prefix that still fits with the ellipsis behind it.
|
||||
while (tooLong - fits > 1) {
|
||||
const mid = Math.floor((fits + tooLong) / 2);
|
||||
if (payloadBytes({ ...base, body: `${chars.slice(0, mid).join('')}${ELLIPSIS}` }) <= MAX_PUSH_PAYLOAD_BYTES)
|
||||
fits = mid;
|
||||
else tooLong = mid;
|
||||
}
|
||||
return { ...base, body: `${chars.slice(0, fits).join('')}${ELLIPSIS}` };
|
||||
}
|
||||
|
||||
function hostOf(endpoint: string): string {
|
||||
try {
|
||||
return new URL(endpoint).hostname;
|
||||
} catch {
|
||||
return 'invalid-endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Push delivery: encrypt the payload for each of a user's browsers and
|
||||
* POST it to that browser's push service, through the SSRF guard.
|
||||
*
|
||||
* Mirrors the ntfy and webhook transports: every failure is logged and turned
|
||||
* into `false`, nothing throws into the dispatcher. It also tends the table as
|
||||
* it goes, because the push services are the only ones who know a browser is
|
||||
* gone: 404 and 410 mean the subscription expired or was revoked, and the row
|
||||
* is deleted at once. A 403 is counted like any other failure and only removes
|
||||
* the row once it has failed FORBIDDEN_FAILURES_BEFORE_REMOVAL times in a row,
|
||||
* because the same status also means a rejected token. While the server's key
|
||||
* pair is unusable (VapidKeysService logs why) nothing is sent, every row
|
||||
* stays and the test send says so. Endpoints are capability URLs, so the log
|
||||
* names the push service host, never the URL.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebPushService {
|
||||
constructor(
|
||||
private readonly keys: VapidKeysService,
|
||||
private readonly subscriptions: PushSubscriptionsService,
|
||||
) {}
|
||||
|
||||
hasDevices(userId: number): boolean {
|
||||
return this.subscriptions.hasAny(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* False while the server has no key pair it can sign with. The channel
|
||||
* reports it as its instance readiness, so the dispatcher skips push for
|
||||
* everyone (every device stays subscribed) and the user's settings drop the
|
||||
* Push card and column instead of offering a switch that could only fail.
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.keys.isAvailable();
|
||||
}
|
||||
|
||||
/** True when at least one of the user's browsers accepted the message. */
|
||||
sendToUser(userId: number, msg: ChannelMessage): Promise<boolean> {
|
||||
const urgency: Urgency = HIGH_URGENCY_EVENTS.has(msg.event) ? 'high' : 'normal';
|
||||
return this.deliver(userId, buildPushPayload(msg), urgency, msg.event);
|
||||
}
|
||||
|
||||
/** The Send test button: a short fixed message to every browser the user turned push on in. */
|
||||
async sendTest(userId: number): Promise<ChannelTestResult> {
|
||||
if (!this.isAvailable()) return { success: false, error: PUSH_UNAVAILABLE_ERROR };
|
||||
const sent = await this.deliver(userId, TEST_PAYLOAD, 'normal', 'test');
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send push notification' };
|
||||
}
|
||||
|
||||
private async deliver(userId: number, payload: PushPayload, urgency: Urgency, event: string): Promise<boolean> {
|
||||
try {
|
||||
const rows = this.subscriptions.listForUser(userId);
|
||||
if (rows.length === 0) return false;
|
||||
const keys = this.keys.getKeys();
|
||||
const sign = createVapidSigner(keys, this.keys.getSubject());
|
||||
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
const results = await Promise.all(
|
||||
rows.map((row) => this.deliverTo(row, plaintext, keys.publicKey, sign, urgency, event)),
|
||||
);
|
||||
return results.includes(true);
|
||||
} catch (err) {
|
||||
logError(`Web Push failed event=${event} user=${userId}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverTo(
|
||||
row: PushSubscriptionRow,
|
||||
plaintext: Buffer,
|
||||
publicKey: string,
|
||||
sign: VapidSigner,
|
||||
urgency: Urgency,
|
||||
event: string,
|
||||
): Promise<boolean> {
|
||||
const host = hostOf(row.endpoint);
|
||||
if (row.vapid_public_key !== publicKey) {
|
||||
// Subscribed against a key this server no longer signs with, so the push
|
||||
// service would refuse every message. Until the browser subscribes again
|
||||
// with the current key there is nothing to send to.
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logInfo(`Web Push dropped a device subscribed with a previous server key user=${row.user_id} host=${host}`);
|
||||
return false;
|
||||
}
|
||||
if (!isPushServiceEndpoint(row.endpoint)) {
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logError(`Web Push dropped a device whose endpoint is not a known push service user=${row.user_id} host=${host}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const p256dh = decodeBase64Url(row.p256dh);
|
||||
const auth = decodeBase64Url(row.auth);
|
||||
if (!p256dh || !auth) throw new Error('stored subscription keys are not base64url');
|
||||
const body = encryptPushMessage(plaintext, { p256dh, auth });
|
||||
// maxRedirects 0: a push service answers, it does not redirect, and a
|
||||
// redirect would carry the VAPID token somewhere it was not minted for.
|
||||
// bypassInternalIpAllowed keeps private addresses closed even on an
|
||||
// install that opened ALLOW_INTERNAL_NETWORK for its own services.
|
||||
const res = await safeFetchFollow(
|
||||
row.endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
TTL: String(PUSH_TTL_SECONDS),
|
||||
Urgency: urgency,
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
Authorization: sign(row.endpoint),
|
||||
},
|
||||
body: new Uint8Array(body),
|
||||
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
|
||||
},
|
||||
{ maxRedirects: 0, bypassInternalIpAllowed: true },
|
||||
);
|
||||
return await this.settle(row, res, host, event);
|
||||
} catch (err) {
|
||||
this.subscriptions.recordFailure(row.id);
|
||||
if (err instanceof SsrfBlockedError) {
|
||||
logError(
|
||||
`Web Push blocked by SSRF guard event=${event} user=${row.user_id} host=${host} reason=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
logError(
|
||||
`Web Push failed event=${event} user=${row.user_id} host=${host}: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async settle(row: PushSubscriptionRow, res: Response, host: string, event: string): Promise<boolean> {
|
||||
if (res.ok) {
|
||||
discardBody(res);
|
||||
this.subscriptions.recordSuccess(row.id);
|
||||
logInfo(`Web Push sent event=${event} user=${row.user_id} host=${host}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const { text } = await readCappedText(res, ERROR_BODY_MAX_BYTES).catch(() => ({ text: '' }));
|
||||
if (res.status === 404 || res.status === 410) {
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logInfo(`Web Push removed an expired device (HTTP ${res.status}) user=${row.user_id} host=${host}`);
|
||||
return false;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
// Either the subscription was made for a different key, or the push
|
||||
// service rejects the token itself (Apple's BadJwtToken), which is a
|
||||
// configuration problem and not the device's. Only a run of refusals
|
||||
// with no success between them removes the row; the reason is logged
|
||||
// every time so the second case can be told apart.
|
||||
const failures = this.subscriptions.recordFailure(row.id);
|
||||
if (failures >= FORBIDDEN_FAILURES_BEFORE_REMOVAL) {
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logError(
|
||||
`Web Push removed a device after ${failures} failed sends in a row ending in HTTP 403 ` +
|
||||
`user=${row.user_id} host=${host}: ${text}`,
|
||||
);
|
||||
} else {
|
||||
logError(
|
||||
`Web Push HTTP 403 event=${event} user=${row.user_id} host=${host} ` +
|
||||
`(failure ${failures} of ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} before removal): ${text}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.subscriptions.recordFailure(row.id);
|
||||
logError(`Web Push HTTP ${res.status} event=${event} user=${row.user_id} host=${host}: ${text}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user