feat: rename WHM→NixServer, cPanel→NixClient, bump v0.2.0
- Rename frontend workspaces: whm/ → nixserver/, cpanel/ → nixclient/ - Rename server route dirs: routes/whm/ → routes/nixserver/, routes/cpanel/ → routes/nixclient/ - Update all API prefixes: /api/whm → /api/nixserver, /api/cpanel → /api/nixclient - Rename env vars: WHM_PORT → NIXSERVER_PORT, CPANEL_PORT → NIXCLIENT_PORT - Update config properties: whmPort → nixserverPort, cpanelPort → nixclientPort - Update all UI labels, login pages, sidebar branding, and error messages - Update installer and update scripts to match new names - Add cpanel email route (missing from initial scaffold) - Add .gitattributes to enforce LF line endings - Bump version to 0.2.0 across all package.json files
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec, writeFile } from '../../core/exec.js'
|
||||
|
||||
// Basic cron schedule validation
|
||||
function isValidCronSchedule(schedule: string): boolean {
|
||||
const parts = schedule.trim().split(/\s+/)
|
||||
if (parts.length !== 5) return false
|
||||
const fieldPattern = /^(\*|(\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?|\*\/\d+)$/
|
||||
return parts.every(p => fieldPattern.test(p))
|
||||
}
|
||||
|
||||
export default async function cronRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const jobs = await prisma.cronJob.findMany({ where: { accountId }, orderBy: { createdAt: 'desc' } })
|
||||
return reply.send({ success: true, data: jobs })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
schedule: z.string().min(9),
|
||||
command: z.string().min(1).max(1000),
|
||||
comment: z.string().max(255).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!isValidCronSchedule(body.data.schedule)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid cron schedule' })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxCronJobs > 0) {
|
||||
const count = await prisma.cronJob.count({ where: { accountId } })
|
||||
if (count >= account.package.maxCronJobs) {
|
||||
return reply.code(429).send({ success: false, error: 'Cron job limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const job = await prisma.cronJob.create({
|
||||
data: { ...body.data, accountId },
|
||||
})
|
||||
|
||||
await writeCrontab(account.username, accountId)
|
||||
|
||||
return reply.code(201).send({ success: true, data: job })
|
||||
})
|
||||
|
||||
fastify.put('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
schedule: z.string().optional(),
|
||||
command: z.string().min(1).max(1000).optional(),
|
||||
comment: z.string().max(255).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (body.data.schedule && !isValidCronSchedule(body.data.schedule)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid cron schedule' })
|
||||
}
|
||||
|
||||
const job = await prisma.cronJob.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!job) return reply.code(404).send({ success: false, error: 'Cron job not found' })
|
||||
|
||||
const updated = await prisma.cronJob.update({
|
||||
where: { id: job.id },
|
||||
data: body.data,
|
||||
})
|
||||
|
||||
await writeCrontab(job.account.username, job.accountId)
|
||||
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const job = await prisma.cronJob.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!job) return reply.code(404).send({ success: false, error: 'Cron job not found' })
|
||||
|
||||
await prisma.cronJob.delete({ where: { id: job.id } })
|
||||
await writeCrontab(job.account.username, job.accountId)
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Cron job deleted' } })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate and install the crontab for a user.
|
||||
*/
|
||||
async function writeCrontab(username: string, accountId: number): Promise<void> {
|
||||
const jobs = await prisma.cronJob.findMany({
|
||||
where: { accountId, enabled: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const lines = [
|
||||
`# NixPanel crontab for ${username}`,
|
||||
`# DO NOT EDIT — managed by NixPanel`,
|
||||
'',
|
||||
...jobs.map(j => `${j.schedule} ${j.command}${j.comment ? ` # ${j.comment}` : ''}`),
|
||||
'',
|
||||
]
|
||||
|
||||
const crontabPath = `/var/spool/cron/crontabs/${username}`
|
||||
await writeFile(crontabPath, lines.join('\n'))
|
||||
await exec('chown', [`${username}:crontab`, crontabPath])
|
||||
await exec('chmod', ['600', crontabPath])
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
function mysqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "\\'").replace(/\\/g, '\\\\')
|
||||
}
|
||||
|
||||
export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/databases
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const databases = await prisma.database.findMany({
|
||||
where: { accountId },
|
||||
include: { dbUsers: { select: { id: true, username: true, host: true, privileges: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
return reply.send({ success: true, data: databases })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/databases
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.body as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const body = z.object({
|
||||
name: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/),
|
||||
charset: z.string().default('utf8mb4'),
|
||||
collation: z.string().default('utf8mb4_unicode_ci'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { package: true },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxDatabases > 0) {
|
||||
const count = await prisma.database.count({ where: { accountId } })
|
||||
if (count >= account.package.maxDatabases) {
|
||||
return reply.code(429).send({ success: false, error: 'Database limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix database name with username to avoid conflicts
|
||||
const dbName = `${account.username}_${body.data.name}`
|
||||
const exists = await prisma.database.findUnique({ where: { name: dbName } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Database already exists' })
|
||||
|
||||
// Create the actual MariaDB database
|
||||
const result = await exec('mysql', [
|
||||
'-e',
|
||||
`CREATE DATABASE \`${mysqlEscape(dbName)}\` CHARACTER SET ${mysqlEscape(body.data.charset)} COLLATE ${mysqlEscape(body.data.collation)};`,
|
||||
])
|
||||
if (result.exitCode !== 0 && !process.env.NODE_ENV?.startsWith('dev')) {
|
||||
return reply.code(500).send({ success: false, error: `Failed to create database: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const db = await prisma.database.create({
|
||||
data: { name: dbName, charset: body.data.charset, collation: body.data.collation, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: db })
|
||||
})
|
||||
|
||||
// DELETE /api/nixclient/databases/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const db = await prisma.database.findFirst({
|
||||
where: {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP DATABASE IF EXISTS \`${mysqlEscape(db.name)}\`;`])
|
||||
await prisma.database.delete({ where: { id: db.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: `Database ${db.name} deleted` } })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/databases/:id/users
|
||||
fastify.post('/:id/users', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(16).regex(/^[a-zA-Z0-9_]+$/),
|
||||
password: z.string().min(8),
|
||||
privileges: z.string().default('ALL'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const db = await prisma.database.findFirst({
|
||||
where: {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
include: { account: true },
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
const dbUsername = `${db.account.username}_${body.data.username}`
|
||||
const escapedPass = mysqlEscape(body.data.password)
|
||||
|
||||
// Create MariaDB user and grant privileges
|
||||
await exec('mysql', ['-e', `CREATE USER '${mysqlEscape(dbUsername)}'@'localhost' IDENTIFIED BY '${escapedPass}';`])
|
||||
await exec('mysql', ['-e', `GRANT ${mysqlEscape(body.data.privileges)} ON \`${mysqlEscape(db.name)}\`.* TO '${mysqlEscape(dbUsername)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
const dbUser = await prisma.databaseUser.create({
|
||||
data: {
|
||||
username: dbUsername,
|
||||
passwordHash,
|
||||
privileges: body.data.privileges,
|
||||
databaseId: db.id,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = dbUser
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/nixclient/databases/:id/users/:userId
|
||||
fastify.delete('/:id/users/:userId', { preHandler }, async (request, reply) => {
|
||||
const { id, userId } = request.params as { id: string; userId: string }
|
||||
|
||||
const dbUser = await prisma.databaseUser.findFirst({
|
||||
where: { id: parseInt(userId, 10), databaseId: parseInt(id, 10) },
|
||||
})
|
||||
if (!dbUser) return reply.code(404).send({ success: false, error: 'Database user not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP USER IF EXISTS '${mysqlEscape(dbUser.username)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
await prisma.databaseUser.delete({ where: { id: dbUser.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Database user deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { validateDomain, exec } from '../../core/exec.js'
|
||||
import { enableVhost } from '../../core/nginx.js'
|
||||
|
||||
export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// ─── Subdomains ──────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const subdomains = await prisma.subdomain.findMany({ where: { accountId }, orderBy: { fqdn: 'asc' } })
|
||||
return reply.send({ success: true, data: subdomains })
|
||||
})
|
||||
|
||||
fastify.post('/subdomains', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
subdomain: z.string().min(1).max(63).regex(/^[a-zA-Z0-9-]+$/),
|
||||
documentRoot: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
const pkg = account.packageId ? await prisma.package.findUnique({ where: { id: account.packageId } }) : null
|
||||
if (pkg && pkg.maxSubdomains > 0) {
|
||||
const count = await prisma.subdomain.count({ where: { accountId } })
|
||||
if (count >= pkg.maxSubdomains) return reply.code(429).send({ success: false, error: 'Subdomain limit reached' })
|
||||
}
|
||||
|
||||
const fqdn = `${body.data.subdomain}.${account.domain}`
|
||||
const existing = await prisma.subdomain.findUnique({ where: { fqdn } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Subdomain already exists' })
|
||||
|
||||
const docRoot = body.data.documentRoot ?? `${account.homedir}/public_html/${body.data.subdomain}`
|
||||
|
||||
// Create doc root
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
// Create nginx vhost for subdomain
|
||||
try {
|
||||
await enableVhost({ domain: fqdn, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for subdomain ${fqdn} failed:`, e)
|
||||
}
|
||||
|
||||
const subdomain = await prisma.subdomain.create({
|
||||
data: { subdomain: body.data.subdomain, domain: account.domain, fqdn, documentRoot: docRoot, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: subdomain })
|
||||
})
|
||||
|
||||
fastify.delete('/subdomains/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const subdomain = await prisma.subdomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!subdomain) return reply.code(404).send({ success: false, error: 'Subdomain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(subdomain.fqdn) } catch {}
|
||||
|
||||
await prisma.subdomain.delete({ where: { id: subdomain.id } })
|
||||
return reply.send({ success: true, data: { message: 'Subdomain deleted' } })
|
||||
})
|
||||
|
||||
// ─── Addon Domains ───────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/addon', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const domains = await prisma.addonDomain.findMany({ where: { accountId }, orderBy: { domain: 'asc' } })
|
||||
return reply.send({ success: true, data: domains })
|
||||
})
|
||||
|
||||
fastify.post('/addon', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
domain: z.string(),
|
||||
subdomain: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!validateDomain(body.data.domain)) return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const existing = await prisma.addonDomain.findUnique({ where: { domain: body.data.domain } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Addon domain already exists' })
|
||||
|
||||
const subdomain = body.data.subdomain ?? body.data.domain.replace(/\./g, '_')
|
||||
const docRoot = `${account.homedir}/public_html/${subdomain}`
|
||||
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
try {
|
||||
await enableVhost({ domain: body.data.domain, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for addon domain ${body.data.domain} failed:`, e)
|
||||
}
|
||||
|
||||
const addon = await prisma.addonDomain.create({
|
||||
data: { domain: body.data.domain, subdomain, documentRoot: docRoot, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: addon })
|
||||
})
|
||||
|
||||
fastify.delete('/addon/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const addon = await prisma.addonDomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!addon) return reply.code(404).send({ success: false, error: 'Addon domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(addon.domain) } catch {}
|
||||
|
||||
await prisma.addonDomain.delete({ where: { id: addon.id } })
|
||||
return reply.send({ success: true, data: { message: 'Addon domain deleted' } })
|
||||
})
|
||||
|
||||
// ─── Parked Domains ──────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/parked', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const domains = await prisma.parkedDomain.findMany({ where: { accountId }, orderBy: { domain: 'asc' } })
|
||||
return reply.send({ success: true, data: domains })
|
||||
})
|
||||
|
||||
fastify.post('/parked', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({ domain: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!validateDomain(body.data.domain)) return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const existing = await prisma.parkedDomain.findUnique({ where: { domain: body.data.domain } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Parked domain already exists' })
|
||||
|
||||
// Parked domains point to main public_html
|
||||
try {
|
||||
await enableVhost({
|
||||
domain: body.data.domain,
|
||||
username: account.username,
|
||||
documentRoot: `${account.homedir}/public_html`,
|
||||
aliases: [account.domain],
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for parked domain ${body.data.domain} failed:`, e)
|
||||
}
|
||||
|
||||
const parked = await prisma.parkedDomain.create({
|
||||
data: { domain: body.data.domain, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: parked })
|
||||
})
|
||||
|
||||
fastify.delete('/parked/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const parked = await prisma.parkedDomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!parked) return reply.code(404).send({ success: false, error: 'Parked domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(parked.domain) } catch {}
|
||||
|
||||
await prisma.parkedDomain.delete({ where: { id: parked.id } })
|
||||
return reply.send({ success: true, data: { message: 'Parked domain deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/email
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const accounts = await prisma.emailAccount.findMany({
|
||||
where: { accountId },
|
||||
orderBy: { address: 'asc' },
|
||||
select: { id: true, address: true, username: true, domain: true, quotaMb: true, usedMb: true, status: true },
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/email
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/),
|
||||
password: z.string().min(8),
|
||||
quotaMb: z.number().int().min(0).default(500),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
if (account.package && account.package.maxEmailAccounts > 0) {
|
||||
const count = await prisma.emailAccount.count({ where: { accountId: account.id } })
|
||||
if (count >= account.package.maxEmailAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'Email account limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const address = `${body.data.username}@${account.domain}`
|
||||
const exists = await prisma.emailAccount.findUnique({ where: { address } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
await exec('doveadm', ['user', 'import', '-u', address, '-p', body.data.password])
|
||||
|
||||
const emailAccount = await prisma.emailAccount.create({
|
||||
data: { username: body.data.username, domain: account.domain, address, passwordHash, quotaMb: body.data.quotaMb, accountId: account.id },
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = emailAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/nixclient/email/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
if (user.role === 'user' && emailAccount.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
|
||||
await exec('doveadm', ['user', 'delete', '-u', emailAccount.address])
|
||||
await prisma.emailAccount.delete({ where: { id: emailAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Email account deleted' } })
|
||||
})
|
||||
|
||||
// GET /api/nixclient/email/forwarders
|
||||
fastify.get('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const forwarders = await prisma.emailForwarder.findMany({ where: { accountId } })
|
||||
return reply.send({ success: true, data: forwarders })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/email/forwarders
|
||||
fastify.post('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
|
||||
|
||||
const body = z.object({
|
||||
source: z.string().email(),
|
||||
destination: z.string().email(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const forwarder = await prisma.emailForwarder.create({ data: { ...body.data, accountId } })
|
||||
return reply.code(201).send({ success: true, data: forwarder })
|
||||
})
|
||||
|
||||
// DELETE /api/nixclient/email/forwarders/:id
|
||||
fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const forwarder = await prisma.emailForwarder.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!forwarder) return reply.code(404).send({ success: false, error: 'Forwarder not found' })
|
||||
if (user.role === 'user' && forwarder.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
|
||||
await prisma.emailForwarder.delete({ where: { id: forwarder.id } })
|
||||
return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import { stat, readdir, unlink, rename, mkdir } from 'fs/promises'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { config } from '../../config.js'
|
||||
import prisma from '../../db/client.js'
|
||||
|
||||
/**
|
||||
* Resolve a user path, enforcing it stays inside their home directory.
|
||||
*/
|
||||
function resolveUserPath(homedir: string, userPath: string): string {
|
||||
const resolved = path.resolve(homedir, userPath.replace(/^\//, ''))
|
||||
if (!resolved.startsWith(homedir + path.sep) && resolved !== homedir) {
|
||||
throw new Error('Path traversal detected')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function getAccount(fastify: FastifyInstance, userId: number) {
|
||||
return prisma.account.findUnique({ where: { id: userId } })
|
||||
}
|
||||
|
||||
export default async function fileRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/files?path=
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const q = request.query as Record<string, string>
|
||||
const userPath = q.path ?? '/'
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, userPath)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const entries = await readdir(targetPath, { withFileTypes: true })
|
||||
const files = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const fullPath = path.join(targetPath, entry.name)
|
||||
let size = 0
|
||||
let mtime = new Date()
|
||||
try {
|
||||
const s = await stat(fullPath)
|
||||
size = s.size
|
||||
mtime = s.mtime
|
||||
} catch {}
|
||||
return {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
size,
|
||||
mtime,
|
||||
path: path.join(userPath, entry.name),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
files.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { path: userPath, files } })
|
||||
})
|
||||
|
||||
// GET /api/nixclient/files/content?path=
|
||||
fastify.get('/content', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const q = request.query as Record<string, string>
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, q.path ?? '')
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const fileStat = await stat(targetPath)
|
||||
if (fileStat.isDirectory()) {
|
||||
return reply.code(400).send({ success: false, error: 'Path is a directory' })
|
||||
}
|
||||
if (fileStat.size > 5 * 1024 * 1024) {
|
||||
return reply.code(413).send({ success: false, error: 'File too large to edit (max 5MB)' })
|
||||
}
|
||||
|
||||
const { readFile } = await import('fs/promises')
|
||||
const content = await readFile(targetPath, 'utf8')
|
||||
return reply.send({ success: true, data: { path: q.path, content } })
|
||||
})
|
||||
|
||||
// PUT /api/nixclient/files/content — save file content
|
||||
fastify.put('/content', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const { writeFile } = await import('fs/promises')
|
||||
await writeFile(targetPath, body.data.content, 'utf8')
|
||||
await exec('chown', [`${account.username}:${account.username}`, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'File saved' } })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/files/mkdir
|
||||
fastify.post('/mkdir', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ path: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'path required' })
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
await mkdir(targetPath, { recursive: true })
|
||||
await exec('chown', [`${account.username}:${account.username}`, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Directory created' } })
|
||||
})
|
||||
|
||||
// DELETE /api/nixclient/files
|
||||
fastify.delete('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ paths: z.array(z.string()).min(1).max(50) }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'paths[] required' })
|
||||
|
||||
const deleted: string[] = []
|
||||
const errors: { path: string; error: string }[] = []
|
||||
|
||||
for (const p of body.data.paths) {
|
||||
try {
|
||||
const targetPath = resolveUserPath(account.homedir, p)
|
||||
const s = await stat(targetPath)
|
||||
if (s.isDirectory()) {
|
||||
await exec('rm', ['-rf', targetPath])
|
||||
} else {
|
||||
await unlink(targetPath)
|
||||
}
|
||||
deleted.push(p)
|
||||
} catch (e) {
|
||||
errors.push({ path: p, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { deleted, errors } })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/files/rename
|
||||
fastify.post('/rename', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ from: z.string(), to: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const fromPath = resolveUserPath(account.homedir, body.data.from)
|
||||
const toPath = resolveUserPath(account.homedir, body.data.to)
|
||||
|
||||
await rename(fromPath, toPath)
|
||||
return reply.send({ success: true, data: { message: 'Renamed' } })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/files/chmod
|
||||
fastify.post('/chmod', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
path: z.string(),
|
||||
mode: z.string().regex(/^[0-7]{3,4}$/),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
await exec('chmod', [body.data.mode, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Permissions updated' } })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/files/compress — zip files
|
||||
fastify.post('/compress', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'NixClient users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
paths: z.array(z.string()).min(1),
|
||||
dest: z.string(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const destPath = resolveUserPath(account.homedir, body.data.dest)
|
||||
const srcPaths = body.data.paths.map(p => resolveUserPath(account.homedir, p))
|
||||
|
||||
await exec('tar', ['-czf', destPath, ...srcPaths])
|
||||
return reply.send({ success: true, data: { message: 'Compressed', dest: body.data.dest } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec, writeFile } from '../../core/exec.js'
|
||||
|
||||
export default async function ftpRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const accounts = await prisma.ftpAccount.findMany({
|
||||
where: { accountId },
|
||||
select: { id: true, username: true, homedir: true, quotaMb: true, status: true, createdAt: true },
|
||||
orderBy: { username: 'asc' },
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/),
|
||||
password: z.string().min(8),
|
||||
homedir: z.string().default('/'),
|
||||
quotaMb: z.number().int().min(0).default(0),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxFtpAccounts > 0) {
|
||||
const count = await prisma.ftpAccount.count({ where: { accountId } })
|
||||
if (count >= account.package.maxFtpAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'FTP account limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const ftpUsername = `${account.username}_${body.data.username}`
|
||||
const homedir = `${account.homedir}${body.data.homedir}`
|
||||
|
||||
// Create system user for FTP (locked shell, home in account dir)
|
||||
await exec('useradd', ['-m', '-d', homedir, '-s', '/sbin/nologin', ftpUsername])
|
||||
await exec('mkdir', ['-p', homedir])
|
||||
await exec('chown', [`${ftpUsername}:${ftpUsername}`, homedir])
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
// Add to vsftpd virtual users list
|
||||
const vsftpdUser = `/etc/vsftpd/users/${ftpUsername}`
|
||||
await writeFile(vsftpdUser, `${ftpUsername}\n${body.data.password}\n`)
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.create({
|
||||
data: { username: ftpUsername, passwordHash, homedir, quotaMb: body.data.quotaMb, accountId },
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = ftpAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.put('/:id/password', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({ password: z.string().min(8) }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'Password must be at least 8 characters' })
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!ftpAccount) return reply.code(404).send({ success: false, error: 'FTP account not found' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
await prisma.ftpAccount.update({ where: { id: ftpAccount.id }, data: { passwordHash } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'FTP password updated' } })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!ftpAccount) return reply.code(404).send({ success: false, error: 'FTP account not found' })
|
||||
|
||||
await exec('userdel', [ftpAccount.username])
|
||||
await prisma.ftpAccount.delete({ where: { id: ftpAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'FTP account deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import fileRoutes from './files.js'
|
||||
import databaseRoutes from './databases.js'
|
||||
import emailRoutes from './email.js'
|
||||
import domainRoutes from './domains.js'
|
||||
import ftpRoutes from './ftp.js'
|
||||
import cronRoutes from './cron.js'
|
||||
import statsRoutes from './stats.js'
|
||||
|
||||
export default async function nixclientRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(fileRoutes, { prefix: '/files' })
|
||||
fastify.register(databaseRoutes, { prefix: '/databases' })
|
||||
fastify.register(emailRoutes, { prefix: '/email' })
|
||||
fastify.register(domainRoutes, { prefix: '/domains' })
|
||||
fastify.register(ftpRoutes, { prefix: '/ftp' })
|
||||
fastify.register(cronRoutes, { prefix: '/cron' })
|
||||
fastify.register(statsRoutes, { prefix: '/stats' })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function statsRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/stats — account resource usage summary
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: accountId },
|
||||
include: {
|
||||
package: true,
|
||||
_count: {
|
||||
select: {
|
||||
emailAccounts: true, databases: true, ftpAccounts: true,
|
||||
subdomains: true, addonDomains: true, parkedDomains: true,
|
||||
sslCertificates: true, cronJobs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Get actual disk usage
|
||||
const duResult = await exec('du', ['-sb', account.homedir])
|
||||
const diskUsedBytes = parseInt(duResult.stdout.split('\t')[0] ?? '0', 10)
|
||||
const diskUsedMb = Math.floor(diskUsedBytes / 1024 / 1024)
|
||||
|
||||
// Update DB with current usage
|
||||
await prisma.account.update({ where: { id: accountId }, data: { diskUsedMb: BigInt(diskUsedMb) } })
|
||||
|
||||
const pkg = account.package
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
diskUsedMb,
|
||||
diskLimitMb: pkg ? Number(pkg.diskMb) : 0,
|
||||
bandwidthUsedMb: Number(account.bandwidthUsedMb),
|
||||
bandwidthLimitMb: pkg ? Number(pkg.bandwidthMb) : 0,
|
||||
counts: account._count,
|
||||
limits: pkg ? {
|
||||
maxEmailAccounts: pkg.maxEmailAccounts,
|
||||
maxDatabases: pkg.maxDatabases,
|
||||
maxFtpAccounts: pkg.maxFtpAccounts,
|
||||
maxSubdomains: pkg.maxSubdomains,
|
||||
maxAddonDomains: pkg.maxAddonDomains,
|
||||
maxParkedDomains: pkg.maxParkedDomains,
|
||||
maxCronJobs: pkg.maxCronJobs,
|
||||
} : null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/nixclient/stats/bandwidth — bandwidth usage over time (placeholder for log parsing)
|
||||
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Read nginx access log and count bytes for this account's domain
|
||||
const logFile = `/var/log/nginx/${account.username}.access.log`
|
||||
const result = await exec('awk', ['{sum += $10} END {print sum}', logFile])
|
||||
const bytes = parseInt(result.stdout || '0', 10)
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { domain: account.domain, bandwidthBytes: bytes, bandwidthMb: Math.floor(bytes / 1024 / 1024) },
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user