import http from "node:http"; import type { ConnectionManager } from "../connections.js"; import type { ICookieStore, IDeviceStore, IAdminStore, DbConfig } from "../db/types.js"; import { saveDbConfig, loadDbConfig, createStores } from "../db/index.js"; import type { RelayServer } from "../server.js"; export interface AdminDeps { adminStore: IAdminStore; connections: ConnectionManager; cookieStore: ICookieStore; deviceRegistry: IDeviceStore; server: RelayServer; } /** * Handle /admin/* routes. Returns true if the route was handled. */ export function handleAdminRoute( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): boolean { const url = req.url ?? ""; const method = req.method ?? ""; if (!url.startsWith("/admin/")) return false; // --- Public routes (no auth) --- if (method === "GET" && url === "/admin/setup/status") { const dbConfig = loadDbConfig(); json(res, 200, { isSetUp: deps.adminStore.isSetUp, dbConfigured: dbConfig !== null, dbType: dbConfig?.type ?? null, }); return true; } if (method === "POST" && url === "/admin/setup/init") { handleSetupInit(req, res, deps); return true; } if (method === "POST" && url === "/admin/auth/login") { handleLogin(req, res, deps); return true; } // --- Protected routes --- const user = authenticate(req, deps.adminStore); if (!user) { json(res, 401, { error: "Unauthorized" }); return true; } if (method === "POST" && url === "/admin/auth/logout") { json(res, 200, { ok: true }); return true; } if (method === "GET" && url === "/admin/auth/me") { const info = deps.adminStore.getUser(); json(res, 200, info); return true; } if (method === "GET" && url === "/admin/dashboard") { handleDashboard(res, deps); return true; } // Cookie management if (method === "GET" && url.startsWith("/admin/cookies")) { handleCookieList(req, res, deps); return true; } if (method === "DELETE" && url.startsWith("/admin/cookies/")) { handleCookieDeleteById(req, res, deps); return true; } if (method === "DELETE" && url === "/admin/cookies") { handleCookieBatchDelete(req, res, deps); return true; } // Device management if (method === "GET" && url === "/admin/devices") { handleDeviceList(res, deps); return true; } if (method === "POST" && url.match(/^\/admin\/devices\/[^/]+\/revoke$/)) { handleDeviceRevoke(req, res, deps); return true; } // Settings if (method === "GET" && url === "/admin/settings") { json(res, 200, deps.adminStore.getSettings()); return true; } if (method === "PATCH" && url === "/admin/settings") { handleSettingsUpdate(req, res, deps); return true; } json(res, 404, { error: "Admin route not found" }); return true; } // --- Auth helpers --- function authenticate( req: http.IncomingMessage, store: IAdminStore, ): { sub: string; role: string } | null { const auth = req.headers.authorization; if (!auth?.startsWith("Bearer ")) return null; try { return store.verifyToken(auth.slice(7)); } catch { return null; } } // --- Route handlers --- function handleSetupInit( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): void { readBody(req, async (body) => { try { const { username, password, dbConfig } = JSON.parse(body) as { username: string; password: string; dbConfig?: DbConfig; }; if (!username || !password) { json(res, 400, { error: "Missing username or password" }); return; } // If a database config is provided, initialize the database first if (dbConfig) { try { const stores = await createStores(dbConfig); saveDbConfig(dbConfig); deps.server.replaceStores(stores); // Update deps references to point to new stores deps.adminStore = stores.adminStore; deps.cookieStore = stores.cookieStore; deps.deviceRegistry = stores.deviceStore; } catch (err) { json(res, 500, { error: `Database connection failed: ${err instanceof Error ? err.message : String(err)}` }); return; } } if (deps.adminStore.isSetUp) { json(res, 409, { error: "Already configured" }); return; } await deps.adminStore.setup(username, password); const token = await deps.adminStore.login(username, password); json(res, 201, { token, username }); } catch { json(res, 400, { error: "Invalid request" }); } }); } function handleLogin( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): void { readBody(req, async (body) => { try { const { username, password } = JSON.parse(body); if (!username || !password) { json(res, 400, { error: "Missing username or password" }); return; } const token = await deps.adminStore.login(username, password); json(res, 200, { token }); } catch { json(res, 401, { error: "Invalid credentials" }); } }); } async function handleDashboard(res: http.ServerResponse, deps: AdminDeps): Promise { const devices = await deps.deviceRegistry.listAll(); const onlineDeviceIds = devices .filter((d) => deps.connections.isOnline(d.deviceId)) .map((d) => d.deviceId); const allCookies = await deps.cookieStore.getAll(); const domains = new Set(allCookies.map((c) => c.domain)); json(res, 200, { connections: deps.connections.connectedCount, totalDevices: devices.length, onlineDevices: onlineDeviceIds.length, totalCookies: allCookies.length, uniqueDomains: domains.size, }); } async function handleCookieList( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): Promise { const parsed = new URL(req.url ?? "", `http://${req.headers.host}`); // Check if this is a single cookie detail request: /admin/cookies/:id const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/); if (idMatch) { const cookie = await deps.cookieStore.getById(idMatch[1]); if (!cookie) { json(res, 404, { error: "Cookie not found" }); return; } json(res, 200, cookie); return; } const domain = parsed.searchParams.get("domain") ?? undefined; const search = parsed.searchParams.get("q") ?? undefined; const page = parseInt(parsed.searchParams.get("page") ?? "1", 10); const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200); let cookies = await deps.cookieStore.getAll(); if (domain) { cookies = cookies.filter((c) => c.domain === domain); } if (search) { const q = search.toLowerCase(); cookies = cookies.filter( (c) => c.domain.toLowerCase().includes(q) || c.cookieName.toLowerCase().includes(q), ); } const total = cookies.length; const offset = (page - 1) * limit; const items = cookies.slice(offset, offset + limit); json(res, 200, { items, total, page, limit }); } async function handleCookieDeleteById( _req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): Promise { const parsed = new URL(_req.url ?? "", `http://${_req.headers.host}`); const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/); if (!idMatch) { json(res, 400, { error: "Invalid cookie ID" }); return; } const deleted = await deps.cookieStore.deleteById(idMatch[1]); json(res, 200, { deleted }); } function handleCookieBatchDelete( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): void { readBody(req, async (body) => { try { const { ids } = JSON.parse(body) as { ids: string[] }; if (!ids || !Array.isArray(ids)) { json(res, 400, { error: "Missing ids array" }); return; } let count = 0; for (const id of ids) { if (await deps.cookieStore.deleteById(id)) count++; } json(res, 200, { deleted: count }); } catch { json(res, 400, { error: "Invalid JSON" }); } }); } async function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): Promise { const devices = (await deps.deviceRegistry.listAll()).map((d) => ({ deviceId: d.deviceId, name: d.name, platform: d.platform, createdAt: d.createdAt, online: deps.connections.isOnline(d.deviceId), })); json(res, 200, { devices }); } async function handleDeviceRevoke( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): Promise { const parsed = new URL(req.url ?? "", `http://${req.headers.host}`); const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/); if (!match) { json(res, 400, { error: "Invalid device ID" }); return; } const deviceId = match[1]; const revoked = await deps.deviceRegistry.revoke(deviceId); if (revoked) { deps.connections.disconnect(deviceId); } json(res, 200, { revoked }); } function handleSettingsUpdate( req: http.IncomingMessage, res: http.ServerResponse, deps: AdminDeps, ): void { readBody(req, (body) => { try { const patch = JSON.parse(body); const updated = deps.adminStore.updateSettings(patch); json(res, 200, updated); } catch { json(res, 400, { error: "Invalid JSON" }); } }); } // --- Helpers --- function json(res: http.ServerResponse, status: number, data: unknown): void { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); } function readBody(req: http.IncomingMessage, cb: (body: string) => void): void { let data = ""; req.on("data", (chunk: Buffer) => { data += chunk.toString(); if (data.length > 64 * 1024) req.destroy(); }); req.on("end", () => cb(data)); }