import { Hono } from "hono"; import type { Env, Route, Group } from "../types"; import { loadRoutes, saveRoutes } from "../config"; import { getAdminSession, destroyAdminSession, clearAdminCookie, type AdminSession, } from "./session"; import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups"; import { getSendLog, getSendLogById } from "../lib/send-log"; import { log } from "../lib/log"; const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]); const ID_RE = /^[a-z0-9][a-z0-9-]*$/; 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 deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b || a === null || b === null) return false; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; return a.every((x, i) => deepEqual(x, b[i])); } if (typeof a === "object" && typeof b === "object") { const ao = a as Record; const bo = b as Record; const ak = Object.keys(ao); const bk = Object.keys(bo); if (ak.length !== bk.length) return false; return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k])); } return false; } /** * Validates the submitted routes. Routes that are byte-for-byte identical to an * entry in `unchanged` (keyed by id) skip the full content check, so a pre-existing * incomplete route can never block edits to a different route. Only new or modified * routes are fully validated. Structural checks (id shape, uniqueness) still run for all. */ function validateRoutes( routes: unknown, unchanged?: Map, ): { 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(); for (let i = 0; i < routes.length; i++) { const r = routes[i] as Record; if (!r || typeof r !== "object") return { ok: false, error: `route[${i}] is not an object` }; if (typeof r.id !== "string" || !ID_RE.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); // Skip full validation for routes that are unchanged from what is stored. const prev = unchanged?.get(r.id); if (prev && deepEqual(r, prev)) continue; if (typeof r.name !== "string" || r.name.trim().length === 0) { return { ok: false, error: `route "${r.id}" needs a name` }; } if (typeof r.groupId !== "string" || r.groupId.trim().length === 0) { return { ok: false, error: `route "${r.id}" needs a group` }; } 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 (r.fallback !== undefined && typeof r.fallback !== "boolean") { return { ok: false, error: `route "${r.id}".fallback must be a boolean` }; } if (!Array.isArray(r.filters)) { return { ok: false, error: `route "${r.id}".filters must be an array` }; } if (r.fallback !== true && 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; 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 rawTarget = r.target as Record | undefined; const rawTargets = r.targets as unknown; if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") { const legacy = validateTarget(r, rawTarget); if (!legacy.ok) return legacy; (r as Record).targets = [legacy.target]; delete (r as Record).target; } else if (Array.isArray(rawTargets)) { if (rawTargets.length === 0) { return { ok: false, error: `route "${r.id}" needs at least one target` }; } const normalized: Route["targets"] = []; for (let j = 0; j < rawTargets.length; j++) { const t = rawTargets[j] as Record; if (!t || typeof t !== "object") { return { ok: false, error: `route "${r.id}".targets[${j}] is not an object` }; } const result = validateTarget(r, t); if (!result.ok) return result; normalized.push(result.target); } (r as Record).targets = normalized; } else { return { ok: false, error: `route "${r.id}" needs a targets array` }; } } return { ok: true, routes: routes as Route[] }; } function validateTarget( r: Record, target: Record, ): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } { const platform = target.platform === undefined ? "discord" : target.platform; if (platform !== "discord" && platform !== "telegram") { return { ok: false, error: `route "${r.id}".target.platform must be "discord" or "telegram"` }; } if (platform === "telegram") { if (typeof target.chatId !== "string" || target.chatId.trim().length === 0) return { ok: false, error: `route "${r.id}".target.chatId is required` }; if (target.topicId !== undefined && typeof target.topicId !== "string") { return { ok: false, error: `route "${r.id}".target.topicId must be a string` }; } } else { 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, target: { platform, channelId: platform === "telegram" ? undefined : (target.channelId as string), threadId: platform === "telegram" ? undefined : ((target.threadId as string) ?? undefined), chatId: platform === "telegram" ? (target.chatId as string) : undefined, topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined, }, }; } function validateGroups( groups: unknown, ): { ok: true; groups: Group[] } | { ok: false; error: string } { if (!Array.isArray(groups)) return { ok: false, error: "groups must be an array" }; if (groups.length > 100) return { ok: false, error: "too many groups" }; const seen = new Set(); for (let i = 0; i < groups.length; i++) { const g = groups[i] as Record; if (!g || typeof g !== "object") return { ok: false, error: `group[${i}] is not an object` }; if (typeof g.id !== "string" || !ID_RE.test(g.id)) { return { ok: false, error: `group[${i}].id is invalid` }; } if (seen.has(g.id)) return { ok: false, error: `duplicate group id "${g.id}"` }; seen.add(g.id); if (typeof g.name !== "string" || g.name.trim().length === 0) { return { ok: false, error: `group "${g.id}" needs a name` }; } if ( !Array.isArray(g.adminIds) || !g.adminIds.every((a) => typeof a === "string" && a.trim().length > 0) ) { return { ok: false, error: `group "${g.id}".adminIds must be a list of strings` }; } if ( g.owners !== undefined && (!Array.isArray(g.owners) || !g.owners.every((o) => typeof o === "string" && o.trim().length > 0)) ) { return { ok: false, error: `group "${g.id}".owners must be a list of strings` }; } if (g.emoji !== undefined && typeof g.emoji !== "boolean") { return { ok: false, error: `group "${g.id}".emoji must be a boolean` }; } } return { ok: true, groups: groups as Group[] }; } export function createAdminRoutes(): Hono<{ Bindings: Env }> { const app = new Hono<{ Bindings: Env }>(); async function loadScope(c: { env: Env; req: { header: (name: string) => string | undefined }; }): Promise<{ session: AdminSession; scope: AccessScope; groups: Group[] } | null> { const session = await getAdminSession(c.env.KV, c.req.header("cookie")); if (!session) return null; const groups = await loadGroups(c.env.KV); const scope = resolveScope(c.env, groups, session.userId, session.login); if (!hasAnyAccess(scope)) return null; return { session, scope, groups }; } app.get("/login", (c) => { return c.redirect("/auth/github?redirect=/admin"); }); app.get("/logout", async (c) => { await destroyAdminSession(c.env.KV, c.req.header("cookie")); c.header("Set-Cookie", clearAdminCookie()); return c.redirect("/admin"); }); app.get("/api/me", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); return c.json({ login: s.session.login, userId: s.session.userId, isSuper: s.scope.isSuper, groups: s.scope.groups, }); }); app.get("/api/groups", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); return c.json({ groups: s.scope.groups, isSuper: s.scope.isSuper }); }); app.put("/api/groups", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); if (!s.scope.isSuper) return c.json({ error: "Forbidden" }, 403); let body: unknown; try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON body" }, 400); } const result = validateGroups((body as { groups?: unknown })?.groups); if (!result.ok) return c.json({ error: result.error }, 400); try { await saveGroups(c.env.KV, result.groups); } catch (err) { log.error({ err }, "Failed to save groups"); return c.json({ error: "Failed to save groups" }, 500); } log.info({ count: result.groups.length }, "Groups updated via admin UI"); return c.json({ ok: true, count: result.groups.length }); }); app.get("/api/routes", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); const all = await loadRoutes(c.env.KV); const routes = s.scope.isSuper ? all : all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId)); return c.json({ routes }); }); app.get("/api/logs", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100); if (s.scope.isSuper) { return c.json({ logs: await getSendLog(c.env.DB, limit) }); } const all = await loadRoutes(c.env.KV); const allowed = new Set( all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId)).map((r) => r.id), ); const logs = (await getSendLog(c.env.DB, 200)) .filter((l) => allowed.has(l.routeId)) .slice(0, limit); return c.json({ logs }); }); app.get("/api/logs/:id", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); const id = Number(c.req.param("id")); if (!Number.isInteger(id) || id <= 0) return c.json({ error: "Invalid log id" }, 400); const entry = await getSendLogById(c.env.DB, id); if (!entry) return c.json({ error: "Log entry not found" }, 404); if (!s.scope.isSuper) { const all = await loadRoutes(c.env.KV); const route = all.find((r) => r.id === entry.routeId); if (!route?.groupId || !s.scope.groupIds.has(route.groupId)) { return c.json({ error: "Forbidden" }, 403); } } return c.json({ log: entry }); }); app.put("/api/routes", async (c) => { const s = await loadScope(c); if (!s) 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 existing = await loadRoutes(c.env.KV); const unchanged = new Map(existing.map((r) => [r.id, r])); const result = validateRoutes((body as { routes?: unknown })?.routes, unchanged); if (!result.ok) return c.json({ error: result.error }, 400); let nextAll: Route[]; if (s.scope.isSuper) { // Super admins see and submit every route: full replace. nextAll = result.routes; } else { // Group admins may only write routes inside their own groups. Reject any // submitted route that targets a group they do not manage, then splice // their groups' routes in place while preserving all other groups' routes. const writable = s.scope.groupIds; for (const r of result.routes) { if (!r.groupId || !writable.has(r.groupId)) { return c.json({ error: `route "${r.id}" is outside your groups` }, 403); } } nextAll = [ ...existing.filter((r) => !(r.groupId != null && writable.has(r.groupId))), ...result.routes, ]; } // Guard against duplicate ids across the merged set. const ids = new Set(); for (const r of nextAll) { if (ids.has(r.id)) return c.json({ error: `duplicate route id "${r.id}"` }, 400); ids.add(r.id); } try { await saveRoutes(c.env.KV, nextAll); } catch (err) { log.error({ err }, "Failed to save routes"); return c.json({ error: "Failed to save routes" }, 500); } log.info({ count: nextAll.length }, "Routes updated via admin UI"); return c.json({ ok: true, count: nextAll.length }); }); // Routes scoped to a single group. The group is the container: the console // enters a group and then lists / edits only that group's routes. function groupAccess( s: { scope: AccessScope; groups: Group[] }, groupId: string, ): { ok: true; group: Group } | { ok: false; status: 403 | 404 } { const group = s.groups.find((g) => g.id === groupId); if (!group) return { ok: false, status: 404 }; if (!s.scope.isSuper && !s.scope.groupIds.has(groupId)) return { ok: false, status: 403 }; return { ok: true, group }; } app.get("/api/groups/:groupId/routes", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); const groupId = c.req.param("groupId"); const access = groupAccess(s, groupId); if (!access.ok) { return c.json( { error: access.status === 404 ? "Group not found" : "Forbidden" }, access.status, ); } const all = await loadRoutes(c.env.KV); return c.json({ group: access.group, routes: all.filter((r) => r.groupId === groupId) }); }); app.put("/api/groups/:groupId/routes", async (c) => { const s = await loadScope(c); if (!s) return c.json({ error: "Unauthorized" }, 401); const groupId = c.req.param("groupId"); const access = groupAccess(s, groupId); if (!access.ok) { return c.json( { error: access.status === 404 ? "Group not found" : "Forbidden" }, access.status, ); } let body: unknown; try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON body" }, 400); } // Force every submitted route into this group so the client never has to // carry a groupId; the path parameter is the single source of truth. const submitted = (body as { routes?: unknown })?.routes; const scoped = Array.isArray(submitted) ? submitted.map((r) => ({ ...(r as Record), groupId })) : submitted; const existing = await loadRoutes(c.env.KV); const unchanged = new Map(existing.map((r) => [r.id, r])); const result = validateRoutes(scoped, unchanged); if (!result.ok) return c.json({ error: result.error }, 400); // Replace only this group's routes; every other group is preserved untouched. const others = existing.filter((r) => r.groupId !== groupId); const nextAll = [...others, ...result.routes]; // Guard against ids colliding with routes in other groups. const ids = new Set(); for (const r of nextAll) { if (ids.has(r.id)) return c.json({ error: `duplicate route id "${r.id}"` }, 400); ids.add(r.id); } try { await saveRoutes(c.env.KV, nextAll); } catch (err) { log.error({ err }, "Failed to save routes"); return c.json({ error: "Failed to save routes" }, 500); } log.info({ groupId, count: result.routes.length }, "Group routes updated via admin UI"); return c.json({ ok: true, count: result.routes.length }); }); return app; }