mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 08:31:29 +00:00
feat: add gitea/forgejo support
This commit is contained in:
parent
ace036c209
commit
aec0d1a257
43 changed files with 683 additions and 130 deletions
|
|
@ -7,8 +7,9 @@ import {
|
|||
adminCookie,
|
||||
clearAdminCookie,
|
||||
} from "../web/session";
|
||||
import { groupAcceptsProvider } from "../web/groups";
|
||||
import { loadRoutes, saveRoutes, loadConfig } from "../config";
|
||||
import type { Env, Route } from "../types";
|
||||
import type { Env, Route, Group } from "../types";
|
||||
|
||||
function createMockKV(): KVNamespace {
|
||||
const store = new Map<string, { value: string; expiration?: number }>();
|
||||
|
|
@ -111,6 +112,26 @@ describe("admin-session", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("groupAcceptsProvider", () => {
|
||||
const base: Group = { id: "g", name: "G", adminIds: [] };
|
||||
|
||||
it("accepts every provider when none are configured", () => {
|
||||
expect(groupAcceptsProvider(base, "github")).toBe(true);
|
||||
expect(groupAcceptsProvider(base, "gitea")).toBe(true);
|
||||
expect(groupAcceptsProvider(base, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("restricts to the configured providers", () => {
|
||||
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, "gitea")).toBe(true);
|
||||
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, "github")).toBe(false);
|
||||
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches case-insensitively and trims whitespace", () => {
|
||||
expect(groupAcceptsProvider({ ...base, providers: [" Gitea "] }, "gitea")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config routes persistence", () => {
|
||||
it("saves and loads routes from KV", async () => {
|
||||
const kv = createMockKV();
|
||||
|
|
|
|||
|
|
@ -215,4 +215,48 @@ describe("dispatchEvent fallback routing", () => {
|
|||
expect(parsed.content).toBe("<@&111> <@&222>");
|
||||
expect(parsed.embeds?.[0]).toBeDefined();
|
||||
});
|
||||
|
||||
it("filters events by the group's source provider", async () => {
|
||||
const sent: string[] = [];
|
||||
mockFetch((url) => {
|
||||
sent.push(url);
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
const kv = createMockKV();
|
||||
await kv.put(
|
||||
"config:groups",
|
||||
JSON.stringify([
|
||||
{ id: "gh", name: "GH", adminIds: [], providers: ["github"] },
|
||||
{ id: "gitea", name: "Gitea", adminIds: [], providers: ["gitea"] },
|
||||
]),
|
||||
);
|
||||
const env = createEnv({ KV: kv, DB: createMockDB() });
|
||||
const routes: Route[] = [
|
||||
{
|
||||
id: "gh-push",
|
||||
name: "GH",
|
||||
enabled: true,
|
||||
groupId: "gh",
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
{
|
||||
id: "gitea-push",
|
||||
name: "Gitea",
|
||||
enabled: true,
|
||||
groupId: "gitea",
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
targets: [{ channelId: "222" }],
|
||||
},
|
||||
];
|
||||
|
||||
await dispatchEvent(
|
||||
{ ...baseConfig, routes },
|
||||
{ event: "push", payload: {}, provider: "gitea" },
|
||||
env,
|
||||
);
|
||||
|
||||
expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1);
|
||||
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
142
src/__tests__/providers.test.ts
Normal file
142
src/__tests__/providers.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { createHmac } from "crypto";
|
||||
import { detectProvider } from "../providers";
|
||||
import { verifyGiteaSignature } from "../providers/gitea/verify";
|
||||
import { parseGiteaEvent } from "../providers/gitea/parse";
|
||||
import { formatEvent } from "../formatters";
|
||||
import type { Route } from "../types";
|
||||
|
||||
function giteaSign(body: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(body).digest("hex");
|
||||
}
|
||||
|
||||
describe("provider detection", () => {
|
||||
it("detects github by X-GitHub-Event header", () => {
|
||||
const p = detectProvider({ "x-github-event": "push" });
|
||||
expect(p?.id).toBe("github");
|
||||
});
|
||||
|
||||
it("detects gitea by X-Gitea-Event header", () => {
|
||||
const p = detectProvider({ "x-gitea-event": "push" });
|
||||
expect(p?.id).toBe("gitea");
|
||||
});
|
||||
|
||||
it("prefers gitea when both gitea and github headers are present", () => {
|
||||
// Gitea webhooks also send GitHub-compatible headers (X-GitHub-Event,
|
||||
// X-Hub-Signature-256, X-Gogs-*), so detection must not misclassify them.
|
||||
const p = detectProvider({
|
||||
"x-gitea-event": "push",
|
||||
"x-github-event": "push",
|
||||
"x-gitea-signature": "abc",
|
||||
"x-hub-signature-256": "sha256=abc",
|
||||
});
|
||||
expect(p?.id).toBe("gitea");
|
||||
});
|
||||
|
||||
it("returns null for unknown providers", () => {
|
||||
expect(detectProvider({})).toBeNull();
|
||||
expect(detectProvider({ "x-gitlab-event": "Push Hook" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gitea signature", () => {
|
||||
const secret = "gitea-secret";
|
||||
|
||||
it("accepts a valid hex HMAC-SHA256 signature (no sha256= prefix)", async () => {
|
||||
const body = '{"ref":"refs/heads/main"}';
|
||||
const sig = giteaSign(body, secret);
|
||||
expect(await verifyGiteaSignature(body, sig, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid signature", async () => {
|
||||
expect(await verifyGiteaSignature("body", "deadbeef", secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when signature or secret is missing", async () => {
|
||||
expect(await verifyGiteaSignature("body", undefined, secret)).toBe(false);
|
||||
expect(await verifyGiteaSignature("body", "abc", undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gitea event parsing", () => {
|
||||
it("parses a push event and sets the provider", () => {
|
||||
const event = parseGiteaEvent(
|
||||
{ "x-gitea-event": "push", "x-gitea-delivery": "d-1" },
|
||||
JSON.stringify({
|
||||
ref: "refs/heads/main",
|
||||
compare_url: "https://git.example.com/org/repo/compare/abc...def",
|
||||
pusher: { login: "octo", html_url: "https://git.example.com/octo" },
|
||||
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
|
||||
}),
|
||||
);
|
||||
expect(event).not.toBeNull();
|
||||
expect(event!.provider).toBe("gitea");
|
||||
expect(event!.event).toBe("push");
|
||||
expect(event!.deliveryId).toBe("d-1");
|
||||
expect(event!.payload.compare).toBe("https://git.example.com/org/repo/compare/abc...def");
|
||||
expect((event!.payload.sender as { login?: string }).login).toBe("octo");
|
||||
});
|
||||
|
||||
it("maps pull_request_comment to pull_request_review_comment and pulls the PR", () => {
|
||||
const event = parseGiteaEvent(
|
||||
{ "x-gitea-event": "pull_request_comment" },
|
||||
JSON.stringify({
|
||||
action: "created",
|
||||
issue: { number: 7, title: "Add feature", html_url: "https://git.example.com/org/repo/pulls/7" },
|
||||
comment: { body: "looks good", line: 12, html_url: "https://git.example.com/org/repo/pulls/7#issuecomment-1" },
|
||||
repository: { full_name: "org/repo" },
|
||||
sender: { login: "octo" },
|
||||
}),
|
||||
);
|
||||
expect(event!.event).toBe("pull_request_review_comment");
|
||||
const pr = event!.payload.pull_request as { number?: number; title?: string };
|
||||
expect(pr.number).toBe(7);
|
||||
const comment = event!.payload.comment as { position?: number };
|
||||
expect(comment.position).toBe(12);
|
||||
});
|
||||
|
||||
it("copies top-level commit_id onto the comment for commit_comment events", () => {
|
||||
const event = parseGiteaEvent(
|
||||
{ "x-gitea-event": "commit_comment" },
|
||||
JSON.stringify({
|
||||
action: "created",
|
||||
commit_id: "abcd1234ef",
|
||||
comment: { body: "why?", html_url: "https://git.example.com/org/repo/commit/abcd1234ef#commitcomment-1" },
|
||||
repository: { full_name: "org/repo" },
|
||||
sender: { login: "octo" },
|
||||
}),
|
||||
);
|
||||
expect(event!.event).toBe("commit_comment");
|
||||
expect((event!.payload.comment as { commit_id?: string }).commit_id).toBe("abcd1234ef");
|
||||
});
|
||||
|
||||
it("returns null for missing event header or invalid JSON", () => {
|
||||
expect(parseGiteaEvent({}, "{}")).toBeNull();
|
||||
expect(parseGiteaEvent({ "x-gitea-event": "push" }, "not json")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a normalized gitea push with gitea commit links", () => {
|
||||
const event = parseGiteaEvent(
|
||||
{ "x-gitea-event": "push" },
|
||||
JSON.stringify({
|
||||
ref: "refs/heads/main",
|
||||
compare_url: "https://git.example.com/org/repo/compare/abc...def",
|
||||
pusher: { login: "octo", html_url: "https://git.example.com/octo" },
|
||||
commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }],
|
||||
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
|
||||
}),
|
||||
);
|
||||
const route: Route = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
enabled: true,
|
||||
filters: [],
|
||||
targets: [{ channelId: "111" }],
|
||||
};
|
||||
const msg = formatEvent(route, event!);
|
||||
expect(msg.title).toContain("org/repo");
|
||||
expect(msg.fields![0].value).toBe(
|
||||
"[`abcd123`](https://git.example.com/org/repo/commit/abcd1234ef) fix stuff",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { createHmac } from "crypto";
|
||||
import { verifySignature } from "../events/verify";
|
||||
import { parseEvent } from "../events/parse";
|
||||
import { verifySignature } from "../providers/github/verify";
|
||||
import { parseEvent } from "../providers/github/parse";
|
||||
import { matchRoute } from "../events/match";
|
||||
import type { Route, WebhookEvent } from "../types";
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { matchRoute, eventOwners } from "../events/match";
|
|||
import { log } from "../lib/log";
|
||||
import { loadTranslations, type Translations } from "../lib/i18n";
|
||||
import { recordSend } from "../lib/send-log";
|
||||
import { loadGroups, groupAcceptsOwners } from "../web/groups";
|
||||
import { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups";
|
||||
import { getDriver } from "../drivers";
|
||||
import type { SendResult } from "../drivers/types";
|
||||
|
||||
|
|
@ -24,7 +24,9 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
|||
const accepted = (route: Route): boolean => {
|
||||
if (!route.groupId) return true;
|
||||
const group = groupById.get(route.groupId);
|
||||
return !group || groupAcceptsOwners(group, owners);
|
||||
if (!group) return true;
|
||||
if (!groupAcceptsOwners(group, owners)) return false;
|
||||
return groupAcceptsProvider(group, event.provider);
|
||||
};
|
||||
const matched = config.routes.filter(
|
||||
(route) => !route.fallback && matchRoute(route, event) && accepted(route),
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
const keyCache = new Map<string, CryptoKey>();
|
||||
|
||||
async function getHmacKey(secret: string): Promise<CryptoKey> {
|
||||
const cached = keyCache.get(secret);
|
||||
if (cached) return cached;
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
keyCache.set(secret, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function verifySignature(
|
||||
payload: string,
|
||||
signature: string | undefined,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
if (!signature) return false;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await getHmacKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
||||
const expected = `sha256=${Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
|
||||
try {
|
||||
const a = encoder.encode(signature);
|
||||
const b = encoder.encode(expected);
|
||||
if (a.byteLength !== b.byteLength) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.byteLength; i++) {
|
||||
diff |= a[i]! ^ b[i]!;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
|
||||
import { emojiPrefix, type T, buildMessage } from "./helpers";
|
||||
import { emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
|
||||
|
||||
export function formatCheckSuite(
|
||||
payload: Record<string, unknown>,
|
||||
|
|
@ -24,6 +24,7 @@ export function formatCheckSuite(
|
|||
: suite.status === "in_progress"
|
||||
? "running"
|
||||
: (suite.conclusion ?? "pending");
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
const colorKey =
|
||||
|
|
@ -61,8 +62,8 @@ export function formatCheckSuite(
|
|||
fields.push({
|
||||
name: t("fields.commit"),
|
||||
value:
|
||||
repo && suite.head_sha
|
||||
? `[${suite.head_sha.slice(0, 7)}](https://github.com/${repo}/commit/${suite.head_sha})`
|
||||
baseUrl && suite.head_sha
|
||||
? `[${suite.head_sha.slice(0, 7)}](${baseUrl}/commit/${suite.head_sha})`
|
||||
: `\`${suite.head_sha.slice(0, 7)}\``,
|
||||
inline: true,
|
||||
});
|
||||
|
|
@ -77,9 +78,7 @@ export function formatCheckSuite(
|
|||
}),
|
||||
url:
|
||||
suite.html_url ??
|
||||
(repo && suite.head_sha
|
||||
? `https://github.com/${repo}/commit/${suite.head_sha}/checks`
|
||||
: undefined),
|
||||
(baseUrl && suite.head_sha ? `${baseUrl}/commit/${suite.head_sha}/checks` : undefined),
|
||||
color: GITHUB_COLORS[colorKey],
|
||||
fields,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,3 +23,14 @@ export function buildMessage(
|
|||
timestamp: partial.timestamp ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL of the forge repo (e.g. `https://github.com/owner/repo` or a Gitea
|
||||
* instance URL). Derived from `repository.html_url` in the payload so it works
|
||||
* for any provider; falls back to github.com for legacy payloads.
|
||||
*/
|
||||
export function repoBaseUrl(payload: Record<string, unknown>, repo?: string): string | undefined {
|
||||
const html = (payload.repository as { html_url?: string } | undefined)?.html_url;
|
||||
if (html) return html;
|
||||
return repo ? `https://github.com/${repo}` : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function formatEvent(
|
|||
const repo = (payload.repository as { full_name?: string })?.full_name;
|
||||
const sender = (payload.sender as { login?: string })?.login;
|
||||
const senderAvatar = (payload.sender as { avatar_url?: string })?.avatar_url;
|
||||
const senderUrl = (payload.sender as { html_url?: string })?.html_url;
|
||||
const repoUrl = (payload.repository as { html_url?: string })?.html_url;
|
||||
|
||||
const t: T = makeT(tr);
|
||||
|
|
@ -39,7 +40,7 @@ export function formatEvent(
|
|||
const author: NeutralAuthor = {
|
||||
name: sender ?? t("common.unknown"),
|
||||
iconUrl: senderAvatar,
|
||||
url: sender ? `https://github.com/${sender}` : undefined,
|
||||
url: senderUrl ?? (sender ? `https://github.com/${sender}` : undefined),
|
||||
};
|
||||
|
||||
switch (eventType) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||
import { GITHUB_COLORS } from "./colors";
|
||||
import { emojiPrefix, type T, buildMessage } from "./helpers";
|
||||
import { emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
|
||||
|
||||
export function formatPush(
|
||||
payload: Record<string, unknown>,
|
||||
|
|
@ -20,6 +20,7 @@ export function formatPush(
|
|||
}>;
|
||||
const count = commits.length;
|
||||
const compareUrl = payload.compare as string | undefined;
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
const forced = payload.forced as boolean | undefined;
|
||||
const created = payload.created as boolean | undefined;
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
|
|
@ -46,7 +47,7 @@ export function formatPush(
|
|||
const commitField = (c: (typeof commits)[number]): { name: string; value: string } => {
|
||||
const shortId = c.id?.slice(0, 7) ?? "???????";
|
||||
const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message");
|
||||
const url = repo && c.id ? `https://github.com/${repo}/commit/${c.id}` : null;
|
||||
const url = baseUrl && c.id ? `${baseUrl}/commit/${c.id}` : null;
|
||||
const hash = url ? `[\`${shortId}\`](${url})` : `\`${shortId}\``;
|
||||
return { name: `\u200b`, value: `${hash} ${msg}` };
|
||||
};
|
||||
|
|
|
|||
20
src/providers/gitea/index.ts
Normal file
20
src/providers/gitea/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Env, WebhookEvent } from "../../types";
|
||||
import type { Provider } from "../types";
|
||||
import { verifyGiteaSignature } from "./verify";
|
||||
import { parseGiteaEvent } from "./parse";
|
||||
|
||||
export const giteaProvider: Provider = {
|
||||
id: "gitea",
|
||||
|
||||
matches(headers) {
|
||||
return headers["x-gitea-event"] !== undefined;
|
||||
},
|
||||
|
||||
async verify(body, headers, env: Env) {
|
||||
return verifyGiteaSignature(body, headers["x-gitea-signature"], env.GITEA_WEBHOOK_SECRET);
|
||||
},
|
||||
|
||||
parse(body, headers): WebhookEvent | null {
|
||||
return parseGiteaEvent(headers, body);
|
||||
},
|
||||
};
|
||||
76
src/providers/gitea/parse.ts
Normal file
76
src/providers/gitea/parse.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { WebhookEvent } from "../../types";
|
||||
|
||||
/**
|
||||
* Gitea webhook events that map to a different internal event name. Everything
|
||||
* else already uses the same name as GitHub (push, issues, release, ...).
|
||||
*/
|
||||
const EVENT_MAP: Record<string, string> = {
|
||||
pull_request_comment: "pull_request_review_comment",
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a Gitea webhook payload so the shared GitHub-shaped formatters can
|
||||
* consume it. Gitea models its payloads on GitHub but with a few differences.
|
||||
*/
|
||||
function normalizePayload(
|
||||
event: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (event === "push") {
|
||||
// Gitea sends `compare_url` (GitHub sends `compare`).
|
||||
if (payload.compare_url && payload.compare === undefined) {
|
||||
payload.compare = payload.compare_url;
|
||||
}
|
||||
// Gitea sends `pusher` (and `sender`); keep a `sender` for the formatters.
|
||||
if (!payload.sender && payload.pusher) {
|
||||
payload.sender = payload.pusher;
|
||||
}
|
||||
}
|
||||
|
||||
if (event === "pull_request_comment") {
|
||||
// GitHub names this event pull_request_review_comment and always includes
|
||||
// a top-level `pull_request`. Gitea may only carry the PR-as-issue.
|
||||
if (!payload.pull_request && payload.issue) {
|
||||
payload.pull_request = payload.issue;
|
||||
}
|
||||
// GitHub uses `comment.position` for the line number, Gitea uses `line`.
|
||||
const comment = payload.comment as { line?: number; position?: number } | undefined;
|
||||
if (comment && comment.position === undefined && comment.line !== undefined) {
|
||||
comment.position = comment.line;
|
||||
}
|
||||
}
|
||||
|
||||
if (event === "commit_comment") {
|
||||
// Gitea puts the commit id at the top level, GitHub on the comment object.
|
||||
const comment = payload.comment as { commit_id?: string } | undefined;
|
||||
if (comment && !comment.commit_id && typeof payload.commit_id === "string") {
|
||||
comment.commit_id = payload.commit_id;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function parseGiteaEvent(
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
): WebhookEvent | null {
|
||||
const event = headers["x-gitea-event"];
|
||||
const signature = headers["x-gitea-signature"];
|
||||
const deliveryId = headers["x-gitea-delivery"];
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
try {
|
||||
const payload = normalizePayload(event, JSON.parse(body));
|
||||
return {
|
||||
provider: "gitea",
|
||||
event: EVENT_MAP[event] ?? event,
|
||||
payload,
|
||||
signature,
|
||||
deliveryId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
15
src/providers/gitea/verify.ts
Normal file
15
src/providers/gitea/verify.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { hmacSha256Hex, timingSafeEqual } from "../hmac";
|
||||
|
||||
/**
|
||||
* Gitea signs webhooks with the HMAC-SHA256 hex digest of the raw body in the
|
||||
* `X-Gitea-Signature` header (no `sha256=` prefix, unlike GitHub).
|
||||
*/
|
||||
export async function verifyGiteaSignature(
|
||||
payload: string,
|
||||
signature: string | undefined,
|
||||
secret: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!signature || !secret) return false;
|
||||
const expected = await hmacSha256Hex(secret, payload);
|
||||
return timingSafeEqual(signature, expected);
|
||||
}
|
||||
20
src/providers/github/index.ts
Normal file
20
src/providers/github/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Env, WebhookEvent } from "../../types";
|
||||
import type { Provider } from "../types";
|
||||
import { verifySignature } from "./verify";
|
||||
import { parseEvent } from "./parse";
|
||||
|
||||
export const githubProvider: Provider = {
|
||||
id: "github",
|
||||
|
||||
matches(headers) {
|
||||
return headers["x-github-event"] !== undefined;
|
||||
},
|
||||
|
||||
async verify(body, headers, env: Env) {
|
||||
return verifySignature(body, headers["x-hub-signature-256"], env.GITHUB_WEBHOOK_SECRET);
|
||||
},
|
||||
|
||||
parse(body, headers): WebhookEvent | null {
|
||||
return parseEvent(headers, body);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { WebhookEvent } from "../types";
|
||||
import type { WebhookEvent } from "../../types";
|
||||
|
||||
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
|
||||
const event = headers["x-github-event"];
|
||||
|
|
@ -9,7 +9,7 @@ export function parseEvent(headers: Record<string, string>, body: string): Webho
|
|||
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
return { event, payload, signature, deliveryId };
|
||||
return { provider: "github", event, payload, signature, deliveryId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
11
src/providers/github/verify.ts
Normal file
11
src/providers/github/verify.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { hmacSha256Hex, timingSafeEqual } from "../hmac";
|
||||
|
||||
export async function verifySignature(
|
||||
payload: string,
|
||||
signature: string | undefined,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
if (!signature || !secret) return false;
|
||||
const expected = `sha256=${await hmacSha256Hex(secret, payload)}`;
|
||||
return timingSafeEqual(signature, expected);
|
||||
}
|
||||
36
src/providers/hmac.ts
Normal file
36
src/providers/hmac.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const keyCache = new Map<string, CryptoKey>();
|
||||
|
||||
async function getHmacKey(secret: string): Promise<CryptoKey> {
|
||||
const cached = keyCache.get(secret);
|
||||
if (cached) return cached;
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
keyCache.set(secret, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function hmacSha256Hex(secret: string, payload: string): Promise<string> {
|
||||
const key = await getHmacKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Constant-time string comparison. */
|
||||
export function timingSafeEqual(a: string, b: string): boolean {
|
||||
const encoder = new TextEncoder();
|
||||
const x = encoder.encode(a);
|
||||
const y = encoder.encode(b);
|
||||
if (x.byteLength !== y.byteLength) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < x.byteLength; i++) {
|
||||
diff |= x[i]! ^ y[i]!;
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
22
src/providers/index.ts
Normal file
22
src/providers/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Provider } from "./types";
|
||||
import { githubProvider } from "./github";
|
||||
import { giteaProvider } from "./gitea";
|
||||
|
||||
export type { Provider } from "./types";
|
||||
export { verifySignature } from "./github/verify";
|
||||
|
||||
/**
|
||||
* Detection order matters: Gitea webhooks also send GitHub-compatible headers
|
||||
* (`X-GitHub-Event`, `X-Hub-Signature-256`, ...), so a Gitea request would
|
||||
* match the GitHub provider too. Check Gitea first — real GitHub requests
|
||||
* never send `X-Gitea-Event`.
|
||||
*/
|
||||
const providers: Provider[] = [giteaProvider, githubProvider];
|
||||
|
||||
/**
|
||||
* Pick the webhook provider for a request based on its headers (e.g.
|
||||
* `X-GitHub-Event` / `X-Gitea-Event`). Returns null when no provider matches.
|
||||
*/
|
||||
export function detectProvider(headers: Record<string, string>): Provider | null {
|
||||
return providers.find((p) => p.matches(headers)) ?? null;
|
||||
}
|
||||
23
src/providers/types.ts
Normal file
23
src/providers/types.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Env, WebhookEvent } from "../types";
|
||||
|
||||
/**
|
||||
* A forge webhook provider (GitHub, Gitea, GitLab, ...). Each provider owns
|
||||
* signature verification and payload parsing/normalization. The rest of the
|
||||
* pipeline (route matching, formatting, dispatch) only sees the normalized
|
||||
* {@link WebhookEvent} and never knows which forge produced it.
|
||||
*/
|
||||
export interface Provider {
|
||||
readonly id: "github" | "gitea" | "gitlab";
|
||||
/**
|
||||
* Whether the request headers belong to this provider (e.g. checks the
|
||||
* `X-Gitea-Event` header).
|
||||
*/
|
||||
matches(headers: Record<string, string>): boolean;
|
||||
/** Verify the webhook signature. Returns false when the secret is missing. */
|
||||
verify(body: string, headers: Record<string, string>, env: Env): Promise<boolean>;
|
||||
/**
|
||||
* Parse the body and normalize it into a {@link WebhookEvent} whose payload
|
||||
* is shaped like a GitHub event so the shared formatters can consume it.
|
||||
*/
|
||||
parse(body: string, headers: Record<string, string>): WebhookEvent | null;
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env } from "./types";
|
||||
import { verifySignature } from "./events/verify";
|
||||
import { parseEvent } from "./events/parse";
|
||||
import { detectProvider } from "./providers";
|
||||
import { dispatchEvent } from "./core/dispatch";
|
||||
import { handleInteractionRequest } from "./drivers/discord/interactions";
|
||||
import { handleTelegramWebhookRequest } from "./drivers/telegram/updates";
|
||||
|
|
@ -44,24 +43,26 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
|||
headers[key] = value;
|
||||
});
|
||||
|
||||
if (
|
||||
!(await verifySignature(body, headers["x-hub-signature-256"], c.env.GITHUB_WEBHOOK_SECRET))
|
||||
) {
|
||||
const provider = detectProvider(headers);
|
||||
if (!provider) {
|
||||
return c.json({ error: "Unknown webhook provider" }, 400);
|
||||
}
|
||||
|
||||
if (!(await provider.verify(body, headers, c.env))) {
|
||||
return c.json({ error: "Invalid signature" }, 401);
|
||||
}
|
||||
|
||||
const event = parseEvent(headers, body);
|
||||
const event = provider.parse(body, headers);
|
||||
if (!event) {
|
||||
return c.json({ error: "Invalid event" }, 400);
|
||||
}
|
||||
|
||||
const delivery = headers["x-github-delivery"];
|
||||
if (delivery) {
|
||||
const seen = await c.env.KV.get(`delivery:${delivery}`);
|
||||
if (event.deliveryId) {
|
||||
const seen = await c.env.KV.get(`delivery:${event.deliveryId}`);
|
||||
if (seen) {
|
||||
return c.json({ ok: true, duplicate: true });
|
||||
}
|
||||
await c.env.KV.put(`delivery:${delivery}`, "1", { expirationTtl: 300 });
|
||||
await c.env.KV.put(`delivery:${event.deliveryId}`, "1", { expirationTtl: 300 });
|
||||
}
|
||||
|
||||
const config = await loadConfig(c.env);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface Env {
|
||||
GITHUB_WEBHOOK_SECRET: string;
|
||||
GITEA_WEBHOOK_SECRET?: string;
|
||||
GITHUB_APP_ID?: string;
|
||||
GITHUB_PRIVATE_KEY?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
|
|
@ -85,6 +86,11 @@ export interface Group {
|
|||
* Only super admins may edit this field.
|
||||
*/
|
||||
owners?: string[];
|
||||
/**
|
||||
* Webhook providers (source platforms) allowed into this group's routes
|
||||
* (e.g. `["github"]`, `["gitea"]`). Empty/omitted = all providers.
|
||||
*/
|
||||
providers?: WebhookProvider[];
|
||||
/**
|
||||
* Whether to include emoji in messages sent through this group's routes.
|
||||
* Defaults to true when omitted.
|
||||
|
|
@ -98,11 +104,14 @@ export interface Filter {
|
|||
exclude?: boolean;
|
||||
}
|
||||
|
||||
export type WebhookProvider = "github" | "gitea" | "gitlab";
|
||||
|
||||
export interface WebhookEvent {
|
||||
event: string;
|
||||
payload: Record<string, unknown>;
|
||||
signature?: string;
|
||||
deliveryId?: string;
|
||||
provider?: WebhookProvider;
|
||||
}
|
||||
|
||||
export interface NeutralAuthor {
|
||||
|
|
|
|||
|
|
@ -209,6 +209,18 @@ function validateGroups(
|
|||
) {
|
||||
return { ok: false, error: `group "${g.id}".owners must be a list of strings` };
|
||||
}
|
||||
if (
|
||||
g.providers !== undefined &&
|
||||
(!Array.isArray(g.providers) ||
|
||||
!g.providers.every(
|
||||
(p) => typeof p === "string" && ["github", "gitea", "gitlab"].includes(p),
|
||||
))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `group "${g.id}".providers must be a list of "github" | "gitea" | "gitlab"`,
|
||||
};
|
||||
}
|
||||
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
|
||||
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,17 @@ export function groupAcceptsOwners(group: Group, owners: string[]): boolean {
|
|||
return seen.some((o) => restrict.includes(o));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event from a webhook `provider` (source platform: github, gitea,
|
||||
* ...) is allowed into this group. A group with no provider restriction
|
||||
* accepts every provider. Events without a provider are treated as github.
|
||||
*/
|
||||
export function groupAcceptsProvider(group: Group, provider?: string): boolean {
|
||||
const allowed = (group.providers ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
if (allowed.length === 0) return true;
|
||||
return allowed.includes(provider ?? "github");
|
||||
}
|
||||
|
||||
export interface AccessScope {
|
||||
isSuper: boolean;
|
||||
/** Groups the user may view/edit. When isSuper, this is every group. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue