diff --git a/AGENTS.md b/AGENTS.md index ad64ac8..849d59d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 - 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) -- 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 - Local dev: wrangler + Miniflare @@ -27,7 +27,7 @@ src/ ├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + / ├── core/ │ └── 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) │ ├── parse.ts # parseEvent (headers + body → WebhookEvent) │ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering diff --git a/admin/composables/useMe.ts b/admin/composables/useMe.ts deleted file mode 100644 index 6e2ff39..0000000 --- a/admin/composables/useMe.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Me } from "~/types"; - -export function useMeApi() { - const me = ref(null); - const loading = ref(false); - const needLogin = ref(false); - - async function load(): Promise { - 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 }; -} diff --git a/admin/composables/useRoutes.ts b/admin/composables/useRoutes.ts deleted file mode 100644 index 105edea..0000000 --- a/admin/composables/useRoutes.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Route } from "~/types"; - -export function useRoutesApi() { - const routes = ref([]); - const loading = ref(false); - const needLogin = ref(false); - const error = ref(""); - - async function load(): Promise { - 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 { - 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 }; -} diff --git a/admin/types.ts b/admin/types.ts index 94604bf..7761027 100644 --- a/admin/types.ts +++ b/admin/types.ts @@ -33,13 +33,6 @@ export interface Group { emoji?: boolean; } -export interface Me { - login: string; - userId: string; - isSuper: boolean; - groups: Group[]; -} - export interface RouteTemplate { id: 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_LABELS: Record = { - event: "Event", - repo: "Repo", - actor: "Actor", - action: "Action", - branch: "Branch", - keyword: "Keyword", -}; - export function fmtMatch(match: string | string[]): string { if (Array.isArray(match)) return match.join(", "); return String(match ?? ""); diff --git a/bun.lock b/bun.lock index 9b93a84..f7db5c9 100644 --- a/bun.lock +++ b/bun.lock @@ -6,9 +6,7 @@ "name": "webhooker", "dependencies": { "hono": "^4.7.0", - "jose": "^6.0.10", "octokit": "^4.1.0", - "yaml": "^2.7.0", }, "devDependencies": { "@cloudflare/workers-types": "^5.20260801.1", @@ -610,8 +608,6 @@ "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=="], "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=="], - "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=="], "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=="], diff --git a/docs/contributing.md b/docs/contributing.md index 6c42a58..441c57f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -20,7 +20,7 @@ src/ ├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + / ├── core/ │ └── 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) │ ├── parse.ts # parseEvent (headers + body → WebhookEvent) │ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index d1ccf10..ba8a379 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -43,7 +43,7 @@ GitHub Webhook → Cloudflare Worker (Hono) - **Telegram delivery**: Telegram Bot API (webhook with optional secret-token verification) - **Web UI**: Nuxt 3 static SPA served from Worker assets - **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 ## License diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md index 793d172..285d9b6 100644 --- a/docs/zh/contributing.md +++ b/docs/zh/contributing.md @@ -20,7 +20,7 @@ src/ ├── server.ts # Hono 应用: /health、/webhook、/discord/interactions、/telegram/webhook,挂载 /auth、/admin + / ├── core/ │ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send/edit -├── events/ # GitHub webhook 事件流水线(旧 src/webhook.ts 为死代码) +├── events/ # GitHub webhook 事件流水线:验证签名、解析事件、匹配路由 │ ├── verify.ts # HMAC 签名验证 (Web Crypto,时间安全) │ ├── parse.ts # parseEvent (headers + body → WebhookEvent) │ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤 diff --git a/docs/zh/guide/introduction.md b/docs/zh/guide/introduction.md index 9e0215d..03deda7 100644 --- a/docs/zh/guide/introduction.md +++ b/docs/zh/guide/introduction.md @@ -43,7 +43,7 @@ GitHub Webhook → Cloudflare Worker (Hono) - **Telegram 投递**: Telegram Bot API(webhook 带可选 secret-token 校验) - **Web UI**: Nuxt 3 静态 SPA,由 Worker 资源托管 - **存储**: Cloudflare KV + D1 -- **鉴权**: Web Crypto API (HMAC-SHA256、Ed25519)、octokit (GitHub API)、jose(依赖) +- **鉴权**: Web Crypto API (HMAC-SHA256、Ed25519)、octokit (GitHub API) - **语言**: TypeScript ## 许可证 diff --git a/package.json b/package.json index 5333c09..c59deae 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,7 @@ }, "dependencies": { "hono": "^4.7.0", - "jose": "^6.0.10", - "octokit": "^4.1.0", - "yaml": "^2.7.0" + "octokit": "^4.1.0" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260801.1", diff --git a/src/config.ts b/src/config.ts index 87f8c4d..d324106 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,10 +33,6 @@ export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise { if (configCache && Date.now() < configCache.expiresAt) { return configCache.config; diff --git a/src/formatters/index.ts b/src/formatters/index.ts index c6635a3..3429218 100644 --- a/src/formatters/index.ts +++ b/src/formatters/index.ts @@ -22,28 +22,6 @@ import { formatCodeScanningAlert, formatDependabotAlert } from "./security"; import { formatGeneric } from "./generic"; 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( route: Route, event: WebhookEvent, diff --git a/src/github/store.ts b/src/github/store.ts index dd7df4b..ce4cc68 100644 --- a/src/github/store.ts +++ b/src/github/store.ts @@ -2,7 +2,6 @@ interface StoredToken { userId: string; accessToken: string; expiresAt: number; - refreshToken?: string; } async function hashToken(token: string): Promise { @@ -18,13 +17,11 @@ export async function saveToken( userId: string, accessToken: string, expiresInSeconds: number, - refreshToken?: string, ): Promise { const token: StoredToken = { userId, accessToken, expiresAt: Date.now() + expiresInSeconds * 1000, - refreshToken, }; const ttl = Math.max(Math.floor(expiresInSeconds * 0.9), 60); await kv.put(`token:${userId}`, JSON.stringify(token), { expirationTtl: ttl }); @@ -44,12 +41,6 @@ export async function getToken(kv: KVNamespace, userId: string): Promise { - 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 { const raw = await kv.get(`token:${userId}`, "json"); if (raw) { diff --git a/src/webhook.ts b/src/webhook.ts deleted file mode 100644 index de79a95..0000000 --- a/src/webhook.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { WebhookEvent, Route, Filter } from "./types"; - -const keyCache = new Map(); -const regexCache = new Map(); -const keywordBodyCache = new WeakMap(); -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 { - 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 { - 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, 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(); - 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)); -}