Files
sencho/frontend/src/lib/appriseEndpoint.ts
T
Anso 83b3d932e5 feat: add Apprise as a fourth notification channel (#1644)
* feat: add Apprise as a fourth notification channel

Support keyed and stateless Apprise endpoints with secret-safe public DTOs, fail-closed malformed config, and mode-specific Settings UI. Docs and screenshots updated for four-channel Channels and routing.

* fix: harden Apprise secrets at rest and preserve-on-write saves

Encrypt Apprise endpoint and config with CryptoService so a downgrade cannot leak via SELECT *. Align channel and routing saves so blank destination fields omit config on same-mode URL edits, enforce keyed notify IDs, and keep secrets_redacted truthful.

* fix: harden Apprise route type changes and mixed-version config UI

Require a raw channel_url when switching notification-route types so ciphertext cannot strand under Discord/Slack/webhook. Default missing remote apprise status, replace Channels state on node switch, and exercise the production config-column migrator.

* fix: tolerate stub fleet configuration payloads without agents

Normalize remote Apprise agent status only when notifications.agents is present so successful Pilot/stub fetches stay online instead of throwing into the offline catch path.

* fix: correct TypeScript in configuration normalize tests

* fix: ignore stale Channels agent bodies after node switch

Compare the active node after response JSON parsing so a slow body
cannot overwrite the newly selected node's channel state.

* fix: isolate corrupt Apprise crypto and keep keyed Tags visible

Decrypt failures on one Apprise row no longer 500 agent/route lists or
suppress sibling channel dispatch. Treat public /notify/<redacted> as keyed
so Tags remain editable after reload.
2026-07-18 16:32:58 -04:00

38 lines
1.2 KiB
TypeScript

/** Pathname-based Apprise endpoint mode; mirrors backend classifyAppriseEndpoint. */
export const APPRISE_NOTIFY_KEY = /^[A-Za-z0-9_-]{1,128}$/;
function notifyKeyFromPath(path: string): string | null {
const match = path.match(/^\/notify\/([^/]+)$/);
return match ? match[1] : null;
}
export function classifyAppriseEndpoint(url: string): 'keyed' | 'stateless' | null {
try {
const path = new URL(url).pathname.replace(/\/$/, '');
const key = notifyKeyFromPath(path);
if (key !== null) {
// Public DTOs mask the notify key as <redacted>; URL() percent-encodes the brackets.
let decodedKey = key;
try {
decodedKey = decodeURIComponent(key);
} catch {
// Keep the raw path segment when it is not valid percent-encoding.
}
if (decodedKey === '<redacted>') return 'keyed';
return APPRISE_NOTIFY_KEY.test(key) ? 'keyed' : null;
}
if (path === '/notify') return 'stateless';
return null;
} catch {
return null;
}
}
export function isStatelessAppriseEndpoint(url: string): boolean {
return classifyAppriseEndpoint(url) === 'stateless';
}
export function isKeyedAppriseEndpoint(url: string): boolean {
return classifyAppriseEndpoint(url) === 'keyed';
}