web
This commit is contained in:
97
src/data-store.ts
Normal file
97
src/data-store.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
const DATA_DIR = path.resolve(__dirname, "..", "data");
|
||||
|
||||
export interface AppConfig {
|
||||
novavpsUrl: string;
|
||||
}
|
||||
|
||||
export interface PanelStatus {
|
||||
status: "idle" | "success" | "error";
|
||||
count?: number;
|
||||
outboundsCount?: number;
|
||||
synced?: boolean;
|
||||
timestamp: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
lastRun: string | null;
|
||||
status: "idle" | "running" | "completed" | "error";
|
||||
currentStep: string;
|
||||
novavps: PanelStatus;
|
||||
xray1: PanelStatus;
|
||||
xray2: PanelStatus;
|
||||
}
|
||||
|
||||
const CONFIG_FILE = path.join(DATA_DIR, "config.json");
|
||||
const STATE_FILE = path.join(DATA_DIR, "state.json");
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
novavpsUrl: "",
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
lastRun: null,
|
||||
status: "idle",
|
||||
currentStep: "",
|
||||
novavps: { status: "idle", count: 0, timestamp: null, error: null },
|
||||
xray1: { status: "idle", outboundsCount: 0, synced: false, timestamp: null, error: null },
|
||||
xray2: { status: "idle", outboundsCount: 0, synced: false, timestamp: null, error: null },
|
||||
};
|
||||
|
||||
async function ensureDataDir(): Promise<void> {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function readJson<T>(filePath: string, defaults: T): Promise<T> {
|
||||
if (!existsSync(filePath)) return defaults;
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson<T>(filePath: string, data: T): Promise<void> {
|
||||
await ensureDataDir();
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export async function loadConfig(envNovavpsUrl: string): Promise<AppConfig> {
|
||||
const saved = await readJson<AppConfig>(CONFIG_FILE, defaultConfig);
|
||||
if (!saved.novavpsUrl && envNovavpsUrl) {
|
||||
saved.novavpsUrl = envNovavpsUrl;
|
||||
await writeJson(CONFIG_FILE, saved);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
await writeJson(CONFIG_FILE, config);
|
||||
}
|
||||
|
||||
export async function loadState(): Promise<AppState> {
|
||||
return readJson<AppState>(STATE_FILE, defaultState);
|
||||
}
|
||||
|
||||
export async function saveState(state: AppState): Promise<void> {
|
||||
await writeJson(STATE_FILE, state);
|
||||
}
|
||||
|
||||
export async function resetState(): Promise<AppState> {
|
||||
const now = new Date().toISOString();
|
||||
const state: AppState = {
|
||||
lastRun: now,
|
||||
status: "running",
|
||||
currentStep: "initializing",
|
||||
novavps: { status: "idle", count: 0, timestamp: null, error: null },
|
||||
xray1: { status: "idle", outboundsCount: 0, synced: false, timestamp: null, error: null },
|
||||
xray2: { status: "idle", outboundsCount: 0, synced: false, timestamp: null, error: null },
|
||||
};
|
||||
await saveState(state);
|
||||
return state;
|
||||
}
|
||||
316
src/server.ts
Normal file
316
src/server.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
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; // 1 hour
|
||||
|
||||
const sessions = new Map<string, { createdAt: number; lastAccess: number }>();
|
||||
|
||||
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<void> {
|
||||
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<ReturnType<typeof parseNovavps>> = [];
|
||||
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);
|
||||
if (panel.label === "xray1") {
|
||||
state.xray1 = {
|
||||
status: "success",
|
||||
outboundsCount: xray.length,
|
||||
synced: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: null,
|
||||
};
|
||||
} else {
|
||||
state.xray2 = {
|
||||
status: "success",
|
||||
outboundsCount: xray.length,
|
||||
synced: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const errorState = {
|
||||
status: "error" as const,
|
||||
outboundsCount: 0,
|
||||
synced: false,
|
||||
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);
|
||||
if (panel.label === "xray1") {
|
||||
state.xray1.synced = true;
|
||||
} else {
|
||||
state.xray2.synced = true;
|
||||
}
|
||||
} 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)"}`);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user