mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
chore: remove dead code and unused dependencies
Cleans up stale implementation artifacts: - Delete legacy `src/webhook.ts` (dead code per AGENTS.md) - Delete admin composables `useMe.ts` and `useRoutes.ts` - Remove unused dependencies `jose` and `yaml` - Remove stale `Me` interface, `FILTER_LABELS`, `invalidateConfigCache`, and formatter re-exports
This commit is contained in:
parent
76ba2c0a89
commit
e1b1daac98
14 changed files with 7 additions and 320 deletions
|
|
@ -13,7 +13,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
|
||||||
- Discord interactions: HTTPS Interactions Endpoint (`POST /discord/interactions`, Ed25519-signed) — no Discord Gateway / Durable Object; bot stays offline, messages always sent via REST
|
- Discord interactions: HTTPS Interactions Endpoint (`POST /discord/interactions`, Ed25519-signed) — no Discord Gateway / Durable Object; bot stays offline, messages always sent via REST
|
||||||
- Storage: Cloudflare KV (tokens, OAuth state, route config `config:routes`, group config `config:groups`, admin sessions, delivery dedup, message-update tracking `msg:*`, i18n overrides `i18n:*`) + D1 (`send_logs`, `discord_links`, `telegram_links`)
|
- Storage: Cloudflare KV (tokens, OAuth state, route config `config:routes`, group config `config:groups`, admin sessions, delivery dedup, message-update tracking `msg:*`, i18n overrides `i18n:*`) + D1 (`send_logs`, `discord_links`, `telegram_links`)
|
||||||
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub, Ed25519 for Discord, timing-safe secret-token compare for Telegram)
|
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub, Ed25519 for Discord, timing-safe secret-token compare for Telegram)
|
||||||
- GitHub OAuth: octokit (token is stored hashed for reverse lookup; jose is a dependency but JWT issuance is not used)
|
- GitHub OAuth: octokit (token is stored hashed for reverse lookup)
|
||||||
- Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist
|
- Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist
|
||||||
- Local dev: wrangler + Miniflare
|
- Local dev: wrangler + Miniflare
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ src/
|
||||||
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
||||||
├── core/
|
├── core/
|
||||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter)
|
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter)
|
||||||
├── events/ # GitHub webhook pipeline (legacy src/webhook.ts is dead code — do not import it)
|
├── events/ # GitHub webhook pipeline: verify signature, parse event, match route
|
||||||
│ ├── verify.ts # HMAC signature verify (Web Crypto, timing-safe)
|
│ ├── verify.ts # HMAC signature verify (Web Crypto, timing-safe)
|
||||||
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
|
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
|
||||||
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
|
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import type { Me } from "~/types";
|
|
||||||
|
|
||||||
export function useMeApi() {
|
|
||||||
const me = ref<Me | null>(null);
|
|
||||||
const loading = ref(false);
|
|
||||||
const needLogin = ref(false);
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
loading.value = true;
|
|
||||||
needLogin.value = false;
|
|
||||||
try {
|
|
||||||
const res = await fetch("/admin/api/me", {
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
needLogin.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
me.value = (await res.json()) as Me;
|
|
||||||
} catch {
|
|
||||||
me.value = null;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { me, loading, needLogin, load };
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import type { Route } from "~/types";
|
|
||||||
|
|
||||||
export function useRoutesApi() {
|
|
||||||
const routes = ref<Route[]>([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
const needLogin = ref(false);
|
|
||||||
const error = ref("");
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = "";
|
|
||||||
needLogin.value = false;
|
|
||||||
try {
|
|
||||||
const res = await fetch("/admin/api/routes", {
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
needLogin.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = (await res.json()) as { routes?: Route[] };
|
|
||||||
routes.value = data.routes ?? [];
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(next: Route[]): Promise<void> {
|
|
||||||
const res = await fetch("/admin/api/routes", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({ routes: next }),
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
needLogin.value = true;
|
|
||||||
throw new Error("unauthorized");
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
||||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
routes.value = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { routes, loading, needLogin, error, load, save };
|
|
||||||
}
|
|
||||||
|
|
@ -33,13 +33,6 @@ export interface Group {
|
||||||
emoji?: boolean;
|
emoji?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Me {
|
|
||||||
login: string;
|
|
||||||
userId: string;
|
|
||||||
isSuper: boolean;
|
|
||||||
groups: Group[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteTemplate {
|
export interface RouteTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
nameKey: string;
|
nameKey: string;
|
||||||
|
|
@ -125,15 +118,6 @@ export const ROUTE_TEMPLATES: RouteTemplate[] = [
|
||||||
|
|
||||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;
|
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;
|
||||||
|
|
||||||
export const FILTER_LABELS: Record<string, string> = {
|
|
||||||
event: "Event",
|
|
||||||
repo: "Repo",
|
|
||||||
actor: "Actor",
|
|
||||||
action: "Action",
|
|
||||||
branch: "Branch",
|
|
||||||
keyword: "Keyword",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function fmtMatch(match: string | string[]): string {
|
export function fmtMatch(match: string | string[]): string {
|
||||||
if (Array.isArray(match)) return match.join(", ");
|
if (Array.isArray(match)) return match.join(", ");
|
||||||
return String(match ?? "");
|
return String(match ?? "");
|
||||||
|
|
|
||||||
6
bun.lock
6
bun.lock
|
|
@ -6,9 +6,7 @@
|
||||||
"name": "webhooker",
|
"name": "webhooker",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
"jose": "^6.0.10",
|
|
||||||
"octokit": "^4.1.0",
|
"octokit": "^4.1.0",
|
||||||
"yaml": "^2.7.0",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^5.20260801.1",
|
"@cloudflare/workers-types": "^5.20260801.1",
|
||||||
|
|
@ -610,8 +608,6 @@
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="],
|
|
||||||
|
|
||||||
"js-yaml": ["js-yaml@5.2.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw=="],
|
"js-yaml": ["js-yaml@5.2.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw=="],
|
||||||
|
|
||||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
@ -864,8 +860,6 @@
|
||||||
|
|
||||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||||
|
|
||||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ src/
|
||||||
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
||||||
├── core/
|
├── core/
|
||||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → getDriver().send/edit
|
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → getDriver().send/edit
|
||||||
├── events/ # GitHub webhook pipeline (legacy src/webhook.ts is dead code)
|
├── events/ # GitHub webhook pipeline: verify signature, parse event, match route
|
||||||
│ ├── verify.ts # HMAC signature verification (Web Crypto, timing-safe)
|
│ ├── verify.ts # HMAC signature verification (Web Crypto, timing-safe)
|
||||||
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
|
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
|
||||||
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering
|
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ GitHub Webhook → Cloudflare Worker (Hono)
|
||||||
- **Telegram delivery**: Telegram Bot API (webhook with optional secret-token verification)
|
- **Telegram delivery**: Telegram Bot API (webhook with optional secret-token verification)
|
||||||
- **Web UI**: Nuxt 3 static SPA served from Worker assets
|
- **Web UI**: Nuxt 3 static SPA served from Worker assets
|
||||||
- **Storage**: Cloudflare KV + D1
|
- **Storage**: Cloudflare KV + D1
|
||||||
- **Auth**: Web Crypto API (HMAC-SHA256, Ed25519), octokit (GitHub API), jose (dependency)
|
- **Auth**: Web Crypto API (HMAC-SHA256, Ed25519), octokit (GitHub API)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ src/
|
||||||
├── server.ts # Hono 应用: /health、/webhook、/discord/interactions、/telegram/webhook,挂载 /auth、/admin + /
|
├── server.ts # Hono 应用: /health、/webhook、/discord/interactions、/telegram/webhook,挂载 /auth、/admin + /
|
||||||
├── core/
|
├── core/
|
||||||
│ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send/edit
|
│ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send/edit
|
||||||
├── events/ # GitHub webhook 事件流水线(旧 src/webhook.ts 为死代码)
|
├── events/ # GitHub webhook 事件流水线:验证签名、解析事件、匹配路由
|
||||||
│ ├── verify.ts # HMAC 签名验证 (Web Crypto,时间安全)
|
│ ├── verify.ts # HMAC 签名验证 (Web Crypto,时间安全)
|
||||||
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
|
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
|
||||||
│ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤
|
│ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ GitHub Webhook → Cloudflare Worker (Hono)
|
||||||
- **Telegram 投递**: Telegram Bot API(webhook 带可选 secret-token 校验)
|
- **Telegram 投递**: Telegram Bot API(webhook 带可选 secret-token 校验)
|
||||||
- **Web UI**: Nuxt 3 静态 SPA,由 Worker 资源托管
|
- **Web UI**: Nuxt 3 静态 SPA,由 Worker 资源托管
|
||||||
- **存储**: Cloudflare KV + D1
|
- **存储**: Cloudflare KV + D1
|
||||||
- **鉴权**: Web Crypto API (HMAC-SHA256、Ed25519)、octokit (GitHub API)、jose(依赖)
|
- **鉴权**: Web Crypto API (HMAC-SHA256、Ed25519)、octokit (GitHub API)
|
||||||
- **语言**: TypeScript
|
- **语言**: TypeScript
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
"jose": "^6.0.10",
|
"octokit": "^4.1.0"
|
||||||
"octokit": "^4.1.0",
|
|
||||||
"yaml": "^2.7.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^5.20260801.1",
|
"@cloudflare/workers-types": "^5.20260801.1",
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,6 @@ export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void
|
||||||
configCache = null;
|
configCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateConfigCache(): void {
|
|
||||||
configCache = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadConfig(env: Env): Promise<Config> {
|
export async function loadConfig(env: Env): Promise<Config> {
|
||||||
if (configCache && Date.now() < configCache.expiresAt) {
|
if (configCache && Date.now() < configCache.expiresAt) {
|
||||||
return configCache.config;
|
return configCache.config;
|
||||||
|
|
|
||||||
|
|
@ -22,28 +22,6 @@ import { formatCodeScanningAlert, formatDependabotAlert } from "./security";
|
||||||
import { formatGeneric } from "./generic";
|
import { formatGeneric } from "./generic";
|
||||||
import { formatPing } from "./ping";
|
import { formatPing } from "./ping";
|
||||||
|
|
||||||
export type { T } from "./helpers";
|
|
||||||
export { formatPush } from "./push";
|
|
||||||
export { formatPullRequest } from "./pull-request";
|
|
||||||
export { formatPullRequestReview, formatPullRequestReviewComment } from "./review";
|
|
||||||
export { formatIssues } from "./issues";
|
|
||||||
export { formatIssueComment } from "./comments";
|
|
||||||
export { formatWorkflowRun, formatWorkflowJob } from "./workflow";
|
|
||||||
export { formatRelease } from "./release";
|
|
||||||
export { formatCreate, formatDelete } from "./create";
|
|
||||||
export { formatStar, formatFork } from "./repo";
|
|
||||||
export { formatCheckRun, formatCheckSuite, formatStatus } from "./check";
|
|
||||||
export { formatCommitComment } from "./commit-comment";
|
|
||||||
export { formatDeployment, formatDeploymentStatus } from "./deployment";
|
|
||||||
export { formatMember } from "./member";
|
|
||||||
export { formatLabel } from "./label";
|
|
||||||
export { formatMilestone } from "./milestone";
|
|
||||||
export { formatDiscussion, formatDiscussionComment } from "./discussion";
|
|
||||||
export { formatRepository } from "./repository";
|
|
||||||
export { formatCodeScanningAlert, formatDependabotAlert } from "./security";
|
|
||||||
export { formatGeneric } from "./generic";
|
|
||||||
export { formatPing } from "./ping";
|
|
||||||
|
|
||||||
export function formatEvent(
|
export function formatEvent(
|
||||||
route: Route,
|
route: Route,
|
||||||
event: WebhookEvent,
|
event: WebhookEvent,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ interface StoredToken {
|
||||||
userId: string;
|
userId: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
refreshToken?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hashToken(token: string): Promise<string> {
|
async function hashToken(token: string): Promise<string> {
|
||||||
|
|
@ -18,13 +17,11 @@ export async function saveToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
expiresInSeconds: number,
|
expiresInSeconds: number,
|
||||||
refreshToken?: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const token: StoredToken = {
|
const token: StoredToken = {
|
||||||
userId,
|
userId,
|
||||||
accessToken,
|
accessToken,
|
||||||
expiresAt: Date.now() + expiresInSeconds * 1000,
|
expiresAt: Date.now() + expiresInSeconds * 1000,
|
||||||
refreshToken,
|
|
||||||
};
|
};
|
||||||
const ttl = Math.max(Math.floor(expiresInSeconds * 0.9), 60);
|
const ttl = Math.max(Math.floor(expiresInSeconds * 0.9), 60);
|
||||||
await kv.put(`token:${userId}`, JSON.stringify(token), { expirationTtl: ttl });
|
await kv.put(`token:${userId}`, JSON.stringify(token), { expirationTtl: ttl });
|
||||||
|
|
@ -44,12 +41,6 @@ export async function getToken(kv: KVNamespace, userId: string): Promise<string
|
||||||
return t.accessToken;
|
return t.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRefreshToken(kv: KVNamespace, userId: string): Promise<string | null> {
|
|
||||||
const raw = await kv.get(`token:${userId}`, "json");
|
|
||||||
if (!raw) return null;
|
|
||||||
return (raw as StoredToken).refreshToken ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeToken(kv: KVNamespace, userId: string): Promise<void> {
|
export async function removeToken(kv: KVNamespace, userId: string): Promise<void> {
|
||||||
const raw = await kv.get(`token:${userId}`, "json");
|
const raw = await kv.get(`token:${userId}`, "json");
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|
|
||||||
173
src/webhook.ts
173
src/webhook.ts
|
|
@ -1,173 +0,0 @@
|
||||||
import type { WebhookEvent, Route, Filter } from "./types";
|
|
||||||
|
|
||||||
const keyCache = new Map<string, CryptoKey>();
|
|
||||||
const regexCache = new Map<string, RegExp>();
|
|
||||||
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
|
|
||||||
const MAX_PATTERN_LENGTH = 200;
|
|
||||||
|
|
||||||
function compileKeywordRegex(pattern: string): RegExp | null {
|
|
||||||
if (pattern.length > MAX_PATTERN_LENGTH) return null;
|
|
||||||
const cached = regexCache.get(pattern);
|
|
||||||
if (cached) return cached;
|
|
||||||
try {
|
|
||||||
const re = new RegExp(pattern, "i");
|
|
||||||
regexCache.set(pattern, re);
|
|
||||||
return re;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getKeywordBody(event: WebhookEvent): string {
|
|
||||||
const cached = keywordBodyCache.get(event);
|
|
||||||
if (cached !== undefined) return cached;
|
|
||||||
const body = JSON.stringify(event.payload).toLowerCase();
|
|
||||||
keywordBodyCache.set(event, body);
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
|
|
||||||
const event = headers["x-github-event"];
|
|
||||||
const signature = headers["x-hub-signature-256"];
|
|
||||||
|
|
||||||
if (!event) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(body);
|
|
||||||
return { event, payload, signature };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractBranch(event: WebhookEvent): string | undefined {
|
|
||||||
if (event.event === "push") {
|
|
||||||
return (event.payload.ref as string)?.replace("refs/heads/", "");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
event.event === "pull_request" ||
|
|
||||||
event.event === "pull_request_review" ||
|
|
||||||
event.event === "pull_request_review_comment"
|
|
||||||
) {
|
|
||||||
const pr = event.payload.pull_request as { head?: { ref?: string } } | undefined;
|
|
||||||
return pr?.head?.ref;
|
|
||||||
}
|
|
||||||
if (event.event === "create" || event.event === "delete") {
|
|
||||||
return event.payload.ref as string | undefined;
|
|
||||||
}
|
|
||||||
if (event.event === "workflow_run") {
|
|
||||||
const wf = event.payload.workflow_run as { head_branch?: string } | undefined;
|
|
||||||
return wf?.head_branch;
|
|
||||||
}
|
|
||||||
if (event.event === "commit_comment") {
|
|
||||||
const comment = event.payload.comment as { position?: number | null } | undefined;
|
|
||||||
if (comment?.position != null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.event === "code_scanning_alert") {
|
|
||||||
return event.payload.ref as string | undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
|
|
||||||
let value: string | undefined;
|
|
||||||
|
|
||||||
switch (filter.type) {
|
|
||||||
case "event":
|
|
||||||
value = event.event;
|
|
||||||
break;
|
|
||||||
case "repo":
|
|
||||||
value = (event.payload.repository as { full_name?: string })?.full_name;
|
|
||||||
break;
|
|
||||||
case "actor":
|
|
||||||
value = (event.payload.sender as { login?: string })?.login;
|
|
||||||
break;
|
|
||||||
case "action":
|
|
||||||
value = event.payload.action as string;
|
|
||||||
break;
|
|
||||||
case "branch":
|
|
||||||
value = extractBranch(event);
|
|
||||||
break;
|
|
||||||
case "keyword": {
|
|
||||||
const body = keywordBody ?? getKeywordBody(event);
|
|
||||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
|
||||||
const matches = patterns.some((p) => {
|
|
||||||
const re = compileKeywordRegex(p);
|
|
||||||
if (!re) return body.includes(p.toLowerCase());
|
|
||||||
return re.test(body);
|
|
||||||
});
|
|
||||||
return filter.exclude ? !matches : matches;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) return false;
|
|
||||||
|
|
||||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
|
||||||
const matches = patterns.some((p) => value!.toLowerCase() === p.toLowerCase());
|
|
||||||
|
|
||||||
return filter.exclude ? !matches : matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Org/user logins that own the event (repository owner + organization). */
|
|
||||||
export function eventOwners(event: WebhookEvent): string[] {
|
|
||||||
const owners = new Set<string>();
|
|
||||||
const repoOwner = (event.payload.repository as { owner?: { login?: string } } | undefined)?.owner
|
|
||||||
?.login;
|
|
||||||
if (repoOwner) owners.add(repoOwner);
|
|
||||||
const org = (event.payload.organization as { login?: string } | undefined)?.login;
|
|
||||||
if (org) owners.add(org);
|
|
||||||
return [...owners];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchRoute(route: Route, event: WebhookEvent): boolean {
|
|
||||||
if (!route.enabled) return false;
|
|
||||||
const hasKeyword = route.filters.some((f) => f.type === "keyword");
|
|
||||||
const keywordBody = hasKeyword ? getKeywordBody(event) : undefined;
|
|
||||||
return route.filters.every((f) => matchFilter(f, event, keywordBody));
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue