diff --git a/AGENTS.md b/AGENTS.md index 60ed52f..02d9ac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord - Signature verification: Web Crypto API (HMAC-SHA256 for GitHub/Gitea, Ed25519 for Discord, timing-safe secret-token compare for Telegram) - Webhook providers: pluggable forge adapters under `src/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; a `custom` provider accepts arbitrary signed JSON posts (`X-WebHooker-Signature`) as `custom` events; GitLab etc. can be added later - Per-group webhook ingress: optional `POST /webhook/{groupId}` with a per-group secret in KV (`tenant:{groupId}`) — Gitea/classic-GitHub/custom webhooks are verified against the group's secret instead of the operator's global ones; only that group's routes fire. The legacy `POST /webhook` (global secrets, all routes) stays untouched -- GitHub App tenant isolation: `Group.installationId` binds a group to one GitHub App installation; events whose `payload.installation.id` differs are rejected at dispatch (hard isolation on top of the optional `owners` list). `installation.created` events auto-provision: a dedicated `inst-{installationId}` group is created, or existing groups whose `owners` match the installing account get bound automatically +- GitHub App tenant isolation: `Group.installationId` binds a group to one GitHub App installation; events whose `payload.installation.id` differs are rejected at dispatch (hard isolation on top of the optional `owners` list). The App's Setup URL points at `GET /auth/github/install`, which renders a choice page (create `inst-{installationId}` or bind to a group the signed-in user owns, verified by role); `POST /auth/github/install/bind` performs the provisioning. `installation.created` webhook events auto-provision as a fallback (create `inst-{installationId}` or bind existing groups whose `owners` match the installing account) - GitHub OAuth: octokit (token is stored hashed for reverse lookup) - Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist - Access control: every group has role-based members (`owner` / `admin` / `viewer`); super admins bypass; legacy `adminIds` are read as owners (backward compatible); owners manage members + invites; `owners` field stays super-only @@ -106,7 +106,7 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te - Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured) - Filter events by: event type, repo name, actor, action, branch, keyword (regex supported) - Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true` -- Auto-provision GitHub App installs on `installation.created`: create `inst-{installationId}` group or bind existing groups whose `owners` match the installing account +- Auto-provision GitHub App installs: the App's Setup URL flow (`/auth/github/install` choice page + `POST /auth/github/install/bind`, owner-role verified for existing groups) and the `installation.created` webhook fallback both create `inst-{installationId}` groups or bind existing groups - Enforce role-based access on every admin API: super admins bypass, `owner` manages the group (routes/members/invites/settings), `admin` edits routes, `viewer` is read-only; legacy `adminIds` groups resolve to `owner` members - Issue single-use 7-day group invite links (`invite:{token}`); accepting joins as admin/viewer (never owner); `ALLOW_SELF_SIGNUP=1` creates a deterministic personal group (`u-{userId}`) on first login - Record every admin operation (login/logout, group/route/member/invite changes) to D1 `audit_logs`; the scheduled trigger prunes entries past `AUDIT_RETENTION_DAYS` diff --git a/admin/components/ConsolePage.vue b/admin/components/ConsolePage.vue index be57bfd..2e36705 100644 --- a/admin/components/ConsolePage.vue +++ b/admin/components/ConsolePage.vue @@ -303,6 +303,7 @@ onMounted(() => { const invite = params.get("invite"); if (invite === "ok") push(t("members.inviteOk")); else if (invite != null && invite !== "ok") push(t("members.inviteBad"), "bad"); + if (params.get("install") === "ok") push(t("install.ok")); } loadGroups(); loadLogs(50, logFilterGroup.value || undefined); diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 65e82c4..10aeb3b 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -50,6 +50,8 @@ const en: Dict = { "members.errLastOwner": "Cannot remove the last owner.", "members.inviteOk": "Invite accepted — welcome!", "members.inviteBad": "This invite is invalid or expired.", + "install.ok": + "GitHub App installation registered — the group has been created and bound to this installation.", "webhook.title": "Webhook endpoint", "webhook.note": "(per-group URL + secret)", "webhook.empty": "Not configured. Generate a secret to enable the group's own webhook endpoint.", @@ -302,6 +304,7 @@ const zh: Dict = { "members.errLastOwner": "不能移除最后一位 owner。", "members.inviteOk": "邀请已接受 —— 欢迎!", "members.inviteBad": "该邀请无效或已过期。", + "install.ok": "GitHub App 安装已注册 —— 分组已创建并绑定到该安装。", "webhook.title": "Webhook 入口", "webhook.note": "(分组独立的 URL + secret)", "webhook.empty": "未配置。生成 secret 即可启用该分组的独立 webhook 入口。", diff --git a/docs/api/overview.md b/docs/api/overview.md index 665a19c..d346e21 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -104,7 +104,14 @@ Any JSON payload signed with `X-WebHooker-Signature: sha256=` (HMAC-SHA256 ### GitHub App Installation Events -`installation` webhook events (`created`, ...) are auto-provisioned: a group named after the installing account (`inst-{installationId}`, bound via `installationId`) is created automatically, or existing groups whose `owners` match the installing account are bound to the installation. See [Configuration → GitHub App tenant isolation](../guide/configuration.md#github-app-tenant-isolation). +`installation` webhook events (`created`, ...) are auto-provisioned as a fallback: a group named after the installing account (`inst-{installationId}`, bound via `installationId`) is created automatically, or existing groups whose `owners` match the installing account are bound to the installation. See [Configuration → GitHub App tenant isolation](../guide/configuration.md#github-app-tenant-isolation). + +The primary flow is the App's **Setup URL** — set it to `{BASE_URL}/auth/github/install`. After a user installs the App, the browser lands on: + +| Method | Path | Description | +| ------ | --------------------------- | ---------------------------------------------------------------------------------------------- | +| `GET` | `/auth/github/install` | Choice page: bind the installation to a new group or an existing group the signed-in user owns | +| `POST` | `/auth/github/install/bind` | Provisions the binding (owner role re-checked) and redirects to `/admin?install=ok` | ## Error Format diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 13c364d..45c9f25 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -138,7 +138,7 @@ Payload schema: When the GitHub App is installed, its events arrive at the global endpoint for **every** installation. To keep tenants apart, bind each group to the installation id that should feed it: `"installationId": 12345678`. The id is visible in the App's installation webhook payload (`installation.id`) or on the GitHub App installation page URL. Events from any other installation are rejected for that group even if its `owners` list is empty. Groups without `installationId` keep the legacy behavior (`owners` filtering). -Binding is **auto-configured**: the `installation.created` webhook event creates a dedicated `inst-{installationId}` group (name = the installing account) bound to the installation, or automatically binds every existing group whose `owners` match the installing account. No manual id entry is needed — just install the app, then add routes/members to the auto-created group in the console. +Binding is **auto-configured** — the GitHub App's _Setup URL_ should point to `{BASE_URL}/auth/github/install`. Right after a user installs the App, the browser lands there and they choose where the installation binds: a **new group** (`inst-{installationId}`, default) or any **existing group they own** (owner role checked again on submit; `POST /auth/github/install/bind` performs the provisioning). No manual id entry is needed. As a fallback (e.g. when the Setup URL is not configured), the `installation.created` webhook event creates/binds the group automatically — existing groups whose `owners` match the installing account are bound, otherwise a dedicated `inst-{installationId}` group is created. Then just add routes/members in the console. ## Routes diff --git a/docs/zh/api/overview.md b/docs/zh/api/overview.md index 41c5e0d..9872e19 100644 --- a/docs/zh/api/overview.md +++ b/docs/zh/api/overview.md @@ -104,7 +104,14 @@ POST /webhook ### GitHub App 安装事件 -`installation` webhook 事件(`created` 等)会自动配置:按安装账号自动创建分组(`inst-{installationId}`,绑定 `installationId`);或把 `owners` 匹配该账号的现有分组自动绑定到该安装。参见[配置 → GitHub App 租户隔离](../guide/configuration.md#github-app-租户隔离)。 +`installation` webhook 事件(`created` 等)作为兜底会自动配置:按安装账号自动创建分组(`inst-{installationId}`,绑定 `installationId`);或把 `owners` 匹配该账号的现有分组自动绑定到该安装。参见[配置 → GitHub App 租户隔离](../guide/configuration.md#github-app-租户隔离)。 + +主要流程是 App 的 **Setup URL** —— 将其设置为 `{BASE_URL}/auth/github/install`。用户安装 App 后浏览器会跳转到: + +| 方法 | 路径 | 说明 | +| ------ | --------------------------- | ------------------------------------------------------------- | +| `GET` | `/auth/github/install` | 选择页:将安装绑定到新分组或登录用户拥有 owner 权限的已有分组 | +| `POST` | `/auth/github/install/bind` | 执行绑定(再次校验 owner 角色)并跳转 `/admin?install=ok` | ## 错误格式 diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index 6bf6c8c..d800081 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -138,7 +138,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 GitHub App 安装后,**所有**安装方的事件都会到达全局端点。要让租户互相隔离,请把每个分组绑定到应当为其提供事件的安装 ID:`"installationId": 12345678`。该 ID 可从 App 安装 webhook 载荷(`installation.id`)或 GitHub App 安装页 URL 看到。即使分组的 `owners` 为空,来自其它安装的事件也会被拒绝。未设置 `installationId` 的分组保持旧行为(`owners` 过滤)。 -绑定是**自动配置**的:`installation.created` webhook 事件会自动创建绑定到该安装的专用分组 `inst-{installationId}`(名称为安装账号),或自动把 `owners` 匹配该账号的现有分组绑定到该安装。无需手动填写 ID——安装 App 后在控制台为自动创建的分组添加路由和成员即可。 +绑定是**自动配置**的 —— 将 GitHub App 的 _Setup URL_ 指向 `{BASE_URL}/auth/github/install`。用户安装 App 后浏览器立即跳转到该页面,可选择将安装绑定到:**新分组**(`inst-{installationId}`,默认)或任意**自己拥有 owner 权限的已有分组**(提交时再次校验角色;由 `POST /auth/github/install/bind` 完成配置)。无需手动填写 ID。作为兜底(例如未配置 Setup URL 时),`installation.created` webhook 事件也会自动创建/绑定分组 —— `owners` 匹配安装账号的现有分组会被绑定,否则创建独立的 `inst-{installationId}` 分组。之后在控制台为分组添加路由和成员即可。 ## 路由 diff --git a/src/__tests__/install.test.ts b/src/__tests__/install.test.ts new file mode 100644 index 0000000..edb3914 --- /dev/null +++ b/src/__tests__/install.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { createOAuthRoutes } from "../web/oauth-routes"; +import { createAdminSession, adminCookie } from "../web/session"; +import { loadGroups } from "../web/groups"; +import { getInstallationAccount } from "../github/oauth"; +import type { Env } from "../types"; + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string, type?: string) => { + const v = store.get(key); + if (v == null) return null; + if (type === "json") return JSON.parse(v); + return v; + }, + put: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ + keys: [...store.keys()].map((k) => ({ name: k })), + list_complete: true, + cacheStatus: null, + }), + } as unknown as KVNamespace; +} + +function createMockDB(): D1Database { + return { + prepare: () => ({ + bind: () => ({ + run: async () => ({ success: true }), + all: async () => ({ results: [] }), + }), + }), + } as unknown as D1Database; +} + +function createEnv(overrides: Partial = {}): Env { + return { + GITHUB_WEBHOOK_SECRET: "secret", + KV: createMockKV(), + DB: createMockDB(), + ...overrides, + }; +} + +function bufToPem(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + const b64 = btoa(s); + return `-----BEGIN PRIVATE KEY-----\n${b64.replace(/(.{64})/g, "$1\n")}\n-----END PRIVATE KEY-----`; +} + +async function makeAppKeyPair(): Promise<{ appId: string; pem: string }> { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + return { appId: "12345", pem: bufToPem(pkcs8) }; +} + +describe("getInstallationAccount", () => { + let calledUrls: string[]; + const restoredFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = restoredFetch; + }); + + function mockApi(status: number, body: unknown): void { + calledUrls = []; + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + calledUrls.push(String(input)); + void init; + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + }; + } + + it("returns the installation account login using an App JWT", async () => { + const { appId, pem } = await makeAppKeyPair(); + mockApi(200, { id: 555, account: { login: "myorg" } }); + const login = await getInstallationAccount(appId, pem, 555); + expect(login).toBe("myorg"); + expect(calledUrls[0]).toContain("/app/installations/555"); + }); + + it("returns null when the App credentials are missing", async () => { + expect(await getInstallationAccount("", "", 555)).toBeNull(); + }); + + it("returns null on API errors", async () => { + const { appId, pem } = await makeAppKeyPair(); + mockApi(404, { message: "not found" }); + expect(await getInstallationAccount(appId, pem, 555)).toBeNull(); + }); +}); + +describe("GET /github/install", () => { + const app = createOAuthRoutes(); + const restoredFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + void init; + return Promise.resolve( + new Response(JSON.stringify({ id: 555, account: { login: "myorg" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }; + }); + + afterEach(() => { + globalThis.fetch = restoredFetch; + }); + + it("requires login first and preserves the installation id", async () => { + const env = createEnv(); + const res = await app.request( + "/github/install?installation_id=555", + { headers: { accept: "text/html" } }, + env, + ); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/auth/github?redirect="); + expect(decodeURIComponent(location)).toContain("/github/install?installation_id=555"); + }); + + it("renders a choice page with the groups the user owns", async () => { + const kv = createMockKV(); + await kv.put( + "config:groups", + JSON.stringify([ + { id: "mine", name: "My Team", adminIds: [], members: [{ login: "alice", role: "owner" }] }, + { + id: "theirs", + name: "Other Team", + adminIds: [], + members: [{ login: "bob", role: "owner" }], + }, + ]), + ); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + + const res = await app.request( + "/github/install?installation_id=555&setup_action=install", + { headers: { cookie: adminCookie(sessionId), accept: "text/html" } }, + env, + ); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("inst-555"); + expect(html).toContain("My Team"); + expect(html).toContain('value="mine"'); + expect(html).not.toContain('value="theirs"'); + }); + + it("rejects a missing installation id", async () => { + const env = createEnv(); + const res = await app.request("/github/install", { headers: { accept: "text/html" } }, env); + expect(res.status).toBe(400); + }); +}); + +describe("POST /github/install/bind", () => { + const app = createOAuthRoutes(); + const restoredFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + void init; + return Promise.resolve( + new Response(JSON.stringify({ id: 555, account: { login: "myorg" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }; + }); + + afterEach(() => { + globalThis.fetch = restoredFetch; + }); + + function formBody(params: Record): string { + return new URLSearchParams(params).toString(); + } + + it("binds the installation to an existing group the user owns", async () => { + const kv = createMockKV(); + await kv.put( + "config:groups", + JSON.stringify([ + { id: "mine", name: "My Team", adminIds: [], members: [{ login: "alice", role: "owner" }] }, + ]), + ); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + + const res = await app.request( + "/github/install/bind", + { + method: "POST", + headers: { + cookie: adminCookie(sessionId), + "content-type": "application/x-www-form-urlencoded", + }, + body: formBody({ installation_id: "555", group: "mine" }), + }, + env, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/admin?install=ok"); + + const groups = await loadGroups(kv); + expect(groups).toHaveLength(1); + expect(groups[0]!.id).toBe("mine"); + expect(groups[0]!.installationId).toBe(555); + }); + + it("refuses to bind to a group the user does not own", async () => { + const kv = createMockKV(); + await kv.put( + "config:groups", + JSON.stringify([ + { + id: "theirs", + name: "Other Team", + adminIds: [], + members: [{ login: "bob", role: "owner" }], + }, + ]), + ); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + + const res = await app.request( + "/github/install/bind", + { + method: "POST", + headers: { + cookie: adminCookie(sessionId), + "content-type": "application/x-www-form-urlencoded", + }, + body: formBody({ installation_id: "555", group: "theirs" }), + }, + env, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/admin?error=forbidden"); + + const groups = await loadGroups(kv); + expect(groups[0]!.installationId).toBeUndefined(); + }); + + it("auto-creates inst-{id} and joins the installer as owner (self-signup)", async () => { + const kv = createMockKV(); + const { appId, pem } = await makeAppKeyPair(); + const env = createEnv({ + KV: kv, + ALLOW_SELF_SIGNUP: "1", + GITHUB_APP_ID: appId, + GITHUB_PRIVATE_KEY: pem, + }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + + const res = await app.request( + "/github/install/bind", + { + method: "POST", + headers: { + cookie: adminCookie(sessionId), + "content-type": "application/x-www-form-urlencoded", + }, + body: formBody({ installation_id: "555", group: "" }), + }, + env, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/admin?install=ok"); + + const groups = await loadGroups(kv); + const group = groups.find((g) => g.id === "inst-555"); + expect(group).toBeDefined(); + expect(group?.installationId).toBe(555); + expect(group?.name).toBe("myorg"); + expect(group?.members).toContainEqual({ login: "alice", role: "owner" }); + }); + + it("auto-creates inst-{id} without joining members when self-signup is off", async () => { + const kv = createMockKV(); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + + const res = await app.request( + "/github/install/bind", + { + method: "POST", + headers: { + cookie: adminCookie(sessionId), + "content-type": "application/x-www-form-urlencoded", + }, + body: formBody({ installation_id: "555", group: "" }), + }, + env, + ); + expect(res.status).toBe(302); + const groups = await loadGroups(kv); + const group = groups.find((g) => g.installationId === 555); + expect(group).toBeDefined(); + expect(group?.members ?? []).toHaveLength(0); + }); + + it("requires login to bind", async () => { + const env = createEnv(); + const res = await app.request( + "/github/install/bind", + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: formBody({ installation_id: "555", group: "" }), + }, + env, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/admin?error=forbidden"); + }); +}); diff --git a/src/github/oauth.ts b/src/github/oauth.ts index 10806b6..50544d0 100644 --- a/src/github/oauth.ts +++ b/src/github/oauth.ts @@ -5,6 +5,69 @@ export function getOAuthURL(clientId: string, state: string): string { return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=repo&state=${state}`; } +function b64url(buf: ArrayBuffer | string): string { + const bytes = typeof buf === "string" ? new TextEncoder().encode(buf) : new Uint8Array(buf); + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function pemToBinary(pem: string): ArrayBuffer { + const base64 = pem + .replace(/-----BEGIN [^-]+-----/, "") + .replace(/-----END [^-]+-----/, "") + .replace(/\s+/g, ""); + const bin = atob(base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; +} + +/** GitHub App JWT (RS256, PKCS#8 PEM key), valid ~10 minutes. */ +async function createAppJwt(appId: string, privateKey: string): Promise { + const now = Math.floor(Date.now() / 1000); + const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = b64url(JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId })); + const data = `${header}.${payload}`; + const key = await crypto.subtle.importKey( + "pkcs8", + pemToBinary(privateKey), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(data)); + return `${data}.${b64url(sig)}`; +} + +/** + * Look up the account (org/user login) that owns a GitHub App installation, + * using an App JWT. Returns null when the App credentials are missing or the + * lookup fails (the caller falls back to an anonymous installation group). + */ +export async function getInstallationAccount( + appId: string, + privateKey: string, + installationId: number, +): Promise { + if (!appId || !privateKey) return null; + try { + const jwt = await createAppJwt(appId, privateKey); + const res = await fetch(`https://api.github.com/app/installations/${installationId}`, { + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { account?: { login?: string } }; + return data.account?.login ?? null; + } catch { + return null; + } +} + export async function handleOAuthCallback( clientId: string, clientSecret: string, diff --git a/src/web/oauth-routes.ts b/src/web/oauth-routes.ts index e215855..aa1efd2 100644 --- a/src/web/oauth-routes.ts +++ b/src/web/oauth-routes.ts @@ -1,8 +1,16 @@ import { Hono } from "hono"; -import { getOAuthURL, handleOAuthCallback } from "../github/oauth"; +import { getOAuthURL, handleOAuthCallback, getInstallationAccount } from "../github/oauth"; import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store"; import { createAdminSession, adminCookie, getAdminSession } from "./session"; -import { loadGroups, saveGroups, resolveScope, hasAnyAccess } from "./groups"; +import { + loadGroups, + saveGroups, + resolveScope, + hasAnyAccess, + ensureInstallationGroup, + normalizeGroupMembers, + roleAt, +} from "./groups"; import { clientIp } from "./auth"; import { recordAudit } from "../lib/audit"; import { sendMessage } from "../drivers/telegram/rest"; @@ -36,6 +44,14 @@ function safeRedirectPath(value: string | undefined): string { return value; } +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + function selfSignupEnabled(env: Env): boolean { const flag = (env.ALLOW_SELF_SIGNUP ?? "").trim().toLowerCase(); return flag === "1" || flag === "true" || flag === "yes" || flag === "on"; @@ -71,6 +87,32 @@ async function ensurePersonalGroup(env: Env, userId: string, login: string): Pro return true; } +/** + * Post-install choice page: pick which group the installation binds to. + * Options are the groups the signed-in user owns (role `owner`), plus a + * default "create a new group" choice. + */ +function installPage(opts: { + installationId: number; + accountLogin: string; + owned: Group[]; +}): string { + const { installationId, accountLogin, owned } = opts; + const accountLine = accountLogin + ? `

账号:${escapeHtml(accountLogin)}(安装 ID ${installationId}

` + : `

安装 ID:${installationId}

`; + const ownedOptions = owned + .map( + (g) => + ``, + ) + .join(""); + const ownedNote = owned.length + ? '

也可以选择绑定到你有 owner 权限的已有分组:

' + : ""; + return `安装 GitHub App

GitHub App 安装成功

${accountLine}

将安装绑定到哪个分组?建议直接创建新分组,之后可以在控制台添加路由与成员。

${ownedNote}${ownedOptions}
`; +} + export function createOAuthRoutes(): Hono<{ Bindings: Env }> { const app = new Hono<{ Bindings: Env }>(); @@ -90,6 +132,140 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> { return c.redirect(url); }); + /** + * GitHub App post-install redirect: the App's "Setup URL" points here, so + * the browser lands on this route right after a user installs the App on an + * org/user — before any webhook event arrives. The user picks which group + * the installation binds to (an existing group they own, or a new + * auto-created `inst-{id}` group); the actual provisioning happens on + * `POST /auth/github/install/bind`. + */ + app.get("/github/install", async (c) => { + const rawId = c.req.query("installation_id"); + const installationId = Number(rawId); + if (!rawId || !Number.isInteger(installationId) || installationId <= 0) { + return c.json({ error: "Missing installation_id" }, 400); + } + const session = await getAdminSession(c.env.KV, c.req.header("cookie")); + if (!session) { + const target = `/auth/github/install?installation_id=${installationId}`; + return c.redirect(`/auth/github?redirect=${encodeURIComponent(target)}`); + } + + const accountLogin = + (await getInstallationAccount( + c.env.GITHUB_APP_ID ?? "", + c.env.GITHUB_PRIVATE_KEY ?? "", + installationId, + )) ?? ""; + const groups = await loadGroups(c.env.KV); + const scope = resolveScope(c.env, groups, session.userId, session.login); + const owned = groups.filter((g) => roleAt(scope, g.id) === "owner"); + + return c.html(installPage({ installationId, accountLogin, owned })); + }); + + app.post("/github/install/bind", async (c) => { + const session = await getAdminSession(c.env.KV, c.req.header("cookie")); + if (!session) { + return c.redirect("/admin?error=forbidden"); + } + const body = await c.req.parseBody(); + const rawId = String(body["installation_id"] ?? ""); + const installationId = Number(rawId); + if (!Number.isInteger(installationId) || installationId <= 0) { + return c.json({ error: "Missing installation_id" }, 400); + } + const chosenGroupId = String(body["group"] ?? "").trim(); + const groups = await loadGroups(c.env.KV); + const scope = resolveScope(c.env, groups, session.userId, session.login); + + const bind = async (groupId: string, group: Group | null): Promise => { + if (!group) { + return c.redirect("/admin?error=install"); + } + const next = groups.map((g) => (g.id === group.id ? { ...g, installationId } : g)); + await saveGroups(c.env.KV, next); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: session.userId, + actorLogin: session.login, + action: "installation.bind", + targetType: "group", + targetId: groupId, + groupId, + detail: { installationId }, + ip: clientIp(c), + }); + return c.redirect("/admin?install=ok"); + }; + + if (chosenGroupId) { + // Binding to an existing group requires owner permission on it. + const group = groups.find((g) => g.id === chosenGroupId); + if (!group || roleAt(scope, chosenGroupId) !== "owner") { + return c.redirect("/admin?error=forbidden"); + } + return bind(chosenGroupId, group); + } + + // Default: auto-create a dedicated inst-{id} group. + const accountLogin = + (await getInstallationAccount( + c.env.GITHUB_APP_ID ?? "", + c.env.GITHUB_PRIVATE_KEY ?? "", + installationId, + )) ?? ""; + const group = await ensureInstallationGroup(c.env.KV, installationId, accountLogin); + if (!group) { + return c.redirect("/admin?error=install"); + } + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: session.userId, + actorLogin: session.login, + action: "installation.created", + targetType: "group", + targetId: group.id, + groupId: group.id, + detail: { source: "setup_url", account: accountLogin || undefined }, + ip: clientIp(c), + }); + + // Self-service SaaS: the installer manages their own auto-created group. + if (selfSignupEnabled(c.env)) { + const members = normalizeGroupMembers(group); + const alreadyMember = members.some( + (m) => m.login.toLowerCase() === session.login.toLowerCase() || m.login === session.userId, + ); + if (!alreadyMember) { + const updated: Group = { + ...group, + members: [...members, { login: session.login, role: "owner" }], + adminIds: [...new Set([...(group.adminIds ?? []), session.login])], + }; + const all = await loadGroups(c.env.KV); + await saveGroups( + c.env.KV, + all.map((g) => (g.id === group.id ? updated : g)), + ); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: session.userId, + actorLogin: session.login, + action: "group.member.add", + targetType: "group", + targetId: group.id, + groupId: group.id, + detail: { login: session.login, role: "owner", auto: true }, + ip: clientIp(c), + }); + } + } + + return c.redirect("/admin?install=ok"); + }); + app.get("/github/callback", async (c) => { const code = c.req.query("code"); const state = c.req.query("state");