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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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 ?? "");
|
||||
|
|
|
|||
6
bun.lock
6
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=="],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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、关键词过滤
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
## 许可证
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -33,10 +33,6 @@ export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void
|
|||
configCache = null;
|
||||
}
|
||||
|
||||
export function invalidateConfigCache(): void {
|
||||
configCache = null;
|
||||
}
|
||||
|
||||
export async function loadConfig(env: Env): Promise<Config> {
|
||||
if (configCache && Date.now() < configCache.expiresAt) {
|
||||
return configCache.config;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ interface StoredToken {
|
|||
userId: string;
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
|
|
@ -18,13 +17,11 @@ export async function saveToken(
|
|||
userId: string,
|
||||
accessToken: string,
|
||||
expiresInSeconds: number,
|
||||
refreshToken?: string,
|
||||
): Promise<void> {
|
||||
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<string
|
|||
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> {
|
||||
const raw = await kv.get(`token:${userId}`, "json");
|
||||
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