import type { FastifyInstance } from 'fastify'; import multipart from '@fastify/multipart'; import { eq, desc, count, and } from 'drizzle-orm'; import { Type } from '@sinclair/typebox'; import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requireSuperAdmin } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; import { uploadPluginArtifact } from '../../lib/cdn.js'; import * as yazl from 'yazl'; import { CreateGameSchema, UpdateGameSchema, GameIdParamSchema, PluginIdParamSchema, PluginReleaseIdParamSchema, CreateGlobalPluginSchema, UpdateGlobalPluginSchema, ImportPluginsSchema, CreatePluginReleaseSchema, UpdatePluginReleaseSchema, } from './schemas.js'; type ReleaseChannel = 'stable' | 'beta' | 'alpha'; interface UploadArtifactFile { relativePath: string; data: Buffer; } interface UploadJsonFile { filename: string; data: Buffer; } function toSlug(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') .slice(0, 200); } function sanitizeRelativeSegments(path: string): string[] { const segments = path.replace(/\\/g, '/').split('/').filter(Boolean); const normalized: string[] = []; for (const segment of segments) { if (segment === '.' || segment === '') continue; if (segment === '..') { throw AppError.badRequest('Invalid artifact path segment'); } normalized.push(segment); } return normalized; } function normalizeRelativePath(path: string, fallbackName: string): string { const segments = sanitizeRelativeSegments(path); if (segments.length === 0) { return sanitizeRelativeSegments(fallbackName).join('/'); } return segments.join('/'); } function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] { if (rawValue === undefined || rawValue === null || rawValue === '') return []; if (typeof rawValue !== 'string') { throw AppError.badRequest(`${fieldName} must be a JSON string`); } let parsed: unknown; try { parsed = JSON.parse(rawValue); } catch { throw AppError.badRequest(`${fieldName} is not valid JSON`); } if (!Array.isArray(parsed)) { throw AppError.badRequest(`${fieldName} must be a JSON array`); } return parsed; } function parseJsonArrayUploadFile( file: UploadJsonFile | null, fieldName: string, ): unknown[] { if (!file) return []; let rawValue = file.data.toString('utf8'); if (rawValue.charCodeAt(0) === 0xfeff) { rawValue = rawValue.slice(1); } return parseJsonArrayField(rawValue, fieldName); } function parseJsonArrayInput( rawValue: unknown, file: UploadJsonFile | null, fieldName: string, ): unknown[] { if (file) return parseJsonArrayUploadFile(file, fieldName); return parseJsonArrayField(rawValue, fieldName); } function parseOptionalBoolean(rawValue: unknown): boolean | undefined { if (rawValue === undefined || rawValue === null || rawValue === '') return undefined; if (typeof rawValue === 'boolean') return rawValue; if (typeof rawValue !== 'string') return undefined; const normalized = rawValue.trim().toLowerCase(); if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true; if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false; return undefined; } function parseReleaseChannel(rawValue: unknown): ReleaseChannel { if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue; if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase(); if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized; } return 'stable'; } async function zipArtifacts(files: UploadArtifactFile[]): Promise { return await new Promise((resolve, reject) => { const archive = new yazl.ZipFile(); const chunks: Buffer[] = []; archive.outputStream.on('data', (chunk: Buffer) => { chunks.push(chunk); }); archive.outputStream.on('error', reject); archive.outputStream.on('end', () => { resolve(Buffer.concat(chunks)); }); for (const file of files) { archive.addBuffer(file.data, file.relativePath.replace(/^\/+/g, '')); } archive.end(); }); } async function resolveImportGame( app: FastifyInstance, { gameId, gameSlug, }: { gameId?: string; gameSlug?: string; }, ) { if (gameId) { const game = await app.db.query.games.findFirst({ where: eq(games.id, gameId), }); if (!game) { throw AppError.notFound(`Game not found: ${gameId}`); } return game; } const normalizedSlug = gameSlug?.trim().toLowerCase(); if (normalizedSlug) { const game = await app.db.query.games.findFirst({ where: eq(games.slug, normalizedSlug), }); if (!game) { throw AppError.notFound(`Game not found: ${normalizedSlug}`); } return game; } throw AppError.badRequest('gameId or gameSlug is required for each import item'); } export default async function adminRoutes(app: FastifyInstance) { await app.register(multipart, { limits: { files: 200, parts: 600, fileSize: 512 * 1024 * 1024, }, }); // All admin routes require auth + super admin app.addHook('onRequest', app.authenticate); app.addHook('onRequest', async (request) => { requireSuperAdmin(request); }); // === Users === // GET /api/admin/users app.get('/users', { schema: { querystring: PaginationQuerySchema } }, async (request) => { const { page, perPage, offset, limit } = paginate(request.query as any); const [totalResult] = await app.db.select({ count: count() }).from(users); const userList = await app.db .select({ id: users.id, email: users.email, username: users.username, isSuperAdmin: users.isSuperAdmin, avatarUrl: users.avatarUrl, createdAt: users.createdAt, }) .from(users) .limit(limit) .offset(offset) .orderBy(users.createdAt); return paginatedResponse(userList, totalResult!.count, page, perPage); }); // === Games === // GET /api/admin/games app.get('/games', async () => { const gameList = await app.db .select() .from(games) .orderBy(games.name); return { data: gameList }; }); // POST /api/admin/games app.post('/games', { schema: CreateGameSchema }, async (request, reply) => { const body = request.body as { slug: string; name: string; dockerImage: string; defaultPort: number; startupCommand: string; stopCommand?: string; stopTimeoutSeconds?: number; containerDataPath?: string; configFiles?: unknown[]; environmentVars?: unknown[]; automationRules?: unknown[]; }; const existing = await app.db.query.games.findFirst({ where: eq(games.slug, body.slug), }); if (existing) throw AppError.conflict('Game slug already exists'); const [game] = await app.db .insert(games) .values({ ...body, configFiles: body.configFiles ?? [], environmentVars: body.environmentVars ?? [], automationRules: body.automationRules ?? [], }) .returning(); return reply.code(201).send(game); }); // PATCH /api/admin/games/:gameId app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => { const { gameId } = request.params as { gameId: string }; const body = request.body as Record; const [updated] = await app.db .update(games) .set({ ...body, updatedAt: new Date() }) .where(eq(games.id, gameId)) .returning(); if (!updated) throw AppError.notFound('Game not found'); return updated; }); // === Nodes (global view) === // === Global Plugins === app.get( '/plugins', { schema: { querystring: Type.Object({ gameId: Type.Optional(Type.String({ format: 'uuid' })), }), }, }, async (request) => { const { gameId } = request.query as { gameId?: string }; const rows = await app.db .select({ id: plugins.id, gameId: plugins.gameId, name: plugins.name, slug: plugins.slug, description: plugins.description, source: plugins.source, isGlobal: plugins.isGlobal, updatedAt: plugins.updatedAt, gameName: games.name, gameSlug: games.slug, }) .from(plugins) .innerJoin(games, eq(plugins.gameId, games.id)) .where(gameId ? eq(plugins.gameId, gameId) : undefined) .orderBy(plugins.name); return { data: rows }; }, ); app.post('/plugins', { schema: CreateGlobalPluginSchema }, async (request, reply) => { const body = request.body as { gameId: string; name: string; slug?: string; description?: string; source?: 'manual' | 'spiget'; }; const game = await app.db.query.games.findFirst({ where: eq(games.id, body.gameId), }); if (!game) throw AppError.notFound('Game not found'); const slug = toSlug(body.slug ?? body.name); if (!slug) throw AppError.badRequest('Plugin slug is invalid'); const existing = await app.db.query.plugins.findFirst({ where: and(eq(plugins.gameId, body.gameId), eq(plugins.slug, slug)), }); if (existing) throw AppError.conflict('Plugin slug already exists for this game'); const [created] = await app.db .insert(plugins) .values({ gameId: body.gameId, name: body.name, slug, description: body.description ?? null, source: body.source ?? 'manual', isGlobal: true, }) .returning(); return reply.code(201).send(created); }); app.post('/plugins/import', { schema: ImportPluginsSchema }, async (request) => { const body = request.body as { defaultGameId?: string; defaultGameSlug?: string; stopOnError?: boolean; items: Array<{ gameId?: string; gameSlug?: string; plugin: { name: string; slug?: string; description?: string; source?: 'manual' | 'spiget'; isGlobal?: boolean; }; release?: { version: string; channel?: 'stable' | 'beta' | 'alpha'; artifactType?: 'file' | 'zip'; artifactUrl: string; destination?: string; fileName?: string; changelog?: string; installSchema?: unknown[]; configTemplates?: unknown[]; isPublished?: boolean; }; }>; }; const results: Array<{ index: number; success: boolean; gameId?: string; gameSlug?: string; pluginId?: string; pluginSlug?: string; pluginAction?: 'created' | 'updated'; releaseId?: string; releaseVersion?: string; releaseAction?: 'created' | 'updated' | 'skipped'; error?: string; }> = []; for (const [index, item] of body.items.entries()) { try { const game = await resolveImportGame(app, { gameId: item.gameId ?? body.defaultGameId, gameSlug: item.gameSlug ?? body.defaultGameSlug, }); const pluginPayload = item.plugin; const pluginSlug = toSlug(pluginPayload.slug ?? pluginPayload.name); if (!pluginSlug) { throw AppError.badRequest('Plugin slug is invalid'); } const existingPlugin = await app.db.query.plugins.findFirst({ where: and(eq(plugins.gameId, game.id), eq(plugins.slug, pluginSlug)), }); let pluginRecord: typeof plugins.$inferSelect; let pluginAction: 'created' | 'updated'; if (existingPlugin) { const [updatedPlugin] = await app.db .update(plugins) .set({ name: pluginPayload.name, slug: pluginSlug, description: pluginPayload.description !== undefined ? pluginPayload.description : existingPlugin.description, source: pluginPayload.source ?? existingPlugin.source, isGlobal: pluginPayload.isGlobal ?? existingPlugin.isGlobal, updatedAt: new Date(), }) .where(eq(plugins.id, existingPlugin.id)) .returning(); if (!updatedPlugin) { throw AppError.notFound('Plugin not found'); } pluginRecord = updatedPlugin; pluginAction = 'updated'; } else { const [createdPlugin] = await app.db .insert(plugins) .values({ gameId: game.id, name: pluginPayload.name, slug: pluginSlug, description: pluginPayload.description ?? null, source: pluginPayload.source ?? 'manual', isGlobal: pluginPayload.isGlobal ?? true, }) .returning(); if (!createdPlugin) { throw new AppError(500, 'Failed to create plugin'); } pluginRecord = createdPlugin; pluginAction = 'created'; } let releaseAction: 'created' | 'updated' | 'skipped' = 'skipped'; let releaseRecord: typeof pluginReleases.$inferSelect | null = null; if (item.release) { const releasePayload = item.release; const existingRelease = await app.db.query.pluginReleases.findFirst({ where: and( eq(pluginReleases.pluginId, pluginRecord.id), eq(pluginReleases.version, releasePayload.version), ), }); if (existingRelease) { const [updatedRelease] = await app.db .update(pluginReleases) .set({ channel: releasePayload.channel ?? existingRelease.channel, artifactType: releasePayload.artifactType ?? existingRelease.artifactType, artifactUrl: releasePayload.artifactUrl, destination: releasePayload.destination !== undefined ? releasePayload.destination : existingRelease.destination, fileName: releasePayload.fileName !== undefined ? releasePayload.fileName : existingRelease.fileName, changelog: releasePayload.changelog !== undefined ? releasePayload.changelog : existingRelease.changelog, installSchema: releasePayload.installSchema ?? existingRelease.installSchema, configTemplates: releasePayload.configTemplates ?? existingRelease.configTemplates, isPublished: releasePayload.isPublished ?? existingRelease.isPublished, updatedAt: new Date(), }) .where(eq(pluginReleases.id, existingRelease.id)) .returning(); if (!updatedRelease) { throw AppError.notFound('Plugin release not found'); } releaseRecord = updatedRelease; releaseAction = 'updated'; } else { const [createdRelease] = await app.db .insert(pluginReleases) .values({ pluginId: pluginRecord.id, version: releasePayload.version, channel: releasePayload.channel ?? 'stable', artifactType: releasePayload.artifactType ?? 'file', artifactUrl: releasePayload.artifactUrl, destination: releasePayload.destination ?? null, fileName: releasePayload.fileName ?? null, changelog: releasePayload.changelog ?? null, installSchema: releasePayload.installSchema ?? [], configTemplates: releasePayload.configTemplates ?? [], isPublished: releasePayload.isPublished ?? true, createdByUserId: request.user.sub, }) .returning(); if (!createdRelease) { throw new AppError(500, 'Failed to create plugin release'); } releaseRecord = createdRelease; releaseAction = 'created'; } } results.push({ index, success: true, gameId: game.id, gameSlug: game.slug, pluginId: pluginRecord.id, pluginSlug: pluginRecord.slug, pluginAction, releaseId: releaseRecord?.id, releaseVersion: releaseRecord?.version, releaseAction, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (body.stopOnError) { throw AppError.badRequest(`Import failed at item ${index}: ${message}`); } results.push({ index, success: false, error: message, }); } } const succeeded = results.filter((result) => result.success).length; const failed = results.length - succeeded; return { results, summary: { total: results.length, succeeded, failed, }, }; }); app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => { const { pluginId } = request.params as { pluginId: string }; const body = request.body as { name?: string; slug?: string; description?: string; source?: 'manual' | 'spiget'; isGlobal?: boolean; }; const existing = await app.db.query.plugins.findFirst({ where: eq(plugins.id, pluginId), }); if (!existing) throw AppError.notFound('Plugin not found'); const nextSlug = body.slug !== undefined ? toSlug(body.slug) : (body.name !== undefined ? toSlug(body.name) : existing.slug); if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid'); const duplicate = await app.db.query.plugins.findFirst({ where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)), }); if (duplicate && duplicate.id !== existing.id) { throw AppError.conflict('Plugin slug already exists for this game'); } const [updated] = await app.db .update(plugins) .set({ name: body.name ?? existing.name, slug: nextSlug, description: body.description ?? existing.description, source: body.source ?? existing.source, isGlobal: body.isGlobal ?? existing.isGlobal, updatedAt: new Date(), }) .where(eq(plugins.id, existing.id)) .returning(); if (!updated) throw AppError.notFound('Plugin not found'); return updated; }); app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => { const { pluginId } = request.params as { pluginId: string }; const plugin = await app.db.query.plugins.findFirst({ where: eq(plugins.id, pluginId), }); if (!plugin) throw AppError.notFound('Plugin not found'); const releases = await app.db .select() .from(pluginReleases) .where(eq(pluginReleases.pluginId, pluginId)) .orderBy(desc(pluginReleases.createdAt)); return { plugin, releases }; }); app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => { const { pluginId } = request.params as { pluginId: string }; const plugin = await app.db.query.plugins.findFirst({ where: eq(plugins.id, pluginId), }); if (!plugin) throw AppError.notFound('Plugin not found'); if (!request.isMultipart()) { throw AppError.badRequest('Content-Type must be multipart/form-data'); } const fields: Record = {}; const files: UploadArtifactFile[] = []; let installSchemaFile: UploadJsonFile | null = null; let configTemplatesFile: UploadJsonFile | null = null; const relativePathQueue: string[] = []; for await (const part of request.parts()) { if (part.type === 'file') { if (part.fieldname === 'installSchemaFile') { const data = await part.toBuffer(); if (data.length > 0) { installSchemaFile = { filename: part.filename || 'install-schema.json', data, }; } continue; } if (part.fieldname === 'configTemplatesFile') { const data = await part.toBuffer(); if (data.length > 0) { configTemplatesFile = { filename: part.filename || 'config-templates.json', data, }; } continue; } const fallbackName = `artifact-${files.length + 1}.bin`; const queuedPath = relativePathQueue.shift(); const relativePath = normalizeRelativePath( queuedPath ?? part.filename ?? '', fallbackName, ); const data = await part.toBuffer(); if (data.length === 0) continue; files.push({ relativePath, data }); } else { if (part.fieldname === 'relativePath') { const raw = typeof part.value === 'string' ? part.value : ''; relativePathQueue.push(raw); continue; } fields[part.fieldname] = part.value; } } if (files.length === 0) { throw AppError.badRequest('At least one file is required'); } const version = typeof fields.version === 'string' ? fields.version.trim() : ''; if (!version) { throw AppError.badRequest('version is required'); } const channel = parseReleaseChannel(fields.channel); const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0 ? fields.destination.trim() : null; const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0 ? fields.changelog : null; const isPublished = parseOptionalBoolean(fields.isPublished) ?? true; const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema'); const configTemplates = parseJsonArrayInput( fields.configTemplates, configTemplatesFile, 'configTemplates', ); const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : ''; const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/')); const shouldZip = files.length > 1 || hasNestedPaths; let artifactType: 'file' | 'zip'; let artifactContent: Buffer; let uploadFileName: string; let releaseFileName: string | null; if (shouldZip) { artifactType = 'zip'; artifactContent = await zipArtifacts(files); const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`; uploadFileName = suggestedName.toLowerCase().endsWith('.zip') ? suggestedName : `${suggestedName}.zip`; releaseFileName = null; } else { artifactType = 'file'; const [singleFile] = files; if (!singleFile) { throw AppError.badRequest('No artifact file received'); } artifactContent = singleFile.data; const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin'; uploadFileName = rawFileName || originalName; releaseFileName = uploadFileName; } const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, { pluginId: plugin.id, pluginSlug: plugin.slug, releaseVersion: version, uploadedBy: request.user.sub, uploadMode: shouldZip ? 'archive' : 'single', sourceFileCount: files.length, }); const [created] = await app.db .insert(pluginReleases) .values({ pluginId: plugin.id, version, channel, artifactType, artifactUrl: uploaded.artifactPointer, destination, fileName: releaseFileName, changelog, installSchema, configTemplates, isPublished, createdByUserId: request.user.sub, }) .returning(); return reply.code(201).send({ release: created, artifact: { bucket: uploaded.bucket, fileId: uploaded.file.id, storedName: uploaded.file.storedName, originalName: uploaded.file.originalName, pointer: uploaded.artifactPointer, }, }); }); app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => { const { pluginId } = request.params as { pluginId: string }; const body = request.body as { version: string; channel?: 'stable' | 'beta' | 'alpha'; artifactType?: 'file' | 'zip'; artifactUrl: string; destination?: string; fileName?: string; changelog?: string; installSchema?: unknown[]; configTemplates?: unknown[]; isPublished?: boolean; cloneFromReleaseId?: string; }; const plugin = await app.db.query.plugins.findFirst({ where: eq(plugins.id, pluginId), }); if (!plugin) throw AppError.notFound('Plugin not found'); let baseRelease: typeof pluginReleases.$inferSelect | null = null; if (body.cloneFromReleaseId) { baseRelease = await app.db.query.pluginReleases.findFirst({ where: and( eq(pluginReleases.id, body.cloneFromReleaseId), eq(pluginReleases.pluginId, pluginId), ), }) ?? null; if (!baseRelease) { throw AppError.notFound('Clone source release not found'); } } const [created] = await app.db .insert(pluginReleases) .values({ pluginId, version: body.version, channel: body.channel ?? baseRelease?.channel ?? 'stable', artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file', artifactUrl: body.artifactUrl, destination: body.destination ?? baseRelease?.destination ?? null, fileName: body.fileName ?? baseRelease?.fileName ?? null, changelog: body.changelog ?? baseRelease?.changelog ?? null, installSchema: body.installSchema ?? baseRelease?.installSchema ?? [], configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [], isPublished: body.isPublished ?? baseRelease?.isPublished ?? true, createdByUserId: request.user.sub, }) .returning(); return reply.code(201).send(created); }); app.patch( '/plugins/:pluginId/releases/:releaseId', { schema: { ...PluginReleaseIdParamSchema, ...UpdatePluginReleaseSchema } }, async (request) => { const { pluginId, releaseId } = request.params as { pluginId: string; releaseId: string }; const body = request.body as { version?: string; channel?: 'stable' | 'beta' | 'alpha'; artifactType?: 'file' | 'zip'; artifactUrl?: string; destination?: string; fileName?: string; changelog?: string; installSchema?: unknown[]; configTemplates?: unknown[]; isPublished?: boolean; }; const release = await app.db.query.pluginReleases.findFirst({ where: and(eq(pluginReleases.id, releaseId), eq(pluginReleases.pluginId, pluginId)), }); if (!release) throw AppError.notFound('Plugin release not found'); const [updated] = await app.db .update(pluginReleases) .set({ version: body.version ?? release.version, channel: body.channel ?? release.channel, artifactType: body.artifactType ?? release.artifactType, artifactUrl: body.artifactUrl ?? release.artifactUrl, destination: body.destination ?? release.destination, fileName: body.fileName ?? release.fileName, changelog: body.changelog ?? release.changelog, installSchema: body.installSchema ?? release.installSchema, configTemplates: body.configTemplates ?? release.configTemplates, isPublished: body.isPublished ?? release.isPublished, updatedAt: new Date(), }) .where(eq(pluginReleases.id, release.id)) .returning(); if (!updated) throw AppError.notFound('Plugin release not found'); return updated; }, ); // GET /api/admin/nodes app.get('/nodes', async () => { const nodeList = await app.db .select() .from(nodes) .orderBy(nodes.createdAt); return { data: nodeList }; }); // === Audit Logs === // GET /api/admin/audit-logs app.get('/audit-logs', { schema: { querystring: PaginationQuerySchema } }, async (request) => { const { page, perPage, offset, limit } = paginate(request.query as any); const [totalResult] = await app.db.select({ count: count() }).from(auditLogs); const logs = await app.db .select({ id: auditLogs.id, organizationId: auditLogs.organizationId, userId: auditLogs.userId, serverId: auditLogs.serverId, action: auditLogs.action, metadata: auditLogs.metadata, ipAddress: auditLogs.ipAddress, createdAt: auditLogs.createdAt, userEmail: users.email, userName: users.username, }) .from(auditLogs) .innerJoin(users, eq(auditLogs.userId, users.id)) .orderBy(desc(auditLogs.createdAt)) .limit(limit) .offset(offset); return paginatedResponse(logs, totalResult!.count, page, perPage); }); }