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

@@ -9,13 +9,14 @@
"start:prod": "node dist/main.js",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"test": "tsc && node --test test/**/*.test.js"
"test": "tsc -p tsconfig.build.json && node --test test/**/*.test.js"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.4",
"@fastify/websocket": "^11.0.2",
"@hexahost/auth": "workspace:*",
"@hexahost/catalog": "workspace:*",
"@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*",

View File

@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { CatalogModule } from './catalog/catalog.module';
import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module';
import { ServersModule } from './servers/servers.module';
@@ -40,6 +41,7 @@ import { LoggerModule } from 'nestjs-pino';
AppConfigModule,
HealthModule,
AuthModule,
CatalogModule,
ServersModule,
NodesModule,
AdminModule,

View File

@@ -0,0 +1,67 @@
import { BadRequestException, Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { catalogSearchQuerySchema } from '@hexahost/contracts';
import { SessionGuard } from '../auth/guards/session.guard';
import { PrismaService } from '../prisma/prisma.service';
import { CatalogService } from './catalog.service';
@ApiTags('catalog')
@Controller('catalog')
@UseGuards(SessionGuard)
export class CatalogController {
constructor(
private readonly catalogService: CatalogService,
private readonly prisma: PrismaService,
) {}
@Get('projects')
@ApiOperation({ summary: 'Search Modrinth projects' })
search(@Query() query: Record<string, string | undefined>) {
const input = catalogSearchQuerySchema.parse({
q: query['q'],
gameVersion: query['gameVersion'],
softwareFamily: query['softwareFamily'],
limit: query['limit'],
});
return this.catalogService.searchProjects({
query: input.q,
gameVersion: input.gameVersion,
softwareFamily: input.softwareFamily,
limit: input.limit,
});
}
@Get('projects/:projectId/versions')
@ApiOperation({ summary: 'List Modrinth project versions' })
async versions(
@Param('projectId') projectId: string,
@Query('serverId') serverId?: string,
@Query('gameVersion') gameVersion?: string,
@Query('softwareFamily') softwareFamily?: string,
) {
let resolvedGameVersion = gameVersion;
let resolvedSoftwareFamily = softwareFamily;
if (serverId) {
const server = await this.prisma.gameServer.findUniqueOrThrow({
where: { id: serverId },
});
resolvedGameVersion = server.minecraftVersion;
resolvedSoftwareFamily = server.softwareFamily;
}
if (!resolvedGameVersion || !resolvedSoftwareFamily) {
throw new BadRequestException('gameVersion and softwareFamily are required');
}
return this.catalogService.listProjectVersions(
projectId,
resolvedGameVersion,
resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT',
);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,81 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type {
CatalogSearchResponse,
CatalogVersionsResponse,
} from '@hexahost/contracts';
import {
getModrinthProjectVersions,
isVersionCompatible,
resolveModrinthLoader,
resolveProjectType,
searchModrinthProjects,
type SoftwareFamily,
} from '@hexahost/catalog';
@Injectable()
export class CatalogService {
async searchProjects(input: {
query: string;
gameVersion: string;
softwareFamily: SoftwareFamily;
limit?: number;
}): Promise<CatalogSearchResponse> {
const projectType = resolveProjectType(input.softwareFamily);
const loader = resolveModrinthLoader(input.softwareFamily);
if (!projectType || !loader) {
throw new BadRequestException(
'This server software does not support catalog add-ons',
);
}
const result = await searchModrinthProjects({
query: input.query,
projectType,
gameVersion: input.gameVersion,
loader,
limit: input.limit,
});
return {
projects: result.hits.map((project) => ({
id: project.project_id,
slug: project.slug,
name: project.title,
description: project.description,
projectType: project.project_type,
downloads: project.downloads,
iconUrl: project.icon_url ?? null,
})),
};
}
async listProjectVersions(
projectId: string,
gameVersion: string,
softwareFamily: SoftwareFamily,
): Promise<CatalogVersionsResponse> {
const loader = resolveModrinthLoader(softwareFamily);
if (!loader) {
throw new BadRequestException(
'This server software does not support catalog add-ons',
);
}
const versions = await getModrinthProjectVersions(projectId);
return {
versions: versions.map((version) => ({
id: version.id,
projectId: version.project_id,
name: version.name,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
compatible: isVersionCompatible(version, gameVersion, loader),
publishedAt: version.date_published,
})),
};
}
}

View File

@@ -327,7 +327,9 @@ export class NodesService {
normalized === 'world.archive' ||
normalized === 'world.replace' ||
normalized === 'storage.upload' ||
normalized === 'storage.download'
normalized === 'storage.download' ||
normalized === 'addon.install' ||
normalized === 'addon.remove'
);
}

View File

@@ -0,0 +1,56 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { AddonListResponse, InstallAddonResponse } from '@hexahost/contracts';
import { installAddonRequestSchema } from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { AddonsService } from './addons.service';
@ApiTags('servers')
@Controller('servers/:serverId/addons')
@UseGuards(SessionGuard)
export class AddonsController {
constructor(private readonly addonsService: AddonsService) {}
@Get()
@ApiOperation({ summary: 'List installed add-ons' })
list(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
): Promise<AddonListResponse> {
return this.addonsService.listAddons(user.id, serverId);
}
@Post()
@ApiOperation({ summary: 'Install an add-on from the catalog' })
install(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body() body: unknown,
) {
const input = installAddonRequestSchema.parse(body);
return this.addonsService.installAddon(user.id, serverId, input);
}
@Delete(':addonId')
@ApiOperation({ summary: 'Remove an installed add-on' })
remove(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('addonId', ParseUUIDPipe) addonId: string,
): Promise<InstallAddonResponse> {
return this.addonsService.removeAddon(user.id, serverId, addonId);
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { Queue } from 'bullmq';
import { getConfig } from '@hexahost/config';
import { AuthModule } from '../../auth/auth.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { ServerStateService } from '../server-state.service';
import { AddonsController } from './addons.controller';
import { AddonsService, SERVER_ADDONS_QUEUE } from './addons.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [AddonsController],
providers: [
AddonsService,
ServerStateService,
{
provide: SERVER_ADDONS_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('server-addons', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
],
exports: [AddonsService],
})
export class AddonsModule {}

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(),
};
}
}

View File

@@ -9,6 +9,7 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.module';
import { ConsoleModule } from './console/console.module';
import { AddonsModule } from './addons/addons.module';
import { BackupsModule } from './backups/backups.module';
import { FilesModule } from './files/files.module';
import { WorldsModule } from './worlds/worlds.module';
@@ -30,6 +31,7 @@ import { PlansService } from './plans.service';
AppConfigModule,
AuthModule,
forwardRef(() => ConsoleModule),
forwardRef(() => AddonsModule),
forwardRef(() => BackupsModule),
forwardRef(() => WorldsModule),
forwardRef(() => FilesModule),

View File

@@ -0,0 +1,42 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('phase 5 api contract', () => {
it('documents catalog and add-ons routes', () => {
const files = [
'catalog/catalog.controller.ts',
'servers/addons/addons.controller.ts',
'servers/addons/addons.service.ts',
];
for (const file of files) {
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
assert.ok(source.length > 0, `${file} should exist`);
}
const catalogSource = readFileSync(
join(process.cwd(), 'src', 'catalog/catalog.controller.ts'),
'utf8',
);
assert.ok(catalogSource.includes("@Get('projects')"));
assert.ok(catalogSource.includes('projects/:projectId/versions'));
const addonsSource = readFileSync(
join(process.cwd(), 'src', 'servers/addons/addons.controller.ts'),
'utf8',
);
assert.ok(addonsSource.includes('servers/:serverId/addons'));
assert.ok(addonsSource.includes('@Post()'));
assert.ok(addonsSource.includes("@Delete(':addonId')"));
const serviceSource = readFileSync(
join(process.cwd(), 'src', 'servers/addons/addons.service.ts'),
'utf8',
);
assert.ok(serviceSource.includes('installAddon'));
assert.ok(serviceSource.includes('removeAddon'));
assert.ok(serviceSource.includes('AddonListResponse'));
});
});

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./"
"baseUrl": "./",
"incremental": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]

File diff suppressed because one or more lines are too long

View File

@@ -189,6 +189,10 @@ func mapSoftwareFamily(family string) string {
switch strings.ToUpper(strings.TrimSpace(family)) {
case "PAPER":
return "PAPER"
case "FABRIC":
return "FABRIC"
case "PURPUR":
return "PURPUR"
default:
return "VANILLA"
}

View File

@@ -229,6 +229,30 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
return nil
}
// DeleteFile removes a file relative to dataRoot.
func DeleteFile(dataRoot, relativePath string) error {
filePath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return err
}
if err := rejectSymlinks(dataRoot, filePath); err != nil {
return err
}
info, err := os.Lstat(filePath)
if err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return err
}
if info.IsDir() {
return ErrIsDirectory
}
return os.Remove(filePath)
}
func rejectSymlinks(dataRoot, target string) error {
root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil {

View File

@@ -44,6 +44,8 @@ const (
TypeServerWorldReplace MessageType = "server.world.replace"
TypeServerStorageUpload MessageType = "server.storage.upload"
TypeServerStorageDownload MessageType = "server.storage.download"
TypeServerAddonInstall MessageType = "server.addon.install"
TypeServerAddonRemove MessageType = "server.addon.remove"
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
)
@@ -80,6 +82,8 @@ var controlToAgentTypes = map[MessageType]struct{}{
TypeServerWorldReplace: {},
TypeServerStorageUpload: {},
TypeServerStorageDownload: {},
TypeServerAddonInstall: {},
TypeServerAddonRemove: {},
TypeServerMetricsSub: {},
TypeServerLogsSubscribe: {},
}
@@ -220,6 +224,20 @@ type ServerWorldReplacePayload struct {
ArchivePath string `json:"archivePath"`
}
// ServerAddonInstallPayload downloads and installs an addon file.
type ServerAddonInstallPayload struct {
OperationMeta
DownloadURL string `json:"downloadUrl"`
RelativePath string `json:"relativePath"`
Sha512 string `json:"sha512,omitempty"`
}
// ServerAddonRemovePayload removes an installed addon file.
type ServerAddonRemovePayload struct {
OperationMeta
RelativePath string `json:"relativePath"`
}
// FileEntry describes a file or directory in a list response.
type FileEntry struct {
Name string `json:"name"`

View File

@@ -570,6 +570,66 @@ func (m *Manager) HandleWorldReplace(ctx context.Context, correlationID string,
})
}
// HandleAddonInstall downloads and installs an addon JAR.
func (m *Manager) HandleAddonInstall(ctx context.Context, correlationID string, payload protocol.ServerAddonInstallPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State == ServerStateRunning {
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_RUNNING", "server must be stopped before installing addons")
return
}
localPath, err := files.ResolvePath(rec.DataPath, payload.RelativePath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "addon.install", filesErrorCode(err), err.Error())
return
}
if err := transfer.DownloadFile(payload.DownloadURL, localPath); err != nil {
m.failOperation(ctx, correlationID, meta, "addon.install", "DOWNLOAD_FAILED", err.Error())
return
}
if err := transfer.VerifySha512File(localPath, payload.Sha512); err != nil {
_ = os.Remove(localPath)
m.failOperation(ctx, correlationID, meta, "addon.install", "HASH_MISMATCH", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "addon.install", map[string]string{
"path": payload.RelativePath,
})
}
// HandleAddonRemove deletes an installed addon file.
func (m *Manager) HandleAddonRemove(ctx context.Context, correlationID string, payload protocol.ServerAddonRemovePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State == ServerStateRunning {
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_RUNNING", "server must be stopped before removing addons")
return
}
if err := files.DeleteFile(rec.DataPath, payload.RelativePath); err != nil {
m.failOperation(ctx, correlationID, meta, "addon.remove", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "addon.remove", map[string]string{
"path": payload.RelativePath,
})
}
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
m.mu.RLock()
defer m.mu.RUnlock()

View File

@@ -0,0 +1,34 @@
package transfer
import (
"crypto/sha512"
"encoding/hex"
"fmt"
"io"
"os"
)
// VerifySha512File checks that a file matches the expected sha512 hash.
func VerifySha512File(path, expected string) error {
if expected == "" {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
hasher := sha512.New()
if _, err := io.Copy(hasher, file); err != nil {
return err
}
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expected {
return fmt.Errorf("sha512 mismatch")
}
return nil
}

View File

@@ -370,6 +370,22 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
}
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
case protocol.TypeServerAddonInstall:
var payload protocol.ServerAddonInstallPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.addon.install", "error", err)
return
}
c.runtime.HandleAddonInstall(ctx, env.MessageID, payload)
case protocol.TypeServerAddonRemove:
var payload protocol.ServerAddonRemovePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.addon.remove", "error", err)
return
}
c.runtime.HandleAddonRemove(ctx, env.MessageID, payload)
default:
c.log.Debug("ignored control message", "type", env.Type)
}

View File

@@ -192,7 +192,8 @@
"properties": "Eigenschaften",
"players": "Spieler",
"worlds": "Welten",
"backups": "Backups"
"backups": "Backups",
"addons": "Add-ons"
},
"console": {
"loading": "Wird geladen…",
@@ -317,6 +318,23 @@
"created": "Erstellt",
"actions": "Aktionen",
"empty": "Noch keine Backups vorhanden."
},
"addons": {
"loading": "Wird geladen…",
"unsupported": "Add-ons werden für diese Server-Software noch nicht unterstützt. Verwenden Sie Paper oder Fabric.",
"stoppedHint": "Add-ons können nur installiert oder entfernt werden, wenn der Server gestoppt ist.",
"searchPlaceholder": "Modrinth durchsuchen…",
"search": "Suchen",
"searchError": "Katalogsuche fehlgeschlagen.",
"versionsError": "Versionen konnten nicht geladen werden.",
"installError": "Installation fehlgeschlagen.",
"removeError": "Entfernen fehlgeschlagen.",
"select": "Versionen",
"compatibleVersions": "Kompatible Versionen für {name}",
"install": "Installieren",
"remove": "Entfernen",
"installed": "Installiert",
"empty": "Noch keine Add-ons installiert."
}
}
}

View File

@@ -192,7 +192,8 @@
"properties": "Properties",
"players": "Players",
"worlds": "Worlds",
"backups": "Backups"
"backups": "Backups",
"addons": "Add-ons"
},
"console": {
"loading": "Loading…",
@@ -317,6 +318,23 @@
"created": "Created",
"actions": "Actions",
"empty": "No backups yet."
},
"addons": {
"loading": "Loading…",
"unsupported": "Add-ons are not supported for this server software yet. Use Paper or Fabric.",
"stoppedHint": "Add-ons can only be installed or removed while the server is stopped.",
"searchPlaceholder": "Search Modrinth…",
"search": "Search",
"searchError": "Catalog search failed.",
"versionsError": "Could not load versions.",
"installError": "Installation failed.",
"removeError": "Remove failed.",
"select": "Versions",
"compatibleVersions": "Compatible versions for {name}",
"install": "Install",
"remove": "Remove",
"installed": "Installed",
"empty": "No add-ons installed yet."
}
}
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerAddons } from "@/components/servers/server-addons";
interface ServerAddonsPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerAddonsPage({ params }: ServerAddonsPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerAddons serverId={id} />;
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Button } from "@hexahost/ui";
import {
installAddon,
listCatalogVersions,
listInstalledAddons,
removeAddon,
searchCatalog,
type CatalogProject,
type CatalogVersion,
} from "@/lib/api/addons";
import { getServer } from "@/lib/api/servers";
interface ServerAddonsProps {
serverId: string;
}
export function ServerAddons({ serverId }: ServerAddonsProps) {
const t = useTranslations("servers.addons");
const [query, setQuery] = useState("");
const [results, setResults] = useState<CatalogProject[]>([]);
const [selectedProject, setSelectedProject] = useState<CatalogProject | null>(null);
const [versions, setVersions] = useState<CatalogVersion[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: server } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const refreshInstalled = useCallback(async () => {
const data = await listInstalledAddons(serverId);
return data.addons;
}, [serverId]);
const [installed, setInstalled] = useState<Awaited<ReturnType<typeof refreshInstalled>>>([]);
useEffect(() => {
void refreshInstalled().then(setInstalled).catch(() => undefined);
}, [refreshInstalled]);
const supportsAddons =
server &&
["PAPER", "PURPUR", "FABRIC", "FORGE", "NEOFORGE", "QUILT"].includes(
server.softwareFamily,
);
async function handleSearch(event: React.FormEvent) {
event.preventDefault();
if (!server || !supportsAddons || !query.trim()) {
return;
}
setBusy(true);
setError(null);
try {
const response = await searchCatalog({
q: query.trim(),
gameVersion: server.minecraftVersion,
softwareFamily: server.softwareFamily,
});
setResults(response.projects);
setSelectedProject(null);
setVersions([]);
} catch (err) {
setError(err instanceof Error ? err.message : t("searchError"));
} finally {
setBusy(false);
}
}
async function handleSelectProject(project: CatalogProject) {
setSelectedProject(project);
setBusy(true);
setError(null);
try {
const response = await listCatalogVersions(project.id, serverId);
setVersions(response.versions.filter((version) => version.compatible));
} catch (err) {
setError(err instanceof Error ? err.message : t("versionsError"));
} finally {
setBusy(false);
}
}
async function handleInstall(version: CatalogVersion) {
if (!selectedProject) {
return;
}
setBusy(true);
setError(null);
try {
await installAddon(serverId, selectedProject.id, version.id);
setInstalled(await refreshInstalled());
} catch (err) {
setError(err instanceof Error ? err.message : t("installError"));
} finally {
setBusy(false);
}
}
async function handleRemove(addonId: string) {
setBusy(true);
setError(null);
try {
await removeAddon(serverId, addonId);
setInstalled(await refreshInstalled());
} catch (err) {
setError(err instanceof Error ? err.message : t("removeError"));
} finally {
setBusy(false);
}
}
if (!server) {
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
}
if (!supportsAddons) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("unsupported")}</p>;
}
if (server.status !== "STOPPED") {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("stoppedHint")}</p>;
}
return (
<div className="space-y-6">
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{error}
</p>
) : null}
<form onSubmit={(event) => void handleSearch(event)} className="flex gap-2">
<input
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("searchPlaceholder")}
/>
<Button type="submit" disabled={busy}>
{t("search")}
</Button>
</form>
{results.length > 0 ? (
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{results.map((project) => (
<li key={project.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="font-medium text-sm">{project.name}</p>
<p className="text-xs text-zinc-500 line-clamp-2">{project.description}</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void handleSelectProject(project)}
disabled={busy}
>
{t("select")}
</Button>
</li>
))}
</ul>
) : null}
{selectedProject && versions.length > 0 ? (
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("compatibleVersions", { name: selectedProject.name })}</h3>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{versions.map((version) => (
<li key={version.id} className="flex items-center justify-between px-4 py-3 text-sm">
<span>{version.versionNumber}</span>
<Button size="sm" onClick={() => void handleInstall(version)} disabled={busy}>
{t("install")}
</Button>
</li>
))}
</ul>
</div>
) : null}
<div>
<h3 className="mb-2 text-sm font-medium">{t("installed")}</h3>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{installed.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
) : (
installed.map((addon) => (
<li key={addon.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">{addon.projectName}</p>
<p className="text-xs text-zinc-500">
{addon.versionNumber} · {addon.status}
</p>
</div>
{addon.status === "INSTALLED" ? (
<Button
size="sm"
variant="ghost"
onClick={() => void handleRemove(addon.id)}
disabled={busy}
>
{t("remove")}
</Button>
) : null}
</li>
))
)}
</ul>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ const tabs = [
{ key: "players", suffix: "/players" },
{ key: "worlds", suffix: "/worlds" },
{ key: "backups", suffix: "/backups" },
{ key: "addons", suffix: "/addons" },
] as const;
export function ServerTabs({ serverId }: ServerTabsProps) {

View File

@@ -0,0 +1,81 @@
import { apiFetch } from "./auth";
import type { SoftwareFamily } from "./servers";
export interface CatalogProject {
id: string;
slug: string;
name: string;
description: string;
projectType: string;
downloads: number;
iconUrl: string | null;
}
export interface CatalogVersion {
id: string;
projectId: string;
name: string;
versionNumber: string;
gameVersions: string[];
loaders: string[];
compatible: boolean;
publishedAt: string;
}
export interface InstalledAddon {
id: string;
projectId: string;
projectName: string;
versionNumber: string;
addonType: string;
fileName: string;
status: string;
failureReason: string | null;
}
export async function searchCatalog(input: {
q: string;
gameVersion: string;
softwareFamily: SoftwareFamily;
}): Promise<{ projects: CatalogProject[] }> {
const params = new URLSearchParams({
q: input.q,
gameVersion: input.gameVersion,
softwareFamily: input.softwareFamily,
});
return apiFetch(`/catalog/projects?${params.toString()}`);
}
export async function listCatalogVersions(
projectId: string,
serverId: string,
): Promise<{ versions: CatalogVersion[] }> {
const params = new URLSearchParams({ serverId });
return apiFetch(`/catalog/projects/${projectId}/versions?${params.toString()}`);
}
export async function listInstalledAddons(
serverId: string,
): Promise<{ addons: InstalledAddon[] }> {
return apiFetch(`/servers/${serverId}/addons`);
}
export async function installAddon(
serverId: string,
projectId: string,
versionId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/addons`, {
method: "POST",
body: JSON.stringify({ projectId, versionId }),
});
}
export async function removeAddon(
serverId: string,
addonId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/addons/${addonId}`, {
method: "DELETE",
});
}

