From e7758badbf298bab4eaf0b7199b8d5150892687a Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Mon, 3 Aug 2026 08:06:26 +0530 Subject: [PATCH] feat: add generic webhook notification provider (#352) Adds Webhook alongside ntfy, Apprise and Gotify: posts a JSON payload (title, message, type, timestamp) to any URL, with an optional signing secret that adds an X-Webhook-Signature header (HMAC-SHA256 of the body, sha256=) so receivers can verify authenticity. Secret is encrypted at rest like the other provider tokens. Settings UI section, provider and service tests included. No database migration needed. --- .../config/NotificationSettings.tsx | 59 +++++++++++++- src/lib/db/schema.ts | 8 +- src/lib/notification-service.test.ts | 64 ++++++++++++++++ src/lib/notification-service.ts | 18 +++++ src/lib/providers/webhook.test.ts | 76 +++++++++++++++++++ src/lib/providers/webhook.ts | 21 +++++ src/pages/api/config/index.ts | 14 ++++ src/types/config.ts | 8 +- 8 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 src/lib/providers/webhook.test.ts create mode 100644 src/lib/providers/webhook.ts diff --git a/src/components/config/NotificationSettings.tsx b/src/components/config/NotificationSettings.tsx index 7754322..7300529 100644 --- a/src/components/config/NotificationSettings.tsx +++ b/src/components/config/NotificationSettings.tsx @@ -93,7 +93,7 @@ export function NotificationSettings({ @@ -394,6 +395,62 @@ export function NotificationSettings({ )} + {/* Webhook configuration */} + {notificationConfig.provider === "webhook" && ( +
+

Webhook Settings

+ +
+ + + onNotificationChange({ + ...notificationConfig, + webhook: { + ...notificationConfig.webhook, + url: e.target.value, + }, + }) + } + /> +

+ Notifications are sent as a JSON POST with title, message, type, and timestamp fields +

+
+ +
+ + + onNotificationChange({ + ...notificationConfig, + webhook: { + ...notificationConfig.webhook, + url: notificationConfig.webhook?.url || "", + secret: e.target.value, + }, + }) + } + /> +

+ If set, requests include an X-Webhook-Signature header with an HMAC-SHA256 hex digest of the body (sha256=...) +

+
+
+ )} + {/* Event toggles */}

Notification Events

diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 1f21dcf..4bfd9cd 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -144,15 +144,21 @@ export const gotifyConfigSchema = z.object({ priority: z.number().int().min(0).max(10).default(5), }); +export const webhookConfigSchema = z.object({ + url: z.string().default(""), + secret: z.string().optional(), +}); + export const notificationConfigSchema = z.object({ enabled: z.boolean().default(false), - provider: z.enum(["ntfy", "apprise", "gotify"]).default("ntfy"), + provider: z.enum(["ntfy", "apprise", "gotify", "webhook"]).default("ntfy"), notifyOnSyncError: z.boolean().default(true), notifyOnSyncSuccess: z.boolean().default(false), notifyOnNewRepo: z.boolean().default(false), ntfy: ntfyConfigSchema.optional(), apprise: appriseConfigSchema.optional(), gotify: gotifyConfigSchema.optional(), + webhook: webhookConfigSchema.optional(), }); export type NotificationConfig = z.infer; diff --git a/src/lib/notification-service.test.ts b/src/lib/notification-service.test.ts index c2d2849..07c8e7b 100644 --- a/src/lib/notification-service.test.ts +++ b/src/lib/notification-service.test.ts @@ -97,6 +97,32 @@ describe("sendNotification", () => { expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token"); }); + test("sends webhook notification when provider is webhook", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "webhook", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + webhook: { + url: "https://example.com/hooks/gitea-mirror", + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe("https://example.com/hooks/gitea-mirror"); + const body = JSON.parse(opts.body); + expect(body.title).toBe("Test"); + expect(body.type).toBe("sync_success"); + }); + test("does not throw when fetch fails", async () => { mockFetch = mock(() => Promise.reject(new Error("Network error"))); globalThis.fetch = mockFetch as any; @@ -189,9 +215,47 @@ describe("sendNotification", () => { expect(mockFetch).not.toHaveBeenCalled(); }); + + test("skips notification when webhook URL is missing", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "webhook", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + webhook: { + url: "", + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe("testNotification", () => { + test("returns error when webhook URL is missing", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "webhook", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + webhook: { + url: "", + }, + }; + + const result = await testNotification(config); + expect(result.success).toBe(false); + expect(result.error).toContain("Webhook URL"); + }); + test("returns success when notification is sent", async () => { const config: NotificationConfig = { enabled: true, diff --git a/src/lib/notification-service.ts b/src/lib/notification-service.ts index 0e68039..8423e92 100644 --- a/src/lib/notification-service.ts +++ b/src/lib/notification-service.ts @@ -3,6 +3,7 @@ import type { NotificationEvent } from "./providers/ntfy"; import { sendNtfyNotification } from "./providers/ntfy"; import { sendAppriseNotification } from "./providers/apprise"; import { sendGotifyNotification } from "./providers/gotify"; +import { sendWebhookNotification } from "./providers/webhook"; import { db, configs } from "@/lib/db"; import { eq, sql } from "drizzle-orm"; import { decrypt } from "@/lib/utils/encryption"; @@ -59,6 +60,12 @@ export async function sendNotification( return; } await sendGotifyNotification(config.gotify, event); + } else if (config.provider === "webhook") { + if (!config.webhook?.url) { + console.warn("[NotificationService] Webhook URL is not configured, skipping notification"); + return; + } + await sendWebhookNotification(config.webhook, event); } } catch (error) { console.error("[NotificationService] Failed to send notification:", error); @@ -95,6 +102,11 @@ export async function testNotification( return { success: false, error: "Gotify URL and token are required" }; } await sendGotifyNotification(notificationConfig.gotify, event); + } else if (notificationConfig.provider === "webhook") { + if (!notificationConfig.webhook?.url) { + return { success: false, error: "Webhook URL is required" }; + } + await sendWebhookNotification(notificationConfig.webhook, event); } else { return { success: false, error: `Unknown provider: ${notificationConfig.provider}` }; } @@ -184,6 +196,12 @@ export async function triggerJobNotification({ token: decrypt(decryptedConfig.gotify.token), }; } + if (decryptedConfig.provider === "webhook" && decryptedConfig.webhook?.secret) { + decryptedConfig.webhook = { + ...decryptedConfig.webhook, + secret: decrypt(decryptedConfig.webhook.secret), + }; + } // Build event const repoLabel = repositoryName || organizationName || "Unknown"; diff --git a/src/lib/providers/webhook.test.ts b/src/lib/providers/webhook.test.ts new file mode 100644 index 0000000..a717d30 --- /dev/null +++ b/src/lib/providers/webhook.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test"; +import { createHmac } from "node:crypto"; +import { sendWebhookNotification } from "./webhook"; +import type { NotificationEvent } from "./ntfy"; +import type { WebhookConfig } from "@/types/config"; + +describe("sendWebhookNotification", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = mock(() => + Promise.resolve(new Response("ok", { status: 200 })) + ); + globalThis.fetch = mockFetch as any; + }); + + const baseConfig: WebhookConfig = { + url: "https://example.com/hooks/gitea-mirror", + }; + + const baseEvent: NotificationEvent = { + title: "Test Notification", + message: "This is a test", + type: "sync_success", + }; + + test("posts to the configured URL", async () => { + await sendWebhookNotification(baseConfig, baseEvent); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe("https://example.com/hooks/gitea-mirror"); + expect(opts.method).toBe("POST"); + expect(opts.headers["Content-Type"]).toBe("application/json"); + }); + + test("sends title, message, type, and timestamp in JSON body", async () => { + await sendWebhookNotification(baseConfig, baseEvent); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.title).toBe("Test Notification"); + expect(body.message).toBe("This is a test"); + expect(body.type).toBe("sync_success"); + expect(new Date(body.timestamp).getTime()).not.toBeNaN(); + }); + + test("omits signature header when no secret is configured", async () => { + await sendWebhookNotification(baseConfig, baseEvent); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers["X-Webhook-Signature"]).toBeUndefined(); + }); + + test("signs the body with HMAC-SHA256 when a secret is configured", async () => { + await sendWebhookNotification( + { ...baseConfig, secret: "my-secret" }, + baseEvent + ); + + const [, opts] = mockFetch.mock.calls[0]; + const expected = createHmac("sha256", "my-secret").update(opts.body).digest("hex"); + expect(opts.headers["X-Webhook-Signature"]).toBe(`sha256=${expected}`); + }); + + test("throws on non-200 response", async () => { + mockFetch = mock(() => + Promise.resolve(new Response("unauthorized", { status: 401 })) + ); + globalThis.fetch = mockFetch as any; + + expect( + sendWebhookNotification(baseConfig, baseEvent) + ).rejects.toThrow("Webhook error: 401"); + }); +}); diff --git a/src/lib/providers/webhook.ts b/src/lib/providers/webhook.ts new file mode 100644 index 0000000..036ff5a --- /dev/null +++ b/src/lib/providers/webhook.ts @@ -0,0 +1,21 @@ +import { createHmac } from "node:crypto"; +import type { WebhookConfig } from "@/types/config"; +import type { NotificationEvent } from "./ntfy"; + +export async function sendWebhookNotification(config: WebhookConfig, event: NotificationEvent): Promise { + const body = JSON.stringify({ + title: event.title, + message: event.message, + type: event.type, + timestamp: new Date().toISOString(), + }); + const headers: Record = { + "Content-Type": "application/json", + }; + if (config.secret) { + const signature = createHmac("sha256", config.secret).update(body).digest("hex"); + headers["X-Webhook-Signature"] = `sha256=${signature}`; + } + const resp = await fetch(config.url, { method: "POST", body, headers }); + if (!resp.ok) throw new Error(`Webhook error: ${resp.status} ${await resp.text()}`); +} diff --git a/src/pages/api/config/index.ts b/src/pages/api/config/index.ts index fb49f50..b6661b4 100644 --- a/src/pages/api/config/index.ts +++ b/src/pages/api/config/index.ts @@ -179,6 +179,13 @@ export const POST: APIRoute = async ({ request, locals }) => { token: encrypt(processedNotificationConfig.gotify.token), }; } + // Encrypt webhook secret if present + if (processedNotificationConfig.webhook?.secret) { + processedNotificationConfig.webhook = { + ...processedNotificationConfig.webhook, + secret: encrypt(processedNotificationConfig.webhook.secret), + }; + } } if (existingConfig) { @@ -368,6 +375,13 @@ export const GET: APIRoute = async ({ request, locals }) => { notificationConfig.gotify = { ...notificationConfig.gotify, token: "" }; } } + if (notificationConfig.webhook?.secret) { + try { + notificationConfig.webhook = { ...notificationConfig.webhook, secret: decrypt(notificationConfig.webhook.secret) }; + } catch { + notificationConfig.webhook = { ...notificationConfig.webhook, secret: "" }; + } + } } return new Response(JSON.stringify({ diff --git a/src/types/config.ts b/src/types/config.ts index b07a6d9..7dafac3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -125,15 +125,21 @@ export interface GotifyConfig { priority: number; } +export interface WebhookConfig { + url: string; + secret?: string; +} + export interface NotificationConfig { enabled: boolean; - provider: "ntfy" | "apprise" | "gotify"; + provider: "ntfy" | "apprise" | "gotify" | "webhook"; notifyOnSyncError: boolean; notifyOnSyncSuccess: boolean; notifyOnNewRepo: boolean; ntfy?: NtfyConfig; apprise?: AppriseConfig; gotify?: GotifyConfig; + webhook?: WebhookConfig; } export interface Config extends ConfigType {}