import { BadRequestException, ConflictException, Inject, Injectable, } from '@nestjs/common'; import { Queue } from 'bullmq'; import type { AddonListResponse, InstallAddonRequest, InstallAddonResponse, InstalledAddon, } from '@hexahost/contracts'; import type { InstalledAddon as InstalledAddonRecord } from '@hexahost/database'; import { getModrinthProject, getModrinthVersion, isVersionCompatible, pickPrimaryFile, resolveInstallDirectory, resolveModrinthLoader, resolveProjectType, } from '@hexahost/catalog'; import { AuditService } from '../../auth/audit.service'; import { PrismaService } from '../../prisma/prisma.service'; import { findOwnedServer } from '../shared/server-ownership.util'; import { ServerStateService } from '../server-state.service'; export const SERVER_ADDONS_QUEUE = Symbol('SERVER_ADDONS_QUEUE'); const STOPPED_ONLY = new Set(['STOPPED']); @Injectable() export class AddonsService { constructor( private readonly prisma: PrismaService, private readonly serverState: ServerStateService, private readonly auditService: AuditService, @Inject(SERVER_ADDONS_QUEUE) private readonly addonsQueue: Queue, ) {} async listAddons(userId: string, serverId: string): Promise { await findOwnedServer(this.prisma, userId, serverId); const addons = await this.prisma.installedAddon.findMany({ where: { serverId, status: { not: 'REMOVED' }, }, orderBy: { createdAt: 'desc' }, }); return { addons: addons.map((addon) => this.toResponse(addon)) }; } async installAddon( userId: string, serverId: string, input: InstallAddonRequest, ): Promise { const server = await findOwnedServer(this.prisma, userId, serverId); this.serverState.assertNotInProgress(server.status); if (!server.nodeId) { throw new BadRequestException('Server is not assigned to a node'); } if (!STOPPED_ONLY.has(server.status)) { throw new BadRequestException( 'Addons can only be installed while the server is STOPPED', ); } const projectType = resolveProjectType(server.softwareFamily); const loader = resolveModrinthLoader(server.softwareFamily); if (!projectType || !loader) { throw new BadRequestException( 'This server software does not support add-on installation', ); } const version = await getModrinthVersion(input.versionId); if (version.project_id !== input.projectId) { throw new BadRequestException('Version does not belong to the selected project'); } if (!isVersionCompatible(version, server.minecraftVersion, loader)) { throw new BadRequestException( 'This version is not compatible with your server version and loader', ); } const file = pickPrimaryFile(version); if (!file) { throw new BadRequestException('No installable file found for this version'); } const project = await getModrinthProject(input.projectId); const projectName = project.title; const projectSlug = project.slug; const installDir = resolveInstallDirectory(projectType); const filePath = `${installDir}/${file.filename}`; const existing = await this.prisma.installedAddon.findFirst({ where: { serverId, projectId: input.projectId, status: { in: ['PENDING', 'INSTALLING', 'INSTALLED'] }, }, }); if (existing) { throw new ConflictException('This project is already installed on the server'); } const addonType = projectType === 'plugin' ? 'PLUGIN' : projectType === 'mod' ? 'MOD' : projectType === 'modpack' ? 'MODPACK' : 'DATAPACK'; const addon = await this.prisma.installedAddon.create({ data: { serverId, projectId: input.projectId, projectSlug, projectName, versionId: input.versionId, versionNumber: version.version_number, addonType, fileName: file.filename, filePath, fileSha512: file.hashes['sha512'] ?? null, status: 'PENDING', }, }); const job = await this.addonsQueue.add('install-addon', { addonId: addon.id, serverId, }); await this.auditService.record({ action: 'server.addon.install', userId, entityType: 'game_server', entityId: serverId, metadata: { addonId: addon.id, projectId: input.projectId, versionId: input.versionId, }, }); return { addon: this.toResponse(addon), jobId: job.id?.toString(), }; } async removeAddon( userId: string, serverId: string, addonId: string, ): Promise { const server = await findOwnedServer(this.prisma, userId, serverId); this.serverState.assertNotInProgress(server.status); if (!STOPPED_ONLY.has(server.status)) { throw new BadRequestException( 'Addons can only be removed while the server is STOPPED', ); } const addon = await this.prisma.installedAddon.findFirstOrThrow({ where: { id: addonId, serverId }, }); await this.prisma.installedAddon.update({ where: { id: addonId }, data: { status: 'REMOVING' }, }); const job = await this.addonsQueue.add('remove-addon', { addonId, serverId, }); await this.auditService.record({ action: 'server.addon.remove', userId, entityType: 'game_server', entityId: serverId, metadata: { addonId }, }); return { addon: this.toResponse({ ...addon, status: 'REMOVING' }), jobId: job.id?.toString(), }; } private toResponse(addon: InstalledAddonRecord): InstalledAddon { return { id: addon.id, serverId: addon.serverId, provider: addon.provider, projectId: addon.projectId, projectSlug: addon.projectSlug, projectName: addon.projectName, versionId: addon.versionId, versionNumber: addon.versionNumber, addonType: addon.addonType as InstalledAddon['addonType'], fileName: addon.fileName, filePath: addon.filePath, status: addon.status as InstalledAddon['status'], failureReason: addon.failureReason, installedAt: addon.installedAt?.toISOString() ?? null, createdAt: addon.createdAt.toISOString(), }; } }