Phase5
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 13:00:01 +02:00
parent 4262464cd5
commit d29a02f2a4
93 changed files with 1875 additions and 55 deletions

View File

@@ -0,0 +1,229 @@
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<AddonListResponse> {
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<InstallAddonResponse> {
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<InstallAddonResponse> {
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(),
};
}
}