mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
44
server/lib/providers/custom/index.ts
Normal file
44
server/lib/providers/custom/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { Env, WebhookEvent } from "../../types";
|
||||
import type { Provider } from "../types";
|
||||
import { verifySignature } from "../github/verify";
|
||||
|
||||
/**
|
||||
* Custom webhook provider: accepts arbitrary JSON posts (monitoring, CI,
|
||||
* scripts, ...) that are not signed by a forge. The sender signs the raw body
|
||||
* with the tenant's secret using the GitHub-style `sha256=<hex>` HMAC header
|
||||
* `X-WebHooker-Signature`. Payloads become `custom` events that flow through
|
||||
* the normal route matching pipeline (a route with `event: custom`).
|
||||
*/
|
||||
export const customProvider: Provider = {
|
||||
id: "custom",
|
||||
|
||||
matches(headers) {
|
||||
return (
|
||||
headers["x-github-event"] === undefined &&
|
||||
headers["x-gitea-event"] === undefined &&
|
||||
headers["x-webhooker-signature"] !== undefined
|
||||
);
|
||||
},
|
||||
|
||||
async verify(body, headers, env: Env) {
|
||||
// The tenant webhook handler overrides GITHUB_WEBHOOK_SECRET with the
|
||||
// group's secret; on the legacy global endpoint this falls back to the
|
||||
// operator's global secret.
|
||||
return verifySignature(body, headers["x-webhooker-signature"], env.GITHUB_WEBHOOK_SECRET);
|
||||
},
|
||||
|
||||
parse(body, _headers): WebhookEvent | null {
|
||||
try {
|
||||
const payload = JSON.parse(body) as Record<string, unknown>;
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
// Optional id for sender-side dedup (retries from monitoring systems).
|
||||
const deliveryId =
|
||||
typeof payload.deliveryId === "string" && payload.deliveryId
|
||||
? payload.deliveryId
|
||||
: undefined;
|
||||
return { event: "custom", provider: "custom", payload, deliveryId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
20
server/lib/providers/gitea/index.ts
Normal file
20
server/lib/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
server/lib/providers/gitea/parse.ts
Normal file
76
server/lib/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
server/lib/providers/gitea/verify.ts
Normal file
15
server/lib/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
server/lib/providers/github/index.ts
Normal file
20
server/lib/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);
|
||||
},
|
||||
};
|
||||
20
server/lib/providers/github/parse.ts
Normal file
20
server/lib/providers/github/parse.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { WebhookEvent } from "../../types";
|
||||
|
||||
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
|
||||
const event = headers["x-github-event"];
|
||||
const signature = headers["x-hub-signature-256"];
|
||||
const deliveryId = headers["x-github-delivery"];
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(body) as Record<string, unknown>;
|
||||
const installationId =
|
||||
typeof (payload.installation as { id?: unknown } | undefined)?.id === "number"
|
||||
? (payload.installation as { id: number }).id
|
||||
: undefined;
|
||||
return { provider: "github", event, payload, signature, deliveryId, installationId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
11
server/lib/providers/github/verify.ts
Normal file
11
server/lib/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
server/lib/providers/hmac.ts
Normal file
36
server/lib/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;
|
||||
}
|
||||
24
server/lib/providers/index.ts
Normal file
24
server/lib/providers/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Provider } from "./types";
|
||||
import { githubProvider } from "./github";
|
||||
import { giteaProvider } from "./gitea";
|
||||
import { customProvider } from "./custom";
|
||||
|
||||
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`. Custom requests carry none of the forge headers,
|
||||
* only `X-WebHooker-Signature`, so they are checked last.
|
||||
*/
|
||||
const providers: Provider[] = [giteaProvider, githubProvider, customProvider];
|
||||
|
||||
/**
|
||||
* 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
server/lib/providers/types.ts
Normal file
23
server/lib/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" | "custom";
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue