// src/server.ts import { readFileSync, existsSync } from "fs"; import { resolve as resolve3 } from "path"; // src/app.ts import { resolve as resolve2 } from "path"; import Fastify from "fastify"; import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"; // src/config.ts import { resolve } from "path"; function boolEnv(v, fallback) { if (v === void 0 || v === "") return fallback; return v === "1" || v.toLowerCase() === "true"; } function loadConfig() { const isProd = process.env.NODE_ENV === "production"; const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? (isProd ? "" : "dev-secret-change-me"); return { databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db", jwtSecret: jwtSecret || "dev-secret-change-me", jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24, adminUsername: process.env.ADMIN_USERNAME ?? "admin", adminPasswordHash: process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder", serverPort: Number(process.env.SERVER_PORT ?? "8081") || 8081, staticDir: process.env.STATIC_DIR ? resolve(process.env.STATIC_DIR) : null, logLevel: process.env.LOG_LEVEL ?? "info", authRequired: boolEnv(process.env.AUTH_REQUIRED, false), authIssuer: process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top", authPortalUrl: (process.env.AUTH_PORTAL_URL ?? process.env.VITE_AUTH_PORTAL_URL ?? "http://localhost:5175").replace(/\/$/, ""), authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null) }; } // src/plugins/auth.ts import fp from "fastify-plugin"; // src/errors.ts import { NotFoundError, ConflictError } from "@cdnmanager/db"; import { ValidationError } from "@cdnmanager/shared"; var AppError = class _AppError extends Error { constructor(code, message, statusCode) { super(message); this.code = code; this.statusCode = statusCode; this.name = "AppError"; } code; statusCode; static notFound(message) { return new _AppError("NOT_FOUND", message, 404); } static validation(message) { return new _AppError("VALIDATION_ERROR", message, 400); } static unauthorized() { return new _AppError("UNAUTHORIZED", "unauthorized", 401); } static forbidden(message = "forbidden") { return new _AppError("FORBIDDEN", message, 403); } static conflict(message) { return new _AppError("CONFLICT", message, 409); } static cloudflare(message) { return new _AppError("CLOUDFLARE_ERROR", message, 502); } static dnsUpdateFailed(message) { return new _AppError( "DNS_UPDATE_FAILED", message, 502 ); } static healthcheckCreateFailed(message) { return new _AppError("HEALTHCHECK_CREATE_FAILED", message, 502); } static zoneNotFound(message = "\u0437\u043E\u043D\u0430 Cloudflare \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430") { return new _AppError("ZONE_NOT_FOUND", message, 404); } static invalidIp(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441") { return new _AppError("INVALID_IP", message, 400); } static invalidHostname(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0438\u043C\u044F \u0445\u043E\u0441\u0442\u0430") { return new _AppError("INVALID_HOSTNAME", message, 400); } static rateLimited(message = "Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443.") { return new _AppError("RATE_LIMITED", message, 429); } static cloudflareAuthFailed(message = "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D \u0434\u043E\u0441\u0442\u0443\u043F\u0430") { return new _AppError("CLOUDFLARE_AUTH_FAILED", message, 401); } static syncFailed(message) { return new _AppError("SYNC_FAILED", message, 502); } static internal(message) { return new _AppError("INTERNAL_ERROR", message, 500); } }; function toAppError(err) { if (err instanceof AppError) return err; if (err instanceof NotFoundError) return AppError.notFound(err.message); if (err instanceof ConflictError) return AppError.conflict(err.message); if (err instanceof ValidationError) return AppError.validation(err.message); if (err instanceof Error) return AppError.internal(err.message); return AppError.internal(String(err)); } function errorBody(err) { return { error: { code: err.code, message: err.message } }; } // src/lib/permissions.ts function hasPermission(granted, required) { if (granted.includes(required)) return true; const parts = required.split(":"); if (parts.length !== 3) return false; const [app2, section, action] = parts; if (action === "read") { return granted.includes(`${app2}:${section}:write`) || granted.includes(`${app2}:${section}:admin`); } if (action === "write") { return granted.includes(`${app2}:${section}:admin`); } return false; } var RULES = [ { methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], match: (p) => p.startsWith("/api/v1/settings"), permission: "cdn:settings:admin" } ]; function permissionForRequest(method, path) { const m = method.toUpperCase(); const pathname = path.split("?")[0] ?? path; for (const rule of RULES) { if (!rule.methods.includes(m)) continue; if (rule.match(pathname)) return rule.permission; } if (pathname.startsWith("/api/v1/")) return "cdn:dashboard:read"; return null; } // src/plugins/auth.ts async function authPlugin(app2, opts) { const { config: config2 } = opts; if (config2.authRequired && (!config2.jwtSecret || config2.jwtSecret.length < 8)) { throw new Error( "AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true" ); } await app2.register(import("@fastify/jwt"), { secret: config2.jwtSecret, ...config2.authRequired ? { verify: { allowedIss: [config2.authIssuer] } } : {} }); app2.decorate("config", config2); if (config2.authRequired) { app2.log.info( { issuer: config2.authIssuer, portal: config2.authPortalUrl }, "AUTH_REQUIRED=true \u2014 portal JWT middleware enabled" ); } else { app2.log.info("AUTH_REQUIRED=false \u2014 local JWT / open protected routes with requireAuth"); } } async function requireAuth(request, reply) { const config2 = request.server.config; const authHeader = request.headers.authorization ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; if (!token) throw AppError.unauthorized(); try { await request.jwtVerify(); } catch { throw AppError.unauthorized(); } if (!config2.authRequired) { return; } const payload = request.user; const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []; const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : []; if (!apps.includes("cdn")) { throw AppError.forbidden("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044E CDN Manager"); } request.authUser = { id: String(payload.sub), email: String(payload.email ?? ""), name: String(payload.name ?? ""), apps, permissions, isAdmin: Boolean(payload.is_admin) }; const required = permissionForRequest(request.method, request.url); if (required && !hasPermission(permissions, required)) { throw AppError.forbidden(`\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432: ${required}`); } } var auth_default = fp(authPlugin, { name: "auth" }); // src/plugins/cors.ts import fp2 from "fastify-plugin"; async function corsPlugin(app2) { await app2.register(import("@fastify/cors"), { origin: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"] }); } var cors_default = fp2(corsPlugin, { name: "cors" }); // src/plugins/db.ts import fp3 from "fastify-plugin"; import { createDb, createMemoryDb, healthCheck, runMigrations } from "@cdnmanager/db"; async function dbPlugin(app2, opts) { const { db, sqlite } = opts.memory ? createMemoryDb() : createDb(opts.config.databaseUrl); runMigrations(sqlite); app2.decorate("db", db); app2.decorate("sqlite", sqlite); app2.addHook("onClose", async () => { sqlite.close(); }); } var db_default = fp3(dbPlugin, { name: "db" }); // src/plugins/error-handler.ts import fp4 from "fastify-plugin"; async function errorHandlerPlugin(app2) { app2.setErrorHandler((err, _request, reply) => { if (reply.sent) return; const appErr = err.statusCode === 401 ? AppError.unauthorized() : toAppError(err); reply.status(appErr.statusCode).send(errorBody(appErr)); }); } var error_handler_default = fp4(errorHandlerPlugin, { name: "error-handler" }); // src/routes/health.ts import { z } from "zod"; // src/services/auth.ts import { verify } from "@node-rs/argon2"; async function verifyPassword(config2, password) { if (config2.adminPasswordHash === "devplaceholder") { if (password === "admin") return; throw AppError.unauthorized(); } const ok = await verify(config2.adminPasswordHash, password); if (!ok) throw AppError.unauthorized(); } async function login(config2, sign, req) { if (req.username !== config2.adminUsername) { throw AppError.unauthorized(); } await verifyPassword(config2, req.password); const expiresAt = new Date( Date.now() + config2.jwtTtlHours * 60 * 60 * 1e3 ); const token = sign({ sub: req.username, exp: Math.floor(expiresAt.getTime() / 1e3) }); return { token, expires_at: expiresAt.toISOString() }; } // src/routes/health.ts async function healthRoutes(app2) { app2.get("/health", async (request) => { healthCheck(request.server.sqlite); return { status: "ok" }; }); app2.get("/ready", async (request) => { healthCheck(request.server.sqlite); return { status: "ready", database: true }; }); } async function authRoutes(app2) { app2.get("/auth/config", async (request) => { const { config: config2 } = request.server; return { required: config2.authRequired, portal_url: config2.authPortalUrl }; }); const loginSchema = z.object({ username: z.string(), password: z.string() }); app2.post("/auth/login", async (request, reply) => { if (request.server.config.authRequired) { return reply.code(403).send({ error: { code: "FORBIDDEN", message: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 auth-portal" } }); } const body = loginSchema.parse(request.body); const result = await login( request.server.config, (payload) => request.server.jwt.sign(payload), body ); return result; }); } // src/routes/settings.ts import { appSettingsPatchSchema } from "@cdnmanager/shared"; import { getAppSettings, updateAppSettings } from "@cdnmanager/db"; async function settingsRoutes(app2) { app2.get("/settings", async (request) => { return getAppSettings(request.server.db); }); app2.patch("/settings", async (request) => { const parsed = appSettingsPatchSchema.safeParse(request.body); if (!parsed.success) { throw AppError.validation( parsed.error.issues[0]?.message ?? "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438" ); } return updateAppSettings(request.server.db, parsed.data); }); } // src/app.ts async function buildApp(opts = {}) { const config2 = opts.config ?? loadConfig(); const app2 = Fastify({ logger: { level: config2.logLevel } }).withTypeProvider(); app2.setValidatorCompiler(validatorCompiler); app2.setSerializerCompiler(serializerCompiler); await app2.register(import("@fastify/sensible")); await app2.register(import("@fastify/helmet"), { contentSecurityPolicy: false }); await app2.register(import("@fastify/rate-limit"), { max: 300, timeWindow: "1 minute" }); await app2.register(cors_default); await app2.register(error_handler_default); await app2.register(db_default, { config: config2, memory: opts.memory }); await app2.register(auth_default, { config: config2 }); await app2.register(healthRoutes); await app2.register(authRoutes, { prefix: "/api/v1" }); await app2.register( async (protectedApi) => { protectedApi.addHook("onRequest", requireAuth); await protectedApi.register(settingsRoutes); }, { prefix: "/api/v1" } ); const staticDir = config2.staticDir ?? resolve2(process.cwd(), "static"); if (config2.staticDir !== null) { await app2.register(import("@fastify/static"), { root: staticDir, wildcard: false }); app2.setNotFoundHandler(async (_request, reply) => { return reply.sendFile("index.html"); }); } return app2; } // src/server.ts for (const path of [ resolve3(import.meta.dirname, "../../../.env"), ".env", "../.env" ]) { if (!existsSync(path)) continue; const content = readFileSync(path, "utf-8"); for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); let value = trimmed.slice(eq + 1).trim(); if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { value = value.slice(1, -1); } if (!(key in process.env)) process.env[key] = value; } break; } var config = loadConfig(); var app = await buildApp({ config }); try { await app.listen({ port: config.serverPort, host: "0.0.0.0" }); app.log.info(`listening on ${config.serverPort}`); } catch (err) { app.log.error(err); process.exit(1); }