mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
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 type { Env, Group } from "../types";
|
||
|
||
interface PendingState {
|
||
redirectTo: string;
|
||
expiresAt: number;
|
||
discordUserId?: string;
|
||
telegramUserId?: string;
|
||
telegramChatId?: string;
|
||
}
|
||
|
||
function linkedPage(login: string): string {
|
||
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>绑定成功</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:32px 40px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.06)}.ok{color:#16a34a;font-size:40px}h1{font-size:18px;margin:12px 0 4px}p{color:#57606a;font-size:14px;margin:0}</style></head><body><div class="card"><div class="ok">✓</div><h1>GitHub 账号已绑定</h1><p>已连接为 <b>@${login}</b>,现在可以回到 Discord 用 GitHub 评论了。</p></div></body></html>`;
|
||
}
|
||
|
||
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, ">")
|
||
.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<boolean> {
|
||
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
|
||
? `<p>账号:<b>${escapeHtml(accountLogin)}</b>(安装 ID <code>${installationId}</code>)</p>`
|
||
: `<p>安装 ID:<code>${installationId}</code></p>`;
|
||
const ownedOptions = owned
|
||
.map(
|
||
(g) =>
|
||
`<label class="opt"><input type="radio" name="group" value="${escapeHtml(g.id)}"><span><b>${escapeHtml(g.name)}</b> <code>${escapeHtml(g.id)}</code></span></label>`,
|
||
)
|
||
.join("");
|
||
const ownedNote = owned.length
|
||
? '<p class="hint">也可以选择绑定到你有 owner 权限的已有分组:</p>'
|
||
: "";
|
||
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安装 GitHub App</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 32px;width:min(480px,92vw);box-shadow:0 1px 3px rgba(0,0,0,.06)}h1{font-size:17px;margin:0 0 4px}p{color:#57606a;font-size:13.5px;margin:6px 0}code{background:#f0f1f3;border-radius:4px;padding:1px 5px;font-size:12.5px}.opt{display:flex;align-items:flex-start;gap:8px;padding:9px 10px;border:1px solid #e5e7eb;border-radius:8px;margin-top:8px;cursor:pointer}.opt:hover{background:#fafbfc}.hint{font-size:12.5px;color:#8b949e;margin-top:10px}.btn{display:inline-block;margin-top:14px;background:#1f2328;color:#fff;border:0;border-radius:8px;padding:10px 18px;font-size:14px;cursor:pointer}.btn:hover{background:#32383f}.skip{margin-left:12px;color:#8b949e;font-size:13px;text-decoration:none}</style></head><body><div class="card"><h1>GitHub App 安装成功</h1>${accountLine}<p>将安装绑定到哪个分组?建议直接创建新分组,之后可以在控制台添加路由与成员。</p><form method="post" action="/auth/github/install/bind"><input type="hidden" name="installation_id" value="${installationId}"><label class="opt"><input type="radio" name="group" value="" checked><span><b>创建新分组</b> <code>inst-${installationId}</code></span></label>${ownedNote}${ownedOptions}<button class="btn" type="submit">确定</button></form></div></body></html>`;
|
||
}
|
||
|
||
/** GET /auth/github — start the OAuth flow. */
|
||
export async function handleOAuthStart(event: H3Event): Promise<void> {
|
||
const env = cfEnv(event);
|
||
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<string | void> {
|
||
const env = cfEnv(event);
|
||
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<void> {
|
||
const env = cfEnv(event);
|
||
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<string, string | undefined>;
|
||
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<void> => {
|
||
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<unknown> {
|
||
const env = cfEnv(event);
|
||
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<unknown> {
|
||
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 };
|
||
}
|