diff --git a/README.md b/README.md index a688a32dc..9414f59ac 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu - **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases - **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets - **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel -- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers +- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers - **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier - **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th - **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running diff --git a/charts/README.md b/charts/README.md index 32f981f5e..6c78a452d 100644 --- a/charts/README.md +++ b/charts/README.md @@ -53,4 +53,5 @@ See `values.yaml` for more options. - `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set. - Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically. - `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.** +- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again. - Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path. diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index a7397e76f..72db633de 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -97,3 +97,9 @@ data: {{- if .Values.env.AMAP_API_BASE }} AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }} {{- end }} + {{- if .Values.env.VAPID_PUBLIC_KEY }} + VAPID_PUBLIC_KEY: {{ .Values.env.VAPID_PUBLIC_KEY | quote }} + {{- end }} + {{- if .Values.env.VAPID_SUBJECT }} + VAPID_SUBJECT: {{ .Values.env.VAPID_SUBJECT | quote }} + {{- end }} diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 3f23458a2..3b91d8964 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -93,6 +93,12 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: AMAP_API_SECRET optional: true + - name: VAPID_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: VAPID_PRIVATE_KEY + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml index 1de6e12c2..0277285f7 100644 --- a/charts/trek/templates/secret.yaml +++ b/charts/trek/templates/secret.yaml @@ -29,6 +29,9 @@ data: {{- if .Values.secretEnv.AMAP_API_SECRET }} AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.VAPID_PRIVATE_KEY }} + VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -68,4 +71,7 @@ stringData: {{- if .Values.secretEnv.AMAP_API_SECRET }} AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }} {{- end }} + {{- if .Values.secretEnv.VAPID_PRIVATE_KEY }} + VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY }} + {{- end }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index fe35e4c05..6d3d05737 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -108,6 +108,15 @@ env: # Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of # restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into # Admin > Settings > API Keys. Defaults to https://restapi.amap.com. + # VAPID_PUBLIC_KEY: "" + # Web Push key pair, public half (base64url, 65 bytes). Leave both halves empty and TREK generates a pair on + # first start and keeps it in the database, so it travels with backups. To use your own pair, set this and + # secretEnv.VAPID_PRIVATE_KEY together, e.g. from `npx web-push generate-vapid-keys`. Changing the pair later + # means every device has to turn push on again. + # VAPID_SUBJECT: "" + # The contact push services see with every Web Push message: a mailto: address or an https:// URL. Defaults to + # APP_URL when it is an https:// address other than localhost, else https://github.com/liketrek/TREK. No email + # address is sent unless you set one here. # Secret environment variables stored in a Kubernetes Secret. @@ -147,6 +156,11 @@ secretEnv: # with signing enabled, because such a key rejects every unsigned request. Leave empty # otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE. AMAP_API_SECRET: "" + # Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with + # env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns + # push off with an error in the log until it is fixed, and every device stays registered. + # Leave both empty to let TREK keep a generated pair in its database. + VAPID_PRIVATE_KEY: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades generateEncryptionKey: false diff --git a/client/public/sw-push.js b/client/public/sw-push.js new file mode 100644 index 000000000..7d5bd0de0 --- /dev/null +++ b/client/public/sw-push.js @@ -0,0 +1,148 @@ +/* + * Web Push handlers for the generated service worker. + * + * vite-plugin-pwa builds sw.js with Workbox (generateSW), and Workbox pulls + * this file in through `workbox.importScripts` in vite.config.js, ahead of its + * own precaching. A CLASSIC, dependency-free script on purpose, in the style of + * theme-boot.js: importScripts cannot load a module, and switching the whole + * worker to injectManifest to get TypeScript here would mean rewriting every + * runtime caching rule by hand. The behaviour is pinned by + * tests/unit/pwa/swPush.test.ts, which runs this file against a fake worker. + * + * What arrives is the shared pushPayloadSchema: { title, body, url?, tag? }, + * rendered on the server in the recipient's language, because a worker has no + * translations of its own. Only chat messages carry a tag, so one trip's chat + * collapses into one notification; everything else stands on its own. + */ +(function () { + 'use strict'; + + var FALLBACK_TITLE = 'TREK'; + var ICON = '/icons/icon-192x192.png'; + var SUBSCRIPTIONS_URL = '/api/notifications/push/subscriptions'; + + /** The payload as an object; a push that is not JSON still shows, with its text as the body. */ + function readPayload(data) { + if (!data) return {}; + try { + var parsed = data.json(); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (e) { + try { + return { body: data.text() }; + } catch (e2) { + return {}; + } + } + } + + function text(value) { + return typeof value === 'string' ? value : ''; + } + + /** + * A path on this origin, or '/'. The url comes from the server, but a click + * must never be able to open another site in the installed app's window. + */ + function sameOriginPath(value) { + if (typeof value !== 'string' || !value) return '/'; + try { + var resolved = new URL(value, self.location.origin); + if (resolved.origin !== self.location.origin) return '/'; + return resolved.pathname + resolved.search + resolved.hash; + } catch (e) { + return '/'; + } + } + + self.addEventListener('push', function (event) { + var payload = readPayload(event.data); + var options = { + body: text(payload.body), + icon: ICON, + data: { url: sameOriginPath(payload.url) }, + }; + // A tag makes this notification replace an earlier one with the same tag + // (the server only sets one for chat, per trip). renotify goes with it, or + // the replacement would arrive without sound or vibration. + if (text(payload.tag)) { + options.tag = payload.tag; + options.renotify = true; + } + // Always show something. Chrome requires a visible notification for every + // push (userVisibleOnly), and WebKit revokes the subscription after a few + // silent ones, even while a TREK window has the focus. + event.waitUntil(self.registration.showNotification(text(payload.title) || FALLBACK_TITLE, options)); + }); + + /** Focus a TREK window and take it to `path`, or open one there when none is left. */ + function openPath(path) { + var target = self.location.origin + path; + return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (windows) { + var client = windows[0]; + for (var i = 0; i < windows.length; i++) { + if (windows[i].focused) client = windows[i]; + } + if (!client) return self.clients.openWindow(target); + return client + .focus() + .then(function (focused) { + // navigate() is only allowed on a window this worker controls. + var win = focused || client; + return typeof win.navigate === 'function' ? win.navigate(target) : win; + }) + .catch(function () { + return self.clients.openWindow(target); + }); + }); + } + + self.addEventListener('notificationclick', function (event) { + event.notification.close(); + var data = event.notification.data || {}; + event.waitUntil(openPath(sameOriginPath(data.url))); + }); + + /** + * The browser replaced or dropped the subscription (Firefox does this now and + * then). Subscribe again with the same server key and tell the server. The + * same-origin fetch carries the session cookie; when the session is gone this + * fails, and the re-sync after the next sign-in is the backstop. + */ + function resubscribe(event) { + var old = event.oldSubscription; + var key = old && old.options ? old.options.applicationServerKey : null; + var next = event.newSubscription + ? Promise.resolve(event.newSubscription) + : key + ? self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }) + : Promise.resolve(null); + return next + .then(function (subscription) { + if (!subscription) return undefined; + return fetch(SUBSCRIPTIONS_URL, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subscription: subscription.toJSON() }), + }).then(function (response) { + // The old endpoint is dead either way; forgetting it only tidies the + // server's device list, and only once the new one is safely stored. + if (!response.ok || !old || old.endpoint === subscription.endpoint) return undefined; + return fetch(SUBSCRIPTIONS_URL, { + method: 'DELETE', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: old.endpoint }), + }); + }); + }) + .catch(function (err) { + console.warn('[push] could not renew the subscription', err); + }); + } + + self.addEventListener('pushsubscriptionchange', function (event) { + event.waitUntil(resubscribe(event)); + }); +})(); diff --git a/client/src/api/push.test.ts b/client/src/api/push.test.ts new file mode 100644 index 000000000..811183000 --- /dev/null +++ b/client/src/api/push.test.ts @@ -0,0 +1,76 @@ +/** + * FE-PUSH-API-001 to FE-PUSH-API-005: the Web Push device endpoints. + * + * One line per endpoint and no logic, which is why a slip here is quiet: a + * wrong path or a body under the wrong key does not throw, the server answers + * 400 or 404 and push simply never arrives. So each call is driven through MSW + * with its verb, path and body pinned, and a failure has to reach the caller + * intact, because `webPush.ts` decides from it whether to roll a subscription + * back. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { http, HttpResponse, type JsonBodyType } from 'msw' +import type { PushSubscriptionInput } from '@trek/shared' +import { server } from '../../tests/helpers/msw/server' +import { pushApi } from './push' + +const BASE = '/api/notifications/push' + +let seen: { method: string; url: string; body: unknown } | null = null + +function record(data: T) { + return async ({ request }: { request: Request }) => { + const text = await request.text() + seen = { method: request.method, url: new URL(request.url).pathname, body: text ? JSON.parse(text) : undefined } + return HttpResponse.json(data) + } +} + +const subscription: PushSubscriptionInput = { + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + expirationTime: null, + keys: { p256dh: 'BPk', auth: 'aGk' }, +} + +beforeEach(() => { + seen = null +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('pushApi', () => { + it('FE-PUSH-API-001: reads the public key', async () => { + server.use(http.get(`${BASE}/public-key`, record({ publicKey: 'BAECAwQ' }))) + await expect(pushApi.getPublicKey()).resolves.toEqual({ publicKey: 'BAECAwQ' }) + expect(seen).toMatchObject({ method: 'GET', url: `${BASE}/public-key` }) + }) + + it('FE-PUSH-API-002: registers the subscription under `subscription`', async () => { + server.use(http.post(`${BASE}/subscriptions`, record({ success: true, devices: 2 }))) + await expect(pushApi.subscribe(subscription)).resolves.toEqual({ success: true, devices: 2 }) + expect(seen).toEqual({ method: 'POST', url: `${BASE}/subscriptions`, body: { subscription } }) + }) + + it('FE-PUSH-API-003: forgets a device by its endpoint, in a DELETE body', async () => { + server.use(http.delete(`${BASE}/subscriptions`, record({ success: true }))) + await expect(pushApi.unsubscribe(subscription.endpoint)).resolves.toEqual({ success: true }) + expect(seen).toEqual({ method: 'DELETE', url: `${BASE}/subscriptions`, body: { endpoint: subscription.endpoint } }) + }) + + it('FE-PUSH-API-004: a refusal reaches the caller instead of passing as success', async () => { + server.use(http.post(`${BASE}/subscriptions`, () => HttpResponse.json({ error: 'Unsupported push service' }, { status: 400 }))) + await expect(pushApi.subscribe(subscription)).rejects.toMatchObject({ response: { status: 400 } }) + }) + + it('FE-PUSH-API-005: the DELETE answer is checked against the shared contract, like the subscribe answer', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + server.use(http.delete(`${BASE}/subscriptions`, record({ success: false }))) + await expect(pushApi.unsubscribe(subscription.endpoint)).resolves.toEqual({ success: false }) + expect(warn).toHaveBeenCalledWith( + '[api] push.unsubscribe: response did not match the @trek/shared schema', + expect.anything(), + ) + }) +}) diff --git a/client/src/api/push.ts b/client/src/api/push.ts new file mode 100644 index 000000000..d4fbb4f51 --- /dev/null +++ b/client/src/api/push.ts @@ -0,0 +1,49 @@ +import apiClient, { parseInDev } from './client' +import { + pushPublicKeyResultSchema, + pushSubscribeResultSchema, + pushUnsubscribeResultSchema, + type PushPublicKeyResult, + type PushSubscribeRequest, + type PushSubscribeResult, + type PushSubscriptionInput, + type PushUnsubscribeRequest, + type PushUnsubscribeResult, +} from '@trek/shared' + +/** + * The Web Push device endpoints, in their own file rather than in the + * `client.ts` god module. Types come from the shared contracts, so a server + * change the client has not caught up with is a typecheck failure rather than + * a runtime surprise. + * + * These calls are online only by nature: a subscription is a device talking to + * the server and to the browser's push service at the same moment, so there is + * nothing to queue or to cache. `push/webPush.ts` is the only caller. The one + * DELETE that does not come through here is the logout teardown's: it is a + * plain fetch in webPush.ts, so a session that already ran out cannot set off + * apiClient's 401 redirect in the middle of a logout. + */ +export const pushApi = { + /** The VAPID public key a browser subscribes with. */ + getPublicKey: (): Promise => + apiClient + .get('/notifications/push/public-key') + .then(r => parseInDev(pushPublicKeyResultSchema, r.data, 'push.publicKey')), + + /** Registers this device. Repeating it for the same endpoint only refreshes the row. */ + subscribe: (subscription: PushSubscriptionInput): Promise => { + const body: PushSubscribeRequest = { subscription } + return apiClient + .post('/notifications/push/subscriptions', body) + .then(r => parseInDev(pushSubscribeResultSchema, r.data, 'push.subscribe')) + }, + + /** Forgets one device, by the endpoint its subscription delivers to. */ + unsubscribe: (endpoint: string): Promise => { + const body: PushUnsubscribeRequest = { endpoint } + return apiClient + .delete('/notifications/push/subscriptions', { data: body }) + .then(r => parseInDev(pushUnsubscribeResultSchema, r.data, 'push.unsubscribe')) + }, +} diff --git a/client/src/components/Admin/useNotificationChannels.test.ts b/client/src/components/Admin/useNotificationChannels.test.ts new file mode 100644 index 000000000..366d9009f --- /dev/null +++ b/client/src/components/Admin/useNotificationChannels.test.ts @@ -0,0 +1,103 @@ +/** + * FE-ADMCHAN-001 to FE-ADMCHAN-008: the admin's channel switches, shared by + * the desktop Notifications tab and the phone section. + * + * Every switch rewrites one comma-separated app setting. The two things that + * must never happen are the ones a rebuild-from-booleans used to do: drop an id + * this build does not know (a plugin channel, or a newer built-in), and leave + * the screen claiming a state the server refused to store. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useState } from 'react' +import { + BUILTIN_CHANNEL_ORDER, + parseChannelList, + serializeChannelList, + SWITCH_ONLY_CHANNELS, + toggleChannelList, + useNotificationChannels, +} from './useNotificationChannels' + +const updateAppSettings = vi.hoisted(() => vi.fn()) +vi.mock('../../api/client', () => ({ authApi: { updateAppSettings } })) + +const t = (key: string) => (key === 'common.error' ? 'Error' : key) + +function mount(initial: Record) { + const toast = { error: vi.fn() } + const hook = renderHook(() => { + const [smtpValues, setSmtpValues] = useState(initial) + return { smtpValues, channels: useNotificationChannels({ smtpValues, setSmtpValues, toast }, t) } + }) + return { hook, toast } +} + +beforeEach(() => { + updateAppSettings.mockReset() + updateAppSettings.mockResolvedValue({}) +}) + +describe('the stored list', () => { + it('FE-ADMCHAN-001: reads the plural setting, the legacy singular one, and none', () => { + expect(parseChannelList({ notification_channels: 'email, push ,plugin-gotify' })).toEqual(['email', 'push', 'plugin-gotify']) + expect(parseChannelList({ notification_channel: 'webhook' })).toEqual(['webhook']) + expect(parseChannelList({ notification_channels: 'none' })).toEqual([]) + expect(parseChannelList({})).toEqual([]) + expect(parseChannelList({ notification_channels: 'email,' })).toEqual(['email']) + }) + + it('FE-ADMCHAN-002: writes built-ins in their fixed order and keeps every other id', () => { + expect(serializeChannelList(['plugin-gotify', 'push', 'email'])).toBe('email,push,plugin-gotify') + expect(serializeChannelList([])).toBe('none') + }) + + it('FE-ADMCHAN-003: flips exactly one id', () => { + expect(toggleChannelList(['email', 'plugin-gotify'], 'push')).toBe('email,push,plugin-gotify') + expect(toggleChannelList(['email', 'push'], 'push')).toBe('email') + expect(toggleChannelList(['push'], 'push')).toBe('none') + }) + + it('FE-ADMCHAN-004: Web Push is a built-in with its own switch card', () => { + expect(BUILTIN_CHANNEL_ORDER).toEqual(['email', 'webhook', 'ntfy', 'push']) + expect(SWITCH_ONLY_CHANNELS.map(c => c.id)).toEqual(['webhook', 'ntfy', 'push']) + expect(SWITCH_ONLY_CHANNELS[2]).toEqual({ + id: 'push', + titleKey: 'admin.notifications.webPushPanel.title', + hintKey: 'admin.notifications.webPushPanel.hint', + }) + }) +}) + +describe('useNotificationChannels', () => { + it('FE-ADMCHAN-005: answers which channels are on', () => { + const { hook } = mount({ notification_channels: 'email,push' }) + expect(hook.result.current.channels.isActive('push')).toBe(true) + expect(hook.result.current.channels.isActive('ntfy')).toBe(false) + }) + + it('FE-ADMCHAN-006: switching one on saves the whole list and shows it at once', async () => { + const { hook, toast } = mount({ notification_channels: 'email,plugin-gotify' }) + await act(() => hook.result.current.channels.toggle('push')) + expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push,plugin-gotify' }) + expect(hook.result.current.smtpValues.notification_channels).toBe('email,push,plugin-gotify') + expect(toast.error).not.toHaveBeenCalled() + }) + + it('FE-ADMCHAN-007: a failed save puts the previous list back and says so', async () => { + updateAppSettings.mockRejectedValueOnce(new Error('500')) + const { hook, toast } = mount({ notification_channels: 'email' }) + await act(() => hook.result.current.channels.toggle('push')) + expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push' }) + expect(hook.result.current.smtpValues.notification_channels).toBe('email') + expect(hook.result.current.channels.isActive('push')).toBe(false) + expect(toast.error).toHaveBeenCalledWith('Error') + }) + + it('FE-ADMCHAN-008: the rollback writes the normalised form, never an id less', async () => { + updateAppSettings.mockRejectedValueOnce(new Error('500')) + const { hook } = mount({ notification_channels: 'plugin-gotify, email' }) + await act(() => hook.result.current.channels.toggle('webhook')) + expect(hook.result.current.smtpValues.notification_channels).toBe('email,plugin-gotify') + }) +}) diff --git a/client/src/components/Admin/useNotificationChannels.ts b/client/src/components/Admin/useNotificationChannels.ts new file mode 100644 index 000000000..ea5e8efa8 --- /dev/null +++ b/client/src/components/Admin/useNotificationChannels.ts @@ -0,0 +1,88 @@ +import type { Dispatch, SetStateAction } from 'react' +import { WEB_PUSH_CHANNEL_ID } from '@trek/shared' +import { authApi } from '../../api/client' +import type { TranslationFn } from '../../types' + +/** + * The admin's channel switches, shared by the desktop Notifications tab and its + * phone twin. Both used to carry their own copy of a three-boolean + * `setChannels`, and a fourth channel would have meant a fourth argument in + * two places; SonarCloud's duplication budget has no room for that either. + * + * All switches write one app setting, `notification_channels`, a comma + * separated list of channel ids. Ids this build does not know (a plugin + * channel, or one a newer server added) must survive a toggle untouched, so + * the list is edited, never rebuilt from the switches. + */ + +/** The built-in channels, in the order they are written to the list. Other ids follow as stored. */ +export const BUILTIN_CHANNEL_ORDER: readonly string[] = ['email', 'webhook', 'ntfy', WEB_PUSH_CHANNEL_ID] + +/** The channels whose admin card is only a title, a hint and the switch (email carries the SMTP form). */ +export const SWITCH_ONLY_CHANNELS: ReadonlyArray<{ id: string; titleKey: string; hintKey: string }> = [ + { id: 'webhook', titleKey: 'admin.notifications.webhookPanel.title', hintKey: 'admin.webhook.hint' }, + { id: 'ntfy', titleKey: 'admin.notifications.ntfy', hintKey: 'admin.ntfy.hint' }, + { + id: WEB_PUSH_CHANNEL_ID, + titleKey: 'admin.notifications.webPushPanel.title', + hintKey: 'admin.notifications.webPushPanel.hint', + }, +] + +/** + * The active ids, from `notification_channels` or, on installs that predate + * it, the singular `notification_channel`. 'none' is the stored empty list. + */ +export function parseChannelList(values: Record): string[] { + const raw = values.notification_channels ?? values.notification_channel ?? 'none' + if (raw === 'none') return [] + return raw + .split(',') + .map(id => id.trim()) + .filter(Boolean) +} + +/** The stored form: built-ins in their fixed order, then every other id as it was, or 'none'. */ +export function serializeChannelList(active: readonly string[]): string { + const builtins = BUILTIN_CHANNEL_ORDER.filter(id => active.includes(id)) + const others = active.filter(id => !BUILTIN_CHANNEL_ORDER.includes(id)) + return [...builtins, ...others].join(',') || 'none' +} + +/** The stored list with `id` switched the other way. */ +export function toggleChannelList(active: readonly string[], id: string): string { + return serializeChannelList(active.includes(id) ? active.filter(c => c !== id) : [...active, id]) +} + +interface ChannelSettingsHost { + smtpValues: Record + setSmtpValues: Dispatch>> + toast: { error: (message: string) => void } +} + +export interface NotificationChannelSwitches { + isActive: (id: string) => boolean + /** Flips one channel and saves the list; puts the old list back and toasts when the save fails. */ + toggle: (id: string) => Promise +} + +export function useNotificationChannels( + { smtpValues, setSmtpValues, toast }: ChannelSettingsHost, + t: TranslationFn, +): NotificationChannelSwitches { + const active = parseChannelList(smtpValues) + + const toggle = async (id: string) => { + const next = toggleChannelList(active, id) + setSmtpValues(prev => ({ ...prev, notification_channels: next })) + try { + await authApi.updateAppSettings({ notification_channels: next }) + } catch { + const reverted = serializeChannelList(active) + setSmtpValues(prev => ({ ...prev, notification_channels: reverted })) + toast.error(t('common.error')) + } + } + + return { isActive: id => active.includes(id), toggle } +} diff --git a/client/src/components/Settings/NotificationsTab.test.tsx b/client/src/components/Settings/NotificationsTab.test.tsx index 78720dc35..89b18309e 100644 --- a/client/src/components/Settings/NotificationsTab.test.tsx +++ b/client/src/components/Settings/NotificationsTab.test.tsx @@ -1185,3 +1185,47 @@ describe('NotificationsTab — webhook and channel-test failures', () => { expect(await screen.findByText('Test failed.')).toBeInTheDocument(); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Web Push (#894) +// ───────────────────────────────────────────────────────────────────────────── +// +// The Push column comes from the server like every other; what this tab adds is +// the per-device card, shown only while the admin has the channel switched on. +// jsdom has no push APIs, so the real hook reads this "browser" as unable to +// receive push, which is exactly what the card must then say. + +const pushMatrix = (active: boolean) => ({ + preferences: { trip_invite: { inapp: true, push: true } }, + channels: [ + { id: 'inapp', source: 'builtin', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true }, + { id: 'push', source: 'builtin', labelKey: 'settings.notificationPreferences.push', active, configured: false }, + ], + event_types: ['trip_invite'], + implemented_combos: { trip_invite: ['inapp', 'push'] }, +}); + +describe('NotificationsTab: Web Push', () => { + beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); + }); + + it('FE-COMP-NOTIFICATIONS-PUSH-001: an active push channel gets its column and the device card', async () => { + mockMatrix(pushMatrix(true)); + render(); + + expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument(); + expect(screen.getByText('Push')).toBeInTheDocument(); + expect(await screen.findByRole('status')).toHaveTextContent(/push/i); + }); + + it('FE-COMP-NOTIFICATIONS-PUSH-002: no card and no column while the admin has push off', async () => { + mockMatrix(pushMatrix(false)); + render(); + + await screen.findByText(/in-app/i); + expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument(); + expect(screen.queryByText('Push')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx index 88c84f953..08315588e 100644 --- a/client/src/components/Settings/NotificationsTab.tsx +++ b/client/src/components/Settings/NotificationsTab.tsx @@ -1,11 +1,13 @@ import React, { useState, useEffect } from 'react' import { Link } from 'react-router' import { Lock } from 'lucide-react' +import { WEB_PUSH_CHANNEL_ID } from '@trek/shared' import { useTranslation } from '../../i18n' import { notificationsApi, settingsApi } from '../../api/client' import { useToast } from '../shared/Toast' import ToggleSwitch from './ToggleSwitch' import Section from './Section' +import WebPushCard from './WebPushCard' interface ChannelDescriptor { id: string @@ -323,6 +325,9 @@ export default function NotificationsTab(): React.ReactElement { )} + {/* Per device rather than per account: the column below picks the events, + this card decides whether this browser is one of the places they go. */} + {hasChannel(WEB_PUSH_CHANNEL_ID) && } {pluginChannels.map(ch => (
- {/* Webhook */} - - setChannels(emailActive, !webhookActive, ntfyActive)} - /> - } - /> - - - {/* Ntfy */} - - setChannels(emailActive, webhookActive, !ntfyActive)} - /> - } - /> - + {/* Webhook, Ntfy and Web Push: a title, a hint and the switch each */} + {SWITCH_ONLY_CHANNELS.map((ch) => ( + + channels.toggle(ch.id)} + /> + } + /> + + ))} {/* In-App (always on) */} diff --git a/client/src/mobile/screens/settings/MSettingsNotifications.tsx b/client/src/mobile/screens/settings/MSettingsNotifications.tsx index 2cc22abb3..e5aeaf656 100644 --- a/client/src/mobile/screens/settings/MSettingsNotifications.tsx +++ b/client/src/mobile/screens/settings/MSettingsNotifications.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router' import { Bell, Link2, Send } from 'lucide-react' +import { WEB_PUSH_CHANNEL_ID } from '@trek/shared' import { useTranslation } from '../../../i18n' import { notificationsApi, settingsApi } from '../../../api/client' import { useToast } from '../../../components/shared/Toast' import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import MChip from '../../components/MChip' +import MWebPushCard from './MWebPushCard' interface ChannelDescriptor { id: string @@ -52,8 +54,8 @@ const MASKED = '••••••••' /** * "Notifications" section — NotificationsTab parity: webhook + ntfy channel - * credentials, plugin channels and the event/channel preference matrix, - * rendered as chip rows instead of the desktop grid. + * credentials, push on this device, plugin channels and the event/channel + * preference matrix, rendered as chip rows instead of the desktop grid. */ export default function MSettingsNotifications() { const { t } = useTranslation() @@ -282,6 +284,8 @@ export default function MSettingsNotifications() { )} + {hasChannel(WEB_PUSH_CHANNEL_ID) && } + {pluginChannels.map((ch) => (
{channelLabel(ch, t)} diff --git a/client/src/mobile/screens/settings/MWebPushCard.tsx b/client/src/mobile/screens/settings/MWebPushCard.tsx new file mode 100644 index 000000000..67729be6b --- /dev/null +++ b/client/src/mobile/screens/settings/MWebPushCard.tsx @@ -0,0 +1,38 @@ +import { BellRing, BellOff, Send } from 'lucide-react' +import { useTranslation } from '../../../i18n' +import { useWebPush } from '../../../hooks/useWebPush' +import { MSetButton, MSetEyebrow, MSetHint } from './MSettingsUi' + +/** + * Phone twin of the desktop `WebPushCard`: markup only, over the same + * `useWebPush` hook. MSettingsNotifications renders it while the admin has the + * push channel switched on. + */ +export default function MWebPushCard() { + const { t } = useTranslation() + const push = useWebPush() + + return ( +
+ {t('settings.webPush.title')} + {t('settings.webPush.hint')} + {push.stateText && ( +

+ {push.stateText} +

+ )} + {push.canSwitch && ( +
+ + {push.on ? : } + {push.switchLabel} + + + + {t('settings.notificationPreferences.sendTest')} + +
+ )} +
+ ) +} diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx index 3a02b2cba..0ee684313 100644 --- a/client/src/pages/AdminPage.test.tsx +++ b/client/src/pages/AdminPage.test.tsx @@ -1441,7 +1441,8 @@ describe('AdminPage', () => { // Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook' const webhookHeading = screen.getByRole('heading', { name: /^webhook$/i }); - const webhookCard = webhookHeading.closest('.bg-white'); + // The channel switch cards are drawn with theme tokens, so the card is found by its shape. + const webhookCard = webhookHeading.closest('.rounded-xl'); // Find the toggle button in webhook card const webhookToggle = within(webhookCard!).getByRole('button'); fireEvent.click(webhookToggle); diff --git a/client/src/pages/admin/AdminNotificationsTab.test.tsx b/client/src/pages/admin/AdminNotificationsTab.test.tsx index 929fc87db..7948cb365 100644 --- a/client/src/pages/admin/AdminNotificationsTab.test.tsx +++ b/client/src/pages/admin/AdminNotificationsTab.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMNOT-001 to FE-ADMNOT-043 +// FE-ADMNOT-001 to FE-ADMNOT-046 import { http, HttpResponse } from 'msw'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -571,4 +571,47 @@ describe('AdminNotificationsTab', () => { expect(screen.queryByRole('heading', { name: 'Admin Webhook' })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Admin Ntfy' })).not.toBeInTheDocument(); }); + + it('FE-ADMNOT-044: Web Push has its own card after Ntfy, before In-App', () => { + renderTab({ smtpValues: { notification_channels: 'email' } }); + + const headings = screen.getAllByRole('heading').map(h => h.textContent); + expect(headings.indexOf('Web Push')).toBe(headings.indexOf('Ntfy') + 1); + expect(headings.indexOf('In-App')).toBe(headings.indexOf('Web Push') + 1); + expect(within(card('Web Push')).getByText(/receive notifications on their phones/)).toBeInTheDocument(); + expect(within(card('Web Push')).getByRole('button', { name: 'Web Push' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('FE-ADMNOT-045: switching Web Push on adds push to the list and keeps the rest', async () => { + let body: Record | null = null; + server.use( + http.put('/api/auth/app-settings', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({}); + }) + ); + const admin = renderTab({ smtpValues: { notification_channels: 'email,plugin-gotify' } }); + + fireEvent.click(within(card('Web Push')).getByRole('button', { name: 'Web Push' })); + + await waitFor(() => expect(body).toEqual({ notification_channels: 'email,push,plugin-gotify' })); + expect(replaySmtp(admin.setSmtpValues, 0).notification_channels).toBe('email,push,plugin-gotify'); + }); + + it('FE-ADMNOT-046: an active push channel shows its switch on, and switching it off keeps email', async () => { + let body: Record | null = null; + server.use( + http.put('/api/auth/app-settings', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({}); + }) + ); + renderTab({ smtpValues: { notification_channels: 'email,push' } }); + + const toggle = within(card('Web Push')).getByRole('button', { name: 'Web Push' }); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(toggle); + + await waitFor(() => expect(body).toEqual({ notification_channels: 'email' })); + }); }); diff --git a/client/src/pages/admin/AdminNotificationsTab.tsx b/client/src/pages/admin/AdminNotificationsTab.tsx index 7f792d64c..ed8a73a2d 100644 --- a/client/src/pages/admin/AdminNotificationsTab.tsx +++ b/client/src/pages/admin/AdminNotificationsTab.tsx @@ -4,43 +4,39 @@ import { Save } from 'lucide-react' import type { TranslationFn } from '../../types' import type { useAdmin } from './useAdmin' import AdminNotificationsPanel from './AdminNotificationsPanel' +import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/Admin/useNotificationChannels' interface AdminNotificationsTabProps { admin: ReturnType t: TranslationFn } -// "Notifications" admin tab: email/webhook/ntfy channel toggles, SMTP credentials, -// trip reminders, admin webhook + ntfy targets, and the per-event preference matrix. -// Derives channel state from smtpValues exactly as the original inline IIFE did. +/** The on/off switch at the right of a channel card. */ +function ChannelSwitch({ on, label, onToggle }: { on: boolean; label: string; onToggle: () => void }): React.ReactElement { + return ( + + ) +} + +// "Notifications" admin tab: email/webhook/ntfy/web push channel toggles, SMTP +// credentials, trip reminders, admin webhook + ntfy targets, and the per-event +// preference matrix. The channel switches come from useNotificationChannels, +// which the phone section shares. export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTabProps): React.ReactElement { const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled, managed } = admin - // Derive active channels from smtpValues.notification_channels (plural) - // with fallback to notification_channel (singular) for existing installs - const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none' - const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim()) - const emailActive = activeChans.includes('email') - const webhookActive = activeChans.includes('webhook') - const ntfyActive = activeChans.includes('ntfy') + const channels = useNotificationChannels(admin, t) + const emailActive = channels.isActive('email') const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false' - const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => { - // Preserve any id this toggle doesn't know about instead of rebuilding the CSV from - // just these three booleans — that used to silently DROP anything else stored here. - const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy') - const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none' - setSmtpValues(prev => ({ ...prev, notification_channels: chans })) - try { - await authApi.updateAppSettings({ notification_channels: chans }) - } catch { - // Revert state on failure - const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') || 'none' - setSmtpValues(prev => ({ ...prev, notification_channels: reverted })) - toast.error(t('common.error')) - } - } - const smtpConfigured = !!(smtpValues.smtp_host?.trim()) const saveNotifications = async () => { // Saves credentials only — channel activation is auto-saved by the toggle @@ -64,7 +60,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa pairs cards row by row and leaves a hole under the shorter one, and the SMTP card is taller than all the toggle rows together. Grouped by audience, not by height — the channels a user can receive on the left, everything the operator - sends or receives themselves on the right. The three channel switches stay + sends or receives themselves on the right. The channel switches stay together because they all write the same notification_channels list. On a managed install the two admin-target cards are gone and the right column is the matrix alone — still a column, so the grid keeps working. */} @@ -81,13 +77,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa

{t('admin.notifications.emailPanel.title')}

{t('admin.smtp.hint')}

- + channels.toggle('email')} />
{smtpLoaded && [ @@ -152,39 +142,18 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
)} - {/* Webhook Panel */} -
-
-
-

{t('admin.notifications.webhookPanel.title')}

-

{t('admin.webhook.hint')}

+ {/* Webhook, Ntfy and Web Push: a title, a hint and the switch each */} + {SWITCH_ONLY_CHANNELS.map(ch => ( +
+
+
+

{t(ch.titleKey)}

+

{t(ch.hintKey)}

+
+ channels.toggle(ch.id)} />
-
-
- - {/* Ntfy Panel */} -
-
-
-

{t('admin.notifications.ntfy')}

-

{t('admin.ntfy.hint') || 'Allow users to configure their own ntfy topics for push notifications.'}

-
- -
-
+ ))} {/* In-App Panel */}
diff --git a/client/src/push/webPush.test.ts b/client/src/push/webPush.test.ts new file mode 100644 index 000000000..fc2c41385 --- /dev/null +++ b/client/src/push/webPush.test.ts @@ -0,0 +1,535 @@ +/** + * FE-PUSH-001 to FE-PUSH-044: this device's Web Push, against a fake browser. + * + * jsdom has no service worker, no PushManager and no Notification, so every + * case installs the three by hand. What is worth pinning here: + * + * 1. **Support is a reason, not a yes/no.** An insecure origin, an iPhone in + * a Safari tab and a browser without push each need a different sentence, + * because each has a different fix. + * 2. **The gesture.** WebKit honours the permission prompt only inside the + * click, so `enableWebPush` must ask before its first await. + * 3. **The browser is the record.** A subscription the server refused is + * rolled back, switching off unsubscribes even when the server cannot be + * told, and logout forgets the device before the cookie goes, bounded so it + * can never hang, and with a plain fetch so a 401 cannot redirect. + * 4. **The re-sync after sign-in** repeats what the device holds, replaces a + * subscription made with an old server key, and never calls update(). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const api = vi.hoisted(() => ({ + getPublicKey: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), +})) +vi.mock('../api/push', () => ({ pushApi: api })) + +const network = vi.hoisted(() => ({ offline: false })) +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline })) + +import { + base64UrlToUint8Array, + currentPushSubscription, + disableWebPush, + enableWebPush, + forgetPushDeviceOnLogout, + getPushPermission, + getWebPushSupport, + loadPushPublicKey, + readWebPushDeviceState, + resyncPushSubscription, + toSubscriptionInput, + waitForPushRegistration, + WebPushError, +} from './webPush' + +/** A P-256 point is 65 bytes; these tests only need two keys that differ. */ +const SERVER_KEY = 'BAECAwQ' +const OTHER_KEY = 'BAUGBwg' + +class FakeSubscription { + readonly options: { applicationServerKey: ArrayBuffer | null; userVisibleOnly: boolean } + unsubscribe = vi.fn(async () => true) + + constructor( + readonly endpoint: string, + key: Uint8Array | null, + ) { + this.options = { applicationServerKey: key ? new Uint8Array(key).buffer : null, userVisibleOnly: true } + } + + toJSON() { + return { endpoint: this.endpoint, expirationTime: null, keys: { p256dh: 'p256-' + this.endpoint, auth: 'auth' } } + } +} + +class FakePushManager { + current: FakeSubscription | null = null + created = 0 + subscribe = vi.fn(async (options: { userVisibleOnly?: boolean; applicationServerKey: Uint8Array }) => { + this.created++ + this.current = new FakeSubscription(`https://push.example/sub-${this.created}`, options.applicationServerKey) + return this.current + }) + getSubscription = vi.fn(async () => this.current) +} + +interface Browser { + pushManager: FakePushManager + registration: { pushManager: FakePushManager; update: ReturnType } + container: { getRegistration: ReturnType; ready: Promise } + notification: { permission: NotificationPermission; requestPermission: ReturnType } +} + +function installBrowser(opts: { permission?: NotificationPermission; answer?: NotificationPermission } = {}): Browser { + const pushManager = new FakePushManager() + const registration = { pushManager, update: vi.fn() } + const container = { getRegistration: vi.fn(async () => registration), ready: Promise.resolve(registration) } + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) + const notification = { + permission: opts.permission ?? 'default', + requestPermission: vi.fn(async () => { + notification.permission = opts.answer ?? 'granted' + return notification.permission + }), + } + vi.stubGlobal('Notification', notification) + vi.stubGlobal('PushManager', function PushManager() {}) + vi.stubGlobal('isSecureContext', true) + return { pushManager, registration, container, notification } +} + +function subscribed(browser: Browser, key = SERVER_KEY, endpoint = 'https://push.example/existing') { + browser.pushManager.current = new FakeSubscription(endpoint, base64UrlToUint8Array(key)) + return browser.pushManager.current +} + +/** Own properties shadow jsdom's prototype getters; afterEach deletes them again. */ +function setUserAgent(ua: string, touchPoints = 0) { + Object.defineProperty(navigator, 'userAgent', { configurable: true, get: () => ua }) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, get: () => touchPoints }) +} + +const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile Safari/604.1' +const IPAD_DESKTOP = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15' +const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36' + +beforeEach(() => { + vi.clearAllMocks() + network.offline = false + api.getPublicKey.mockResolvedValue({ publicKey: SERVER_KEY }) + api.subscribe.mockResolvedValue({ success: true, devices: 1 }) + api.unsubscribe.mockResolvedValue({ success: true }) + setUserAgent(CHROME) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() + delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker + delete (navigator as unknown as { standalone?: unknown }).standalone + delete (navigator as unknown as { userAgent?: unknown }).userAgent + delete (navigator as unknown as { maxTouchPoints?: unknown }).maxTouchPoints +}) + +describe('getWebPushSupport', () => { + it('FE-PUSH-001: a secure browser with a worker, PushManager and Notification is supported', () => { + installBrowser() + expect(getWebPushSupport()).toEqual({ supported: true, reason: null }) + }) + + it('FE-PUSH-002: an insecure origin says so before anything else', () => { + installBrowser() + vi.stubGlobal('isSecureContext', false) + setUserAgent(IPHONE) + expect(getWebPushSupport()).toEqual({ supported: false, reason: 'insecure' }) + }) + + it('FE-PUSH-003: an iPhone in a Safari tab is told to add TREK to the Home Screen', () => { + vi.stubGlobal('isSecureContext', true) + setUserAgent(IPHONE, 5) + expect(getWebPushSupport()).toEqual({ supported: false, reason: 'ios-install' }) + }) + + it('FE-PUSH-004: an iPad asking for the desktop site counts as iOS too', () => { + installBrowser() + setUserAgent(IPAD_DESKTOP, 5) + expect(getWebPushSupport().reason).toBe('ios-install') + }) + + it('FE-PUSH-005: a real Mac (no touch screen) is not mistaken for an iPad', () => { + installBrowser() + setUserAgent(IPAD_DESKTOP, 0) + expect(getWebPushSupport().supported).toBe(true) + }) + + it('FE-PUSH-006: the installed iOS app is supported', () => { + installBrowser() + setUserAgent(IPHONE, 5) + Object.defineProperty(navigator, 'standalone', { configurable: true, value: true }) + expect(getWebPushSupport().supported).toBe(true) + }) + + it('FE-PUSH-007: the installed app on an iOS without push still reads as unsupported', () => { + vi.stubGlobal('isSecureContext', true) + setUserAgent(IPHONE, 5) + Object.defineProperty(navigator, 'standalone', { configurable: true, value: true }) + expect(getWebPushSupport()).toEqual({ supported: false, reason: 'unsupported' }) + }) + + it('FE-PUSH-008: a desktop browser without the APIs is unsupported', () => { + vi.stubGlobal('isSecureContext', true) + expect(getWebPushSupport()).toEqual({ supported: false, reason: 'unsupported' }) + }) +}) + +describe('small helpers', () => { + it('FE-PUSH-009: the permission falls back to default where Notification is missing', () => { + expect(getPushPermission()).toBe('default') + installBrowser({ permission: 'denied' }) + expect(getPushPermission()).toBe('denied') + }) + + it('FE-PUSH-010: base64url keys decode to their raw bytes, padding or not', () => { + expect(Array.from(base64UrlToUint8Array('AQID'))).toEqual([1, 2, 3]) + expect(Array.from(base64UrlToUint8Array('-_8'))).toEqual([251, 255]) + expect(Array.from(base64UrlToUint8Array('-_8='))).toEqual([251, 255]) + }) + + it('FE-PUSH-011: a subscription becomes the shared contract shape', () => { + const sub = new FakeSubscription('https://push.example/a', null) + expect(toSubscriptionInput(sub as unknown as PushSubscription)).toEqual({ + endpoint: 'https://push.example/a', + expirationTime: null, + keys: { p256dh: 'p256-https://push.example/a', auth: 'auth' }, + }) + }) + + it('FE-PUSH-012: a subscription without keys is refused rather than sent half', () => { + const broken = { toJSON: () => ({ endpoint: 'https://push.example/a', keys: {} }) } + expect(() => toSubscriptionInput(broken as unknown as PushSubscription)).toThrow(WebPushError) + }) + + it('FE-PUSH-013: the public key loads, and a failure is null rather than a rejection', async () => { + await expect(loadPushPublicKey()).resolves.toBe(SERVER_KEY) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + api.getPublicKey.mockRejectedValueOnce(new Error('offline')) + await expect(loadPushPublicKey()).resolves.toBeNull() + }) + + it('FE-PUSH-014: there is no subscription to read without the APIs or a registration', async () => { + await expect(currentPushSubscription()).resolves.toBeNull() + const browser = installBrowser() + browser.container.getRegistration.mockResolvedValueOnce(undefined) + await expect(currentPushSubscription()).resolves.toBeNull() + }) +}) + +describe('waitForPushRegistration', () => { + it('FE-PUSH-015: hands over the active registration', async () => { + const browser = installBrowser() + await expect(waitForPushRegistration()).resolves.toBe(browser.registration) + }) + + it('FE-PUSH-016: gives up after the timeout on a page that never gets a worker', async () => { + const browser = installBrowser() + browser.container.ready = new Promise(() => {}) + vi.useFakeTimers() + const waiting = waitForPushRegistration(1000) + await vi.advanceTimersByTimeAsync(1000) + await expect(waiting).resolves.toBeNull() + }) + + it('FE-PUSH-017: is null straight away where the APIs are missing', async () => { + await expect(waitForPushRegistration()).resolves.toBeNull() + }) +}) + +describe('readWebPushDeviceState', () => { + it('FE-PUSH-018: an unsupported browser reports its reason', async () => { + vi.stubGlobal('isSecureContext', false) + await expect(readWebPushDeviceState()).resolves.toBe('insecure') + }) + + it('FE-PUSH-019: a blocked permission wins over an old subscription', async () => { + const browser = installBrowser({ permission: 'denied' }) + subscribed(browser) + await expect(readWebPushDeviceState()).resolves.toBe('denied') + }) + + it('FE-PUSH-020: on with a subscription, off without', async () => { + const browser = installBrowser({ permission: 'granted' }) + await expect(readWebPushDeviceState()).resolves.toBe('off') + subscribed(browser) + await expect(readWebPushDeviceState()).resolves.toBe('on') + }) + + it('FE-PUSH-021: a registration that cannot be read counts as off, not as an error', async () => { + const browser = installBrowser({ permission: 'granted' }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + browser.container.getRegistration.mockRejectedValueOnce(new Error('site data blocked')) + await expect(readWebPushDeviceState()).resolves.toBe('off') + }) +}) + +describe('enableWebPush', () => { + it('FE-PUSH-022: asks for permission before its first await, so the gesture still counts', async () => { + const browser = installBrowser() + const done = enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration) + expect(browser.notification.requestPermission).toHaveBeenCalledTimes(1) + await done + }) + + it('FE-PUSH-023: subscribes visibly with the server key and registers the device', async () => { + const browser = installBrowser({ permission: 'granted' }) + await enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration) + + expect(browser.notification.requestPermission).not.toHaveBeenCalled() + const options = browser.pushManager.subscribe.mock.calls[0][0] + expect(options.userVisibleOnly).toBe(true) + expect(Array.from(options.applicationServerKey)).toEqual(Array.from(base64UrlToUint8Array(SERVER_KEY))) + expect(api.subscribe).toHaveBeenCalledWith(toSubscriptionInput(browser.pushManager.current as unknown as PushSubscription)) + }) + + it('FE-PUSH-024: waits for the worker when no registration was handed over', async () => { + const browser = installBrowser({ permission: 'granted' }) + await enableWebPush(SERVER_KEY) + expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1) + }) + + it('FE-PUSH-025: a block is reported as denied and nothing is subscribed', async () => { + const browser = installBrowser({ answer: 'denied' }) + await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'denied' }) + expect(browser.pushManager.subscribe).not.toHaveBeenCalled() + }) + + it('FE-PUSH-026: a prompt closed without an answer is dismissed, not a failure', async () => { + const browser = installBrowser({ answer: 'default' }) + await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'dismissed' }) + }) + + it('FE-PUSH-027: without a worker it fails instead of spinning', async () => { + const browser = installBrowser({ permission: 'granted' }) + browser.container.ready = new Promise(() => {}) + vi.useFakeTimers() + const done = enableWebPush(SERVER_KEY).catch(err => err) + await vi.advanceTimersByTimeAsync(10_000) + expect(await done).toMatchObject({ code: 'failed' }) + }) + + it('FE-PUSH-028: a subscription left over from an older server key is replaced', async () => { + const browser = installBrowser({ permission: 'granted' }) + const stale = subscribed(browser, OTHER_KEY, 'https://push.example/stale') + browser.pushManager.subscribe.mockRejectedValueOnce(new DOMException('key mismatch', 'InvalidStateError')) + + await enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration) + + expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/stale') + expect(stale.unsubscribe).toHaveBeenCalled() + expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(2) + expect(api.subscribe).toHaveBeenCalledTimes(1) + }) + + it('FE-PUSH-029: any other subscribe failure is final', async () => { + const browser = installBrowser({ permission: 'granted' }) + browser.pushManager.subscribe.mockRejectedValueOnce(new DOMException('push service down', 'AbortError')) + await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'failed' }) + expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1) + expect(api.subscribe).not.toHaveBeenCalled() + }) + + it('FE-PUSH-030: a subscription the server refused is rolled back in the browser', async () => { + const browser = installBrowser({ permission: 'granted' }) + api.subscribe.mockRejectedValueOnce(new Error('400')) + await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'failed' }) + expect(browser.pushManager.current?.unsubscribe).toHaveBeenCalled() + }) +}) + +describe('disableWebPush', () => { + it('FE-PUSH-031: forgets the device on the server, then unsubscribes the browser', async () => { + const browser = installBrowser({ permission: 'granted' }) + const sub = subscribed(browser) + const order: string[] = [] + api.unsubscribe.mockImplementationOnce(async () => { + order.push('server') + return { success: true } + }) + sub.unsubscribe.mockImplementationOnce(async () => { + order.push('browser') + return true + }) + + await disableWebPush() + + expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/existing') + expect(order).toEqual(['server', 'browser']) + }) + + it('FE-PUSH-032: still unsubscribes when the server cannot be told', async () => { + const browser = installBrowser({ permission: 'granted' }) + const sub = subscribed(browser) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + api.unsubscribe.mockRejectedValueOnce(new Error('offline')) + await disableWebPush() + expect(sub.unsubscribe).toHaveBeenCalled() + }) + + it('FE-PUSH-033: does nothing without a subscription, and reports a browser that refuses', async () => { + const browser = installBrowser({ permission: 'granted' }) + await disableWebPush() + expect(api.unsubscribe).not.toHaveBeenCalled() + + const sub = subscribed(browser) + sub.unsubscribe.mockRejectedValueOnce(new Error('refused')) + await expect(disableWebPush()).rejects.toThrow('refused') + }) +}) + +describe('resyncPushSubscription', () => { + it('FE-PUSH-034: stays quiet when push is unsupported, not granted or the app is offline', async () => { + await resyncPushSubscription() + const browser = installBrowser({ permission: 'default' }) + subscribed(browser) + await resyncPushSubscription() + browser.notification.permission = 'granted' + network.offline = true + await resyncPushSubscription() + expect(browser.container.getRegistration).not.toHaveBeenCalled() + expect(api.subscribe).not.toHaveBeenCalled() + }) + + it('FE-PUSH-035: registers the subscription the device holds again, without update()', async () => { + const browser = installBrowser({ permission: 'granted' }) + const sub = subscribed(browser) + await resyncPushSubscription() + expect(api.subscribe).toHaveBeenCalledWith(toSubscriptionInput(sub as unknown as PushSubscription)) + expect(browser.pushManager.subscribe).not.toHaveBeenCalled() + expect(browser.registration.update).not.toHaveBeenCalled() + }) + + it('FE-PUSH-036: does nothing for a device that never switched push on', async () => { + installBrowser({ permission: 'granted' }) + await resyncPushSubscription() + expect(api.getPublicKey).not.toHaveBeenCalled() + expect(api.subscribe).not.toHaveBeenCalled() + }) + + it('FE-PUSH-037: replaces a subscription made with an old server key', async () => { + const browser = installBrowser({ permission: 'granted' }) + const stale = subscribed(browser, OTHER_KEY, 'https://push.example/stale') + await resyncPushSubscription() + expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/stale') + expect(stale.unsubscribe).toHaveBeenCalled() + expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1) + expect(api.subscribe).toHaveBeenCalledWith(expect.objectContaining({ endpoint: 'https://push.example/sub-1' })) + }) + + it('FE-PUSH-038: a browser that does not report its key is taken at its word', async () => { + const browser = installBrowser({ permission: 'granted' }) + browser.pushManager.current = new FakeSubscription('https://push.example/unknown-key', null) + await resyncPushSubscription() + expect(browser.pushManager.subscribe).not.toHaveBeenCalled() + expect(api.subscribe).toHaveBeenCalledWith(expect.objectContaining({ endpoint: 'https://push.example/unknown-key' })) + }) + + it('FE-PUSH-039: never rejects, even when the server does', async () => { + const browser = installBrowser({ permission: 'granted' }) + subscribed(browser) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + api.subscribe.mockRejectedValueOnce(new Error('500')) + await expect(resyncPushSubscription()).resolves.toBeUndefined() + expect(warn).toHaveBeenCalled() + }) +}) + +describe('forgetPushDeviceOnLogout', () => { + /** The logout DELETE is a plain fetch, so a 401 can never reach apiClient's login redirect. */ + function stubFetch(answer: (init?: RequestInit) => Promise) { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => answer(init)) + vi.stubGlobal('fetch', fetchMock) + return fetchMock + } + + it('FE-PUSH-040: forgets the device on the server with a plain fetch, and in the browser', async () => { + const browser = installBrowser({ permission: 'granted' }) + const sub = subscribed(browser) + const fetchMock = stubFetch(async () => Response.json({ success: true })) + + await forgetPushDeviceOnLogout() + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('/api/notifications/push/subscriptions') + expect(init).toMatchObject({ + method: 'DELETE', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }) + expect(JSON.parse(String(init?.body))).toEqual({ endpoint: 'https://push.example/existing' }) + expect(init?.signal).toBeInstanceOf(AbortSignal) + expect(api.unsubscribe).not.toHaveBeenCalled() + expect(sub.unsubscribe).toHaveBeenCalled() + }) + + it('FE-PUSH-041: returns at once where there is no push at all', async () => { + const fetchMock = stubFetch(async () => Response.json({ success: true })) + await forgetPushDeviceOnLogout() + expect(fetchMock).not.toHaveBeenCalled() + expect(api.unsubscribe).not.toHaveBeenCalled() + }) + + it('FE-PUSH-042: a server that never answers cannot hold the logout up or keep the browser subscribed', async () => { + const browser = installBrowser({ permission: 'granted' }) + const sub = subscribed(browser) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // Like a real fetch to a server that never answers: it settles only once its signal aborts. + const fetchMock = stubFetch( + init => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) + }), + ) + vi.useFakeTimers() + let finished = false + const done = forgetPushDeviceOnLogout(3000).then(() => { + finished = true + }) + await vi.advanceTimersByTimeAsync(2999) + expect(finished).toBe(false) + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(false) + // The browser half never waited for the server half. + expect(sub.unsubscribe).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await done + expect(finished).toBe(true) + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true) + expect(warn).toHaveBeenCalledWith('[push] forgetting the device on the server failed', expect.any(DOMException)) + }) + + it('FE-PUSH-043: a 401 from a session that already ran out neither redirects nor stops the browser half', async () => { + const browser = installBrowser({ permission: 'granted' }) + const sub = subscribed(browser) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const before = window.location.href + stubFetch(async () => Response.json({ error: 'Authentication required', code: 'AUTH_REQUIRED' }, { status: 401 })) + + await expect(forgetPushDeviceOnLogout()).resolves.toBeUndefined() + + expect(window.location.href).toBe(before) + expect(api.unsubscribe).not.toHaveBeenCalled() + expect(sub.unsubscribe).toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith('[push] forgetting the device on the server failed', expect.any(Error)) + }) + + it('FE-PUSH-044: a network failure on the server half still unsubscribes the browser', async () => { + const browser = installBrowser({ permission: 'granted' }) + const sub = subscribed(browser) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + stubFetch(() => Promise.reject(new TypeError('Failed to fetch'))) + await forgetPushDeviceOnLogout() + expect(sub.unsubscribe).toHaveBeenCalled() + }) +}) diff --git a/client/src/push/webPush.ts b/client/src/push/webPush.ts new file mode 100644 index 000000000..b394cb810 --- /dev/null +++ b/client/src/push/webPush.ts @@ -0,0 +1,338 @@ +import type { PushSubscriptionInput, PushUnsubscribeRequest } from '@trek/shared' +import { pushApi } from '../api/push' +import { isEffectivelyOffline } from '../sync/networkMode' +import { isIosDevice, isIosStandalone } from '../utils/iosDevice' + +/** + * Web Push on this device: whether it can work here, and switching it on and + * off. + * + * Deliberately online only, and deliberately without a mirror. The browser's + * push subscription is the one record of "this device receives push"; the + * server keeps its copy keyed by the subscription's endpoint, and nothing in + * Zustand or Dexie repeats either. Making a subscription needs the browser's + * push service and the server at the same moment, so an offline queue would + * have nothing to replay, and a cached copy could only ever disagree with the + * browser. + * + * React-free on purpose: the settings cards (through `useWebPush`), the auth + * store (logout and the re-sync after sign-in) and the tests all drive these + * same functions. + */ + +export type WebPushUnsupportedReason = 'insecure' | 'unsupported' | 'ios-install' + +/** `reason` is null exactly when `supported` is true. */ +export interface WebPushSupport { + supported: boolean + reason: WebPushUnsupportedReason | null +} + +/** What the settings card has to say about this device. */ +export type WebPushDeviceState = WebPushUnsupportedReason | 'denied' | 'off' | 'on' + +export type WebPushErrorCode = 'denied' | 'dismissed' | 'failed' + +/** Why switching push on did not work: blocked, the prompt closed without an answer, or anything else. */ +export class WebPushError extends Error { + readonly code: WebPushErrorCode + readonly reason: unknown + + constructor(code: WebPushErrorCode, reason?: unknown) { + super(`Web Push: ${code}`) + this.name = 'WebPushError' + this.code = code + this.reason = reason + } +} + +/** + * How long switching push on waits for the service worker. `registerSW.js` + * installs it on page load, so this only runs out on a page that never got + * one, and there the card should say so rather than spin. + */ +export const REGISTRATION_TIMEOUT_MS = 10_000 + +/** Logout waits at most this long for this device to be forgotten. It must never hang on it. */ +export const LOGOUT_PUSH_TIMEOUT_MS = 3_000 + +function hasPushApis(): boolean { + return ( + typeof window !== 'undefined' && + typeof navigator !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ) +} + +/** + * Whether push can work in this browser, and if not, the reason the user can + * do something about. + */ +export function getWebPushSupport(): WebPushSupport { + if (typeof window === 'undefined') return unsupported('unsupported') + // A service worker, and push with it, exists only in a secure context: HTTPS, + // or localhost while developing. A plain http LAN address never gets one. + if (window.isSecureContext === false) return unsupported('insecure') + // iOS and iPadOS deliver push only to TREK added to the Home Screen. In a + // Safari tab the APIs are simply missing, which would read as "this browser + // cannot" when the fix is one tap in the share menu. + if (isIosDevice() && !isIosStandalone()) return unsupported('ios-install') + if (!hasPushApis()) return unsupported('unsupported') + return { supported: true, reason: null } +} + +function unsupported(reason: WebPushUnsupportedReason): WebPushSupport { + return { supported: false, reason } +} + +/** The notification permission as the browser holds it right now. */ +export function getPushPermission(): NotificationPermission { + return typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : 'default' +} + +/** The applicationServerKey the browser wants: the raw bytes behind the server's base64url string. */ +export function base64UrlToUint8Array(value: string): Uint8Array { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + return Uint8Array.from(atob(padded), ch => ch.codePointAt(0) ?? 0) +} + +/** A key the browser reports it subscribed with, compared with the server's. Unknown counts as the same. */ +function keyMatches(subscribedWith: ArrayBuffer | null | undefined, serverKey: Uint8Array): boolean { + if (!subscribedWith) return true + const bytes = new Uint8Array(subscribedWith) + return bytes.length === serverKey.length && bytes.every((byte, i) => byte === serverKey[i]) +} + +/** What `PushSubscription.toJSON()` hands over, in the shape of the shared contract. */ +export function toSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput { + const json = subscription.toJSON() + const p256dh = json.keys?.p256dh + const auth = json.keys?.auth + if (!json.endpoint || !p256dh || !auth) throw new WebPushError('failed', 'subscription without keys') + return { endpoint: json.endpoint, expirationTime: json.expirationTime ?? null, keys: { p256dh, auth } } +} + +/** Settles with `fallback` once `ms` have passed, unless `promise` settled first. */ +function withTimeout(promise: Promise, ms: number, fallback: F): Promise { + let timer: ReturnType | undefined + const timeout = new Promise(resolve => { + timer = setTimeout(() => resolve(fallback), ms) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +/** + * A step whose failure changes nothing for the caller. It still gets a line in + * the console, because "push quietly stopped" is otherwise impossible to trace. + */ +async function bestEffort(what: string, step: () => Promise): Promise { + try { + return await step() + } catch (err) { + console.warn(`[push] ${what} failed`, err) + return undefined + } +} + +/** + * The registration already in charge of this origin, or null. It neither waits + * for one to appear nor calls update(): the version handover owns updates, and + * its tests count every call. + */ +async function existingRegistration(): Promise { + const container = navigator.serviceWorker + if (!container || typeof container.getRegistration !== 'function') return null + return (await container.getRegistration()) ?? null +} + +/** This device's subscription, or null when it has none or cannot have one. */ +export async function currentPushSubscription(): Promise { + if (!hasPushApis()) return null + const registration = await existingRegistration() + if (!registration?.pushManager) return null + return registration.pushManager.getSubscription() +} + +/** + * The active registration, for subscribing. `ready` never settles on a page + * without a service worker, hence the timeout. + */ +export async function waitForPushRegistration( + timeoutMs = REGISTRATION_TIMEOUT_MS, +): Promise { + if (!hasPushApis()) return null + return withTimeout(navigator.serviceWorker.ready, timeoutMs, null) +} + +/** The server's public key, or null when it could not be fetched now. Never rejects. */ +export async function loadPushPublicKey(): Promise { + const result = await bestEffort('loading the public key', () => pushApi.getPublicKey()) + return result?.publicKey || null +} + +/** What the settings card shows for this device. Never rejects. */ +export async function readWebPushDeviceState(): Promise { + const { reason } = getWebPushSupport() + if (reason) return reason + if (getPushPermission() === 'denied') return 'denied' + const subscription = await bestEffort('reading the subscription', () => currentPushSubscription()) + return subscription ? 'on' : 'off' +} + +/** Both halves of dropping a subscription, each best effort: the server's row and the browser's own. */ +async function forgetSubscription(subscription: PushSubscription): Promise { + await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(subscription.endpoint)) + await bestEffort('unsubscribing in the browser', () => subscription.unsubscribe()) +} + +async function subscribeWithKey( + registration: ServiceWorkerRegistration, + key: Uint8Array, +): Promise { + const options: PushSubscriptionOptionsInit = { userVisibleOnly: true, applicationServerKey: key } + try { + return await registration.pushManager.subscribe(options) + } catch (err) { + // A subscription made with an earlier server key blocks a new one. Drop it + // and try once more; any other failure is final. + const stale = await registration.pushManager.getSubscription() + if (!stale || keyMatches(stale.options?.applicationServerKey, key)) throw err + await forgetSubscription(stale) + return registration.pushManager.subscribe(options) + } +} + +/** + * Switches push on for this device: the permission, then the browser's + * subscription, then the server. + * + * Call it straight from the click handler, with the key and the registration + * fetched beforehand. WebKit honours the permission prompt and subscribe() only + * inside the user's gesture, and an awaited network call in front of them would + * spend it. The permission request is therefore the first thing that runs. + */ +export async function enableWebPush(publicKey: string, registration?: ServiceWorkerRegistration | null): Promise { + const permission = + Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission() + if (permission === 'denied') throw new WebPushError('denied') + if (permission !== 'granted') throw new WebPushError('dismissed') + + let subscription: PushSubscription + try { + const active = registration?.pushManager ? registration : await waitForPushRegistration() + if (!active?.pushManager) throw new WebPushError('failed', 'no service worker') + subscription = await subscribeWithKey(active, base64UrlToUint8Array(publicKey)) + } catch (err) { + throw err instanceof WebPushError ? err : new WebPushError('failed', err) + } + + try { + await pushApi.subscribe(toSubscriptionInput(subscription)) + } catch (err) { + // The server never took this subscription, so it must not outlive the + // failure: the card reads the browser's record, and would claim push works + // on a device the server will never send to. + await bestEffort('rolling back the browser subscription', () => subscription.unsubscribe()) + throw err instanceof WebPushError ? err : new WebPushError('failed', err) + } +} + +/** + * Switches push off for this device. The server's row goes first, while this + * session can still name the endpoint. If that request fails the browser still + * unsubscribes: that alone stops delivery here, and the server drops a row the + * push service answers 404 or 410 for on its next send. + */ +export async function disableWebPush(): Promise { + const subscription = await currentPushSubscription() + if (!subscription) return + await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(subscription.endpoint)) + await subscription.unsubscribe() +} + +/** + * Tells the server about this device's subscription again after sign-in. The + * server upserts by endpoint, so repeating it is harmless, and it heals a row + * that was lost or still belongs to the account that used this browser before. + * + * When the server's key changed since the device subscribed, every push to the + * old subscription fails its signature check, so it is replaced. Permission is + * already granted then, which lets the browser subscribe again without a + * gesture; where it refuses, the device simply reads as off and the card offers + * to switch it on. Never rejects. + */ +export async function resyncPushSubscription(): Promise { + if (!getWebPushSupport().supported || getPushPermission() !== 'granted' || isEffectivelyOffline()) return + await bestEffort('re-registering this device', async () => { + const registration = await existingRegistration() + const subscription = await registration?.pushManager?.getSubscription() + if (!registration || !subscription) return + const { publicKey } = await pushApi.getPublicKey() + const key = base64UrlToUint8Array(publicKey) + let current = subscription + if (!keyMatches(subscription.options?.applicationServerKey, key)) { + await forgetSubscription(subscription) + current = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }) + } + await pushApi.subscribe(toSubscriptionInput(current)) + }) +} + +/** Where the logout step tells the server to forget this device. */ +const PUSH_SUBSCRIPTIONS_URL = '/api/notifications/push/subscriptions' + +/** + * The server half of the logout teardown, as a plain fetch rather than through + * apiClient: when the session has already run out the answer is a 401, and + * apiClient meets that with its redirect to the login page, in the middle of + * the logout. It carries its own timeout, so the request itself is cut off + * rather than left running behind a logout that has moved on. + */ +async function forgetOnServerAtLogout(endpoint: string, timeoutMs: number): Promise { + const body: PushUnsubscribeRequest = { endpoint } + const abort = new AbortController() + const timer = setTimeout(() => abort.abort(), timeoutMs) + try { + const response = await fetch(PUSH_SUBSCRIPTIONS_URL, { + method: 'DELETE', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: abort.signal, + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + } finally { + clearTimeout(timer) + } +} + +/** + * The logout step: forget this device on the server and in the browser, so the + * next account on a shared device does not receive this one's notifications. + * It has to run while the session cookie still exists, and it waits at most + * `timeoutMs`. Never rejects, and never redirects: a server that already + * dropped the session, never answers or cannot be reached only costs the + * server half, the browser still unsubscribes. + * + * The two halves run side by side rather than one after the other. The server + * half may use up the whole timeout, and the browser half is the one that + * stops delivery here, so it must not queue behind it. The server needs + * nothing the browser still has to hold: the endpoint is already in hand, and + * a row left behind goes on the next send, when the push service answers 404 + * or 410. + */ +export async function forgetPushDeviceOnLogout(timeoutMs = LOGOUT_PUSH_TIMEOUT_MS): Promise { + if (!hasPushApis()) return + const teardown = bestEffort('forgetting this device at logout', async () => { + const subscription = await currentPushSubscription() + if (!subscription) return + await Promise.all([ + bestEffort('forgetting the device on the server', () => forgetOnServerAtLogout(subscription.endpoint, timeoutMs)), + bestEffort('unsubscribing in the browser', () => subscription.unsubscribe()), + ]) + }) + await withTimeout(teardown, timeoutMs, undefined) +} diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 2b28304d8..d6bbb6089 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -15,6 +15,7 @@ import { clearAllPluginSessions } from './pluginStore' import { forgetStartDestination } from '../utils/startDestination' import { forgetServerLanguage } from './settingsStore' import { markSignedOut, clearSignedOut } from '../utils/signedOut' +import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush' interface AuthResponse { user: User @@ -119,6 +120,9 @@ async function onAuthSuccess(userId: number): Promise { // an SPA session, so a second login in the same tab would leave the mutation // queue without a flush trigger. Re-registering is a no-op while they are up. registerSyncTriggers() + // Tell the server again which push subscription this device holds, in the + // background: sign-in must not wait on it, and it never rejects. + void resyncPushSubscription() } export const useAuthStore = create()( @@ -264,6 +268,11 @@ export const useAuthStore = create()( // and the next account would come up believing it is offline over a working // connection, with nothing cached to answer from. setForcedOffline(false) + // Forget this device's push subscription, on the server and in the browser, + // or the next account on a shared device keeps receiving this one's + // notifications. It has to happen here: the DELETE needs the session cookie + // that step 4 clears. Best effort and bounded, so logout never hangs on it. + await forgetPushDeviceOnLogout() // 4. Tell server to clear the httpOnly cookie (best-effort). await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) // 5. Clear service worker caches containing sensitive data. diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index 96ae0cbb6..48c1fab70 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -1,5 +1,6 @@ import { getCachedBlob } from '../db/offlineDb' import { isEffectivelyOffline } from '../sync/networkMode' +import { isIosStandalone } from './iosDevice' // MIME types safe to open inline (will not execute script in any browser). // Everything else (text/html, image/svg+xml, text/javascript, …) is forced to @@ -35,13 +36,6 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void { setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100) } -// navigator.standalone is true only on iOS when running as an -// add-to-home-screen PWA. In that context, target="_blank" hands off to -// Safari, which cannot access blob URLs sandboxed to the WebView. -function isIosStandalone(): boolean { - return (navigator as any).standalone === true -} - /** * Resolves a protected file to a Blob, preferring the live server but falling * back to the offline cache (pre-downloaded by the trip sync manager). This is @@ -124,7 +118,8 @@ export async function openFile(url: string, filename?: string): Promise { return } - // iOS PWA: target="_blank" would open Safari, which can't access the blob + // iOS PWA: target="_blank" hands off to Safari, which cannot reach a blob URL + // sandboxed to the installed app's WebView. if (isIosStandalone()) { triggerAnchorDownload(blobUrl, filename) return diff --git a/client/src/utils/iosDevice.test.ts b/client/src/utils/iosDevice.test.ts new file mode 100644 index 000000000..8dc0cb9e8 --- /dev/null +++ b/client/src/utils/iosDevice.test.ts @@ -0,0 +1,51 @@ +/** + * FE-IOS-001 to FE-IOS-004: the two iPhone and iPad facts shared by file + * opening and Web Push. + */ +import { describe, it, expect, afterEach } from 'vitest' +import { isIosDevice, isIosStandalone } from './iosDevice' + +function define(name: 'userAgent' | 'maxTouchPoints' | 'standalone', value: unknown) { + Object.defineProperty(navigator, name, { configurable: true, get: () => value }) +} + +afterEach(() => { + for (const name of ['userAgent', 'maxTouchPoints', 'standalone']) { + delete (navigator as unknown as Record)[name] + } +}) + +describe('isIosStandalone', () => { + it("FE-IOS-001: is true only for Apple's own home-screen flag", () => { + expect(isIosStandalone()).toBe(false) + define('standalone', false) + expect(isIosStandalone()).toBe(false) + define('standalone', true) + expect(isIosStandalone()).toBe(true) + }) +}) + +describe('isIosDevice', () => { + it('FE-IOS-002: recognises iPhone, iPod and iPad by their user agent', () => { + for (const device of ['iPhone', 'iPod touch', 'iPad']) { + define('userAgent', `Mozilla/5.0 (${device}; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15`) + expect(isIosDevice()).toBe(true) + } + }) + + it('FE-IOS-003: an iPad asking for the desktop site is a Mac with a touch screen', () => { + define('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15') + define('maxTouchPoints', 5) + expect(isIosDevice()).toBe(true) + define('maxTouchPoints', 0) + expect(isIosDevice()).toBe(false) + }) + + it('FE-IOS-004: Android and Windows are not iOS', () => { + define('maxTouchPoints', 5) + define('userAgent', 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/140.0 Mobile Safari/537.36') + expect(isIosDevice()).toBe(false) + define('userAgent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36') + expect(isIosDevice()).toBe(false) + }) +}) diff --git a/client/src/utils/iosDevice.ts b/client/src/utils/iosDevice.ts new file mode 100644 index 000000000..563a64558 --- /dev/null +++ b/client/src/utils/iosDevice.ts @@ -0,0 +1,28 @@ +/** + * iPhone and iPad facts that more than one feature has to know about: file + * opening needs to know whether a new tab would leave the installed app, and + * Web Push needs to know whether it can work here at all. + */ + +type NavigatorWithStandalone = Navigator & { standalone?: boolean } + +/** + * True only on iOS and iPadOS while TREK runs as an app added to the Home + * Screen. `navigator.standalone` is Apple's own flag and exists nowhere else, + * so every other platform answers false. + */ +export function isIosStandalone(): boolean { + return typeof navigator !== 'undefined' && (navigator as NavigatorWithStandalone).standalone === true +} + +/** + * True on iPhone, iPod touch and iPad. iPadOS 13 and later asks for the desktop + * site by default and then introduces itself as a Mac, so a Mac with a touch + * screen counts too: no real Mac has one. + */ +export function isIosDevice(): boolean { + if (typeof navigator === 'undefined') return false + const ua = navigator.userAgent || '' + if (/iPad|iPhone|iPod/.test(ua)) return true + return /Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1 +} diff --git a/client/tests/unit/mobile/admin/MAdminNotificationsSection.test.tsx b/client/tests/unit/mobile/admin/MAdminNotificationsSection.test.tsx index 0fa6f419f..da79a125c 100644 --- a/client/tests/unit/mobile/admin/MAdminNotificationsSection.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminNotificationsSection.test.tsx @@ -1,4 +1,4 @@ -// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-031 +// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-035 import { useState } from 'react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; @@ -558,4 +558,38 @@ describe('MAdminNotificationsSection', () => { await waitFor(() => expect(bodies).toHaveLength(1)); expect(bodies[0].smtp_pass).toBe('hunter2'); }); + + it('FE-MOB-ANOTIF-033: Web Push has its own card between Ntfy and In-App', () => { + render(); + + const switches = screen.getAllByRole('switch').map((el) => el.getAttribute('aria-label')); + expect(switches.indexOf('Web Push')).toBe(switches.indexOf('Ntfy') + 1); + expect(switches.indexOf('In-App')).toBe(switches.indexOf('Web Push') + 1); + expect(screen.getByText(/receive notifications on their phones/)).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('FE-MOB-ANOTIF-034: switching Web Push on saves the list and keeps unknown ids', async () => { + const bodies = captureAppSettings(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Web Push' })); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ notification_channels: 'webhook,push,pushover' }); + expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-ANOTIF-035: a failed Web Push save rolls the switch back', async () => { + failAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Web Push' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'true'); + }); }); diff --git a/client/tests/unit/mobile/settings/MSettingsNotifications.test.tsx b/client/tests/unit/mobile/settings/MSettingsNotifications.test.tsx index 2aa52faca..a3c2a10c8 100644 --- a/client/tests/unit/mobile/settings/MSettingsNotifications.test.tsx +++ b/client/tests/unit/mobile/settings/MSettingsNotifications.test.tsx @@ -594,3 +594,37 @@ describe('MSettingsNotifications', () => { }); }); }); + +// Web Push (#894): the per-device card joins the other channel cards while the +// admin has push switched on. jsdom has no push APIs, so the real hook reads +// this "browser" as unable to receive push, and the card has to say so. +describe('MSettingsNotifications: Web Push', () => { + it('FE-MOB-SETNOTIF-033: an active push channel gets its chip and the device card', async () => { + usePrefs( + matrix({ + preferences: { trip_invite: { inapp: true, push: true } }, + channels: [builtin('inapp'), builtin('push', { configured: false })], + implemented_combos: { trip_invite: ['inapp', 'push'] }, + }), + ); + render(); + + expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument(); + const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement; + expect(within(row).getByRole('button', { name: 'Push' })).toBeInTheDocument(); + expect(await screen.findByRole('status')).toHaveTextContent(/push/i); + }); + + it('FE-MOB-SETNOTIF-034: no card while the admin has push off', async () => { + usePrefs( + matrix({ + channels: [builtin('inapp'), builtin('push', { active: false })], + implemented_combos: { trip_invite: ['inapp', 'push'] }, + }), + ); + render(); + + await screen.findByText('Trip invitations'); + expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/settings/MWebPushCard.test.tsx b/client/tests/unit/mobile/settings/MWebPushCard.test.tsx new file mode 100644 index 000000000..345daede2 --- /dev/null +++ b/client/tests/unit/mobile/settings/MWebPushCard.test.tsx @@ -0,0 +1,77 @@ +// FE-MOB-WEBPUSH-001 to FE-MOB-WEBPUSH-004: the phone device card. +// +// Markup only, like its desktop twin: useWebPush is mocked and has its own +// spec, so these cases pin what each hook state renders and that the buttons +// reach the hook. +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../helpers/render'; +import type { WebPushCardState } from '../../../../src/hooks/useWebPush'; +import MWebPushCard from '../../../../src/mobile/screens/settings/MWebPushCard'; + +const hookState = vi.hoisted(() => ({ current: null as unknown as WebPushCardState })); +vi.mock('../../../../src/hooks/useWebPush', () => ({ useWebPush: () => hookState.current })); + +function state(over: Partial = {}): WebPushCardState { + return { + state: 'off', + stateText: null, + canSwitch: true, + on: false, + switchLabel: 'Turn on for this device', + busy: false, + canTest: false, + toggle: vi.fn(), + sendTest: vi.fn(), + ...over, + }; +} + +beforeEach(() => { + hookState.current = state(); +}); + +describe('MWebPushCard', () => { + it('FE-MOB-WEBPUSH-001: explains itself and offers to switch on', async () => { + render(); + expect(screen.getByText('Push notifications on this device')).toBeInTheDocument(); + expect(screen.getByText(/Shows TREK notifications on this device/)).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + const turnOn = screen.getByRole('button', { name: 'Turn on for this device' }); + expect(turnOn.className).toContain('bg-m-act'); + expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled(); + + await userEvent.click(turnOn); + expect(hookState.current.toggle).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-WEBPUSH-002: when on, says so, offers to switch off and to test', async () => { + hookState.current = state({ + state: 'on', + on: true, + stateText: 'On for this device', + switchLabel: 'Turn off for this device', + canTest: true, + }); + render(); + expect(screen.getByRole('status')).toHaveTextContent('On for this device'); + expect(screen.getByRole('button', { name: 'Turn off for this device' }).className).not.toContain('bg-m-act'); + + await userEvent.click(screen.getByRole('button', { name: 'Send test' })); + expect(hookState.current.sendTest).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-WEBPUSH-003: a state that cannot be switched shows its sentence and no buttons', () => { + hookState.current = state({ state: 'denied', canSwitch: false, stateText: 'Notifications are blocked.' }); + render(); + expect(screen.getByRole('status')).toHaveTextContent('Notifications are blocked.'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('FE-MOB-WEBPUSH-004: the switch is disabled while busy', () => { + hookState.current = state({ busy: true }); + render(); + expect(screen.getByRole('button', { name: 'Turn on for this device' })).toBeDisabled(); + }); +}); diff --git a/client/tests/unit/pwa/swPush.test.ts b/client/tests/unit/pwa/swPush.test.ts new file mode 100644 index 000000000..abbd28e5c --- /dev/null +++ b/client/tests/unit/pwa/swPush.test.ts @@ -0,0 +1,297 @@ +// FE-SW-PUSH-001 to FE-SW-PUSH-016: the Web Push handlers in public/sw-push.js. +// +// The file is a classic script that Workbox imports into the generated service +// worker, so it sits outside src/, ESLint and coverage. This spec is its only +// guard: it evaluates the real file against a fake worker global and drives +// the three events the way a browser would. +// +// What matters most: +// - every push shows a notification inside waitUntil, whatever arrives, since +// Chrome demands one per push and WebKit revokes a subscription that stays +// silent; +// - a click only ever opens a path on this origin; +// - a renewed subscription reaches the server, and the old one is forgotten +// only once the new one is stored. +// Plus the drift guard: vite.config.js must import the file, and it must exist, +// or the SPA fallback answers importScripts with index.html and the new worker +// never installs. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const ORIGIN = 'https://trek.example'; +const SOURCE_PATH = resolve(process.cwd(), 'public/sw-push.js'); +const SOURCE = readFileSync(SOURCE_PATH, 'utf8'); + +type Listener = (event: Record) => void; + +interface FakeWindow { + url: string; + focused: boolean; + focus: ReturnType; + navigate: ReturnType; +} + +function fakeWindow(over: Partial = {}): FakeWindow { + const win: FakeWindow = { + url: `${ORIGIN}/dashboard`, + focused: false, + focus: vi.fn(async () => win), + navigate: vi.fn(async () => win), + ...over, + }; + return win; +} + +function boot(windows: FakeWindow[] = []) { + const listeners: Record = {}; + const registration = { + showNotification: vi.fn(async (_title: string, _options: NotificationOptions) => undefined), + pushManager: { subscribe: vi.fn() }, + }; + const clients = { + matchAll: vi.fn(async () => windows), + openWindow: vi.fn(async (_url: string) => null), + }; + const self = { + addEventListener: (type: string, fn: Listener) => { + listeners[type] = fn; + }, + registration, + clients, + location: { origin: ORIGIN }, + }; + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(null, { status: 200 })); + new Function('self', 'fetch', SOURCE)(self, fetchMock); + return { listeners, registration, clients, fetch: fetchMock }; +} + +/** Dispatches one event and hands back what it passed to waitUntil. */ +function fire(listener: Listener, event: Record): Promise { + let waited: Promise | undefined; + listener({ ...event, waitUntil: (p: Promise) => { waited = p; } }); + expect(waited).toBeInstanceOf(Promise); + return waited as Promise; +} + +function pushData(value: unknown) { + return { + json: () => (typeof value === 'string' ? JSON.parse(value) : value), + text: () => (typeof value === 'string' ? value : JSON.stringify(value)), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('sw-push.js: push', () => { + it('FE-SW-PUSH-001: shows the payload as sent, with the icon and the path to open', async () => { + const sw = boot(); + await fire(sw.listeners.push, { + data: pushData({ title: 'Trip invite', body: 'Anna invited you to Rome', url: '/trips/5' }), + }); + expect(sw.registration.showNotification).toHaveBeenCalledWith('Trip invite', { + body: 'Anna invited you to Rome', + icon: '/icons/icon-192x192.png', + data: { url: '/trips/5' }, + }); + }); + + it('FE-SW-PUSH-001b: a tagged push replaces the last one with that tag and still alerts', async () => { + const sw = boot(); + const message = { title: 'New message in "Rome"', body: 'anna: see you there', url: '/trips/5' }; + await fire(sw.listeners.push, { data: pushData({ ...message, tag: 'collab_message:/trips/5' }) }); + expect(sw.registration.showNotification).toHaveBeenCalledWith(message.title, { + body: message.body, + icon: '/icons/icon-192x192.png', + tag: 'collab_message:/trips/5', + renotify: true, + data: { url: '/trips/5' }, + }); + // Without a tag there is nothing to replace, and renotify would be refused. + await fire(sw.listeners.push, { data: pushData({ ...message, tag: '' }) }); + expect(sw.registration.showNotification.mock.calls[1][1]).not.toHaveProperty('tag'); + expect(sw.registration.showNotification.mock.calls[1][1]).not.toHaveProperty('renotify'); + }); + + it('FE-SW-PUSH-001c: renotify follows the tag alone, never the payload', async () => { + const sw = boot(); + // showNotification throws on renotify without a tag, so a payload asking for it must not get it. + await fire(sw.listeners.push, { data: pushData({ title: 'Booking', body: 'changed', renotify: true }) }); + expect(sw.registration.showNotification.mock.calls[0][1]).not.toHaveProperty('renotify'); + // Any tag at all brings it, not only the chat one. + await fire(sw.listeners.push, { data: pushData({ title: 'Booking', body: 'changed', tag: 'any' }) }); + expect(sw.registration.showNotification.mock.calls[1][1]).toMatchObject({ tag: 'any', renotify: true }); + }); + + it('FE-SW-PUSH-002: a payload that is not JSON still shows, with its text as the body', async () => { + const sw = boot(); + await fire(sw.listeners.push, { data: pushData('plain words, no JSON') }); + expect(sw.registration.showNotification).toHaveBeenCalledWith('TREK', { + body: 'plain words, no JSON', + icon: '/icons/icon-192x192.png', + data: { url: '/' }, + }); + }); + + it('FE-SW-PUSH-003: a push without data, or with the wrong types, still shows something', async () => { + const sw = boot(); + await fire(sw.listeners.push, { data: null }); + await fire(sw.listeners.push, { data: pushData({ title: 42, body: ['x'], tag: 7 }) }); + await fire(sw.listeners.push, { data: pushData('"just a string"') }); + for (const call of sw.registration.showNotification.mock.calls) { + expect(call[0]).toBe('TREK'); + expect(call[1]).toEqual({ body: '', icon: '/icons/icon-192x192.png', data: { url: '/' } }); + } + expect(sw.registration.showNotification).toHaveBeenCalledTimes(3); + }); + + it('FE-SW-PUSH-004: a url pointing anywhere else is replaced by the start page', async () => { + const sw = boot(); + for (const url of ['https://evil.example/phish', '//evil.example/x', 'javascript:alert(1)']) { + await fire(sw.listeners.push, { data: pushData({ title: 'x', body: 'y', url }) }); + } + for (const call of sw.registration.showNotification.mock.calls) { + expect(call[1].data.url).toBe('/'); + } + }); + + it('FE-SW-PUSH-005: a same-origin absolute url is reduced to its path, query and hash', async () => { + const sw = boot(); + await fire(sw.listeners.push, { data: pushData({ title: 'x', body: 'y', url: `${ORIGIN}/trips/5?tab=plan#day-2` }) }); + expect(sw.registration.showNotification.mock.calls[0][1]).toMatchObject({ data: { url: '/trips/5?tab=plan#day-2' } }); + }); +}); + +describe('sw-push.js: notificationclick', () => { + function click(sw: ReturnType, url: unknown) { + const notification = { close: vi.fn(), data: { url } }; + return { notification, done: fire(sw.listeners.notificationclick, { notification }) }; + } + + it('FE-SW-PUSH-006: closes the notification, focuses a TREK window and takes it there', async () => { + const win = fakeWindow(); + const sw = boot([win]); + const { notification, done } = click(sw, '/trips/5'); + await done; + expect(notification.close).toHaveBeenCalled(); + expect(sw.clients.matchAll).toHaveBeenCalledWith({ type: 'window', includeUncontrolled: true }); + expect(win.focus).toHaveBeenCalled(); + expect(win.navigate).toHaveBeenCalledWith(`${ORIGIN}/trips/5`); + expect(sw.clients.openWindow).not.toHaveBeenCalled(); + }); + + it('FE-SW-PUSH-007: prefers the window that already has the focus', async () => { + const background = fakeWindow(); + const focused = fakeWindow({ focused: true }); + const sw = boot([background, focused]); + await click(sw, '/notifications').done; + expect(focused.navigate).toHaveBeenCalledWith(`${ORIGIN}/notifications`); + expect(background.focus).not.toHaveBeenCalled(); + }); + + it('FE-SW-PUSH-008: opens a window when none is left', async () => { + const sw = boot([]); + await click(sw, '/trips/5').done; + expect(sw.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/trips/5`); + }); + + it('FE-SW-PUSH-009: a window the worker may not navigate gets a new one instead', async () => { + const uncontrolled = fakeWindow(); + uncontrolled.navigate.mockRejectedValueOnce(new TypeError('not controlled')); + const sw = boot([uncontrolled]); + await click(sw, '/trips/5').done; + expect(sw.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/trips/5`); + }); + + it('FE-SW-PUSH-010: a url from elsewhere, or none at all, opens the start page', async () => { + const sw = boot([]); + await click(sw, 'https://evil.example/').done; + const notification = { close: vi.fn(), data: null }; + await fire(sw.listeners.notificationclick, { notification }); + expect(sw.clients.openWindow.mock.calls).toEqual([[`${ORIGIN}/`], [`${ORIGIN}/`]]); + }); +}); + +describe('sw-push.js: pushsubscriptionchange', () => { + const OLD_KEY = new Uint8Array([4, 1, 2, 3]).buffer; + + function renewed(endpoint: string) { + return { endpoint, toJSON: () => ({ endpoint, keys: { p256dh: 'p', auth: 'a' } }) }; + } + + it('FE-SW-PUSH-011: subscribes again with the old key, stores the new one, then forgets the old', async () => { + const sw = boot(); + sw.registration.pushManager.subscribe.mockResolvedValue(renewed('https://push.example/new')); + await fire(sw.listeners.pushsubscriptionchange, { + oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } }, + newSubscription: null, + }); + + expect(sw.registration.pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: OLD_KEY }); + expect(sw.fetch).toHaveBeenCalledTimes(2); + const [postUrl, post] = sw.fetch.mock.calls[0]; + expect(postUrl).toBe('/api/notifications/push/subscriptions'); + expect(post).toMatchObject({ method: 'POST', credentials: 'same-origin' }); + expect(JSON.parse(String(post?.body))).toEqual({ + subscription: { endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }, + }); + const [, del] = sw.fetch.mock.calls[1]; + expect(del).toMatchObject({ method: 'DELETE', credentials: 'same-origin' }); + expect(JSON.parse(String(del?.body))).toEqual({ endpoint: 'https://push.example/old' }); + }); + + it('FE-SW-PUSH-012: uses a subscription the browser already made', async () => { + const sw = boot(); + await fire(sw.listeners.pushsubscriptionchange, { + oldSubscription: null, + newSubscription: renewed('https://push.example/fresh'), + }); + expect(sw.registration.pushManager.subscribe).not.toHaveBeenCalled(); + expect(sw.fetch).toHaveBeenCalledTimes(1); + }); + + it('FE-SW-PUSH-013: keeps the row when the endpoint did not change', async () => { + const sw = boot(); + await fire(sw.listeners.pushsubscriptionchange, { + oldSubscription: { endpoint: 'https://push.example/same', options: { applicationServerKey: OLD_KEY } }, + newSubscription: renewed('https://push.example/same'), + }); + expect(sw.fetch).toHaveBeenCalledTimes(1); + }); + + it('FE-SW-PUSH-014: forgets nothing when the server did not take the new subscription', async () => { + const sw = boot(); + sw.fetch.mockResolvedValueOnce(new Response(null, { status: 401 })); + await fire(sw.listeners.pushsubscriptionchange, { + oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } }, + newSubscription: renewed('https://push.example/new'), + }); + expect(sw.fetch).toHaveBeenCalledTimes(1); + }); + + it('FE-SW-PUSH-015: gives up quietly, never with a rejected waitUntil', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const sw = boot(); + sw.registration.pushManager.subscribe.mockRejectedValueOnce(new Error('push service down')); + await expect( + fire(sw.listeners.pushsubscriptionchange, { + oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } }, + newSubscription: null, + }), + ).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + + await fire(sw.listeners.pushsubscriptionchange, { oldSubscription: null, newSubscription: null }); + expect(sw.fetch).not.toHaveBeenCalled(); + }); +}); + +describe('sw-push.js is wired into the service worker (drift guard)', () => { + it('FE-SW-PUSH-016: vite.config.js imports it and public/ ships it', () => { + const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8'); + expect(config).toMatch(/importScripts:\s*\[\s*'sw-push\.js'\s*\]/); + expect(existsSync(SOURCE_PATH)).toBe(true); + }); +}); diff --git a/client/tests/unit/stores/authStore.push.test.ts b/client/tests/unit/stores/authStore.push.test.ts new file mode 100644 index 000000000..a97e6c4f6 --- /dev/null +++ b/client/tests/unit/stores/authStore.push.test.ts @@ -0,0 +1,132 @@ +// FE-STORE-AUTH-PUSH-001 to FE-STORE-AUTH-PUSH-005: where Web Push sits in the +// sign-in and sign-out sequences. +// +// Logout has to forget this device's push subscription while the session cookie +// still exists, because the DELETE that tells the server needs it and the next +// step clears it. Sign-in re-registers the subscription the device holds, in +// the background, so a slow push service can never hold a login up. The push +// module has its own spec; here it is a pair of spies, except in the one case +// that runs the real logout teardown against a session that already ran out. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../helpers/msw/server'; +import { resetAllStores } from '../../helpers/store'; +import { buildUser } from '../../helpers/factories'; +import { useAuthStore } from '../../../src/store/authStore'; + +const push = vi.hoisted(() => ({ + forgetPushDeviceOnLogout: vi.fn(), + resyncPushSubscription: vi.fn(), +})); +vi.mock('../../../src/push/webPush', () => push); + +vi.mock('../../../src/sync/syncTriggers', () => ({ + registerSyncTriggers: vi.fn(), + unregisterSyncTriggers: vi.fn(), +})); + +beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); + push.forgetPushDeviceOnLogout.mockResolvedValue(undefined); + push.resyncPushSubscription.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('authStore and Web Push', () => { + it('FE-STORE-AUTH-PUSH-001: logout forgets the push device before the logout request clears the cookie', async () => { + useAuthStore.setState({ user: buildUser(), isAuthenticated: true }); + const order: string[] = []; + push.forgetPushDeviceOnLogout.mockImplementation(async () => { + order.push('forget push device'); + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + order.push(`fetch ${String(input)}`); + return new Response(null, { status: 200 }); + }); + + await useAuthStore.getState().logout(); + + expect(fetchSpy).toHaveBeenCalled(); + expect(order).toEqual(['forget push device', 'fetch /api/auth/logout']); + }); + + it('FE-STORE-AUTH-PUSH-002: the logout request waits until the push step has finished', async () => { + useAuthStore.setState({ user: buildUser(), isAuthenticated: true }); + let finishPush!: () => void; + push.forgetPushDeviceOnLogout.mockReturnValue(new Promise((resolve) => { finishPush = resolve; })); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + + const loggingOut = useAuthStore.getState().logout(); + await new Promise((r) => setTimeout(r, 10)); + expect(fetchSpy).not.toHaveBeenCalled(); + + finishPush(); + await loggingOut; + expect(fetchSpy).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ method: 'POST' })); + expect(useAuthStore.getState().user).toBeNull(); + }); + + it('FE-STORE-AUTH-PUSH-003: an expired session answers the push DELETE with 401, and logout still runs to the end', async () => { + // The real teardown this time: its DELETE must be a plain fetch, because + // apiClient would meet the 401 with its redirect to the login page. + const real = await vi.importActual('../../../src/push/webPush'); + push.forgetPushDeviceOnLogout.mockImplementation(real.forgetPushDeviceOnLogout); + const unsubscribe = vi.fn(async () => true); + const subscription = { endpoint: 'https://fcm.googleapis.com/fcm/send/this-device', unsubscribe }; + const registration = { pushManager: { getSubscription: vi.fn(async () => subscription) } }; + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: vi.fn(async () => registration) }, + }); + vi.stubGlobal('PushManager', function PushManager() {}); + vi.stubGlobal('Notification', { permission: 'granted' }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => + String(input) === '/api/notifications/push/subscriptions' + ? Response.json({ error: 'Authentication required', code: 'AUTH_REQUIRED' }, { status: 401 }) + : new Response(null, { status: 200 }), + ); + useAuthStore.setState({ user: buildUser(), isAuthenticated: true }); + const before = window.location.href; + + try { + await useAuthStore.getState().logout(); + } finally { + delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker; + vi.unstubAllGlobals(); + } + + expect(fetchSpy.mock.calls.map(([input]) => String(input))).toEqual([ + '/api/notifications/push/subscriptions', + '/api/auth/logout', + ]); + expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: 'DELETE', credentials: 'include' }); + expect(unsubscribe).toHaveBeenCalled(); + expect(window.location.href).toBe(before); + expect(useAuthStore.getState().user).toBeNull(); + }); + + it('FE-STORE-AUTH-PUSH-004: a successful login re-registers this device in the background', async () => { + const user = buildUser(); + server.use(http.post('/api/auth/login', () => HttpResponse.json({ user, token: 'tok' }))); + // Never settles: the login must not be waiting on it. + push.resyncPushSubscription.mockReturnValue(new Promise(() => {})); + + await useAuthStore.getState().login(user.email, 'password'); + + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(push.resyncPushSubscription).toHaveBeenCalledTimes(1); + }); + + it('FE-STORE-AUTH-PUSH-005: a failed login does not touch push', async () => { + server.use(http.post('/api/auth/login', () => HttpResponse.json({ error: 'Bad credentials' }, { status: 401 }))); + + await expect(useAuthStore.getState().login('bad@example.com', 'wrong')).rejects.toThrow(); + + expect(push.resyncPushSubscription).not.toHaveBeenCalled(); + }); +}); diff --git a/client/vite.config.js b/client/vite.config.js index 0be595f4d..a13053abf 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -51,6 +51,12 @@ export default defineConfig(({ mode }) => ({ navigateFallback: undefined, }, workbox: { + // The Web Push handlers (push, notificationclick, pushsubscriptionchange). + // Workbox writes importScripts('sw-push.js') at the top of the generated + // worker, in dev too. The file lives in public/: were it missing, the SPA + // fallback would answer with index.html and the new worker would fail to + // install, which tests/unit/pwa/swPush.test.ts guards against. + importScripts: ['sw-push.js'], // Anything above this is dropped from the precache manifest. The build does // not fail over it, it only prints "won't be precached", so the ceiling has // to sit close to the real bundle or an accidental heavyweight goes diff --git a/docker-compose.yml b/docker-compose.yml index bae396e83..be23de36f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,6 +59,9 @@ services: # - AMAP_API_KEY= # Optional. Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY. # - AMAP_API_SECRET= # Optional. The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise. # - AMAP_API_BASE=https://restapi.amap.com # Optional. Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com) +# - VAPID_PUBLIC_KEY= # Optional. Web Push key pair, public half (base64url). Unset, TREK generates a pair on first start and keeps it in the database, so it travels with backups. Set it together with VAPID_PRIVATE_KEY to use your own, e.g. from `npx web-push generate-vapid-keys`. Changing the pair means every device has to turn push on again. +# - VAPID_PRIVATE_KEY= # Optional. Web Push key pair, private half (base64url). Only used together with VAPID_PUBLIC_KEY; a mismatched pair, or only one half, turns push off with an error in the log until it is fixed, and every device stays registered. +# - VAPID_SUBJECT=mailto:admin@example.com # Optional. The contact push services see with every message: a mailto: address or an https:// URL. Defaults to APP_URL when it is https, else the TREK project page. volumes: - ./data:/app/data - ./uploads:/app/uploads diff --git a/server/.env.example b/server/.env.example index c287642a0..e1c78fd8d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -68,6 +68,10 @@ DEMO_MODE=false # Demo mode - resets data hourly # LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000) +# VAPID_PUBLIC_KEY= # Web Push: the public half of the key pair browsers subscribe with (base64url, 65 bytes). Leave both halves unset and TREK generates a pair on first start and keeps it in the database, so it travels with every backup. Set both to use a pair of your own, e.g. printed by `npx web-push generate-vapid-keys`. Changing the pair later means every device has to turn push on again. +# VAPID_PRIVATE_KEY= # Web Push: the private half (base64url, 32 bytes). Only used together with VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns push off with an error in the log until the pair is fixed. A pair stored in the database is not used in its place, and every device stays registered. +# VAPID_SUBJECT=mailto:admin@example.com # Web Push: the contact push services see with every message, a mailto: address or an https:// URL. Unset, TREK uses APP_URL when it is https, else the TREK project page. + # UNSPLASH_ACCESS_KEY= # Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here. # Initial admin account — ONLY applied on the first boot, when the database has no diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts index 5ce11fe7b..49ee44c08 100644 --- a/server/scripts/migrate-encryption.ts +++ b/server/scripts/migrate-encryption.ts @@ -283,6 +283,10 @@ async function main() { // rotation leaves it encrypted under the old key, which reads back as "no // key" and silently drops the install to OpenStreetMap, so the copy is // pinned by tests/unit/db/migrate-encryption-parity.test.ts. + // + // web_push_vapid_private_key is the Web Push signing key VapidKeysService + // generates. Missed here it would no longer decrypt under the new key, and + // push would stay off on every device until the old key came back. for (const key of [ 'oidc_client_secret', 'smtp_pass', @@ -291,6 +295,7 @@ async function main() { 'maps_api_key', 'unsplash_api_key', 'amap_api_key', + 'web_push_vapid_private_key', ]) { const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined; if (!row?.value) continue; diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index ed6c90700..d3c411532 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -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 diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 8f817239e..a7de06d3b 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -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), }; } diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index d4611ae57..58c725802 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -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, diff --git a/server/src/app-config/env.ts b/server/src/app-config/env.ts index 8e3d0f415..34ee6fbed 100644 --- a/server/src/app-config/env.ts +++ b/server/src/app-config/env.ts @@ -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 = 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; diff --git a/server/src/app-config/index.ts b/server/src/app-config/index.ts index 211f078a4..f43499d84 100644 --- a/server/src/app-config/index.ts +++ b/server/src/app-config/index.ts @@ -20,6 +20,7 @@ export { deriveDb, derivePaths, deriveNet, + derivePush, } from './derive'; export * from './parsers'; export { envSchema } from './env.schema'; diff --git a/server/src/app-config/parsers.ts b/server/src/app-config/parsers.ts index 4eae83454..d42bcd0f5 100644 --- a/server/src/app-config/parsers.ts +++ b/server/src/app-config/parsers.ts @@ -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; + } +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 47235d413..123acd9ae 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 4bd5df726..647410d7d 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -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, 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 = {}; 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; } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 5cc8e3af1..b4098a202 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -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); } }); diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 844c17cd6..57811084d 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -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. diff --git a/server/src/nest/notifications/channels/builtins.ts b/server/src/nest/notifications/channels/builtins.ts index 1f713c722..2ffe684bb 100644 --- a/server/src/nest/notifications/channels/builtins.ts +++ b/server/src/nest/notifications/channels/builtins.ts @@ -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. */ diff --git a/server/src/nest/notifications/notification-events.ts b/server/src/nest/notifications/notification-events.ts index 5b542efc4..525df912e 100644 --- a/server/src/nest/notifications/notification-events.ts +++ b/server/src/nest/notifications/notification-events.ts @@ -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; /** diff --git a/server/src/nest/notifications/notification-preferences.service.ts b/server/src/nest/notifications/notification-preferences.service.ts index 5f627aedb..1bd1d75ab 100644 --- a/server/src/nest/notifications/notification-preferences.service.ts +++ b/server/src/nest/notifications/notification-preferences.service.ts @@ -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 = { 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), }); } diff --git a/server/src/nest/notifications/notifications.dto.ts b/server/src/nest/notifications/notifications.dto.ts index cdce0d64c..253bed0b0 100644 --- a/server/src/nest/notifications/notifications.dto.ts +++ b/server/src/nest/notifications/notifications.dto.ts @@ -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) {} diff --git a/server/src/nest/notifications/notifications.module.ts b/server/src/nest/notifications/notifications.module.ts index a5aa951f6..592a5b0f1 100644 --- a/server/src/nest/notifications/notifications.module.ts +++ b/server/src/nest/notifications/notifications.module.ts @@ -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, diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index f5923d080..31176a7ac 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -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. diff --git a/server/src/nest/notifications/push/push-subscription.helpers.ts b/server/src/nest/notifications/push/push-subscription.helpers.ts new file mode 100644 index 000000000..bfc31a860 --- /dev/null +++ b/server/src/nest/notifications/push/push-subscription.helpers.ts @@ -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') }, + }; +} diff --git a/server/src/nest/notifications/push/push-subscriptions.service.ts b/server/src/nest/notifications/push/push-subscriptions.service.ts new file mode 100644 index 000000000..d8ca15bfb --- /dev/null +++ b/server/src/nest/notifications/push/push-subscriptions.service.ts @@ -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('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 + ); + } +} diff --git a/server/src/nest/notifications/push/push.controller.ts b/server/src/nest/notifications/push/push.controller.ts new file mode 100644 index 000000000..2cd5a910f --- /dev/null +++ b/server/src/nest/notifications/push/push.controller.ts @@ -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; + } + } +} diff --git a/server/src/nest/notifications/push/vapid-keys.service.ts b/server/src/nest/notifications/push/vapid-keys.service.ts new file mode 100644 index 000000000..b720a33ae --- /dev/null +++ b/server/src/nest/notifications/push/vapid-keys.service.ts @@ -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; + +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 = { + 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; + } +} diff --git a/server/src/nest/notifications/push/web-push-crypto.ts b/server/src/nest/notifications/push/web-push-crypto.ts new file mode 100644 index 000000000..65425fa6d --- /dev/null +++ b/server/src/nest/notifications/push/web-push-crypto.ts @@ -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=, k=`, 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(); + 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; diff --git a/server/src/nest/notifications/transports/web-push.service.ts b/server/src/nest/notifications/transports/web-push.service.ts new file mode 100644 index 000000000..841f61076 --- /dev/null +++ b/server/src/nest/notifications/transports/web-push.service.ts @@ -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 = 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:`, 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, +): 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/server/tests/e2e/push.e2e.test.ts b/server/tests/e2e/push.e2e.test.ts new file mode 100644 index 000000000..c8ad14821 --- /dev/null +++ b/server/tests/e2e/push.e2e.test.ts @@ -0,0 +1,268 @@ +/** + * Web Push routes e2e: /api/notifications/push/* and the push test send + * through the real JwtAuthGuard, the Zod pipe and the real providers on a temp + * SQLite db. Only the outbound POST (safeFetchFollow) is replaced. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { createECDH } from 'node:crypto'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +// The harness db (users for the guard) plus the two tables push reads, built +// inside the factory; the test reaches it through the mocked module. +vi.mock('../../src/db/database', async () => { + const { createTempDb } = await import('./harness'); + const db = createTempDb(); + db.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); + // Same DDL as migration 244. + db.exec(`CREATE TABLE 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 + );`); + return { db, closeDb: () => {}, reinitialize: () => {} }; +}); + +const { safeFetchFollow } = vi.hoisted(() => ({ safeFetchFollow: vi.fn() })); +vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({ + ...(await importOriginal()), + safeFetchFollow, +})); + +import { db } from '../../src/db/database'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { NotificationsModule } from '../../src/nest/notifications/notifications.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; + +const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/e2e-device'; + +function subscription(endpoint = ENDPOINT) { + const ua = createECDH('prime256v1'); + ua.generateKeys(); + return { + endpoint, + expirationTime: null, + keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 3).toString('base64url') }, + }; +} + +function rows() { + return db + .prepare('SELECT user_id, endpoint, vapid_public_key, user_agent FROM push_subscriptions ORDER BY id') + .all() as { + user_id: number; + endpoint: string; + vapid_public_key: string; + user_agent: string | null; + }[]; +} + +describe('Web Push e2e (real auth guard + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [DatabaseModule, RealtimeModule, NotificationsModule], + }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db, { id: 1, email: 'one@example.test' }); + seedUser(db, { id: 2, email: 'two@example.test' }); + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + db.prepare('DELETE FROM push_subscriptions').run(); + safeFetchFollow.mockReset(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('401 on every push route without a session cookie', async () => { + expect((await request(server).get('/api/notifications/push/public-key')).status).toBe(401); + expect( + (await request(server).post('/api/notifications/push/subscriptions').send({ subscription: subscription() })) + .status, + ).toBe(401); + expect( + (await request(server).delete('/api/notifications/push/subscriptions').send({ endpoint: ENDPOINT })).status, + ).toBe(401); + }); + + it('GET public-key: the pair exists from bootstrap on, so the route only reads it', async () => { + const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'web_push_vapid_public_key'").get() as { + value: string; + }; + const res = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ publicKey: stored.value }); + const raw = Buffer.from(res.body.publicKey, 'base64url'); + expect(raw.length).toBe(65); + expect(raw[0]).toBe(0x04); + }); + + it('POST subscriptions: 200 with the device count, stored against the served key', async () => { + const { body: key } = await request(server) + .get('/api/notifications/push/public-key') + .set('Cookie', sessionCookie(1)); + const res = await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .set('User-Agent', 'e2e-browser/1.0') + .send({ subscription: subscription() }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, devices: 1 }); + expect(rows()).toEqual([ + { user_id: 1, endpoint: ENDPOINT, vapid_public_key: key.publicKey, user_agent: 'e2e-browser/1.0' }, + ]); + }); + + it('POST subscriptions: the same browser subscribing for another account moves to it', async () => { + await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ subscription: subscription() }); + const res = await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(2)) + .send({ subscription: subscription() }); + expect(res.body).toEqual({ success: true, devices: 1 }); + expect(rows().map((r) => r.user_id)).toEqual([2]); + }); + + it('POST subscriptions: 400 with the bespoke message for a host that is not a push service', async () => { + const res = await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ subscription: subscription('https://example.com/collect') }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Push endpoint is not a known push service' }); + expect(rows()).toEqual([]); + }); + + it('POST subscriptions: 400 from the contract when the keys are missing', async () => { + const res = await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ subscription: { endpoint: ENDPOINT } }); + expect(res.status).toBe(400); + expect(res.body.error).toEqual(expect.stringContaining('keys')); + }); + + it('DELETE subscriptions: only the owner removes the endpoint, and a repeat is still 200', async () => { + await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ subscription: subscription() }); + + const stranger = await request(server) + .delete('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(2)) + .send({ endpoint: ENDPOINT }); + expect(stranger.status).toBe(200); + expect(rows()).toHaveLength(1); + + const owner = await request(server) + .delete('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ endpoint: ENDPOINT }); + expect(owner.status).toBe(200); + expect(owner.body).toEqual({ success: true }); + expect(rows()).toEqual([]); + + const again = await request(server) + .delete('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ endpoint: ENDPOINT }); + expect(again.status).toBe(200); + }); + + it('DELETE subscriptions: 400 without an endpoint', async () => { + const res = await request(server) + .delete('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({}); + expect(res.status).toBe(400); + }); + + it('POST test/push: sends the test message to the caller’s devices through the SSRF-guarded fetch', async () => { + const none = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1)); + expect(none.status).toBe(200); + expect(none.body).toEqual({ success: false, error: 'Channel is not configured for this user' }); + + await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ subscription: subscription() }); + safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } }); + const res = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(safeFetchFollow).toHaveBeenCalledWith( + ENDPOINT, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'Content-Encoding': 'aes128gcm' }), + }), + { maxRedirects: 0, bypassInternalIpAllowed: true }, + ); + }); + + it('a stored private key that no longer decrypts turns push off with a 503 and is never replaced', async () => { + const readKey = (key: string) => + (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string }).value; + const unreadable = 'enc:v1:bm90IGEgY2lwaGVydGV4dA'; + const publicKey = readKey('web_push_vapid_public_key'); + const ciphertext = readKey('web_push_vapid_private_key'); + await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ subscription: subscription() }); + db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(unreadable, 'web_push_vapid_private_key'); + try { + const key = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1)); + expect(key.status).toBe(503); + expect(key.body).toEqual({ error: 'Web Push is currently unavailable on this server' }); + const sub = await request(server) + .post('/api/notifications/push/subscriptions') + .set('Cookie', sessionCookie(1)) + .send({ subscription: subscription(`${ENDPOINT}-second`) }); + expect(sub.status).toBe(503); + const test = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1)); + expect(test.status).toBe(200); + expect(test.body).toEqual({ success: false, error: 'Web Push is currently unavailable on this server' }); + expect(safeFetchFollow).not.toHaveBeenCalled(); + // The device registered before stays, and neither stored row was touched. + expect(rows().map((r) => r.endpoint)).toEqual([ENDPOINT]); + expect(readKey('web_push_vapid_public_key')).toBe(publicKey); + expect(readKey('web_push_vapid_private_key')).toBe(unreadable); + } finally { + db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, 'web_push_vapid_private_key'); + } + const back = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1)); + expect(back.body).toEqual({ publicKey }); + }); +}); diff --git a/server/tests/helpers/notifications.ts b/server/tests/helpers/notifications.ts index a9293800d..afbd6b28c 100644 --- a/server/tests/helpers/notifications.ts +++ b/server/tests/helpers/notifications.ts @@ -5,13 +5,16 @@ import { NotificationPreferencesService } from '../../src/nest/notifications/not import { NotificationsService } from '../../src/nest/notifications/notifications.service'; import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service'; import { WebhookService } from '../../src/nest/notifications/transports/webhook.service'; +import { WebPushService } from '../../src/nest/notifications/transports/web-push.service'; +import { PushSubscriptionsService } from '../../src/nest/notifications/push/push-subscriptions.service'; +import { VapidKeysService } from '../../src/nest/notifications/push/vapid-keys.service'; /** * A NotificationsService wired the way Nest wires it. * - * The domain takes six providers since the fold, and eight places used to build - * it by hand — every added constructor parameter was an eight-file diff. One - * helper keeps that at one. + * The domain takes seven providers since Web Push joined, and eight places used + * to build it by hand: every added constructor parameter was an eight-file + * diff. One helper keeps that at one. */ export function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): NotificationsService { const mailer = new MailerService(dbs); @@ -22,9 +25,15 @@ export function makeNotificationsService(dbs: DatabaseService, realtime = new Re new WebhookService(dbs), new NtfyService(dbs), new NotificationPreferencesService(dbs, mailer), + makeWebPushService(dbs), ); } +/** The Web Push transport over its two providers, on the same connection. */ +export function makeWebPushService(dbs: DatabaseService): WebPushService { + return new WebPushService(new VapidKeysService(dbs), new PushSubscriptionsService(dbs)); +} + /** The preferences half on its own, over the same connection. */ export function makeNotificationPreferencesService(dbs: DatabaseService): NotificationPreferencesService { return new NotificationPreferencesService(dbs, new MailerService(dbs)); diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 828b79f2a..99756524c 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -108,6 +108,7 @@ const RESET_TABLES = [ 'visited_countries', 'bucket_list', // Notifications & audit + 'push_subscriptions', 'notification_channel_preferences', 'notifications', 'audit_log', diff --git a/server/tests/unit/app-config/config-templates.test.ts b/server/tests/unit/app-config/config-templates.test.ts index 41465081c..d58af1649 100644 --- a/server/tests/unit/app-config/config-templates.test.ts +++ b/server/tests/unit/app-config/config-templates.test.ts @@ -81,3 +81,62 @@ describe('the Places and Amap switches in the templates', () => { } }); }); + +describe('the Web Push keys in the templates', () => { + // All optional, since TREK keeps a generated pair in its database; an + // operator who wants their own pair has to find the names somewhere. + const documented = ['VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY', 'VAPID_SUBJECT']; + + it('.env.example and docker-compose.yml document every one of them', () => { + for (const name of documented) { + expect(envExample).toContain(`# ${name}=`); + expect(compose).toContain(`# - ${name}=`); + } + }); + + it('the Helm ConfigMap passes through the public key and the subject', () => { + for (const name of ['VAPID_PUBLIC_KEY', 'VAPID_SUBJECT']) { + expect(helmValues).toContain(`# ${name}:`); + expect(helmConfigMap).toContain(`${name}: {{ .Values.env.${name} | quote }}`); + } + }); + + it('no template promises an admin address as the push contact, since TREK never sends one on its own', () => { + const subjectDocs = [ + envExample.split(/\r?\n/).find((l) => l.includes('# VAPID_SUBJECT=')), + compose.split(/\r?\n/).find((l) => l.includes('- VAPID_SUBJECT=')), + helmValues.slice(helmValues.indexOf('# VAPID_SUBJECT:')).split(/\r?\n\r?\n/)[0], + ]; + for (const doc of subjectDocs) { + expect(doc).toContain('APP_URL'); + expect(doc).not.toContain('ADMIN_EMAIL'); + } + }); + + it('no template says a broken pair is ignored, since push is off until it is fixed and no stored pair stands in', () => { + // The Helm comment wraps, so its lines are joined back into one sentence first. + const helmPrivateKey = helmValues + .slice(helmValues.indexOf('# Optional Web Push key pair, private half'), helmValues.indexOf(' VAPID_PRIVATE_KEY: ""')) + .replace(/\s*\n\s*#\s*/g, ' '); + const privateKeyDocs = [ + envExample.split(/\r?\n/).find((l) => l.includes('# VAPID_PRIVATE_KEY=')), + compose.split(/\r?\n/).find((l) => l.includes('- VAPID_PRIVATE_KEY=')), + helmPrivateKey, + ]; + for (const doc of privateKeyDocs) { + expect(doc).toContain('turns push off'); + expect(doc).not.toContain('ignored'); + expect(doc).not.toContain('keeps working'); + } + }); + + it('the Helm chart carries the private key in the Secret, never in the ConfigMap', () => { + const name = 'VAPID_PRIVATE_KEY'; + expect(helmValues).toContain(` ${name}: ""`); + expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} | b64enc | quote }}`); + expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} }}`); + expect(helmDeployment).toContain(`- name: ${name}\n valueFrom:\n secretKeyRef:`); + expect(helmDeployment).toContain(`key: ${name}\n optional: true`); + expect(helmConfigMap).not.toContain(name); + }); +}); diff --git a/server/tests/unit/app-config/derive.test.ts b/server/tests/unit/app-config/derive.test.ts index 79a9fef28..bc4b83047 100644 --- a/server/tests/unit/app-config/derive.test.ts +++ b/server/tests/unit/app-config/derive.test.ts @@ -16,6 +16,7 @@ import { deriveBackup, deriveNet, derivePaths, + derivePush, deriveAll, } from '../../../src/app-config/derive'; @@ -277,7 +278,7 @@ describe('deriveAll', () => { expect(env.demo.enabled).toBe(true); for (const ns of [ 'app', 'http', 'session', 'demo', 'adminBootstrap', 'oidc', 'smtp', 'mcp', - 'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net', + 'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net', 'push', ] as const) { expect(env[ns]).toBeDefined(); } @@ -309,3 +310,17 @@ describe('deriveMaps', () => { } }); }); + +describe('derivePush', () => { + it('passes the VAPID_* values through trimmed, blank counting as unset', () => { + expect(derivePush({})).toEqual({ vapidPublicKey: undefined, vapidPrivateKey: undefined, vapidSubject: undefined }); + expect( + derivePush({ VAPID_PUBLIC_KEY: ' BPub ', VAPID_PRIVATE_KEY: 'priv', VAPID_SUBJECT: ' mailto:ops@example.com ' }), + ).toEqual({ vapidPublicKey: 'BPub', vapidPrivateKey: 'priv', vapidSubject: 'mailto:ops@example.com' }); + expect(derivePush({ VAPID_PUBLIC_KEY: ' ', VAPID_SUBJECT: '' })).toEqual({ + vapidPublicKey: undefined, + vapidPrivateKey: undefined, + vapidSubject: undefined, + }); + }); +}); diff --git a/server/tests/unit/app-config/parsers.test.ts b/server/tests/unit/app-config/parsers.test.ts index 9836d5ae4..53d4eaf5c 100644 --- a/server/tests/unit/app-config/parsers.test.ts +++ b/server/tests/unit/app-config/parsers.test.ts @@ -3,6 +3,10 @@ import { describe, it, expect } from 'vitest'; import { csvList, csvListFiltered, + decodeBase64Url, + isP256PrivateKey, + isUncompressedP256Key, + isVapidSubject, nonNegativeIntOr, numberOr, parseBool, @@ -176,3 +180,37 @@ describe('parseLinkLocalAllowList (ALLOW_LINK_LOCAL_IPS)', () => { } }); }); + +describe('Web Push key material', () => { + it('decodeBase64Url refuses anything outside the base64url alphabet instead of skipping it', () => { + expect(decodeBase64Url('AQID')).toEqual(Buffer.from([1, 2, 3])); + expect(decodeBase64Url(' AQID ')).toEqual(Buffer.from([1, 2, 3])); + expect(decodeBase64Url('AQI=')).toEqual(Buffer.from([1, 2])); + expect(decodeBase64Url('-_8')).toEqual(Buffer.from([0xfb, 0xff])); + for (const value of ['', 'AQ+D', 'AQ/D', 'AQ ID', 'AQ=D', 'AQ===']) { + expect(decodeBase64Url(value), value).toBeNull(); + } + }); + + it('isUncompressedP256Key and isP256PrivateKey check the sizes the Web Push tools print', () => { + const point = Buffer.concat([Buffer.from([0x04]), Buffer.alloc(64, 1)]).toString('base64url'); + expect(isUncompressedP256Key(point)).toBe(true); + const compressed = Buffer.concat([Buffer.from([0x02]), Buffer.alloc(64, 1)]).toString('base64url'); + expect(isUncompressedP256Key(compressed)).toBe(false); + expect(isUncompressedP256Key(Buffer.alloc(33, 4).toString('base64url'))).toBe(false); + expect(isP256PrivateKey(Buffer.alloc(32, 9).toString('base64url'))).toBe(true); + expect(isP256PrivateKey(Buffer.alloc(31, 9).toString('base64url'))).toBe(false); + expect(isP256PrivateKey('not base64url!')).toBe(false); + }); + + it('isVapidSubject accepts a mailto: address or an https: URL and nothing else', () => { + const accepted = ['mailto:ops@example.com', 'MAILTO:ops@example.com', 'https://trek.example.com', ' https://x.test/c ']; + for (const ok of accepted) { + expect(isVapidSubject(ok), ok).toBe(true); + } + const refused = ['ops@example.com', 'mailto:', 'mailto:no-at-sign', 'mailto:a b@example.com', 'http://x.test', 'not a url']; + for (const bad of refused) { + expect(isVapidSubject(bad), bad).toBe(false); + } + }); +}); diff --git a/server/tests/unit/app-config/validate.test.ts b/server/tests/unit/app-config/validate.test.ts index acc31d4b4..2e647bd64 100644 --- a/server/tests/unit/app-config/validate.test.ts +++ b/server/tests/unit/app-config/validate.test.ts @@ -1,6 +1,31 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { z } from 'zod'; -import { validateEnvAtBoot, readEnv } from '../../../src/app-config/env'; +import { validateEnvAtBoot, readEnv, SECRET_ENV_KEYS } from '../../../src/app-config/env'; +import { envSchema } from '../../../src/app-config/env.schema'; + +const schemaShape: Record = envSchema.shape; + +/** + * A value no check of the schema accepts (not a number, URL, boolean, duration, + * language or base64url key), and one that names its variable, so a leak is + * traceable to the key it came from. + */ +const probe = (key: string): string => `probe+${key}/not valid=`; + +/** Everything one boot check printed and threw. */ +function bootReport(raw: Record): { printed: string; thrown: string } { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + let thrown = ''; + try { + validateEnvAtBoot(raw); + } catch (err) { + thrown = err instanceof Error ? err.message : String(err); + } + const printed = error.mock.calls.map((c) => c.join(' ')).join('\n'); + error.mockRestore(); + return { printed, thrown }; +} describe('validateEnvAtBoot', () => { afterEach(() => { @@ -58,6 +83,98 @@ describe('validateEnvAtBoot', () => { expect(() => validateEnvAtBoot({ ALLOW_LINK_LOCAL_IPS: '' })).not.toThrow(); }); + it('checks the VAPID_* shapes and lets a well-formed set through', () => { + // The application server pair from RFC 8291 Appendix A, in the form the Web Push tools print. + const publicKey = 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8'; + const privateKey = 'yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw'; + expect(() => + validateEnvAtBoot({ + VAPID_PUBLIC_KEY: publicKey, + VAPID_PRIVATE_KEY: privateKey, + VAPID_SUBJECT: 'mailto:ops@example.com', + }), + ).not.toThrow(); + expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'https://trek.example.com' })).not.toThrow(); + // Whether the halves belong together is checked where they are used, not here. + expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: privateKey })).not.toThrow(); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => validateEnvAtBoot({ VAPID_PUBLIC_KEY: privateKey })).toThrow(); + expect(() => validateEnvAtBoot({ VAPID_PUBLIC_KEY: `A${publicKey.slice(1)}` })).toThrow(); + expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: publicKey })).toThrow(); + expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: 'yfWPiYE+n46HLnH0KqZOF1fJJU3MYrct3AELtAQ/oRw' })).toThrow(); + expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'ops@example.com' })).toThrow(); + expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'http://trek.example.com' })).toThrow(); + }); + + it('never prints the value of a secret variable, only its name and the problem', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // 43 characters like a real key, with one character base64url does not have. + const leakedPrivateKey = 'yfWPiYE+n46HLnH0KqZOF1fJJU3MYrct3AELtAQ/oRw'; + expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: leakedPrivateKey, PORT: 'not-a-port' })).toThrow( + /2 problems/, + ); + const report = error.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(report).toContain('VAPID_PRIVATE_KEY=***: must be a base64url-encoded P-256 private key'); + expect(report).not.toContain(leakedPrivateKey); + expect(report).not.toContain('yfWPiYE'); + // Everything else keeps its value, which is what makes a typo findable. + expect(report).toContain('PORT="not-a-port"'); + }); + + it('never prints any secret variable, whether or not its own check refuses the value', () => { + // A broken PORT guarantees a report, so every secret gets its chance to leak into it. + const { printed, thrown } = bootReport({ + ...Object.fromEntries([...SECRET_ENV_KEYS].map((key) => [key, probe(key)])), + PORT: 'not-a-port', + }); + expect(thrown).toMatch(/Invalid environment configuration/); + expect(printed).toContain('PORT="not-a-port"'); + let refused = 0; + for (const key of SECRET_ENV_KEYS) { + expect(printed, key).not.toContain(probe(key)); + expect(thrown, key).not.toContain(probe(key)); + if (!schemaShape[key].safeParse(probe(key)).success) { + refused += 1; + expect(printed, key).toContain(` - ${key}=***: `); + } + } + // The masking only proves itself on a variable the schema refuses. + expect(refused).toBeGreaterThan(0); + }); + + it('still prints the value of every variable that is not a secret', () => { + const open = Object.keys(schemaShape).filter((key) => !SECRET_ENV_KEYS.has(key)); + const { printed } = bootReport(Object.fromEntries(open.map((key) => [key, probe(key)]))); + // Free-form variables accept anything, so only the checked ones show up. + const refused = open.filter((key) => !schemaShape[key].safeParse(probe(key)).success); + expect(refused).toContain('VAPID_PUBLIC_KEY'); + for (const key of refused) expect(printed, key).toContain(` - ${key}=${JSON.stringify(probe(key))}: `); + expect(printed.split('\n').filter((line) => line.startsWith(' - '))).toHaveLength(refused.length); + }); + + it('knows every secret-looking variable of the schema as a secret, and nothing the schema does not know', () => { + // A new variable with a word in its name that suggests a credential lands + // here and has to be decided on. Whole words, so OVERPASS_URL and + // WEBAUTHN_RP_ID do not count. + const looksSecret = (key: string): boolean => + /(^|_)(SECRET|PASS(WORD|PHRASE)?|TOKEN|KEY|APIKEY|CREDENTIALS?|PRIVATE|LICEN[CS]E|WEBHOOK|AUTH|DSN|SALT)(_|$)/.test(key); + const notSecret = new Set([ + 'VAPID_PUBLIC_KEY', // the public half, which every subscribing browser receives + 'TREK_PLUGIN_ALLOW_PRIVATE_EGRESS', // a switch about private networks, not a private value + ]); + const schemaKeys = Object.keys(schemaShape); + const flagged = schemaKeys.filter((key) => looksSecret(key) && !notSecret.has(key)); + expect(flagged.length).toBeGreaterThan(5); + for (const key of flagged) expect(SECRET_ENV_KEYS.has(key), key).toBe(true); + for (const key of SECRET_ENV_KEYS) expect(schemaKeys, key).toContain(key); + for (const key of notSecret) { + expect(schemaKeys, key).toContain(key); + expect(looksSecret(key), key).toBe(true); + expect(SECRET_ENV_KEYS.has(key), key).toBe(false); + } + }); + it('treats blank values as unset (defaults apply, no error)', () => { expect(() => validateEnvAtBoot({ DEMO_MODE: '', PORT: ' ', TZ: '' })).not.toThrow(); }); diff --git a/server/tests/unit/db/migrate-encryption-parity.test.ts b/server/tests/unit/db/migrate-encryption-parity.test.ts index 9a3c8ab3d..b529eda99 100644 --- a/server/tests/unit/db/migrate-encryption-parity.test.ts +++ b/server/tests/unit/db/migrate-encryption-parity.test.ts @@ -95,6 +95,19 @@ describe('migrate-encryption.ts app_settings parity', () => { } }); + // VapidKeysService stores the Web Push signing key with encrypt_api_key. + // Missed by a rotation it no longer decrypts, and push stays off on every + // device until the old key is back. + it('ROTPAR-012: rotates the stored Web Push private key', () => { + const vapid = fs.readFileSync( + path.join(SERVER_ROOT, 'src', 'nest', 'notifications', 'push', 'vapid-keys.service.ts'), + 'utf8', + ); + expect(vapid).toContain("VAPID_PRIVATE_KEY_SETTING = 'web_push_vapid_private_key'"); + expect(vapid).toContain('encrypt_api_key(keys.privateKey)'); + expect(appSettingsLoop).toContain("'web_push_vapid_private_key'"); + }); + it('ROTPAR-003: the canonical list is not empty, so ROTPAR-001 cannot pass vacuously', () => { expect(INSTANCE_API_KEY_NAMES.length).toBeGreaterThan(0); }); diff --git a/server/tests/unit/middleware/globalMiddleware.redaction.test.ts b/server/tests/unit/middleware/globalMiddleware.redaction.test.ts index b092a13f4..1d06749bc 100644 --- a/server/tests/unit/middleware/globalMiddleware.redaction.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.redaction.test.ts @@ -80,6 +80,86 @@ describe('globalMiddleware request-log redaction', () => { expect(redact({ accessKeyId: 'ak-1', tokenCount: 5 })).toEqual({ accessKeyId: 'ak-1', tokenCount: 5 }); }); + it('hides the Web Push subscription body: the endpoint capability URL and both keys', () => { + // POST /api/notifications/push/subscriptions, as PushSubscription.toJSON() hands it over. + expect( + redact({ + subscription: { + endpoint: 'https://fcm.googleapis.com/fcm/send/device-capability', + expirationTime: null, + keys: { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA', auth: 'tBHItJI5svbpez7KI4CCXg' }, + }, + }), + ).toEqual({ + subscription: { + endpoint: '[REDACTED]', + expirationTime: null, + keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' }, + }, + }); + }); + + it('hides the subscription the service worker posts again after the browser renewed it', () => { + // sw-push.js on pushsubscriptionchange: the same route and shape, and a renewed + // subscription may carry a real expiry instead of null. + expect( + redact({ + subscription: { + endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/renewed-capability', + expirationTime: 1767225600000, + keys: { p256dh: 'BOr1renewedPublicKey', auth: 'renewedAuthSecret' }, + }, + }), + ).toEqual({ + subscription: { + endpoint: '[REDACTED]', + expirationTime: 1767225600000, + keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' }, + }, + }); + }); + + it('hides the endpoint of a subscription that arrives without its keys', () => { + // The route refuses this body with a 400, but the request is logged all the same. + expect( + redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/keyless', expirationTime: null } }), + ).toEqual({ subscription: { endpoint: '[REDACTED]', expirationTime: null } }); + expect(redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/lone' } })).toEqual({ + subscription: { endpoint: '[REDACTED]' }, + }); + expect( + redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/odd-keys', keys: 'not-an-object' } }), + ).toEqual({ subscription: { endpoint: '[REDACTED]', keys: 'not-an-object' } }); + }); + + it('hides the endpoint of the body that forgets a push device', () => { + // DELETE /api/notifications/push/subscriptions (logout, switching push off, the worker's renewal). + expect(redact({ endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ endpoint: '[REDACTED]' }); + expect(redact({ Endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ Endpoint: '[REDACTED]' }); + }); + + it('keeps an endpoint that is an ordinary setting readable, like the S3 backend URL', () => { + expect( + redact({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: 'sk' }), + ).toEqual({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: '[REDACTED]' }); + expect(redact({ endpoint: 'https://s3.example.com', forcePathStyle: true })).toEqual({ + endpoint: 'https://s3.example.com', + forcePathStyle: true, + }); + }); + + it('decides p256dh and auth by name wherever they appear, and nothing near them by accident', () => { + // Only the push subscription sends either field today; an `auth` anywhere + // else would carry credentials too. The fields around them stay readable. + expect(redact({ p256dh: 'BPub', Auth: 'secret', auth_method: 'basic', author: 'Ada' })).toEqual({ + p256dh: '[REDACTED]', + Auth: '[REDACTED]', + auth_method: 'basic', + author: 'Ada', + }); + expect(redact({ auth: { user: 'mailer', pass: 'hunter2' } })).toEqual({ auth: '[REDACTED]' }); + }); + it('passes non-object values through untouched', () => { expect(redact('plain string')).toBe('plain string'); expect(redact(42)).toBe(42); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 39c7dda7e..88551e76e 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -607,6 +607,20 @@ const pv = (id: number): number => const mcpTokenCount = (id: number): number => (testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n; +const addPushDevice = (userId: number, endpoint: string): void => { + testDb + .prepare( + "INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')", + ) + .run(userId, endpoint); +}; + +const pushDeviceCount = (id: number): number => + (testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?').get(id) as { n: number }).n; + +const passwordHash = (id: number): string => + (testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(id) as { password_hash: string }).password_hash; + describe('admin password reset revokes what an intruder already holds', () => { it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', () => { // An admin sets somebody else's password for one reason: the account is @@ -630,15 +644,49 @@ describe('admin password reset revokes what an intruder already holds', () => { expect(mcpTokenCount(user.id)).toBe(0); }); + it('ADMIN-SVC-081b: and forgets the push devices, which outlive every session, of that user only', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder'); + addPushDevice(user.id, 'https://web.push.apple.com/owner'); + addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander'); + + updateUser(String(user.id), { password: 'ANewStrongPass123!' }); + + expect(pushDeviceCount(user.id)).toBe(0); + expect(pushDeviceCount(other.id)).toBe(1); + }); + + it('ADMIN-SVC-081c: drops them in the same transaction as the password, so a failure leaves the account as it was', () => { + const { user } = createUser(testDb); + const before = pv(user.id); + const hashBefore = passwordHash(user.id); + testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder'); + testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END"); + try { + expect(() => updateUser(String(user.id), { password: 'ANewStrongPass123!' })).toThrow('boom'); + } finally { + testDb.exec('DROP TRIGGER boom'); + } + + expect(pv(user.id)).toBe(before); + expect(passwordHash(user.id)).toBe(hashBefore); + expect(mcpTokenCount(user.id)).toBe(1); + expect(pushDeviceCount(user.id)).toBe(1); + }); + it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', () => { const { user } = createUser(testDb); const before = pv(user.id); testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed'); updateUser(String(user.id), { username: 'renamed' }); expect(pv(user.id)).toBe(before); expect(mcpTokenCount(user.id)).toBe(1); + expect(pushDeviceCount(user.id)).toBe(1); }); }); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index d568c9922..86c23c841 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -108,6 +108,19 @@ const svc = new AuthService( new AllowedFileTypesService(new DatabaseService(testDb)), ); +/** A Web Push device row; the password paths only care whose it is. */ +function addPushDevice(userId: number, endpoint: string): void { + testDb + .prepare( + "INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')", + ) + .run(userId, endpoint); +} + +function pushDeviceCount(userId: number): number { + return (testDb.prepare('SELECT COUNT(*) c FROM push_subscriptions WHERE user_id = ?').get(userId) as { c: number }).c; +} + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -495,6 +508,51 @@ describe('changePassword — session invalidation', () => { expect(decoded.remember).toBe(true); expect(decoded.exp - decoded.iat).toBe(2592000); // remember window survives the change }); + + it('AUTH-DB-036e: forgets every push device of the user, and only of that user', () => { + const { user, password } = createUser(testDb); + const { user: other } = createUser(testDb); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1'); + addPushDevice(user.id, 'https://web.push.apple.com/mine-2'); + addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs'); + + const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); + + expect(result.success).toBe(true); + expect(pushDeviceCount(user.id)).toBe(0); + expect(pushDeviceCount(other.id)).toBe(1); + }); + + it('AUTH-DB-036f: a refused change keeps the push devices', () => { + const { user } = createUser(testDb); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + + const result = svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' }); + + expect(result.status).toBe(401); + expect(pushDeviceCount(user.id)).toBe(1); + }); + + it('AUTH-DB-036g: drops the push devices in the same transaction as the password change, so a failure changes nothing', () => { + const { user, password } = createUser(testDb); + tokens.createMcpToken(user.id, 'cli'); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END"); + try { + expect(() => + svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }), + ).toThrow('boom'); + } finally { + testDb.exec('DROP TRIGGER boom'); + } + + expect(pvOf(user.id)).toBe(0); + expect(mcpCount(user.id)).toBe(1); + expect(pushDeviceCount(user.id)).toBe(1); + // The new hash rolled back as well, so the old password is still the one that counts. + expect(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }).success) + .toBe(true); + }); }); // --------------------------------------------------------------------------- @@ -918,6 +976,42 @@ describe('resetPassword', () => { .toEqual({ error: 'This reset link has already been used', status: 400 }); }); + it('AUTH-DB-082b: forgets every push device of the user along with the MCP tokens', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + tokens.createMcpToken(user.id, 'cli'); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs'); + const issued = svc.requestPasswordReset(user.email, null); + + expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) + .toEqual({ success: true, userId: user.id }); + + expect((testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(user.id) as { c: number }).c).toBe(0); + expect(pushDeviceCount(user.id)).toBe(0); + expect(pushDeviceCount(other.id)).toBe(1); + }); + + it('AUTH-DB-082c: drops the push devices in the same transaction that burns the link, so a failure keeps the link', () => { + const { user } = createUser(testDb); + addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + const issued = svc.requestPasswordReset(user.email, null); + testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END"); + try { + expect(() => svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).toThrow('boom'); + } finally { + testDb.exec('DROP TRIGGER boom'); + } + + expect(pushDeviceCount(user.id)).toBe(1); + const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }; + expect(row.password_version).toBe(0); + // Nothing was burned, so the same link still completes the reset. + expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) + .toEqual({ success: true, userId: user.id }); + expect(pushDeviceCount(user.id)).toBe(0); + }); + it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', () => { expect(svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 }); expect(svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 }); diff --git a/server/tests/unit/nest/channel-registry.test.ts b/server/tests/unit/nest/channel-registry.test.ts index 8d3fce04b..71e3ed513 100644 --- a/server/tests/unit/nest/channel-registry.test.ts +++ b/server/tests/unit/nest/channel-registry.test.ts @@ -2,7 +2,7 @@ * Unit tests for the external notification channel registry. * Covers CHREG-001 to CHREG-008. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { listChannels, getChannel, @@ -18,14 +18,22 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel import type { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; import type { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service'; import type { WebhookService } from '../../../src/nest/notifications/transports/webhook.service'; +import type { WebPushService } from '../../../src/nest/notifications/transports/web-push.service'; // The built-ins take their transports as an argument now; these cases only care -// that the three ids land in the registry with the right privileges, so the +// that the four ids land in the registry with the right privileges, so the // transports are stubs. +const pushStub = { + isAvailable: vi.fn(() => true), + hasDevices: vi.fn((userId: number) => userId === 7), + sendToUser: vi.fn(async () => true), + sendTest: vi.fn(async () => ({ success: true })), +}; const stubTransports = { mailer: { isSmtpConfigured: () => true, getUserEmail: () => null } as unknown as MailerService, webhook: { getUserWebhookUrl: () => null, getAdminWebhookUrl: () => null } as unknown as WebhookService, ntfy: { getUserNtfyConfig: () => null, getAdminNtfyConfig: () => ({ server: null, topic: null, token: null }) } as unknown as NtfyService, + push: pushStub as unknown as WebPushService, }; function fakeChannel(id: string, over: Partial = {}): ExternalChannel { @@ -52,8 +60,8 @@ afterEach(() => { }); describe('channelRegistry', () => { - it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => { - expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']); + it('CHREG-001: the four built-in external channels are registered; in-app is not', () => { + expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']); expect(getChannel('inapp')).toBeUndefined(); }); @@ -71,14 +79,14 @@ describe('channelRegistry', () => { expect(getChannel('plugin:gotify')).toBeDefined(); live = false; expect(getChannel('plugin:gotify')).toBeUndefined(); - expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']); + expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']); }); it('CHREG-004 — a throwing plugin source cannot take notifications down', () => { setPluginChannelSource(() => { throw new Error('runtime exploded'); }); - expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']); + expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']); }); it('CHREG-005 — a plugin can never claim a built-in id', () => { @@ -92,12 +100,50 @@ describe('channelRegistry', () => { expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true); expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined(); expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined(); + expect(getChannel('push')?.bypassesActiveToggleForAdminEvents).toBeUndefined(); }); it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => { expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true); expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true); expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined(); + expect(getChannel('push')?.supportsAdminGlobal).toBeUndefined(); + expect(getChannel('push')?.sendGlobal).toBeUndefined(); + }); + + it('CHREG-008b: push carries every user event, but neither admin-scoped ones nor synology_session_cleared', () => { + const push = getChannel('push')!; + expect(push.source).toBe('builtin'); + expect(push.labelKey).toBe('settings.notificationPreferences.push'); + for (const event of ['trip_invite', 'collab_message', 'trip_reminder', 'plugin_notification'] as const) { + expect(push.supportsEvent(event)).toBe(true); + } + expect(push.supportsEvent('version_available')).toBe(false); + expect(push.supportsEvent('replica_failure')).toBe(false); + expect(push.supportsEvent('synology_session_cleared')).toBe(false); + }); + + it('CHREG-008c: push is configured for a user with a device and hands sends and tests to the transport', async () => { + const push = getChannel('push')!; + expect(push.isConfiguredFor(7)).toBe(true); + expect(push.isConfiguredFor(8)).toBe(false); + await push.sendToUser(7, MSG); + expect(pushStub.sendToUser).toHaveBeenCalledWith(7, MSG); + await expect(push.test!(7)).resolves.toEqual({ success: true }); + expect(pushStub.sendTest).toHaveBeenCalledWith(7); + }); + + it('CHREG-008d: push is ready only while it can sign, and hides meanwhile; a plugin cannot claim that', () => { + const push = getChannel('push')!; + expect(push.hiddenWhileInstanceUnconfigured).toBe(true); + expect(push.isInstanceConfigured!()).toBe(true); + pushStub.isAvailable.mockReturnValueOnce(false); + expect(push.isInstanceConfigured!()).toBe(false); + // Email keeps its column without SMTP, as before. + expect(getChannel('email')?.hiddenWhileInstanceUnconfigured).toBeUndefined(); + + setPluginChannelSource(() => [fakeChannel('plugin:gotify', { hiddenWhileInstanceUnconfigured: true })]); + expect(getChannel('plugin:gotify')?.hiddenWhileInstanceUnconfigured).toBeUndefined(); }); it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => { @@ -122,6 +168,6 @@ describe('channelRegistry', () => { setPluginChannelSource(() => [boom]); await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope'); // and the registry is unharmed - expect(listChannels()).toHaveLength(4); + expect(listChannels()).toHaveLength(5); }); }); diff --git a/server/tests/unit/nest/notification-preferences.service.test.ts b/server/tests/unit/nest/notification-preferences.service.test.ts index 6b9165ca9..0432d7171 100644 --- a/server/tests/unit/nest/notification-preferences.service.test.ts +++ b/server/tests/unit/nest/notification-preferences.service.test.ts @@ -43,10 +43,16 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service'; import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service'; import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry'; +import { makeWebPushService } from '../../helpers/notifications'; const dbs = new DatabaseService(testDb); const mailer = new MailerService(dbs); -registerBuiltinChannels({ mailer, webhook: new WebhookService(dbs), ntfy: new NtfyService(dbs) }); +registerBuiltinChannels({ + mailer, + webhook: new WebhookService(dbs), + ntfy: new NtfyService(dbs), + push: makeWebPushService(dbs), +}); const svc = new NotificationPreferencesService(dbs, mailer); // Legacy free-function names bound to the service, so the moved cases read as before. diff --git a/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts b/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts new file mode 100644 index 000000000..d326db43f --- /dev/null +++ b/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts @@ -0,0 +1,276 @@ +/** + * Web Push subscriptions: what the browser may hand over (PUSHSUB-CHK-*) and + * the table behind it (PUSHSUB-*), on the real schema including migration 244. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { createECDH } from 'node:crypto'; +import { MAX_PUSH_ENDPOINT_LENGTH, pushUnsubscribeRequestSchema } from '@trek/shared'; + +// One :memory: connection per file, created inside the factory so nothing has +// to be hoisted above the imports; the tests reach it through the mocked module. +vi.mock('../../../../src/db/database', async () => { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + return { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }; +}); +vi.mock('../../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +import { db as testDb } from '../../../../src/db/database'; +import { createTables } from '../../../../src/db/schema'; +import { runMigrations } from '../../../../src/db/migrations'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createUser } from '../../../helpers/factories'; +import { DatabaseService } from '../../../../src/nest/database/database.service'; +import { + MAX_PUSH_DEVICES_PER_USER, + PushSubscriptionsService, +} from '../../../../src/nest/notifications/push/push-subscriptions.service'; +import { + PUSH_SUBSCRIPTION_ERRORS, + checkPushEndpoint, + checkPushSubscription, + isPushServiceEndpoint, + type CheckedPushSubscription, +} from '../../../../src/nest/notifications/push/push-subscription.helpers'; + +const subs = new PushSubscriptionsService(new DatabaseService(testDb)); + +function browserKeys() { + const ua = createECDH('prime256v1'); + ua.generateKeys(); + return { p256dh: ua.getPublicKey('base64url'), auth: Buffer.from('0123456789abcdef').toString('base64url') }; +} + +function checked(endpoint: string): CheckedPushSubscription { + const result = checkPushSubscription({ endpoint, keys: browserKeys() }); + if ('error' in result) throw new Error(result.error); + return result.value; +} + +const FCM = 'https://fcm.googleapis.com/fcm/send/device-1'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +describe('checkPushEndpoint / checkPushSubscription', () => { + it('PUSHSUB-CHK-001: accepts the push services browsers use', () => { + for (const endpoint of [ + FCM, + 'https://android.googleapis.com/gcm/send/x', + 'https://updates.push.services.mozilla.com/wpush/v2/gAAAA', + 'https://push.services.mozilla.com/wpush/v1/x', + 'https://web.push.apple.com/QGx9', + 'https://api.push.apple.com/3/device/x', + 'https://wns2-par02p.notify.windows.com/w/?token=BQYAAA', + ]) { + expect(checkPushEndpoint(endpoint), endpoint).toBe('ok'); + expect(isPushServiceEndpoint(endpoint)).toBe(true); + } + }); + + it('PUSHSUB-CHK-002: refuses anything that is not a plain https URL', () => { + for (const endpoint of [ + 'not a url', + 'http://fcm.googleapis.com/fcm/send/x', + 'https://user:pw@fcm.googleapis.com/fcm/send/x', + 'https://fcm.googleapis.com:8443/fcm/send/x', + `https://fcm.googleapis.com/${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH)}`, + ]) { + expect(checkPushEndpoint(endpoint), endpoint).toBe('endpoint'); + } + }); + + it('PUSHSUB-CHK-002b: takes an endpoint exactly as long as the contract allows, and not one character more', () => { + const base = 'https://fcm.googleapis.com/fcm/send/'; + const longest = `${base}${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH - base.length)}`; + // The contract and the check share one limit, so a device that could register can also be sent to and removed. + expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: longest }).success).toBe(true); + expect(checkPushEndpoint(longest)).toBe('ok'); + expect(checkPushEndpoint(`${longest}a`)).toBe('endpoint'); + }); + + it('PUSHSUB-CHK-003: refuses hosts that are not push services, including lookalikes', () => { + for (const endpoint of [ + 'https://example.com/push', + 'https://127.0.0.1/push', + 'https://fcm.googleapis.com.evil.test/x', + 'https://evilnotify.windows.com/x', + 'https://.notify.windows.com/x', + 'https://push.apple.com/x', + ]) { + expect(checkPushEndpoint(endpoint), endpoint).not.toBe('ok'); + } + expect(checkPushSubscription({ endpoint: 'https://example.com/push', keys: browserKeys() })).toEqual({ + ok: false, + error: PUSH_SUBSCRIPTION_ERRORS.service, + }); + expect(checkPushSubscription({ endpoint: 'http://fcm.googleapis.com/x', keys: browserKeys() })).toEqual({ + ok: false, + error: PUSH_SUBSCRIPTION_ERRORS.endpoint, + }); + }); + + it('PUSHSUB-CHK-004: wants a p256dh on the curve and a 16-byte auth secret', () => { + const keys = browserKeys(); + const offCurve = Buffer.from(keys.p256dh, 'base64url'); + offCurve[64] ^= 0x01; + for (const p256dh of [ + '!!!', + Buffer.alloc(65, 4).toString('base64url'), + offCurve.toString('base64url'), + keys.auth, + ]) { + expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, p256dh } })).toEqual({ + ok: false, + error: PUSH_SUBSCRIPTION_ERRORS.p256dh, + }); + } + for (const auth of ['!!!', Buffer.alloc(8).toString('base64url')]) { + expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, auth } })).toEqual({ + ok: false, + error: PUSH_SUBSCRIPTION_ERRORS.auth, + }); + } + }); + + it('PUSHSUB-CHK-005: hands the keys back canonical, without padding', () => { + const keys = browserKeys(); + const result = checkPushSubscription({ + endpoint: FCM, + keys: { p256dh: `${keys.p256dh}=`, auth: `${keys.auth}==` }, + }); + expect(result).toEqual({ ok: true, value: { endpoint: FCM, p256dh: keys.p256dh, auth: keys.auth } }); + }); +}); + +describe('PushSubscriptionsService', () => { + it('PUSHSUB-001: stores a subscription with the key it was made for and counts devices', () => { + const { user } = createUser(testDb); + const sub = checked(FCM); + expect(subs.hasAny(user.id)).toBe(false); + expect(subs.upsert(user.id, sub, 'server-key', 'Mozilla/5.0')).toBe(1); + const [row] = subs.listForUser(user.id); + expect(row).toMatchObject({ + user_id: user.id, + endpoint: FCM, + p256dh: sub.p256dh, + auth: sub.auth, + vapid_public_key: 'server-key', + user_agent: 'Mozilla/5.0', + failure_count: 0, + last_success_at: null, + }); + expect(subs.hasAny(user.id)).toBe(true); + expect(subs.countForUser(user.id)).toBe(1); + }); + + it('PUSHSUB-002: subscribing again refreshes the same row instead of adding one', () => { + const { user } = createUser(testDb); + subs.upsert(user.id, checked(FCM), 'old-key'); + const [before] = subs.listForUser(user.id); + subs.recordFailure(before.id); + const fresh = checked(FCM); + expect(subs.upsert(user.id, fresh, 'new-key', null)).toBe(1); + const [after] = subs.listForUser(user.id); + expect(after.id).toBe(before.id); + expect(after).toMatchObject({ + p256dh: fresh.p256dh, + vapid_public_key: 'new-key', + failure_count: 0, + user_agent: null, + }); + }); + + it('PUSHSUB-003: a browser shared by two accounts belongs to whoever subscribed on it last', () => { + const { user: first } = createUser(testDb); + const { user: second } = createUser(testDb); + subs.upsert(first.id, checked(FCM), 'k'); + expect(subs.upsert(second.id, checked(FCM), 'k')).toBe(1); + expect(subs.countForUser(first.id)).toBe(0); + expect(subs.listForUser(second.id).map((r) => r.endpoint)).toEqual([FCM]); + }); + + it('PUSHSUB-004: caps the devices per user and drops the one registered longest ago', () => { + const { user } = createUser(testDb); + for (let i = 0; i < MAX_PUSH_DEVICES_PER_USER; i++) { + subs.upsert(user.id, checked(`https://fcm.googleapis.com/fcm/send/d${i}`), 'k'); + } + // Same second for every row; the id breaks the tie, so d0 is the oldest. + // d0 subscribing again renews it, which leaves d1 as the oldest. + testDb.prepare("UPDATE push_subscriptions SET created_at = '2026-01-01 00:00:00'").run(); + subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k'); + expect(subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe( + MAX_PUSH_DEVICES_PER_USER, + ); + const endpoints = subs.listForUser(user.id).map((r) => r.endpoint); + expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/d0'); + expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/new'); + expect(endpoints).not.toContain('https://fcm.googleapis.com/fcm/send/d1'); + }); + + it('PUSHSUB-005: a user can only remove their own endpoint', () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb); + subs.upsert(owner.id, checked(FCM), 'k'); + expect(subs.removeForUser(other.id, FCM)).toBe(false); + expect(subs.countForUser(owner.id)).toBe(1); + expect(subs.removeForUser(owner.id, FCM)).toBe(true); + expect(subs.countForUser(owner.id)).toBe(0); + }); + + it('PUSHSUB-006: success resets the failure count, failures add up, deleteById removes the row', () => { + const { user } = createUser(testDb); + subs.upsert(user.id, checked(FCM), 'k'); + const [row] = subs.listForUser(user.id); + expect(subs.recordFailure(row.id)).toBe(1); + expect(subs.recordFailure(row.id)).toBe(2); + expect(subs.listForUser(user.id)[0].failure_count).toBe(2); + subs.recordSuccess(row.id); + const [after] = subs.listForUser(user.id); + expect(after.failure_count).toBe(0); + expect(after.last_success_at).not.toBeNull(); + expect(subs.recordFailure(row.id)).toBe(1); + subs.deleteById(row.id); + expect(subs.hasAny(user.id)).toBe(false); + // A failure recorded for a row another send already removed changes nothing. + expect(subs.recordFailure(row.id)).toBe(0); + }); + + it('PUSHSUB-007: cuts a long user agent and the rows go with the user', () => { + const { user } = createUser(testDb); + subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000)); + expect(subs.listForUser(user.id)[0].user_agent).toHaveLength(256); + testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get()).toEqual({ n: 0 }); + }); + + it('PUSHSUB-008: migration 244 created the table with its user index', () => { + const index = testDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'push_subscriptions'") + .all() as { name: string }[]; + expect(index.map((i) => i.name)).toContain('idx_push_subscriptions_user'); + }); +}); diff --git a/server/tests/unit/nest/notifications/push.controller.test.ts b/server/tests/unit/nest/notifications/push.controller.test.ts new file mode 100644 index 000000000..add1ad792 --- /dev/null +++ b/server/tests/unit/nest/notifications/push.controller.test.ts @@ -0,0 +1,116 @@ +/** + * PushController parity (PUSHCTL-*): status codes, bodies and the bespoke 400 + * strings of /api/notifications/push, over stubbed providers. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; +import { createECDH } from 'node:crypto'; +import { PushController } from '../../../../src/nest/notifications/push/push.controller'; +import { PUSH_SUBSCRIPTION_ERRORS } from '../../../../src/nest/notifications/push/push-subscription.helpers'; +import type { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service'; +import { + PUSH_UNAVAILABLE_ERROR, + PushUnavailableError, + type VapidKeysService, +} from '../../../../src/nest/notifications/push/vapid-keys.service'; +import type { User } from '../../../../src/types'; + +const keys = { getPublicKey: vi.fn((): string => 'server-public-key') }; +const subscriptions = { upsert: vi.fn(() => 2), removeForUser: vi.fn(() => true) }; +const controller = new PushController( + keys as unknown as VapidKeysService, + subscriptions as unknown as PushSubscriptionsService, +); +const user = { id: 5 } as User; + +function subscription(endpoint = 'https://fcm.googleapis.com/fcm/send/abc') { + const ua = createECDH('prime256v1'); + ua.generateKeys(); + return { + endpoint, + expirationTime: null, + keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 1).toString('base64url') }, + }; +} + +function thrown(fn: () => unknown): HttpException { + try { + fn(); + } catch (err) { + return err as HttpException; + } + throw new Error('expected a throw'); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('PushController', () => { + it('PUSHCTL-001: GET public-key answers the key browsers subscribe with', () => { + expect(controller.publicKey()).toEqual({ publicKey: 'server-public-key' }); + }); + + it('PUSHCTL-002: POST subscriptions stores the checked subscription against the current key and answers 200', () => { + const sub = subscription(); + expect(controller.subscribe(user, { subscription: sub }, 'Firefox/130')).toEqual({ success: true, devices: 2 }); + expect(subscriptions.upsert).toHaveBeenCalledWith( + 5, + { endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }, + 'server-public-key', + 'Firefox/130', + ); + expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.subscribe)).toBe(200); + }); + + it('PUSHCTL-003: POST subscriptions answers the bespoke 400 and stores nothing', () => { + const err = thrown(() => controller.subscribe(user, { subscription: subscription('https://example.com/collect') })); + expect(err).toBeInstanceOf(HttpException); + expect(err.getStatus()).toBe(400); + expect(err.getResponse()).toEqual({ error: PUSH_SUBSCRIPTION_ERRORS.service }); + expect(subscriptions.upsert).not.toHaveBeenCalled(); + }); + + it('PUSHCTL-004: DELETE subscriptions removes only the caller’s endpoint and is idempotent', () => { + expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({ + success: true, + }); + expect(subscriptions.removeForUser).toHaveBeenCalledWith(5, 'https://fcm.googleapis.com/fcm/send/abc'); + subscriptions.removeForUser.mockReturnValueOnce(false); + expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/gone' })).toEqual({ + success: true, + }); + expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.unsubscribe)).toBe(200); + }); + + it('PUSHCTL-005: while the stored key pair is unusable, public-key and subscribe answer 503 and store nothing', () => { + keys.getPublicKey.mockImplementation(() => { + throw new PushUnavailableError(); + }); + try { + const fromKey = thrown(() => controller.publicKey()); + expect(fromKey.getStatus()).toBe(503); + expect(fromKey.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR }); + + const fromSubscribe = thrown(() => controller.subscribe(user, { subscription: subscription() })); + expect(fromSubscribe.getStatus()).toBe(503); + expect(fromSubscribe.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR }); + expect(subscriptions.upsert).not.toHaveBeenCalled(); + + // A device can still be forgotten. + expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({ + success: true, + }); + } finally { + keys.getPublicKey.mockImplementation(() => 'server-public-key'); + } + }); + + it('PUSHCTL-006: any other key failure is not dressed up as a 503', () => { + keys.getPublicKey.mockImplementationOnce(() => { + throw new Error('database is locked'); + }); + expect(() => controller.publicKey()).toThrow('database is locked'); + }); +}); diff --git a/server/tests/unit/nest/notifications/vapid-keys.service.test.ts b/server/tests/unit/nest/notifications/vapid-keys.service.test.ts new file mode 100644 index 000000000..ce4f3cc0d --- /dev/null +++ b/server/tests/unit/nest/notifications/vapid-keys.service.test.ts @@ -0,0 +1,438 @@ +/** + * Where the server's VAPID identity comes from (VKEY-*): the environment pair + * when it is complete and consistent, else a pair generated once into + * app_settings with the private half encrypted. Push is off, with every row + * left as it was, while the pair it would sign with cannot be used: a stored + * pair is never regenerated, and a broken environment pair falls back on + * neither a stored nor a new one. And the `sub` contact. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; + +// One :memory: connection per file, created inside the factory so nothing has +// to be hoisted above the imports; the tests reach it through the mocked module. +vi.mock('../../../../src/db/database', async () => { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + return { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }; +}); +vi.mock('../../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +const { logInfo, logError } = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn() })); +vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo, + logDebug: vi.fn(), + logError, + logWarn: vi.fn(), +})); + +import { db as testDb } from '../../../../src/db/database'; +import { createTables } from '../../../../src/db/schema'; +import { runMigrations } from '../../../../src/db/migrations'; +import { resetTestDb } from '../../../helpers/test-db'; +import { DatabaseService } from '../../../../src/nest/database/database.service'; +import { decrypt_api_key, encrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto'; +import { + FALLBACK_VAPID_SUBJECT, + PushUnavailableError, + VAPID_PRIVATE_KEY_SETTING, + VAPID_PUBLIC_KEY_SETTING, + VapidKeysService, +} from '../../../../src/nest/notifications/push/vapid-keys.service'; +import { generateVapidKeyPair, isVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto'; + +const dbs = new DatabaseService(testDb); + +/** An enc:v1: value the current ENCRYPTION_KEY cannot open. */ +const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA'; + +function setting(key: string): string | undefined { + return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined) + ?.value; +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + logInfo.mockClear(); + logError.mockClear(); + for (const name of [ + 'VAPID_PUBLIC_KEY', + 'VAPID_PRIVATE_KEY', + 'VAPID_SUBJECT', + 'APP_URL', + 'ALLOWED_ORIGINS', + 'ADMIN_EMAIL', + ]) { + vi.stubEnv(name, ''); + } +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +afterAll(() => { + testDb.close(); +}); + +describe('VapidKeysService key pair', () => { + it('VKEY-001: generates the pair once, the private half encrypted, and keeps serving it', () => { + const svc = new VapidKeysService(dbs); + const keys = svc.getKeys(); + expect(keys.source).toBe('database'); + expect(isVapidKeyPair(keys.publicKey, keys.privateKey)).toBe(true); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(keys.publicKey); + const stored = setting(VAPID_PRIVATE_KEY_SETTING)!; + expect(stored.startsWith('enc:v1:')).toBe(true); + expect(decrypt_api_key(stored)).toBe(keys.privateKey); + expect(logInfo).toHaveBeenCalledTimes(1); + + // Another instance over the same database (a restart) reads the same pair. + const again = new VapidKeysService(dbs); + expect(again.getKeys()).toEqual(keys); + expect(svc.getPublicKey()).toBe(keys.publicKey); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(stored); + expect(logInfo).toHaveBeenCalledTimes(1); + }); + + it('VKEY-001b: a pair another instance stored between the first look and the write is the one used', () => { + const theirs = generateVapidKeyPair(); + const svc = new VapidKeysService(dbs); + const transaction = dbs.transaction.bind(dbs); + const spy = vi.spyOn(dbs, 'transaction').mockImplementationOnce((fn) => { + testDb + .prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)') + .run(VAPID_PUBLIC_KEY_SETTING, theirs.publicKey, VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(theirs.privateKey)); + return transaction(fn); + }); + try { + expect(svc.getKeys()).toEqual({ ...theirs, source: 'database' }); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(theirs.publicKey); + expect(logInfo).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it('VKEY-002: bootstrap creates the pair, so the public-key GET only reads', () => { + new VapidKeysService(dbs).onApplicationBootstrap(); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeTruthy(); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeTruthy(); + }); + + it('VKEY-003: a bootstrap failure is logged, not thrown', () => { + const broken = { + get: () => { + throw new Error('no such table: app_settings'); + }, + } as unknown as DatabaseService; + expect(() => new VapidKeysService(broken).onApplicationBootstrap()).not.toThrow(); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('no such table')); + }); + + it('VKEY-004: a consistent pair in the environment wins and the database is left alone', () => { + const pair = generateVapidKeyPair(); + // Padding on the way in is fine; the key handed to browsers has none. + vi.stubEnv('VAPID_PUBLIC_KEY', `${pair.publicKey}=`); + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + const keys = new VapidKeysService(dbs).getKeys(); + expect(keys).toEqual({ ...pair, source: 'env' }); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(logError).not.toHaveBeenCalled(); + }); + + it('VKEY-005: a mismatched environment pair turns push off, reported once, and the stored pair is not used', () => { + const stored = new VapidKeysService(dbs).getKeys(); + const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING); + vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey); + vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey); + const svc = new VapidKeysService(dbs); + expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(() => svc.getPublicKey()).toThrow(PushUnavailableError); + expect(svc.isAvailable()).toBe(false); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'), + ); + // Both stored rows are exactly as they were, for when the variables go again. + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext); + expect(logInfo).toHaveBeenCalledTimes(1); + }); + + it('VKEY-006: half a pair in the environment turns push off over a stored pair, and is reported again after a fix and relapse', () => { + const stored = new VapidKeysService(dbs).getKeys(); + const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING); + const pair = generateVapidKeyPair(); + const svc = new VapidKeysService(dbs); + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(svc.isAvailable()).toBe(false); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('VAPID_PRIVATE_KEY is set without its other half')); + + vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); + expect(svc.getKeys()).toEqual({ ...pair, source: 'env' }); + // An existingSecret rotated without the private half: the devices hold the + // environment key, so signing with the stored pair would drop every one. + vi.stubEnv('VAPID_PRIVATE_KEY', ''); + expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(svc.isAvailable()).toBe(false); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenLastCalledWith( + expect.stringContaining('VAPID_PUBLIC_KEY is set without its other half'), + ); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext); + + // Both variables removed on purpose: the stored pair, as on any install without them. + vi.stubEnv('VAPID_PUBLIC_KEY', ''); + expect(svc.getKeys()).toEqual(stored); + }); + + it('VKEY-006b: an install that only ran on the environment pair generates nothing when that pair breaks', () => { + const pair = generateVapidKeyPair(); + vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + const svc = new VapidKeysService(dbs); + svc.onApplicationBootstrap(); + expect(svc.isAvailable()).toBe(true); + + // An existingSecret without the private half: the variable is simply gone. + vi.stubEnv('VAPID_PRIVATE_KEY', ''); + new VapidKeysService(dbs).onApplicationBootstrap(); + expect(svc.isAvailable()).toBe(false); + expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); + expect(logInfo).not.toHaveBeenCalled(); + // Once per instance, with what to do and without the key. + expect(logError).toHaveBeenCalledTimes(2); + const line = logError.mock.calls[0][0] as string; + expect(line).toContain('Web Push is off: VAPID_PUBLIC_KEY is set without its other half'); + expect(line).toContain('Set both variables'); + expect(line).not.toContain(pair.publicKey); + + // Halves that do not belong together are refused the same way. + vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey); + expect(svc.isAvailable()).toBe(false); + expect(logError).toHaveBeenLastCalledWith( + expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'), + ); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + + // The pair fixed: the same key as before, so every device receives again as it was. + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + expect(svc.getKeys()).toEqual({ ...pair, source: 'env' }); + }); + + it('VKEY-006c: a broken environment pair over an unusable stored pair names the variables and touches no row', () => { + new VapidKeysService(dbs).getKeys(); + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey); + const svc = new VapidKeysService(dbs); + expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + // The variables are what push would sign with, so they are what the log names. + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY is set without its other half'), + ); + }); + + it('VKEY-007: a stored private key that no longer decrypts is kept, and push is off until the key is back', () => { + const svc = new VapidKeysService(dbs); + const original = svc.getKeys(); + const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!; + // What a restore under a different ENCRYPTION_KEY looks like from here. + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + logInfo.mockClear(); + + const broken = new VapidKeysService(dbs); + expect(() => broken.getKeys()).toThrow(PushUnavailableError); + expect(() => broken.getPublicKey()).toThrow(PushUnavailableError); + // Nothing was replaced: both rows are exactly as they were. + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(original.publicKey); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + expect(logInfo).not.toHaveBeenCalled(); + // Said once, with the way out, and without any key material. + expect(logError).toHaveBeenCalledTimes(1); + const line = logError.mock.calls[0][0] as string; + expect(line).toContain('cannot be decrypted with the current ENCRYPTION_KEY'); + expect(line).not.toContain(original.publicKey); + expect(line).not.toContain(UNREADABLE); + expect(line).not.toContain(ciphertext); + + // The right key again (here: the original ciphertext back) brings the same pair back. + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING); + expect(broken.getKeys()).toEqual(original); + }); + + it('VKEY-008: half a stored pair is not completed with a new pair either', () => { + const publicKey = generateVapidKeyPair().publicKey; + testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run(VAPID_PUBLIC_KEY_SETTING, publicKey); + expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('only one half of the stored VAPID key pair')); + expect(logError).toHaveBeenCalledWith(expect.not.stringContaining(publicKey)); + }); + + it('VKEY-008b: only the private half stored counts as incomplete too', () => { + const { privateKey } = generateVapidKeyPair(); + testDb + .prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)') + .run(VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(privateKey)); + expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + }); + + it('VKEY-008c: halves that decrypt but do not belong together are refused, not replaced', () => { + const stored = generateVapidKeyPair(); + testDb + .prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)') + .run( + VAPID_PUBLIC_KEY_SETTING, + stored.publicKey, + VAPID_PRIVATE_KEY_SETTING, + encrypt_api_key(generateVapidKeyPair().privateKey), + ); + expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('does not belong to the stored public key')); + }); + + it('VKEY-008d: an unusable pair is reported once per state, and again after it was fixed and broke again', () => { + const svc = new VapidKeysService(dbs); + svc.getKeys(); + const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!; + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + for (let i = 0; i < 3; i++) expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(logError).toHaveBeenCalledTimes(1); + + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING); + svc.getKeys(); + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + expect(() => svc.getKeys()).toThrow(PushUnavailableError); + expect(logError).toHaveBeenCalledTimes(2); + }); + + it('VKEY-008e: bootstrap over an unusable pair logs the reason once and does not throw', () => { + new VapidKeysService(dbs).getKeys(); + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + logError.mockClear(); + expect(() => new VapidKeysService(dbs).onApplicationBootstrap()).not.toThrow(); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('Web Push is off')); + }); + + it('VKEY-008f: a consistent environment pair still works while the stored one is unusable', () => { + new VapidKeysService(dbs).getKeys(); + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + const pair = generateVapidKeyPair(); + vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + const svc = new VapidKeysService(dbs); + expect(svc.getKeys()).toEqual({ ...pair, source: 'env' }); + expect(svc.isAvailable()).toBe(true); + // The stored rows are still exactly what was there. + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + }); + + it('VKEY-008g: isAvailable only reads: it creates nothing, and never touches an unusable pair', () => { + const svc = new VapidKeysService(dbs); + // Neither row yet: available, since the next send or subscribe creates the + // pair, but nothing is written now (the preference matrix GET asks this). + expect(svc.isAvailable()).toBe(true); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); + expect(logInfo).not.toHaveBeenCalled(); + + const { publicKey } = svc.getKeys(); + expect(svc.isAvailable()).toBe(true); + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + expect(svc.isAvailable()).toBe(false); + expect(svc.isAvailable()).toBe(false); + expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey); + expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + expect(logError).toHaveBeenCalledTimes(1); + }); + + it('VKEY-008h: isAvailable turns any other failure into a logged false', () => { + const svc = new VapidKeysService(dbs); + const spy = vi.spyOn(dbs, 'get').mockImplementationOnce(() => { + throw new Error('database is locked'); + }); + expect(svc.isAvailable()).toBe(false); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('database is locked')); + spy.mockRestore(); + expect(svc.isAvailable()).toBe(true); + }); +}); + +describe('VapidKeysService subject', () => { + const svc = new VapidKeysService(dbs); + + it('VKEY-009: VAPID_SUBJECT comes first', () => { + vi.stubEnv('VAPID_SUBJECT', 'mailto:push@example.test'); + vi.stubEnv('APP_URL', 'https://trek.example.test'); + expect(svc.getSubject()).toBe('mailto:push@example.test'); + }); + + it('VKEY-010: then APP_URL, when it is public https', () => { + vi.stubEnv('APP_URL', ' https://trek.example.test/ '); + expect(svc.getSubject()).toBe('https://trek.example.test'); + }); + + it('VKEY-010b: never an ALLOWED_ORIGINS entry, which can be another site allowed to call the API', () => { + vi.stubEnv('ALLOWED_ORIGINS', 'https://companion.example.test,https://trek.example.test'); + expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT); + vi.stubEnv('APP_URL', 'https://trek.example.test'); + expect(svc.getSubject()).toBe('https://trek.example.test'); + }); + + it('VKEY-010c: an APP_URL that does not parse is no candidate', () => { + vi.stubEnv('APP_URL', 'not a url'); + expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT); + }); + + it('VKEY-011: never the bootstrap admin address, which would travel to every push service', () => { + vi.stubEnv('ADMIN_EMAIL', 'admin@example.test'); + vi.stubEnv('APP_URL', 'https://trek.example.test'); + expect(svc.getSubject()).toBe('https://trek.example.test'); + vi.stubEnv('APP_URL', 'http://trek.lan'); + expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT); + }); + + it('VKEY-012: then the project URL when the instance URL is plain http or localhost', () => { + vi.stubEnv('APP_URL', 'http://trek.lan'); + expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT); + vi.stubEnv('APP_URL', 'https://localhost:3000'); + expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT); + }); + + it('VKEY-013: and never a subject Apple would refuse, even when one is configured', () => { + vi.stubEnv('VAPID_SUBJECT', 'push@example.test'); // not mailto:, not https: (boot would have refused it) + expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT); + vi.stubEnv('APP_URL', 'https://trek.example.test'); + expect(svc.getSubject()).toBe('https://trek.example.test'); + }); +}); diff --git a/server/tests/unit/nest/notifications/web-push-crypto.test.ts b/server/tests/unit/nest/notifications/web-push-crypto.test.ts new file mode 100644 index 000000000..843c6038b --- /dev/null +++ b/server/tests/unit/nest/notifications/web-push-crypto.test.ts @@ -0,0 +1,211 @@ +/** + * RFC 8291 message encryption and RFC 8292 VAPID headers (WPCRYPTO-*). + * + * The encryption is pinned twice: byte for byte against the worked example in + * RFC 8291 Appendix A, and by a decrypt written here from the receiving side, + * the way a browser unwraps the message, for fresh keys and salts. + */ +import { describe, it, expect } from 'vitest'; +import { createDecipheriv, createECDH, createPublicKey, hkdfSync, type ECDH } from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import { + MAX_PUSH_PLAINTEXT_BYTES, + PUSH_RECORD_SIZE, + VAPID_TOKEN_TTL_SECONDS, + createVapidSigner, + derivePublicKey, + encryptPushMessage, + generateVapidKeyPair, + isP256Point, + isVapidKeyPair, +} from '../../../../src/nest/notifications/push/web-push-crypto'; + +const b64 = (value: string) => Buffer.from(value, 'base64url'); + +// RFC 8291 Appendix A. +const RFC = { + plaintext: b64('V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24'), + asPublic: 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8', + asPrivate: b64('yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw'), + uaPublic: b64('BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'), + uaPrivate: b64('q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'), + salt: b64('DGv6ra1nlYgDCS1FRnbzlw'), + auth: b64('BTBZMqHH6r4Tts7J_aSIgg'), + // Section 5, the message body as sent (the three lines of the RFC joined). + body: + 'DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml' + + 'mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT' + + 'pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN', +}; + +/** The receiving side of RFC 8291, independent of the module under test. */ +function decrypt(body: Buffer, ua: ECDH, auth: Buffer): { plaintext: Buffer; rs: number; keyId: Buffer } { + const salt = body.subarray(0, 16); + const rs = body.readUInt32BE(16); + const idlen = body[20]; + const keyId = body.subarray(21, 21 + idlen); + const record = body.subarray(21 + idlen); + const secret = ua.computeSecret(keyId); + const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), ua.getPublicKey(), keyId]); + const ikm = Buffer.from(hkdfSync('sha256', secret, auth, keyInfo, 32)); + const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16)); + const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12)); + const decipher = createDecipheriv('aes-128-gcm', cek, nonce); + decipher.setAuthTag(record.subarray(record.length - 16)); + const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]); + let end = padded.length - 1; + while (end >= 0 && padded[end] === 0) end--; + expect(padded[end]).toBe(0x02); // the last-record delimiter + return { plaintext: padded.subarray(0, end), rs, keyId }; +} + +function browser() { + const ua = createECDH('prime256v1'); + ua.generateKeys(); + const auth = Buffer.from('0123456789abcdef'); + return { ua, keys: { p256dh: ua.getPublicKey(), auth } }; +} + +describe('encryptPushMessage', () => { + it('WPCRYPTO-001: reproduces the RFC 8291 Appendix A message byte for byte', () => { + const body = encryptPushMessage( + RFC.plaintext, + { p256dh: RFC.uaPublic, auth: RFC.auth }, + { salt: RFC.salt, senderPrivateKey: RFC.asPrivate }, + ); + expect(body.toString('base64url')).toBe(RFC.body); + }); + + it('WPCRYPTO-002: the RFC message decrypts with the user agent key, which checks the reference decrypt too', () => { + const ua = createECDH('prime256v1'); + ua.setPrivateKey(RFC.uaPrivate); + const { plaintext, rs, keyId } = decrypt(b64(RFC.body), ua, RFC.auth); + expect(plaintext.toString('utf8')).toBe('When I grow up, I want to be a watermelon'); + expect(rs).toBe(4096); + expect(keyId.toString('base64url')).toBe(RFC.asPublic); + }); + + it('WPCRYPTO-003: a real message round-trips, with a fresh salt and sender key every time', () => { + const { ua, keys } = browser(); + const message = Buffer.from(JSON.stringify({ title: 'Trip reminder: Kyōto', body: 'Tomorrow 🚄' })); + const first = encryptPushMessage(message, keys); + const second = encryptPushMessage(message, keys); + expect(decrypt(first, ua, keys.auth).plaintext.equals(message)).toBe(true); + expect(decrypt(second, ua, keys.auth).plaintext.equals(message)).toBe(true); + expect(first.subarray(0, 16).equals(second.subarray(0, 16))).toBe(false); + expect(first.subarray(21, 86).equals(second.subarray(21, 86))).toBe(false); + }); + + it('WPCRYPTO-004: the largest allowed plaintext still fits one 4096-byte record', () => { + const { ua, keys } = browser(); + const message = Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES, 'a'); + const body = encryptPushMessage(message, keys); + expect(body.length).toBe(PUSH_RECORD_SIZE); + expect(decrypt(body, ua, keys.auth).plaintext.length).toBe(MAX_PUSH_PLAINTEXT_BYTES); + expect(() => encryptPushMessage(Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES + 1), keys)).toThrow( + /does not fit one record/, + ); + }); + + it('WPCRYPTO-005: refuses keys it cannot encrypt for', () => { + const { keys } = browser(); + const offCurve = Buffer.from(keys.p256dh); + offCurve[64] ^= 0x01; + expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, p256dh: offCurve })).toThrow(/p256dh/); + expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, auth: Buffer.alloc(8) })).toThrow(/auth secret/); + expect(() => encryptPushMessage(Buffer.from('x'), keys, { salt: Buffer.alloc(4) })).toThrow(/salt/); + }); +}); + +describe('key helpers', () => { + it('WPCRYPTO-006: generates a full-length pair whose halves belong together', () => { + for (let i = 0; i < 20; i++) { + const pair = generateVapidKeyPair(); + const pub = b64(pair.publicKey); + expect(pub.length).toBe(65); + expect(pub[0]).toBe(0x04); + expect(b64(pair.privateKey).length).toBe(32); + expect(isVapidKeyPair(pair.publicKey, pair.privateKey)).toBe(true); + } + }); + + it('WPCRYPTO-007: tells a pair from two unrelated halves, and rejects what is not a key', () => { + const a = generateVapidKeyPair(); + const b = generateVapidKeyPair(); + expect(isVapidKeyPair(a.publicKey, b.privateKey)).toBe(false); + expect(isVapidKeyPair('not base64url!', a.privateKey)).toBe(false); + expect(derivePublicKey('short')).toBeNull(); + expect(derivePublicKey(Buffer.alloc(32).toString('base64url'))).toBeNull(); // zero is no scalar + expect(derivePublicKey(a.privateKey)?.toString('base64url')).toBe(a.publicKey); + }); + + it('WPCRYPTO-008: isP256Point wants an uncompressed point that is on the curve', () => { + const { keys } = browser(); + expect(isP256Point(keys.p256dh)).toBe(true); + const offCurve = Buffer.from(keys.p256dh); + offCurve[40] ^= 0xff; + expect(isP256Point(offCurve)).toBe(false); + expect(isP256Point(Buffer.concat([Buffer.from([0x03]), keys.p256dh.subarray(1)]))).toBe(false); + expect(isP256Point(keys.p256dh.subarray(0, 33))).toBe(false); + }); +}); + +describe('createVapidSigner', () => { + const pair = generateVapidKeyPair(); + const pub = b64(pair.publicKey); + const verifyKey = createPublicKey({ + key: { + kty: 'EC', + crv: 'P-256', + x: pub.subarray(1, 33).toString('base64url'), + y: pub.subarray(33).toString('base64url'), + }, + format: 'jwk', + }); + + function parse(header: string) { + const m = /^vapid t=([^,]+), k=(.+)$/.exec(header); + expect(m).not.toBeNull(); + return { token: m![1], k: m![2] }; + } + + it('WPCRYPTO-009: builds `vapid t=, k=` for the endpoint origin', () => { + const now = Date.UTC(2026, 8, 24, 12, 0, 0); + const sign = createVapidSigner(pair, 'mailto:ops@example.test', () => now); + const { token, k } = parse(sign('https://fcm.googleapis.com/fcm/send/abc:def')); + expect(k).toBe(pair.publicKey); + + const claims = jwt.verify(token, verifyKey, { + algorithms: ['ES256'], + clockTimestamp: now / 1000, + }) as jwt.JwtPayload; + expect(claims).toEqual({ + aud: 'https://fcm.googleapis.com', + exp: now / 1000 + VAPID_TOKEN_TTL_SECONDS, + sub: 'mailto:ops@example.test', + }); + expect(VAPID_TOKEN_TTL_SECONDS).toBeLessThanOrEqual(24 * 60 * 60); + + const [header, , signature] = token.split('.'); + expect(JSON.parse(b64(header).toString('utf8'))).toEqual({ alg: 'ES256', typ: 'JWT' }); + // JOSE r||s, not DER: the form push services verify. + expect(b64(signature).length).toBe(64); + }); + + it('WPCRYPTO-010: signs once per origin and separately for another push service', () => { + const sign = createVapidSigner(pair, 'https://trek.example.test'); + const a = sign('https://updates.push.services.mozilla.com/wpush/v2/one'); + const b = sign('https://updates.push.services.mozilla.com/wpush/v2/two'); + const c = sign('https://web.push.apple.com/QGx'); + expect(a).toBe(b); + expect(c).not.toBe(a); + const claims = jwt.decode(parse(c).token) as jwt.JwtPayload; + expect(claims.aud).toBe('https://web.push.apple.com'); + }); + + it('WPCRYPTO-011: refuses a malformed key pair up front', () => { + expect(() => createVapidSigner({ publicKey: 'abc', privateKey: pair.privateKey }, 'mailto:a@b.test')).toThrow( + /malformed/, + ); + }); +}); diff --git a/server/tests/unit/nest/notifications/web-push-dispatch.test.ts b/server/tests/unit/nest/notifications/web-push-dispatch.test.ts new file mode 100644 index 000000000..da84f2e7e --- /dev/null +++ b/server/tests/unit/nest/notifications/web-push-dispatch.test.ts @@ -0,0 +1,302 @@ +/** + * Web Push inside the dispatcher (WPDISP-*): the admin switch, the per-user + * Push column, admin-scoped events, the preference matrix, the generic test + * route, and all of them while the key pair cannot be used, stored or from + * VAPID_*. Real NotificationsService and real SQL; safeFetchFollow is the edge. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; +import { createECDH } from 'node:crypto'; + +// One :memory: connection per file, created inside the factory so nothing has +// to be hoisted above the imports; the tests reach it through the mocked module. +vi.mock('../../../../src/db/database', async () => { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + return { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }; +}); +vi.mock('../../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +const { safeFetchFollow, logError } = vi.hoisted(() => ({ safeFetchFollow: vi.fn(), logError: vi.fn() })); +vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError, + logWarn: vi.fn(), +})); +vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); +vi.mock('../../../../src/utils/ssrfGuard', () => { + class SsrfBlockedError extends Error {} + return { SsrfBlockedError, safeFetchFollow }; +}); + +import { db as testDb } from '../../../../src/db/database'; +import { createTables } from '../../../../src/db/schema'; +import { runMigrations } from '../../../../src/db/migrations'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createAdmin, createUser, disableNotificationPref, setNotificationChannels } from '../../../helpers/factories'; +import { makeNotificationPreferencesService, makeNotificationsService } from '../../../helpers/notifications'; +import { DatabaseService } from '../../../../src/nest/database/database.service'; +import { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service'; +import { + PUSH_UNAVAILABLE_ERROR, + VAPID_PRIVATE_KEY_SETTING, + VapidKeysService, +} from '../../../../src/nest/notifications/push/vapid-keys.service'; +import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers'; +import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto'; + +const dbs = new DatabaseService(testDb); +const notifications = makeNotificationsService(dbs); +const prefs = makeNotificationPreferencesService(dbs); +const subscriptions = new PushSubscriptionsService(dbs); +const keys = new VapidKeysService(dbs); + +function addDevice(userId: number, endpoint = `https://fcm.googleapis.com/fcm/send/u${userId}`): string { + const ua = createECDH('prime256v1'); + ua.generateKeys(); + const checked = checkPushSubscription({ + endpoint, + keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 7).toString('base64url') }, + }); + if ('error' in checked) throw new Error(checked.error); + subscriptions.upsert(userId, checked.value, keys.getPublicKey()); + return endpoint; +} + +function invite(targetId: number, actorId: number) { + return notifications.send({ + event: 'trip_invite', + actorId, + params: { trip: 'Lisbon', actor: 'bob@example.test', invitee: 'alice', tripId: '12' }, + scope: 'user', + targetId, + }); +} + +const pushedTo = () => safeFetchFollow.mock.calls.map((c) => c[0] as string); + +/** What a restore under a different ENCRYPTION_KEY leaves behind: a private key that no longer opens. */ +const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA'; + +function breakStoredPrivateKey(): void { + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); +} + +const pushColumn = (userId: number) => + prefs.getPreferencesMatrix(userId, 'user').channels.find((c) => c.id === 'push'); + +const storedKeyRows = () => + testDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'web_push_vapid_%' ORDER BY key").all(); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + safeFetchFollow.mockReset(); + safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } }); + logError.mockClear(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +afterAll(() => { + testDb.close(); +}); + +describe('Web Push in NotificationsService.send()', () => { + it('WPDISP-001: goes out once the admin enabled push and the user has a device', async () => { + const { user } = createUser(testDb); + const { user: actor } = createUser(testDb); + const endpoint = addDevice(user.id); + setNotificationChannels(testDb, 'push'); + await invite(user.id, actor.id); + expect(pushedTo()).toEqual([endpoint]); + }); + + it('WPDISP-002: stays quiet while the admin has not enabled push', async () => { + const { user } = createUser(testDb); + const { user: actor } = createUser(testDb); + addDevice(user.id); + setNotificationChannels(testDb, 'email'); + await invite(user.id, actor.id); + expect(safeFetchFollow).not.toHaveBeenCalled(); + }); + + it('WPDISP-003: respects the user switching the event off in the Push column', async () => { + const { user } = createUser(testDb); + const { user: actor } = createUser(testDb); + addDevice(user.id); + setNotificationChannels(testDb, 'push'); + disableNotificationPref(testDb, user.id, 'trip_invite', 'push'); + await invite(user.id, actor.id); + expect(safeFetchFollow).not.toHaveBeenCalled(); + }); + + it('WPDISP-004: never carries an admin-scoped event', async () => { + const { user: admin } = createAdmin(testDb); + addDevice(admin.id); + setNotificationChannels(testDb, 'push'); + await notifications.send({ + event: 'version_available', + actorId: null, + params: { version: '9.9.9' }, + scope: 'admin', + targetId: 0, + }); + expect(safeFetchFollow).not.toHaveBeenCalled(); + }); +}); + +describe('Web Push in the preference matrix', () => { + it('WPDISP-005: the user matrix gets a Push column, active by the admin switch, configured by a device', () => { + const { user } = createUser(testDb); + let push = prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'push'); + expect(push).toMatchObject({ + source: 'builtin', + labelKey: 'settings.notificationPreferences.push', + active: false, + configured: false, + }); + + setNotificationChannels(testDb, 'email,push'); + addDevice(user.id); + push = prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'push'); + expect(push).toMatchObject({ active: true, configured: true }); + expect(prefs.getActiveChannels()).toEqual(['email', 'push']); + expect(prefs.getPreferencesMatrix(user.id, 'user').implemented_combos['trip_invite']).toContain('push'); + }); + + it('WPDISP-006: the admin matrix has no Push column, since push never carries admin events', () => { + const { user: admin } = createAdmin(testDb); + const matrix = prefs.getPreferencesMatrix(admin.id, 'admin', 'admin'); + expect(matrix.channels.map((c) => c.id)).toEqual(['inapp', 'email', 'webhook', 'ntfy']); + expect(matrix.implemented_combos['version_available']).not.toContain('push'); + expect(matrix.preferences['version_available']).not.toHaveProperty('push'); + }); +}); + +describe('POST /api/notifications/test/push, through the registry', () => { + it('WPDISP-007: refuses a user without a device and sends to one with', async () => { + const { user } = createUser(testDb); + await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({ + success: false, + error: 'Channel is not configured for this user', + }); + const endpoint = addDevice(user.id); + await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({ success: true }); + expect(pushedTo()).toEqual([endpoint]); + }); +}); + +describe('Web Push while the stored key pair cannot be used', () => { + it('WPDISP-008: a send skips push without an error while in-app and webhook still go out', async () => { + const { user } = createUser(testDb); + const { user: actor } = createUser(testDb); + const endpoint = addDevice(user.id); + testDb + .prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)") + .run(user.id, 'https://hooks.example.test/trek'); + setNotificationChannels(testDb, 'webhook,push'); + breakStoredPrivateKey(); + + await expect(invite(user.id, actor.id)).resolves.toBeUndefined(); + + expect(pushedTo()).toEqual(['https://hooks.example.test/trek']); + const inApp = testDb.prepare('SELECT COUNT(*) AS n FROM notifications WHERE recipient_id = ?').get(user.id) as { + n: number; + }; + expect(inApp.n).toBe(1); + // The device is kept for when the key is back, and nothing failed on the way. + expect(subscriptions.listForUser(user.id).map((r) => r.endpoint)).toEqual([endpoint]); + expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('dispatch failed')); + expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('Web Push failed')); + }); + + it('WPDISP-009: the matrix stops offering push, so the card and the column go, and both come back with the key', () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'email,push'); + addDevice(user.id); + const ciphertext = ( + testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PRIVATE_KEY_SETTING) as { value: string } + ).value; + breakStoredPrivateKey(); + + expect(pushColumn(user.id)).toMatchObject({ active: false, configured: true }); + // Only push: email keeps its column, SMTP or not, as it always has. + expect(prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'email')?.active).toBe(true); + + testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING); + expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true }); + }); + + it('WPDISP-009b: reading the matrix while neither key row exists writes nothing, and push is still offered', () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'email,push'); + addDevice(user.id); + // What deleting both rows to start over leaves under a running server. + testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'web_push_vapid_%'").run(); + + expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true }); + expect(storedKeyRows()).toEqual([]); + }); + + it('WPDISP-010: the test send says push is unavailable and sends nothing', async () => { + const { user } = createUser(testDb); + addDevice(user.id); + breakStoredPrivateKey(); + await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({ + success: false, + error: PUSH_UNAVAILABLE_ERROR, + }); + expect(safeFetchFollow).not.toHaveBeenCalled(); + }); +}); + +describe('Web Push while the VAPID_* pair is broken and an older pair is stored', () => { + it('WPDISP-011: nothing is sent, the device and both rows stay, the column goes, and all of it returns with the variables', async () => { + const { user } = createUser(testDb); + const { user: actor } = createUser(testDb); + setNotificationChannels(testDb, 'push'); + // The first start without VAPID_* stored a pair; the operator then brought their own. + keys.getPublicKey(); + const stored = storedKeyRows(); + const pair = generateVapidKeyPair(); + vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + const endpoint = addDevice(user.id); + // A typo or a rotated Secret: still a valid 32-byte key, so boot is fine. + vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey); + + await expect(invite(user.id, actor.id)).resolves.toBeUndefined(); + + expect(safeFetchFollow).not.toHaveBeenCalled(); + expect(subscriptions.listForUser(user.id).map((r) => [r.endpoint, r.vapid_public_key])).toEqual([ + [endpoint, pair.publicKey], + ]); + expect(storedKeyRows()).toEqual(stored); + expect(pushColumn(user.id)).toMatchObject({ active: false, configured: true }); + + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true }); + await invite(user.id, actor.id); + expect(pushedTo()).toEqual([endpoint]); + }); +}); diff --git a/server/tests/unit/nest/notifications/web-push.service.test.ts b/server/tests/unit/nest/notifications/web-push.service.test.ts new file mode 100644 index 000000000..ef2cf8513 --- /dev/null +++ b/server/tests/unit/nest/notifications/web-push.service.test.ts @@ -0,0 +1,528 @@ +/** + * The Web Push transport (WPUSH-*): what goes over the wire, what each push + * service answer does to the table, and the payload the worker receives + * (WPUSH-PAY-*). safeFetchFollow is the boundary; the database is real. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import { createDecipheriv, createECDH, hkdfSync, type ECDH } from 'node:crypto'; + +// One :memory: connection per file, created inside the factory so nothing has +// to be hoisted above the imports; the tests reach it through the mocked module. +vi.mock('../../../../src/db/database', async () => { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + return { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }; +}); +vi.mock('../../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +const { logInfo, logError, safeFetchFollow } = vi.hoisted(() => ({ + logInfo: vi.fn(), + logError: vi.fn(), + safeFetchFollow: vi.fn(), +})); +vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo, + logDebug: vi.fn(), + logError, + logWarn: vi.fn(), +})); +vi.mock('../../../../src/utils/ssrfGuard', () => { + class SsrfBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = 'SsrfBlockedError'; + } + } + return { SsrfBlockedError, safeFetchFollow }; +}); + +import { db as testDb } from '../../../../src/db/database'; +import { createTables } from '../../../../src/db/schema'; +import { runMigrations } from '../../../../src/db/migrations'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createUser } from '../../../helpers/factories'; +import { DatabaseService } from '../../../../src/nest/database/database.service'; +import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard'; +import { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service'; +import { + PUSH_UNAVAILABLE_ERROR, + VAPID_PRIVATE_KEY_SETTING, + VAPID_PUBLIC_KEY_SETTING, + VapidKeysService, +} from '../../../../src/nest/notifications/push/vapid-keys.service'; +import { PushController } from '../../../../src/nest/notifications/push/push.controller'; +import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers'; +import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto'; +import { + FORBIDDEN_FAILURES_BEFORE_REMOVAL, + MAX_PUSH_PAYLOAD_BYTES, + WebPushService, + buildPushPayload, + samePagePath, +} from '../../../../src/nest/notifications/transports/web-push.service'; +import { ALL_EVENT_TYPES, type ChannelMessage } from '../../../../src/nest/notifications/notification-events'; + +const dbs = new DatabaseService(testDb); +const keys = new VapidKeysService(dbs); +const subscriptions = new PushSubscriptionsService(dbs); +const push = new WebPushService(keys, subscriptions); + +const MSG: ChannelMessage = { + event: 'collab_message', + title: 'New message in "Lisbon"', + body: 'alice: see you at the station', + navigateTarget: '/trips/12', + url: 'https://trek.example.test/trips/12', +}; + +interface Device { + endpoint: string; + ua: ECDH; + auth: Buffer; +} + +function subscribe(userId: number, endpoint: string): Device { + const ua = createECDH('prime256v1'); + ua.generateKeys(); + const auth = Buffer.from('fedcba9876543210'); + const checked = checkPushSubscription({ + endpoint, + keys: { p256dh: ua.getPublicKey('base64url'), auth: auth.toString('base64url') }, + }); + if ('error' in checked) throw new Error(checked.error); + subscriptions.upsert(userId, checked.value, keys.getPublicKey()); + return { endpoint, ua, auth }; +} + +/** The browser's side of RFC 8291, to read what was actually sent. */ +function open(body: Uint8Array, device: Device): unknown { + const buf = Buffer.from(body); + const salt = buf.subarray(0, 16); + const keyId = buf.subarray(21, 21 + buf[20]); + const record = buf.subarray(21 + buf[20]); + const secret = device.ua.computeSecret(keyId); + const info = Buffer.concat([Buffer.from('WebPush: info\0'), device.ua.getPublicKey(), keyId]); + const ikm = Buffer.from(hkdfSync('sha256', secret, device.auth, info, 32)); + const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16)); + const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12)); + const decipher = createDecipheriv('aes-128-gcm', cek, nonce); + decipher.setAuthTag(record.subarray(record.length - 16)); + const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]); + return JSON.parse(padded.subarray(0, padded.lastIndexOf(0x02)).toString('utf8')); +} + +function reply(status: number, text = '') { + return { + ok: status >= 200 && status < 300, + status, + body: null, + headers: { get: () => null }, + text: async () => text, + }; +} + +function rows(userId: number) { + return subscriptions.listForUser(userId); +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + safeFetchFollow.mockReset(); + logInfo.mockClear(); + logError.mockClear(); +}); + +afterAll(() => { + testDb.close(); +}); + +describe('WebPushService delivery', () => { + it('WPUSH-001: posts one encrypted, VAPID-signed message through the SSRF guard', async () => { + const { user } = createUser(testDb); + const device = subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/abc'); + safeFetchFollow.mockResolvedValue(reply(201)); + + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true); + + expect(safeFetchFollow).toHaveBeenCalledTimes(1); + const [url, init, options] = safeFetchFollow.mock.calls[0]; + expect(url).toBe(device.endpoint); + expect(options).toEqual({ maxRedirects: 0, bypassInternalIpAllowed: true }); + expect(init.method).toBe('POST'); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init.headers).toMatchObject({ + TTL: '86400', + Urgency: 'normal', + 'Content-Encoding': 'aes128gcm', + 'Content-Type': 'application/octet-stream', + }); + expect(init.headers.Authorization).toMatch( + new RegExp(`^vapid t=[\\w-]+\\.[\\w-]+\\.[\\w-]+, k=${keys.getPublicKey()}$`), + ); + expect(open(init.body, device)).toEqual({ + title: MSG.title, + body: MSG.body, + url: '/trips/12', + tag: 'collab_message:/trips/12', + }); + + const [row] = rows(user.id); + expect(row.last_success_at).not.toBeNull(); + expect(row.failure_count).toBe(0); + }); + + it('WPUSH-002: date-bound events go out with high urgency', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://web.push.apple.com/QGx'); + safeFetchFollow.mockResolvedValue(reply(201)); + await push.sendToUser(user.id, { ...MSG, event: 'trip_reminder' }); + expect(safeFetchFollow.mock.calls[0][1].headers.Urgency).toBe('high'); + }); + + it('WPUSH-003: 404 and 410 mean the browser is gone, and the row goes with it', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/expired'); + subscribe(user.id, 'https://updates.push.services.mozilla.com/wpush/v2/revoked'); + safeFetchFollow.mockImplementation(async (url: string) => reply(url.includes('expired') ? 404 : 410)); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(rows(user.id)).toEqual([]); + expect(logError).not.toHaveBeenCalled(); + }); + + it('WPUSH-004: a first 403 with Apple’s BadJwtToken keeps the device, counts it and logs the reason', async () => { + // Apple answers 403 for a token it rejects, which is the server's problem + // (subject, clock, key), not a sign the device is gone. + const { user } = createUser(testDb); + subscribe(user.id, 'https://web.push.apple.com/QGx'); + safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}')); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(rows(user.id)).toHaveLength(1); + expect(rows(user.id)[0].failure_count).toBe(1); + const line = logError.mock.calls[0][0] as string; + expect(line).toContain('HTTP 403'); + expect(line).toContain('BadJwtToken'); + expect(line).toContain('host=web.push.apple.com'); + // The endpoint is a capability URL: the log names the host only. + expect(line).not.toContain('/QGx'); + }); + + it(`WPUSH-004b: the device goes after ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} failed sends in a row ending in 403`, async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://web.push.apple.com/QGx'); + safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}')); + for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) { + await push.sendToUser(user.id, MSG); + expect(rows(user.id)[0].failure_count).toBe(i); + } + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(rows(user.id)).toEqual([]); + expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('removed a device')); + expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('BadJwtToken')); + }); + + it('WPUSH-004c: a success between refusals starts the count over', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://web.push.apple.com/QGx'); + safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}')); + for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) await push.sendToUser(user.id, MSG); + safeFetchFollow.mockResolvedValueOnce(reply(201)); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true); + expect(rows(user.id)[0].failure_count).toBe(0); + await push.sendToUser(user.id, MSG); + expect(rows(user.id)).toHaveLength(1); + expect(rows(user.id)[0].failure_count).toBe(1); + }); + + it('WPUSH-005: any other failure keeps the row, counts it and logs the capped body', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/busy'); + safeFetchFollow.mockResolvedValue(reply(503, `overloaded ${'x'.repeat(5000)}`)); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(rows(user.id)[0].failure_count).toBe(1); + const line = logError.mock.calls[0][0] as string; + expect(line).toContain('HTTP 503'); + expect(line.length).toBeLessThan(1300); + + // A body that cannot be read changes nothing but the log text. + safeFetchFollow.mockResolvedValueOnce({ + ...reply(500), + text: async () => { + throw new Error('stream reset'); + }, + }); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(rows(user.id)[0].failure_count).toBe(2); + expect(logError).toHaveBeenLastCalledWith(expect.stringMatching(/HTTP 500 .*host=fcm\.googleapis\.com: $/)); + }); + + it('WPUSH-006: a refused or failed request is a failure, never a throw', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/a'); + safeFetchFollow.mockRejectedValueOnce(new SsrfBlockedError('Requests to private addresses are not allowed')); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('blocked by SSRF guard')); + + safeFetchFollow.mockRejectedValueOnce(new Error('The operation was aborted due to timeout')); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('timeout')); + expect(rows(user.id)[0].failure_count).toBe(2); + }); + + it('WPUSH-007: one device getting it is enough', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/ok'); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/down'); + safeFetchFollow.mockImplementation(async (url: string) => reply(url.endsWith('/ok') ? 201 : 500)); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true); + // Both hosts are the same origin, so one token served both requests. + const [a, b] = safeFetchFollow.mock.calls.map((c) => c[1].headers.Authorization); + expect(a).toBe(b); + }); + + it('WPUSH-008: a row made for a previous server key is dropped without a request', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/old'); + testDb.prepare("UPDATE push_subscriptions SET vapid_public_key = 'previous-key'").run(); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(safeFetchFollow).not.toHaveBeenCalled(); + expect(rows(user.id)).toEqual([]); + }); + + it('WPUSH-009: a row whose host is not a push service any more is dropped without a request', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/y'); + testDb + .prepare("UPDATE push_subscriptions SET endpoint = 'https://attacker.example.test/collect' WHERE endpoint LIKE '%/x'") + .run(); + testDb.prepare("UPDATE push_subscriptions SET endpoint = 'not a url' WHERE endpoint LIKE '%/y'").run(); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(safeFetchFollow).not.toHaveBeenCalled(); + expect(rows(user.id)).toEqual([]); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=attacker.example.test')); + // Not even a URL: the log still has something to name instead of the value. + expect(logError).toHaveBeenCalledWith(expect.stringContaining('not a known push service user')); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=invalid-endpoint')); + }); + + it('WPUSH-010: broken stored keys count as a failure for that row', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); + testDb.prepare("UPDATE push_subscriptions SET auth = '***'").run(); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(safeFetchFollow).not.toHaveBeenCalled(); + expect(rows(user.id)[0].failure_count).toBe(1); + }); + + it('WPUSH-011: nothing to send to is simply false', async () => { + const { user } = createUser(testDb); + expect(push.hasDevices(user.id)).toBe(false); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(safeFetchFollow).not.toHaveBeenCalled(); + }); + + it('WPUSH-012: a failure before any request (no key pair) is logged and false', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); + const spy = vi.spyOn(keys, 'getKeys').mockImplementationOnce(() => { + throw new Error('database is locked'); + }); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('database is locked')); + spy.mockRestore(); + }); + + it('WPUSH-012b: while the stored key pair cannot be used nothing is sent and every device stays', async () => { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); + const publicKey = keys.getPublicKey(); + // A restore under a different ENCRYPTION_KEY: the ciphertext no longer opens. + testDb + .prepare("UPDATE app_settings SET value = 'enc:v1:bm90IGEgY2lwaGVydGV4dA' WHERE key = ?") + .run(VAPID_PRIVATE_KEY_SETTING); + + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + + expect(safeFetchFollow).not.toHaveBeenCalled(); + expect(rows(user.id)).toHaveLength(1); + expect(rows(user.id)[0].vapid_public_key).toBe(publicKey); + expect(rows(user.id)[0].failure_count).toBe(0); + expect(push.isAvailable()).toBe(false); + + // The test send says why instead of a bare failure, and sends nothing either. + await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR }); + expect(safeFetchFollow).not.toHaveBeenCalled(); + }); + + it('WPUSH-012c: an install that only ran on VAPID_* generates nothing when that pair breaks, and keeps every device', async () => { + const pair = generateVapidKeyPair(); + vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + try { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); + subscribe(user.id, 'https://web.push.apple.com/y'); + // An existingSecret without the private half: the variable is simply gone. + vi.stubEnv('VAPID_PRIVATE_KEY', ''); + + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR }); + + expect(push.isAvailable()).toBe(false); + expect(safeFetchFollow).not.toHaveBeenCalled(); + expect(testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'web_push_vapid_%'").all()).toEqual([]); + expect(rows(user.id).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]); + + // The Secret fixed: the same devices receive again, nobody turns push on again. + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + safeFetchFollow.mockResolvedValue(reply(201)); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true); + expect(safeFetchFollow).toHaveBeenCalledTimes(2); + expect(rows(user.id)).toHaveLength(2); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('WPUSH-012d: a broken VAPID_* pair over a pair stored from before sends nothing, keeps every device and serves no key', async () => { + // The first start without VAPID_* stored a pair; the operator then brought their own. + const storedKey = keys.getPublicKey(); + const pair = generateVapidKeyPair(); + vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + const controller = new PushController(keys, subscriptions); + try { + const { user } = createUser(testDb); + subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); + subscribe(user.id, 'https://web.push.apple.com/y'); + // An existingSecret rotated without the private half. + vi.stubEnv('VAPID_PRIVATE_KEY', ''); + + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); + await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR }); + expect(push.isAvailable()).toBe(false); + expect(safeFetchFollow).not.toHaveBeenCalled(); + // Signing with the stored pair would have dropped both rows; they stay on the environment key. + expect(rows(user.id).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]); + expect(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PUBLIC_KEY_SETTING)).toEqual({ + value: storedKey, + }); + // Browsers are not handed the stored key to subscribe again with either. + let refusal: unknown = null; + try { + controller.publicKey(); + } catch (err) { + refusal = err; + } + expect(refusal).toBeInstanceOf(HttpException); + expect((refusal as HttpException).getStatus()).toBe(503); + + // The Secret fixed: the same rows receive again, and the key served is the one they hold. + vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); + safeFetchFollow.mockResolvedValue(reply(201)); + await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true); + expect(safeFetchFollow).toHaveBeenCalledTimes(2); + expect(rows(user.id)).toHaveLength(2); + expect(controller.publicKey()).toEqual({ publicKey: pair.publicKey }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('WPUSH-013: the test send reports success and failure like the other channels', async () => { + const { user } = createUser(testDb); + const device = subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); + safeFetchFollow.mockResolvedValueOnce(reply(201)); + expect(push.isAvailable()).toBe(true); + await expect(push.sendTest(user.id)).resolves.toEqual({ success: true }); + const sent = open(safeFetchFollow.mock.calls[0][1].body, device); + expect(sent).toMatchObject({ title: 'Test Notification', url: '/settings?tab=notifications' }); + // Only chat collapses; a second test is a second notification. + expect(sent).not.toHaveProperty('tag'); + safeFetchFollow.mockResolvedValueOnce(reply(500)); + await expect(push.sendTest(user.id)).resolves.toEqual({ + success: false, + error: 'Failed to send push notification', + }); + }); +}); + +describe('buildPushPayload', () => { + const size = (p: unknown) => Buffer.byteLength(JSON.stringify(p), 'utf8'); + + it('WPUSH-PAY-001: passes a short message through; chat collapses per trip', () => { + expect(buildPushPayload(MSG)).toEqual({ + title: MSG.title, + body: MSG.body, + url: '/trips/12', + tag: 'collab_message:/trips/12', + }); + // Without a trip to collapse on there is no tag at all, never one for every chat everywhere. + expect(buildPushPayload({ ...MSG, navigateTarget: undefined })).toEqual({ + title: MSG.title, + body: MSG.body, + }); + }); + + it('WPUSH-PAY-001b: every other event has no tag, so two of them never replace each other', () => { + const others = ALL_EVENT_TYPES.filter((event) => event !== 'collab_message'); + expect(others.length).toBeGreaterThan(5); + for (const event of others) { + const payload = buildPushPayload({ ...MSG, event }); + expect(payload, event).toEqual({ title: MSG.title, body: MSG.body, url: '/trips/12' }); + expect(payload, event).not.toHaveProperty('tag'); + } + }); + + it('WPUSH-PAY-002: cuts a long body at a character boundary until the payload fits', () => { + const payload = buildPushPayload({ ...MSG, body: '🚄'.repeat(5000) }); + expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES); + expect(size(payload)).toBeGreaterThan(MAX_PUSH_PAYLOAD_BYTES - 8); + expect(payload.body.endsWith('…')).toBe(true); + expect(payload.body.slice(0, -1)).toMatch(/^(🚄)+$/u); + }); + + it('WPUSH-PAY-003: characters JSON has to escape are counted as what they cost', () => { + const payload = buildPushPayload({ ...MSG, title: '\u0001'.repeat(500), body: '"\u0002'.repeat(3000) }); + expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES); + expect(Array.from(payload.title)).toHaveLength(120); + expect(payload.body.length).toBeGreaterThan(0); + }); + + it('WPUSH-PAY-004: only same-origin paths reach the worker', () => { + expect(samePagePath('/trips/5')).toBe('/trips/5'); + expect(samePagePath('/admin?tab=storage')).toBe('/admin?tab=storage'); + for (const target of [ + 'https://evil.test/x', + '//evil.test/x', + '/\\evil.test', + 'trips/5', + '/trips/5 x', + `/${'a'.repeat(300)}`, + '', + undefined, + ]) { + expect(samePagePath(target), String(target)).toBeUndefined(); + } + expect(buildPushPayload({ ...MSG, navigateTarget: 'https://evil.test/' })).not.toHaveProperty('url'); + }); +}); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 6d069d19a..ed4d8b24e 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -720,6 +720,9 @@ const admin: TranslationStrings = { 'admin.notifications.ntfy': 'Ntfy', // en-fallback 'admin.notifications.emailPanel.title': 'Email (SMTP)', // en-fallback 'admin.notifications.webhookPanel.title': 'Webhook', // en-fallback + 'admin.notifications.webPushPanel.title': 'إشعارات الويب الفورية', + 'admin.notifications.webPushPanel.hint': + 'تتيح للمستخدمين تلقي الإشعارات على هواتفهم وحواسيبهم عبر المتصفح، حتى عندما يكون TREK مغلقًا. يتطلب HTTPS؛ وعلى iPhone وiPad يجب إضافة TREK إلى الشاشة الرئيسية.', 'admin.notifications.inappPanel.title': 'In-App', // en-fallback 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', // en-fallback 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', // en-fallback diff --git a/shared/src/i18n/ar/help.ts b/shared/src/i18n/ar/help.ts index ea780917e..db255d171 100644 --- a/shared/src/i18n/ar/help.ts +++ b/shared/src/i18n/ar/help.ts @@ -960,11 +960,13 @@ const help: TranslationStrings = { '«وضع الجودة العالية» لتنعيم الحواف وإسقاط الكرة الأرضية؛ و«حفظ الخريطة» يثبّت الاختيار.', 'help.ctx.settings-notifications.title': 'الإشعارات', 'help.ctx.settings-notifications.summary': - 'أين يصلك TREK خارج التطبيق: موضوع ntfy، أو webhook، أو قناة توفرها إضافة. تحت القنوات، صف لكل حدث يحدد ما يذهب إلى أين.', + 'أين يصلك TREK خارج التطبيق: إشعارات فورية على هذا الجهاز، أو موضوع ntfy، أو webhook، أو قناة توفرها إضافة. تحت القنوات، صف لكل حدث يحدد ما يذهب إلى أين.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: الموضوع، وخادم خاص بك اختياري، ورمز وصول اختياري، مع «اختبار» لإرسال رسالة على الفور.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: عنوان URL واحد يستقبل كل حدث بصيغة JSON، مع «اختبار».', 'help.ctx.settings-notifications.bullet.3': + 'الإشعارات الفورية على هذا الجهاز: «تشغيل على هذا الجهاز» يشمل المتصفح الذي تستخدمه فقط، لذا كرّره على كل هاتف أو حاسوب. «إرسال اختبار» يصل إليها جميعًا.', + 'help.ctx.settings-notifications.bullet.4': 'صفوف التفضيلات: لكل حدث، أي قناة مفعّلة. تعرض قنوات الإضافات «تهيئة» حتى يتم إعدادها.', 'help.ctx.settings-integrations.title': 'التكاملات', 'help.ctx.settings-integrations.summary': @@ -1266,7 +1268,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'القنوات التي تقدمها النسخة لمستخدميها، والقنوات التي تصلك أنت بصفتك المسؤول. يختار المستخدمون مواضيعهم وعناوين URL الخاصة بهم في «الإعدادات»؛ وأنت تقرر ما يوجد وتهيّئ البريد الإلكتروني.', 'help.ctx.admin-notifications.bullet.1': - '«In-App» و«Email (SMTP)» و«Ntfy» و«Webhook»: لوحة لكل منها، مع مفتاح يقدّم القناة للمستخدمين، والتهيئة التي تحتاجها من جهة الخادم.', + '«In-App» و«Email (SMTP)» و«Ntfy» و«Webhook» و«إشعارات الويب الفورية»: لوحة لكل منها، مع مفتاح يقدّم القناة للمستخدمين، والتهيئة التي تحتاجها من جهة الخادم.', 'help.ctx.admin-notifications.bullet.2': '«تذكيرات الرحلات»: هل يرسل الخادم التذكير قبل بدء الرحلة.', 'help.ctx.admin-notifications.bullet.3': '«Ntfy المسؤول» و«Webhook المسؤول»: وجهة أحداث المسؤول مثل فشل نسخة احتياطية أو صدور إصدار جديد، مع «اختبار».', @@ -1482,7 +1484,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'تحت «Email (SMTP)»، أدخل SMTP Host وSMTP Port وSMTP User وSMTP Password وFrom Address؛ و«إرسال بريد تجريبي» يرسل رسالة إليك.', 'help.guide.channels-instance.step.2': - 'شغّل «Ntfy» و«Webhook» لتقديمهما؛ ثم يدخل المستخدمون موضوعهم أو عنوان URL الخاص بهم في «الإعدادات»، «الإشعارات».', + 'شغّل «إشعارات الويب الفورية» و«Ntfy» و«Webhook» لتقديمها؛ ثم يشغّل المستخدمون الإشعارات الفورية لكل جهاز، أو يدخلون موضوعهم أو عنوان URL الخاص بهم، في «الإعدادات»، «الإشعارات».', 'help.guide.channels-instance.step.3': '«تذكيرات الرحلات» تشغّل التذكير قبل بدء الرحلة؛ و«In-App» مفعّل دائمًا ويُشرح هنا فحسب.', 'help.guide.channels-instance.result': 'يعرض تبويب «الإشعارات» لدى كل مستخدم القنوات التي شغّلتها.', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 7aef2103c..213936d27 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -300,6 +300,18 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': 'فشل الاختبار.', 'settings.notificationPreferences.pluginConfigured': 'تم الإعداد. أدر بيانات الاعتماد من صفحة إعدادات الإضافة.', 'settings.notificationPreferences.ntfy': 'Ntfy', // en-fallback + 'settings.notificationPreferences.push': 'إشعارات فورية', + 'settings.webPush.title': 'الإشعارات الفورية على هذا الجهاز', + 'settings.webPush.hint': + 'يعرض إشعارات TREK على هذا الجهاز حتى عندما يكون TREK مغلقًا. الأحداث التي تصل تتبع عمود «إشعارات فورية».', + 'settings.webPush.enable': 'تشغيل على هذا الجهاز', + 'settings.webPush.disable': 'إيقاف على هذا الجهاز', + 'settings.webPush.enabled': 'مُفعّل على هذا الجهاز', + 'settings.webPush.unsupported': 'لا يمكن لهذا المتصفح تلقي الإشعارات الفورية.', + 'settings.webPush.insecure': 'تتطلب الإشعارات الفورية فتح TREK عبر HTTPS.', + 'settings.webPush.iosInstall': 'على iPhone وiPad، أضف TREK أولًا إلى الشاشة الرئيسية ثم افتحه من هناك.', + 'settings.webPush.denied': 'الإشعارات محظورة لـ TREK في هذا المتصفح. اسمح بها في إعدادات المتصفح، ثم حاول مرة أخرى.', + 'settings.webPush.failed': 'تعذّر تشغيل الإشعارات الفورية على هذا الجهاز.', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', // en-fallback 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // en-fallback 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', // en-fallback diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index 7a0650323..c8317497a 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Sınaq ntfy bildirişi uğursuz oldu', 'admin.notifications.emailPanel.title': 'E-poçt (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'İstifadəçilərə TREK bağlı olanda belə brauzer vasitəsilə telefon və kompüterlərində bildiriş almağa imkan verir. HTTPS tələb olunur; iPhone və iPad-də TREK Əsas ekrana əlavə edilməlidir.', 'admin.notifications.inappPanel.title': 'Tətbiqdaxili', 'admin.notifications.inappPanel.hint': 'Tətbiqdaxili bildirişlər həmişə aktivdir və ümumi şəkildə deaktiv edilə bilməz.', diff --git a/shared/src/i18n/az/help.ts b/shared/src/i18n/az/help.ts index be26a48e8..c344d6e43 100644 --- a/shared/src/i18n/az/help.ts +++ b/shared/src/i18n/az/help.ts @@ -1085,11 +1085,13 @@ const help: TranslationStrings = { 'Hamarlaşdırma və qlobus proyeksiyası üçün Yüksək keyfiyyət rejimi; Xəritəni yadda saxla seçimi saxlayır.', 'help.ctx.settings-notifications.title': 'Bildirişlər', 'help.ctx.settings-notifications.summary': - 'TREK-in tətbiqdən kənarda sizə harada çatdığı: ntfy mövzusu, webhook və ya plaginin təqdim etdiyi kanal. Kanalların altında hər hadisə üçün bir sətir nəyin hara getdiyini müəyyən edir.', + 'TREK-in tətbiqdən kənarda sizə harada çatdığı: bu cihazda push bildirişləri, ntfy mövzusu, webhook və ya plaginin təqdim etdiyi kanal. Kanalların altında hər hadisə üçün bir sətir nəyin hara getdiyini müəyyən edir.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: mövzu, istəyə bağlı öz serveriniz və istəyə bağlı giriş tokeni, dərhal test göndərmək üçün Sına düyməsi ilə.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: hər hadisəni JSON kimi qəbul edən bir URL, Sına düyməsi ilə.', 'help.ctx.settings-notifications.bullet.3': + 'Bu cihazda push bildirişləri: Bu cihaz üçün aktiv et yalnız istifadə etdiyiniz brauzerə aiddir, ona görə də bunu hər telefonda və ya kompüterdə təkrarlayın. Test göndər hamısına çatır.', + 'help.ctx.settings-notifications.bullet.4': 'Tənzimləmə sətirləri: hər hadisə üçün hansı kanalın aktiv olduğu. Plagin kanalları qurulana qədər Konfiqurasiya et göstərir.', 'help.ctx.settings-integrations.title': 'İnteqrasiyalar', 'help.ctx.settings-integrations.summary': @@ -1418,7 +1420,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'İnstansiyanın istifadəçilərə təklif etdiyi kanallar və sizə administrator kimi çatan kanallar. İstifadəçilər öz mövzularını və URL-lərini Tənzimləmələr bölməsində seçir; nəyin mövcud olacağını siz müəyyən edirsiniz və e-poçtu siz konfiqurasiya edirsiniz.', 'help.ctx.admin-notifications.bullet.1': - 'Tətbiqdaxili, E-poçt (SMTP), Ntfy və Webhook: hər biri üçün kanalı istifadəçilərə təklif edən açarı və tələb etdiyi server tərəfi konfiqurasiyası olan ayrıca panel.', + 'Tətbiqdaxili, E-poçt (SMTP), Ntfy, Webhook və Web Push: hər biri üçün kanalı istifadəçilərə təklif edən açarı və tələb etdiyi server tərəfi konfiqurasiyası olan ayrıca panel.', 'help.ctx.admin-notifications.bullet.2': 'Səyahət xatırlatmaları: serverin səyahət başlamazdan əvvəl xatırlatma göndərib-göndərməməsi.', 'help.ctx.admin-notifications.bullet.3': @@ -1687,7 +1689,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'E-poçt (SMTP) bölməsində SMTP hostu, SMTP portu, SMTP istifadəçisi, SMTP parolu və Göndərən ünvanı daxil edin; Sınaq e-poçtu göndər sizə məktub göndərir.', 'help.guide.channels-instance.step.2': - 'Ntfy və Webhook-u təklif etmək üçün onları aktiv edin; sonra istifadəçilər Tənzimləmələr, Bildirişlər bölməsində öz mövzularını və ya URL-lərini daxil edirlər.', + 'Web Push, Ntfy və Webhook-u təklif etmək üçün onları aktiv edin; sonra istifadəçilər Tənzimləmələr, Bildirişlər bölməsində push-u hər cihaz üçün aktiv edir və ya öz mövzularını və ya URL-lərini daxil edirlər.', 'help.guide.channels-instance.step.3': 'Səyahət xatırlatmaları səyahət başlamazdan əvvəlki xatırlatmanı aktiv və ya deaktiv edir; Tətbiqdaxili həmişə aktivdir və burada yalnız izah olunur.', 'help.guide.channels-instance.result': diff --git a/shared/src/i18n/az/settings.ts b/shared/src/i18n/az/settings.ts index 2abb32d60..7cadf3bdd 100644 --- a/shared/src/i18n/az/settings.ts +++ b/shared/src/i18n/az/settings.ts @@ -163,6 +163,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.pluginConfigured': 'Konfiqurasiya edilib. Giriş məlumatlarını plaginin tənzimləmələr səhifəsindən idarə edin.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Bu cihazda push bildirişləri', + 'settings.webPush.hint': + 'TREK bağlı olanda belə TREK bildirişlərini bu cihazda göstərir. Hansı hadisələrin gəldiyini Push sütunu müəyyən edir.', + 'settings.webPush.enable': 'Bu cihaz üçün aktiv et', + 'settings.webPush.disable': 'Bu cihaz üçün deaktiv et', + 'settings.webPush.enabled': 'Bu cihaz üçün aktivdir', + 'settings.webPush.unsupported': 'Bu brauzer push bildirişlərini qəbul edə bilmir.', + 'settings.webPush.insecure': 'Push üçün TREK HTTPS vasitəsilə açılmalıdır.', + 'settings.webPush.iosInstall': 'iPhone və iPad-də əvvəlcə TREK-i Əsas ekrana əlavə edin və onu oradan açın.', + 'settings.webPush.denied': + 'Bu brauzerdə TREK üçün bildirişlər bloklanıb. Onlara brauzer tənzimləmələrində icazə verin, sonra yenidən cəhd edin.', + 'settings.webPush.failed': 'Bu cihaz üçün push aktiv edilə bilmədi.', 'settings.notificationPreferences.noChannels': 'Heç bir bildiriş kanalı konfiqurasiya edilməyib. Administratordan e-poçt və ya webhook bildirişlərini quraşdırmasını istəyin.', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 0c5551630..f6e2b9ef4 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -714,6 +714,9 @@ const admin: TranslationStrings = { 'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Permite que os usuários recebam notificações no celular e no computador pelo navegador, mesmo com o TREK fechado. Requer HTTPS; no iPhone e no iPad, o TREK precisa ser adicionado à Tela de Início.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.', diff --git a/shared/src/i18n/br/help.ts b/shared/src/i18n/br/help.ts index 3a4cb6e2d..9dc556acb 100644 --- a/shared/src/i18n/br/help.ts +++ b/shared/src/i18n/br/help.ts @@ -1051,11 +1051,13 @@ const help: TranslationStrings = { 'Modo alta qualidade para antialiasing e a projeção de globo; Salvar mapa grava a escolha.', 'help.ctx.settings-notifications.title': 'Notificações', 'help.ctx.settings-notifications.summary': - 'Onde o TREK alcança você fora do app: um tópico ntfy, um webhook ou um canal que um plugin fornece. Abaixo dos canais, uma linha por evento decide o que vai para onde.', + 'Onde o TREK alcança você fora do app: notificações push neste dispositivo, um tópico ntfy, um webhook ou um canal que um plugin fornece. Abaixo dos canais, uma linha por evento decide o que vai para onde.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: o tópico, um servidor próprio opcional e um token de acesso opcional, com Testar para enviar um na hora.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: uma URL que recebe cada evento como JSON, com Testar.', 'help.ctx.settings-notifications.bullet.3': + 'Notificações push neste dispositivo: Ativar neste dispositivo vale só para o navegador que você está usando, então repita em cada celular ou computador. Enviar teste chega a todos eles.', + 'help.ctx.settings-notifications.bullet.4': 'As linhas de preferências: por evento, qual canal está ligado. Canais de plugin mostram Configurar até serem configurados.', 'help.ctx.settings-integrations.title': 'Integrações', 'help.ctx.settings-integrations.summary': @@ -1377,7 +1379,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Os canais que a instância oferece aos usuários, e os que chegam até você como admin. Os usuários escolhem seus próprios tópicos e URLs em Configurações; você decide o que existe e configura o e-mail.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy e Webhook: um painel para cada, com uma chave que oferece o canal aos usuários e a configuração do lado do servidor de que ele precisa.', + 'In-App, Email (SMTP), Ntfy, Webhook e Web Push: um painel para cada, com uma chave que oferece o canal aos usuários e a configuração do lado do servidor de que ele precisa.', 'help.ctx.admin-notifications.bullet.2': 'Lembretes de viagem: se o servidor envia o lembrete antes de uma viagem começar.', 'help.ctx.admin-notifications.bullet.3': @@ -1620,7 +1622,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Em Email (SMTP), informe SMTP Host, SMTP Port, SMTP User, SMTP Password e a From Address; Enviar e-mail de teste manda um e-mail para você.', 'help.guide.channels-instance.step.2': - 'Ligue Ntfy e Webhook para oferecê-los; os usuários então informam o próprio tópico ou URL em Configurações, Notificações.', + 'Ligue Web Push, Ntfy e Webhook para oferecê-los; os usuários então ativam o push em cada dispositivo, ou informam o próprio tópico ou URL, em Configurações, Notificações.', 'help.guide.channels-instance.step.3': 'Lembretes de viagem controla o lembrete antes de uma viagem começar; In-App está sempre ligado e aqui só é explicado.', 'help.guide.channels-instance.result': 'A aba Notificações de cada usuário mostra os canais que você ligou.', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index fca026fa9..a81eabad9 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -322,6 +322,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Notificações push neste dispositivo', + 'settings.webPush.hint': + 'Mostra as notificações do TREK neste dispositivo mesmo com o TREK fechado. Os eventos que chegam seguem a coluna Push.', + 'settings.webPush.enable': 'Ativar neste dispositivo', + 'settings.webPush.disable': 'Desativar neste dispositivo', + 'settings.webPush.enabled': 'Ativado neste dispositivo', + 'settings.webPush.unsupported': 'Este navegador não pode receber notificações push.', + 'settings.webPush.insecure': 'O push exige que o TREK seja aberto via HTTPS.', + 'settings.webPush.iosInstall': 'No iPhone e no iPad, adicione primeiro o TREK à Tela de Início e abra-o por lá.', + 'settings.webPush.denied': + 'As notificações do TREK estão bloqueadas neste navegador. Permita-as nas configurações do navegador e tente novamente.', + 'settings.webPush.failed': 'Não foi possível ativar o push neste dispositivo.', 'settings.currency': 'Moeda de exibição', 'settings.currencyHint': 'Os valores em Custos são exibidos convertidos para esta moeda apenas para visualização — os valores originais permanecem inalterados.', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 507394938..2aad646b4 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -361,6 +361,9 @@ const admin: TranslationStrings = { 'admin.group.maintenance': 'Manteniment', 'admin.notifications.emailPanel.title': 'Correu (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + "Permet als usuaris rebre notificacions als telèfons i ordinadors a través del navegador, fins i tot amb TREK tancat. Cal HTTPS; a l'iPhone i l'iPad, TREK s'ha d'afegir a la pantalla d'inici.", 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Les notificacions in-app sempre estan actives i no es poden desactivar globalment.', diff --git a/shared/src/i18n/ca/help.ts b/shared/src/i18n/ca/help.ts index 8c3a571d3..2802867a0 100644 --- a/shared/src/i18n/ca/help.ts +++ b/shared/src/i18n/ca/help.ts @@ -1068,11 +1068,13 @@ const help: TranslationStrings = { "Mode d'alta qualitat per a l’antialiàsing i la projecció de globus; Desa el mapa escriu la tria.", 'help.ctx.settings-notifications.title': 'Notificacions', 'help.ctx.settings-notifications.summary': - 'On et troba TREK fora de l’app: un tema de ntfy, un webhook o un canal que aporta un connector. Sota els canals, una fila per esdeveniment decideix què va on.', + 'On et troba TREK fora de l’app: notificacions push en aquest dispositiu, un tema de ntfy, un webhook o un canal que aporta un connector. Sota els canals, una fila per esdeveniment decideix què va on.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: el tema, un servidor propi opcional i un token d’accés opcional, amb Prova per enviar-ne un a l’instant.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: una URL que rep cada esdeveniment com a JSON, amb Prova.', 'help.ctx.settings-notifications.bullet.3': + 'Notificacions push en aquest dispositiu: Activa en aquest dispositiu només afecta el navegador que fas servir, així que repeteix-ho a cada mòbil o ordinador. Envia una prova arriba a tots.', + 'help.ctx.settings-notifications.bullet.4': 'Les files de preferències: per esdeveniment, quin canal està actiu. Els canals de connectors mostren Configura fins que estan configurats.', 'help.ctx.settings-integrations.title': 'Integracions', 'help.ctx.settings-integrations.summary': @@ -1401,7 +1403,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Els canals que la instància ofereix als seus usuaris, i els que t’arriben a tu com a admin. Els usuaris trien els seus propis temes i URL a Configuració; tu decideixes què existeix i configures el correu.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Correu (SMTP), Ntfy i Webhook: un panell cadascun, amb un interruptor que ofereix el canal als usuaris i la configuració del costat del servidor que necessita.', + 'In-App, Correu (SMTP), Ntfy, Webhook i Web Push: un panell cadascun, amb un interruptor que ofereix el canal als usuaris i la configuració del costat del servidor que necessita.', 'help.ctx.admin-notifications.bullet.2': 'Recordatoris de viatge: si el servidor envia el recordatori abans que comenci un viatge.', 'help.ctx.admin-notifications.bullet.3': @@ -1653,7 +1655,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'A Correu (SMTP), introdueix SMTP Host, SMTP Port, SMTP User, SMTP Password i la From Address; Enviar correu de prova t’envia un correu a tu.', 'help.guide.channels-instance.step.2': - 'Activa Ntfy i Webhook per oferir-los; els usuaris introdueixen llavors el seu propi tema o URL a Configuració, Notificacions.', + 'Activa Web Push, Ntfy i Webhook per oferir-los; els usuaris activen llavors el push per a cada dispositiu, o introdueixen el seu propi tema o URL, a Configuració, Notificacions.', 'help.guide.channels-instance.step.3': 'Recordatoris de viatge controla el recordatori abans que comenci un viatge; In-App sempre està activat i aquí només s’explica.', 'help.guide.channels-instance.result': 'La pestanya Notificacions de cada usuari mostra els canals que has activat.', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 6d5d3be5f..b1faebc43 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -296,6 +296,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Correu electrònic', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Notificacions push en aquest dispositiu', + 'settings.webPush.hint': + 'Mostra les notificacions de TREK en aquest dispositiu fins i tot quan TREK està tancat. Els esdeveniments que arriben segueixen la columna Push.', + 'settings.webPush.enable': 'Activa en aquest dispositiu', + 'settings.webPush.disable': 'Desactiva en aquest dispositiu', + 'settings.webPush.enabled': 'Activat en aquest dispositiu', + 'settings.webPush.unsupported': 'Aquest navegador no pot rebre notificacions push.', + 'settings.webPush.insecure': "Push necessita que TREK s'obri per HTTPS.", + 'settings.webPush.iosInstall': "A l'iPhone i l'iPad, afegeix primer TREK a la pantalla d'inici i obre'l des d'allà.", + 'settings.webPush.denied': + 'Les notificacions de TREK estan bloquejades en aquest navegador. Permet-les a la configuració del navegador i torna-ho a provar.', + 'settings.webPush.failed': "No s'ha pogut activar push en aquest dispositiu.", 'settings.passkey.title': 'Passkeys', 'settings.passkey.description': 'Inicia la sessió més ràpidament i amb protecció contra el phishing utilitzant una passkey: la teva empremta, la teva cara, el teu PIN o una clau de seguretat física. La teva contrasenya continua disponible com a alternativa.', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index b0f3d7c53..17af1b66a 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -709,6 +709,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Oprávnění', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Umožňuje uživatelům přijímat oznámení na telefonech a počítačích přes prohlížeč, i když je TREK zavřený. Vyžaduje HTTPS; na iPhonu a iPadu musí být TREK přidán na plochu.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'In-app oznámení jsou vždy aktivní a nelze je globálně vypnout.', 'admin.notifications.adminWebhookPanel.title': 'Admin webhook', diff --git a/shared/src/i18n/cs/help.ts b/shared/src/i18n/cs/help.ts index 31882ad60..6ab0c0eba 100644 --- a/shared/src/i18n/cs/help.ts +++ b/shared/src/i18n/cs/help.ts @@ -1021,12 +1021,14 @@ const help: TranslationStrings = { 'Režim vysoké kvality pro vyhlazování a projekci glóbu; Uložit nastavení mapy volbu zapíše.', 'help.ctx.settings-notifications.title': 'Oznámení', 'help.ctx.settings-notifications.summary': - 'Kde vás TREK zastihne mimo aplikaci: téma ntfy, webhook nebo kanál, který poskytuje doplněk. Pod kanály rozhoduje jeden řádek na událost, co kam půjde.', + 'Kde vás TREK zastihne mimo aplikaci: push notifikace na tomto zařízení, téma ntfy, webhook nebo kanál, který poskytuje doplněk. Pod kanály rozhoduje jeden řádek na událost, co kam půjde.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: téma, volitelně vlastní server a volitelný přístupový token, s tlačítkem Otestovat, které hned jedno odešle.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: jedna URL, která dostává každou událost jako JSON, s tlačítkem Otestovat.', 'help.ctx.settings-notifications.bullet.3': + 'Push notifikace na tomto zařízení: Zapnout pro toto zařízení platí jen pro prohlížeč, který právě používáte, takže to zopakujte na každém telefonu nebo počítači. Odeslat test dorazí na všechny.', + 'help.ctx.settings-notifications.bullet.4': 'Řádky předvoleb: pro každou událost, který kanál je zapnutý. Kanály doplňků ukazují Nastavit, dokud nejsou nastavené.', 'help.ctx.settings-integrations.title': 'Integrace', 'help.ctx.settings-integrations.summary': @@ -1345,7 +1347,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Kanály, které instance nabízí svým uživatelům, a ty, které zastihnou vás jako administrátora. Uživatelé si vybírají vlastní témata a URL pod Nastavení; vy rozhodujete, co existuje, a nastavujete e-mail.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy a Webhook: jeden panel pro každý, s přepínačem, který kanál nabídne uživatelům, a konfigurací na straně serveru, kterou potřebuje.', + 'In-App, Email (SMTP), Ntfy, Webhook a Web Push: jeden panel pro každý, s přepínačem, který kanál nabídne uživatelům, a konfigurací na straně serveru, kterou potřebuje.', 'help.ctx.admin-notifications.bullet.2': 'Připomínky výletů: zda server posílá připomínku před začátkem cesty.', 'help.ctx.admin-notifications.bullet.3': 'Admin Ntfy a Admin webhook: kam jdou události pro administrátory, jako neúspěšná záloha nebo nové vydání, s tlačítkem pro test.', @@ -1580,7 +1582,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Pod Email (SMTP) zadejte SMTP Host, SMTP Port, SMTP User, SMTP Password a From Address; Odeslat testovací e-mail vám pošle zprávu.', 'help.guide.channels-instance.step.2': - 'Zapněte Ntfy a Webhook, abyste je nabídli; uživatelé pak zadají vlastní téma nebo URL pod Nastavení, Oznámení.', + 'Zapněte Web Push, Ntfy a Webhook, abyste je nabídli; uživatelé pak pod Nastavení, Oznámení zapnou push pro každé zařízení, nebo zadají vlastní téma či URL.', 'help.guide.channels-instance.step.3': 'Připomínky výletů přepínají připomínku před začátkem cesty; In-App je vždy zapnuté a tady je jen vysvětlené.', 'help.guide.channels-instance.result': 'Karta Oznámení každého uživatele ukazuje kanály, které jste zapnuli.', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 12f40ad54..f2f1fb4a5 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -315,6 +315,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push notifikace na tomto zařízení', + 'settings.webPush.hint': + 'Zobrazuje oznámení TREK na tomto zařízení, i když je TREK zavřený. Které události dorazí, určuje sloupec Push.', + 'settings.webPush.enable': 'Zapnout pro toto zařízení', + 'settings.webPush.disable': 'Vypnout pro toto zařízení', + 'settings.webPush.enabled': 'Zapnuto pro toto zařízení', + 'settings.webPush.unsupported': 'Tento prohlížeč nemůže přijímat push notifikace.', + 'settings.webPush.insecure': 'Push vyžaduje, aby byl TREK otevřen přes HTTPS.', + 'settings.webPush.iosInstall': 'Na iPhonu a iPadu nejprve přidejte TREK na plochu a otevřete ho odtud.', + 'settings.webPush.denied': + 'Oznámení pro TREK jsou v tomto prohlížeči zablokovaná. Povolte je v nastavení prohlížeče a zkuste to znovu.', + 'settings.webPush.failed': 'Push se pro toto zařízení nepodařilo zapnout.', 'settings.currency': 'Zobrazovaná měna', 'settings.currencyHint': 'Částky v Nákladech se do této měny přepočítávají pouze pro zobrazení – původní částky zůstávají beze změny.', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 629a2ee6b..0e2aac636 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -721,6 +721,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Berechtigungen', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Ermöglicht Benutzern, über den Browser Benachrichtigungen auf ihren Smartphones und Computern zu empfangen, auch wenn TREK geschlossen ist. Erfordert HTTPS; auf iPhone und iPad muss TREK zum Home-Bildschirm hinzugefügt werden.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.', diff --git a/shared/src/i18n/de/help.ts b/shared/src/i18n/de/help.ts index de639e8d6..57759767c 100644 --- a/shared/src/i18n/de/help.ts +++ b/shared/src/i18n/de/help.ts @@ -1075,11 +1075,13 @@ const help: TranslationStrings = { 'Hochqualitäts-Modus für Antialiasing und die Globus-Projektion; Karte speichern schreibt die Wahl.', 'help.ctx.settings-notifications.title': 'Mitteilungen', 'help.ctx.settings-notifications.summary': - 'Wo TREK dich außerhalb der App erreicht: ein ntfy-Thema, ein Webhook oder ein Kanal, den ein Plugin bereitstellt. Unter den Kanälen entscheidet eine Zeile pro Ereignis, was wohin geht.', + 'Wo TREK dich außerhalb der App erreicht: Push-Benachrichtigungen auf diesem Gerät, ein ntfy-Thema, ein Webhook oder ein Kanal, den ein Plugin bereitstellt. Unter den Kanälen entscheidet eine Zeile pro Ereignis, was wohin geht.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: das Thema, optional ein eigener Server und ein optionaler Zugriffstoken, mit Testen, um sofort eine Nachricht zu schicken.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: eine URL, die jedes Ereignis als JSON empfängt, mit Testen.', 'help.ctx.settings-notifications.bullet.3': + 'Push-Benachrichtigungen auf diesem Gerät: Für dieses Gerät einschalten gilt nur für den Browser, den du gerade nutzt, also wiederhole es auf jedem Handy oder Computer. Test senden erreicht alle.', + 'help.ctx.settings-notifications.bullet.4': 'Die Zeilen mit den Präferenzen: pro Ereignis, welcher Kanal an ist. Plugin-Kanäle zeigen Einrichten, bis sie eingerichtet sind.', 'help.ctx.settings-integrations.title': 'Integrationen', 'help.ctx.settings-integrations.summary': @@ -1407,7 +1409,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Die Kanäle, die die Instanz ihren Benutzern anbietet, und die, die dich als Admin erreichen. Benutzer wählen ihre eigenen Themen und URLs unter Einstellungen; du entscheidest, was es gibt, und richtest E-Mail ein.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy und Webhook: je ein Panel, mit einem Schalter, der den Kanal den Benutzern anbietet, und der serverseitigen Konfiguration, die er braucht.', + 'In-App, Email (SMTP), Ntfy, Webhook und Web Push: je ein Panel, mit einem Schalter, der den Kanal den Benutzern anbietet, und der serverseitigen Konfiguration, die er braucht.', 'help.ctx.admin-notifications.bullet.2': 'Reiseerinnerungen: ob der Server die Erinnerung vor Reisebeginn schickt.', 'help.ctx.admin-notifications.bullet.3': 'Admin-Ntfy und Admin-Webhook: wohin Admin-Ereignisse wie ein fehlgeschlagenes Backup oder ein neues Release gehen, mit Test.', @@ -1662,7 +1664,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Trag unter Email (SMTP) SMTP Host, SMTP Port, SMTP User, SMTP Password und die From Address ein; Test-E-Mail senden schickt eine Mail an dich.', 'help.guide.channels-instance.step.2': - 'Schalte Ntfy und Webhook an, um sie anzubieten; Benutzer tragen dann ihr eigenes Thema oder ihre URL unter Einstellungen, Mitteilungen ein.', + 'Schalte Web Push, Ntfy und Webhook an, um sie anzubieten; Benutzer schalten dann unter Einstellungen, Mitteilungen Push pro Gerät ein oder tragen ihr eigenes Thema oder ihre URL ein.', 'help.guide.channels-instance.step.3': 'Reiseerinnerungen schaltet die Erinnerung vor Reisebeginn; In-App ist immer an und wird hier nur erklärt.', 'help.guide.channels-instance.result': diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 446da5f05..7dc650452 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -323,6 +323,20 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push-Benachrichtigungen auf diesem Gerät', + 'settings.webPush.hint': + 'Zeigt TREK-Benachrichtigungen auf diesem Gerät an, auch wenn TREK geschlossen ist. Welche Ereignisse ankommen, richtet sich nach der Spalte Push.', + 'settings.webPush.enable': 'Für dieses Gerät einschalten', + 'settings.webPush.disable': 'Für dieses Gerät ausschalten', + 'settings.webPush.enabled': 'Für dieses Gerät eingeschaltet', + 'settings.webPush.unsupported': 'Dieser Browser kann keine Push-Benachrichtigungen empfangen.', + 'settings.webPush.insecure': 'Für Push muss TREK über HTTPS geöffnet werden.', + 'settings.webPush.iosInstall': + 'Füge TREK auf iPhone und iPad zuerst zum Home-Bildschirm hinzu und öffne es von dort.', + 'settings.webPush.denied': + 'Benachrichtigungen für TREK sind in diesem Browser blockiert. Erlaube sie in den Browsereinstellungen und versuche es dann erneut.', + 'settings.webPush.failed': 'Push konnte für dieses Gerät nicht eingeschaltet werden.', 'settings.currency': 'Anzeigewährung', 'settings.currencyHint': 'Beträge unter Kosten werden nur zur Anzeige in diese Währung umgerechnet – die ursprünglichen Beträge bleiben unverändert.', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 3dc4ba084..4ae4ade7e 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Test ntfy failed', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Lets users receive notifications on their phones and computers through the browser, even while TREK is closed. Needs HTTPS; on iPhone and iPad TREK has to be added to the Home Screen.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.', 'admin.notifications.adminWebhookPanel.title': 'Admin Webhook', diff --git a/shared/src/i18n/en/help.ts b/shared/src/i18n/en/help.ts index aff8a5c54..137ca3a3d 100644 --- a/shared/src/i18n/en/help.ts +++ b/shared/src/i18n/en/help.ts @@ -1034,11 +1034,13 @@ const help: TranslationStrings = { 'High Quality Mode for antialiasing and the globe projection; Save Map writes the choice.', 'help.ctx.settings-notifications.title': 'Notifications', 'help.ctx.settings-notifications.summary': - 'Where TREK reaches you outside the app: an ntfy topic, a webhook, or a channel a plugin provides. Below the channels, one row per event decides what goes where.', + 'Where TREK reaches you outside the app: push notifications on this device, an ntfy topic, a webhook, or a channel a plugin provides. Below the channels, one row per event decides what goes where.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: the topic, an optional server of your own and an optional access token, with Test to send one right away.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: one URL that receives every event as JSON, with Test.', 'help.ctx.settings-notifications.bullet.3': + 'Push notifications on this device: Turn on for this device covers only the browser you are using, so repeat it on each phone or computer. Send test reaches all of them.', + 'help.ctx.settings-notifications.bullet.4': 'The preference rows: per event, which channel is on. Plugin channels show Configure until they are set up.', 'help.ctx.settings-integrations.title': 'Integrations', 'help.ctx.settings-integrations.summary': @@ -1350,7 +1352,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'The channels the instance offers its users, and the ones that reach you as the admin. Users pick their own topics and URLs under Settings; you decide what exists and configure email.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy and Webhook: one panel each, with a switch that offers the channel to users and the server-side configuration it needs.', + 'In-App, Email (SMTP), Ntfy, Webhook and Web Push: one panel each, with a switch that offers the channel to users and the server-side configuration it needs.', 'help.ctx.admin-notifications.bullet.2': 'Trip Reminders: whether the server sends the reminder before a trip starts.', 'help.ctx.admin-notifications.bullet.3': @@ -1587,7 +1589,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Under Email (SMTP), enter SMTP Host, SMTP Port, SMTP User, SMTP Password and the From Address; Send test email sends a mail to you.', 'help.guide.channels-instance.step.2': - 'Switch Ntfy and Webhook on to offer them; users then enter their own topic or URL under Settings, Notifications.', + 'Switch Web Push, Ntfy and Webhook on to offer them; users then turn push on per device, or enter their own topic or URL, under Settings, Notifications.', 'help.guide.channels-instance.step.3': 'Trip Reminders switches the reminder before a trip starts; In-App is always on and only explained here.', 'help.guide.channels-instance.result': 'The Notifications tab of every user shows the channels you switched on.', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 533e912d6..0b4387e59 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -125,6 +125,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': 'Test failed.', 'settings.notificationPreferences.pluginConfigured': 'Configured. Manage credentials on the plugin’s settings page.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push notifications on this device', + 'settings.webPush.hint': + 'Shows TREK notifications on this device even while TREK is closed. Which events arrive follows the Push column.', + 'settings.webPush.enable': 'Turn on for this device', + 'settings.webPush.disable': 'Turn off for this device', + 'settings.webPush.enabled': 'On for this device', + 'settings.webPush.unsupported': 'This browser cannot receive push notifications.', + 'settings.webPush.insecure': 'Push needs TREK to be opened over HTTPS.', + 'settings.webPush.iosInstall': 'On iPhone and iPad, add TREK to the Home Screen first and open it from there.', + 'settings.webPush.denied': + 'Notifications are blocked for TREK in this browser. Allow them in the browser settings, then try again.', + 'settings.webPush.failed': 'Push could not be turned on for this device.', 'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 283729bc9..6fc1b9cfb 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -728,6 +728,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Permisos', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Permite a los usuarios recibir notificaciones en sus teléfonos y ordenadores a través del navegador, incluso con TREK cerrado. Requiere HTTPS; en iPhone y iPad, TREK tiene que añadirse a la pantalla de inicio.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Las notificaciones in-app siempre están activas y no se pueden desactivar globalmente.', diff --git a/shared/src/i18n/es/help.ts b/shared/src/i18n/es/help.ts index 7352b9b9c..7d5865ce5 100644 --- a/shared/src/i18n/es/help.ts +++ b/shared/src/i18n/es/help.ts @@ -1070,11 +1070,13 @@ const help: TranslationStrings = { 'Modo de alta calidad para el antialiasing y la proyección de globo; Guardar mapa escribe la elección.', 'help.ctx.settings-notifications.title': 'Notificaciones', 'help.ctx.settings-notifications.summary': - 'Dónde te localiza TREK fuera de la app: un tema de ntfy, un webhook o un canal que aporta un complemento. Bajo los canales, una fila por evento decide qué va a dónde.', + 'Dónde te localiza TREK fuera de la app: notificaciones push en este dispositivo, un tema de ntfy, un webhook o un canal que aporta un complemento. Bajo los canales, una fila por evento decide qué va a dónde.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: el tema, un servidor propio opcional y un token de acceso opcional, con Probar para enviar uno al momento.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: una URL que recibe cada evento como JSON, con Probar.', 'help.ctx.settings-notifications.bullet.3': + 'Notificaciones push en este dispositivo: Activar en este dispositivo solo cubre el navegador que estás usando, así que repítelo en cada móvil u ordenador. Enviar prueba llega a todos.', + 'help.ctx.settings-notifications.bullet.4': 'Las filas de preferencias: por evento, qué canal está activo. Los canales de complementos muestran Configurar hasta que estén configurados.', 'help.ctx.settings-integrations.title': 'Integraciones', 'help.ctx.settings-integrations.summary': @@ -1402,7 +1404,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Los canales que la instancia ofrece a sus usuarios, y los que te llegan a ti como admin. Los usuarios eligen sus propios temas y URL en Ajustes; tú decides qué existe y configuras el correo.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy y Webhook: un panel cada uno, con un interruptor que ofrece el canal a los usuarios y la configuración del lado del servidor que necesita.', + 'In-App, Email (SMTP), Ntfy, Webhook y Web Push: un panel cada uno, con un interruptor que ofrece el canal a los usuarios y la configuración del lado del servidor que necesita.', 'help.ctx.admin-notifications.bullet.2': 'Recordatorios de viaje: si el servidor envía el recordatorio antes de que empiece un viaje.', 'help.ctx.admin-notifications.bullet.3': @@ -1658,7 +1660,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'En Email (SMTP), introduce SMTP Host, SMTP Port, SMTP User, SMTP Password y la From Address; Enviar correo de prueba te manda un correo a ti.', 'help.guide.channels-instance.step.2': - 'Activa Ntfy y Webhook para ofrecerlos; los usuarios introducen entonces su propio tema o URL en Ajustes, Notificaciones.', + 'Activa Web Push, Ntfy y Webhook para ofrecerlos; los usuarios activan entonces push en cada dispositivo, o introducen su propio tema o URL, en Ajustes, Notificaciones.', 'help.guide.channels-instance.step.3': 'Recordatorios de viaje controla el recordatorio antes de que empiece un viaje; In-App siempre está activo y aquí solo se explica.', 'help.guide.channels-instance.result': 'La pestaña Notificaciones de cada usuario muestra los canales que activaste.', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 08a8e31b6..e612ae95e 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -323,6 +323,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Notificaciones push en este dispositivo', + 'settings.webPush.hint': + 'Muestra las notificaciones de TREK en este dispositivo aunque TREK esté cerrado. Los eventos que llegan siguen la columna Push.', + 'settings.webPush.enable': 'Activar en este dispositivo', + 'settings.webPush.disable': 'Desactivar en este dispositivo', + 'settings.webPush.enabled': 'Activado en este dispositivo', + 'settings.webPush.unsupported': 'Este navegador no puede recibir notificaciones push.', + 'settings.webPush.insecure': 'Push necesita que TREK se abra mediante HTTPS.', + 'settings.webPush.iosInstall': 'En iPhone y iPad, añade primero TREK a la pantalla de inicio y ábrelo desde ahí.', + 'settings.webPush.denied': + 'Las notificaciones de TREK están bloqueadas en este navegador. Permítelas en los ajustes del navegador y vuelve a intentarlo.', + 'settings.webPush.failed': 'No se pudo activar push en este dispositivo.', 'settings.currency': 'Moneda de visualización', 'settings.currencyHint': 'Los importes en Costes se muestran convertidos a esta moneda solo a efectos de visualización — los importes originales no se modifican.', diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index 3ba84bc09..43adb28ef 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Ntfy testteavituse saatmine ebaõnnestus', 'admin.notifications.emailPanel.title': 'E-post (SMTP)', 'admin.notifications.webhookPanel.title': 'Veebihaak', + 'admin.notifications.webPushPanel.title': 'Veebitõuketeavitused', + 'admin.notifications.webPushPanel.hint': + "Võimaldab kasutajatel saada brauseri kaudu teavitusi oma telefonides ja arvutites ka siis, kui TREK on suletud. Vajab HTTPS-i; iPhone'is ja iPadis tuleb TREK lisada avakuvale.", 'admin.notifications.inappPanel.title': 'Rakenduses', 'admin.notifications.inappPanel.hint': 'Rakendusesisesed teavitused on alati aktiivsed ja neid ei saa kõigi jaoks välja lülitada.', diff --git a/shared/src/i18n/et/help.ts b/shared/src/i18n/et/help.ts index 5dd9ca1a2..df488eeb0 100644 --- a/shared/src/i18n/et/help.ts +++ b/shared/src/i18n/et/help.ts @@ -1050,11 +1050,13 @@ const help: TranslationStrings = { 'Kõrge kvaliteediga režiim servade silumiseks ja gloobuse projektsiooniks; Salvesta kaart salvestab valiku.', 'help.ctx.settings-notifications.title': 'Teavitused', 'help.ctx.settings-notifications.summary': - 'Kus TREK sinuni väljaspool rakendust jõuab: ntfy teema, veebihaak või plugina pakutav kanal. Kanalite all otsustab iga sündmuse rida, mis kuhu läheb.', + 'Kus TREK sinuni väljaspool rakendust jõuab: tõuketeavitused selles seadmes, ntfy teema, veebihaak või plugina pakutav kanal. Kanalite all otsustab iga sündmuse rida, mis kuhu läheb.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: teema, valikuline oma server ja valikuline pääsutõend, koos nupuga Testi, mis saadab kohe ühe sõnumi.', 'help.ctx.settings-notifications.bullet.2': 'Veebihaak: üks URL, mis saab iga sündmuse JSON-ina, koos nupuga Testi.', 'help.ctx.settings-notifications.bullet.3': + 'Tõuketeavitused selles seadmes: Lülita selles seadmes sisse kehtib ainult brauserile, mida praegu kasutad, seega korda seda igas telefonis või arvutis. Saada test jõuab kõigini.', + 'help.ctx.settings-notifications.bullet.4': 'Eelistuste read: iga sündmuse puhul, milline kanal on sees. Plugina kanalitel on nupp Seadista, kuni need on seadistatud.', 'help.ctx.settings-integrations.title': 'Liidestused', 'help.ctx.settings-integrations.summary': @@ -1376,7 +1378,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Kanalid, mida instants oma kasutajatele pakub, ja need, mis jõuavad sinuni administraatorina. Kasutajad valivad oma teemad ja URL-id jaotises Seaded; sina otsustad, mis on olemas, ja seadistad e-posti.', 'help.ctx.admin-notifications.bullet.1': - 'Rakenduses, E-post (SMTP), Ntfy ja Veebihaak: igaühel oma paneel lülitiga, mis pakub kanalit kasutajatele, ja serveripoolse seadistusega, mida see vajab.', + 'Rakenduses, E-post (SMTP), Ntfy, Veebihaak ja Veebitõuketeavitused: igaühel oma paneel lülitiga, mis pakub kanalit kasutajatele, ja serveripoolse seadistusega, mida see vajab.', 'help.ctx.admin-notifications.bullet.2': 'Reisi meeldetuletused: kas server saadab meeldetuletuse enne reisi algust.', 'help.ctx.admin-notifications.bullet.3': 'Administraatori ntfy ja Administraatori veebihaak: kuhu lähevad administraatori sündmused, nagu ebaõnnestunud varundus või uus väljalase, koos nupuga Testi.', @@ -1638,7 +1640,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Jaotises E-post (SMTP) sisesta SMTP-host, SMTP-port, SMTP-kasutaja, SMTP-parool ja Saatja aadress; Saada testkiri saadab sulle kirja.', 'help.guide.channels-instance.step.2': - 'Nende pakkumiseks lülita sisse Ntfy ja Veebihaak; seejärel sisestavad kasutajad oma teema või URL-i jaotises Seaded, Teavitused.', + 'Nende pakkumiseks lülita sisse Veebitõuketeavitused, Ntfy ja Veebihaak; seejärel lülitavad kasutajad jaotises Seaded, Teavitused tõuketeavitused iga seadme jaoks sisse või sisestavad oma teema või URL-i.', 'help.guide.channels-instance.step.3': 'Reisi meeldetuletused lülitab sisse või välja meeldetuletuse enne reisi algust; Rakenduses on alati sees ja seda ainult selgitatakse siin.', 'help.guide.channels-instance.result': 'Iga kasutaja vahekaardil Teavitused on näha kanalid, mille sisse lülitasid.', diff --git a/shared/src/i18n/et/settings.ts b/shared/src/i18n/et/settings.ts index 52554ca5c..1e2041438 100644 --- a/shared/src/i18n/et/settings.ts +++ b/shared/src/i18n/et/settings.ts @@ -131,6 +131,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': 'Test ebaõnnestus.', 'settings.notificationPreferences.pluginConfigured': 'Seadistatud. Halda kasutajaandmeid plugina seadete lehel.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Tõuketeavitus', + 'settings.webPush.title': 'Tõuketeavitused selles seadmes', + 'settings.webPush.hint': + 'Näitab TREKi teavitusi selles seadmes ka siis, kui TREK on suletud. Milliseid sündmusi saabub, määrab veerg Tõuketeavitus.', + 'settings.webPush.enable': 'Lülita selles seadmes sisse', + 'settings.webPush.disable': 'Lülita selles seadmes välja', + 'settings.webPush.enabled': 'Selles seadmes sisse lülitatud', + 'settings.webPush.unsupported': 'See brauser ei saa tõuketeavitusi vastu võtta.', + 'settings.webPush.insecure': 'Tõuketeavituste jaoks peab TREK olema avatud HTTPS-i kaudu.', + 'settings.webPush.iosInstall': "iPhone'is ja iPadis lisa TREK esmalt avakuvale ja ava see sealt.", + 'settings.webPush.denied': + 'TREKi teavitused on selles brauseris blokeeritud. Luba need brauseri seadetes ja proovi siis uuesti.', + 'settings.webPush.failed': 'Tõuketeavitusi ei õnnestunud selles seadmes sisse lülitada.', 'settings.notificationPreferences.noChannels': 'Ühtegi teavituskanalit pole seadistatud. Palu administraatoril seadistada e-posti või veebihaagi teavitused.', 'settings.webhookUrl.label': 'Veebihaagi URL', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 585de4796..3e5b47cec 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -728,6 +728,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Permissions', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + "Permet aux utilisateurs de recevoir des notifications sur leurs téléphones et ordinateurs via le navigateur, même quand TREK est fermé. Nécessite HTTPS ; sur iPhone et iPad, TREK doit être ajouté à l'écran d'accueil.", 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Les notifications in-app sont toujours actives et ne peuvent pas être désactivées globalement.', diff --git a/shared/src/i18n/fr/help.ts b/shared/src/i18n/fr/help.ts index 9204b8dde..4789d0ebf 100644 --- a/shared/src/i18n/fr/help.ts +++ b/shared/src/i18n/fr/help.ts @@ -1087,11 +1087,13 @@ const help: TranslationStrings = { 'Mode haute qualité pour l’anticrénelage et la projection en globe ; Enregistrer la carte écrit le choix.', 'help.ctx.settings-notifications.title': 'Notifications', 'help.ctx.settings-notifications.summary': - 'Où TREK vous joint en dehors de l’application : un sujet ntfy, un webhook ou un canal fourni par un module. Sous les canaux, une ligne par événement décide de ce qui va où.', + 'Où TREK vous joint en dehors de l’application : des notifications push sur cet appareil, un sujet ntfy, un webhook ou un canal fourni par un module. Sous les canaux, une ligne par événement décide de ce qui va où.', 'help.ctx.settings-notifications.bullet.1': 'ntfy : le sujet, un serveur à vous en option et un jeton d’accès en option, avec Tester pour en envoyer un tout de suite.', 'help.ctx.settings-notifications.bullet.2': 'Webhook : une URL qui reçoit chaque événement en JSON, avec Tester.', 'help.ctx.settings-notifications.bullet.3': + 'Notifications push sur cet appareil : Activer sur cet appareil ne concerne que le navigateur que vous utilisez, alors répétez-le sur chaque téléphone ou ordinateur. Envoyer un test les atteint tous.', + 'help.ctx.settings-notifications.bullet.4': 'Les lignes de préférences : par événement, quel canal est actif. Les canaux de modules affichent Configurer tant qu’ils ne sont pas configurés.', 'help.ctx.settings-integrations.title': 'Intégrations', 'help.ctx.settings-integrations.summary': @@ -1429,7 +1431,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Les canaux que l’instance propose à ses utilisateurs, et ceux qui vous joignent en tant qu’admin. Les utilisateurs choisissent leurs propres sujets et URL sous Paramètres ; vous décidez de ce qui existe et configurez l’e-mail.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy et Webhook : un panneau chacun, avec un interrupteur qui propose le canal aux utilisateurs et la configuration côté serveur dont il a besoin.', + 'In-App, Email (SMTP), Ntfy, Webhook et Web Push : un panneau chacun, avec un interrupteur qui propose le canal aux utilisateurs et la configuration côté serveur dont il a besoin.', 'help.ctx.admin-notifications.bullet.2': 'Rappels de voyage : si le serveur envoie le rappel avant le début d’un voyage.', 'help.ctx.admin-notifications.bullet.3': @@ -1693,7 +1695,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Sous Email (SMTP), saisissez SMTP Host, SMTP Port, SMTP User, SMTP Password et la From Address ; Envoyer un e-mail de test vous envoie un message.', 'help.guide.channels-instance.step.2': - 'Activez Ntfy et Webhook pour les proposer ; les utilisateurs saisissent ensuite leur propre sujet ou URL sous Paramètres, Notifications.', + 'Activez Web Push, Ntfy et Webhook pour les proposer ; les utilisateurs activent ensuite le push appareil par appareil, ou saisissent leur propre sujet ou URL, sous Paramètres, Notifications.', 'help.guide.channels-instance.step.3': 'Rappels de voyage commande le rappel avant le début d’un voyage ; In-App est toujours actif et seulement expliqué ici.', 'help.guide.channels-instance.result': diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 1ecf1574f..8c40b91a6 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -327,6 +327,20 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Notifications push sur cet appareil', + 'settings.webPush.hint': + 'Affiche les notifications de TREK sur cet appareil, même quand TREK est fermé. Les événements reçus suivent la colonne Push.', + 'settings.webPush.enable': 'Activer sur cet appareil', + 'settings.webPush.disable': 'Désactiver sur cet appareil', + 'settings.webPush.enabled': 'Activé sur cet appareil', + 'settings.webPush.unsupported': 'Ce navigateur ne peut pas recevoir de notifications push.', + 'settings.webPush.insecure': 'Le push nécessite que TREK soit ouvert en HTTPS.', + 'settings.webPush.iosInstall': + "Sur iPhone et iPad, ajoutez d'abord TREK à l'écran d'accueil, puis ouvrez-le depuis celui-ci.", + 'settings.webPush.denied': + 'Les notifications sont bloquées pour TREK dans ce navigateur. Autorisez-les dans les réglages du navigateur, puis réessayez.', + 'settings.webPush.failed': "Le push n'a pas pu être activé sur cet appareil.", 'settings.currency': "Devise d'affichage", 'settings.currencyHint': "Les montants dans Coûts sont convertis dans cette devise uniquement pour l'affichage — les montants d'origine restent inchangés.", diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index ffc7976bb..75b31980a 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Η δοκιμή ntfy απέτυχε', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Επιτρέπει στους χρήστες να λαμβάνουν ειδοποιήσεις στα τηλέφωνα και τους υπολογιστές τους μέσω του προγράμματος περιήγησης, ακόμη και όταν το TREK είναι κλειστό. Απαιτεί HTTPS· σε iPhone και iPad το TREK πρέπει να προστεθεί στην οθόνη Αφετηρίας.', 'admin.notifications.inappPanel.title': 'Εντός εφαρμογής', 'admin.notifications.inappPanel.hint': 'Οι ειδοποιήσεις εντός εφαρμογής είναι πάντα ενεργές και δεν μπορούν να απενεργοποιηθούν καθολικά.', diff --git a/shared/src/i18n/gr/help.ts b/shared/src/i18n/gr/help.ts index c74a98ad6..526c85db8 100644 --- a/shared/src/i18n/gr/help.ts +++ b/shared/src/i18n/gr/help.ts @@ -1094,11 +1094,13 @@ const help: TranslationStrings = { 'Λειτουργία Υψηλής Ποιότητας για εξομάλυνση και προβολή υδρογείου· η Αποθήκευση Χάρτη γράφει την επιλογή.', 'help.ctx.settings-notifications.title': 'Ειδοποιήσεις', 'help.ctx.settings-notifications.summary': - 'Πού σας βρίσκει το TREK έξω από την εφαρμογή: ένα θέμα ntfy, ένα webhook ή ένα κανάλι που παρέχει ένα πρόσθετο. Κάτω από τα κανάλια, μία γραμμή ανά συμβάν αποφασίζει τι πάει πού.', + 'Πού σας βρίσκει το TREK έξω από την εφαρμογή: push ειδοποιήσεις σε αυτή τη συσκευή, ένα θέμα ntfy, ένα webhook ή ένα κανάλι που παρέχει ένα πρόσθετο. Κάτω από τα κανάλια, μία γραμμή ανά συμβάν αποφασίζει τι πάει πού.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: το θέμα, ένας προαιρετικός δικός σας διακομιστής και ένα προαιρετικό access token, με Δοκιμή για να στείλετε ένα αμέσως.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: ένα URL που λαμβάνει κάθε συμβάν ως JSON, με Δοκιμή.', 'help.ctx.settings-notifications.bullet.3': + 'Push ειδοποιήσεις σε αυτή τη συσκευή: η Ενεργοποίηση για αυτή τη συσκευή αφορά μόνο το πρόγραμμα περιήγησης που χρησιμοποιείτε, γι’ αυτό επαναλάβετέ την σε κάθε κινητό ή υπολογιστή. Η Αποστολή δοκιμής φτάνει σε όλες τις συσκευές.', + 'help.ctx.settings-notifications.bullet.4': 'Οι γραμμές προτιμήσεων: ανά συμβάν, ποιο κανάλι είναι ενεργό. Τα κανάλια προσθέτων δείχνουν Ρύθμιση μέχρι να ρυθμιστούν.', 'help.ctx.settings-integrations.title': 'Ενσωματώσεις', 'help.ctx.settings-integrations.summary': @@ -1440,7 +1442,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Τα κανάλια που η εγκατάσταση προσφέρει στους χρήστες της, και εκείνα που φτάνουν σε εσάς ως διαχειριστή. Οι χρήστες επιλέγουν τα δικά τους θέματα και URL στις Ρυθμίσεις· εσείς αποφασίζετε τι υπάρχει και ρυθμίζετε το email.', 'help.ctx.admin-notifications.bullet.1': - 'Εντός εφαρμογής, Email (SMTP), Ntfy και Webhook: ένα πάνελ για καθένα, με διακόπτη που προσφέρει το κανάλι στους χρήστες και τη διαμόρφωση διακομιστή που χρειάζεται.', + 'Εντός εφαρμογής, Email (SMTP), Ntfy, Webhook και Web Push: ένα πάνελ για καθένα, με διακόπτη που προσφέρει το κανάλι στους χρήστες και τη διαμόρφωση διακομιστή που χρειάζεται.', 'help.ctx.admin-notifications.bullet.2': 'Υπενθυμίσεις Ταξιδιού: αν ο διακομιστής στέλνει την υπενθύμιση πριν ξεκινήσει ένα ταξίδι.', 'help.ctx.admin-notifications.bullet.3': @@ -1702,7 +1704,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Στο Email (SMTP), εισαγάγετε SMTP Host, SMTP Port, SMTP User, SMTP Password και From Address· η Αποστολή δοκιμαστικού email στέλνει ένα μήνυμα σε εσάς.', 'help.guide.channels-instance.step.2': - 'Ενεργοποιήστε Ntfy και Webhook για να τα προσφέρετε· οι χρήστες μετά εισάγουν το δικό τους θέμα ή URL στις Ρυθμίσεις, Ειδοποιήσεις.', + 'Ενεργοποιήστε Web Push, Ntfy και Webhook για να τα προσφέρετε· οι χρήστες μετά ενεργοποιούν το push ανά συσκευή ή εισάγουν το δικό τους θέμα ή URL στις Ρυθμίσεις, Ειδοποιήσεις.', 'help.guide.channels-instance.step.3': 'Οι Υπενθυμίσεις Ταξιδιού ρυθμίζουν την υπενθύμιση πριν ξεκινήσει ένα ταξίδι· το Εντός εφαρμογής είναι πάντα ενεργό και απλώς εξηγείται εδώ.', 'help.guide.channels-instance.result': 'Η καρτέλα Ειδοποιήσεις κάθε χρήστη δείχνει τα κανάλια που ενεργοποιήσατε.', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 867c56868..e4b5e55b2 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -123,6 +123,20 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.pluginConfigured': 'Ρυθμίστηκε. Διαχειριστείτε τα διαπιστευτήρια στη σελίδα ρυθμίσεων του πρόσθετου.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push ειδοποιήσεις σε αυτή τη συσκευή', + 'settings.webPush.hint': + 'Εμφανίζει τις ειδοποιήσεις του TREK σε αυτή τη συσκευή ακόμη και όταν το TREK είναι κλειστό. Ποια συμβάντα φτάνουν καθορίζεται από τη στήλη Push.', + 'settings.webPush.enable': 'Ενεργοποίηση για αυτή τη συσκευή', + 'settings.webPush.disable': 'Απενεργοποίηση για αυτή τη συσκευή', + 'settings.webPush.enabled': 'Ενεργό για αυτή τη συσκευή', + 'settings.webPush.unsupported': 'Αυτό το πρόγραμμα περιήγησης δεν μπορεί να λάβει push ειδοποιήσεις.', + 'settings.webPush.insecure': 'Για το push, το TREK πρέπει να ανοίγει μέσω HTTPS.', + 'settings.webPush.iosInstall': + 'Σε iPhone και iPad, προσθέστε πρώτα το TREK στην οθόνη Αφετηρίας και ανοίξτε το από εκεί.', + 'settings.webPush.denied': + 'Οι ειδοποιήσεις για το TREK είναι αποκλεισμένες σε αυτό το πρόγραμμα περιήγησης. Επιτρέψτε τις στις ρυθμίσεις του προγράμματος περιήγησης και δοκιμάστε ξανά.', + 'settings.webPush.failed': 'Δεν ήταν δυνατή η ενεργοποίηση του push για αυτή τη συσκευή.', 'settings.notificationPreferences.noChannels': 'Δεν έχουν διαμορφωθεί κανάλια ειδοποιήσεων. Ζητήστε από έναν διαχειριστή να ρυθμίσει ειδοποιήσεις email ή webhook.', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 93e83e08f..a63b16703 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -728,6 +728,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Jogosultságok', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Lehetővé teszi a felhasználóknak, hogy a böngészőn keresztül értesítéseket kapjanak telefonjukon és számítógépükön, akkor is, ha a TREK be van zárva. HTTPS szükséges; iPhone-on és iPaden a TREK-et hozzá kell adni a Főképernyőhöz.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Az alkalmazáson belüli értesítések mindig aktívak, és globálisan nem kapcsolhatók ki.', diff --git a/shared/src/i18n/hu/help.ts b/shared/src/i18n/hu/help.ts index 8bfa0e713..276778fdd 100644 --- a/shared/src/i18n/hu/help.ts +++ b/shared/src/i18n/hu/help.ts @@ -1066,11 +1066,13 @@ const help: TranslationStrings = { 'Magas minőség mód az élsimításhoz és a gömbvetülethez; a Térkép mentése írja be a választást.', 'help.ctx.settings-notifications.title': 'Értesítések', 'help.ctx.settings-notifications.summary': - 'Hol ér el a TREK az alkalmazáson kívül: egy ntfy-téma, egy webhook vagy egy bővítmény által adott csatorna. A csatornák alatt eseményenként egy sor dönti el, mi hová megy.', + 'Hol ér el a TREK az alkalmazáson kívül: push értesítések ezen az eszközön, egy ntfy-téma, egy webhook vagy egy bővítmény által adott csatorna. A csatornák alatt eseményenként egy sor dönti el, mi hová megy.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: a téma, egy opcionális saját szerver és egy opcionális hozzáférési token, a Teszt gombbal, amely azonnal küld egyet.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: egy URL, amely minden eseményt JSON-ként kap, Teszt gombbal.', 'help.ctx.settings-notifications.bullet.3': + 'Push értesítések ezen az eszközön: a Bekapcsolás ezen az eszközön csak az éppen használt böngészőre vonatkozik, ezért ismételd meg minden telefonon vagy számítógépen. A Teszt küldése mindegyikre eljut.', + 'help.ctx.settings-notifications.bullet.4': 'A preferencia-sorok: eseményenként, melyik csatorna van bekapcsolva. A bővítménycsatornák a Beállítás feliratot mutatják, amíg nincsenek beállítva.', 'help.ctx.settings-integrations.title': 'Integrációk', 'help.ctx.settings-integrations.summary': @@ -1399,7 +1401,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'A csatornák, amelyeket a példány a felhasználóinak kínál, és azok, amelyek téged mint admint érnek el. A felhasználók a Beállítások alatt választják saját témáikat és URL-jeiket; te döntöd el, mi létezik, és te állítod be az e-mailt.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy és Webhook: mindegyiknek egy panel, egy kapcsolóval, amely felkínálja a csatornát a felhasználóknak, és a szerveroldali beállítással, amelyre szüksége van.', + 'In-App, Email (SMTP), Ntfy, Webhook és Web Push: mindegyiknek egy panel, egy kapcsolóval, amely felkínálja a csatornát a felhasználóknak, és a szerveroldali beállítással, amelyre szüksége van.', 'help.ctx.admin-notifications.bullet.2': 'Utazási emlékeztetők: küld-e a szerver emlékeztetőt egy utazás kezdete előtt.', 'help.ctx.admin-notifications.bullet.3': @@ -1654,7 +1656,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Az Email (SMTP) alatt add meg az SMTP Host, SMTP Port, SMTP User, SMTP Password és From Address értékeket; a Teszt e-mail küldése neked küld egy levelet.', 'help.guide.channels-instance.step.2': - 'Kapcsold be az Ntfy és a Webhook kapcsolót a felkínálásukhoz; a felhasználók ezután a Beállítások, Értesítések alatt adják meg saját témájukat vagy URL-jüket.', + 'Kapcsold be a Web Push, az Ntfy és a Webhook kapcsolót a felkínálásukhoz; a felhasználók ezután a Beállítások, Értesítések alatt eszközönként bekapcsolják a pusht, vagy megadják saját témájukat vagy URL-jüket.', 'help.guide.channels-instance.step.3': 'Az Utazási emlékeztetők az utazás kezdete előtti emlékeztetőt kapcsolja; az In-App mindig be van kapcsolva, és itt csak magyarázat van hozzá.', 'help.guide.channels-instance.result': 'Minden felhasználó Értesítések füle a bekapcsolt csatornákat mutatja.', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index defc6bf5d..13264026c 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -323,6 +323,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push értesítések ezen az eszközön', + 'settings.webPush.hint': + 'Megjeleníti a TREK értesítéseit ezen az eszközön, akkor is, ha a TREK be van zárva. Hogy mely események érkeznek meg, azt a Push oszlop dönti el.', + 'settings.webPush.enable': 'Bekapcsolás ezen az eszközön', + 'settings.webPush.disable': 'Kikapcsolás ezen az eszközön', + 'settings.webPush.enabled': 'Bekapcsolva ezen az eszközön', + 'settings.webPush.unsupported': 'Ez a böngésző nem tud push értesítéseket fogadni.', + 'settings.webPush.insecure': 'A pushhoz a TREK-et HTTPS-en keresztül kell megnyitni.', + 'settings.webPush.iosInstall': 'iPhone-on és iPaden először add hozzá a TREK-et a Főképernyőhöz, és onnan nyisd meg.', + 'settings.webPush.denied': + 'A TREK értesítései le vannak tiltva ebben a böngészőben. Engedélyezd őket a böngésző beállításaiban, majd próbáld újra.', + 'settings.webPush.failed': 'A pusht nem sikerült bekapcsolni ezen az eszközön.', 'settings.currency': 'Megjelenítési pénznem', 'settings.currencyHint': 'A Költségek részben az összegek csak a megjelenítéshez lesznek erre a pénznemre átszámítva – az eredeti összegek nem változnak.', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index cbb570354..981a3d9c4 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -13,6 +13,9 @@ const admin: TranslationStrings = { 'admin.notifications.testWebhookFailed': 'Test webhook gagal', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Memungkinkan pengguna menerima notifikasi di ponsel dan komputer mereka melalui browser, bahkan saat TREK ditutup. Memerlukan HTTPS; di iPhone dan iPad, TREK harus ditambahkan ke Layar Utama.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Notifikasi in-app selalu aktif dan tidak bisa dinonaktifkan secara global.', 'admin.notifications.adminWebhookPanel.title': 'Admin Webhook', diff --git a/shared/src/i18n/id/help.ts b/shared/src/i18n/id/help.ts index dc2d2fef8..2bd752ddd 100644 --- a/shared/src/i18n/id/help.ts +++ b/shared/src/i18n/id/help.ts @@ -1063,12 +1063,14 @@ const help: TranslationStrings = { '“Mode kualitas tinggi” untuk antialiasing dan proyeksi bola dunia; “Simpan Peta” menulis pilihannya.', 'help.ctx.settings-notifications.title': 'Notifikasi', 'help.ctx.settings-notifications.summary': - 'Di mana TREK menjangkau Anda di luar aplikasi: topik ntfy, webhook, atau saluran yang disediakan plugin. Di bawah saluran, satu baris per peristiwa menentukan apa yang dikirim ke mana.', + 'Di mana TREK menjangkau Anda di luar aplikasi: notifikasi push di perangkat ini, topik ntfy, webhook, atau saluran yang disediakan plugin. Di bawah saluran, satu baris per peristiwa menentukan apa yang dikirim ke mana.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: topik, server Anda sendiri yang opsional, dan token akses yang opsional, dengan “Uji” untuk langsung mengirim satu pesan.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: satu URL yang menerima setiap peristiwa sebagai JSON, dengan “Uji”.', 'help.ctx.settings-notifications.bullet.3': + 'Notifikasi push di perangkat ini: “Aktifkan untuk perangkat ini” hanya berlaku untuk browser yang sedang Anda gunakan, jadi ulangi di setiap ponsel atau komputer. “Kirim tes” sampai ke semuanya.', + 'help.ctx.settings-notifications.bullet.4': 'Baris preferensi: per peristiwa, saluran mana yang aktif. Saluran plugin menampilkan “Konfigurasi” sampai selesai disiapkan.', 'help.ctx.settings-integrations.title': 'Integrasi', 'help.ctx.settings-integrations.summary': @@ -1398,7 +1400,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Saluran yang ditawarkan instans kepada penggunanya, dan saluran yang menjangkau Anda sebagai admin. Pengguna memilih topik dan URL mereka sendiri di “Pengaturan”; Anda menentukan apa yang tersedia dan mengonfigurasi email.', 'help.ctx.admin-notifications.bullet.1': - '“In-App”, “Email (SMTP)”, “Ntfy”, dan “Webhook”: satu panel masing-masing, dengan sakelar yang menawarkan saluran itu kepada pengguna dan konfigurasi sisi server yang dibutuhkannya.', + '“In-App”, “Email (SMTP)”, “Ntfy”, “Webhook”, dan “Web Push”: satu panel masing-masing, dengan sakelar yang menawarkan saluran itu kepada pengguna dan konfigurasi sisi server yang dibutuhkannya.', 'help.ctx.admin-notifications.bullet.2': '“Pengingat Perjalanan”: apakah server mengirim pengingat sebelum perjalanan dimulai.', 'help.ctx.admin-notifications.bullet.3': @@ -1649,7 +1651,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Di bawah “Email (SMTP)”, masukkan SMTP Host, SMTP Port, SMTP User, SMTP Password, dan From Address; “Kirim email uji” mengirim surat kepada Anda.', 'help.guide.channels-instance.step.2': - 'Aktifkan “Ntfy” dan “Webhook” untuk menawarkannya; pengguna lalu memasukkan topik atau URL mereka sendiri di “Pengaturan”, “Notifikasi”.', + 'Aktifkan “Web Push”, “Ntfy” dan “Webhook” untuk menawarkannya; pengguna lalu mengaktifkan push per perangkat, atau memasukkan topik atau URL mereka sendiri, di “Pengaturan”, “Notifikasi”.', 'help.guide.channels-instance.step.3': '“Pengingat Perjalanan” mengatur pengingat sebelum perjalanan dimulai; “In-App” selalu aktif dan di sini hanya dijelaskan.', 'help.guide.channels-instance.result': 'Tab “Notifikasi” setiap pengguna menampilkan saluran yang Anda aktifkan.', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 77430dc6d..551542a66 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -112,6 +112,20 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': 'Tes gagal.', 'settings.notificationPreferences.pluginConfigured': 'Sudah diatur. Kelola kredensial di halaman pengaturan plugin.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Notifikasi push di perangkat ini', + 'settings.webPush.hint': + 'Menampilkan notifikasi TREK di perangkat ini meskipun TREK sedang ditutup. Peristiwa yang dikirim mengikuti kolom Push.', + 'settings.webPush.enable': 'Aktifkan untuk perangkat ini', + 'settings.webPush.disable': 'Nonaktifkan untuk perangkat ini', + 'settings.webPush.enabled': 'Aktif untuk perangkat ini', + 'settings.webPush.unsupported': 'Browser ini tidak dapat menerima notifikasi push.', + 'settings.webPush.insecure': 'Push memerlukan TREK dibuka melalui HTTPS.', + 'settings.webPush.iosInstall': + 'Di iPhone dan iPad, tambahkan TREK ke Layar Utama terlebih dahulu, lalu buka dari sana.', + 'settings.webPush.denied': + 'Notifikasi untuk TREK diblokir di browser ini. Izinkan di pengaturan browser, lalu coba lagi.', + 'settings.webPush.failed': 'Push tidak dapat diaktifkan untuk perangkat ini.', 'settings.notificationPreferences.noChannels': 'Belum ada saluran notifikasi yang dikonfigurasi. Minta admin untuk mengatur notifikasi email atau webhook.', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index b0521282b..8c570fe2a 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -723,6 +723,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Permessi', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Consente agli utenti di ricevere notifiche su telefoni e computer tramite il browser, anche quando TREK è chiuso. Richiede HTTPS; su iPhone e iPad TREK deve essere aggiunto alla schermata Home.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Le notifiche in-app sono sempre attive e non possono essere disabilitate globalmente.', diff --git a/shared/src/i18n/it/help.ts b/shared/src/i18n/it/help.ts index 28c24c9e9..cf794371a 100644 --- a/shared/src/i18n/it/help.ts +++ b/shared/src/i18n/it/help.ts @@ -1063,11 +1063,13 @@ const help: TranslationStrings = { 'Modalità alta qualità per l’antialiasing e la proiezione a globo; Salva Mappa scrive la scelta.', 'help.ctx.settings-notifications.title': 'Notifiche', 'help.ctx.settings-notifications.summary': - 'Dove TREK ti raggiunge fuori dall’app: un argomento ntfy, un webhook o un canale fornito da un plugin. Sotto i canali, una riga per evento decide cosa va dove.', + 'Dove TREK ti raggiunge fuori dall’app: notifiche push su questo dispositivo, un argomento ntfy, un webhook o un canale fornito da un plugin. Sotto i canali, una riga per evento decide cosa va dove.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: l’argomento, un server tuo opzionale e un token di accesso opzionale, con Testa per inviarne uno subito.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: un URL che riceve ogni evento come JSON, con Testa.', 'help.ctx.settings-notifications.bullet.3': + 'Notifiche push su questo dispositivo: Attiva su questo dispositivo vale solo per il browser che stai usando, quindi ripetilo su ogni telefono o computer. Invia test li raggiunge tutti.', + 'help.ctx.settings-notifications.bullet.4': 'Le righe delle preferenze: per evento, quale canale è attivo. I canali dei plugin mostrano Configura finché non sono impostati.', 'help.ctx.settings-integrations.title': 'Integrazioni', 'help.ctx.settings-integrations.summary': @@ -1391,7 +1393,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'I canali che l’istanza offre ai suoi utenti, e quelli che raggiungono te come admin. Gli utenti scelgono i propri topic e URL sotto Impostazioni; tu decidi cosa esiste e configuri l’email.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy e Webhook: un pannello ciascuno, con un interruttore che offre il canale agli utenti e la configurazione lato server di cui ha bisogno.', + 'In-App, Email (SMTP), Ntfy, Webhook e Web Push: un pannello ciascuno, con un interruttore che offre il canale agli utenti e la configurazione lato server di cui ha bisogno.', 'help.ctx.admin-notifications.bullet.2': 'Promemoria viaggio: se il server invia il promemoria prima che un viaggio inizi.', 'help.ctx.admin-notifications.bullet.3': @@ -1637,7 +1639,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Sotto Email (SMTP), inserisci SMTP Host, SMTP Port, SMTP User, SMTP Password e la From Address; Invia email di prova manda una mail a te.', 'help.guide.channels-instance.step.2': - 'Accendi Ntfy e Webhook per offrirli; gli utenti inseriscono poi il proprio topic o URL sotto Impostazioni, Notifiche.', + 'Accendi Web Push, Ntfy e Webhook per offrirli; gli utenti poi attivano il push per ogni dispositivo, oppure inseriscono il proprio topic o URL, sotto Impostazioni, Notifiche.', 'help.guide.channels-instance.step.3': 'Promemoria viaggio comanda il promemoria prima che un viaggio inizi; In-App è sempre acceso e qui viene solo spiegato.', 'help.guide.channels-instance.result': 'La scheda Notifiche di ogni utente mostra i canali che hai acceso.', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index e537fd1a2..ef92cd4cf 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -322,6 +322,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Notifiche push su questo dispositivo', + 'settings.webPush.hint': + 'Mostra le notifiche di TREK su questo dispositivo anche quando TREK è chiuso. Gli eventi che arrivano seguono la colonna Push.', + 'settings.webPush.enable': 'Attiva su questo dispositivo', + 'settings.webPush.disable': 'Disattiva su questo dispositivo', + 'settings.webPush.enabled': 'Attivo su questo dispositivo', + 'settings.webPush.unsupported': 'Questo browser non può ricevere notifiche push.', + 'settings.webPush.insecure': 'Per il push TREK deve essere aperto tramite HTTPS.', + 'settings.webPush.iosInstall': 'Su iPhone e iPad, aggiungi prima TREK alla schermata Home e aprilo da lì.', + 'settings.webPush.denied': + 'Le notifiche per TREK sono bloccate in questo browser. Consentile nelle impostazioni del browser, poi riprova.', + 'settings.webPush.failed': 'Non è stato possibile attivare il push su questo dispositivo.', 'settings.currency': 'Valuta di visualizzazione', 'settings.currencyHint': 'Gli importi in Costi vengono mostrati convertiti in questa valuta solo per la visualizzazione — gli importi originali restano invariati.', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 02279a77d..a68e1f0b1 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -18,6 +18,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'テストntfyに失敗しました', 'admin.notifications.emailPanel.title': 'メール(SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Webプッシュ', + 'admin.notifications.webPushPanel.hint': + 'TREKを閉じている間も、ユーザーがブラウザ経由でスマートフォンやパソコンに通知を受け取れるようにします。HTTPSが必要です。iPhoneとiPadでは、TREKをホーム画面に追加する必要があります。', 'admin.notifications.inappPanel.title': 'アプリ内', 'admin.notifications.inappPanel.hint': 'アプリ内通知は常に有効で、全体では無効にできません。', 'admin.notifications.adminWebhookPanel.title': '管理者Webhook', diff --git a/shared/src/i18n/ja/help.ts b/shared/src/i18n/ja/help.ts index fbc7df869..eef0cd9ac 100644 --- a/shared/src/i18n/ja/help.ts +++ b/shared/src/i18n/ja/help.ts @@ -1002,11 +1002,13 @@ const help: TranslationStrings = { 'アンチエイリアスと地球儀投影のための「高品質モード」。「地図を保存」で選択が書き込まれます。', 'help.ctx.settings-notifications.title': '通知', 'help.ctx.settings-notifications.summary': - 'アプリの外でTREKがあなたに届く先:ntfyのトピック、Webhook、またはプラグインが提供するチャンネル。チャンネルの下では、イベントごとの行が何をどこへ送るかを決めます。', + 'アプリの外でTREKがあなたに届く先:このデバイスへのプッシュ通知、ntfyのトピック、Webhook、またはプラグインが提供するチャンネル。チャンネルの下では、イベントごとの行が何をどこへ送るかを決めます。', 'help.ctx.settings-notifications.bullet.1': 'ntfy:トピック、任意で自前のサーバー、任意でアクセストークン。「テスト」ですぐに1件送信できます。', 'help.ctx.settings-notifications.bullet.2': 'Webhook:すべてのイベントをJSONで受け取る1つのURL。「テスト」付きです。', 'help.ctx.settings-notifications.bullet.3': + 'このデバイスのプッシュ通知:「このデバイスでオンにする」は今使っているブラウザーだけが対象なので、スマートフォンやパソコンごとに行います。「テスト送信」はそのすべてに届きます。', + 'help.ctx.settings-notifications.bullet.4': '設定の行:イベントごとに、どのチャンネルがオンか。プラグインのチャンネルは設定が済むまで「設定する」と表示されます。', 'help.ctx.settings-integrations.title': '連携', 'help.ctx.settings-integrations.summary': @@ -1327,7 +1329,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'インスタンスがユーザーに提供するチャンネルと、管理者であるあなたに届くチャンネル。ユーザーは「設定」で自分のトピックやURLを選び、あなたは何を用意するかを決めてメールを設定します。', 'help.ctx.admin-notifications.bullet.1': - '「アプリ内」「メール(SMTP)」「Ntfy」「Webhook」:それぞれ1つのパネルに、チャンネルをユーザーに提供するスイッチと、必要なサーバー側の設定があります。', + '「アプリ内」「メール(SMTP)」「Ntfy」「Webhook」「Webプッシュ」:それぞれ1つのパネルに、チャンネルをユーザーに提供するスイッチと、必要なサーバー側の設定があります。', 'help.ctx.admin-notifications.bullet.2': '「旅行リマインダー」:旅行が始まる前にサーバーがリマインダーを送るかどうか。', 'help.ctx.admin-notifications.bullet.3': @@ -1569,7 +1571,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': '「メール(SMTP)」の下で、SMTP Host、SMTP Port、SMTP User、SMTP Password、From Addressを入力します。「テストメール送信」であなた宛てにメールが送られます。', 'help.guide.channels-instance.step.2': - '「Ntfy」と「Webhook」をオンにして提供します。ユーザーは「設定」の「通知」で自分のトピックやURLを入力します。', + '「Webプッシュ」「Ntfy」「Webhook」をオンにして提供します。ユーザーは「設定」の「通知」で、デバイスごとにプッシュをオンにするか、自分のトピックやURLを入力します。', 'help.guide.channels-instance.step.3': '「旅行リマインダー」は旅行が始まる前のリマインダーを切り替えます。「アプリ内」は常にオンで、ここでは説明されるだけです。', 'help.guide.channels-instance.result': diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index bc44b2401..647e83c33 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -113,6 +113,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': 'テストに失敗しました。', 'settings.notificationPreferences.pluginConfigured': '設定済みです。認証情報はプラグインの設定ページで管理します。', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'プッシュ', + 'settings.webPush.title': 'このデバイスのプッシュ通知', + 'settings.webPush.hint': + 'TREKを閉じている間も、このデバイスにTREKの通知を表示します。届くイベントは「プッシュ」列の設定に従います。', + 'settings.webPush.enable': 'このデバイスでオンにする', + 'settings.webPush.disable': 'このデバイスでオフにする', + 'settings.webPush.enabled': 'このデバイスでオン', + 'settings.webPush.unsupported': 'このブラウザはプッシュ通知を受信できません。', + 'settings.webPush.insecure': 'プッシュ通知を使うには、TREKをHTTPSで開く必要があります。', + 'settings.webPush.iosInstall': 'iPhoneとiPadでは、まずTREKをホーム画面に追加し、そこから開いてください。', + 'settings.webPush.denied': + 'このブラウザでTREKの通知がブロックされています。ブラウザの設定で許可してから、もう一度お試しください。', + 'settings.webPush.failed': 'このデバイスでプッシュ通知をオンにできませんでした。', 'settings.notificationPreferences.noChannels': '通知チャネルが未設定です。管理者に設定を依頼してください。', 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 078c56672..2e4cf791c 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': '테스트 ntfy 실패', 'admin.notifications.emailPanel.title': '이메일 (SMTP)', 'admin.notifications.webhookPanel.title': '웹훅', + 'admin.notifications.webPushPanel.title': '웹 푸시', + 'admin.notifications.webPushPanel.hint': + 'TREK이 닫혀 있어도 사용자가 브라우저를 통해 휴대폰과 컴퓨터에서 알림을 받을 수 있습니다. HTTPS가 필요하며, iPhone과 iPad에서는 TREK을 홈 화면에 추가해야 합니다.', 'admin.notifications.inappPanel.title': '앱 내', 'admin.notifications.inappPanel.hint': '앱 내 알림은 항상 활성화되어 있으며 전역으로 비활성화할 수 없습니다.', 'admin.notifications.adminWebhookPanel.title': '관리자 웹훅', diff --git a/shared/src/i18n/ko/help.ts b/shared/src/i18n/ko/help.ts index cb5981240..fab467017 100644 --- a/shared/src/i18n/ko/help.ts +++ b/shared/src/i18n/ko/help.ts @@ -980,11 +980,13 @@ const help: TranslationStrings = { 'help.ctx.settings-map.bullet.3': '안티앨리어싱과 지구본 투영을 위한 “고품질 모드”. “지도 저장”이 선택을 기록합니다.', 'help.ctx.settings-notifications.title': '알림', 'help.ctx.settings-notifications.summary': - '앱 밖에서 TREK이 나에게 닿는 곳: ntfy 토픽, 웹훅, 또는 플러그인이 제공하는 채널. 채널 아래에서는 이벤트마다 한 줄씩 무엇을 어디로 보낼지 정합니다.', + '앱 밖에서 TREK이 나에게 닿는 곳: 이 기기의 푸시 알림, ntfy 토픽, 웹훅, 또는 플러그인이 제공하는 채널. 채널 아래에서는 이벤트마다 한 줄씩 무엇을 어디로 보낼지 정합니다.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: 토픽, 선택 사항인 내 서버, 선택 사항인 액세스 토큰. “테스트”로 바로 하나 보낼 수 있습니다.', 'help.ctx.settings-notifications.bullet.2': '웹훅: 모든 이벤트를 JSON으로 받는 URL 하나. “테스트”가 있습니다.', 'help.ctx.settings-notifications.bullet.3': + '이 기기의 푸시 알림: “이 기기에서 켜기”는 지금 쓰는 브라우저에만 적용되므로 휴대폰이나 컴퓨터마다 따로 켜야 합니다. “테스트 전송”은 켜 둔 모든 기기로 보냅니다.', + 'help.ctx.settings-notifications.bullet.4': '환경설정 행: 이벤트마다 어느 채널이 켜져 있는지. 플러그인 채널은 설정이 끝날 때까지 “설정하기”를 표시합니다.', 'help.ctx.settings-integrations.title': '통합', 'help.ctx.settings-integrations.summary': @@ -1283,7 +1285,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': '인스턴스가 사용자에게 제공하는 채널과, 관리자인 나에게 닿는 채널입니다. 사용자는 “설정”에서 자기 토픽과 URL을 고르고, 나는 무엇을 제공할지 정하고 이메일을 설정합니다.', 'help.ctx.admin-notifications.bullet.1': - '“앱 내”, “이메일 (SMTP)”, “Ntfy”, “웹훅”: 패널 하나씩이며, 채널을 사용자에게 제공하는 스위치와 필요한 서버 측 설정이 있습니다.', + '“앱 내”, “이메일 (SMTP)”, “Ntfy”, “웹훅”, “웹 푸시”: 패널 하나씩이며, 채널을 사용자에게 제공하는 스위치와 필요한 서버 측 설정이 있습니다.', 'help.ctx.admin-notifications.bullet.2': '“여행 리마인더”: 여행 시작 전에 서버가 리마인더를 보낼지 여부.', 'help.ctx.admin-notifications.bullet.3': '“관리자 Ntfy”와 “관리자 웹훅”: 실패한 백업이나 새 릴리스 같은 관리자 이벤트가 가는 곳이며, “테스트”가 있습니다.', @@ -1505,7 +1507,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': '“이메일 (SMTP)” 아래에서 SMTP Host, SMTP Port, SMTP User, SMTP Password, From Address를 입력합니다. “테스트 이메일 전송”은 나에게 메일을 보냅니다.', 'help.guide.channels-instance.step.2': - '“Ntfy”와 “웹훅”을 켜서 제공합니다. 그러면 사용자는 “설정”의 “알림”에서 자기 토픽이나 URL을 입력합니다.', + '“웹 푸시”, “Ntfy”, “웹훅”을 켜서 제공합니다. 그러면 사용자는 “설정”의 “알림”에서 기기별로 푸시를 켜거나 자기 토픽이나 URL을 입력합니다.', 'help.guide.channels-instance.step.3': '“여행 리마인더”는 여행 시작 전 리마인더를 켜고 끕니다. “앱 내”는 언제나 켜져 있고 여기서는 설명만 합니다.', 'help.guide.channels-instance.result': '모든 사용자의 “알림” 탭에 내가 켠 채널이 나타납니다.', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 3c920c3b9..4148513c9 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -114,6 +114,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': '테스트에 실패했습니다.', 'settings.notificationPreferences.pluginConfigured': '설정됨. 자격 증명은 플러그인 설정 페이지에서 관리하세요.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': '푸시', + 'settings.webPush.title': '이 기기의 푸시 알림', + 'settings.webPush.hint': + 'TREK이 닫혀 있어도 이 기기에 TREK 알림을 표시합니다. 어떤 이벤트가 도착할지는 푸시 열을 따릅니다.', + 'settings.webPush.enable': '이 기기에서 켜기', + 'settings.webPush.disable': '이 기기에서 끄기', + 'settings.webPush.enabled': '이 기기에서 켜짐', + 'settings.webPush.unsupported': '이 브라우저는 푸시 알림을 받을 수 없습니다.', + 'settings.webPush.insecure': '푸시를 사용하려면 TREK을 HTTPS로 열어야 합니다.', + 'settings.webPush.iosInstall': 'iPhone과 iPad에서는 먼저 TREK을 홈 화면에 추가한 뒤 그곳에서 여세요.', + 'settings.webPush.denied': + '이 브라우저에서 TREK 알림이 차단되어 있습니다. 브라우저 설정에서 허용한 뒤 다시 시도하세요.', + 'settings.webPush.failed': '이 기기에서 푸시를 켤 수 없습니다.', 'settings.notificationPreferences.noChannels': '알림 채널이 설정되지 않았습니다. 관리자에게 이메일 또는 웹훅 알림 설정을 요청하세요.', 'settings.webhookUrl.label': '웹훅 URL', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 23fdaf565..cbcb144f3 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -719,6 +719,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Rechten', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Laat gebruikers via de browser meldingen ontvangen op hun telefoons en computers, ook als TREK gesloten is. Vereist HTTPS; op iPhone en iPad moet TREK aan het beginscherm worden toegevoegd.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'In-app-meldingen zijn altijd actief en kunnen niet globaal worden uitgeschakeld.', diff --git a/shared/src/i18n/nl/help.ts b/shared/src/i18n/nl/help.ts index 35a2e59ed..326fa50f6 100644 --- a/shared/src/i18n/nl/help.ts +++ b/shared/src/i18n/nl/help.ts @@ -1068,11 +1068,13 @@ const help: TranslationStrings = { 'Hoge kwaliteit modus voor antialiasing en de globeprojectie; Kaart opslaan legt de keuze vast.', 'help.ctx.settings-notifications.title': 'Meldingen', 'help.ctx.settings-notifications.summary': - 'Waar TREK je buiten de app bereikt: een ntfy-onderwerp, een webhook of een kanaal dat een plug-in levert. Onder de kanalen bepaalt één rij per gebeurtenis wat waarheen gaat.', + 'Waar TREK je buiten de app bereikt: pushmeldingen op dit apparaat, een ntfy-onderwerp, een webhook of een kanaal dat een plug-in levert. Onder de kanalen bepaalt één rij per gebeurtenis wat waarheen gaat.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: het onderwerp, optioneel een eigen server en een optioneel toegangstoken, met Testen om er meteen een te sturen.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: één URL die elke gebeurtenis als JSON ontvangt, met Testen.', 'help.ctx.settings-notifications.bullet.3': + 'Pushmeldingen op dit apparaat: Inschakelen op dit apparaat geldt alleen voor de browser die je nu gebruikt, dus herhaal het op elke telefoon of computer. Test versturen bereikt ze allemaal.', + 'help.ctx.settings-notifications.bullet.4': 'De voorkeursrijen: per gebeurtenis welk kanaal aan staat. Plug-inkanalen tonen Instellen tot ze zijn ingesteld.', 'help.ctx.settings-integrations.title': 'Integraties', 'help.ctx.settings-integrations.summary': @@ -1399,7 +1401,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'De kanalen die de instantie zijn gebruikers aanbiedt, en de kanalen die jou als beheerder bereiken. Gebruikers kiezen hun eigen topics en URL’s onder Instellingen; jij bepaalt wat er bestaat en stelt e-mail in.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy en Webhook: elk een paneel, met een schakelaar die het kanaal aan gebruikers aanbiedt en de serverconfiguratie die het nodig heeft.', + 'In-App, Email (SMTP), Ntfy, Webhook en Web Push: elk een paneel, met een schakelaar die het kanaal aan gebruikers aanbiedt en de serverconfiguratie die het nodig heeft.', 'help.ctx.admin-notifications.bullet.2': 'Reisherinneringen: of de server de herinnering stuurt voordat een reis begint.', 'help.ctx.admin-notifications.bullet.3': @@ -1652,7 +1654,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Vul onder Email (SMTP) SMTP Host, SMTP Port, SMTP User, SMTP Password en de From Address in; Test-e-mail verzenden stuurt een mail naar jou.', 'help.guide.channels-instance.step.2': - 'Zet Ntfy en Webhook aan om ze aan te bieden; gebruikers vullen dan hun eigen topic of URL in onder Instellingen, Meldingen.', + 'Zet Web Push, Ntfy en Webhook aan om ze aan te bieden; gebruikers zetten dan push per apparaat aan, of vullen hun eigen topic of URL in, onder Instellingen, Meldingen.', 'help.guide.channels-instance.step.3': 'Reisherinneringen schakelt de herinnering voordat een reis begint; In-App staat altijd aan en wordt hier alleen uitgelegd.', 'help.guide.channels-instance.result': diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index e0149db8e..6d976ff4f 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -322,6 +322,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Pushmeldingen op dit apparaat', + 'settings.webPush.hint': + 'Toont TREK-meldingen op dit apparaat, ook als TREK gesloten is. Welke gebeurtenissen binnenkomen, volgt de kolom Push.', + 'settings.webPush.enable': 'Inschakelen op dit apparaat', + 'settings.webPush.disable': 'Uitschakelen op dit apparaat', + 'settings.webPush.enabled': 'Ingeschakeld op dit apparaat', + 'settings.webPush.unsupported': 'Deze browser kan geen pushmeldingen ontvangen.', + 'settings.webPush.insecure': 'Voor push moet TREK via HTTPS geopend zijn.', + 'settings.webPush.iosInstall': 'Zet TREK op iPhone en iPad eerst op het beginscherm en open het vanaf daar.', + 'settings.webPush.denied': + 'Meldingen voor TREK zijn in deze browser geblokkeerd. Sta ze toe in de browserinstellingen en probeer het opnieuw.', + 'settings.webPush.failed': 'Push kon niet worden ingeschakeld op dit apparaat.', 'settings.currency': 'Weergavevaluta', 'settings.currencyHint': 'Bedragen onder Onkosten worden alleen ter weergave naar deze valuta omgerekend — de oorspronkelijke bedragen blijven ongewijzigd.', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index afcfbea66..181b1bd83 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -727,6 +727,9 @@ const admin: TranslationStrings = { 'admin.notifications.testWebhookFailed': 'Testowy webhook nie powiódł się', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Pozwala użytkownikom otrzymywać powiadomienia na telefonach i komputerach przez przeglądarkę, nawet gdy TREK jest zamknięty. Wymaga HTTPS; na iPhonie i iPadzie TREK musi zostać dodany do ekranu początkowego.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Powiadomienia w aplikacji są zawsze aktywne i nie można ich globalnie wyłączyć.', diff --git a/shared/src/i18n/pl/help.ts b/shared/src/i18n/pl/help.ts index 0eb3e1f59..dc07fb642 100644 --- a/shared/src/i18n/pl/help.ts +++ b/shared/src/i18n/pl/help.ts @@ -1040,11 +1040,13 @@ const help: TranslationStrings = { 'Tryb wysokiej jakości dla antyaliasingu i projekcji globusa; Zapisz mapę zapisuje wybór.', 'help.ctx.settings-notifications.title': 'Powiadomienia', 'help.ctx.settings-notifications.summary': - 'Gdzie TREK dociera do Ciebie poza aplikacją: temat ntfy, webhook albo kanał dostarczany przez wtyczkę. Pod kanałami jeden wiersz na zdarzenie decyduje, co idzie dokąd.', + 'Gdzie TREK dociera do Ciebie poza aplikacją: powiadomienia push na tym urządzeniu, temat ntfy, webhook albo kanał dostarczany przez wtyczkę. Pod kanałami jeden wiersz na zdarzenie decyduje, co idzie dokąd.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: temat, opcjonalnie własny serwer i opcjonalny token dostępu, z Testuj, by od razu wysłać wiadomość.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: jeden URL, który odbiera każde zdarzenie jako JSON, z Testuj.', 'help.ctx.settings-notifications.bullet.3': + 'Powiadomienia push na tym urządzeniu: Włącz na tym urządzeniu dotyczy tylko przeglądarki, której używasz, więc powtórz to na każdym telefonie lub komputerze. Wyślij test trafia do nich wszystkich.', + 'help.ctx.settings-notifications.bullet.4': 'Wiersze preferencji: dla każdego zdarzenia, który kanał jest włączony. Kanały wtyczek pokazują Skonfiguruj, dopóki nie są ustawione.', 'help.ctx.settings-integrations.title': 'Integracje', 'help.ctx.settings-integrations.summary': @@ -1369,7 +1371,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Kanały, które instancja oferuje swoim użytkownikom, i te, które docierają do Ciebie jako administratora. Użytkownicy wybierają własne tematy i URL-e w Ustawienia; Ty decydujesz, co istnieje, i konfigurujesz e-mail.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy i Webhook: po jednym panelu, z przełącznikiem, który oferuje kanał użytkownikom, i konfiguracją po stronie serwera, której potrzebuje.', + 'In-App, Email (SMTP), Ntfy, Webhook i Web Push: po jednym panelu, z przełącznikiem, który oferuje kanał użytkownikom, i konfiguracją po stronie serwera, której potrzebuje.', 'help.ctx.admin-notifications.bullet.2': 'Przypomnienia o podróżach: czy serwer wysyła przypomnienie przed rozpoczęciem podróży.', 'help.ctx.admin-notifications.bullet.3': @@ -1614,7 +1616,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'W Email (SMTP) wpisz SMTP Host, SMTP Port, SMTP User, SMTP Password i From Address; Wyślij testowego e-maila wysyła wiadomość do Ciebie.', 'help.guide.channels-instance.step.2': - 'Włącz Ntfy i Webhook, by je zaoferować; użytkownicy wpisują wtedy własny temat lub URL w Ustawienia, Powiadomienia.', + 'Włącz Web Push, Ntfy i Webhook, by je zaoferować; użytkownicy włączają wtedy push na każdym urządzeniu albo wpisują własny temat lub URL w Ustawienia, Powiadomienia.', 'help.guide.channels-instance.step.3': 'Przypomnienia o podróżach przełącza przypomnienie przed rozpoczęciem podróży; In-App jest zawsze włączony i tylko tu opisany.', 'help.guide.channels-instance.result': 'Zakładka Powiadomienia każdego użytkownika pokazuje kanały, które włączyłeś.', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 55e8e09a4..71ed56522 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -316,6 +316,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Powiadomienia push na tym urządzeniu', + 'settings.webPush.hint': + 'Pokazuje powiadomienia TREK na tym urządzeniu, nawet gdy TREK jest zamknięty. O tym, które zdarzenia docierają, decyduje kolumna Push.', + 'settings.webPush.enable': 'Włącz na tym urządzeniu', + 'settings.webPush.disable': 'Wyłącz na tym urządzeniu', + 'settings.webPush.enabled': 'Włączone na tym urządzeniu', + 'settings.webPush.unsupported': 'Ta przeglądarka nie może odbierać powiadomień push.', + 'settings.webPush.insecure': 'Push wymaga otwarcia TREK przez HTTPS.', + 'settings.webPush.iosInstall': 'Na iPhonie i iPadzie najpierw dodaj TREK do ekranu początkowego i otwórz go stamtąd.', + 'settings.webPush.denied': + 'Powiadomienia dla TREK są zablokowane w tej przeglądarce. Zezwól na nie w ustawieniach przeglądarki i spróbuj ponownie.', + 'settings.webPush.failed': 'Nie udało się włączyć push na tym urządzeniu.', 'settings.notificationsActive': 'Aktywny kanał', 'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.', 'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 82ff73a02..325ee0191 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -722,6 +722,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Разрешения', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Позволяет пользователям получать уведомления на телефонах и компьютерах через браузер, даже когда TREK закрыт. Требуется HTTPS; на iPhone и iPad TREK нужно добавить на экран «Домой».', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Уведомления в приложении всегда активны и не могут быть отключены глобально.', 'admin.notifications.adminWebhookPanel.title': 'Вебхук администратора', diff --git a/shared/src/i18n/ru/help.ts b/shared/src/i18n/ru/help.ts index 168089b23..4379da4cb 100644 --- a/shared/src/i18n/ru/help.ts +++ b/shared/src/i18n/ru/help.ts @@ -1040,12 +1040,14 @@ const help: TranslationStrings = { 'Режим высокого качества для сглаживания и проекции глобуса; Сохранить карту записывает выбор.', 'help.ctx.settings-notifications.title': 'Уведомления', 'help.ctx.settings-notifications.summary': - 'Где TREK находит вас вне приложения: тема ntfy, вебхук или канал, который даёт плагин. Под каналами по одной строке на событие решает, что куда уходит.', + 'Где TREK находит вас вне приложения: push-уведомления на этом устройстве, тема ntfy, вебхук или канал, который даёт плагин. Под каналами по одной строке на событие решает, что куда уходит.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: тема, необязательный собственный сервер и необязательный токен доступа, с кнопкой Тест, чтобы сразу отправить сообщение.', 'help.ctx.settings-notifications.bullet.2': 'Вебхук: один URL, который получает каждое событие как JSON, с кнопкой Тест.', 'help.ctx.settings-notifications.bullet.3': + 'Push-уведомления на этом устройстве: кнопка Включить на этом устройстве действует только для браузера, которым вы пользуетесь, поэтому повторите это на каждом телефоне или компьютере. Отправить тест шлёт сообщение на все эти устройства.', + 'help.ctx.settings-notifications.bullet.4': 'Строки предпочтений: для каждого события, какой канал включён. Каналы плагинов показывают Настроить, пока не настроены.', 'help.ctx.settings-integrations.title': 'Интеграции', 'help.ctx.settings-integrations.summary': @@ -1375,7 +1377,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Каналы, которые инстанс предлагает своим пользователям, и те, что доходят до вас как администратора. Пользователи выбирают свои темы и URL в Настройки; вы решаете, что существует, и настраиваете эл. почту.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy и Webhook: по одной панели на каждый, с переключателем, который предлагает канал пользователям, и нужной ему настройкой на стороне сервера.', + 'In-App, Email (SMTP), Ntfy, Webhook и Web Push: по одной панели на каждый, с переключателем, который предлагает канал пользователям, и нужной ему настройкой на стороне сервера.', 'help.ctx.admin-notifications.bullet.2': 'Напоминания о поездках: отправляет ли сервер напоминание перед началом поездки.', 'help.ctx.admin-notifications.bullet.3': @@ -1628,7 +1630,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'В Email (SMTP) введите SMTP Host, SMTP Port, SMTP User, SMTP Password и From Address; Отправить тестовое письмо отправляет письмо вам.', 'help.guide.channels-instance.step.2': - 'Включите Ntfy и Webhook, чтобы предложить их; пользователи затем вводят свою тему или URL в Настройки, Уведомления.', + 'Включите Web Push, Ntfy и Webhook, чтобы предложить их; пользователи затем включают push на каждом устройстве или вводят свою тему или URL в Настройки, Уведомления.', 'help.guide.channels-instance.step.3': 'Напоминания о поездках переключают напоминание перед началом поездки; In-App включён всегда и здесь только описан.', 'help.guide.channels-instance.result': diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index a9e87b587..0bea98457 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -321,6 +321,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push-уведомления на этом устройстве', + 'settings.webPush.hint': + 'Показывает уведомления TREK на этом устройстве, даже когда TREK закрыт. Какие события приходят, определяет столбец Push.', + 'settings.webPush.enable': 'Включить на этом устройстве', + 'settings.webPush.disable': 'Выключить на этом устройстве', + 'settings.webPush.enabled': 'Включено на этом устройстве', + 'settings.webPush.unsupported': 'Этот браузер не может получать push-уведомления.', + 'settings.webPush.insecure': 'Для push TREK должен быть открыт по HTTPS.', + 'settings.webPush.iosInstall': 'На iPhone и iPad сначала добавьте TREK на экран «Домой» и откройте его оттуда.', + 'settings.webPush.denied': + 'Уведомления для TREK заблокированы в этом браузере. Разрешите их в настройках браузера и попробуйте снова.', + 'settings.webPush.failed': 'Не удалось включить push на этом устройстве.', 'settings.currency': 'Валюта отображения', 'settings.currencyHint': 'Суммы в разделе «Расходы» отображаются в этой валюте только для просмотра — исходные суммы не изменяются.', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index 7f8a74542..b7ce1448f 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Odoslanie testovacieho ntfy zlyhalo', 'admin.notifications.emailPanel.title': 'E-mail (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Umožňuje používateľom prijímať oznámenia na telefónoch a počítačoch cez prehliadač, aj keď je TREK zatvorený. Vyžaduje HTTPS; na iPhone a iPade musí byť TREK pridaný na plochu.', 'admin.notifications.inappPanel.title': 'V aplikácii', 'admin.notifications.inappPanel.hint': 'Oznámenia v aplikácii sú vždy aktívne a nedajú sa globálne vypnúť.', 'admin.notifications.adminWebhookPanel.title': 'Admin webhook', diff --git a/shared/src/i18n/sk/help.ts b/shared/src/i18n/sk/help.ts index f02107f5e..aa114a57f 100644 --- a/shared/src/i18n/sk/help.ts +++ b/shared/src/i18n/sk/help.ts @@ -1059,12 +1059,14 @@ const help: TranslationStrings = { 'Režim vysokej kvality pre vyhladzovanie hrán a projekciu glóbusu; Uložiť nastavenia mapy výber uloží.', 'help.ctx.settings-notifications.title': 'Oznámenia', 'help.ctx.settings-notifications.summary': - 'Kde vás TREK zastihne mimo aplikácie: téma ntfy, webhook alebo kanál, ktorý poskytuje plugin. Pod kanálmi jeden riadok pre každú udalosť určuje, čo kam ide.', + 'Kde vás TREK zastihne mimo aplikácie: push oznámenia v tomto zariadení, téma ntfy, webhook alebo kanál, ktorý poskytuje plugin. Pod kanálmi jeden riadok pre každú udalosť určuje, čo kam ide.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: téma, voliteľný vlastný server a voliteľný prístupový token, s tlačidlom Testovať na okamžité odoslanie skúšobnej správy.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: jedna URL adresa, ktorá prijíma každú udalosť ako JSON, s tlačidlom Testovať.', 'help.ctx.settings-notifications.bullet.3': + 'Push oznámenia v tomto zariadení: Zapnúť pre toto zariadenie platí len pre prehliadač, ktorý práve používate, preto to zopakujte na každom telefóne alebo počítači. Odoslať test príde na všetky.', + 'help.ctx.settings-notifications.bullet.4': 'Riadky predvolieb: pre každú udalosť určujú, ktorý kanál je zapnutý. Kanály pluginov zobrazujú Nastaviť, kým nie sú nastavené.', 'help.ctx.settings-integrations.title': 'Integrácie', 'help.ctx.settings-integrations.summary': @@ -1387,7 +1389,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Kanály, ktoré inštancia ponúka svojim používateľom, a tie, ktoré zastihnú vás ako administrátora. Používatelia si vlastné témy a URL vyberajú v Nastaveniach; vy rozhodujete, čo je k dispozícii, a nastavujete e-mail.', 'help.ctx.admin-notifications.bullet.1': - 'V aplikácii, E-mail (SMTP), Ntfy a Webhook: každý s vlastným panelom, s prepínačom, ktorý kanál ponúkne používateľom, a s konfiguráciou na strane servera, ktorú potrebuje.', + 'V aplikácii, E-mail (SMTP), Ntfy, Webhook a Web Push: každý s vlastným panelom, s prepínačom, ktorý kanál ponúkne používateľom, a s konfiguráciou na strane servera, ktorú potrebuje.', 'help.ctx.admin-notifications.bullet.2': 'Pripomienky ciest: či server posiela pripomienku pred začiatkom cesty.', 'help.ctx.admin-notifications.bullet.3': 'Admin Ntfy a Admin webhook: kam idú udalosti pre administrátora, napríklad neúspešná záloha alebo nová verzia, s tlačidlom Testovať.', @@ -1646,7 +1648,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'V časti E-mail (SMTP) zadajte SMTP hostiteľa, SMTP port, SMTP používateľa, SMTP heslo a adresu odosielateľa; Odoslať testovací e-mail vám pošle správu.', 'help.guide.channels-instance.step.2': - 'Zapnite Ntfy a Webhook, ak ich chcete ponúknuť; používatelia potom zadajú vlastnú tému alebo URL v časti Nastavenia, Oznámenia.', + 'Zapnite Web Push, Ntfy a Webhook, ak ich chcete ponúknuť; používatelia potom v časti Nastavenia, Oznámenia zapnú push pre každé zariadenie alebo zadajú vlastnú tému či URL.', 'help.guide.channels-instance.step.3': 'Pripomienky ciest zapínajú pripomienku pred začiatkom cesty; V aplikácii je vždy zapnuté a je tu iba vysvetlené.', 'help.guide.channels-instance.result': 'Karta Oznámenia každého používateľa zobrazuje kanály, ktoré ste zapli.', diff --git a/shared/src/i18n/sk/settings.ts b/shared/src/i18n/sk/settings.ts index 1da11824f..d5c572f3a 100644 --- a/shared/src/i18n/sk/settings.ts +++ b/shared/src/i18n/sk/settings.ts @@ -128,6 +128,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.pluginConfigured': 'Nastavené. Prihlasovacie údaje spravujte na stránke nastavení doplnku.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push oznámenia v tomto zariadení', + 'settings.webPush.hint': + 'Zobrazuje oznámenia TREK v tomto zariadení, aj keď je TREK zatvorený. Ktoré udalosti prídu, určuje stĺpec Push.', + 'settings.webPush.enable': 'Zapnúť pre toto zariadenie', + 'settings.webPush.disable': 'Vypnúť pre toto zariadenie', + 'settings.webPush.enabled': 'Zapnuté pre toto zariadenie', + 'settings.webPush.unsupported': 'Tento prehliadač nedokáže prijímať push oznámenia.', + 'settings.webPush.insecure': 'Push vyžaduje, aby bol TREK otvorený cez HTTPS.', + 'settings.webPush.iosInstall': 'Na iPhone a iPade najprv pridajte TREK na plochu a otvorte ho odtiaľ.', + 'settings.webPush.denied': + 'Oznámenia pre TREK sú v tomto prehliadači zablokované. Povoľte ich v nastaveniach prehliadača a skúste to znova.', + 'settings.webPush.failed': 'Push sa pre toto zariadenie nepodarilo zapnúť.', 'settings.notificationPreferences.noChannels': 'Nie sú nakonfigurované žiadne kanály oznámení. Požiadajte správcu o nastavenie e-mailových alebo webhookových oznámení.', 'settings.webhookUrl.label': 'URL webhooku', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 65ea52c66..412a0bdbb 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Test ntfy misslyckades', 'admin.notifications.emailPanel.title': 'E-post (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Låter användare ta emot meddelanden på sina telefoner och datorer via webbläsaren, även när TREK är stängt. Kräver HTTPS; på iPhone och iPad måste TREK läggas till på hemskärmen.', 'admin.notifications.inappPanel.title': 'I-App', 'admin.notifications.inappPanel.hint': 'Meddelanden i appen är alltid aktiva och kan inte inaktiveras generellt.', 'admin.notifications.adminWebhookPanel.title': 'Webhook för administratörer', diff --git a/shared/src/i18n/sv/help.ts b/shared/src/i18n/sv/help.ts index c5614401b..6d0a7e629 100644 --- a/shared/src/i18n/sv/help.ts +++ b/shared/src/i18n/sv/help.ts @@ -1028,11 +1028,13 @@ const help: TranslationStrings = { 'Högkvalitetsläge för kantutjämning och globprojektionen; Spara karta skriver valet.', 'help.ctx.settings-notifications.title': 'Meddelanden', 'help.ctx.settings-notifications.summary': - 'Var TREK når dig utanför appen: ett ntfy-ämne, en webhook eller en kanal som ett tillägg tillhandahåller. Under kanalerna avgör en rad per händelse vad som går vart.', + 'Var TREK når dig utanför appen: push-meddelanden på den här enheten, ett ntfy-ämne, en webhook eller en kanal som ett tillägg tillhandahåller. Under kanalerna avgör en rad per händelse vad som går vart.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: ämnet, en valfri egen server och en valfri åtkomsttoken, med Test för att skicka ett på en gång.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: en URL som tar emot varje händelse som JSON, med Test.', 'help.ctx.settings-notifications.bullet.3': + 'Push-meddelanden på den här enheten: Slå på för den här enheten gäller bara webbläsaren du använder, så upprepa det på varje telefon eller dator. Skicka test når dem alla.', + 'help.ctx.settings-notifications.bullet.4': 'Preferensraderna: per händelse, vilken kanal som är på. Tilläggskanaler visar Konfigurera tills de är uppsatta.', 'help.ctx.settings-integrations.title': 'Integrationer', 'help.ctx.settings-integrations.summary': @@ -1351,7 +1353,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Kanalerna instansen erbjuder sina användare, och de som når dig som administratör. Användarna väljer sina egna ämnen och URL:er under Inställningar; du bestämmer vad som finns och konfigurerar e-post.', 'help.ctx.admin-notifications.bullet.1': - 'I-App, E-post (SMTP), Ntfy och Webhook: en panel var, med ett reglage som erbjuder kanalen till användarna och den serverkonfiguration den behöver.', + 'I-App, E-post (SMTP), Ntfy, Webhook och Web Push: en panel var, med ett reglage som erbjuder kanalen till användarna och den serverkonfiguration den behöver.', 'help.ctx.admin-notifications.bullet.2': 'Påminnelser inför resan: om servern skickar påminnelsen innan en resa börjar.', 'help.ctx.admin-notifications.bullet.3': @@ -1591,7 +1593,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Under E-post (SMTP), ange SMTP Host, SMTP Port, SMTP User, SMTP Password och From Address; Skicka ett testmejl skickar ett mejl till dig.', 'help.guide.channels-instance.step.2': - 'Slå på Ntfy och Webhook för att erbjuda dem; användarna anger sedan sitt eget ämne eller sin URL under Inställningar, Meddelanden.', + 'Slå på Web Push, Ntfy och Webhook för att erbjuda dem; användarna slår sedan på push per enhet, eller anger sitt eget ämne eller sin URL, under Inställningar, Meddelanden.', 'help.guide.channels-instance.step.3': 'Påminnelser inför resan växlar påminnelsen innan en resa börjar; I-App är alltid på och förklaras bara här.', 'help.guide.channels-instance.result': 'Fliken Meddelanden hos varje användare visar kanalerna du slog på.', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index d14873469..8d2a15dcb 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -120,6 +120,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.pluginConfigured': 'Konfigurerat. Hantera dina uppgifter på pluginets inställningssida.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push-meddelanden på den här enheten', + 'settings.webPush.hint': + 'Visar TREK-meddelanden på den här enheten även när TREK är stängt. Vilka händelser som kommer fram styrs av kolumnen Push.', + 'settings.webPush.enable': 'Slå på för den här enheten', + 'settings.webPush.disable': 'Slå av för den här enheten', + 'settings.webPush.enabled': 'På för den här enheten', + 'settings.webPush.unsupported': 'Den här webbläsaren kan inte ta emot push-meddelanden.', + 'settings.webPush.insecure': 'Push kräver att TREK öppnas via HTTPS.', + 'settings.webPush.iosInstall': 'På iPhone och iPad lägger du först till TREK på hemskärmen och öppnar det därifrån.', + 'settings.webPush.denied': + 'Meddelanden från TREK är blockerade i den här webbläsaren. Tillåt dem i webbläsarens inställningar och försök sedan igen.', + 'settings.webPush.failed': 'Push kunde inte slås på för den här enheten.', 'settings.notificationPreferences.noChannels': 'Inga meddelandekanaler har konfigurerats. Be en administratör att ställa in e-post- eller webhook-meddelanden.', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index 74ae681be..aa8de921d 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': "การทดสอบ ntfy ล้มเหลว", 'admin.notifications.emailPanel.title': "อีเมล์ (SMTP)", 'admin.notifications.webhookPanel.title': "เว็บฮุค", + 'admin.notifications.webPushPanel.title': 'เว็บพุช', + 'admin.notifications.webPushPanel.hint': + 'ให้ผู้ใช้รับการแจ้งเตือนบนโทรศัพท์และคอมพิวเตอร์ผ่านเบราว์เซอร์ได้ แม้ในขณะที่ปิด TREK อยู่ ต้องใช้ HTTPS และบน iPhone และ iPad ต้องเพิ่ม TREK ไปยังหน้าจอโฮม', 'admin.notifications.inappPanel.title': "ในแอป", 'admin.notifications.inappPanel.hint': "การแจ้งเตือนในแอปจะทำงานอยู่เสมอและไม่สามารถปิดใช้งานได้ทั่วโลก", 'admin.notifications.adminWebhookPanel.title': "แอดมินเว็บฮุค", diff --git a/shared/src/i18n/th/help.ts b/shared/src/i18n/th/help.ts index 7dc32ba98..5d49f727b 100644 --- a/shared/src/i18n/th/help.ts +++ b/shared/src/i18n/th/help.ts @@ -894,11 +894,13 @@ const help: TranslationStrings = { 'help.ctx.settings-map.bullet.3': 'โหมดคุณภาพสูง สำหรับการลดรอยหยักและการฉายภาพแบบลูกโลก บันทึกแผนที่ จะบันทึกตัวเลือก', 'help.ctx.settings-notifications.title': 'การแจ้งเตือน', 'help.ctx.settings-notifications.summary': - 'ช่องทางที่ TREK ติดต่อคุณนอกแอป: หัวข้อ ntfy, เว็บฮุค หรือช่องทางที่ปลั๊กอินให้มา ใต้ช่องทางต่างๆ จะมีหนึ่งแถวต่อหนึ่งเหตุการณ์เพื่อกำหนดว่าจะส่งอะไรไปที่ไหน', + 'ช่องทางที่ TREK ติดต่อคุณนอกแอป: การแจ้งเตือนแบบพุชบนอุปกรณ์นี้, หัวข้อ ntfy, เว็บฮุค หรือช่องทางที่ปลั๊กอินให้มา ใต้ช่องทางต่างๆ จะมีหนึ่งแถวต่อหนึ่งเหตุการณ์เพื่อกำหนดว่าจะส่งอะไรไปที่ไหน', 'help.ctx.settings-notifications.bullet.1': 'ntfy: หัวข้อ เซิร์ฟเวอร์ของคุณเอง (ไม่บังคับ) และโทเค็นการเข้าถึง (ไม่บังคับ) พร้อม ทดสอบ เพื่อส่งข้อความทันที', 'help.ctx.settings-notifications.bullet.2': 'เว็บฮุค: URL เดียวที่รับทุกเหตุการณ์เป็น JSON พร้อม ทดสอบ', 'help.ctx.settings-notifications.bullet.3': + 'การแจ้งเตือนแบบพุชบนอุปกรณ์นี้: เปิดสำหรับอุปกรณ์นี้ มีผลเฉพาะกับเบราว์เซอร์ที่คุณกำลังใช้ จึงต้องทำซ้ำบนโทรศัพท์หรือคอมพิวเตอร์ทุกเครื่อง ส่วน ส่งแบบทดสอบ จะส่งไปถึงทุกเครื่อง', + 'help.ctx.settings-notifications.bullet.4': 'แถวการตั้งค่า: กำหนดว่าแต่ละเหตุการณ์เปิดช่องทางใดไว้ ช่องทางของปลั๊กอินจะแสดง กำหนดค่า จนกว่าจะตั้งค่าเสร็จ', 'help.ctx.settings-integrations.title': 'การเชื่อมต่อ', 'help.ctx.settings-integrations.summary': @@ -1167,7 +1169,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'ช่องทางที่อินสแตนซ์เสนอให้ผู้ใช้ และช่องทางที่ติดต่อคุณในฐานะผู้ดูแลระบบ ผู้ใช้เลือกหัวข้อและ URL ของตนเองใน การตั้งค่า ส่วนคุณกำหนดว่ามีช่องทางใดบ้างและกำหนดค่าอีเมล', 'help.ctx.admin-notifications.bullet.1': - 'ในแอป, อีเมล์ (SMTP), เอ็นทีฟี่ และ เว็บฮุค: ช่องทางละหนึ่งแผง พร้อมสวิตช์ที่เปิดช่องทางให้ผู้ใช้ และการกำหนดค่าฝั่งเซิร์ฟเวอร์ที่ต้องใช้', + 'ในแอป, อีเมล์ (SMTP), เอ็นทีฟี่, เว็บฮุค และ เว็บพุช: ช่องทางละหนึ่งแผง พร้อมสวิตช์ที่เปิดช่องทางให้ผู้ใช้ และการกำหนดค่าฝั่งเซิร์ฟเวอร์ที่ต้องใช้', 'help.ctx.admin-notifications.bullet.2': 'การแจ้งเตือนการเดินทาง: เซิร์ฟเวอร์จะส่งการแจ้งเตือนก่อนทริปเริ่มหรือไม่', 'help.ctx.admin-notifications.bullet.3': 'แอดมิน Ntfy และ แอดมินเว็บฮุค: ปลายทางของเหตุการณ์ผู้ดูแลระบบ เช่น การสำรองข้อมูลล้มเหลวหรือเวอร์ชันใหม่ พร้อม ทดสอบ', @@ -1380,7 +1382,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'ใน อีเมล์ (SMTP) กรอกโฮสต์ SMTP พอร์ต SMTP ผู้ใช้ SMTP รหัสผ่าน SMTP และที่อยู่ผู้ส่ง ส่วน ส่งอีเมลทดสอบ จะส่งอีเมลถึงคุณ', 'help.guide.channels-instance.step.2': - 'เปิด เอ็นทีฟี่ และ เว็บฮุค เพื่อให้ผู้ใช้เลือกได้ จากนั้นผู้ใช้จะกรอก topic หรือ URL ของตนเองใน การตั้งค่า ส่วน การแจ้งเตือน', + 'เปิด เว็บพุช, เอ็นทีฟี่ และ เว็บฮุค เพื่อให้ผู้ใช้เลือกได้ จากนั้นผู้ใช้จะเปิดพุชทีละอุปกรณ์ หรือกรอก topic หรือ URL ของตนเองใน การตั้งค่า ส่วน การแจ้งเตือน', 'help.guide.channels-instance.step.3': 'การแจ้งเตือนการเดินทาง ใช้เปิดหรือปิดการเตือนก่อนเริ่มทริป ส่วน ในแอป เปิดอยู่เสมอและแสดงไว้ที่นี่เพื่ออธิบายเท่านั้น', 'help.guide.channels-instance.result': 'แท็บ การแจ้งเตือน ของผู้ใช้ทุกคนจะแสดงช่องทางที่คุณเปิดไว้', diff --git a/shared/src/i18n/th/settings.ts b/shared/src/i18n/th/settings.ts index f7ad38029..48396d6c6 100644 --- a/shared/src/i18n/th/settings.ts +++ b/shared/src/i18n/th/settings.ts @@ -116,6 +116,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': "การทดสอบล้มเหลว", 'settings.notificationPreferences.pluginConfigured': "กำหนดค่าแล้ว จัดการข้อมูลประจำตัวในหน้าการตั้งค่าปลั๊กอิน", 'settings.notificationPreferences.ntfy': "เอ็นทีฟี่", + 'settings.notificationPreferences.push': 'พุช', + 'settings.webPush.title': 'การแจ้งเตือนแบบพุชบนอุปกรณ์นี้', + 'settings.webPush.hint': + 'แสดงการแจ้งเตือนของ TREK บนอุปกรณ์นี้แม้ในขณะที่ปิด TREK อยู่ เหตุการณ์ที่จะได้รับเป็นไปตามคอลัมน์ พุช', + 'settings.webPush.enable': 'เปิดสำหรับอุปกรณ์นี้', + 'settings.webPush.disable': 'ปิดสำหรับอุปกรณ์นี้', + 'settings.webPush.enabled': 'เปิดอยู่สำหรับอุปกรณ์นี้', + 'settings.webPush.unsupported': 'เบราว์เซอร์นี้ไม่สามารถรับการแจ้งเตือนแบบพุชได้', + 'settings.webPush.insecure': 'การแจ้งเตือนแบบพุชต้องเปิด TREK ผ่าน HTTPS', + 'settings.webPush.iosInstall': 'บน iPhone และ iPad ให้เพิ่ม TREK ไปยังหน้าจอโฮมก่อน แล้วเปิดจากที่นั่น', + 'settings.webPush.denied': + 'การแจ้งเตือนของ TREK ถูกบล็อกในเบราว์เซอร์นี้ อนุญาตในการตั้งค่าเบราว์เซอร์ แล้วลองอีกครั้ง', + 'settings.webPush.failed': 'ไม่สามารถเปิดการแจ้งเตือนแบบพุชสำหรับอุปกรณ์นี้ได้', 'settings.notificationPreferences.noChannels': "ไม่มีการกำหนดค่าช่องทางการแจ้งเตือน ขอให้ผู้ดูแลระบบตั้งค่าการแจ้งเตือนทางอีเมลหรือเว็บฮุค", 'settings.webhookUrl.label': "URL ของเว็บฮุค", diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index f6811c1c6..6555fccfd 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Test ntfy başarısız', 'admin.notifications.emailPanel.title': 'E-posta (SMTP)', 'admin.notifications.webhookPanel.title': 'Web kancası', + 'admin.notifications.webPushPanel.title': 'Web anlık bildirimleri', + 'admin.notifications.webPushPanel.hint': + "Kullanıcıların, TREK kapalıyken bile tarayıcı üzerinden telefon ve bilgisayarlarında bildirim almasını sağlar. HTTPS gerekir; iPhone ve iPad'de TREK'in Ana Ekran'a eklenmesi gerekir.", 'admin.notifications.inappPanel.title': 'Uygulama içi', 'admin.notifications.inappPanel.hint': 'Uygulama içi bildirimler her zaman açıktır ve genel olarak kapatılamaz.', 'admin.notifications.adminWebhookPanel.title': 'Yönetici Webhook', diff --git a/shared/src/i18n/tr/help.ts b/shared/src/i18n/tr/help.ts index 00fd14ab3..bd074b53f 100644 --- a/shared/src/i18n/tr/help.ts +++ b/shared/src/i18n/tr/help.ts @@ -1047,11 +1047,13 @@ const help: TranslationStrings = { 'Kenar yumuşatma ve küre projeksiyonu için Yüksek Kalite Modu; Haritayı Kaydet seçimi yazar.', 'help.ctx.settings-notifications.title': 'Bildirimler', 'help.ctx.settings-notifications.summary': - 'TREK’in size uygulama dışında nereden ulaştığı: bir ntfy konusu, bir web kancası ya da bir eklentinin sağladığı kanal. Kanalların altında, olay başına bir satır neyin nereye gideceğine karar verir.', + 'TREK’in size uygulama dışında nereden ulaştığı: bu cihazdaki anlık bildirimler, bir ntfy konusu, bir web kancası ya da bir eklentinin sağladığı kanal. Kanalların altında, olay başına bir satır neyin nereye gideceğine karar verir.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: konu, isteğe bağlı kendi sunucunuz ve isteğe bağlı bir erişim anahtarı, hemen bir tane göndermek için Dene ile.', 'help.ctx.settings-notifications.bullet.2': 'Web kancası: her olayı JSON olarak alan tek bir URL, Dene ile.', 'help.ctx.settings-notifications.bullet.3': + 'Bu cihazda anlık bildirimler: Bu cihaz için aç yalnızca kullandığınız tarayıcıyı kapsar, bu yüzden bunu her telefonda veya bilgisayarda tekrarlayın. Test gönder hepsine ulaşır.', + 'help.ctx.settings-notifications.bullet.4': 'Tercih satırları: olay başına hangi kanalın açık olduğu. Eklenti kanalları kurulana kadar Yapılandır gösterir.', 'help.ctx.settings-integrations.title': 'Entegrasyonlar', 'help.ctx.settings-integrations.summary': @@ -1372,7 +1374,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Örneğin kullanıcılarına sunduğu kanallar ve yönetici olarak size ulaşanlar. Kullanıcılar kendi konularını ve URL’lerini Ayarlar altında seçer; neyin var olduğuna siz karar verir ve e-postayı yapılandırırsınız.', 'help.ctx.admin-notifications.bullet.1': - 'Uygulama içi, E-posta (SMTP), Ntfy ve Web kancası: her biri için bir panel, kanalı kullanıcılara sunan bir anahtar ve ihtiyaç duyduğu sunucu tarafı yapılandırmasıyla.', + 'Uygulama içi, E-posta (SMTP), Ntfy, Web kancası ve Web anlık bildirimleri: her biri için bir panel, kanalı kullanıcılara sunan bir anahtar ve ihtiyaç duyduğu sunucu tarafı yapılandırmasıyla.', 'help.ctx.admin-notifications.bullet.2': 'Seyahat Hatırlatıcıları: sunucunun bir seyahat başlamadan önce hatırlatıcı gönderip göndermediği.', 'help.ctx.admin-notifications.bullet.3': @@ -1618,7 +1620,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'E-posta (SMTP) altında SMTP Host, SMTP Port, SMTP User, SMTP Password ve From Address girin; Test e-postası gönder size bir posta gönderir.', 'help.guide.channels-instance.step.2': - 'Sunmak için Ntfy ve Web kancası’nı açın; kullanıcılar sonra kendi konularını ya da URL’lerini Ayarlar, Bildirimler altında girer.', + 'Sunmak için Web anlık bildirimleri, Ntfy ve Web kancası’nı açın; kullanıcılar sonra Ayarlar, Bildirimler altında anlık bildirimleri cihaz başına açar ya da kendi konularını veya URL’lerini girer.', 'help.guide.channels-instance.step.3': 'Seyahat Hatırlatıcıları bir seyahat başlamadan önceki hatırlatıcıyı açıp kapatır; Uygulama içi her zaman açıktır ve burada yalnızca açıklanır.', 'help.guide.channels-instance.result': 'Her kullanıcının Bildirimler sekmesi açtığınız kanalları gösterir.', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 1c42a050f..91be392d9 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -116,6 +116,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.pluginConfigured': 'Yapılandırıldı. Kimlik bilgilerini eklentinin ayarlar sayfasından yönetin.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Anlık bildirim', + 'settings.webPush.title': 'Bu cihazda anlık bildirimler', + 'settings.webPush.hint': + 'TREK kapalıyken bile TREK bildirimlerini bu cihazda gösterir. Hangi olayların geleceğini Anlık bildirim sütunu belirler.', + 'settings.webPush.enable': 'Bu cihaz için aç', + 'settings.webPush.disable': 'Bu cihaz için kapat', + 'settings.webPush.enabled': 'Bu cihaz için açık', + 'settings.webPush.unsupported': 'Bu tarayıcı anlık bildirim alamıyor.', + 'settings.webPush.insecure': "Anlık bildirimler için TREK'in HTTPS üzerinden açılması gerekir.", + 'settings.webPush.iosInstall': "iPhone ve iPad'de önce TREK'i Ana Ekran'a ekleyin ve oradan açın.", + 'settings.webPush.denied': + 'TREK bildirimleri bu tarayıcıda engellendi. Tarayıcı ayarlarından izin verin, ardından yeniden deneyin.', + 'settings.webPush.failed': 'Anlık bildirimler bu cihaz için açılamadı.', 'settings.notificationPreferences.noChannels': 'Bildirim kanalı yapılandırılmadı. Yöneticiden e-posta veya webhook kurmasını isteyin.', 'settings.webhookUrl.label': "Web kancası URL'si", diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index bd81ebd5a..1d6014889 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -718,6 +718,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': 'Дозволи', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Дозволяє користувачам отримувати сповіщення на телефонах і комп’ютерах через браузер, навіть коли TREK закрито. Потрібен HTTPS; на iPhone та iPad TREK треба додати на початковий екран.', 'admin.notifications.inappPanel.title': 'In-App', 'admin.notifications.inappPanel.hint': 'Сповіщення в додатку завжди активні і не можуть бути вимкнені глобально.', 'admin.notifications.adminWebhookPanel.title': 'Webhook адміністратора', diff --git a/shared/src/i18n/uk/help.ts b/shared/src/i18n/uk/help.ts index 8d9e2e4f3..ca128fcfa 100644 --- a/shared/src/i18n/uk/help.ts +++ b/shared/src/i18n/uk/help.ts @@ -1031,11 +1031,13 @@ const help: TranslationStrings = { 'Режим високої якості для згладжування та проєкції глобуса; Зберегти карту записує вибір.', 'help.ctx.settings-notifications.title': 'Сповіщення', 'help.ctx.settings-notifications.summary': - 'Де TREK знаходить вас поза застосунком: тема ntfy, вебхук або канал, який дає плагін. Під каналами по одному рядку на подію вирішує, що куди йде.', + 'Де TREK знаходить вас поза застосунком: push-сповіщення на цьому пристрої, тема ntfy, вебхук або канал, який дає плагін. Під каналами по одному рядку на подію вирішує, що куди йде.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: тема, необов’язковий власний сервер і необов’язковий токен доступу, з кнопкою Тест, щоб одразу надіслати повідомлення.', 'help.ctx.settings-notifications.bullet.2': 'Вебхук: один URL, який отримує кожну подію як JSON, з кнопкою Тест.', 'help.ctx.settings-notifications.bullet.3': + 'Push-сповіщення на цьому пристрої: кнопка Увімкнути на цьому пристрої діє лише для браузера, яким ви користуєтеся, тож повторіть це на кожному телефоні чи комп’ютері. Надіслати тест надсилає повідомлення на всі ці пристрої.', + 'help.ctx.settings-notifications.bullet.4': 'Рядки вподобань: для кожної події, який канал увімкнено. Канали плагінів показують Налаштувати, поки їх не налаштовано.', 'help.ctx.settings-integrations.title': 'Інтеграції', 'help.ctx.settings-integrations.summary': @@ -1361,7 +1363,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Канали, які інстанс пропонує своїм користувачам, і ті, що доходять до вас як адміністратора. Користувачі обирають свої теми та URL у Налаштування; ви вирішуєте, що існує, і налаштовуєте ел. пошту.', 'help.ctx.admin-notifications.bullet.1': - 'In-App, Email (SMTP), Ntfy і Webhook: по одній панелі на кожен, із перемикачем, який пропонує канал користувачам, і потрібним йому налаштуванням на боці сервера.', + 'In-App, Email (SMTP), Ntfy, Webhook і Web Push: по одній панелі на кожен, із перемикачем, який пропонує канал користувачам, і потрібним йому налаштуванням на боці сервера.', 'help.ctx.admin-notifications.bullet.2': 'Нагадування про поїздки: чи надсилає сервер нагадування перед початком поїздки.', 'help.ctx.admin-notifications.bullet.3': @@ -1604,7 +1606,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'У Email (SMTP) введіть SMTP Host, SMTP Port, SMTP User, SMTP Password і From Address; Відправити тестовий лист надсилає лист вам.', 'help.guide.channels-instance.step.2': - 'Увімкніть Ntfy і Webhook, щоб запропонувати їх; користувачі потім вводять свою тему або URL у Налаштування, Сповіщення.', + 'Увімкніть Web Push, Ntfy і Webhook, щоб запропонувати їх; користувачі потім вмикають push на кожному пристрої або вводять свою тему чи URL у Налаштування, Сповіщення.', 'help.guide.channels-instance.step.3': 'Нагадування про поїздки перемикає нагадування перед початком поїздки; In-App увімкнено завжди і тут лише описано.', 'help.guide.channels-instance.result': 'Вкладка Сповіщення кожного користувача показує канали, які ви ввімкнули.', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index b7c0abc3d..9bf1b779a 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -315,6 +315,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Push', + 'settings.webPush.title': 'Push-сповіщення на цьому пристрої', + 'settings.webPush.hint': + 'Показує сповіщення TREK на цьому пристрої, навіть коли TREK закрито. Які події надходять, визначає стовпець Push.', + 'settings.webPush.enable': 'Увімкнути на цьому пристрої', + 'settings.webPush.disable': 'Вимкнути на цьому пристрої', + 'settings.webPush.enabled': 'Увімкнено на цьому пристрої', + 'settings.webPush.unsupported': 'Цей браузер не може отримувати push-сповіщення.', + 'settings.webPush.insecure': 'Для push TREK має бути відкрито через HTTPS.', + 'settings.webPush.iosInstall': 'На iPhone та iPad спершу додайте TREK на початковий екран і відкрийте його звідти.', + 'settings.webPush.denied': + 'Сповіщення для TREK заблоковано в цьому браузері. Дозвольте їх у налаштуваннях браузера й спробуйте ще раз.', + 'settings.webPush.failed': 'Не вдалося увімкнути push на цьому пристрої.', 'settings.oauth.modal.machineClient': 'Машинний клієнт (без входу через браузер)', 'settings.oauth.modal.machineClientHint': 'Використовуйте надання client_credentials — URI перенаправлення не потрібні. Токен видається безпосередньо через client_id + client_secret і діє від вашого імені в межах вибраних областей.', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 68f3a2521..1e60f9114 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -19,6 +19,9 @@ const admin: TranslationStrings = { 'admin.notifications.testNtfyFailed': 'Kiểm tra ntfy không thành công', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web Push', + 'admin.notifications.webPushPanel.hint': + 'Cho phép người dùng nhận thông báo trên điện thoại và máy tính qua trình duyệt, ngay cả khi TREK đang đóng. Cần HTTPS; trên iPhone và iPad, TREK phải được thêm vào Màn hình chính.', 'admin.notifications.inappPanel.title': 'Trong ứng dụng', 'admin.notifications.inappPanel.hint': 'Thông báo trong ứng dụng luôn hoạt động và không thể tắt trên toàn cầu.', 'admin.notifications.adminWebhookPanel.title': 'Webhook quản trị viên', diff --git a/shared/src/i18n/vi/help.ts b/shared/src/i18n/vi/help.ts index 627d52842..ba23dc985 100644 --- a/shared/src/i18n/vi/help.ts +++ b/shared/src/i18n/vi/help.ts @@ -1036,11 +1036,13 @@ const help: TranslationStrings = { '“Chế độ chất lượng cao” để khử răng cưa và phép chiếu địa cầu; “Lưu bản đồ” ghi lại lựa chọn.', 'help.ctx.settings-notifications.title': 'Thông báo', 'help.ctx.settings-notifications.summary': - 'TREK liên lạc với bạn ở đâu ngoài ứng dụng: một chủ đề ntfy, một webhook, hoặc một kênh do plugin cung cấp. Bên dưới các kênh, mỗi sự kiện một hàng quyết định cái gì đi đâu.', + 'TREK liên lạc với bạn ở đâu ngoài ứng dụng: thông báo đẩy trên thiết bị này, một chủ đề ntfy, một webhook, hoặc một kênh do plugin cung cấp. Bên dưới các kênh, mỗi sự kiện một hàng quyết định cái gì đi đâu.', 'help.ctx.settings-notifications.bullet.1': 'ntfy: chủ đề, máy chủ riêng của bạn (tùy chọn) và mã thông báo truy cập (tùy chọn), với “Bài kiểm tra” để gửi ngay một tin.', 'help.ctx.settings-notifications.bullet.2': 'Webhook: một URL nhận mọi sự kiện dưới dạng JSON, với “Bài kiểm tra”.', 'help.ctx.settings-notifications.bullet.3': + 'Thông báo đẩy trên thiết bị này: “Bật cho thiết bị này” chỉ áp dụng cho trình duyệt bạn đang dùng, nên hãy lặp lại trên từng điện thoại hoặc máy tính. “Gửi thử” sẽ đến tất cả các thiết bị đó.', + 'help.ctx.settings-notifications.bullet.4': 'Các hàng tùy chọn: với mỗi sự kiện, kênh nào đang bật. Kênh của plugin hiện “Cấu hình” cho đến khi được thiết lập.', 'help.ctx.settings-integrations.title': 'Tích hợp', 'help.ctx.settings-integrations.summary': @@ -1362,7 +1364,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': 'Các kênh mà phiên bản cung cấp cho người dùng, và các kênh tới bạn với tư cách quản trị viên. Người dùng chọn chủ đề và URL của họ trong “Cài đặt”; bạn quyết định có những kênh nào và cấu hình email.', 'help.ctx.admin-notifications.bullet.1': - '“Trong ứng dụng”, “Email (SMTP)”, “Ntfy” và “Webhook”: mỗi kênh một bảng, với công tắc cung cấp kênh đó cho người dùng và cấu hình phía máy chủ mà nó cần.', + '“Trong ứng dụng”, “Email (SMTP)”, “Ntfy”, “Webhook” và “Web Push”: mỗi kênh một bảng, với công tắc cung cấp kênh đó cho người dùng và cấu hình phía máy chủ mà nó cần.', 'help.ctx.admin-notifications.bullet.2': '“Lời nhắc chuyến đi”: máy chủ có gửi lời nhắc trước khi chuyến đi bắt đầu hay không.', 'help.ctx.admin-notifications.bullet.3': @@ -1603,7 +1605,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': 'Dưới “Email (SMTP)”, nhập SMTP Host, SMTP Port, SMTP User, SMTP Password và From Address; “Gửi email kiểm tra” gửi một thư tới bạn.', 'help.guide.channels-instance.step.2': - 'Bật “Ntfy” và “Webhook” để cung cấp chúng; người dùng sau đó nhập chủ đề hoặc URL của họ trong “Cài đặt”, “Thông báo”.', + 'Bật “Web Push”, “Ntfy” và “Webhook” để cung cấp chúng; người dùng sau đó bật thông báo đẩy cho từng thiết bị, hoặc nhập chủ đề hoặc URL của họ, trong “Cài đặt”, “Thông báo”.', 'help.guide.channels-instance.step.3': '“Lời nhắc chuyến đi” bật tắt lời nhắc trước khi chuyến đi bắt đầu; “Trong ứng dụng” luôn bật và ở đây chỉ được giải thích.', 'help.guide.channels-instance.result': 'Tab “Thông báo” của mọi người dùng hiện các kênh bạn đã bật.', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index c0a746386..9b00d7c48 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -121,6 +121,19 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.pluginConfigured': 'Đã cấu hình. Quản lý thông tin đăng nhập ở trang cài đặt của plugin.', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': 'Thông báo đẩy', + 'settings.webPush.title': 'Thông báo đẩy trên thiết bị này', + 'settings.webPush.hint': + 'Hiển thị thông báo của TREK trên thiết bị này ngay cả khi TREK đang đóng. Những sự kiện nào được gửi đến tuân theo cột Thông báo đẩy.', + 'settings.webPush.enable': 'Bật cho thiết bị này', + 'settings.webPush.disable': 'Tắt cho thiết bị này', + 'settings.webPush.enabled': 'Đã bật cho thiết bị này', + 'settings.webPush.unsupported': 'Trình duyệt này không thể nhận thông báo đẩy.', + 'settings.webPush.insecure': 'Thông báo đẩy cần mở TREK qua HTTPS.', + 'settings.webPush.iosInstall': 'Trên iPhone và iPad, hãy thêm TREK vào Màn hình chính trước rồi mở từ đó.', + 'settings.webPush.denied': + 'Thông báo của TREK đang bị chặn trong trình duyệt này. Hãy cho phép trong cài đặt trình duyệt rồi thử lại.', + 'settings.webPush.failed': 'Không thể bật thông báo đẩy cho thiết bị này.', 'settings.notificationPreferences.noChannels': 'Không có kênh thông báo nào được cấu hình. Yêu cầu quản trị viên thiết lập thông báo qua email hoặc webhook.', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 8938aea5e..b21e52d2e 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -13,6 +13,9 @@ const admin: TranslationStrings = { 'admin.notifications.testWebhookFailed': '測試 Webhook 傳送失敗', 'admin.notifications.emailPanel.title': '電子郵件 (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web 推播', + 'admin.notifications.webPushPanel.hint': + '讓使用者即使在 TREK 關閉時,也能透過瀏覽器在手機和電腦上接收通知。需要 HTTPS;在 iPhone 和 iPad 上,必須將 TREK 加入主畫面。', 'admin.notifications.inappPanel.title': '應用程式內通知', 'admin.notifications.inappPanel.hint': '應用程式內通知始終啟用,無法全域性停用。', 'admin.notifications.adminWebhookPanel.title': '管理員 Webhook', diff --git a/shared/src/i18n/zh-TW/help.ts b/shared/src/i18n/zh-TW/help.ts index f7e3617f4..8e6d833f1 100644 --- a/shared/src/i18n/zh-TW/help.ts +++ b/shared/src/i18n/zh-TW/help.ts @@ -876,11 +876,13 @@ const help: TranslationStrings = { 'help.ctx.settings-map.bullet.3': '「高畫質模式」提供反鋸齒和地球儀投影;「儲存地圖」寫入你的選擇。', 'help.ctx.settings-notifications.title': '通知', 'help.ctx.settings-notifications.summary': - 'TREK 在應用程式之外聯絡你的地方:一個 ntfy 主題、一個 webhook,或者外掛提供的管道。管道下方每個事件一列,決定什麼送到哪裡。', + 'TREK 在應用程式之外聯絡你的地方:此裝置上的推播通知、一個 ntfy 主題、一個 webhook,或者外掛提供的管道。管道下方每個事件一列,決定什麼送到哪裡。', 'help.ctx.settings-notifications.bullet.1': 'ntfy:主題、選填的自建伺服器和選填的存取權杖,按「測試」可以立刻送一則。', 'help.ctx.settings-notifications.bullet.2': 'Webhook:一個以 JSON 接收所有事件的 URL,附「測試」。', 'help.ctx.settings-notifications.bullet.3': + '此裝置上的推播通知:「為此裝置開啟」只對你正在使用的瀏覽器生效,所以要在每支手機或每台電腦上各開一次。「傳送測試」會送到所有這些裝置。', + 'help.ctx.settings-notifications.bullet.4': '偏好設定列:每個事件開啟了哪個管道。外掛管道在設定好之前顯示「前往設定」。', 'help.ctx.settings-integrations.title': '整合', 'help.ctx.settings-integrations.summary': @@ -1147,7 +1149,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': '實例向使用者提供的管道,以及能聯絡到你這位管理員的管道。使用者在「設定」裡選自己的主題和 URL;你決定有哪些管道,並設定郵件。', 'help.ctx.admin-notifications.bullet.1': - '「應用程式內通知」「電子郵件 (SMTP)」「Ntfy」和「Webhook」:各一個面板,帶一個向使用者開放該管道的開關,以及它需要的伺服器端設定。', + '「應用程式內通知」「電子郵件 (SMTP)」「Ntfy」「Webhook」和「Web 推播」:各一個面板,帶一個向使用者開放該管道的開關,以及它需要的伺服器端設定。', 'help.ctx.admin-notifications.bullet.2': '「行程提醒」:伺服器是否在行程開始前傳送提醒。', 'help.ctx.admin-notifications.bullet.3': '「管理員 Ntfy」和「管理員 Webhook」:備份失敗或新版本發布這類管理員事件的去向,帶「測試」。', @@ -1345,7 +1347,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': '在「電子郵件 (SMTP)」下輸入 SMTP Host、SMTP Port、SMTP User、SMTP Password 和 From Address;「傳送測試郵件」會給你發一封郵件。', 'help.guide.channels-instance.step.2': - '開啟「Ntfy」和「Webhook」以提供它們;使用者隨後在「設定」的「通知」裡輸入自己的主題或 URL。', + '開啟「Web 推播」「Ntfy」和「Webhook」以提供它們;使用者隨後在「設定」的「通知」裡為每台裝置開啟推播,或輸入自己的主題或 URL。', 'help.guide.channels-instance.step.3': '「行程提醒」開關行程開始前的提醒;「應用程式內通知」始終開啟,這裡只是說明。', 'help.guide.channels-instance.result': '每個使用者的「通知」分頁會顯示你開啟的管道。', 'help.guide.channels-instance.tip.1': '在這裡輸入的預設 ntfy 伺服器會為使用者預填;他們仍然可以指定自己的。', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 20f31249a..509f2b0b5 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -109,6 +109,17 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.testFailed': '測試失敗。', 'settings.notificationPreferences.pluginConfigured': '已設定。請在外掛的設定頁面管理憑證。', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': '推播', + 'settings.webPush.title': '此裝置上的推播通知', + 'settings.webPush.hint': '即使 TREK 已關閉,也會在此裝置上顯示 TREK 通知。收到哪些事件取決於「推播」欄。', + 'settings.webPush.enable': '為此裝置開啟', + 'settings.webPush.disable': '為此裝置關閉', + 'settings.webPush.enabled': '已為此裝置開啟', + 'settings.webPush.unsupported': '此瀏覽器無法接收推播通知。', + 'settings.webPush.insecure': '推播需要透過 HTTPS 開啟 TREK。', + 'settings.webPush.iosInstall': '在 iPhone 和 iPad 上,請先將 TREK 加入主畫面,再從主畫面開啟。', + 'settings.webPush.denied': '此瀏覽器已封鎖 TREK 的通知。請在瀏覽器設定中允許通知,然後再試一次。', + 'settings.webPush.failed': '無法為此裝置開啟推播。', 'settings.notificationPreferences.noChannels': '未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。', 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index e33f3e068..592849387 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -668,6 +668,9 @@ const admin: TranslationStrings = { 'admin.tabs.permissions': '权限', 'admin.notifications.emailPanel.title': '电子邮件(SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.webPushPanel.title': 'Web 推送', + 'admin.notifications.webPushPanel.hint': + '让用户即使在 TREK 关闭时,也能通过浏览器在手机和电脑上接收通知。需要 HTTPS;在 iPhone 和 iPad 上,必须将 TREK 添加到主屏幕。', 'admin.notifications.inappPanel.title': '应用内通知', 'admin.notifications.inappPanel.hint': '应用内通知始终处于活跃状态,无法全局禁用。', 'admin.notifications.adminWebhookPanel.title': '管理员 Webhook', diff --git a/shared/src/i18n/zh/help.ts b/shared/src/i18n/zh/help.ts index e14e86387..d45f18855 100644 --- a/shared/src/i18n/zh/help.ts +++ b/shared/src/i18n/zh/help.ts @@ -860,10 +860,12 @@ const help: TranslationStrings = { 'help.ctx.settings-map.bullet.3': '“高画质模式”提供抗锯齿和地球仪投影;“保存地图”写入你的选择。', 'help.ctx.settings-notifications.title': '通知', 'help.ctx.settings-notifications.summary': - 'TREK 在应用之外联系你的地方:一个 ntfy 主题、一个 webhook,或者插件提供的渠道。渠道下方每个事件一行,决定什么发到哪里。', + 'TREK 在应用之外联系你的地方:此设备上的推送通知、一个 ntfy 主题、一个 webhook,或者插件提供的渠道。渠道下方每个事件一行,决定什么发到哪里。', 'help.ctx.settings-notifications.bullet.1': 'ntfy:主题、可选的自建服务器和可选的访问令牌,点“测试”可以立刻发一条。', 'help.ctx.settings-notifications.bullet.2': 'Webhook:一个以 JSON 接收所有事件的 URL,带“测试”。', - 'help.ctx.settings-notifications.bullet.3': '偏好设置行:每个事件开启了哪个渠道。插件渠道在设置好之前显示“去配置”。', + 'help.ctx.settings-notifications.bullet.3': + '此设备上的推送通知:“为此设备开启”只对你正在使用的浏览器生效,所以要在每台手机或电脑上各开一次。“发送测试”会发到所有这些设备。', + 'help.ctx.settings-notifications.bullet.4': '偏好设置行:每个事件开启了哪个渠道。插件渠道在设置好之前显示“去配置”。', 'help.ctx.settings-integrations.title': '集成', 'help.ctx.settings-integrations.summary': '从外部连接到 TREK 的一切:日记用的照片库、脚本用的 API 密钥,以及供 AI 助手使用的 MCP 端点及其令牌和 OAuth 客户端。', @@ -1119,7 +1121,7 @@ const help: TranslationStrings = { 'help.ctx.admin-notifications.summary': '实例向用户提供的渠道,以及能联系到你这位管理员的渠道。用户在“设置”里选自己的主题和 URL;你决定有哪些渠道,并配置邮件。', 'help.ctx.admin-notifications.bullet.1': - '“应用内通知”“电子邮件(SMTP)”“Ntfy”和“Webhook”:各一个面板,带一个向用户开放该渠道的开关,以及它需要的服务器端配置。', + '“应用内通知”“电子邮件(SMTP)”“Ntfy”“Webhook”和“Web 推送”:各一个面板,带一个向用户开放该渠道的开关,以及它需要的服务器端配置。', 'help.ctx.admin-notifications.bullet.2': '“行程提醒”:服务器是否在行程开始前发送提醒。', 'help.ctx.admin-notifications.bullet.3': '“管理员 Ntfy”和“管理员 Webhook”:备份失败或新版本发布这类管理员事件的去向,带“测试”。', @@ -1314,7 +1316,7 @@ const help: TranslationStrings = { 'help.guide.channels-instance.step.1': '在“电子邮件(SMTP)”下输入 SMTP Host、SMTP Port、SMTP User、SMTP Password 和 From Address;“发送测试邮件”会给你发一封邮件。', 'help.guide.channels-instance.step.2': - '打开“Ntfy”和“Webhook”以提供它们;用户随后在“设置”的“通知”里输入自己的主题或 URL。', + '打开“Web 推送”“Ntfy”和“Webhook”以提供它们;用户随后在“设置”的“通知”里为每台设备开启推送,或输入自己的主题或 URL。', 'help.guide.channels-instance.step.3': '“行程提醒”开关行程开始前的提醒;“应用内通知”始终开启,这里只是说明。', 'help.guide.channels-instance.result': '每个用户的“通知”标签页会显示你开启的渠道。', 'help.guide.channels-instance.tip.1': '在这里输入的默认 ntfy 服务器会为用户预填;他们仍然可以指定自己的。', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 530f49ac4..06ddbfd51 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -301,6 +301,17 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': '电子邮件', 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.push': '推送', + 'settings.webPush.title': '此设备上的推送通知', + 'settings.webPush.hint': '即使 TREK 已关闭,也会在此设备上显示 TREK 通知。收到哪些事件取决于“推送”列。', + 'settings.webPush.enable': '为此设备开启', + 'settings.webPush.disable': '为此设备关闭', + 'settings.webPush.enabled': '已为此设备开启', + 'settings.webPush.unsupported': '此浏览器无法接收推送通知。', + 'settings.webPush.insecure': '推送需要通过 HTTPS 打开 TREK。', + 'settings.webPush.iosInstall': '在 iPhone 和 iPad 上,请先将 TREK 添加到主屏幕,然后从主屏幕打开。', + 'settings.webPush.denied': '此浏览器已阻止 TREK 的通知。请在浏览器设置中允许通知,然后重试。', + 'settings.webPush.failed': '无法为此设备开启推送。', 'settings.currency': '显示货币', 'settings.currencyHint': '“费用”中的金额仅在显示时换算为该货币,原始金额保持不变。', 'settings.currencyTrip': '行程货币', diff --git a/shared/src/notification/notification.schema.spec.ts b/shared/src/notification/notification.schema.spec.ts index 5aa3b8b2f..b88bc4c75 100644 --- a/shared/src/notification/notification.schema.spec.ts +++ b/shared/src/notification/notification.schema.spec.ts @@ -4,6 +4,14 @@ import { channelTestResultSchema, inAppListResultSchema, testNtfyRequestSchema, + pushSubscriptionSchema, + pushSubscribeRequestSchema, + pushUnsubscribeRequestSchema, + pushPublicKeyResultSchema, + pushSubscribeResultSchema, + pushUnsubscribeResultSchema, + pushPayloadSchema, + MAX_PUSH_ENDPOINT_LENGTH, } from './notification.schema'; import { describe, it, expect } from 'vitest'; @@ -56,3 +64,140 @@ describe('inAppListResultSchema', () => { ).toBe(true); }); }); + +// ── Web Push ──────────────────────────────────────────────────────────────── + +const SUBSCRIPTION = { + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + expirationTime: null, + keys: { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA', auth: 'tBHItJI5svbpez7KI4CCXg' }, +}; + +describe('pushSubscriptionSchema', () => { + it('accepts what PushSubscription.toJSON() hands over, with or without an expiry', () => { + expect(pushSubscriptionSchema.safeParse(SUBSCRIPTION).success).toBe(true); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, expirationTime: 1767225600000 }).success).toBe(true); + expect(pushSubscriptionSchema.safeParse({ endpoint: SUBSCRIPTION.endpoint, keys: SUBSCRIPTION.keys }).success).toBe( + true, + ); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, expirationTime: 'tomorrow' }).success).toBe(false); + }); + + it('refuses a subscription without keys, or with one of them missing or empty', () => { + expect(pushSubscriptionSchema.safeParse({ endpoint: SUBSCRIPTION.endpoint, expirationTime: null }).success).toBe( + false, + ); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: 'x' } }).success).toBe(false); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { auth: 'x' } }).success).toBe(false); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: '', auth: 'x' } }).success).toBe(false); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: '' }).success).toBe(false); + }); + + it('refuses an endpoint that is not a string, and leaves checking the URL itself to the server', () => { + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: 42 }).success).toBe(false); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: null }).success).toBe(false); + expect(pushSubscriptionSchema.safeParse({ keys: SUBSCRIPTION.keys }).success).toBe(false); + // Only the shape is the contract's: a string that is not an https URL on a push + // service passes here, so the server can refuse it with its own 400 body. + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: 'not a url' }).success).toBe(true); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: 'http://example.com/push' }).success).toBe( + true, + ); + }); + + it('refuses keys that are not strings', () => { + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: 'p256dh,auth' }).success).toBe(false); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: 1, auth: 'x' } }).success).toBe(false); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { p256dh: 'x', auth: null } }).success).toBe( + false, + ); + }); + + it('caps the endpoint at MAX_PUSH_ENDPOINT_LENGTH characters and the keys at 200 and 100', () => { + const at = (n: number) => 'a'.repeat(n); + expect(MAX_PUSH_ENDPOINT_LENGTH).toBe(2048); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: at(MAX_PUSH_ENDPOINT_LENGTH) }).success).toBe( + true, + ); + expect( + pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, endpoint: at(MAX_PUSH_ENDPOINT_LENGTH + 1) }).success, + ).toBe(false); + const keys = SUBSCRIPTION.keys; + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, p256dh: at(200) } }).success).toBe( + true, + ); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, p256dh: at(201) } }).success).toBe( + false, + ); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, auth: at(100) } }).success).toBe(true); + expect(pushSubscriptionSchema.safeParse({ ...SUBSCRIPTION, keys: { ...keys, auth: at(101) } }).success).toBe(false); + }); +}); + +describe('pushSubscribeRequestSchema', () => { + it('wants the subscription under `subscription`', () => { + expect(pushSubscribeRequestSchema.safeParse({ subscription: SUBSCRIPTION }).success).toBe(true); + expect(pushSubscribeRequestSchema.safeParse(SUBSCRIPTION).success).toBe(false); + expect(pushSubscribeRequestSchema.safeParse({}).success).toBe(false); + }); + + it('checks the subscription inside it like a bare one', () => { + expect(pushSubscribeRequestSchema.safeParse({ subscription: { ...SUBSCRIPTION, keys: {} } }).success).toBe(false); + expect( + pushSubscribeRequestSchema.safeParse({ + subscription: { ...SUBSCRIPTION, endpoint: 'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH + 1) }, + }).success, + ).toBe(false); + }); +}); + +describe('pushUnsubscribeRequestSchema', () => { + it('takes one endpoint, non-empty and at most MAX_PUSH_ENDPOINT_LENGTH characters, like a subscription', () => { + expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: SUBSCRIPTION.endpoint }).success).toBe(true); + expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: '' }).success).toBe(false); + expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: 'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH) }).success).toBe( + true, + ); + expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: 'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH + 1) }).success).toBe( + false, + ); + expect(pushUnsubscribeRequestSchema.safeParse({}).success).toBe(false); + expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: 42 }).success).toBe(false); + }); +}); + +describe('pushPublicKeyResultSchema', () => { + it('is the key as a string', () => { + expect(pushPublicKeyResultSchema.safeParse({ publicKey: 'BP4z9KsN6nGRTbVY' }).success).toBe(true); + expect(pushPublicKeyResultSchema.safeParse({ publicKey: null }).success).toBe(false); + expect(pushPublicKeyResultSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('pushSubscribeResultSchema', () => { + it('is success true with the device count', () => { + expect(pushSubscribeResultSchema.safeParse({ success: true, devices: 2 }).success).toBe(true); + expect(pushSubscribeResultSchema.safeParse({ success: false, devices: 2 }).success).toBe(false); + expect(pushSubscribeResultSchema.safeParse({ success: true }).success).toBe(false); + }); +}); + +describe('pushUnsubscribeResultSchema', () => { + it('is success true, nothing else', () => { + expect(pushUnsubscribeResultSchema.safeParse({ success: true }).success).toBe(true); + expect(pushUnsubscribeResultSchema.safeParse({ success: false }).success).toBe(false); + expect(pushUnsubscribeResultSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('pushPayloadSchema', () => { + it('needs a title and a body; url and tag are optional', () => { + expect(pushPayloadSchema.safeParse({ title: 'T', body: 'B' }).success).toBe(true); + expect( + pushPayloadSchema.safeParse({ title: 'T', body: 'B', url: '/trips/1', tag: 'collab_message:/trips/1' }).success, + ).toBe(true); + expect(pushPayloadSchema.safeParse({ title: 'T' }).success).toBe(false); + expect(pushPayloadSchema.safeParse({ body: 'B' }).success).toBe(false); + expect(pushPayloadSchema.safeParse({ title: 'T', body: 'B', tag: 5 }).success).toBe(false); + }); +}); diff --git a/shared/src/notification/notification.schema.ts b/shared/src/notification/notification.schema.ts index 91a7dc4c8..029d7d0f5 100644 --- a/shared/src/notification/notification.schema.ts +++ b/shared/src/notification/notification.schema.ts @@ -55,3 +55,75 @@ export type InAppListResult = z.infer; export const unreadCountResultSchema = z.object({ count: z.number() }); export type UnreadCountResult = z.infer; + +// ── Web Push (#894) ────────────────────────────────────────────────────────── + +/** Id of the built-in Web Push channel in the preference matrix and the admin CSV. */ +export const WEB_PUSH_CHANNEL_ID = 'push'; + +/** + * The longest push endpoint the server takes, well above what the push + * services hand out. One value for the contract and the server's own endpoint + * check, so a device that can register can always unsubscribe and be sent to. + */ +export const MAX_PUSH_ENDPOINT_LENGTH = 2048; + +/** + * The browser's PushSubscription as PushSubscription.toJSON() hands it over. + * Only the shape is checked here; the service refuses an endpoint that is not + * https on a known push service and keys that do not decode to the RFC 8291 + * sizes, with messages of its own. + */ +export const pushSubscriptionSchema = z.object({ + endpoint: z.string().min(1).max(MAX_PUSH_ENDPOINT_LENGTH), + expirationTime: z.number().nullable().optional(), + keys: z.object({ + p256dh: z.string().min(1).max(200), + auth: z.string().min(1).max(100), + }), +}); +export type PushSubscriptionInput = z.infer; + +/** Register this device for push; repeating it for the same endpoint only refreshes the row. */ +export const pushSubscribeRequestSchema = z.object({ + subscription: pushSubscriptionSchema, +}); +export type PushSubscribeRequest = z.infer; + +/** Forget one device (logout, or the user switching push off there). */ +export const pushUnsubscribeRequestSchema = z.object({ + endpoint: z.string().min(1).max(MAX_PUSH_ENDPOINT_LENGTH), +}); +export type PushUnsubscribeRequest = z.infer; + +/** The VAPID public key a browser subscribes with (base64url, uncompressed P-256 point). */ +export const pushPublicKeyResultSchema = z.object({ + publicKey: z.string(), +}); +export type PushPublicKeyResult = z.infer; + +export const pushSubscribeResultSchema = z.object({ + success: z.literal(true), + /** How many devices this account now receives push on. */ + devices: z.number(), +}); +export type PushSubscribeResult = z.infer; + +/** Forgetting a device always succeeds, also for an endpoint the server no longer knows. */ +export const pushUnsubscribeResultSchema = z.object({ + success: z.literal(true), +}); +export type PushUnsubscribeResult = z.infer; + +/** + * What the service worker receives. Title and body are rendered on the server + * in the recipient's language, because the worker has no translations; `url` is + * a same-origin path the click opens. + */ +export const pushPayloadSchema = z.object({ + title: z.string(), + body: z.string(), + url: z.string().optional(), + tag: z.string().optional(), +}); +export type PushPayload = z.infer; diff --git a/unraid-template.xml b/unraid-template.xml index 0e6069960..e036f7a9f 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -64,5 +64,8 @@ false 300 20 + + + diff --git a/wiki/Admin-Panel-Overview.md b/wiki/Admin-Panel-Overview.md index 63f41ef11..097c4655e 100644 --- a/wiki/Admin-Panel-Overview.md +++ b/wiki/Admin-Panel-Overview.md @@ -21,7 +21,7 @@ The Admin Panel is divided into tabs. Most tabs are always visible; a few appear | **Plugins** | Install, update, and manage plugins; rescan the plugins folder; view each plugin's error log. See [Admin-Plugins](Admin-Plugins) | No | | **Storage** | Storage backends, category assignment, replication, health | No | | **Settings** | Authentication methods, MFA, allowed file types, API keys, OIDC/SSO configuration, and JWT secret rotation | No | -| **Notifications** | SMTP, webhook, ntfy, and push notification channel configuration; trip reminder toggle; admin notification preferences | No | +| **Notifications** | SMTP, webhook, ntfy and Web Push channel configuration; trip reminder toggle; admin notification preferences | No | | **Backup** | Manual and scheduled full-instance backups: database, uploads, plugin data and plugin code. See [Backups](Backups) | No | | **Audit** | Chronological activity log | No | | **MCP Access** | OAuth sessions and static API tokens | Only when the MCP addon is enabled | diff --git a/wiki/Encryption-Key-Rotation.md b/wiki/Encryption-Key-Rotation.md index ce50e7fc8..ff43612f7 100644 --- a/wiki/Encryption-Key-Rotation.md +++ b/wiki/Encryption-Key-Rotation.md @@ -20,6 +20,7 @@ TREK encrypts sensitive settings at rest using AES-256-GCM. The following values - OIDC client secret (global, in `app_settings`) - SMTP password (global, in `app_settings`) - Admin webhook URL and admin ntfy token (global, in `app_settings`) +- Web Push VAPID private key, the one TREK generates and signs push messages with when the `VAPID_*` variables supply no key pair (global, in `app_settings`; see [Notifications](Notifications#server-keys)) - MFA (TOTP) secrets for all users - Photo passphrases for Synology shared-link photos (in `trek_photos`) - Passphrases for shared trip album links (in `trip_album_links`) @@ -41,6 +42,8 @@ On startup, TREK resolves the encryption key in this order: All encrypted settings (API keys, SMTP password, OIDC secret, MFA secrets, notification tokens, etc.) become unreadable — TREK cannot decrypt them. They must be re-entered manually after the key is restored or replaced. Unencrypted data (trips, places, users, etc.) is unaffected. +The Web Push key pair cannot be re-entered, and TREK never replaces it on its own: unless the `VAPID_*` variables supply a pair, push stays off, with an error in the log, until TREK runs with the original key again, and then every device receives as before. To give it up instead, delete both `web_push_vapid_public_key` and `web_push_vapid_private_key` from `app_settings`. TREK then generates a new pair, and every device has to subscribe again (see [Environment-Variables](Environment-Variables#web-push)). + ## Backing up the key Unless you set `ENCRYPTION_KEY` in the environment, your backup ZIP **does** include the encryption key: `./data/.encryption_key` is bundled into every archive, manual and automatic, as a root-level `.encryption_key` entry, so a restore onto a different install can still decrypt the stored secrets. That makes the ZIP exactly as sensitive as the key itself — store and transfer it accordingly. @@ -71,7 +74,7 @@ The script: 2. Asks for confirmation before making any changes. 3. Creates a timestamped backup of the database (e.g. `travel.db.backup-1713484800000`) before modifying anything. 4. Re-encrypts all stored secrets across all tables: - - `app_settings`: `oidc_client_secret`, `smtp_pass`, `admin_webhook_url`, `admin_ntfy_token`, `maps_api_key`, `unsplash_api_key`, `amap_api_key` + - `app_settings`: `oidc_client_secret`, `smtp_pass`, `admin_webhook_url`, `admin_ntfy_token`, `maps_api_key`, `unsplash_api_key`, `amap_api_key`, `web_push_vapid_private_key` - `app_settings['storage.backends']`: the `secretAccessKey` of every S3 storage backend - `users` (per user): `maps_api_key`, `unsplash_api_key`, `amap_api_key`, `openweather_api_key`, `immich_api_key`, `synology_password`, `synology_sid`, `synology_did`, `airtrail_api_key`, `mfa_secret` - `settings` (per user): `webhook_url`, `ntfy_token`, `mapbox_access_token`, `carto_api_key`, `llm_api_key` diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index dc3e268dc..a184db2fb 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -7,7 +7,7 @@ Complete reference for all environment variables TREK reads. - **Docker Compose** — use the `environment:` block or a `.env` file alongside `docker-compose.yml` - **Docker run** — pass each variable with `-e VARIABLE=value` - **Helm** — use `env:` for plain values and `secretEnv:` for sensitive values in `values.yaml`. The chart only - passes through the keys it declares (31 in `templates/configmap.yaml`, the credentials in `templates/secret.yaml`), so a variable + passes through the keys it declares (33 in `templates/configmap.yaml`, the credentials in `templates/secret.yaml`), so a variable that is not one of them is dropped silently — patch it onto the Deployment or add it to the chart - **Unraid** — set in the container template editor - **Proxmox Community Script** — set in `/opt/trek/server/.env` @@ -26,6 +26,9 @@ Invalid environment configuration: - SESSION_DURATION="bogus": must be a duration like "1h", "7d" or "30d" ``` +The value of a secret is never printed: `ENCRYPTION_KEY`, `SMTP_PASS`, `OIDC_CLIENT_SECRET`, `VAPID_PRIVATE_KEY` and +the other keys, secrets and passwords appear as `***`, so the report names the variable and the problem only. + In Docker this crash-loops the container until the value is corrected or removed. Boolean switches accept `true`/`false`, `1`/`0`, `on`/`off`, `yes`/`no` in any casing — anything else counts as malformed. Variables TREK does not know are passed through untouched. @@ -227,6 +230,53 @@ optional (for unauthenticated relays). --- +## Web Push + +[Web Push](Notifications#web-push) signs every message with a VAPID key pair. None of these variables is needed: +without them TREK generates a pair on first start and keeps it in the database, the private half encrypted with +`ENCRYPTION_KEY`, so it moves with every backup and survives a restore. + +| Variable | Description | Default | +|---------------------|------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------| +| `VAPID_PUBLIC_KEY` | Public half of your own key pair: the uncompressed P-256 point, base64url (65 bytes). Only used together with `VAPID_PRIVATE_KEY`. | generated | +| `VAPID_PRIVATE_KEY` | Private half of that pair: the 32-byte P-256 key, base64url. | generated | +| `VAPID_SUBJECT` | The contact the push services see with every message: a `mailto:` address or an `https://` URL. | `APP_URL` when it is `https://`, else `https://github.com/liketrek/TREK` | + +A value in the wrong shape aborts startup like any other variable on this page; for `VAPID_PRIVATE_KEY` the report +prints `***` instead of the value. A pair whose halves do not belong together, or only one half of a pair, does not +stop TREK from starting, but push is off with an error in the log until the variables are fixed: the Push card and +column disappear from **Settings → Notifications**, the routes that register a device answer `503`, nothing is sent, +and every registered device stays registered. TREK never signs with another pair in its place, neither one stored in +the database (from a start without the variables) nor a newly generated one: no device registered with yours holds +that key, and the first message sent with it would drop every one of them. Once both variables hold the two halves of +your pair again, every device receives as before. +Any tool that prints the standard Web Push pair works, for example `npx web-push generate-vapid-keys`. + +Without `VAPID_SUBJECT`, the contact is `APP_URL` when that is an `https://` address other than `localhost`, and +otherwise the project page. Unlike links in emails, it never falls back on the first `ALLOWED_ORIGINS` entry, which may +be another site that is only allowed to call the API. TREK never fills in an email address on its own, so no admin +address travels to the push services. + +TREK generates a pair only while neither `web_push_vapid_public_key` nor `web_push_vapid_private_key` exists in +`app_settings`, and never overwrites a pair that is there. When no pair comes from the variables and the stored +private key cannot be decrypted with the current `ENCRYPTION_KEY` (after a restore under a different key, for example), +push is off: the log says why, the Push card and column disappear from every user's **Settings → Notifications** (the +routes that register a device answer `503`), nothing is sent, and every registered device stays registered. +Starting TREK with the original `ENCRYPTION_KEY` again brings push back on every device as it was. A stored private key +that does not belong to the stored public key, or only one of the two rows, turns push off the same way. To start over +with a new pair instead, delete both rows from `app_settings`; TREK then generates a new pair, and every device has to +subscribe again. + +Browsers bind each subscription to the public key it was made with. After a switch to another pair (setting the +variables on an instance that has been using its generated pair, changing them, removing them again, or a newly +generated pair), TREK drops each old subscription the first time it would have sent to it. A device subscribes again +with the new key on its own the next time TREK is opened on it, where the browser allows that without a tap; +elsewhere, push shows as off in **Settings → Notifications** and has to be turned on again there. + +On Helm, `VAPID_PUBLIC_KEY` and `VAPID_SUBJECT` go under `env:` and `VAPID_PRIVATE_KEY` under `secretEnv:`. + +--- + ## Initial Setup These variables only take effect on first boot, before any user exists. diff --git a/wiki/Install-Helm.md b/wiki/Install-Helm.md index 794532b41..a59b12ba3 100644 --- a/wiki/Install-Helm.md +++ b/wiki/Install-Helm.md @@ -126,6 +126,8 @@ env: # OIDC_ADMIN_VALUE: "" # value of that claim that grants admin role # OIDC_SCOPE: "openid email profile groups" # OIDC_DISCOVERY_URL: "" # override for providers with non-standard discovery paths (e.g. Authentik) + # VAPID_PUBLIC_KEY: "" # Web Push: public half of your own key pair; leave unset to use the generated one + # VAPID_SUBJECT: "mailto:admin@example.com" # Web Push contact; defaults to APP_URL when it is https ``` > **Note:** `DEFAULT_LANGUAGE` is not declared in the chart's ConfigMap, so a value under `env:` is dropped and the fallback stays `en`. Patch it onto the Deployment if you need it. See [Environment-Variables](Environment-Variables#default_language--supported-codes) for the codes. @@ -142,10 +144,13 @@ secretEnv: OIDC_CLIENT_SECRET: "" # set if using OIDC UNSPLASH_ACCESS_KEY: "" # optional; free key from unsplash.com/developers PLACES_API_KEY: "" # optional; Google Maps API key, wins over the one in Admin > Settings + VAPID_PRIVATE_KEY: "" # optional; Web Push private half, only together with env.VAPID_PUBLIC_KEY ``` Alternatively, use `generateEncryptionKey: true` to let the chart generate and manage the encryption key, or point `existingSecret` / `existingSecretKey` at an existing Kubernetes Secret. +> **Note:** Web Push needs none of the `VAPID_*` values. TREK generates its key pair on first start and keeps it in the database on the data PVC, so it survives upgrades and restores. Set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together only to bring a pair of your own; changing the pair later means every device has to subscribe again. See [Environment-Variables](Environment-Variables#web-push). + > **Note:** Without `UNSPLASH_ACCESS_KEY` the server queries Unsplash's unauthenticated endpoint, which many datacenter and VPS IP ranges — including a lot of Kubernetes clusters — are blocked or rate-limited on. Trip-cover and place-image search then fail with **"Unsplash search unavailable"**. A free Access Key switches the server to Unsplash's authenticated API (`api.unsplash.com`), which is not subject to that block. The key can also be set in **Admin → Settings → API Keys**; this value takes priority over that one. See [Environment-Variables](Environment-Variables#image-search-unsplash). ### Persistent Storage diff --git a/wiki/Install-Unraid.md b/wiki/Install-Unraid.md index d8da44e98..ee72378f2 100644 --- a/wiki/Install-Unraid.md +++ b/wiki/Install-Unraid.md @@ -45,7 +45,7 @@ The Unraid template exposes the following fields in the container UI: ### Advanced Variables -Additional variables (`PORT`, `NODE_ENV`, `LOG_LEVEL`, `TREK_WIKI_DIR`, `DEFAULT_LANGUAGE`, `FORCE_HTTPS`, `HSTS_INCLUDE_SUBDOMAINS`, `TRUST_PROXY`, `COOKIE_SECURE`, `ALLOW_INTERNAL_NETWORK`, `SESSION_DURATION`, `SESSION_DURATION_REMEMBER`, all OIDC variables, `MCP_RATE_LIMIT`, `MCP_MAX_SESSION_PER_USER`, `DEMO_MODE`, `UNSPLASH_ACCESS_KEY`) are available under **Advanced View** in the template editor. +Additional variables (`PORT`, `NODE_ENV`, `LOG_LEVEL`, `TREK_WIKI_DIR`, `DEFAULT_LANGUAGE`, `FORCE_HTTPS`, `HSTS_INCLUDE_SUBDOMAINS`, `TRUST_PROXY`, `COOKIE_SECURE`, `ALLOW_INTERNAL_NETWORK`, `SESSION_DURATION`, `SESSION_DURATION_REMEMBER`, all OIDC variables, `MCP_RATE_LIMIT`, `MCP_MAX_SESSION_PER_USER`, `DEMO_MODE`, `UNSPLASH_ACCESS_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`) are available under **Advanced View** in the template editor. ## Setting the Encryption Key diff --git a/wiki/Internal-Network-Access.md b/wiki/Internal-Network-Access.md index ebb6832ac..68c970486 100644 --- a/wiki/Internal-Network-Access.md +++ b/wiki/Internal-Network-Access.md @@ -8,6 +8,8 @@ TREK has two SSRF guards, both in `ssrfGuard.ts`. Which one applies depends on t **The strict guard** (`safeFetch` / `safeFetchFollow`, built on `checkSsrf`) covers most outbound traffic: Immich, Synology Photos, AirTrail, Dawarich, the document stores behind [Document-Sync](Document-Sync) (Paperless-ngx, Papra, Nextcloud, OpenCloud and Synology), notification webhooks, ntfy, Unsplash, and place lookups. It resolves the hostname to an IP address before allowing the connection and blocks loopback, link-local and private ranges. Only the private ranges open up, and only with `ALLOW_INTERNAL_NETWORK=true`. The two tables below describe this guard. +[Web Push](Notifications#web-push) deliveries go through the strict guard as well, with two narrower rules: they only ever go to the known push services (Google, Mozilla, Apple and Microsoft), and private ranges stay closed for them even with `ALLOW_INTERNAL_NETWORK=true`, because a push service is never on your LAN. + **The relaxed guard** (`safeFetchAdminConfigured`, also exported as `safeFetchLlm`) covers endpoints that are expected to live on your own network: OIDC (discovery, token, userinfo, JWKS), the LLM providers behind the AI Parsing addon (a local Ollama or any OpenAI-compatible endpoint), and plugin OAuth token exchanges. The self-hosted routing engines an admin sets for the [Road-Trip](Road-Trip) addon (**Own routing engine**, **Own Valhalla instance**) go through it too when the server asks them itself, which it does for the MCP road trip tools. The planner asks them from the browser, so they must also be reachable from your users' devices. It deliberately permits loopback and LAN targets, so a model server on `localhost` or an identity provider on your LAN works **without** `ALLOW_INTERNAL_NETWORK`. It still resolves every hostname, re-checks every redirect hop, and always blocks link-local and cloud-metadata addresses (`169.254.0.0/16`, the full `fe80::/10`, and the AWS and Alibaba metadata addresses). The only way past is a single address listed in `ALLOW_LINK_LOCAL_IPS`, see [below](#a-link-local-address-you-need). **No guard** applies to the addresses an admin sets in the environment for place search: `TREK_PLACES_URL`, `NOMINATIM_URL` and `OVERPASS_URL`. They are configuration rather than user input, so a self-run copy of the [TREK Places API](TREK-Places-API), a Nominatim or an Overpass instance on your LAN or in the same Docker network is reached without `ALLOW_INTERNAL_NETWORK`. diff --git a/wiki/Notifications.md b/wiki/Notifications.md index 55533dedb..ee2784121 100644 --- a/wiki/Notifications.md +++ b/wiki/Notifications.md @@ -6,7 +6,7 @@ The Notifications tab (Settings → Notifications) lets you choose which events ## Notification channels -TREK ships four delivery channels, and a plugin can add more. Which channels appear depends on what the admin has enabled server-side. +TREK ships five delivery channels, and a plugin can add more. Which channels appear depends on what the admin has enabled server-side. | Channel | Description | |---------|-------------| @@ -14,6 +14,7 @@ TREK ships four delivery channels, and a plugin can add more. Which channels app | **Email** | Delivered to your account email. Requires the admin to configure SMTP. | | **Webhook** | TREK POSTs a JSON payload to a URL you specify. Discord and Slack webhook URLs are auto-detected and receive a natively formatted payload. | | **ntfy** | Push notifications via [ntfy.sh](https://ntfy.sh) or a self-hosted ntfy server. | +| **Push** | Web Push: notifications from the browser, or the Home Screen app, on each device you turn it on for, even while TREK is closed. See [Web Push](#web-push). | ### Plugin channels @@ -52,7 +53,7 @@ The following events are configurable in user settings: | `packing_tagged` | You were assigned to a packing category in a trip | | `plugin_notification` | An installed plugin sent you a notification (only plugins granted the `notify:send` capability can do this) | -All user-facing events support all four channels (in-app, email, webhook, ntfy). A dash (—) in the matrix means that channel/event combination is not implemented. +All user-facing events support all five channels (in-app, email, webhook, ntfy, push). A dash in the matrix means that channel/event combination is not implemented. ### Admin-only events @@ -63,6 +64,8 @@ The following events are shown in the admin panel (Admin → Notifications) and | `version_available` | A new TREK version is available | in-app, email, webhook, ntfy | | `replica_failure` | A write to a storage replica failed (repeats within the hour are suppressed and counted) | in-app, email, webhook, ntfy | +Admin-only events are never sent as Web Push, so the admin matrix has no Push column. + ### In-app-only events The following event is fired automatically and can only be delivered in-app — it appears in the preferences matrix with an In-app toggle and a dash in every other column: @@ -89,6 +92,83 @@ TREK auto-detects the webhook destination and adjusts the payload format: Enter your ntfy **topic** and optionally a custom **server URL** (defaults to the server-wide ntfy server set by the admin) and an **access token** for private topics. The token is stored encrypted and displayed as `••••••••` after saving. Use the **Test** button to verify delivery. +## Web Push + +Web Push shows TREK notifications on your phone or computer the way other web apps do, even while TREK is closed. No +app store and no third-party account are involved: your TREK server hands each message to the push service your +browser uses (Google for Chrome and most Chromium browsers, Mozilla for Firefox, Apple for Safari and iOS, Microsoft +for Edge on Windows), encrypted so that only your browser can read it. + +### Turning it on + +1. **The admin** switches the **Web Push** channel on in **Admin → Notifications**, like email or ntfy. +2. **Each user, on each device**, opens **Settings → Notifications** and presses **Turn on for this device** in the + **Push notifications on this device** card. The browser asks for permission once. Every phone, tablet or computer + is registered on its own, so repeat this wherever you want notifications. **Turn off for this device**, or logging + out, removes only that device; after logging back in, turn it on there again. +3. The **Push** column of the matrix decides which events arrive. Every event starts switched on, and the column + applies to all of your devices at once. + +**Send test** in the same card sends a short test message to every device you turned push on for. + +### Requirements + +- TREK has to be opened over **HTTPS**. Browsers only offer push to secure pages (a plain `http://` address works on + `localhost` only); on any other `http://` address the card says that push needs HTTPS. +- On **iPhone and iPad** (iOS and iPadOS 16.4 or later) push only works in the Home Screen app: open TREK in Safari, + choose **Share → Add to Home Screen**, then open TREK from the new icon and turn push on there. +- If notifications are blocked for TREK in the browser or the system settings, allow them there first. +- The browser has to use one of the push services named above. TREK refuses a subscription to any other push service, + so turning push on fails in such a browser. + +### What arrives + +The same title and text as the email or ntfy message, in your language (a very long text is shortened); tapping it +opens the trip or page it is about. Chat messages are the one exception to "one notification each": a newer message in +the same trip replaces the older one and still alerts you, so a busy chat shows up as one notification that keeps +updating. Everything else (to-dos, bookings, invites, plugin notices) arrives as its own notification and never +replaces another one. Notifications you also receive in-app stay in the in-app notification center. + +A device that is switched off or offline receives what was sent in the last 24 hours once it is back online; the push +service drops anything older. + +A device the push service reports as gone (HTTP 404 or 410: browser uninstalled, site data cleared, subscription +expired) is removed at once, and turning push on again there registers it anew. A refusal (HTTP 403) is not taken as +proof: Apple also answers it when it rejects the server's signature (`BadJwtToken`), which is a server problem, not the +device's. TREK logs the push service's reason every time and removes the device only when a 403 is its third failed +send in a row or later. A successful send in between starts the count over, and so does registering the device again. +Other failures (a timeout, an error at the push service) are logged and counted, but never remove a device on their +own. + +Changing your password, resetting it, or an admin setting a new one for you removes every push device of your account +on the server, along with your other sessions. A device where push was on registers again on its own as soon as you are +signed in there: right away on the device you changed the password on, after the next sign-in everywhere else. + +### Server keys + +Push messages are signed with a VAPID key pair. Unless `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` provide one, TREK +generates a pair on first start and keeps it in the database, the private half encrypted with `ENCRYPTION_KEY`, so it +survives backups and restores and there is nothing to set up. A new pair is only ever generated while neither half is +stored and neither variable is set. Operators who want to bring their own pair can set the variables; see +[Environment-Variables](Environment-Variables#web-push). Every subscription belongs to the key it was made with: after +the key pair changes, each device has to subscribe again. TREK does that on its own the next time it is opened on the +device, where the browser allows it; elsewhere, turn push on again. + +Each message also names a contact for the push services: `VAPID_SUBJECT` when it is set, otherwise the instance's +`APP_URL` when it is an `https://` address other than `localhost`, otherwise the TREK project page +(`https://github.com/liketrek/TREK`). `ALLOWED_ORIGINS` never stands in for `APP_URL` here, and no email address is sent +unless you put one in `VAPID_SUBJECT`. + +TREK never overwrites a stored pair it cannot use. When the private key does not decrypt with the current +`ENCRYPTION_KEY` (after a restore under a different key, for example), push is off until TREK runs with the original +key again: the server log says why, the Push card and column disappear from **Settings → Notifications** and nothing +is sent, but every device stays registered and receives again afterwards without anyone doing anything. The same +holds when the two stored halves do not belong together or one of them is missing; only starting over with a new pair +fixes that (see [Environment-Variables](Environment-Variables#web-push)). Push is off the same way while +`VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` do not belong together or only one of them is set: TREK does not fall back +on a pair stored in the database, which would drop every device registered with your key, and every device receives +again once both variables hold your pair. See also [Encryption-Key-Rotation](Encryption-Key-Rotation). + ## In-app notification center ![Full-page Notifications inbox with All and Unread filter pills and an empty "No notifications — you're all caught up!" state](assets/NotificationsInbox.png) @@ -98,7 +178,7 @@ The bell icon in the navigation bar shows your unread notification count. Click - Mark individual items read or unread. - Mark all notifications read at once. - Delete individual notifications or clear all at once. -- Respond to **boolean notifications** (e.g. trip invites that offer Accept / Decline actions directly in the panel). +- Answer **boolean notifications**, which ask a yes-or-no question, with their two buttons directly in the panel. In-app notifications are pushed in real time via WebSocket so the badge and panel update without a page refresh. diff --git a/wiki/Offline-Mode-and-PWA.md b/wiki/Offline-Mode-and-PWA.md index 1566c3e31..170c211f2 100644 --- a/wiki/Offline-Mode-and-PWA.md +++ b/wiki/Offline-Mode-and-PWA.md @@ -20,6 +20,15 @@ Once installed, TREK launches in **standalone** mode (fullscreen, no browser UI) The installed app starts at the app root, so the **Start page** setting decides what you see when you tap the icon — the dashboard, or straight into your active trip on a tab of your choice. See [Display-Settings](Display-Settings). +### Push notifications + +Web Push shows TREK notifications on a phone or computer even while TREK is closed. It needs two things from the setup above: + +- **HTTPS everywhere.** Browsers only allow push on a secure origin. On plain `http://` (for example a LAN address) the card under **Settings → Notifications** says that push needs HTTPS. +- **The installed app on iPhone and iPad.** iOS and iPadOS 16.4 or later deliver push only to TREK added to the Home Screen. Install it as described above, open it from the Home Screen icon, and switch push on there. In a Safari tab the card asks you to add TREK to the Home Screen first. + +Push is switched on per device: each phone, tablet or browser you want to receive notifications on needs its own **Turn on for this device**. Logging out switches it off for that device. The admin has to enable the Web Push channel first, and which events arrive follows the **Push** column. See [Notifications](Notifications). + ## What works offline TREK uses Workbox service-worker caching plus an IndexedDB database (Dexie) for structured trip data. The following content is available offline after the first sync: @@ -94,3 +103,4 @@ The stats panel shows cached trips, pending changes, conflicts and failed change - [User-Settings](User-Settings) - [Display-Settings](Display-Settings) +- [Notifications](Notifications) diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md index 09aa0958f..2b60176c8 100644 --- a/wiki/Plugin-Cookbook.md +++ b/wiki/Plugin-Cookbook.md @@ -270,7 +270,7 @@ The recipient is **forced** by the host: `scope:'user'` can only reach the actin **Needs:** `hook:notification-channel` + `http:outbound:` (and a matching `egress`) The recipe above *produces* a notification. This one **delivers** one — your plugin becomes a -channel next to email / webhook / ntfy in the user's notification preferences. +channel next to email / webhook / ntfy / Web Push in the user's notification preferences. ```bash npx create-trek-plugin my-gotify --type integration --template notification-channel diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index cde45dfb5..0bc7fb1a5 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -269,7 +269,7 @@ parser would ignore one if you added it. | `ctx.collab` | `listNotes(tripId)` / `listPolls(tripId)` / `listMessages(tripId, before?)` — a trip's notes, polls (with options + voters) and chat (newest 100, oldest first; `before` = a message id to page back), membership-checked | `db:read:collab` (+ Collab addon) | | `ctx.collab` (write) | `createNote(tripId, {title, ...})` / `createPoll(tripId, {question, options})` / `votePoll(tripId, pollId, optionIndex)` / `createMessage(tripId, text, replyTo?)` — broadcasts `collab:*` | `db:write:collab` (+ `collab_edit`, Collab addon) | | `ctx.trips.addMember` / `.removeMember` | `addMember(tripId, userId)` — **grants trip access**; `removeMember(tripId, userId)` — revokes it (never the owner), so a directory-sync integration can reconcile departures too | `db:write:members` (+ `member_manage`) | -| `ctx.notify` | `send({title, body, link?, scope, targetId})` — bell inbox + email/ntfy fan-out; recipient forced to the acting user (`scope:'user'`) or a trip they belong to (`scope:'trip'`) | `notify:send` | +| `ctx.notify` | `send({title, body, link?, scope, targetId})`: bell inbox + email/ntfy/webhook/Web Push fan-out; recipient forced to the acting user (`scope:'user'`) or a trip they belong to (`scope:'trip'`) | `notify:send` | | `ctx.ai` | `complete(prompt, system?)` → `{ text }`; `extract(text, jsonSchema, prompt?)` → `{ results }` — the admin/user-configured provider; host holds the key; output is DATA (no auto-writes) | `ai:invoke` | | `ctx.oauth` | `getAccessToken()` → a **short-lived access token** for the acting user of a third-party service the host connected on their behalf (Settings → Plugins → Connect); `null` if not connected / userless. Host holds the refresh token + client secret | `oauth:client` | | `ctx.scheduler` | `at(whenMs, name, payload?)` / `in(ms, name, payload?)` / `every(ms, name, payload?)` / `cancel(name)` — **persistent, userless** timers that survive restarts and fire your `scheduled(input, ctx)` handler. `set` is an upsert by `name`; caps: ≤100 tasks, 8 KB payload, recurring interval ≥ 60 s, ≤ ~1 year out. Same risk class as `jobs` (no acting user → trip reads refused) | `jobs:run` | @@ -839,7 +839,7 @@ to design. ## Notification channels `hook:notification-channel` lets your plugin become a delivery channel alongside TREK's -built-in email / webhook / ntfy — Gotify, Pushover, Telegram, whatever takes a message. +built-in email / webhook / ntfy / Web Push: Gotify, Pushover, Telegram, whatever takes a message. Scaffold one with: diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index 3651c68e5..8729bfebc 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -50,7 +50,7 @@ ungranted capability is physically unreachable**, not just disallowed. See | `db:meta` | Store the plugin's **own** private key/value data on a trip/place/day/**reservation**/**accommodation** via `ctx.meta` | Namespaced per plugin (a plugin only sees its own rows). Reads need trip **access**; **writes** additionally need the entity's edit permission (`trip_edit`/`place_edit`/`day_edit`; reservations use `reservation_edit`, accommodations use `day_edit`). The natural home for an external-id mapping (AirTrail/calendar/booking-import sync) without forking the schema. Quotas: ≤256-char key, ≤64 KB value, ≤100 keys per entity. Purged on uninstall-with-delete-data. | | `ws:broadcast:trip` | Push a real-time event to a trip room via `ctx.ws.broadcastToTrip` | Event types are force-namespaced `plugin::` — a plugin can't forge a core event. | | `ws:broadcast:user` | Push a real-time event to a user's connections | Same namespacing. | -| `notify:send` | Send a persisted notification (bell inbox + email/ntfy/webhook fan-out) via `ctx.notify.send` | Host-mediated: the host owns recipient resolution, channel fan-out and per-user preferences. Recipients are **forced** to the acting user (`scope:'user'`, `targetId` = the acting user) or a trip they belong to (`scope:'trip'`); `scope:'admin'` is refused. The plugin supplies only a plain-text title/body (caps 200/1000) + an optional in-app `link` (must be a relative `/…` path — open-redirect-safe). No arbitrary recipient, no impersonation. Budgeted: 100 sends/day per plugin, resetting at UTC midnight. | +| `notify:send` | Send a persisted notification (bell inbox + email/ntfy/webhook/Web Push fan-out) via `ctx.notify.send` | Host-mediated: the host owns recipient resolution, channel fan-out and per-user preferences. Recipients are **forced** to the acting user (`scope:'user'`, `targetId` = the acting user) or a trip they belong to (`scope:'trip'`); `scope:'admin'` is refused. The plugin supplies only a plain-text title/body (caps 200/1000) + an optional in-app `link` (must be a relative `/…` path, which keeps it open-redirect-safe). No arbitrary recipient, no impersonation. Budgeted: 100 sends/day per plugin, resetting at UTC midnight. | | `oauth:client` | Become an OAuth *client* of a third-party service via `ctx.oauth.getAccessToken()` | Host-brokered: the host runs the whole flow (authorize→callback→token→refresh) with **PKCE + state** and **holds the tokens** — client secret + refresh token never leave the host. Provider config (`oauth_authorize_url`/`oauth_token_url`/`oauth_scopes` + the secrets `oauth_client_id`/`oauth_client_secret`) is the plugin's **admin-owned instance settings**; endpoints must be **https** (SSRF backstop). Each user connects under **Settings → Plugins**; tokens are per-user + encrypted at rest. The plugin only ever gets a **short-lived access token** for the acting user. | | `ai:invoke` | Run the admin/user-configured LLM via `ctx.ai.complete` / `ctx.ai.extract` | Host-mediated: the host holds the (encrypted) credential and runs the call under the acting user's resolved provider — the plugin never sees a key. Refused when no provider is configured; prompt/text capped at 20 000 chars. Output is **DATA** — `complete` returns `{ text }`, `extract` returns `{ results }` for your JSON schema — and is never auto-written, so prompt-injection can't reach a write without your own gated call. Budgeted: 200 calls/day per plugin, resetting at UTC midnight. | | `events:subscribe` | React to core activity via `events: [{ on, handler }]` on the plugin definition | The handler gets the **event name + tripId + a `{ entity, entityId }` hint**, plus a **whitelisted `snapshot` of the changed entity when the plugin also holds the family's `db:read:*` grant** (per-plugin filtered at deliver time — no grant, no fields). It runs with **no user** (like a job), so nothing beyond the snapshot is readable. The whitelist never carries user ids, private packing items (#858) or secrets; deletes/bulk/reorder events carry no snapshot, a non-entity id (e.g. a userId) never surfaces. Fire-and-forget on a short timeout; `plugin:*` re-broadcasts are never delivered back. | diff --git a/wiki/Plugins.md b/wiki/Plugins.md index c45ac853e..895aab2eb 100644 --- a/wiki/Plugins.md +++ b/wiki/Plugins.md @@ -25,13 +25,13 @@ A plugin declares one `type` in its manifest, which decides where it surfaces: ### Notification channels -An `integration` plugin can add a whole new **notification channel** — Gotify, Pushover, -Telegram, anything that takes a message — alongside TREK's built-in email, webhook and -ntfy. +An `integration` plugin can add a whole new **notification channel** (Gotify, Pushover, +Telegram, anything that takes a message) alongside TREK's built-in email, webhook, ntfy +and Web Push. Once you install and activate such a plugin, its channel is live — there is no separate -switch for it in **Admin → Notifications**, which only turns the built-in email, webhook -and ntfy channels on. It appears right away as a new column in every user's +switch for it in **Admin → Notifications**, which only turns the built-in email, webhook, +ntfy and Web Push channels on. It appears right away as a new column in every user's **Settings → Notifications** matrix, and each user supplies their own credentials on the plugin's own settings page and picks per-event what they want pushed — exactly like a built-in channel. diff --git a/wiki/User-Settings.md b/wiki/User-Settings.md index bee043fbb..a5a9d79cf 100644 --- a/wiki/User-Settings.md +++ b/wiki/User-Settings.md @@ -15,7 +15,7 @@ If your account requires MFA setup, TREK redirects you directly to the **Account | General | Start page and start tab, currency, language, temperature unit, distance unit, time format, week start, booking route labels, always show booking routes, map POI pills, blur booking codes, and optimize route from accommodation | Always | | Appearance | Color mode, color scheme / accent, readability (transparency, reduce motion, density, text size), and which widgets appear on your dashboard | Always | | Map | Map provider (Leaflet, Mapbox GL, or MapLibre GL), tile presets, map style and Mapbox token, 3D buildings, high-quality mode | Always | -| Notifications | Email, webhook, ntfy, and in-app notification preferences | Always | +| Notifications | Email, webhook, ntfy, push and in-app notification preferences, plus switching push on or off for this device (every device is switched on separately) | Always | | Integrations | Photo providers (Immich, Synology, etc.), the AirTrail connection, the Dawarich connection, your own AI parsing model, public API keys, and MCP OAuth clients / API tokens | Only when a photo provider (Immich or Synology Photos), MCP, AirTrail, AI Parsing or Dawarich is enabled | | Plugins | Per-user settings for installed plugins | Only when at least one plugin is installed | | Offline | Cached trips, pending changes, re-sync and clear cache | Always |