import dotenv from "dotenv"; dotenv.config(); import express, { Request, Response, NextFunction } from "express"; import cookieParser from "cookie-parser"; import crypto from "crypto"; import path from "path"; import fs from "fs/promises"; import { parseNovavps, getValidConnections } from "./novavps"; import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel"; import { closeBrowser } from "./browser"; import { loadConfig, saveConfig, loadState, saveState, resetState, AppState, } from "./data-store"; const app = express(); const PORT = parseInt(process.env.PORT || "3000", 10); const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString("hex"); const SESSION_TTL = 60 * 60 * 1000; const sessions = new Map(); app.use(express.json({ limit: "1mb" })); app.use(cookieParser()); app.use(express.static(path.resolve(__dirname, "..", "public"))); interface PanelConfig { url: string; username: string; password: string; label: string; } function getPanels(): PanelConfig[] { const panels: PanelConfig[] = []; const url1 = process.env.XRAY_URL; const user1 = process.env.XRAY_USERNAME; const pass1 = process.env.XRAY_PASSWORD; if (url1 && user1 && pass1) { panels.push({ url: url1, username: user1, password: pass1, label: "xray1" }); } const url2 = process.env.XRAY_URL2; const user2 = process.env.XRAY_USERNAME2; const pass2 = process.env.XRAY_PASSWORD2; if (url2 && user2 && pass2) { panels.push({ url: url2, username: user2, password: pass2, label: "xray2" }); } return panels; } function validCredentials(username: string, password: string): boolean { const panels = getPanels(); return panels.some( (p) => p.username === username && p.password === password ); } function createSession(): string { const token = crypto.randomBytes(32).toString("hex"); const now = Date.now(); sessions.set(token, { createdAt: now, lastAccess: now }); return token; } function getSession(token: string | undefined): { createdAt: number; lastAccess: number } | null { if (!token) return null; const session = sessions.get(token); if (!session) return null; if (Date.now() - session.lastAccess > SESSION_TTL) { sessions.delete(token); return null; } session.lastAccess = Date.now(); return session; } function authMiddleware(req: Request, res: Response, next: NextFunction) { const token = req.cookies?.session; if (!getSession(token)) { res.status(401).json({ error: "Unauthorized" }); return; } next(); } function cleanupSessions() { const now = Date.now(); for (const [token, session] of sessions.entries()) { if (now - session.lastAccess > SESSION_TTL) { sessions.delete(token); } } } setInterval(cleanupSessions, 5 * 60 * 1000); let isRunning = false; async function runParser(): Promise { if (isRunning) return; isRunning = true; const state = await resetState(); try { const config = await loadConfig(process.env.NOVAVPS_URL || ""); const novavpsUrl = config.novavpsUrl; if (!novavpsUrl) { state.status = "error"; state.currentStep = "novavps_url_missing"; await saveState(state); return; } const panels = getPanels(); if (panels.length === 0) { state.status = "error"; state.currentStep = "no_xray_panels"; await saveState(state); return; } state.currentStep = "novavps_fetching"; await saveState(state); let novavpsConnections: Awaited> = []; try { novavpsConnections = await parseNovavps(novavpsUrl); state.novavps = { status: "success", count: novavpsConnections.length, timestamp: new Date().toISOString(), error: null, }; } catch (err) { state.novavps = { status: "error", count: 0, timestamp: new Date().toISOString(), error: err instanceof Error ? err.message : String(err), }; } await saveState(state); const valid = getValidConnections(novavpsConnections); for (const panel of panels) { state.currentStep = `${panel.label}_parsing`; await saveState(state); try { const xray = await parseXrayPanel(panel.url, panel.username, panel.password); const panelStateKey = panel.label as "xray1" | "xray2"; state[panelStateKey] = { status: "success", outboundsCount: xray.length, synced: false, syncedOutbounds: [], timestamp: new Date().toISOString(), error: null, }; } catch (err) { const errorState = { status: "error" as const, outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: new Date().toISOString(), error: err instanceof Error ? err.message : String(err), }; if (panel.label === "xray1") { state.xray1 = errorState; } else { state.xray2 = errorState; } } await saveState(state); if (valid.length > 0) { state.currentStep = `${panel.label}_syncing`; await saveState(state); try { await syncNovavpsToXray(valid, panel.url, panel.username, panel.password, panel.label); const syncedTags = valid.map((_, idx) => `novavps${idx + 1}`); const panelStateKey = panel.label as "xray1" | "xray2"; state[panelStateKey].synced = true; state[panelStateKey].syncedOutbounds = syncedTags; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); if (panel.label === "xray1") { state.xray1.error = errMsg; } else { state.xray2.error = errMsg; } } await saveState(state); } } state.status = "completed"; state.currentStep = "done"; await saveState(state); } catch (err) { state.status = "error"; state.currentStep = "fatal"; if (state.novavps.status === "idle") { state.novavps = { status: "error", count: 0, timestamp: new Date().toISOString(), error: err instanceof Error ? err.message : String(err) }; } await saveState(state); } finally { await closeBrowser(); isRunning = false; } } app.post("/api/login", async (req: Request, res: Response) => { const { username, password } = req.body; if (!username || !password) { res.status(400).json({ error: "username and password required" }); return; } if (!validCredentials(username, password)) { res.status(401).json({ error: "Invalid credentials" }); return; } const token = createSession(); res.cookie("session", token, { httpOnly: true, sameSite: "strict", maxAge: SESSION_TTL, path: "/", }); res.json({ ok: true }); }); app.post("/api/logout", (_req: Request, res: Response) => { res.clearCookie("session"); res.json({ ok: true }); }); app.get("/api/status", authMiddleware, async (_req: Request, res: Response) => { const state = await loadState(); res.json(state); }); app.post("/api/run", authMiddleware, async (_req: Request, res: Response) => { if (isRunning) { res.status(409).json({ error: "Parser already running" }); return; } runParser().catch((err) => console.error("[server] Parser error:", err)); res.json({ ok: true, message: "Parser started" }); }); app.get("/api/config", authMiddleware, async (_req: Request, res: Response) => { const config = await loadConfig(process.env.NOVAVPS_URL || ""); res.json(config); }); app.put("/api/config", authMiddleware, async (req: Request, res: Response) => { const { novavpsUrl } = req.body; if (!novavpsUrl) { res.status(400).json({ error: "novavpsUrl required" }); return; } const config = await loadConfig(process.env.NOVAVPS_URL || ""); config.novavpsUrl = novavpsUrl; await saveConfig(config); res.json({ ok: true }); }); app.get("/api/data/novavps", authMiddleware, async (_req: Request, res: Response) => { const filePath = path.resolve(__dirname, "..", "data", "novavps-connections.json"); try { const raw = await fs.readFile(filePath, "utf-8"); res.json(JSON.parse(raw)); } catch { res.json([]); } }); app.get("/api/data/xray", authMiddleware, async (_req: Request, res: Response) => { const filePath = path.resolve(__dirname, "..", "data", "xray-outbounds.json"); try { const raw = await fs.readFile(filePath, "utf-8"); res.json(JSON.parse(raw)); } catch { res.json([]); } }); (async () => { const config = await loadConfig(process.env.NOVAVPS_URL || ""); app.listen(PORT, "0.0.0.0", () => { console.log(`[server] Web interface running on http://0.0.0.0:${PORT}`); console.log(`[server] NOVAVPS_URL: ${config.novavpsUrl || "(not set)"}`); console.log(`[server] Panels: ${getPanels().map((p) => p.label).join(", ") || "(none)"}`); }); })();