mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
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.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyAppriseEndpoint,
|
||||
isKeyedAppriseEndpoint,
|
||||
isStatelessAppriseEndpoint,
|
||||
} from '../appriseEndpoint';
|
||||
|
||||
describe('classifyAppriseEndpoint', () => {
|
||||
it('classifies keyed and stateless endpoints', () => {
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/notify/my-key')).toBe('keyed');
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/notify')).toBe('stateless');
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/other')).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a public redacted notify key as keyed so Tags remain visible', () => {
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/notify/<redacted>')).toBe('keyed');
|
||||
expect(isKeyedAppriseEndpoint('http://apprise.local/notify/<redacted>')).toBe(true);
|
||||
expect(isStatelessAppriseEndpoint('http://apprise.local/notify/<redacted>')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/** 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';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Wire shape for a single notification agent slot on /dashboard/configuration. */
|
||||
export interface AgentStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Agents block as returned by current hubs (four channels). */
|
||||
export type ConfigurationAgents = {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise: AgentStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Older remotes omit `apprise`. Treat a missing slot as unconfigured/disabled so
|
||||
* upgraded hubs do not throw when reading mixed-version fleet/dashboard payloads.
|
||||
*/
|
||||
export function normalizeConfigurationAgents(agents: {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise?: AgentStatus;
|
||||
}): ConfigurationAgents {
|
||||
return {
|
||||
discord: agents.discord,
|
||||
slack: agents.slack,
|
||||
webhook: agents.webhook,
|
||||
apprise: agents.apprise ?? { configured: false, enabled: false },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user