feat: add admin REST API layer for frontend management panel

Implement JWT-protected /admin/* routes on the relay server:
- Auth: login, logout, me, setup/status, setup/init (first-time config)
- Dashboard: stats overview (connections, devices, cookies, domains)
- Cookies: paginated list with domain/search filter, detail, delete, batch delete
- Devices: list with online status, revoke
- Settings: get/update (sync interval, max devices, theme)

Uses scrypt for password hashing and jsonwebtoken for JWT.
Adds listAll/revoke to DeviceRegistry, getAll/getById/deleteById to CookieBlobStore,
disconnect to ConnectionManager. Updates frontend to use /admin/* endpoints.

All 38 existing tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 20:28:56 +08:00
parent f4144c96f1
commit a320f7ad97
14 changed files with 733 additions and 19 deletions

328
src/relay/admin/routes.ts Normal file
View File

@@ -0,0 +1,328 @@
import http from "node:http";
import type { AdminStore } from "./auth.js";
import type { ConnectionManager } from "../connections.js";
import type { CookieBlobStore } from "../store.js";
import type { DeviceRegistry } from "../tokens.js";
export interface AdminDeps {
adminStore: AdminStore;
connections: ConnectionManager;
cookieStore: CookieBlobStore;
deviceRegistry: DeviceRegistry;
}
/**
* 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") {
json(res, 200, { isSetUp: deps.adminStore.isSetUp });
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: AdminStore,
): { 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 } = JSON.parse(body);
if (!username || !password) {
json(res, 400, { error: "Missing username or password" });
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" });
}
});
}
function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
const devices = deps.deviceRegistry.listAll();
const onlineDeviceIds = devices
.filter((d) => deps.connections.isOnline(d.deviceId))
.map((d) => d.deviceId);
const allCookies = 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,
});
}
function handleCookieList(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
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 = 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 = 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 });
}
function handleCookieDeleteById(
_req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
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 = deps.cookieStore.deleteById(idMatch[1]);
json(res, 200, { deleted });
}
function handleCookieBatchDelete(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
readBody(req, (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 (deps.cookieStore.deleteById(id)) count++;
}
json(res, 200, { deleted: count });
} catch {
json(res, 400, { error: "Invalid JSON" });
}
});
}
function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
const devices = 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 });
}
function handleDeviceRevoke(
req: http.IncomingMessage,
res: http.ServerResponse,
deps: AdminDeps,
): void {
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 = 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));
}