230 lines
6.3 KiB
TypeScript
230 lines
6.3 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|