mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat(admin): GitHub OAuth login, admin whitelist and Nuxt WebUI
Add an admin WebUI (Nuxt static SPA in admin/, served via the ASSETS binding) for managing routes and viewing send logs. Access is gated by GitHub OAuth plus an ADMIN_USER_IDS whitelist with cookie sessions (admin-session.ts). Expose routes/logs CRUD API (admin-routes.ts), add a discord-link mapping in token-store.ts, and mount admin routes with an SPA assets fallback in the server.
This commit is contained in:
parent
cfdf37e273
commit
407514f3c9
20 changed files with 3242 additions and 13 deletions
132
src/admin-routes.ts
Normal file
132
src/admin-routes.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env, Route } from "./types";
|
||||
import { loadRoutes, saveRoutes } from "./config";
|
||||
import {
|
||||
isAdminUser,
|
||||
getAdminSession,
|
||||
destroyAdminSession,
|
||||
clearAdminCookie,
|
||||
} from "./admin-session";
|
||||
import { getSendLog } from "./send-log";
|
||||
import { log } from "./log";
|
||||
|
||||
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
|
||||
|
||||
function isValidMatch(match: unknown): match is string | string[] {
|
||||
if (typeof match === "string") return match.trim().length > 0;
|
||||
if (Array.isArray(match))
|
||||
return match.length > 0 && match.every((m) => typeof m === "string" && m.trim().length > 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateRoutes(
|
||||
routes: unknown,
|
||||
): { ok: true; routes: Route[] } | { ok: false; error: string } {
|
||||
if (!Array.isArray(routes)) return { ok: false, error: "routes must be an array" };
|
||||
if (routes.length > 200) return { ok: false, error: "too many routes" };
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
const r = routes[i] as Record<string, unknown>;
|
||||
if (!r || typeof r !== "object") return { ok: false, error: `route[${i}] is not an object` };
|
||||
if (typeof r.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(r.id)) {
|
||||
return { ok: false, error: `route[${i}].id is invalid` };
|
||||
}
|
||||
if (seen.has(r.id)) return { ok: false, error: `duplicate route id "${r.id}"` };
|
||||
seen.add(r.id);
|
||||
if (typeof r.name !== "string" || r.name.trim().length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs a name` };
|
||||
}
|
||||
if (typeof r.enabled !== "boolean")
|
||||
return { ok: false, error: `route "${r.id}".enabled must be boolean` };
|
||||
if (r.lang !== undefined && typeof r.lang !== "string") {
|
||||
return { ok: false, error: `route "${r.id}".lang must be a string` };
|
||||
}
|
||||
if (!Array.isArray(r.filters) || r.filters.length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs at least one filter` };
|
||||
}
|
||||
for (let j = 0; j < r.filters.length; j++) {
|
||||
const f = r.filters[j] as Record<string, unknown>;
|
||||
if (!f || typeof f !== "object")
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] invalid` };
|
||||
if (!VALID_FILTER_TYPES.has(f.type as string)) {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] has unknown type` };
|
||||
}
|
||||
if (!isValidMatch(f.match)) {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] needs a match value` };
|
||||
}
|
||||
if (f.exclude !== undefined && typeof f.exclude !== "boolean") {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` };
|
||||
}
|
||||
}
|
||||
const target = r.target as Record<string, unknown> | undefined;
|
||||
if (!target || typeof target !== "object")
|
||||
return { ok: false, error: `route "${r.id}" needs a target` };
|
||||
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
|
||||
return { ok: false, error: `route "${r.id}".target.channelId is required` };
|
||||
if (target.threadId !== undefined && typeof target.threadId !== "string") {
|
||||
return { ok: false, error: `route "${r.id}".target.threadId must be a string` };
|
||||
}
|
||||
}
|
||||
return { ok: true, routes: routes as Route[] };
|
||||
}
|
||||
|
||||
export function createAdminRoutes(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
async function requireAdmin(c: {
|
||||
env: Env;
|
||||
req: { header: (name: string) => string | undefined };
|
||||
}): Promise<{ userId: string; login: string } | null> {
|
||||
const session = await getAdminSession(c.env.KV, c.req.header("cookie"));
|
||||
if (!session) return null;
|
||||
if (!isAdminUser(c.env, session.userId, session.login)) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
app.get("/login", (c) => {
|
||||
return c.redirect("/auth/github?redirect=/");
|
||||
});
|
||||
|
||||
app.get("/logout", async (c) => {
|
||||
await destroyAdminSession(c.env.KV, c.req.header("cookie"));
|
||||
c.header("Set-Cookie", clearAdminCookie());
|
||||
return c.redirect("/");
|
||||
});
|
||||
|
||||
app.get("/api/routes", async (c) => {
|
||||
if (!(await requireAdmin(c))) return c.json({ error: "Unauthorized" }, 401);
|
||||
const routes = await loadRoutes(c.env.KV);
|
||||
return c.json({ routes });
|
||||
});
|
||||
|
||||
app.get("/api/logs", async (c) => {
|
||||
if (!(await requireAdmin(c))) return c.json({ error: "Unauthorized" }, 401);
|
||||
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
|
||||
const logs = await getSendLog(c.env.KV, limit);
|
||||
return c.json({ logs });
|
||||
});
|
||||
|
||||
app.put("/api/routes", async (c) => {
|
||||
if (!(await requireAdmin(c))) return c.json({ error: "Unauthorized" }, 401);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
const result = validateRoutes((body as { routes?: unknown })?.routes);
|
||||
if (!result.ok) return c.json({ error: result.error }, 400);
|
||||
|
||||
try {
|
||||
await saveRoutes(c.env.KV, result.routes);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to save routes");
|
||||
return c.json({ error: "Failed to save routes" }, 500);
|
||||
}
|
||||
log.info({ count: result.routes.length }, "Routes updated via admin UI");
|
||||
return c.json({ ok: true, count: result.routes.length });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
77
src/admin-session.ts
Normal file
77
src/admin-session.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { Env } from "./types";
|
||||
|
||||
const SESSION_COOKIE = "wh_admin_session";
|
||||
const SESSION_TTL = 7 * 24 * 3600;
|
||||
|
||||
export interface AdminSession {
|
||||
userId: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
export function isAdminUser(env: Env, userId: string, login: string): boolean {
|
||||
const ids = (env.ADMIN_USER_IDS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (ids.length === 0) return false;
|
||||
return ids.includes(userId) || ids.some((id) => id.toLowerCase() === login.toLowerCase());
|
||||
}
|
||||
|
||||
function generateSessionId(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function createAdminSession(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
login: string,
|
||||
): Promise<string> {
|
||||
const sessionId = generateSessionId();
|
||||
const session: AdminSession = { userId, login };
|
||||
await kv.put(`session:${sessionId}`, JSON.stringify(session), {
|
||||
expirationTtl: SESSION_TTL,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function adminCookie(sessionId: string): string {
|
||||
return `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL}`;
|
||||
}
|
||||
|
||||
export function clearAdminCookie(): string {
|
||||
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
|
||||
function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getAdminSession(
|
||||
kv: KVNamespace,
|
||||
cookieHeader: string | undefined,
|
||||
): Promise<AdminSession | null> {
|
||||
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (!sessionId) return null;
|
||||
const raw = await kv.get<AdminSession>(`session:${sessionId}`, "json");
|
||||
if (!raw) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function destroyAdminSession(
|
||||
kv: KVNamespace,
|
||||
cookieHeader: string | undefined,
|
||||
): Promise<void> {
|
||||
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (sessionId) await kv.delete(`session:${sessionId}`);
|
||||
}
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import { Hono } from "hono";
|
||||
import { getOAuthURL, handleOAuthCallback } from "./github-oauth";
|
||||
import { removeToken } from "./token-store";
|
||||
import { removeToken, saveDiscordLink } from "./token-store";
|
||||
import { isAdminUser, createAdminSession, adminCookie } from "./admin-session";
|
||||
import type { Env } from "./types";
|
||||
|
||||
interface PendingState {
|
||||
redirectTo: string;
|
||||
expiresAt: number;
|
||||
discordUserId?: 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 {
|
||||
|
|
@ -66,6 +72,26 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
|
|||
return c.json({ error: "OAuth failed" }, 400);
|
||||
}
|
||||
|
||||
// Discord account-linking flow: bind the Discord user to this GitHub account.
|
||||
if (pending.discordUserId) {
|
||||
await saveDiscordLink(c.env.KV, pending.discordUserId, result.userId);
|
||||
const isBrowserLink = (c.req.header("accept") ?? "").includes("text/html");
|
||||
if (isBrowserLink) {
|
||||
return c.html(linkedPage(result.login));
|
||||
}
|
||||
return c.json({ ok: true, discordUserId: pending.discordUserId, login: result.login });
|
||||
}
|
||||
|
||||
const isBrowser = (c.req.header("accept") ?? "").includes("text/html");
|
||||
if (isBrowser) {
|
||||
if (!isAdminUser(c.env, result.userId, result.login)) {
|
||||
return c.redirect("/?error=forbidden");
|
||||
}
|
||||
const sessionId = await createAdminSession(c.env.KV, result.userId, result.login);
|
||||
c.header("Set-Cookie", adminCookie(sessionId));
|
||||
return c.redirect(pending.redirectTo);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
userId: result.userId,
|
||||
login: result.login,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { verifySignature, parseEvent } from "./webhook";
|
|||
import { dispatchEvent } from "./discord";
|
||||
import { createOAuthRoutes } from "./oauth-routes";
|
||||
import { createActionRoutes } from "./action-routes";
|
||||
import { createAdminRoutes } from "./admin-routes";
|
||||
import { log } from "./log";
|
||||
|
||||
const MAX_BODY_SIZE = 1024 * 1024;
|
||||
|
|
@ -15,6 +16,7 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
|||
|
||||
app.route("/auth", createOAuthRoutes());
|
||||
app.route("/", createActionRoutes());
|
||||
app.route("/admin", createAdminRoutes());
|
||||
|
||||
app.post("/webhook", async (c) => {
|
||||
const contentLength = Number(c.req.header("content-length") ?? 0);
|
||||
|
|
@ -50,5 +52,12 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
|||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.notFound((c) => {
|
||||
if (c.env.ASSETS) {
|
||||
return c.env.ASSETS.fetch(c.req.raw);
|
||||
}
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ interface StoredToken {
|
|||
refreshToken?: string;
|
||||
}
|
||||
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(token);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function saveToken(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
|
|
@ -20,6 +28,9 @@ export async function saveToken(
|
|||
};
|
||||
const ttl = Math.max(Math.floor(expiresInSeconds * 0.9), 60);
|
||||
await kv.put(`token:${userId}`, JSON.stringify(token), { expirationTtl: ttl });
|
||||
|
||||
const tokenHash = await hashToken(accessToken);
|
||||
await kv.put(`token-reverse:${tokenHash}`, userId, { expirationTtl: ttl });
|
||||
}
|
||||
|
||||
export async function getToken(kv: KVNamespace, userId: string): Promise<string | null> {
|
||||
|
|
@ -40,6 +51,12 @@ export async function getRefreshToken(kv: KVNamespace, userId: string): Promise<
|
|||
}
|
||||
|
||||
export async function removeToken(kv: KVNamespace, userId: string): Promise<void> {
|
||||
const raw = await kv.get(`token:${userId}`, "json");
|
||||
if (raw) {
|
||||
const t = raw as StoredToken;
|
||||
const tokenHash = await hashToken(t.accessToken);
|
||||
await kv.delete(`token-reverse:${tokenHash}`);
|
||||
}
|
||||
await kv.delete(`token:${userId}`);
|
||||
}
|
||||
|
||||
|
|
@ -47,16 +64,32 @@ export async function findUserIdByToken(
|
|||
kv: KVNamespace,
|
||||
accessToken: string,
|
||||
): Promise<string | null> {
|
||||
const list = await kv.list({ prefix: "token:" });
|
||||
for (const key of list.keys) {
|
||||
const raw = await kv.get(key.name, "json");
|
||||
if (!raw) continue;
|
||||
const t = raw as StoredToken;
|
||||
if (Date.now() >= t.expiresAt) {
|
||||
await kv.delete(key.name);
|
||||
continue;
|
||||
}
|
||||
if (t.accessToken === accessToken) return t.userId;
|
||||
}
|
||||
return null;
|
||||
const tokenHash = await hashToken(accessToken);
|
||||
return await kv.get(`token-reverse:${tokenHash}`, "text");
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a Discord user id to a GitHub user id so that bot commands can act
|
||||
* as that GitHub account. The actual OAuth token lives under `token:{githubUserId}`.
|
||||
*/
|
||||
export async function saveDiscordLink(
|
||||
kv: KVNamespace,
|
||||
discordUserId: string,
|
||||
githubUserId: string,
|
||||
): Promise<void> {
|
||||
await kv.put(`discord-link:${discordUserId}`, githubUserId);
|
||||
}
|
||||
|
||||
export async function getDiscordLink(
|
||||
kv: KVNamespace,
|
||||
discordUserId: string,
|
||||
): Promise<string | null> {
|
||||
return await kv.get(`discord-link:${discordUserId}`, "text");
|
||||
}
|
||||
|
||||
export async function removeDiscordLink(
|
||||
kv: KVNamespace,
|
||||
discordUserId: string,
|
||||
): Promise<void> {
|
||||
await kv.delete(`discord-link:${discordUserId}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue