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:
RhenCloud 2026-08-11 16:31:13 +08:00
parent 76ba2c0a89
commit e1b1daac98
14 changed files with 7 additions and 320 deletions

View file

@ -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

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 ?? "");

View file

@ -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=="],

View file

@ -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

View file

@ -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

View file

@ -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、关键词过滤

View file

@ -43,7 +43,7 @@ GitHub Webhook → Cloudflare Worker (Hono)
- **Telegram 投递**: Telegram Bot APIwebhook 带可选 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
## 许可证

View file

@ -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",

View file

@ -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;

View file

@ -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,

View file

@ -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) {

View file

@ -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));
}