Replace in-memory storage with a database abstraction layer supporting SQLite and MySQL. Users choose their preferred database during the first-time setup wizard. The server persists the database config to data/db-config.json and loads it automatically on restart. - Add database abstraction interfaces (ICookieStore, IDeviceStore, IAgentStore, IAdminStore) - Implement SQLite driver using better-sqlite3 with WAL mode - Implement MySQL driver using mysql2 connection pooling - Keep memory-backed driver for backwards compatibility and testing - Add database selection step (step 2) to the setup wizard UI - Update setup API to accept dbConfig and initialize the chosen database - Update RelayServer to use async store interfaces with runtime store replacement Co-Authored-By: Paperclip <noreply@paperclip.ing>
356 lines
9.7 KiB
TypeScript
356 lines
9.7 KiB
TypeScript
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<void> {
|
|
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<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 = 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<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 = 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<void> {
|
|
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<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 = 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));
|
|
}
|