quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 7s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Failing after 38s
quality / api (push) Successful in 49s
CD / quality (push) Failing after 1m36s
CD / publish (push) Skipped
418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
// 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);
|
|
}
|