View File

@@ -12,6 +12,7 @@
"test": "node --test test/**/*.test.js"
},
"dependencies": {
"@hexahost/catalog": "workspace:*",
"@hexahost/config": "workspace:*",
"@hexahost/database": "workspace:*",
"@hexahost/storage": "workspace:*",

View File

@@ -0,0 +1,171 @@
import { Job } from 'bullmq';
import { z } from 'zod';
import {
getModrinthVersion,
isVersionCompatible,
pickPrimaryFile,
resolveModrinthLoader,
} from '@hexahost/catalog';
import { prisma } from '@hexahost/database';
import { sendAgentRequest } from '../agent-bridge';
import { logger } from '../logger';
const installAddonJobSchema = z.object({
addonId: z.string().uuid(),
serverId: z.string().uuid(),
});
const removeAddonJobSchema = z.object({
addonId: z.string().uuid(),
serverId: z.string().uuid(),
});
const ADDON_TIMEOUT_MS = 10 * 60 * 1000;
export async function processInstallAddonJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'install-addon') {
return { status: 'ignored' };
}
const { addonId, serverId } = installAddonJobSchema.parse(job.data);
const addon = await prisma.installedAddon.findUniqueOrThrow({
where: { id: addonId },
});
const server = await prisma.gameServer.findUniqueOrThrow({
where: { id: serverId },
});
if (!server.nodeId) {
throw new Error(`Server ${serverId} is not assigned to a node`);
}
await prisma.installedAddon.update({
where: { id: addonId },
data: { status: 'INSTALLING' },
});
try {
const version = await getModrinthVersion(addon.versionId);
const loader = resolveModrinthLoader(server.softwareFamily);
if (!loader || !isVersionCompatible(version, server.minecraftVersion, loader)) {
throw new Error('Selected version is not compatible with this server');
}
const file = pickPrimaryFile(version);
if (!file) {
throw new Error('No installable file found for this version');
}
const response = await sendAgentRequest(
server.nodeId,
'server.addon.install',
{
serverId,
generation: server.version,
downloadUrl: file.url,
relativePath: addon.filePath,
sha512: file.hashes['sha512'] ?? addon.fileSha512 ?? '',
},
ADDON_TIMEOUT_MS,
);
if (!response.success) {
throw new Error(response.errorMessage ?? 'Addon install failed');
}
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'INSTALLED',
installedAt: new Date(),
fileSha512: file.hashes['sha512'] ?? addon.fileSha512,
},
});
return { status: 'installed' };
} catch (error) {
const message = error instanceof Error ? error.message : 'Addon install failed';
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'FAILED',
failureReason: message,
},
});
throw error;
}
}
export async function processRemoveAddonJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'remove-addon') {
return { status: 'ignored' };
}
const { addonId, serverId } = removeAddonJobSchema.parse(job.data);
const addon = await prisma.installedAddon.findUniqueOrThrow({
where: { id: addonId },
});
const server = await prisma.gameServer.findUniqueOrThrow({
where: { id: serverId },
});
if (!server.nodeId) {
throw new Error(`Server ${serverId} is not assigned to a node`);
}
await prisma.installedAddon.update({
where: { id: addonId },
data: { status: 'REMOVING' },
});
try {
const response = await sendAgentRequest(
server.nodeId,
'server.addon.remove',
{
serverId,
generation: server.version,
relativePath: addon.filePath,
},
ADDON_TIMEOUT_MS,
);
if (!response.success) {
throw new Error(response.errorMessage ?? 'Addon remove failed');
}
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'REMOVED',
},
});
return { status: 'removed' };
} catch (error) {
const message = error instanceof Error ? error.message : 'Addon remove failed';
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'FAILED',
failureReason: message,
},
});
throw error;
}
}
export async function processAddonJob(job: Job): Promise<{ status: string }> {
switch (job.name) {
case 'install-addon':
return processInstallAddonJob(job);
case 'remove-addon':
return processRemoveAddonJob(job);
default:
logger.warn({ jobName: job.name }, 'Unknown addon job');
return { status: 'ignored' };
}
}

