mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate send logs and discord links to D1
This commit is contained in:
parent
5a98e45544
commit
3a6a2cbbc5
16 changed files with 139 additions and 79 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -11,7 +11,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
|
||||||
- Runtime: Cloudflare Workers
|
- Runtime: Cloudflare Workers
|
||||||
- HTTP framework: Hono
|
- HTTP framework: Hono
|
||||||
- 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, admin sessions)
|
- Storage: Cloudflare KV (tokens, OAuth state, route config, admin sessions, delivery dedup) + D1 (send logs, discord-link mapping)
|
||||||
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub, Ed25519 for Discord)
|
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub, Ed25519 for Discord)
|
||||||
- GitHub OAuth: octokit + jose (JWT)
|
- GitHub OAuth: octokit + jose (JWT)
|
||||||
- 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
|
||||||
|
|
@ -51,7 +51,7 @@ src/
|
||||||
│ └── index.ts # TelegramDriver stub (not implemented yet)
|
│ └── index.ts # TelegramDriver stub (not implemented yet)
|
||||||
├── github/
|
├── github/
|
||||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions
|
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions
|
||||||
│ └── store.ts # KV-based token CRUD + discord-link mapping (was token-store.ts)
|
│ └── store.ts # KV token CRUD + D1 discord-link mapping (was token-store.ts)
|
||||||
├── web/ # HTTP UI/API routes
|
├── web/ # HTTP UI/API routes
|
||||||
│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link), DELETE /token/:userId
|
│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link), DELETE /token/:userId
|
||||||
│ ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup)
|
│ ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup)
|
||||||
|
|
@ -62,7 +62,7 @@ src/
|
||||||
│ └── legal-routes.ts # legal / privacy / terms pages
|
│ └── legal-routes.ts # legal / privacy / terms pages
|
||||||
└── lib/ # shared infra
|
└── lib/ # shared infra
|
||||||
├── i18n.ts # loadTranslations, t() with param interpolation
|
├── i18n.ts # loadTranslations, t() with param interpolation
|
||||||
├── send-log.ts # SendRecord, recordSend, getSendLog
|
├── send-log.ts # SendRecord, recordSend/getSendLog (D1 send_logs)
|
||||||
├── log.ts # JSON console logger (info/warn/error/fatal)
|
├── log.ts # JSON console logger (info/warn/error/fatal)
|
||||||
└── locales/ # en.ts, zh.ts translation dictionaries
|
└── locales/ # en.ts, zh.ts translation dictionaries
|
||||||
```
|
```
|
||||||
|
|
@ -104,7 +104,8 @@ npm run lint # ESLint
|
||||||
- **Local dev**: `.dev.vars` (wrangler reads this for env bindings)
|
- **Local dev**: `.dev.vars` (wrangler reads this for env bindings)
|
||||||
- **Production**: `wrangler secret put <NAME>` for each secret
|
- **Production**: `wrangler secret put <NAME>` for each secret
|
||||||
- **Routes**: KV key `config:routes` (JSON array, empty until configured)
|
- **Routes**: KV key `config:routes` (JSON array, empty until configured)
|
||||||
- **KV namespace**: Required binding for token/state/config storage
|
- **KV namespace**: Required binding for token/state/config/session storage
|
||||||
|
- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `discord_links` tables
|
||||||
- **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions
|
- **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
@ -115,6 +116,9 @@ npx wrangler secret put DISCORD_TOKEN
|
||||||
npx wrangler secret put DISCORD_PUBLIC_KEY
|
npx wrangler secret put DISCORD_PUBLIC_KEY
|
||||||
npx wrangler kv namespace create KV
|
npx wrangler kv namespace create KV
|
||||||
# Update wrangler.jsonc with KV ID
|
# Update wrangler.jsonc with KV ID
|
||||||
|
npx wrangler d1 create webhooker
|
||||||
|
# Update wrangler.jsonc d1_databases with the database ID
|
||||||
|
npx wrangler d1 execute webhooker --remote --file ./migrations/0001_init.sql
|
||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ Tokens are stored in KV with key pattern `token:{userId}`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`expiresAt` is a Unix timestamp in milliseconds. KV entries expire at 90% of the token's lifetime (minimum 60 seconds). A reverse index `token-reverse:{sha256 of token}` maps the access token back to its user id so Bearer-authenticated endpoints can resolve the caller. Discord users linked to a GitHub account are stored under `discord-link:{discordUserId}`.
|
`expiresAt` is a Unix timestamp in milliseconds. KV entries expire at 90% of the token's lifetime (minimum 60 seconds). A reverse index `token-reverse:{sha256 of token}` maps the access token back to its user id so Bearer-authenticated endpoints can resolve the caller. Discord users linked to a GitHub account are stored in the D1 `discord_links` table.
|
||||||
|
|
||||||
## Using Tokens
|
## Using Tokens
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ src/
|
||||||
│ └── telegram/ # TelegramDriver stub (not implemented yet)
|
│ └── telegram/ # TelegramDriver stub (not implemented yet)
|
||||||
├── github/ # GitHub OAuth + as-user actions
|
├── github/ # GitHub OAuth + as-user actions
|
||||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, actions
|
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, actions
|
||||||
│ └── store.ts # KV-based token CRUD + discord-link mapping
|
│ └── store.ts # KV token CRUD + D1 discord-link mapping
|
||||||
├── web/ # HTTP UI/API routes
|
├── web/ # HTTP UI/API routes
|
||||||
│ ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state)
|
│ ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state)
|
||||||
│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup)
|
│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup)
|
||||||
|
|
@ -48,7 +48,7 @@ src/
|
||||||
│ └── legal-routes.ts # Legal page routes
|
│ └── legal-routes.ts # Legal page routes
|
||||||
└── lib/ # Shared infrastructure
|
└── lib/ # Shared infrastructure
|
||||||
├── i18n.ts # Message language overrides (en/zh)
|
├── i18n.ts # Message language overrides (en/zh)
|
||||||
├── send-log.ts # Send logging (logs:send KV keys)
|
├── send-log.ts # Send logging (D1 send_logs)
|
||||||
├── log.ts # JSON console logger (info/warn/error/fatal)
|
├── log.ts # JSON console logger (info/warn/error/fatal)
|
||||||
└── locales/ # en.ts, zh.ts translation dictionaries
|
└── locales/ # en.ts, zh.ts translation dictionaries
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ Token 以键模式 `token:{userId}` 存储在 KV 中:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`expiresAt` 是毫秒级 Unix 时间戳。KV 条目在 Token 有效期的 90% 时过期(至少 60 秒)。反向索引 `token-reverse:{sha256 of token}` 将访问令牌映射回用户 id,使 Bearer 鉴权的端点能解析调用者。与 GitHub 账号绑定的 Discord 用户存储在 `discord-link:{discordUserId}` 下。
|
`expiresAt` 是毫秒级 Unix 时间戳。KV 条目在 Token 有效期的 90% 时过期(至少 60 秒)。反向索引 `token-reverse:{sha256 of token}` 将访问令牌映射回用户 id,使 Bearer 鉴权的端点能解析调用者。与 GitHub 账号绑定的 Discord 用户存储在 D1 的 `discord_links` 表中。
|
||||||
|
|
||||||
## 使用 Token
|
## 使用 Token
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ src/
|
||||||
│ └── telegram/ # TelegramDriver 占位(未实现)
|
│ └── telegram/ # TelegramDriver 占位(未实现)
|
||||||
├── github/ # GitHub OAuth + 以用户身份操作
|
├── github/ # GitHub OAuth + 以用户身份操作
|
||||||
│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、操作
|
│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、操作
|
||||||
│ └── store.ts # 基于 KV 的 Token CRUD + discord-link 映射
|
│ └── store.ts # KV Token CRUD + D1 discord-link 映射
|
||||||
├── web/ # HTTP UI/API 路由
|
├── web/ # HTTP UI/API 路由
|
||||||
│ ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态)
|
│ ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态)
|
||||||
│ ├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权)
|
│ ├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权)
|
||||||
|
|
@ -48,7 +48,7 @@ src/
|
||||||
│ └── legal-routes.ts # 法律页面路由
|
│ └── legal-routes.ts # 法律页面路由
|
||||||
└── lib/ # 共享基础设施
|
└── lib/ # 共享基础设施
|
||||||
├── i18n.ts # 消息语言覆盖 (en/zh)
|
├── i18n.ts # 消息语言覆盖 (en/zh)
|
||||||
├── send-log.ts # 发送日志 (logs:send KV 键)
|
├── send-log.ts # 发送日志 (D1 send_logs)
|
||||||
├── log.ts # JSON 控制台日志 (info/warn/error/fatal)
|
├── log.ts # JSON 控制台日志 (info/warn/error/fatal)
|
||||||
└── locales/ # en.ts、zh.ts 翻译字典
|
└── locales/ # en.ts、zh.ts 翻译字典
|
||||||
```
|
```
|
||||||
|
|
|
||||||
16
migrations/0001_init.sql
Normal file
16
migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS send_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
route_id TEXT NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
repo TEXT,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_send_logs_ts ON send_logs (ts DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_links (
|
||||||
|
discord_user_id TEXT PRIMARY KEY,
|
||||||
|
github_user_id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -12,6 +12,7 @@ function createEnv(overrides: Partial<Env> = {}): Env {
|
||||||
return {
|
return {
|
||||||
GITHUB_WEBHOOK_SECRET: "secret",
|
GITHUB_WEBHOOK_SECRET: "secret",
|
||||||
KV: {} as KVNamespace,
|
KV: {} as KVNamespace,
|
||||||
|
DB: {} as D1Database,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +92,17 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
} as unknown as KVNamespace;
|
} as unknown as KVNamespace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMockDB(): D1Database {
|
||||||
|
return {
|
||||||
|
prepare: () => ({
|
||||||
|
bind: () => ({
|
||||||
|
run: async () => ({ success: true }),
|
||||||
|
all: async () => ({ results: [] }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as D1Database;
|
||||||
|
}
|
||||||
|
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
baseUrl: "https://example.com",
|
baseUrl: "https://example.com",
|
||||||
github: {
|
github: {
|
||||||
|
|
@ -110,7 +122,7 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
sent.push(url);
|
sent.push(url);
|
||||||
return new Response("{}", { status: 200 });
|
return new Response("{}", { status: 200 });
|
||||||
});
|
});
|
||||||
const env = createEnv({ KV: createMockKV() });
|
const env = createEnv({ KV: createMockKV(), DB: createMockDB() });
|
||||||
const routes: Route[] = [
|
const routes: Route[] = [
|
||||||
{
|
{
|
||||||
id: "regular-push",
|
id: "regular-push",
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,46 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { recordSend, getSendLog } from "../lib/send-log";
|
import { recordSend, getSendLog } from "../lib/send-log";
|
||||||
|
|
||||||
function createMockKV(): KVNamespace {
|
function createMockDB(): D1Database {
|
||||||
const store = new Map<string, { value: string; expiration?: number }>();
|
const rows: Array<Record<string, unknown>> = [];
|
||||||
return {
|
return {
|
||||||
get: async (key: string, type?: string) => {
|
prepare: (sql: string) => ({
|
||||||
const entry = store.get(key);
|
bind: (..._args: unknown[]) => ({
|
||||||
if (!entry) return null;
|
run: async (): Promise<{ success: boolean }> => {
|
||||||
if (entry.expiration && Date.now() / 1000 > entry.expiration) {
|
if (sql.startsWith("INSERT")) {
|
||||||
store.delete(key);
|
const args = _args as unknown[];
|
||||||
return null;
|
rows.push({
|
||||||
}
|
ts: args[0],
|
||||||
if (type === "json") return JSON.parse(entry.value);
|
route_id: args[1],
|
||||||
return entry.value;
|
event: args[2],
|
||||||
},
|
repo: args[3],
|
||||||
put: async (key: string, value: string, opts?: { expirationTtl?: number }) => {
|
target: args[4],
|
||||||
const expiration = opts?.expirationTtl ? Date.now() / 1000 + opts.expirationTtl : undefined;
|
ok: args[5],
|
||||||
store.set(key, { value, expiration });
|
error: args[6],
|
||||||
},
|
});
|
||||||
delete: async (key: string) => {
|
}
|
||||||
store.delete(key);
|
return { success: true };
|
||||||
},
|
},
|
||||||
list: async () => ({
|
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => {
|
||||||
keys: [...store.keys()].map((k) => ({ name: k })),
|
const args = _args as unknown[];
|
||||||
list_complete: true,
|
const limit = (args[0] as number) ?? 50;
|
||||||
cacheStatus: null,
|
return {
|
||||||
|
results: rows
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (b.ts as number) - (a.ts as number))
|
||||||
|
.slice(0, limit),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
} as unknown as KVNamespace;
|
} as unknown as D1Database;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("send-log", () => {
|
describe("send-log", () => {
|
||||||
it("records and returns logs sorted newest first", async () => {
|
it("records and returns logs sorted newest first", async () => {
|
||||||
const kv = createMockKV();
|
const db = createMockDB();
|
||||||
await recordSend(kv, { ts: 1000, routeId: "a", event: "push", target: "111", ok: true });
|
await recordSend(db, { ts: 1000, routeId: "a", event: "push", target: "111", ok: true });
|
||||||
await recordSend(kv, {
|
await recordSend(db, {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
routeId: "b",
|
routeId: "b",
|
||||||
event: "issues",
|
event: "issues",
|
||||||
|
|
@ -41,7 +48,7 @@ describe("send-log", () => {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "Missing Permissions",
|
error: "Missing Permissions",
|
||||||
});
|
});
|
||||||
const logs = await getSendLog(kv);
|
const logs = await getSendLog(db);
|
||||||
expect(logs).toHaveLength(2);
|
expect(logs).toHaveLength(2);
|
||||||
expect(logs[0]!.routeId).toBe("b");
|
expect(logs[0]!.routeId).toBe("b");
|
||||||
expect(logs[0]!.ok).toBe(false);
|
expect(logs[0]!.ok).toBe(false);
|
||||||
|
|
@ -50,7 +57,7 @@ describe("send-log", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty when no logs", async () => {
|
it("returns empty when no logs", async () => {
|
||||||
const kv = createMockKV();
|
const db = createMockDB();
|
||||||
expect(await getSendLog(kv)).toEqual([]);
|
expect(await getSendLog(db)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
const message = formatEvent(route, event, tr, showEmoji);
|
const message = formatEvent(route, event, tr, showEmoji);
|
||||||
const result = await getDriver(route.target).send(message, route.target, env);
|
const result = await getDriver(route.target).send(message, route.target, env);
|
||||||
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
||||||
await recordSend(env.KV, {
|
await recordSend(env.DB, {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
event: event.event,
|
event: event.event,
|
||||||
|
|
@ -57,7 +57,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
ok: true,
|
ok: true,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await recordSend(env.KV, {
|
await recordSend(env.DB, {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
event: event.event,
|
event: event.event,
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ async function handleButton(
|
||||||
customId: string | undefined,
|
customId: string | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||||
const githubUserId = await getDiscordLink(env.KV, userId);
|
const githubUserId = await getDiscordLink(env.DB, userId);
|
||||||
if (!githubUserId) {
|
if (!githubUserId) {
|
||||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +348,7 @@ async function cmdLogout(
|
||||||
userId: string | null,
|
userId: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||||
await removeDiscordLink(env.KV, userId);
|
await removeDiscordLink(env.DB, userId);
|
||||||
await respond(env, id, token, "已解绑你的 GitHub 账号。");
|
await respond(env, id, token, "已解绑你的 GitHub 账号。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,7 +374,7 @@ async function commentOp(
|
||||||
source: string,
|
source: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||||
const githubUserId = await getDiscordLink(env.KV, userId);
|
const githubUserId = await getDiscordLink(env.DB, userId);
|
||||||
if (!githubUserId) {
|
if (!githubUserId) {
|
||||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||||
}
|
}
|
||||||
|
|
@ -487,7 +487,7 @@ async function modalSubmit(
|
||||||
const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim();
|
const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim();
|
||||||
if (!body) return respond(env, id, token, "评论内容不能为空。");
|
if (!body) return respond(env, id, token, "评论内容不能为空。");
|
||||||
|
|
||||||
const githubUserId = await getDiscordLink(env.KV, userId);
|
const githubUserId = await getDiscordLink(env.DB, userId);
|
||||||
if (!githubUserId) {
|
if (!githubUserId) {
|
||||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,20 +73,27 @@ export async function findUserIdByToken(
|
||||||
* as that GitHub account. The actual OAuth token lives under `token:{githubUserId}`.
|
* as that GitHub account. The actual OAuth token lives under `token:{githubUserId}`.
|
||||||
*/
|
*/
|
||||||
export async function saveDiscordLink(
|
export async function saveDiscordLink(
|
||||||
kv: KVNamespace,
|
db: D1Database,
|
||||||
discordUserId: string,
|
discordUserId: string,
|
||||||
githubUserId: string,
|
githubUserId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await kv.put(`discord-link:${discordUserId}`, githubUserId);
|
await db
|
||||||
|
.prepare("INSERT OR REPLACE INTO discord_links (discord_user_id, github_user_id) VALUES (?, ?)")
|
||||||
|
.bind(discordUserId, githubUserId)
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDiscordLink(
|
export async function getDiscordLink(
|
||||||
kv: KVNamespace,
|
db: D1Database,
|
||||||
discordUserId: string,
|
discordUserId: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
return await kv.get(`discord-link:${discordUserId}`, "text");
|
const { results } = await db
|
||||||
|
.prepare("SELECT github_user_id FROM discord_links WHERE discord_user_id = ?")
|
||||||
|
.bind(discordUserId)
|
||||||
|
.all<{ github_user_id: string }>();
|
||||||
|
return results[0]?.github_user_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeDiscordLink(kv: KVNamespace, discordUserId: string): Promise<void> {
|
export async function removeDiscordLink(db: D1Database, discordUserId: string): Promise<void> {
|
||||||
await kv.delete(`discord-link:${discordUserId}`);
|
await db.prepare("DELETE FROM discord_links WHERE discord_user_id = ?").bind(discordUserId).run();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,36 +10,42 @@ export interface SendRecord {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY_PREFIX = "logs:send:";
|
export async function recordSend(db: D1Database, record: SendRecord): Promise<void> {
|
||||||
const RETENTION_TTL = 3600;
|
|
||||||
const MAX_READ = 200;
|
|
||||||
|
|
||||||
function randomHex(): string {
|
|
||||||
const bytes = new Uint8Array(8);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
return Array.from(bytes)
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recordSend(kv: KVNamespace, record: SendRecord): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await kv.put(`${KEY_PREFIX}${record.ts}-${randomHex()}`, JSON.stringify(record), {
|
await db
|
||||||
expirationTtl: RETENTION_TTL,
|
.prepare(
|
||||||
});
|
"INSERT INTO send_logs (ts, route_id, event, repo, target, ok, error) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
record.ts,
|
||||||
|
record.routeId,
|
||||||
|
record.event,
|
||||||
|
record.repo ?? null,
|
||||||
|
record.target,
|
||||||
|
record.ok ? 1 : 0,
|
||||||
|
record.error ?? null,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ err }, "Failed to record send log");
|
log.warn({ err }, "Failed to record send log");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSendLog(kv: KVNamespace, limit = 50): Promise<SendRecord[]> {
|
export async function getSendLog(db: D1Database, limit = 50): Promise<SendRecord[]> {
|
||||||
try {
|
try {
|
||||||
const list = await kv.list({ prefix: KEY_PREFIX, limit: MAX_READ });
|
const { results } = await db
|
||||||
const records = await Promise.all(list.keys.map((k) => kv.get<SendRecord>(k.name, "json")));
|
.prepare("SELECT * FROM send_logs ORDER BY ts DESC LIMIT ?")
|
||||||
return records
|
.bind(limit)
|
||||||
.filter((r): r is SendRecord => r != null)
|
.all<{ ts: number; route_id: string; event: string; repo: string | null; target: string; ok: number; error: string | null }>();
|
||||||
.sort((a, b) => b.ts - a.ts)
|
return results.map((r) => ({
|
||||||
.slice(0, limit);
|
ts: r.ts,
|
||||||
|
routeId: r.route_id,
|
||||||
|
event: r.event,
|
||||||
|
repo: r.repo ?? undefined,
|
||||||
|
target: r.target,
|
||||||
|
ok: r.ok === 1,
|
||||||
|
error: r.error ?? undefined,
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ err }, "Failed to load send log");
|
log.warn({ err }, "Failed to load send log");
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export interface Env {
|
||||||
DISCORD_APPLICATION_ID?: string;
|
DISCORD_APPLICATION_ID?: string;
|
||||||
ASSETS?: Fetcher;
|
ASSETS?: Fetcher;
|
||||||
KV: KVNamespace;
|
KV: KVNamespace;
|
||||||
|
DB: D1Database;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|
|
||||||
|
|
@ -227,13 +227,13 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
|
||||||
if (!s) return c.json({ error: "Unauthorized" }, 401);
|
if (!s) return c.json({ error: "Unauthorized" }, 401);
|
||||||
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
|
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
|
||||||
if (s.scope.isSuper) {
|
if (s.scope.isSuper) {
|
||||||
return c.json({ logs: await getSendLog(c.env.KV, limit) });
|
return c.json({ logs: await getSendLog(c.env.DB, limit) });
|
||||||
}
|
}
|
||||||
const all = await loadRoutes(c.env.KV);
|
const all = await loadRoutes(c.env.KV);
|
||||||
const allowed = new Set(
|
const allowed = new Set(
|
||||||
all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId)).map((r) => r.id),
|
all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId)).map((r) => r.id),
|
||||||
);
|
);
|
||||||
const logs = (await getSendLog(c.env.KV, 200))
|
const logs = (await getSendLog(c.env.DB, 200))
|
||||||
.filter((l) => allowed.has(l.routeId))
|
.filter((l) => allowed.has(l.routeId))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
return c.json({ logs });
|
return c.json({ logs });
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
|
||||||
|
|
||||||
// Discord account-linking flow: bind the Discord user to this GitHub account.
|
// Discord account-linking flow: bind the Discord user to this GitHub account.
|
||||||
if (pending.discordUserId) {
|
if (pending.discordUserId) {
|
||||||
await saveDiscordLink(c.env.KV, pending.discordUserId, result.userId);
|
await saveDiscordLink(c.env.DB, pending.discordUserId, result.userId);
|
||||||
const isBrowserLink = (c.req.header("accept") ?? "").includes("text/html");
|
const isBrowserLink = (c.req.header("accept") ?? "").includes("text/html");
|
||||||
if (isBrowserLink) {
|
if (isBrowserLink) {
|
||||||
return c.html(linkedPage(result.login));
|
return c.html(linkedPage(result.login));
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,11 @@
|
||||||
"id": "53abb6d985a44b80b2b08d510f73928a"
|
"id": "53abb6d985a44b80b2b08d510f73928a"
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "webhooker",
|
||||||
|
"database_id": "214a0104-3235-47c0-b7bf-ddda95f3c8ac"
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue