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 {}