mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
389
server/lib/web/oauth.ts
Normal file
389
server/lib/web/oauth.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue