Phase5
This commit is contained in:
229
apps/api/src/servers/addons/addons.service.ts
Normal file
229
apps/api/src/servers/addons/addons.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user