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=<hex>) 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.
This commit is contained in:
Arunavo Ray
2026-08-03 08:06:26 +05:30
parent 69198c047a
commit e7758badbf
8 changed files with 265 additions and 3 deletions
+58 -1
View File
@@ -93,7 +93,7 @@ export function NotificationSettings({
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise" | "gotify") =>
onValueChange={(value: "ntfy" | "apprise" | "gotify" | "webhook") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
@@ -104,6 +104,7 @@ export function NotificationSettings({
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
</SelectContent>
</Select>
</div>
@@ -394,6 +395,62 @@ export function NotificationSettings({
</div>
)}
{/* Webhook configuration */}
{notificationConfig.provider === "webhook" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Webhook Settings</h3>
<div className="space-y-2">
<Label htmlFor="webhook-url" className="text-sm">
Webhook URL <span className="text-destructive">*</span>
</Label>
<Input
id="webhook-url"
type="url"
placeholder="https://example.com/hooks/gitea-mirror"
value={notificationConfig.webhook?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: e.target.value,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Notifications are sent as a JSON POST with title, message, type, and timestamp fields
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-secret" className="text-sm">
Signing secret (optional)
</Label>
<Input
id="webhook-secret"
type="password"
placeholder="whsec_..."
value={notificationConfig.webhook?.secret || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: notificationConfig.webhook?.url || "",
secret: e.target.value,
},
})
}
/>
<p className="text-xs text-muted-foreground">
If set, requests include an X-Webhook-Signature header with an HMAC-SHA256 hex digest of the body (sha256=...)
</p>
</div>
</div>
)}
{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
+7 -1
View File
@@ -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<typeof notificationConfigSchema>;
+64
View File
@@ -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,
+18
View File
@@ -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";
+76
View File
@@ -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<typeof mock>;
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");
});
});
+21
View File
@@ -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<void> {
const body = JSON.stringify({
title: event.title,
message: event.message,
type: event.type,
timestamp: new Date().toISOString(),
});
const headers: Record<string, string> = {
"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()}`);
}
+14
View File
@@ -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({
+7 -1
View File
@@ -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 {}