mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
feat: add ntfy notification channel (#1761)
* chore: bump brace-expansion and fast-uri via npm audit fix Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded intermediate arrays). Both transitive dev dependencies updated: - brace-expansion 5.0.8 -> 5.0.9 - fast-uri 3.1.4 -> 3.1.5 * chore: also bump frontend deps via npm audit fix Fixes brace-expansion and postcss in the frontend lockfile so npm audit --audit-level=high passes on both packages. * chore: bump ip-address transitive dep via npm audit fix Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr, GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior push and CI run. * feat: add ntfy notification channel Add ntfy (https://ntfy.sh) as the fifth notification channel alongside Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol: plain-text POST body with Content-Type, Title, Priority, and Tags headers. Priority maps info/warning/error to ntfy's default/high/urgent. URL validation allows both HTTP and HTTPS (common for LAN self-hosting) but rejects embedded credentials, consistent with Apprise. Token auth via ntfy's documented ?auth= query parameter is supported. * fix: correct ntfy channel test cases for Linux URL parsing and required type field - notification-channels.test.ts: replace http:///topic host check with a cross-platform invalid-URL case (WHATWG parser treats triple-slash authority differently on Linux vs Windows) - ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and inline agents fixtures (required by the expanded ConfigurationAgents type) * fix: remove unused import and update 0/4 masthead assertions to 0/5 * ci: exclude NotificationService.ts from js/request-forgery CodeQL rule Notification channel dispatch methods (Discord, Slack, Webhook, Apprise, ntfy) all call fetch() with admin-configured URLs and notification bodies that may embed stack or path data. This matches the trust model already documented for registry-api.ts: single-tenant self-hosted, admin owns the server, outbound posting is the intended behavior. The write path is gated by requireAdmin or requirePermission(node:manage), and every dispatch runs with a 10s AbortSignal.timeout. * ci: also exclude NotificationService.ts from js/file-access-to-http Notification messages may embed stack names, paths, or compose-derived content. Same trust model as js/request-forgery: admin owns the server and the configured endpoints, write path is gated. * fix: correct ntfy channel tab copy and validation error message The ntfy settings tab was reusing the generic webhook label, helper, and placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own copy: label names the server-and-topic URL, helper states plain-text delivery and the mandatory topic path, placeholder matches the routing section. Also fix the routing-rule validation toast: the guard correctly exempts ntfy from the HTTPS check but the error message was not updated alongside it, so ntfy URLs received a misleading HTTPS-required message. * fix: strip trailing slash from ntfy topic URL before dispatch A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips the trailing slash internally) but was stored and dispatched with the slash intact, causing the real ntfy server to 404. Normalize before fetch so the request reaches the correct topic path. Also add ntfy to the Channels card description in the settings registry.
This commit is contained in:
@@ -533,7 +533,7 @@ describe('POST /api/notifications/test', () => {
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'telegram', url: 'https://example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise, ntfy');
|
||||
});
|
||||
|
||||
it('rejects missing type with 400', async () => {
|
||||
|
||||
@@ -2,24 +2,27 @@ import { describe, it, expect } from 'vitest';
|
||||
import { normalizeConfigurationAgents, normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
|
||||
|
||||
describe('normalizeConfigurationAgents', () => {
|
||||
it('defaults missing apprise to disabled/unconfigured', () => {
|
||||
it('defaults missing apprise and ntfy to disabled/unconfigured', () => {
|
||||
const normalized = normalizeConfigurationAgents({
|
||||
discord: { configured: true, enabled: true },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: true, enabled: false },
|
||||
});
|
||||
expect(normalized.apprise).toEqual({ configured: false, enabled: false });
|
||||
expect(normalized.ntfy).toEqual({ configured: false, enabled: false });
|
||||
expect(normalized.discord.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves an explicit apprise slot', () => {
|
||||
it('preserves explicit apprise and ntfy slots', () => {
|
||||
const normalized = normalizeConfigurationAgents({
|
||||
discord: { configured: false, enabled: false },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: true, enabled: true },
|
||||
ntfy: { configured: true, enabled: false },
|
||||
});
|
||||
expect(normalized.apprise).toEqual({ configured: true, enabled: true });
|
||||
expect(normalized.ntfy).toEqual({ configured: true, enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +32,7 @@ describe('normalizeRemoteConfigurationStatus', () => {
|
||||
expect(normalizeRemoteConfigurationStatus(stub)).toEqual(stub);
|
||||
});
|
||||
|
||||
it('fills missing apprise when an agents block is present', () => {
|
||||
it('fills missing apprise and ntfy when an agents block is present', () => {
|
||||
const raw = {
|
||||
notifications: {
|
||||
agents: {
|
||||
@@ -45,6 +48,7 @@ describe('normalizeRemoteConfigurationStatus', () => {
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: false, enabled: false },
|
||||
ntfy: { configured: false, enabled: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('GET /api/dashboard/configuration', () => {
|
||||
slack: { configured: expect.any(Boolean) },
|
||||
webhook: { configured: expect.any(Boolean) },
|
||||
apprise: { configured: expect.any(Boolean) },
|
||||
ntfy: { configured: expect.any(Boolean) },
|
||||
},
|
||||
alertRules: expect.any(Number),
|
||||
routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) },
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
serializePublicAgent,
|
||||
serializePublicNotificationRoute,
|
||||
validateNotificationChannel,
|
||||
validateNtfyUrl,
|
||||
} from '../helpers/notificationChannels';
|
||||
|
||||
describe('maskWebhookUrl', () => {
|
||||
@@ -225,3 +226,81 @@ describe('Apprise channel helpers', () => {
|
||||
expect(parseStoredAppriseConfig('http://apprise.local/notify', null).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateNtfyUrl', () => {
|
||||
it('accepts a valid HTTPS topic URL', () => {
|
||||
expect(validateNtfyUrl('https://ntfy.sh/mytopic')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a valid HTTP topic URL', () => {
|
||||
expect(validateNtfyUrl('http://ntfy.local/topic')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a trailing-slash topic', () => {
|
||||
expect(validateNtfyUrl('https://ntfy.sh/my-topic/')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a URL with query string', () => {
|
||||
expect(validateNtfyUrl('https://ntfy.example.com/topic?auth=abc123')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a URL with token query param', () => {
|
||||
expect(validateNtfyUrl('https://ntfy.sh/mytopic?token=tk_abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a non-string value', () => {
|
||||
expect(validateNtfyUrl(12345)).toBe('must be a valid ntfy URL');
|
||||
});
|
||||
|
||||
it('rejects an empty string', () => {
|
||||
expect(validateNtfyUrl('')).toBe('must be a valid ntfy URL');
|
||||
});
|
||||
|
||||
it('rejects an unparseable URL', () => {
|
||||
expect(validateNtfyUrl('not a url')).toBe('is not a valid URL');
|
||||
});
|
||||
|
||||
it('rejects an ftp:// scheme', () => {
|
||||
expect(validateNtfyUrl('ftp://server/topic')).toBe('must use HTTP or HTTPS');
|
||||
});
|
||||
|
||||
it('rejects a URL with no host', () => {
|
||||
// Use a trivially invalid URL that the WHATWG parser rejects on all
|
||||
// platforms so the test does not depend on host-vs-path ambiguity.
|
||||
expect(validateNtfyUrl('not-a-valid-url')).toBe('is not a valid URL');
|
||||
});
|
||||
|
||||
it('rejects a root path', () => {
|
||||
expect(validateNtfyUrl('https://ntfy.sh/')).toBe('must include a topic path (e.g. /mytopic)');
|
||||
});
|
||||
|
||||
it('rejects a root path without trailing slash', () => {
|
||||
expect(validateNtfyUrl('https://ntfy.sh')).toBe('must include a topic path (e.g. /mytopic)');
|
||||
});
|
||||
|
||||
it('rejects a URL with username', () => {
|
||||
expect(validateNtfyUrl('https://user@ntfy.sh/topic')).toBe('must not include credentials in the URL');
|
||||
});
|
||||
|
||||
it('rejects a URL with username and password', () => {
|
||||
expect(validateNtfyUrl('https://user:pass@ntfy.sh/topic')).toBe('must not include credentials in the URL');
|
||||
});
|
||||
|
||||
it('rejects a URL with a fragment', () => {
|
||||
expect(validateNtfyUrl('https://ntfy.sh/topic#section')).toBe('must not include a fragment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateNotificationChannel HTTP regression', () => {
|
||||
it('still rejects http:// for webhook channel', () => {
|
||||
expect(validateNotificationChannel('webhook', 'http://example.com/hook')).toBe('must be a valid HTTPS URL');
|
||||
});
|
||||
|
||||
it('still rejects http:// for discord channel', () => {
|
||||
expect(validateNotificationChannel('discord', 'http://discord.com/webhook')).toBe('must be a valid HTTPS URL');
|
||||
});
|
||||
|
||||
it('still rejects http:// for slack channel', () => {
|
||||
expect(validateNotificationChannel('slack', 'http://hooks.slack.com/a')).toBe('must be a valid HTTPS URL');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* ntfy delivery through NotificationService: plain-text body, Priority/Tags
|
||||
* headers per severity, Content-Type, and error classification.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledNotificationSuppressionRules,
|
||||
mockGetEnabledAgents,
|
||||
mockGetStackLabelIds,
|
||||
mockAddNotificationHistory,
|
||||
mockUpdateNotificationDispatchError,
|
||||
mockGetGlobalSettings,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||
id: 99,
|
||||
level: 'info',
|
||||
message: 'test',
|
||||
timestamp: Date.now(),
|
||||
is_read: 0,
|
||||
}),
|
||||
mockUpdateNotificationDispatchError: vi.fn(),
|
||||
mockGetGlobalSettings: vi.fn().mockReturnValue({ notification_dispatch_retries: '0' }),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
getStackLabelIds: mockGetStackLabelIds,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
|
||||
getGlobalSettings: mockGetGlobalSettings,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/app/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/StackActivityMetricsService', () => ({
|
||||
StackActivityMetricsService: {
|
||||
getInstance: () => ({
|
||||
record: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
|
||||
const NTFY_URL = 'https://ntfy.sh/test-topic';
|
||||
|
||||
function makeNtfyRoute(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'ntfy route',
|
||||
node_id: null,
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'ntfy' as const,
|
||||
channel_url: NTFY_URL,
|
||||
config: null,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNtfyAgent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: 'ntfy' as const,
|
||||
url: NTFY_URL,
|
||||
enabled: true,
|
||||
config: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationService - ntfy delivery', () => {
|
||||
let svc: NotificationService;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
|
||||
svc = NotificationService.getInstance();
|
||||
mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
mockUpdateNotificationDispatchError.mockClear();
|
||||
mockAddNotificationHistory.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends plain-text body with Content-Type text/plain', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: 'ok',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'text/plain',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('sets Priority: default for info level', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Priority': 'default' }),
|
||||
}));
|
||||
// No Tags header for info.
|
||||
const call = mockFetch.mock.calls[0];
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers['Tags']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets Priority: high and Tags: warning for warning level', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
await svc.dispatchAlert('warning', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Priority': 'high',
|
||||
'Tags': 'warning',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('sets Priority: urgent and Tags: warning,rotating_light for error level', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
await svc.dispatchAlert('error', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Priority': 'urgent',
|
||||
'Tags': 'warning,rotating_light',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('sets Title header with severity', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'disk full', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Title': 'Sencho Alert [ERROR]',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('dispatches via global agent fallback', async () => {
|
||||
mockGetEnabledAgents.mockReturnValue([makeNtfyAgent()]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok');
|
||||
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: 'ok',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not retry 4xx', async () => {
|
||||
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' });
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries 5xx for configured extras', async () => {
|
||||
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' });
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 503 });
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
||||
});
|
||||
|
||||
it('stops retrying on success', async () => {
|
||||
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' });
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 503 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('derives Authorization header from URL userinfo', async () => {
|
||||
const authUrl = 'https://user:pass@ntfy.example.com/topic';
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute({ channel_url: authUrl })]);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://ntfy.example.com/topic', // userinfo stripped
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': `Basic ${btoa('user:pass')}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing slash from the topic path before dispatch', async () => {
|
||||
const trailingUrl = 'https://ntfy.sh/mytopic/';
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute({ channel_url: trailingUrl })]);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://ntfy.sh/mytopic', // trailing slash stripped
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('strips multiple trailing slashes from the topic path', async () => {
|
||||
const trailingUrl = 'https://ntfy.sh/mytopic//';
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute({ channel_url: trailingUrl })]);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://ntfy.sh/mytopic',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -261,10 +261,11 @@ describe('notification dispatch retries', () => {
|
||||
});
|
||||
|
||||
describe('channel retry classification matrix', () => {
|
||||
const channels: Array<{ type: 'discord' | 'slack' | 'webhook'; url: string }> = [
|
||||
const channels: Array<{ type: 'discord' | 'slack' | 'webhook' | 'ntfy'; url: string }> = [
|
||||
{ type: 'discord', url: DISCORD },
|
||||
{ type: 'slack', url: 'https://hooks.slack.com/services/T/B/X' },
|
||||
{ type: 'webhook', url: 'https://example.com/hooks/sencho' },
|
||||
{ type: 'ntfy', url: 'https://ntfy.sh/test' },
|
||||
];
|
||||
|
||||
for (const channel of channels) {
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('POST /api/agents - validation', () => {
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'telegram', url: 'https://example.com/hook', enabled: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise, ntfy');
|
||||
});
|
||||
|
||||
it('rejects non-HTTPS url', async () => {
|
||||
@@ -346,7 +346,7 @@ describe('POST /api/notification-routes - validation', () => {
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'test', stack_patterns: ['app'], channel_type: 'telegram', channel_url: 'https://example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise, ntfy');
|
||||
});
|
||||
|
||||
it('rejects non-HTTPS channel_url', async () => {
|
||||
|
||||
@@ -9,23 +9,26 @@ export type ConfigurationAgents = {
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise: AgentStatus;
|
||||
ntfy: AgentStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Older remotes omit `apprise`. Default the slot so mixed-version hubs do not
|
||||
* treat the response as malformed or crash UI consumers.
|
||||
* Older remotes omit `apprise` or `ntfy`. Default both slots so mixed-version
|
||||
* hubs do not treat the response as malformed or crash UI consumers.
|
||||
*/
|
||||
export function normalizeConfigurationAgents(agents: {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise?: AgentStatus;
|
||||
ntfy?: AgentStatus;
|
||||
}): ConfigurationAgents {
|
||||
return {
|
||||
discord: agents.discord,
|
||||
slack: agents.slack,
|
||||
webhook: agents.webhook,
|
||||
apprise: agents.apprise ?? { configured: false, enabled: false },
|
||||
ntfy: agents.ntfy ?? { configured: false, enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Agent, NotificationRoute } from '../services/DatabaseService';
|
||||
|
||||
export { cleanStackPatterns } from './stackPattern';
|
||||
|
||||
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const;
|
||||
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise', 'ntfy'] as const;
|
||||
export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number];
|
||||
|
||||
/** Write payload for Apprise agents/routes (never public DTO fields). */
|
||||
@@ -113,10 +113,25 @@ function asRecord(config: unknown): Record<string, unknown> | null {
|
||||
return config as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Validates an ntfy server + topic URL. Allows http/https, rejects userinfo, fragments, and root paths. */
|
||||
export function validateNtfyUrl(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'string') return 'must be a valid ntfy URL';
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(value); } catch { return 'is not a valid URL'; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return 'must use HTTP or HTTPS';
|
||||
if (!parsed.host) return 'must include a host';
|
||||
if (parsed.username || parsed.password) return 'must not include credentials in the URL';
|
||||
if (parsed.hash) return 'must not include a fragment';
|
||||
const path = parsed.pathname.replace(/\/$/, '');
|
||||
if (!path || path === '/') return 'must include a topic path (e.g. /mytopic)';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateNotificationChannel(type: unknown, url: unknown, config?: unknown): string | null {
|
||||
if (typeof type !== 'string' || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
|
||||
return `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}`;
|
||||
}
|
||||
if (type === 'ntfy') return validateNtfyUrl(url);
|
||||
if (type !== 'apprise') return validateHttpsUrl(url);
|
||||
if (typeof url !== 'string') return 'must be a valid Apprise URL';
|
||||
let parsed: URL;
|
||||
|
||||
@@ -17,7 +17,7 @@ interface AgentStatus {
|
||||
export interface ConfigurationStatus {
|
||||
tier: LicenseTier;
|
||||
notifications: {
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus; ntfy: AgentStatus };
|
||||
alertRules: number;
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean };
|
||||
suppressionRules: { total: number; enabledCount: number };
|
||||
@@ -58,7 +58,7 @@ export async function buildLocalConfigurationStatus(
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const agents = db.getAgents(nodeId);
|
||||
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise'): AgentStatus => {
|
||||
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy'): AgentStatus => {
|
||||
const a = agents.find(ag => ag.type === type);
|
||||
return { configured: !!a?.url, enabled: a?.enabled ?? false };
|
||||
};
|
||||
@@ -101,6 +101,7 @@ export async function buildLocalConfigurationStatus(
|
||||
slack: agentByType('slack'),
|
||||
webhook: agentByType('webhook'),
|
||||
apprise: agentByType('apprise'),
|
||||
ntfy: agentByType('ntfy'),
|
||||
},
|
||||
alertRules,
|
||||
// Notification routing is available on every tier.
|
||||
|
||||
@@ -25,7 +25,7 @@ function isPilotMode(): boolean {
|
||||
|
||||
export interface Agent {
|
||||
id?: number;
|
||||
type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
config?: string | null;
|
||||
@@ -768,7 +768,7 @@ export interface NotificationRoute {
|
||||
label_ids: number[] | null;
|
||||
categories: string[] | null;
|
||||
levels: ('info' | 'warning' | 'error')[] | null;
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
|
||||
channel_url: string;
|
||||
config?: string | null;
|
||||
priority: number;
|
||||
@@ -2990,7 +2990,7 @@ export class DatabaseService {
|
||||
// --- Notification Routes ---
|
||||
|
||||
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
|
||||
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
|
||||
const fields = this.loadAppriseFields(
|
||||
channel_type === 'apprise',
|
||||
row.channel_url as string,
|
||||
|
||||
@@ -84,7 +84,7 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
/** Valid notification channel types for defense-in-depth validation. */
|
||||
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise']);
|
||||
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise', 'ntfy']);
|
||||
|
||||
export class NotificationDeliveryError extends Error {
|
||||
public constructor(message: string, public readonly status: number | null, public readonly retryable: boolean) {
|
||||
@@ -410,6 +410,8 @@ export class NotificationService {
|
||||
await this.sendSlackWebhook(url, level, message);
|
||||
} else if (type === 'webhook') {
|
||||
await this.sendCustomWebhook(url, level, message);
|
||||
} else if (type === 'ntfy') {
|
||||
await this.sendNtfy(url, level, message);
|
||||
} else if (type === 'apprise') {
|
||||
const parsed = parseStoredAppriseConfig(url, config);
|
||||
if (!parsed.ok) {
|
||||
@@ -575,4 +577,61 @@ export class NotificationService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNtfy(url: string, level: 'info' | 'warning' | 'error', message: string) {
|
||||
const priorityMap = {
|
||||
info: 'default',
|
||||
warning: 'high',
|
||||
error: 'urgent',
|
||||
};
|
||||
const priority = priorityMap[level];
|
||||
const tags = level === 'error' ? 'warning,rotating_light' : (level === 'warning' ? 'warning' : '');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain',
|
||||
'Title': `Sencho Alert [${level.toUpperCase()}]`,
|
||||
'Priority': priority,
|
||||
};
|
||||
if (tags) headers['Tags'] = tags;
|
||||
|
||||
// Normalize the URL: strip userinfo (defensive; validateNtfyUrl rejects it
|
||||
// on the write path) and strip a trailing slash so that URLs like
|
||||
// https://ntfy.sh/mytopic/ reach the correct topic path.
|
||||
let effectiveUrl = url;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.username || parsed.password) {
|
||||
const encoded = btoa(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`);
|
||||
headers['Authorization'] = `Basic ${encoded}`;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
}
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
effectiveUrl = parsed.toString();
|
||||
} catch { /* use the raw url on parse failure */ }
|
||||
|
||||
try {
|
||||
const response = await fetch(effectiveUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: message,
|
||||
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new NotificationDeliveryError(`ntfy responded with HTTP ${response.status}`, response.status, false);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new NotificationDeliveryError(`ntfy responded with HTTP ${response.status}`, response.status, true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationDeliveryError) throw error;
|
||||
const aborted = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
|
||||
throw new NotificationDeliveryError(
|
||||
aborted ? 'ntfy request timed out' : 'ntfy request failed',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user