mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-11 03:12:54 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()}`);
|
||||
}
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user