import type { FastifyInstance } from 'fastify'; import { eq, and } from 'drizzle-orm'; import { Type } from '@sinclair/typebox'; import { servers, plugins, serverPlugins, games } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { createAuditLog } from '../../lib/audit.js'; import { searchSpigetPlugins, getSpigetResource, getSpigetDownloadUrl, } from '../../lib/spiget.js'; const ParamSchema = { params: Type.Object({ orgId: Type.String({ format: 'uuid' }), serverId: Type.String({ format: 'uuid' }), }), }; export default async function pluginRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); // GET /plugins — list installed plugins for this server app.get('/', { schema: ParamSchema }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'plugin.read'); const server = await app.db.query.servers.findFirst({ where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), }); if (!server) throw AppError.notFound('Server not found'); const installed = await app.db .select({ id: serverPlugins.id, pluginId: serverPlugins.pluginId, installedVersion: serverPlugins.installedVersion, isActive: serverPlugins.isActive, installedAt: serverPlugins.installedAt, name: plugins.name, slug: plugins.slug, description: plugins.description, source: plugins.source, externalId: plugins.externalId, }) .from(serverPlugins) .innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id)) .where(eq(serverPlugins.serverId, serverId)); return { plugins: installed }; }); // GET /plugins/search — search Spiget for Minecraft plugins app.get( '/search', { schema: { ...ParamSchema, querystring: Type.Object({ q: Type.String({ minLength: 2 }), page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { q, page } = request.query as { q: string; page?: number }; await requirePermission(request, orgId, 'plugin.manage'); // Verify server exists and is Minecraft const server = await app.db.query.servers.findFirst({ where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), }); if (!server) throw AppError.notFound('Server not found'); const game = await app.db.query.games.findFirst({ where: eq(games.id, server.gameId), }); if (!game) throw AppError.notFound('Game not found'); if (game.slug !== 'minecraft-java') { throw AppError.badRequest('Spiget search is only available for Minecraft: Java Edition'); } const results = await searchSpigetPlugins(q, page ?? 1); return { results: results.map((r) => ({ id: r.id, name: r.name, tag: r.tag, downloads: r.downloads, rating: r.rating, updateDate: r.updateDate, external: r.external, })), }; }, ); // POST /plugins/install/spiget — install a plugin from Spiget app.post( '/install/spiget', { schema: { ...ParamSchema, body: Type.Object({ resourceId: Type.Number(), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { resourceId } = request.body as { resourceId: number }; await requirePermission(request, orgId, 'plugin.manage'); const server = await app.db.query.servers.findFirst({ where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), }); if (!server) throw AppError.notFound('Server not found'); const game = await app.db.query.games.findFirst({ where: eq(games.id, server.gameId), }); if (!game) throw AppError.notFound('Game not found'); // Fetch resource info from Spiget const resource = await getSpigetResource(resourceId); if (!resource) throw AppError.notFound('Spiget resource not found'); // Create or find plugin entry let plugin = await app.db.query.plugins.findFirst({ where: and( eq(plugins.gameId, game.id), eq(plugins.externalId, String(resourceId)), eq(plugins.source, 'spiget'), ), }); if (!plugin) { const [created] = await app.db .insert(plugins) .values({ gameId: game.id, name: resource.name, slug: resource.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .slice(0, 200), description: resource.tag || null, source: 'spiget', externalId: String(resourceId), downloadUrl: getSpigetDownloadUrl(resourceId), version: null, }) .returning(); plugin = created!; } // Check if already installed const existing = await app.db.query.serverPlugins.findFirst({ where: and( eq(serverPlugins.serverId, serverId), eq(serverPlugins.pluginId, plugin.id), ), }); if (existing) throw AppError.conflict('Plugin is already installed'); // Install const [installed] = await app.db .insert(serverPlugins) .values({ serverId, pluginId: plugin.id, installedVersion: resource.version ? String(resource.version.id) : null, isActive: true, }) .returning(); // TODO: Send gRPC command to daemon to download the plugin file to /data/plugins/ // downloadUrl: getSpigetDownloadUrl(resourceId) await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'plugin.install', metadata: { name: resource.name, source: 'spiget', resourceId }, }); return installed; }, ); // POST /plugins/install/manual — install a plugin manually (upload) app.post( '/install/manual', { schema: { ...ParamSchema, body: Type.Object({ name: Type.String({ minLength: 1 }), fileName: Type.String({ minLength: 1 }), version: Type.Optional(Type.String()), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { name, fileName, version } = request.body as { name: string; fileName: string; version?: string; }; await requirePermission(request, orgId, 'plugin.manage'); const server = await app.db.query.servers.findFirst({ where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), }); if (!server) throw AppError.notFound('Server not found'); const [plugin] = await app.db .insert(plugins) .values({ gameId: server.gameId, name, slug: name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .slice(0, 200), source: 'manual', version: version ?? null, }) .returning(); const [installed] = await app.db .insert(serverPlugins) .values({ serverId, pluginId: plugin!.id, installedVersion: version ?? null, isActive: true, }) .returning(); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'plugin.install', metadata: { name, source: 'manual', fileName }, }); return installed; }, ); // DELETE /plugins/:pluginInstallId — uninstall a plugin app.delete( '/:pluginInstallId', { schema: { params: Type.Object({ orgId: Type.String({ format: 'uuid' }), serverId: Type.String({ format: 'uuid' }), pluginInstallId: Type.String({ format: 'uuid' }), }), }, }, async (request, reply) => { const { orgId, serverId, pluginInstallId } = request.params as { orgId: string; serverId: string; pluginInstallId: string; }; await requirePermission(request, orgId, 'plugin.manage'); const installed = await app.db.query.serverPlugins.findFirst({ where: and( eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, serverId), ), }); if (!installed) throw AppError.notFound('Plugin installation not found'); await app.db.delete(serverPlugins).where(eq(serverPlugins.id, pluginInstallId)); // TODO: Send gRPC to daemon to delete the plugin file from /data/plugins/ await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'plugin.uninstall', metadata: { pluginInstallId }, }); return reply.code(204).send(); }, ); // PATCH /plugins/:pluginInstallId/toggle — enable/disable a plugin app.patch( '/:pluginInstallId/toggle', { schema: { params: Type.Object({ orgId: Type.String({ format: 'uuid' }), serverId: Type.String({ format: 'uuid' }), pluginInstallId: Type.String({ format: 'uuid' }), }), }, }, async (request) => { const { orgId, serverId, pluginInstallId } = request.params as { orgId: string; serverId: string; pluginInstallId: string; }; await requirePermission(request, orgId, 'plugin.manage'); const installed = await app.db.query.serverPlugins.findFirst({ where: and( eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, serverId), ), }); if (!installed) throw AppError.notFound('Plugin installation not found'); const [updated] = await app.db .update(serverPlugins) .set({ isActive: !installed.isActive }) .where(eq(serverPlugins.id, pluginInstallId)) .returning(); return updated; }, ); }