feat: add SQLite and MySQL database support with setup wizard selection (RCA-21)
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>
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
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";
|
||||
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: AdminStore;
|
||||
adminStore: IAdminStore;
|
||||
connections: ConnectionManager;
|
||||
cookieStore: CookieBlobStore;
|
||||
deviceRegistry: DeviceRegistry;
|
||||
cookieStore: ICookieStore;
|
||||
deviceRegistry: IDeviceStore;
|
||||
server: RelayServer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,12 @@ export function handleAdminRoute(
|
||||
// --- Public routes (no auth) ---
|
||||
|
||||
if (method === "GET" && url === "/admin/setup/status") {
|
||||
json(res, 200, { isSetUp: deps.adminStore.isSetUp });
|
||||
const dbConfig = loadDbConfig();
|
||||
json(res, 200, {
|
||||
isSetUp: deps.adminStore.isSetUp,
|
||||
dbConfigured: dbConfig !== null,
|
||||
dbType: dbConfig?.type ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -107,7 +113,7 @@ export function handleAdminRoute(
|
||||
|
||||
function authenticate(
|
||||
req: http.IncomingMessage,
|
||||
store: AdminStore,
|
||||
store: IAdminStore,
|
||||
): { sub: string; role: string } | null {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) return null;
|
||||
@@ -127,11 +133,32 @@ function handleSetupInit(
|
||||
): void {
|
||||
readBody(req, async (body) => {
|
||||
try {
|
||||
const { username, password } = JSON.parse(body);
|
||||
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;
|
||||
@@ -165,13 +192,13 @@ function handleLogin(
|
||||
});
|
||||
}
|
||||
|
||||
function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
const devices = deps.deviceRegistry.listAll();
|
||||
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 = deps.cookieStore.getAll();
|
||||
const allCookies = await deps.cookieStore.getAll();
|
||||
|
||||
const domains = new Set(allCookies.map((c) => c.domain));
|
||||
|
||||
@@ -184,17 +211,17 @@ function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
});
|
||||
}
|
||||
|
||||
function handleCookieList(
|
||||
async function handleCookieList(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
): 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 = deps.cookieStore.getById(idMatch[1]);
|
||||
const cookie = await deps.cookieStore.getById(idMatch[1]);
|
||||
if (!cookie) {
|
||||
json(res, 404, { error: "Cookie not found" });
|
||||
return;
|
||||
@@ -208,7 +235,7 @@ function handleCookieList(
|
||||
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();
|
||||
let cookies = await deps.cookieStore.getAll();
|
||||
|
||||
if (domain) {
|
||||
cookies = cookies.filter((c) => c.domain === domain);
|
||||
@@ -227,18 +254,18 @@ function handleCookieList(
|
||||
json(res, 200, { items, total, page, limit });
|
||||
}
|
||||
|
||||
function handleCookieDeleteById(
|
||||
async function handleCookieDeleteById(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
): 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 = deps.cookieStore.deleteById(idMatch[1]);
|
||||
const deleted = await deps.cookieStore.deleteById(idMatch[1]);
|
||||
json(res, 200, { deleted });
|
||||
}
|
||||
|
||||
@@ -247,7 +274,7 @@ function handleCookieBatchDelete(
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
readBody(req, (body) => {
|
||||
readBody(req, async (body) => {
|
||||
try {
|
||||
const { ids } = JSON.parse(body) as { ids: string[] };
|
||||
if (!ids || !Array.isArray(ids)) {
|
||||
@@ -256,7 +283,7 @@ function handleCookieBatchDelete(
|
||||
}
|
||||
let count = 0;
|
||||
for (const id of ids) {
|
||||
if (deps.cookieStore.deleteById(id)) count++;
|
||||
if (await deps.cookieStore.deleteById(id)) count++;
|
||||
}
|
||||
json(res, 200, { deleted: count });
|
||||
} catch {
|
||||
@@ -265,8 +292,8 @@ function handleCookieBatchDelete(
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
const devices = deps.deviceRegistry.listAll().map((d) => ({
|
||||
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,
|
||||
@@ -276,11 +303,11 @@ function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
json(res, 200, { devices });
|
||||
}
|
||||
|
||||
function handleDeviceRevoke(
|
||||
async function handleDeviceRevoke(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/);
|
||||
if (!match) {
|
||||
@@ -288,7 +315,7 @@ function handleDeviceRevoke(
|
||||
return;
|
||||
}
|
||||
const deviceId = match[1];
|
||||
const revoked = deps.deviceRegistry.revoke(deviceId);
|
||||
const revoked = await deps.deviceRegistry.revoke(deviceId);
|
||||
if (revoked) {
|
||||
deps.connections.disconnect(deviceId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user