diff --git a/backend/src/routes/panel.ts b/backend/src/routes/panel.ts new file mode 100644 index 0000000..9cd3dcc --- /dev/null +++ b/backend/src/routes/panel.ts @@ -0,0 +1,435 @@ +import type { FastifyPluginAsync } from "fastify"; +import { requireSession, accountIdFor } from "../lib/auth.js"; +import { fail, ok } from "../lib/envelope.js"; +import { + databaseDto, + mailboxDto, + resourceUsageDto, + serverDto, + userDto, +} from "../lib/serializers.js"; + +const services = [ + { name: "Web server", status: "healthy", uptime: "18 days", detail: "nginx active" }, + { name: "PHP-FPM", status: "healthy", uptime: "18 days", detail: "8 pools running" }, + { name: "Database", status: "healthy", uptime: "18 days", detail: "PostgreSQL responding" }, + { name: "DNS", status: "healthy", uptime: "18 days", detail: "authoritative zones synced" }, + { name: "Email", status: "warning", uptime: "7 days", detail: "14 deferred messages" }, + { name: "Redis", status: "healthy", uptime: "18 days", detail: "queue cache ready" }, + { name: "Queue", status: "healthy", uptime: "18 days", detail: "workers idle" }, + { name: "Firewall", status: "healthy", uptime: "18 days", detail: "38 IPs blocked" }, + { name: "Backup service", status: "healthy", uptime: "18 days", detail: "last run verified" }, + { + name: "Monitoring service", + status: "healthy", + uptime: "18 days", + detail: "all probes green", + }, +] as const; + +const files = [ + { + id: "file-1", + name: "public_html", + path: "/home/coaxys/public_html", + type: "directory", + size: 0, + modifiedAt: "2026-06-12T10:12:00.000Z", + owner: "coaxys", + permissions: "0755", + mime: "inode/directory", + }, + { + id: "file-2", + name: "index.php", + path: "/home/coaxys/public_html/index.php", + type: "file", + size: 18432, + modifiedAt: "2026-06-12T08:44:00.000Z", + owner: "coaxys", + permissions: "0644", + mime: "application/x-php", + }, + { + id: "file-3", + name: ".env", + path: "/home/coaxys/app/.env", + type: "file", + size: 1024, + modifiedAt: "2026-06-11T22:03:00.000Z", + owner: "coaxys", + permissions: "0600", + mime: "text/plain", + hidden: true, + }, +] as const; + +const runtimes = [ + { + id: "php", + name: "PHP", + versions: ["8.1", "8.2", "8.3", "8.4"], + extensions: ["opcache", "intl", "imagick", "redis", "pdo_mysql"], + processManagers: ["PHP-FPM"], + environmentSchema: ["APP_ENV", "APP_DEBUG", "PHP_MEMORY_LIMIT"], + }, + { + id: "nodejs", + name: "Node.js", + versions: ["20", "22", "24"], + extensions: ["npm", "pnpm", "yarn"], + processManagers: ["pm2", "systemd"], + environmentSchema: ["NODE_ENV", "PORT", "NPM_CONFIG_PRODUCTION"], + }, + { + id: "python", + name: "Python", + versions: ["3.11", "3.12", "3.13"], + extensions: ["pip", "venv", "uvicorn", "gunicorn"], + processManagers: ["systemd", "supervisor"], + environmentSchema: ["PYTHONPATH", "VIRTUAL_ENV", "PORT"], + }, +] as const; + +function accountDto(account: { + id: string; + username: string; + primaryDomain: string; + homeDirectory: string; + sharedIp: string; + dedicatedIp: string | null; + theme: string; +}) { + return { + id: account.id, + username: account.username, + primaryDomain: account.primaryDomain, + homeDirectory: account.homeDirectory, + sharedIp: account.sharedIp, + dedicatedIp: account.dedicatedIp ?? undefined, + theme: account.theme, + }; +} + +function iso(value: Date | null) { + return value?.toISOString() ?? new Date(0).toISOString(); +} + +export const panelRoutes: FastifyPluginAsync = async (app) => { + app.get("/api/dashboard/summary", async (request) => { + const session = await requireSession(app, request); + const accountId = accountIdFor(session); + const [account, accounts, serverList, resources, notifications, metrics] = + await Promise.all([ + app.prisma.hostingAccount.findUniqueOrThrow({ where: { id: accountId } }), + app.prisma.hostingAccount.findMany({ + where: { users: { some: { userId: session.userId } } }, + orderBy: { username: "asc" }, + }), + app.prisma.server.findMany({ orderBy: { name: "asc" } }), + app.prisma.resourceUsage.findMany({ where: { accountId }, orderBy: { key: "asc" } }), + app.prisma.notification.findMany({ + where: { accountId }, + orderBy: { createdAt: "desc" }, + take: 8, + }), + app.prisma.metricPoint.findMany({ + where: { accountId }, + orderBy: { timestamp: "asc" }, + take: 48, + }), + ]); + + return ok(request, { + user: userDto(session.user), + account: accountDto(account), + server: serverDto(serverList[0]!), + accounts: accounts.map(accountDto), + servers: serverList.map(serverDto), + resources: resources.map(resourceUsageDto), + services, + notifications: notifications.map((notification) => ({ + id: notification.id, + title: notification.title, + category: notification.category, + read: notification.read, + createdAt: notification.createdAt.toISOString(), + body: notification.body, + })), + metrics: metrics.map((point) => ({ + timestamp: point.timestamp.toISOString(), + cpu: point.cpu, + memory: point.memory, + disk: point.disk, + bandwidth: point.bandwidth, + requests: point.requests, + })), + }); + }); + + app.get("/api/files", async (request) => { + await requireSession(app, request); + return ok(request, files); + }); + + app.post("/api/files/operations", async (request) => { + await requireSession(app, request); + const body = request.body as { operation?: string } | undefined; + return ok(request, { + id: `job-${Date.now()}`, + label: body?.operation ?? "File operation", + status: "running", + progress: 64, + startedAt: new Date().toISOString(), + message: "Operation is queued in the control plane and scoped to the hosting account.", + }); + }); + + app.get("/api/databases", async (request) => { + const session = await requireSession(app, request); + const databases = await app.prisma.database.findMany({ + where: { accountId: accountIdFor(session) }, + include: { users: true }, + orderBy: { name: "asc" }, + }); + return ok(request, databases.map(databaseDto)); + }); + + app.post("/api/databases", async (request, reply) => { + const session = await requireSession(app, request); + const accountId = accountIdFor(session); + const body = request.body as { name?: string } | undefined; + const name = body?.name?.trim(); + if (!name || name.length < 2) { + return fail(request, reply, 400, { + code: "VALIDATION_ERROR", + message: "Database name is required.", + fields: { name: "Use at least two characters." }, + }); + } + + const existingDatabase = await app.prisma.database.findFirst({ + where: { accountId, name }, + select: { id: true }, + }); + if (existingDatabase) { + return fail(request, reply, 409, { + code: "VALIDATION_ERROR", + message: "A database with this name already exists.", + fields: { name: "Choose a unique database name for this account." }, + }); + } + + const database = await app.prisma.database.create({ + data: { + name, + engine: "MySQL", + size: 0n, + tables: 0, + connections: 0, + lastBackup: new Date(), + accountId, + }, + }); + + return reply + .status(201) + .send(ok(request, { id: database.id, name: database.name, status: "created" })); + }); + + app.get("/api/email/accounts", async (request) => { + const session = await requireSession(app, request); + const mailboxes = await app.prisma.mailbox.findMany({ + where: { accountId: accountIdFor(session) }, + include: { domain: true }, + orderBy: { address: "asc" }, + }); + return ok(request, mailboxes.map(mailboxDto)); + }); + + app.post("/api/email/accounts", async (request, reply) => { + const session = await requireSession(app, request); + const accountId = accountIdFor(session); + const body = request.body as { address?: string } | undefined; + const address = body?.address?.trim().toLowerCase(); + const domainName = address?.split("@")[1]; + const domain = domainName + ? await app.prisma.domain.findFirst({ + where: { accountId, name: domainName }, + }) + : null; + + if (!address || !address.includes("@") || !domain) { + return fail(request, reply, 400, { + code: "VALIDATION_ERROR", + message: "Mailbox address is invalid or its domain is not managed by this account.", + fields: { address: "Use an address on a managed domain." }, + }); + } + + const existingMailbox = await app.prisma.mailbox.findFirst({ + where: { accountId, address }, + select: { id: true }, + }); + if (existingMailbox) { + return fail(request, reply, 409, { + code: "VALIDATION_ERROR", + message: "A mailbox with this address already exists.", + fields: { address: "Choose a unique mailbox address." }, + }); + } + + const mailbox = await app.prisma.mailbox.create({ + data: { + address, + quota: 5_000_000_000n, + used: 0n, + forwarding: false, + spamScore: 0, + authStatus: "healthy", + domainId: domain.id, + accountId, + }, + }); + + return reply + .status(201) + .send(ok(request, { id: mailbox.id, address: mailbox.address, status: "created" })); + }); + + app.get("/api/metrics/history", async (request) => { + const session = await requireSession(app, request); + const points = await app.prisma.metricPoint.findMany({ + where: { accountId: accountIdFor(session) }, + orderBy: { timestamp: "asc" }, + take: 96, + }); + return ok( + request, + points.map((point) => ({ + timestamp: point.timestamp.toISOString(), + cpu: point.cpu, + memory: point.memory, + disk: point.disk, + bandwidth: point.bandwidth, + requests: point.requests, + })), + ); + }); + + app.get("/api/security/findings", async (request) => { + const session = await requireSession(app, request); + const findings = await app.prisma.securityFinding.findMany({ + where: { accountId: accountIdFor(session) }, + orderBy: [{ severity: "asc" }, { title: "asc" }], + }); + return ok(request, findings); + }); + + app.get("/api/backups", async (request) => { + const session = await requireSession(app, request); + const backups = await app.prisma.backup.findMany({ + where: { accountId: accountIdFor(session) }, + orderBy: { createdAt: "desc" }, + }); + return ok( + request, + backups.map((backup) => ({ + id: backup.id, + name: backup.name, + scope: backup.scope, + destination: backup.destination, + size: Number(backup.size), + status: backup.status, + encrypted: backup.encrypted, + createdAt: backup.createdAt.toISOString(), + })), + ); + }); + + app.post("/api/backups", async (request) => { + await requireSession(app, request); + const body = request.body as { scope?: string } | undefined; + return ok(request, { + id: `job-${Date.now()}`, + label: `Create ${body?.scope ?? "full"} backup`, + status: "queued", + progress: 0, + startedAt: new Date().toISOString(), + }); + }); + + app.get("/api/automation", async (request) => { + const session = await requireSession(app, request); + const automations = await app.prisma.automation.findMany({ + where: { accountId: accountIdFor(session) }, + orderBy: { name: "asc" }, + }); + return ok( + request, + automations.map((automation) => ({ + id: automation.id, + name: automation.name, + trigger: automation.trigger, + status: automation.status, + lastRun: iso(automation.lastRun), + successRate: automation.successRate, + })), + ); + }); + + app.get("/api/notifications", async (request) => { + const session = await requireSession(app, request); + const notifications = await app.prisma.notification.findMany({ + where: { accountId: accountIdFor(session) }, + orderBy: { createdAt: "desc" }, + }); + return ok( + request, + notifications.map((notification) => ({ + id: notification.id, + title: notification.title, + category: notification.category, + read: notification.read, + createdAt: notification.createdAt.toISOString(), + body: notification.body, + })), + ); + }); + + app.get("/api/audit-logs", async (request) => { + const session = await requireSession(app, request); + const entries = await app.prisma.auditEntry.findMany({ + where: { accountId: accountIdFor(session) }, + orderBy: { createdAt: "desc" }, + }); + return ok( + request, + entries.map((entry) => ({ + id: entry.id, + actor: entry.actor, + action: entry.action, + target: entry.target, + ipAddress: entry.ipAddress, + createdAt: entry.createdAt.toISOString(), + requestId: entry.requestId, + })), + ); + }); + + app.get("/api/software/runtimes", async (request) => { + await requireSession(app, request); + return ok(request, runtimes); + }); + + app.get("/api/servers", async (request) => { + await requireSession(app, request); + const servers = await app.prisma.server.findMany({ orderBy: { name: "asc" } }); + return ok(request, servers.map(serverDto)); + }); + + app.get("/api/servers/services", async (request) => { + await requireSession(app, request); + return ok(request, services); + }); +};