View File

@@ -4,6 +4,7 @@ import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database';
import { processNotificationJob } from './handlers/notifications';
import { processAddonJob } from './handlers/server-addons';
import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups';
import { processLifecycleJob } from './handlers/server-lifecycle';
import { processProvisionServerJob } from './handlers/server-provisioning';
@@ -12,6 +13,7 @@ import { logger } from './logger';
import { closeRedisConnections } from './redis';
import {
QUEUE_NOTIFICATIONS,
QUEUE_SERVER_ADDONS,
QUEUE_SERVER_BACKUPS,
QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING,
@@ -43,6 +45,10 @@ function createQueueWorker(
return processBackupJob(job);
}
if (queueName === QUEUE_SERVER_ADDONS) {
return processAddonJob(job);
}
if (queueName === QUEUE_SERVER_LIFECYCLE) {
return processLifecycleJob(job);
}

View File

@@ -1,3 +1,4 @@
export const QUEUE_SERVER_ADDONS = 'server-addons' as const;
export const QUEUE_SERVER_BACKUPS = 'server-backups' as const;
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
@@ -8,6 +9,7 @@ import { Queue } from 'bullmq';
import { validateConfig } from '@hexahost/config';
export const WORKER_QUEUES = [
QUEUE_SERVER_ADDONS,
QUEUE_SERVER_BACKUPS,
QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING,
@@ -16,6 +18,22 @@ export const WORKER_QUEUES = [
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
let addonsQueue: Queue | null = null;
export function getAddonsQueue(): Queue {
if (!addonsQueue) {
const config = validateConfig();
addonsQueue = new Queue(QUEUE_SERVER_ADDONS, {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
}
return addonsQueue;
}
let backupsQueue: Queue | null = null;
export function getBackupsQueue(): Queue {

File diff suppressed because one or more lines are too long