import type { H3Event } from "h3"; import { appendResponseHeader, createError, getHeader, getQuery, readBody, sendRedirect, setResponseStatus, } from "h3"; import { getOAuthURL, handleOAuthCallback as handleGithubOAuthCallback, getInstallationAccount, } from "../github/oauth"; import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store"; import { createAdminSession, adminCookie, getAdminSession } from "./session"; 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"; import { cfEnv } from "../cf"; import { initConfigStore } from "../config"; import type { Env, Group } from "../types"; interface PendingState { redirectTo: string; expiresAt: number; discordUserId?: string; telegramUserId?: string; telegramChatId?: string; } function linkedPage(login: string): string { return `绑定成功

GitHub 账号已绑定

已连接为 @${login},现在可以回到 Discord 用 GitHub 评论了。

`; } function generateRandomHex(length: number): string { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } function safeRedirectPath(value: string | undefined): string { if (!value) return "/"; if (!value.startsWith("/")) return "/"; if (value.startsWith("//")) return "/"; if (/^\/\\/.test(value)) return "/"; 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"; } /** * Opt-in self service: users without any group access get a personal group * they own, so they can configure their own routing without a super admin. * The group id is deterministic (`u-{userId}`), so it is created at most once. */ async function ensurePersonalGroup(env: Env, userId: string, login: string): Promise { if (!selfSignupEnabled(env)) return false; const groups = await loadGroups(env.KV); const gid = `u-${userId}`; if (groups.some((g) => g.id === gid)) return true; const personal: Group = { id: gid, name: `@${login}`, members: [{ login, role: "owner" }], adminIds: [login], }; await saveGroups(env.KV, [...groups, personal]); await recordAudit(env.DB, { ts: Date.now(), actorId: userId, actorLogin: login, action: "group.create", targetType: "group", targetId: gid, groupId: gid, detail: { auto: true }, }); 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}
`; } /** GET /auth/github — start the OAuth flow. */ export async function handleOAuthStart(event: H3Event): Promise { const env = cfEnv(event); initConfigStore(env); const query = getQuery(event); const redirectTo = safeRedirectPath(String(query["redirect"] ?? "")); const state = generateRandomHex(16); const pending: PendingState = { redirectTo, expiresAt: Date.now() + 10 * 60 * 1000, }; await env.KV.put(`state:${state}`, JSON.stringify(pending), { expirationTtl: 600 }); await sendRedirect(event, getOAuthURL(env.GITHUB_CLIENT_ID ?? "", state)); } /** GET /auth/github/install — post-install choice page. */ export async function handleInstallPage(event: H3Event): Promise { const env = cfEnv(event); initConfigStore(env); const query = getQuery(event); const rawId = String(query["installation_id"] ?? ""); const installationId = Number(rawId); if (!rawId || !Number.isInteger(installationId) || installationId <= 0) { throw createError({ statusCode: 400, statusMessage: "Missing installation_id" }); } const session = await getAdminSession(env.KV, getHeader(event, "cookie")); if (!session) { const target = `/auth/github/install?installation_id=${installationId}`; await sendRedirect(event, `/auth/github?redirect=${encodeURIComponent(target)}`); return; } const accountLogin = (await getInstallationAccount( env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId, )) ?? ""; const groups = await loadGroups(env.KV); const scope = resolveScope(env, groups, session.userId, session.login); const owned = groups.filter((g) => roleAt(scope, g.id) === "owner"); return installPage({ installationId, accountLogin, owned }); } /** POST /auth/github/install/bind — provision the chosen binding. */ export async function handleInstallBind(event: H3Event): Promise { const env = cfEnv(event); initConfigStore(env); const session = await getAdminSession(env.KV, getHeader(event, "cookie")); if (!session) { await sendRedirect(event, "/admin?error=forbidden"); return; } const body = (await readBody(event).catch(() => ({}))) as Record; const rawId = String(body["installation_id"] ?? ""); const installationId = Number(rawId); if (!Number.isInteger(installationId) || installationId <= 0) { throw createError({ statusCode: 400, statusMessage: "Missing installation_id" }); } const chosenGroupId = String(body["group"] ?? "").trim(); const groups = await loadGroups(env.KV); const scope = resolveScope(env, groups, session.userId, session.login); const bind = async (groupId: string, group: Group | null): Promise => { if (!group) { await sendRedirect(event, "/admin?error=install"); return; } const next = groups.map((g) => (g.id === group.id ? { ...g, installationId } : g)); await saveGroups(env.KV, next); await recordAudit(env.DB, { ts: Date.now(), actorId: session.userId, actorLogin: session.login, action: "installation.bind", targetType: "group", targetId: groupId, groupId, detail: { installationId }, ip: clientIp(event), }); await sendRedirect(event, "/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") { await sendRedirect(event, "/admin?error=forbidden"); return; } return bind(chosenGroupId, group); } // Default: auto-create a dedicated inst-{id} group. const accountLogin = (await getInstallationAccount( env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId, )) ?? ""; const group = await ensureInstallationGroup(env.KV, installationId, accountLogin); if (!group) { await sendRedirect(event, "/admin?error=install"); return; } await recordAudit(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(event), }); // Self-service SaaS: the installer manages their own auto-created group. if (selfSignupEnabled(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(env.KV); await saveGroups( env.KV, all.map((g) => (g.id === group.id ? updated : g)), ); await recordAudit(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(event), }); } } await sendRedirect(event, "/admin?install=ok"); } /** GET /auth/github/callback — OAuth callback. */ export async function handleOAuthCallback(event: H3Event): Promise { const env = cfEnv(event); initConfigStore(env); const query = getQuery(event); const code = String(query["code"] ?? ""); const state = String(query["state"] ?? ""); if (!code || !state) { setResponseStatus(event, 400); return { error: "Missing code or state" }; } const raw = await env.KV.get(`state:${state}`, "json"); if (!raw) { setResponseStatus(event, 400); return { error: "Invalid or expired state" }; } const pending = raw as PendingState; if (Date.now() > pending.expiresAt) { await env.KV.delete(`state:${state}`); setResponseStatus(event, 400); return { error: "Invalid or expired state" }; } await env.KV.delete(`state:${state}`); const result = await handleGithubOAuthCallback( env.GITHUB_CLIENT_ID ?? "", env.GITHUB_CLIENT_SECRET ?? "", code, state, env.KV, ); if (!result) { setResponseStatus(event, 400); return { error: "OAuth failed" }; } // Discord account-linking flow: bind the Discord user to this GitHub account. if (pending.discordUserId) { await saveDiscordLink(env.DB, pending.discordUserId, result.userId); const isBrowserLink = (getHeader(event, "accept") ?? "").includes("text/html"); if (isBrowserLink) return linkedPage(result.login); return { ok: true, discordUserId: pending.discordUserId, login: result.login }; } // Telegram account-linking flow: bind the Telegram user to this GitHub account. if (pending.telegramUserId) { await saveTelegramLink(env.DB, pending.telegramUserId, result.userId); if (pending.telegramChatId && env.TELEGRAM_TOKEN) { await sendMessage( env.TELEGRAM_TOKEN, pending.telegramChatId, `✅ GitHub 账号已绑定:**@${result.login}**。现在可以用 /gh comment 评论了。`, ).catch(() => undefined); } return { ok: true, telegramUserId: pending.telegramUserId, login: result.login }; } const isBrowser = (getHeader(event, "accept") ?? "").includes("text/html"); if (isBrowser) { // Invite accept flow: the redirect target is the invite page, which // processes the token after the session exists. Skip the access gate so // non-members can get in and accept. const isInviteFlow = pending.redirectTo.startsWith("/admin/invite") || pending.redirectTo.startsWith("/admin/invite?"); let groups = await loadGroups(env.KV); let scope = resolveScope(env, groups, result.userId, result.login); if (!hasAnyAccess(scope) && !isInviteFlow) { const created = await ensurePersonalGroup(env, result.userId, result.login); if (created) { groups = await loadGroups(env.KV); scope = resolveScope(env, groups, result.userId, result.login); } } if (!hasAnyAccess(scope) && !isInviteFlow) { await sendRedirect(event, "/admin?error=forbidden"); return; } const sessionId = await createAdminSession(env.KV, result.userId, result.login); appendResponseHeader(event, "Set-Cookie", adminCookie(sessionId)); await recordAudit(env.DB, { ts: Date.now(), actorId: result.userId, actorLogin: result.login, action: "session.login", ip: clientIp(event), }); await sendRedirect(event, pending.redirectTo); return; } return { userId: result.userId, login: result.login, redirectTo: pending.redirectTo, }; } /** DELETE /auth/token/:userId — revoke a stored user token. */ export async function handleTokenDelete(event: H3Event, userId: string): Promise { const env = cfEnv(event); const session = await getAdminSession(env.KV, getHeader(event, "cookie")); if (!session) { setResponseStatus(event, 401); return { error: "Unauthorized" }; } await removeToken(env.KV, userId); await recordAudit(env.DB, { ts: Date.now(), actorId: session.userId, actorLogin: session.login, action: "token.delete", targetType: "token", targetId: userId, ip: clientIp(event), }); return { ok: true }; }