Compare commits

...

2 Commits

236 changed files with 17849 additions and 440 deletions

View File

@@ -89,6 +89,16 @@ STRIPE_WEBHOOK_SECRET=
OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_ENDPOINT=
LOG_LEVEL=info LOG_LEVEL=info
# --- OIDC (optional) ---
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:3001/api/v1/auth/oidc/callback
# --- Notification delivery (optional) ---
NOTIFICATION_WEBHOOK_URL=
NOTIFICATION_DISCORD_WEBHOOK_URL=
# --- Service Ports (development) --- # --- Service Ports (development) ---
API_PORT=3001 API_PORT=3001
WEB_PORT=3000 WEB_PORT=3000

View File

@@ -1 +1 @@
{"files":{"packages/auth/dist/username.d.ts":{"size":442,"mtime_nanos":1783255151494048909,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js":{"size":1089,"mtime_nanos":1783255151493710470,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts.map":{"size":281,"mtime_nanos":1783255151493885819,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts.map":{"size":430,"mtime_nanos":1783255151494073119,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts":{"size":200,"mtime_nanos":1783255151493536190,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts.map":{"size":306,"mtime_nanos":1783255151493686290,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts.map":{"size":266,"mtime_nanos":1783255151493572620,"mode":420,"is_dir":false},"packages/auth/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1783255052127378550,"mode":420,"is_dir":false},"packages/auth/dist/session.js":{"size":672,"mtime_nanos":1783255151493811820,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts.map":{"size":303,"mtime_nanos":1783255151493978659,"mode":420,"is_dir":false},"packages/auth/dist/index.js.map":{"size":824,"mtime_nanos":1783255151493485050,"mode":420,"is_dir":false},"packages/auth/dist/totp.js.map":{"size":569,"mtime_nanos":1783255151494024989,"mode":420,"is_dir":false},"packages/auth/dist/username.js":{"size":2787,"mtime_nanos":1783255151494096559,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts.map":{"size":300,"mtime_nanos":1783255151493784580,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts":{"size":1570,"mtime_nanos":1783255151493314190,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts.map":{"size":1443,"mtime_nanos":1783255151493364820,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js.map":{"size":760,"mtime_nanos":1783255151493932809,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts":{"size":269,"mtime_nanos":1783255151493955819,"mode":420,"is_dir":false},"packages/auth/dist/index.js":{"size":4135,"mtime_nanos":1783255151493407900,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js.map":{"size":949,"mtime_nanos":1783255151493735800,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts":{"size":244,"mtime_nanos":1783255151493861049,"mode":420,"is_dir":false},"packages/auth/dist/totp.js":{"size":683,"mtime_nanos":1783255151494002079,"mode":420,"is_dir":false},"packages/auth/dist/username.js.map":{"size":2294,"mtime_nanos":1783255151494119969,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js":{"size":864,"mtime_nanos":1783255151493909599,"mode":420,"is_dir":false},"packages/auth/dist/session.js.map":{"size":493,"mtime_nanos":1783255151493836729,"mode":420,"is_dir":false},"packages/auth/dist/password.js":{"size":482,"mtime_nanos":1783255151493603670,"mode":420,"is_dir":false},"packages/auth/dist/password.js.map":{"size":404,"mtime_nanos":1783255151493633310,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts":{"size":264,"mtime_nanos":1783255151493659970,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts":{"size":334,"mtime_nanos":1783255151493759480,"mode":420,"is_dir":false}},"order":["packages/auth/.turbo/turbo-build.log","packages/auth/dist/index.d.ts","packages/auth/dist/index.d.ts.map","packages/auth/dist/index.js","packages/auth/dist/index.js.map","packages/auth/dist/password.d.ts","packages/auth/dist/password.d.ts.map","packages/auth/dist/password.js","packages/auth/dist/password.js.map","packages/auth/dist/recovery-codes.d.ts","packages/auth/dist/recovery-codes.d.ts.map","packages/auth/dist/recovery-codes.js","packages/auth/dist/recovery-codes.js.map","packages/auth/dist/session.d.ts","packages/auth/dist/session.d.ts.map","packages/auth/dist/session.js","packages/auth/dist/session.js.map","packages/auth/dist/tokens.d.ts","packages/auth/dist/tokens.d.ts.map","packages/auth/dist/tokens.js","packages/auth/dist/tokens.js.map","packages/auth/dist/totp.d.ts","packages/auth/dist/totp.d.ts.map","packages/auth/dist/totp.js","packages/auth/dist/totp.js.map","packages/auth/dist/username.d.ts","packages/auth/dist/username.d.ts.map","packages/auth/dist/username.js","packages/auth/dist/username.js.map"]} {"files":{"packages/auth/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1783255052127378550,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts":{"size":200,"mtime_nanos":1783255318234684822,"mode":420,"is_dir":false},"packages/auth/dist/totp.js":{"size":683,"mtime_nanos":1783255318235066252,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts":{"size":269,"mtime_nanos":1783255318235033232,"mode":420,"is_dir":false},"packages/auth/dist/session.js":{"size":672,"mtime_nanos":1783255318234925202,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js.map":{"size":949,"mtime_nanos":1783255318234865402,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts":{"size":1570,"mtime_nanos":1783255318234480642,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts":{"size":264,"mtime_nanos":1783255318234800762,"mode":420,"is_dir":false},"packages/auth/dist/username.js":{"size":2787,"mtime_nanos":1783255318235144382,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js.map":{"size":760,"mtime_nanos":1783255318235017152,"mode":420,"is_dir":false},"packages/auth/dist/username.js.map":{"size":2294,"mtime_nanos":1783255318235160872,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts.map":{"size":266,"mtime_nanos":1783255318234717722,"mode":420,"is_dir":false},"packages/auth/dist/index.js.map":{"size":824,"mtime_nanos":1783255318234638252,"mode":420,"is_dir":false},"packages/auth/dist/session.js.map":{"size":493,"mtime_nanos":1783255318234944292,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts.map":{"size":1443,"mtime_nanos":1783255318234518132,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts":{"size":334,"mtime_nanos":1783255318234883722,"mode":420,"is_dir":false},"packages/auth/dist/password.js":{"size":482,"mtime_nanos":1783255318234755462,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js":{"size":1089,"mtime_nanos":1783255318234843272,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts.map":{"size":306,"mtime_nanos":1783255318234822282,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts.map":{"size":430,"mtime_nanos":1783255318235123102,"mode":420,"is_dir":false},"packages/auth/dist/password.js.map":{"size":404,"mtime_nanos":1783255318234775572,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts.map":{"size":281,"mtime_nanos":1783255318234984402,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts":{"size":442,"mtime_nanos":1783255318235106382,"mode":420,"is_dir":false},"packages/auth/dist/index.js":{"size":4135,"mtime_nanos":1783255318234572542,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts.map":{"size":303,"mtime_nanos":1783255318235050102,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js":{"size":864,"mtime_nanos":1783255318235000782,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts.map":{"size":300,"mtime_nanos":1783255318234901542,"mode":420,"is_dir":false},"packages/auth/dist/totp.js.map":{"size":569,"mtime_nanos":1783255318235085572,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts":{"size":244,"mtime_nanos":1783255318234964842,"mode":420,"is_dir":false}},"order":["packages/auth/.turbo/turbo-build.log","packages/auth/dist/index.d.ts","packages/auth/dist/index.d.ts.map","packages/auth/dist/index.js","packages/auth/dist/index.js.map","packages/auth/dist/password.d.ts","packages/auth/dist/password.d.ts.map","packages/auth/dist/password.js","packages/auth/dist/password.js.map","packages/auth/dist/recovery-codes.d.ts","packages/auth/dist/recovery-codes.d.ts.map","packages/auth/dist/recovery-codes.js","packages/auth/dist/recovery-codes.js.map","packages/auth/dist/session.d.ts","packages/auth/dist/session.d.ts.map","packages/auth/dist/session.js","packages/auth/dist/session.js.map","packages/auth/dist/tokens.d.ts","packages/auth/dist/tokens.d.ts.map","packages/auth/dist/tokens.js","packages/auth/dist/tokens.js.map","packages/auth/dist/totp.d.ts","packages/auth/dist/totp.d.ts.map","packages/auth/dist/totp.js","packages/auth/dist/totp.js.map","packages/auth/dist/username.d.ts","packages/auth/dist/username.d.ts.map","packages/auth/dist/username.js","packages/auth/dist/username.js.map"]}

View File

@@ -0,0 +1 @@
{"files":{"packages/database/.turbo/turbo-build.log":{"size":549,"mtime_nanos":1783255321226890055,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts":{"size":991,"mtime_nanos":1783255321208338929,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":733,"mtime_nanos":1783255321207890090,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1783255321192311503,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1783255321191890119,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"1b4ddb8b5e9b564a","duration":2992,"sha":"2126e7bebf9ff0796ac77f8548cba399f45e759a","dirty_hash":null}

BIN
.turbo/cache/1b4ddb8b5e9b564a.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"8de6070230e6cb7f","duration":1207,"sha":"2126e7bebf9ff0796ac77f8548cba399f45e759a","dirty_hash":null}

BIN
.turbo/cache/8de6070230e6cb7f.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -137,7 +137,7 @@ pnpm db:migrate
pnpm dev pnpm dev
# Admin anlegen (keine Default-Zugangsdaten) # Admin anlegen (keine Default-Zugangsdaten)
pnpm bootstrap:admin -- --email admin@localhost --password 'min-12-chars-here' pnpm bootstrap:admin -- --email admin@example.com --password 'min-12-chars-here'
``` ```
| Flow | URL | | Flow | URL |

View File

@@ -21,16 +21,22 @@
"@hexahost/contracts": "workspace:*", "@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*", "@hexahost/database": "workspace:*",
"@hexahost/dns": "workspace:*", "@hexahost/dns": "workspace:*",
"@hexahost/feature-flags": "workspace:*",
"@hexahost/otel": "workspace:*",
"@hexahost/integration-auth": "workspace:*", "@hexahost/integration-auth": "workspace:*",
"@hexahost/metering": "workspace:*", "@hexahost/metering": "workspace:*",
"@hexahost/permissions": "workspace:*",
"@hexahost/scheduler": "workspace:*", "@hexahost/scheduler": "workspace:*",
"@hexahost/server-state": "workspace:*",
"@hexahost/storage": "workspace:*", "@hexahost/storage": "workspace:*",
"@aws-sdk/client-s3": "^3.750.0",
"@nestjs/common": "^11.0.7", "@nestjs/common": "^11.0.7",
"@nestjs/core": "^11.0.7", "@nestjs/core": "^11.0.7",
"@nestjs/platform-fastify": "^11.0.7", "@nestjs/platform-fastify": "^11.0.7",
"@nestjs/swagger": "^11.0.3", "@nestjs/swagger": "^11.0.3",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"bullmq": "^5.34.8", "bullmq": "^5.34.8",
"cron-parser": "^5.0.4",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { createAbuseReportSchema } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AbuseService } from './abuse.service';
@ApiTags('abuse')
@Controller('abuse/reports')
@UseGuards(SessionGuard)
export class AbuseController {
constructor(private readonly abuseService: AbuseService) {}
@Post()
@ApiOperation({ summary: 'Submit an abuse report' })
create(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(createAbuseReportSchema)) body: unknown,
) {
return this.abuseService.create(
user.id,
body as Parameters<AbuseService['create']>[1],
);
}
}

View File

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

View File

@@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import type {
AbuseReport,
AbuseReportListResponse,
CreateAbuseReport,
UpdateAbuseReport,
} from '@hexahost/contracts';
import type { AbuseReport as AbuseReportRow } from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
function mapReport(report: AbuseReportRow): AbuseReport {
return {
id: report.id,
reporterId: report.reporterId,
targetUserId: report.targetUserId,
serverId: report.serverId,
status: report.status,
reason: report.reason,
details: report.details,
resolution: report.resolution,
createdAt: report.createdAt.toISOString(),
updatedAt: report.updatedAt.toISOString(),
};
}
@Injectable()
export class AbuseService {
constructor(private readonly prisma: PrismaService) {}
async create(reporterId: string, input: CreateAbuseReport): Promise<AbuseReport> {
const report = await this.prisma.abuseReport.create({
data: {
reporterId,
targetUserId: input.targetUserId,
serverId: input.serverId,
reason: input.reason.trim(),
details: input.details?.trim(),
},
});
return mapReport(report);
}
async listAdmin(options?: {
status?: AbuseReport['status'];
limit?: number;
cursor?: string;
}): Promise<AbuseReportListResponse> {
const limit = Math.min(Math.max(options?.limit ?? 50, 1), 100);
const reports = await this.prisma.abuseReport.findMany({
where: options?.status ? { status: options.status } : undefined,
orderBy: { createdAt: 'desc' },
take: limit + 1,
...(options?.cursor
? { cursor: { id: options.cursor }, skip: 1 }
: {}),
});
return {
reports: reports.slice(0, limit).map(mapReport),
};
}
async updateAdmin(reportId: string, input: UpdateAbuseReport): Promise<AbuseReport> {
const report = await this.prisma.abuseReport.update({
where: { id: reportId },
data: {
status: input.status,
resolution: input.resolution,
},
});
return mapReport(report);
}
}

View File

@@ -0,0 +1,58 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { updateAbuseReportSchema } from '@hexahost/contracts';
import type { AbuseReport } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AbuseService } from '../abuse/abuse.service';
import { assertSuperAdmin } from './admin-auth.util';
@ApiTags('admin')
@Controller('admin/abuse')
@UseGuards(SessionGuard)
export class AdminAbuseController {
constructor(private readonly abuseService: AbuseService) {}
@Get()
@ApiOperation({ summary: 'List abuse reports' })
list(
@CurrentUser() user: { roles: string[] },
@Query('status') status?: AbuseReport['status'],
@Query('limit') limit?: string,
@Query('cursor') cursor?: string,
) {
assertSuperAdmin(user.roles);
return this.abuseService.listAdmin({
status,
limit: limit ? Number(limit) : undefined,
cursor,
});
}
@Patch(':reportId')
@ApiOperation({ summary: 'Update an abuse report' })
update(
@CurrentUser() user: { roles: string[] },
@Param('reportId', ParseUUIDPipe) reportId: string,
@Body(new ZodValidationPipe(updateAbuseReportSchema)) body: unknown,
) {
assertSuperAdmin(user.roles);
return this.abuseService.updateAdmin(
reportId,
body as Parameters<AbuseService['updateAdmin']>[1],
);
}
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { assertSuperAdmin } from './admin-auth.util';
import { AdminAuditService } from './admin-audit.service';
@ApiTags('admin')
@Controller('admin/audit')
@UseGuards(SessionGuard)
export class AdminAuditController {
constructor(private readonly audit: AdminAuditService) {}
@Get()
@ApiOperation({ summary: 'List audit events' })
list(
@CurrentUser() user: { roles: string[] },
@Query('limit') limit?: string,
@Query('cursor') cursor?: string,
@Query('action') action?: string,
) {
assertSuperAdmin(user.roles);
return this.audit.listAuditEvents({
limit: limit ? Number(limit) : undefined,
cursor,
action,
});
}
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminAuditService {
constructor(private readonly prisma: PrismaService) {}
async listAuditEvents(query: {
limit?: number;
cursor?: string;
action?: string;
}): Promise<{
items: Array<{
id: string;
action: string;
entityType: string | null;
entityId: string | null;
metadata: unknown;
ipAddress: string | null;
createdAt: string;
user: { id: string; email: string; username: string } | null;
}>;
nextCursor: string | null;
}> {
const limit = Math.min(query.limit ?? 50, 100);
const events = await this.prisma.auditEvent.findMany({
where: query.action ? { action: query.action } : undefined,
orderBy: { createdAt: 'desc' },
take: limit + 1,
...(query.cursor
? {
cursor: { id: query.cursor },
skip: 1,
}
: {}),
include: {
user: {
select: { id: true, email: true, username: true },
},
},
});
const hasMore = events.length > limit;
const items = hasMore ? events.slice(0, limit) : events;
return {
items: items.map((event) => ({
id: event.id,
action: event.action,
entityType: event.entityType,
entityId: event.entityId,
metadata: event.metadata,
ipAddress: event.ipAddress,
createdAt: event.createdAt.toISOString(),
user: event.user,
})),
nextCursor: hasMore ? items[items.length - 1]?.id ?? null : null,
};
}
}

View File

@@ -0,0 +1,18 @@
import { ForbiddenException } from '@nestjs/common';
import { UserRole } from '@hexahost/auth';
export function assertSuperAdmin(roles: string[]): void {
if (!roles.includes(UserRole.SUPER_ADMIN)) {
throw new ForbiddenException('Super administrator access required');
}
}
export function assertPlatformRole(
roles: string[],
allowed: UserRole[],
): void {
if (!allowed.some((role) => roles.includes(role))) {
throw new ForbiddenException('Insufficient platform permissions');
}
}

View File

@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { assertSuperAdmin } from './admin-auth.util';
import { AdminDashboardService } from './admin-dashboard.service';
@ApiTags('admin')
@Controller('admin/dashboard')
@UseGuards(SessionGuard)
export class AdminDashboardController {
constructor(private readonly dashboard: AdminDashboardService) {}
@Get()
@ApiOperation({ summary: 'Admin dashboard statistics' })
stats(@CurrentUser() user: { roles: string[] }) {
assertSuperAdmin(user.roles);
return this.dashboard.getStats();
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminDashboardService {
constructor(private readonly prisma: PrismaService) {}
async getStats() {
const [
activeUsers,
totalServers,
runningServers,
queuedServers,
onlineNodes,
failedJobs24h,
openAbuseReports,
openTickets,
] = await Promise.all([
this.prisma.user.count({ where: { status: 'ACTIVE' } }),
this.prisma.gameServer.count({
where: { status: { not: 'DELETED' } },
}),
this.prisma.gameServer.count({ where: { status: 'RUNNING' } }),
this.prisma.serverStartQueueEntry.count({ where: { status: 'QUEUED' } }),
this.prisma.gameNode.count({ where: { status: 'ONLINE' } }),
this.prisma.jobRecord.count({
where: {
status: 'FAILED',
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
}),
this.prisma.abuseReport.count({ where: { status: 'OPEN' } }),
this.prisma.supportTicket.count({
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING'] } },
}),
]);
return {
activeUsers,
totalServers,
runningServers,
queueLength: queuedServers,
onlineNodes,
failedJobs24h,
openAbuseReports,
openTickets,
generatedAt: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { assertSuperAdmin } from './admin-auth.util';
import { AdminJobsService } from './admin-jobs.service';
@ApiTags('admin')
@Controller('admin/jobs')
@UseGuards(SessionGuard)
export class AdminJobsController {
constructor(private readonly jobs: AdminJobsService) {}
@Get()
@ApiOperation({ summary: 'List background jobs' })
list(
@CurrentUser() user: { roles: string[] },
@Query('limit') limit?: string,
@Query('status') status?: string,
@Query('queue') queue?: string,
) {
assertSuperAdmin(user.roles);
return this.jobs.listJobs({
limit: limit ? Number(limit) : undefined,
status,
queue,
});
}
}

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminJobsService {
constructor(private readonly prisma: PrismaService) {}
async listJobs(query: { limit?: number; status?: string; queue?: string }): Promise<{
jobs: Array<{
id: string;
queue: string;
jobName: string;
status: string;
attempts: number;
error: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
}>;
}> {
const limit = Math.min(query.limit ?? 50, 100);
const jobs = await this.prisma.jobRecord.findMany({
where: {
...(query.status ? { status: query.status as never } : {}),
...(query.queue ? { queue: query.queue } : {}),
},
orderBy: { createdAt: 'desc' },
take: limit,
});
return {
jobs: jobs.map((job) => ({
id: job.id,
queue: job.queue,
jobName: job.jobName,
status: job.status,
attempts: job.attempts,
error: job.error,
startedAt: job.startedAt?.toISOString() ?? null,
completedAt: job.completedAt?.toISOString() ?? null,
createdAt: job.createdAt.toISOString(),
})),
};
}
}

View File

@@ -0,0 +1,57 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { assertSuperAdmin } from './admin-auth.util';
import { AdminUsersService } from './admin-users.service';
@ApiTags('admin')
@Controller('admin/users')
@UseGuards(SessionGuard)
export class AdminUsersController {
constructor(private readonly users: AdminUsersService) {}
@Get()
@ApiOperation({ summary: 'Search users' })
search(
@CurrentUser() user: { roles: string[] },
@Query('q') q?: string,
@Query('limit') limit?: string,
) {
assertSuperAdmin(user.roles);
return this.users.searchUsers({
q,
limit: limit ? Number(limit) : undefined,
});
}
@Post(':id/suspend')
@ApiOperation({ summary: 'Suspend a user account' })
suspend(
@CurrentUser() user: { roles: string[] },
@Param('id', ParseUUIDPipe) id: string,
) {
assertSuperAdmin(user.roles);
return this.users.suspendUser(id);
}
@Post(':id/unsuspend')
@ApiOperation({ summary: 'Unsuspend a user account' })
unsuspend(
@CurrentUser() user: { roles: string[] },
@Param('id', ParseUUIDPipe) id: string,
) {
assertSuperAdmin(user.roles);
return this.users.unsuspendUser(id);
}
}

View File

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
async searchUsers(query: { q?: string; limit?: number }): Promise<{
users: Array<{
id: string;
email: string;
username: string;
displayName: string | null;
status: string;
roles: string[];
twoFactorEnabled: boolean;
sessionCount: number;
serverCount: number;
createdAt: string;
}>;
}> {
const limit = Math.min(query.limit ?? 25, 100);
const q = query.q?.trim();
const users = await this.prisma.user.findMany({
where: q
? {
OR: [
{ email: { contains: q, mode: 'insensitive' } },
{ username: { contains: q, mode: 'insensitive' } },
],
}
: undefined,
orderBy: { createdAt: 'desc' },
take: limit,
include: {
platformRoles: {
include: { role: true },
},
totpCredential: { select: { enabledAt: true } },
_count: { select: { sessions: true, gameServers: true } },
},
});
return {
users: users.map((user) => ({
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
status: user.status,
roles: user.platformRoles.map((entry) => entry.role.name),
twoFactorEnabled: Boolean(user.totpCredential?.enabledAt),
sessionCount: user._count.sessions,
serverCount: user._count.gameServers,
createdAt: user.createdAt.toISOString(),
})),
};
}
async suspendUser(userId: string, reason?: string): Promise<{ id: string; status: string }> {
return this.prisma.user.update({
where: { id: userId },
data: { status: 'SUSPENDED' },
select: { id: true, status: true },
});
}
async unsuspendUser(userId: string): Promise<{ id: string; status: string }> {
return this.prisma.user.update({
where: { id: userId },
data: { status: 'ACTIVE', failedLoginAttempts: 0, lockedUntil: null },
select: { id: true, status: true },
});
}
}

View File

@@ -1,13 +1,36 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AbuseModule } from '../abuse/abuse.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { AdminAbuseController } from './admin-abuse.controller';
import { AdminAuditController } from './admin-audit.controller';
import { AdminAuditService } from './admin-audit.service';
import { AdminDashboardController } from './admin-dashboard.controller';
import { AdminDashboardService } from './admin-dashboard.service';
import { AdminJobsController } from './admin-jobs.controller';
import { AdminJobsService } from './admin-jobs.service';
import { AdminNodesController } from './admin-nodes.controller'; import { AdminNodesController } from './admin-nodes.controller';
import { AdminNodesService } from './admin-nodes.service'; import { AdminNodesService } from './admin-nodes.service';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, AbuseModule],
controllers: [AdminNodesController], controllers: [
providers: [AdminNodesService], AdminNodesController,
AdminDashboardController,
AdminAuditController,
AdminJobsController,
AdminUsersController,
AdminAbuseController,
],
providers: [
AdminNodesService,
AdminDashboardService,
AdminAuditService,
AdminJobsService,
AdminUsersService,
],
}) })
export class AdminModule {} export class AdminModule {}

View File

@@ -1,5 +1,11 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AbuseModule } from './abuse/abuse.module';
import { ComplianceModule } from './compliance/compliance.module';
import { NotificationsModule } from './notifications/notifications.module';
import { FeatureFlagsModule } from './feature-flags/feature-flags.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { SupportModule } from './support/support.module';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware'; import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -13,10 +19,14 @@ import { EdgeModule } from './edge/edge.module';
import { ServersModule } from './servers/servers.module'; import { ServersModule } from './servers/servers.module';
import { AppConfigModule } from './config/app-config.module'; import { AppConfigModule } from './config/app-config.module';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { CsrfModule } from './common/csrf/csrf.module';
import { CsrfGuard } from './common/csrf/csrf.guard';
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
@Module({ @Module({
imports: [ imports: [
LoggerModule.forRoot({ LoggerModule.forRoot({
@@ -51,12 +61,27 @@ import { LoggerModule } from 'nestjs-pino';
ServersModule, ServersModule,
NodesModule, NodesModule,
AdminModule, AdminModule,
NotificationsModule,
ComplianceModule,
FeatureFlagsModule,
OrganizationsModule,
SupportModule,
AbuseModule,
CsrfModule,
], ],
providers: [ providers: [
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: ThrottlerGuard, useClass: ThrottlerGuard,
}, },
{
provide: APP_GUARD,
useClass: CsrfGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: IdempotencyInterceptor,
},
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

@@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
Body, Body,
Controller, Controller,
Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Post,
@@ -221,4 +222,35 @@ export class AuthController {
request.ip, request.ip,
); );
} }
@Public()
@Get('oidc/login')
@ApiOperation({ summary: 'Redirect to OIDC provider (optional)' })
oidcLogin(@Res() reply: FastifyReply) {
const issuer = process.env['OIDC_ISSUER'];
const clientId = process.env['OIDC_CLIENT_ID'];
const redirectUri = process.env['OIDC_REDIRECT_URI'];
if (!issuer || !clientId || !redirectUri) {
throw new BadRequestException('OIDC is not configured');
}
const url = new URL(`${issuer.replace(/\/$/, '')}/authorize`);
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
return reply.redirect(url.toString(), 302);
}
@Public()
@Get('oidc/callback')
@ApiOperation({ summary: 'OIDC callback (optional)' })
async oidcCallback(
@Query('code') code: string | undefined,
@Res() reply: FastifyReply,
) {
if (!code) {
throw new BadRequestException('Missing authorization code');
}
return this.authService.completeOidcLogin(code, reply);
}
} }

View File

@@ -50,6 +50,13 @@ import { SsoService } from './sso.service';
}, },
}, },
], ],
exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService], exports: [
AuthService,
SessionService,
SessionGuard,
AuditService,
SsoService,
NOTIFICATIONS_QUEUE,
],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -712,4 +712,77 @@ export class AuthService {
return false; return false;
} }
async completeOidcLogin(code: string, reply: FastifyReply): Promise<void> {
const issuer = process.env['OIDC_ISSUER'];
const clientId = process.env['OIDC_CLIENT_ID'];
const clientSecret = process.env['OIDC_CLIENT_SECRET'];
const redirectUri = process.env['OIDC_REDIRECT_URI'];
if (!issuer || !clientId || !clientSecret || !redirectUri) {
throw new BadRequestException('OIDC is not configured');
}
const tokenResponse = await fetch(`${issuer.replace(/\/$/, '')}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
}),
});
if (!tokenResponse.ok) {
throw new UnauthorizedException('OIDC token exchange failed');
}
const tokenJson = (await tokenResponse.json()) as { access_token?: string };
if (!tokenJson.access_token) {
throw new UnauthorizedException('OIDC token missing');
}
const userInfoResponse = await fetch(`${issuer.replace(/\/$/, '')}/userinfo`, {
headers: { Authorization: `Bearer ${tokenJson.access_token}` },
});
if (!userInfoResponse.ok) {
throw new UnauthorizedException('OIDC userinfo failed');
}
const profile = (await userInfoResponse.json()) as {
email?: string;
sub?: string;
name?: string;
};
if (!profile.email) {
throw new BadRequestException('OIDC profile has no email');
}
let user = await this.prisma.user.findUnique({
where: { email: profile.email },
});
if (!user) {
const username = generateUsernameFromEmail(profile.email);
const passwordHash = await hashPassword(generateSecureToken(32));
user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
email: profile.email!,
username,
displayName: profile.name ?? username,
emailVerifiedAt: new Date(),
},
});
await tx.userCredential.create({
data: { userId: created.id, passwordHash },
});
return created;
});
}
const { token, expiresAt } = await this.sessionService.createSession(user.id);
this.sessionService.setSessionCookie(reply, token, expiresAt);
reply.redirect(`${this.config.APP_URL}/dashboard`, 302);
}
} }

View File

@@ -8,6 +8,19 @@ import { PrismaService } from '../prisma/prisma.service';
import { CatalogService } from './catalog.service'; import { CatalogService } from './catalog.service';
const SOFTWARE_FAMILIES = [
'VANILLA',
'PAPER',
'PURPUR',
'FABRIC',
'FORGE',
'NEOFORGE',
'QUILT',
'BEDROCK_DEDICATED',
] as const;
const MINECRAFT_VERSIONS = ['1.21.1', '1.21', '1.20.6', '1.20.4'];
@ApiTags('catalog') @ApiTags('catalog')
@Controller('catalog') @Controller('catalog')
@UseGuards(SessionGuard) @UseGuards(SessionGuard)
@@ -17,6 +30,55 @@ export class CatalogController {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
) {} ) {}
@Get('software')
@ApiOperation({ summary: 'List supported server software families' })
listSoftware() {
return {
families: SOFTWARE_FAMILIES.map((family) => ({ id: family, name: family })),
};
}
@Get('versions')
@ApiOperation({ summary: 'List supported Minecraft versions' })
async listVersions(@Query('edition') edition?: string) {
const dbVersions = await this.prisma.minecraftVersion.findMany({
where: edition ? { edition: edition as 'JAVA' | 'BEDROCK', isActive: true } : { isActive: true },
orderBy: { version: 'desc' },
});
if (dbVersions.length > 0) {
return { versions: dbVersions.map((entry) => entry.version) };
}
return { versions: MINECRAFT_VERSIONS };
}
@Get('regions')
@ApiOperation({ summary: 'List available deployment regions' })
async listRegions(): Promise<{
regions: Array<{
id: string;
name: string;
hostname: string;
status: string;
}>;
}> {
const nodes = await this.prisma.gameNode.findMany({
where: { status: { in: ['ONLINE', 'OFFLINE'] } },
select: { id: true, name: true, hostname: true, status: true },
orderBy: { name: 'asc' },
});
return {
regions: nodes.map((node) => ({
id: node.id,
name: node.name,
hostname: node.hostname,
status: node.status,
})),
};
}
@Get('projects') @Get('projects')
@ApiOperation({ summary: 'Search Modrinth projects' }) @ApiOperation({ summary: 'Search Modrinth projects' })
search(@Query() query: Record<string, string | undefined>) { search(@Query() query: Record<string, string | undefined>) {
@@ -64,4 +126,17 @@ export class CatalogController {
resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT', resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT',
); );
} }
@Get('curseforge/projects')
@ApiOperation({ summary: 'Search CurseForge mods (requires API key)' })
searchCurseForge(@Query('q') q?: string, @Query('limit') limit?: string) {
if (!q?.trim()) {
throw new BadRequestException('q is required');
}
const parsedLimit = limit ? Number.parseInt(limit, 10) : 20;
return this.catalogService.searchCurseForge({
query: q.trim(),
limit: Number.isFinite(parsedLimit) ? parsedLimit : 20,
});
}
} }

View File

@@ -9,6 +9,7 @@ import {
isVersionCompatible, isVersionCompatible,
resolveModrinthLoader, resolveModrinthLoader,
resolveProjectType, resolveProjectType,
searchCurseForgeMods,
searchModrinthProjects, searchModrinthProjects,
type SoftwareFamily, type SoftwareFamily,
} from '@hexahost/catalog'; } from '@hexahost/catalog';
@@ -78,4 +79,26 @@ export class CatalogService {
})), })),
}; };
} }
async searchCurseForge(input: { query: string; limit?: number }) {
try {
const result = await searchCurseForgeMods({
searchFilter: input.query,
pageSize: input.limit ?? 20,
});
return {
provider: 'curseforge' as const,
projects: result.data.map((project) => ({
id: String(project.id),
slug: project.slug,
name: project.name,
description: project.summary,
downloads: project.downloadCount,
iconUrl: project.logo?.thumbnailUrl ?? null,
})),
};
} catch {
throw new BadRequestException('CurseForge integration is not configured');
}
}
} }

View File

@@ -0,0 +1,20 @@
import { Controller, Get, Res, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply } from 'fastify';
import { SkipThrottle } from '@nestjs/throttler';
import { CsrfService } from './csrf.service';
@ApiTags('auth')
@Controller('auth')
@SkipThrottle()
export class CsrfController {
constructor(private readonly csrf: CsrfService) {}
@Get('csrf')
@ApiOperation({ summary: 'Issue a CSRF token for browser clients' })
issue(@Res({ passthrough: true }) reply: FastifyReply): { token: string } {
const token = this.csrf.issueToken(reply);
return { token };
}
}

View File

@@ -0,0 +1,19 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { CsrfService } from './csrf.service';
@Injectable()
export class CsrfGuard implements CanActivate {
constructor(private readonly csrf: CsrfService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<FastifyRequest>();
this.csrf.validate(request);
return true;
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AppConfigModule } from '../../config/app-config.module';
import { CsrfController } from './csrf.controller';
import { CsrfGuard } from './csrf.guard';
import { CsrfService } from './csrf.service';
@Module({
imports: [AppConfigModule],
controllers: [CsrfController],
providers: [CsrfService, CsrfGuard],
exports: [CsrfService, CsrfGuard],
})
export class CsrfModule {}

View File

@@ -0,0 +1,77 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { AppConfig } from '@hexahost/config';
import { APP_CONFIG } from '../../config/config.constants';
const CSRF_COOKIE = 'hgc_csrf';
const CSRF_HEADER = 'x-csrf-token';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@Injectable()
export class CsrfService {
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
issueToken(reply: FastifyReply): string {
const token = randomBytes(32).toString('base64url');
const signed = this.sign(token);
reply.setCookie(CSRF_COOKIE, signed, {
httpOnly: false,
sameSite: 'lax',
secure: this.config.APP_ENV === 'production',
path: '/',
});
return token;
}
validate(request: FastifyRequest): void {
const method = request.method.toUpperCase();
if (SAFE_METHODS.has(method)) {
return;
}
const path = request.url.split('?')[0] ?? '';
if (
path.startsWith('/api/v1/integrations/') ||
path.startsWith('/api/v1/internal/') ||
path.startsWith('/api/v1/webhooks/') ||
path.startsWith('/api/v1/health/')
) {
return;
}
const cookieToken = this.readSignedCookie(request);
const headerToken = request.headers[CSRF_HEADER];
if (typeof headerToken !== 'string' || !cookieToken) {
throw new UnauthorizedException('CSRF token missing');
}
const expected = this.sign(headerToken);
const actual = cookieToken;
if (
expected.length !== actual.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(actual))
) {
throw new UnauthorizedException('CSRF token invalid');
}
}
private readSignedCookie(request: FastifyRequest): string | null {
const cookies = request.cookies as Record<string, string | undefined>;
return cookies[CSRF_COOKIE] ?? null;
}
private sign(token: string): string {
return createHmac('sha256', this.config.SESSION_SECRET)
.update(token)
.digest('base64url');
}
}

View File

@@ -0,0 +1,71 @@
import {
BadRequestException,
CallHandler,
ExecutionContext,
Inject,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import Redis from 'ioredis';
import { REDIS_CLIENT } from '../../servers/servers.service';
const IDEMPOTENCY_HEADER = 'idempotency-key';
const TTL_SECONDS = 86_400;
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<unknown>> {
const request = context.switchToHttp().getRequest<{
method?: string;
headers?: Record<string, string | string[] | undefined>;
user?: { id?: string };
url?: string;
}>();
if (!request.method || !['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
return next.handle();
}
const rawKey = request.headers?.[IDEMPOTENCY_HEADER];
const key = Array.isArray(rawKey) ? rawKey[0] : rawKey;
if (!key || key.length < 8) {
return next.handle();
}
const userId = request.user?.id ?? 'anonymous';
const cacheKey = `idempotency:panel:${userId}:${key}:${request.method}:${request.url ?? ''}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return of(JSON.parse(cached) as unknown);
}
return new Observable((subscriber) => {
next.handle().subscribe({
next: (value) => {
void this.redis
.set(cacheKey, JSON.stringify(value), 'EX', TTL_SECONDS)
.catch(() => undefined);
subscriber.next(value);
subscriber.complete();
},
error: (error: unknown) => subscriber.error(error),
});
});
}
}
export function assertIdempotencyKey(headers: Record<string, unknown>): string {
const raw = headers[IDEMPOTENCY_HEADER];
const key = Array.isArray(raw) ? raw[0] : raw;
if (typeof key !== 'string' || key.length < 8) {
throw new BadRequestException('Idempotency-Key header is required');
}
return key;
}

View File

@@ -0,0 +1,63 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { requestAccountDeletionSchema } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { ComplianceService } from './compliance.service';
@ApiTags('account')
@Controller('account/compliance')
@UseGuards(SessionGuard)
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Post('export')
requestExport(@CurrentUser() user: { id: string }) {
return this.complianceService.requestDataExport(user.id);
}
@Get('export')
listExports(@CurrentUser() user: { id: string }) {
return this.complianceService.listDataExports(user.id);
}
@Get('export/:exportId/download')
downloadExport(
@CurrentUser() user: { id: string },
@Param('exportId', ParseUUIDPipe) exportId: string,
) {
return this.complianceService.getExportDownload(user.id, exportId);
}
@Post('deletion')
requestDeletion(
@CurrentUser() user: { id: string; email: string },
@Body(new ZodValidationPipe(requestAccountDeletionSchema)) body: unknown,
) {
const parsed = body as { confirmEmail: string };
return this.complianceService.requestAccountDeletion(user.id, parsed.confirmEmail);
}
@Get('deletion')
deletionStatus(@CurrentUser() user: { id: string }) {
return this.complianceService.getAccountDeletionStatus(user.id);
}
@Delete('deletion')
cancelDeletion(@CurrentUser() user: { id: string }) {
return this.complianceService.cancelAccountDeletion(user.id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ServersModule } from '../servers/servers.module';
import { ComplianceController } from './compliance.controller';
import { ComplianceService } from './compliance.service';
@Module({
imports: [PrismaModule, AuthModule, ServersModule],
controllers: [ComplianceController],
providers: [ComplianceService],
exports: [ComplianceService],
})
export class ComplianceModule {}

View File

@@ -0,0 +1,400 @@
import { randomUUID } from 'node:crypto';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import type { GameServerStatus } from '@hexahost/server-state';
import { ALLOWED_TRANSITIONS } from '@hexahost/server-state';
import {
createPresignedGetUrl,
createS3Client,
loadStorageConfig,
} from '@hexahost/storage';
import { PrismaService } from '../prisma/prisma.service';
import {
REDIS_CLIENT,
SERVER_LIFECYCLE_QUEUE,
} from '../servers/servers.service';
const DELETION_GRACE_DAYS = 14;
const EXPORT_TTL_DAYS = 7;
const EXPORT_BLOB_PREFIX = 'export:blob:';
function isStorageConfigured(): boolean {
return Boolean(process.env['S3_ENDPOINT']?.trim());
}
function exportObjectKey(userId: string, exportId: string): string {
return `users/${userId}/exports/${exportId}.json`;
}
@Injectable()
export class ComplianceService {
constructor(
private readonly prisma: PrismaService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(SERVER_LIFECYCLE_QUEUE)
private readonly lifecycleQueue: Queue,
) {}
async requestDataExport(userId: string) {
const existing = await this.prisma.dataExportRequest.findFirst({
where: { userId, status: { in: ['PENDING', 'PROCESSING', 'READY'] } },
orderBy: { createdAt: 'desc' },
});
if (existing) {
return this.mapExport(existing);
}
const created = await this.prisma.dataExportRequest.create({
data: { userId, status: 'PENDING' },
});
void this.processDataExport(created.id).catch(() => undefined);
return this.mapExport(created);
}
async listDataExports(userId: string) {
const rows = await this.prisma.dataExportRequest.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 10,
});
return { exports: rows.map((row) => this.mapExport(row)) };
}
async getExportDownload(userId: string, exportId: string) {
const row = await this.prisma.dataExportRequest.findFirst({
where: { id: exportId, userId },
});
if (!row || row.status !== 'READY') {
throw new NotFoundException('Export not available');
}
if (row.expiresAt && row.expiresAt <= new Date()) {
throw new NotFoundException('Export has expired');
}
if (row.downloadUrl?.startsWith('s3://')) {
const [, bucket, ...keyParts] = row.downloadUrl.split('/');
const key = keyParts.join('/');
const config = loadStorageConfig();
const client = createS3Client(config);
const url = await createPresignedGetUrl(client, bucket!, key, 3600);
return { url, expiresInSeconds: 3600 };
}
const blob = await this.redis.get(`${EXPORT_BLOB_PREFIX}${exportId}`);
if (!blob) {
throw new NotFoundException('Export data not found');
}
return {
contentType: 'application/json',
data: JSON.parse(blob) as unknown,
};
}
async requestAccountDeletion(userId: string, confirmEmail: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.email !== confirmEmail) {
throw new BadRequestException('Email confirmation does not match');
}
const scheduledFor = new Date();
scheduledFor.setDate(scheduledFor.getDate() + DELETION_GRACE_DAYS);
const existing = await this.prisma.accountDeletionRequest.findFirst({
where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } },
});
if (existing) {
return this.mapDeletion(existing);
}
const created = await this.prisma.accountDeletionRequest.create({
data: {
userId,
status: 'SCHEDULED',
scheduledFor,
},
});
return this.mapDeletion(created);
}
async cancelAccountDeletion(userId: string) {
const pending = await this.prisma.accountDeletionRequest.findFirst({
where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } },
});
if (!pending) {
throw new NotFoundException('No pending deletion request');
}
const updated = await this.prisma.accountDeletionRequest.update({
where: { id: pending.id },
data: { status: 'CANCELLED' },
});
return this.mapDeletion(updated);
}
async getAccountDeletionStatus(userId: string) {
const pending = await this.prisma.accountDeletionRequest.findFirst({
where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } },
orderBy: { createdAt: 'desc' },
});
return { request: pending ? this.mapDeletion(pending) : null };
}
async processPendingExports(limit = 10): Promise<number> {
const pending = await this.prisma.dataExportRequest.findMany({
where: { status: 'PENDING' },
take: limit,
});
for (const row of pending) {
await this.processDataExport(row.id);
}
return pending.length;
}
async processDueDeletions(limit = 10): Promise<number> {
const due = await this.prisma.accountDeletionRequest.findMany({
where: { status: 'SCHEDULED', scheduledFor: { lte: new Date() } },
take: limit,
});
for (const row of due) {
await this.executeAccountDeletion(row.id);
}
return due.length;
}
async processDataExport(exportId: string): Promise<void> {
const row = await this.prisma.dataExportRequest.findUnique({
where: { id: exportId },
});
if (!row || !['PENDING', 'PROCESSING'].includes(row.status)) {
return;
}
await this.prisma.dataExportRequest.update({
where: { id: exportId },
data: { status: 'PROCESSING' },
});
try {
const payload = await this.collectUserExportData(row.userId);
const json = JSON.stringify(payload, null, 2);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + EXPORT_TTL_DAYS);
let downloadUrl: string;
if (isStorageConfigured()) {
const config = loadStorageConfig();
const client = createS3Client(config);
const key = exportObjectKey(row.userId, exportId);
await client.send(
new PutObjectCommand({
Bucket: config.S3_BUCKET,
Key: key,
Body: json,
ContentType: 'application/json',
}),
);
downloadUrl = `s3://${config.S3_BUCKET}/${key}`;
} else {
await this.redis.set(
`${EXPORT_BLOB_PREFIX}${exportId}`,
json,
'EX',
EXPORT_TTL_DAYS * 24 * 60 * 60,
);
const config = getConfig();
downloadUrl = `${config.API_URL}/account/compliance/export/${exportId}/download`;
}
await this.prisma.dataExportRequest.update({
where: { id: exportId },
data: {
status: 'READY',
downloadUrl,
expiresAt,
completedAt: new Date(),
},
});
} catch {
await this.prisma.dataExportRequest.update({
where: { id: exportId },
data: { status: 'FAILED' },
});
}
}
async executeAccountDeletion(requestId: string): Promise<void> {
const request = await this.prisma.accountDeletionRequest.findUnique({
where: { id: requestId },
});
if (!request || request.status !== 'SCHEDULED') {
return;
}
const userId = request.userId;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.status === 'DELETED') {
await this.prisma.accountDeletionRequest.update({
where: { id: requestId },
data: { status: 'COMPLETED', completedAt: new Date() },
});
return;
}
const servers = await this.prisma.gameServer.findMany({
where: { userId, status: { not: 'DELETED' } },
});
for (const server of servers) {
if (server.status === 'RUNNING' || server.status === 'STARTING') {
continue;
}
if (ALLOWED_TRANSITIONS[server.status as GameServerStatus]?.includes('DELETING')) {
const correlationId = randomUUID();
await this.prisma.gameServer.update({
where: { id: server.id },
data: { status: 'DELETING' },
});
await this.lifecycleQueue.add(
'delete-server',
{ serverId: server.id, action: 'delete', correlationId },
{ jobId: correlationId },
);
}
}
const anonymizedId = randomUUID().slice(0, 8);
await this.prisma.$transaction([
this.prisma.userSession.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
this.prisma.user.update({
where: { id: userId },
data: {
email: `deleted-${anonymizedId}@deleted.invalid`,
username: `deleted-${anonymizedId}`,
displayName: null,
status: 'DELETED',
},
}),
this.prisma.accountDeletionRequest.update({
where: { id: requestId },
data: { status: 'COMPLETED', completedAt: new Date() },
}),
]);
}
private async collectUserExportData(userId: string) {
const [user, servers, auditEvents] = await Promise.all([
this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
username: true,
displayName: true,
status: true,
emailVerifiedAt: true,
createdAt: true,
updatedAt: true,
},
}),
this.prisma.gameServer.findMany({
where: { userId },
select: {
id: true,
name: true,
status: true,
edition: true,
softwareFamily: true,
minecraftVersion: true,
ramMb: true,
hostPort: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.auditEvent.findMany({
where: { userId },
select: {
action: true,
entityType: true,
entityId: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 500,
}),
]);
const actionCounts = auditEvents.reduce<Record<string, number>>((acc, event) => {
acc[event.action] = (acc[event.action] ?? 0) + 1;
return acc;
}, {});
return {
exportedAt: new Date().toISOString(),
profile: user,
servers,
auditSummary: {
totalEvents: auditEvents.length,
actionCounts,
recentEvents: auditEvents.slice(0, 50),
},
};
}
private mapExport(row: {
id: string;
status: string;
downloadUrl: string | null;
expiresAt: Date | null;
completedAt: Date | null;
createdAt: Date;
}) {
return {
id: row.id,
status: row.status as 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'EXPIRED',
downloadUrl: row.downloadUrl,
expiresAt: row.expiresAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
private mapDeletion(row: {
id: string;
status: string;
scheduledFor: Date;
completedAt: Date | null;
createdAt: Date;
}) {
return {
id: row.id,
status: row.status as 'PENDING' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED',
scheduledFor: row.scheduledFor.toISOString(),
completedAt: row.completedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FeatureFlagService } from '@hexahost/feature-flags';
import { Public } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { FEATURE_FLAG_SERVICE } from './feature-flags.module';
@ApiTags('system')
@Controller('feature-flags')
export class FeatureFlagsController {
constructor(
@Inject(FEATURE_FLAG_SERVICE) private readonly flags: FeatureFlagService,
) {}
@Public()
@Get()
@ApiOperation({ summary: 'List public feature flags' })
async list() {
const flags = await this.flags.listFlags();
return {
flags: flags.map((flag) => ({
key: flag.key,
enabled: flag.enabled,
})),
};
}
@Get('admin')
@UseGuards(SessionGuard)
@ApiOperation({ summary: 'List feature flags with metadata (authenticated)' })
async listDetailed() {
return { flags: await this.flags.listFlags() };
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { FeatureFlagService } from '@hexahost/feature-flags';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { PrismaService } from '../prisma/prisma.service';
import { FeatureFlagsController } from './feature-flags.controller';
export const FEATURE_FLAG_SERVICE = Symbol('FEATURE_FLAG_SERVICE');
@Module({
imports: [PrismaModule, AuthModule],
controllers: [FeatureFlagsController],
providers: [
{
provide: FEATURE_FLAG_SERVICE,
useFactory: (prisma: PrismaService) => new FeatureFlagService(prisma),
inject: [PrismaService],
},
],
exports: [FEATURE_FLAG_SERVICE],
})
export class FeatureFlagsModule {}

View File

@@ -109,6 +109,21 @@ export class WhmcsIntegrationController {
); );
} }
@Put('users/:externalUserId')
upsertUser(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalUserId') externalUserId: string,
@Body() body: unknown,
) {
const parsed = whmcsUpsertClientRequestSchema.parse(body);
return this.whmcs.upsertClient(
request.whmcsInstallation!,
externalUserId,
parsed,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services') @Post('services')
provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) { provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
const parsed = whmcsProvisionServiceRequestSchema.parse(body); const parsed = whmcsProvisionServiceRequestSchema.parse(body);
@@ -181,6 +196,21 @@ export class WhmcsIntegrationController {
); );
} }
@Post('services/:externalServiceId/renew')
renewService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
@Body() body: unknown,
) {
const parsed = (body ?? {}) as { invoiceId?: string; monthlyCreditGrant?: number };
return this.whmcs.renewService(
request.whmcsInstallation!,
externalServiceId,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
parsed,
);
}
@Post('services/:externalServiceId/sso') @Post('services/:externalServiceId/sso')
createServiceSso( createServiceSso(
@Req() request: WhmcsAuthenticatedRequest, @Req() request: WhmcsAuthenticatedRequest,
@@ -284,6 +314,54 @@ export class WhmcsIntegrationController {
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
); );
} }
@Post('services/:externalServiceId/actions/start')
startService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
) {
return this.whmcs.startServiceAction(
request.whmcsInstallation!,
externalServiceId,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services/:externalServiceId/actions/stop')
stopService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
) {
return this.whmcs.stopServiceAction(
request.whmcsInstallation!,
externalServiceId,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services/:externalServiceId/actions/restart')
restartService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
) {
return this.whmcs.restartServiceAction(
request.whmcsInstallation!,
externalServiceId,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services/:externalServiceId/actions/backup')
backupService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
) {
return this.whmcs.backupServiceAction(
request.whmcsInstallation!,
externalServiceId,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
} }
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined { function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {

View File

@@ -3,6 +3,7 @@ import Redis from 'ioredis';
import { getConfig } from '@hexahost/config'; import { getConfig } from '@hexahost/config';
import { BackupsModule } from '../../servers/backups/backups.module';
import { BillingModule } from '../../billing/billing.module'; import { BillingModule } from '../../billing/billing.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { ServersModule } from '../../servers/servers.module'; import { ServersModule } from '../../servers/servers.module';
@@ -20,7 +21,7 @@ import { WhmcsUsageService } from './whmcs-usage.service';
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants'; export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
@Module({ @Module({
imports: [ServersModule, BillingModule, AuthModule], imports: [ServersModule, BackupsModule, BillingModule, AuthModule],
controllers: [WhmcsIntegrationController], controllers: [WhmcsIntegrationController],
providers: [ providers: [
WhmcsIntegrationService, WhmcsIntegrationService,

View File

@@ -20,6 +20,7 @@ import type { GameServer } from '@hexahost/database';
import { BillingService } from '../../billing/billing.service'; import { BillingService } from '../../billing/billing.service';
import { getBillingProvider } from '../../billing/billing-provider'; import { getBillingProvider } from '../../billing/billing-provider';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { BackupsService } from '../../servers/backups/backups.service';
import { ServersService } from '../../servers/servers.service'; import { ServersService } from '../../servers/servers.service';
import type { WhmcsInstallationContext } from './whmcs-integration.guard'; import type { WhmcsInstallationContext } from './whmcs-integration.guard';
@@ -34,6 +35,7 @@ export class WhmcsIntegrationService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly serversService: ServersService, private readonly serversService: ServersService,
private readonly backupsService: BackupsService,
private readonly billing: BillingService, private readonly billing: BillingService,
) {} ) {}
@@ -356,6 +358,67 @@ export class WhmcsIntegrationService {
return response; return response;
} }
async renewService(
installation: InstallationRef,
externalServiceId: string,
idempotencyKey: string,
body: { invoiceId?: string; monthlyCreditGrant?: number },
): Promise<WhmcsServiceResponse> {
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
if (existingOp?.result) {
return existingOp.result as WhmcsServiceResponse;
}
const link = await this.findServiceLink(installation.id, externalServiceId);
const server = link.server;
if (body.monthlyCreditGrant && body.monthlyCreditGrant > 0) {
const wallet = await this.prisma.creditWallet.findUnique({
where: { userId: server.userId },
});
if (wallet) {
await this.prisma.$transaction([
this.prisma.creditWallet.update({
where: { id: wallet.id },
data: { balance: { increment: body.monthlyCreditGrant } },
}),
this.prisma.creditTransaction.create({
data: {
walletId: wallet.id,
amount: body.monthlyCreditGrant,
type: 'GRANT',
referenceType: 'whmcs_renew',
referenceId: body.invoiceId ?? externalServiceId,
idempotencyKey,
},
}),
]);
}
}
const updatedLink = await this.prisma.whmcsServiceLink.update({
where: { id: link.id },
data: { status: 'ACTIVE', suspendedAt: null },
});
const response = this.toServiceResponse(
externalServiceId,
updatedLink.status,
server,
);
await this.recordOperation(
installation.id,
idempotencyKey,
'service:renew',
response,
externalServiceId,
);
return response;
}
async terminateService( async terminateService(
installation: InstallationRef, installation: InstallationRef,
externalServiceId: string, externalServiceId: string,
@@ -530,4 +593,93 @@ export class WhmcsIntegrationService {
}, },
}; };
} }
async startServiceAction(
installation: InstallationRef,
externalServiceId: string,
idempotencyKey: string,
): Promise<WhmcsServiceResponse> {
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
if (existingOp?.result) {
return existingOp.result as WhmcsServiceResponse;
}
const link = await this.findServiceLink(installation.id, externalServiceId);
const result = await this.serversService.startServerInternal(
link.server.id,
`whmcs:start:${externalServiceId}`,
);
const response = this.toServiceResponse(
externalServiceId,
link.status,
result.server as unknown as GameServer,
);
await this.recordOperation(installation.id, idempotencyKey, 'service:start', response, externalServiceId);
return response;
}
async stopServiceAction(
installation: InstallationRef,
externalServiceId: string,
idempotencyKey: string,
): Promise<WhmcsServiceResponse> {
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
if (existingOp?.result) {
return existingOp.result as WhmcsServiceResponse;
}
const link = await this.findServiceLink(installation.id, externalServiceId);
const result = await this.serversService.stopServer(link.server.userId, link.server.id);
const response = this.toServiceResponse(
externalServiceId,
link.status,
result.server as unknown as GameServer,
);
await this.recordOperation(installation.id, idempotencyKey, 'service:stop', response, externalServiceId);
return response;
}
async restartServiceAction(
installation: InstallationRef,
externalServiceId: string,
idempotencyKey: string,
): Promise<WhmcsServiceResponse> {
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
if (existingOp?.result) {
return existingOp.result as WhmcsServiceResponse;
}
const link = await this.findServiceLink(installation.id, externalServiceId);
const result = await this.serversService.restartServer(link.server.userId, link.server.id);
const response = this.toServiceResponse(
externalServiceId,
link.status,
result.server as unknown as GameServer,
);
await this.recordOperation(installation.id, idempotencyKey, 'service:restart', response, externalServiceId);
return response;
}
async backupServiceAction(
installation: InstallationRef,
externalServiceId: string,
idempotencyKey: string,
): Promise<{ externalServiceId: string; jobId: string }> {
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
if (existingOp?.result) {
return existingOp.result as { externalServiceId: string; jobId: string };
}
const link = await this.findServiceLink(installation.id, externalServiceId);
const backup = await this.backupsService.createBackup(link.server.userId, link.server.id, {
label: 'WHMCS backup',
});
const response = {
externalServiceId,
jobId: backup.jobId ?? idempotencyKey,
};
await this.recordOperation(installation.id, idempotencyKey, 'service:backup', response, externalServiceId);
return response;
}
} }

View File

@@ -11,12 +11,14 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { validateConfig } from '@hexahost/config'; import { validateConfig } from '@hexahost/config';
import { initOpenTelemetry, shutdownOpenTelemetry } from '@hexahost/otel';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { Rfc7807ExceptionFilter } from './common/filters/rfc7807-exception.filter'; import { Rfc7807ExceptionFilter } from './common/filters/rfc7807-exception.filter';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const config = validateConfig(); const config = validateConfig();
initOpenTelemetry('hexahost-api');
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
AppModule, AppModule,

View File

@@ -167,6 +167,9 @@ export class NodesService {
case 'server.files.list.result': case 'server.files.list.result':
case 'server.files.read.result': case 'server.files.read.result':
case 'server.files.write.result': case 'server.files.write.result':
case 'server.files.delete.result':
case 'server.files.archive.result':
case 'server.files.unarchive.result':
case 'server.command.result': case 'server.command.result':
await this.handleDirectResult(envelope); await this.handleDirectResult(envelope);
break; break;

View File

@@ -0,0 +1,61 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { updateNotificationPreferencesSchema } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { NotificationsService } from './notifications.service';
@ApiTags('account')
@Controller('account/notifications')
@UseGuards(SessionGuard)
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
list(@CurrentUser() user: { id: string }) {
return this.notificationsService.list(user.id);
}
@Post(':id/read')
markRead(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.notificationsService.markRead(user.id, id);
}
@Post('read-all')
markAllRead(@CurrentUser() user: { id: string }) {
return this.notificationsService.markAllRead(user.id);
}
@Get('preferences')
getPreferences(@CurrentUser() user: { id: string }) {
return this.notificationsService.getPreferences(user.id);
}
@Patch('preferences')
@ApiOperation({ summary: 'Update notification channel preferences' })
updatePreferences(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(updateNotificationPreferencesSchema)) body: unknown,
) {
return this.notificationsService.updatePreferences(
user.id,
body as Parameters<NotificationsService['updatePreferences']>[1],
);
}
}

View File

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

View File

@@ -0,0 +1,166 @@
import { Inject, Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
import type {
NotificationListResponse,
UpdateNotificationPreferences,
} from '@hexahost/contracts';
import { NOTIFICATIONS_QUEUE } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
) {}
async list(userId: string): Promise<NotificationListResponse> {
const [notifications, unreadCount] = await Promise.all([
this.prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 100,
}),
this.prisma.notification.count({
where: { userId, readAt: null },
}),
]);
return {
unreadCount,
notifications: notifications.map((n) => ({
id: n.id,
type: n.type,
channel: n.channel,
title: n.title,
body: n.body,
readAt: n.readAt?.toISOString() ?? null,
createdAt: n.createdAt.toISOString(),
})),
};
}
async markRead(userId: string, notificationId: string) {
await this.prisma.notification.updateMany({
where: { id: notificationId, userId },
data: { readAt: new Date() },
});
return { read: true };
}
async markAllRead(userId: string) {
await this.prisma.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
return { read: true };
}
async getPreferences(userId: string): Promise<{
preferences: Array<{ type: string; channel: string; enabled: boolean }>;
}> {
const prefs = await this.prisma.notificationPreference.findMany({
where: { userId },
});
return {
preferences: prefs.map((p) => ({
type: p.type,
channel: p.channel,
enabled: p.enabled,
})),
};
}
async updatePreferences(
userId: string,
input: UpdateNotificationPreferences,
): Promise<{ preferences: Array<{ type: string; channel: string; enabled: boolean }> }> {
await this.prisma.$transaction(
input.preferences.map((pref) =>
this.prisma.notificationPreference.upsert({
where: {
userId_type_channel: {
userId,
type: pref.type,
channel: pref.channel,
},
},
create: {
userId,
type: pref.type,
channel: pref.channel,
enabled: pref.enabled,
},
update: { enabled: pref.enabled },
}),
),
);
return this.getPreferences(userId);
}
async createInApp(
userId: string,
input: { type: NotificationListResponse['notifications'][number]['type']; title: string; body: string },
): Promise<{ id: string }> {
const created = await this.prisma.notification.create({
data: {
userId,
type: input.type,
channel: 'IN_APP',
title: input.title,
body: input.body,
},
});
return { id: created.id };
}
async dispatch(
userId: string,
input: {
type: NotificationListResponse['notifications'][number]['type'];
title: string;
body: string;
metadata?: Record<string, unknown>;
},
): Promise<void> {
await this.createInApp(userId, input);
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (!user) {
return;
}
const prefs = await this.prisma.notificationPreference.findMany({
where: { userId, type: input.type, enabled: true },
});
const enabledChannels = new Set(prefs.map((p) => p.channel));
const channels = ['EMAIL', 'WEBHOOK', 'DISCORD'] as const;
for (const channel of channels) {
if (!enabledChannels.has(channel) && prefs.length > 0) {
continue;
}
await this.notificationsQueue.add(
'dispatch-notification',
{
userId,
type: input.type,
channel,
title: input.title,
body: input.body,
metadata: {
...input.metadata,
email: user.email,
},
},
{ removeOnComplete: true },
);
}
}
}

View File

@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
createOrganizationSchema,
inviteOrganizationMemberSchema,
} from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { OrganizationsService } from './organizations.service';
@ApiTags('organizations')
@Controller('organizations')
@UseGuards(SessionGuard)
export class OrganizationsController {
constructor(private readonly organizationsService: OrganizationsService) {}
@Get()
@ApiOperation({ summary: "List current user's organizations" })
list(@CurrentUser() user: { id: string }) {
return this.organizationsService.list(user.id);
}
@Post()
@ApiOperation({ summary: 'Create a new organization' })
create(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(createOrganizationSchema)) body: unknown,
) {
return this.organizationsService.create(
user.id,
body as Parameters<OrganizationsService['create']>[1],
);
}
@Get(':organizationId/members')
@ApiOperation({ summary: 'List organization members' })
listMembers(
@CurrentUser() user: { id: string },
@Param('organizationId', ParseUUIDPipe) organizationId: string,
) {
return this.organizationsService.listMembers(user.id, organizationId);
}
@Post(':organizationId/members')
@ApiOperation({ summary: 'Invite a member to the organization' })
inviteMember(
@CurrentUser() user: { id: string },
@Param('organizationId', ParseUUIDPipe) organizationId: string,
@Body(new ZodValidationPipe(inviteOrganizationMemberSchema)) body: unknown,
) {
return this.organizationsService.inviteMember(
user.id,
organizationId,
body as Parameters<OrganizationsService['inviteMember']>[2],
);
}
}

View File

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

View File

@@ -0,0 +1,217 @@
import { randomUUID } from 'node:crypto';
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateSecureToken, hashToken } from '@hexahost/auth';
import type {
CreateOrganization,
InviteOrganizationMember,
Organization,
OrganizationInviteResponse,
OrganizationListResponse,
OrganizationMemberListResponse,
} from '@hexahost/contracts';
import type {
Organization as OrganizationRow,
OrganizationMember,
OrganizationMemberRole,
} from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
const INVITE_TTL_DAYS = 7;
function slugifyName(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
function mapOrganization(
org: OrganizationRow,
role: OrganizationMemberRole,
): Organization {
return {
id: org.id,
name: org.name,
slug: org.slug,
role,
createdAt: org.createdAt.toISOString(),
};
}
@Injectable()
export class OrganizationsService {
constructor(private readonly prisma: PrismaService) {}
async list(userId: string): Promise<OrganizationListResponse> {
const memberships = await this.prisma.organizationMember.findMany({
where: { userId },
include: { organization: true },
orderBy: { createdAt: 'asc' },
});
return {
organizations: memberships.map((membership) =>
mapOrganization(membership.organization, membership.role),
),
};
}
async create(userId: string, input: CreateOrganization): Promise<Organization> {
const baseSlug = input.slug ?? slugifyName(input.name);
if (!baseSlug) {
throw new ConflictException('Unable to derive organization slug');
}
let slug = baseSlug;
for (let attempt = 0; attempt < 5; attempt += 1) {
const existing = await this.prisma.organization.findUnique({
where: { slug },
});
if (!existing) {
break;
}
slug = `${baseSlug}-${randomUUID().slice(0, 6)}`;
}
const organization = await this.prisma.$transaction(async (tx) => {
const created = await tx.organization.create({
data: {
name: input.name.trim(),
slug,
},
});
await tx.organizationMember.create({
data: {
organizationId: created.id,
userId,
role: 'OWNER',
},
});
return created;
});
return mapOrganization(organization, 'OWNER');
}
async listMembers(
userId: string,
organizationId: string,
): Promise<OrganizationMemberListResponse> {
await this.requireMember(organizationId, userId);
const members = await this.prisma.organizationMember.findMany({
where: { organizationId },
include: {
user: {
select: {
id: true,
email: true,
username: true,
displayName: true,
},
},
},
orderBy: { createdAt: 'asc' },
});
return {
members: members.map((member) => ({
id: member.id,
userId: member.userId,
role: member.role,
email: member.user.email,
username: member.user.username,
displayName: member.user.displayName,
createdAt: member.createdAt.toISOString(),
})),
};
}
async inviteMember(
userId: string,
organizationId: string,
input: InviteOrganizationMember,
): Promise<OrganizationInviteResponse | { memberId: string; userId: string; role: string }> {
const membership = await this.requireMember(organizationId, userId);
if (!['OWNER', 'ADMIN'].includes(membership.role)) {
throw new ForbiddenException('Only organization admins can invite members');
}
const email = input.email.toLowerCase();
const existingUser = await this.prisma.user.findUnique({ where: { email } });
if (existingUser) {
const member = await this.prisma.organizationMember.upsert({
where: {
organizationId_userId: {
organizationId,
userId: existingUser.id,
},
},
create: {
organizationId,
userId: existingUser.id,
role: input.role,
},
update: { role: input.role },
});
return {
memberId: member.id,
userId: member.userId,
role: member.role,
};
}
const token = generateSecureToken();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + INVITE_TTL_DAYS);
const invite = await this.prisma.organizationInvite.create({
data: {
organizationId,
email,
role: input.role,
tokenHash: hashToken(token),
invitedById: userId,
expiresAt,
},
});
return {
id: invite.id,
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt.toISOString(),
};
}
private async requireMember(
organizationId: string,
userId: string,
): Promise<OrganizationMember> {
const membership = await this.prisma.organizationMember.findUnique({
where: {
organizationId_userId: { organizationId, userId },
},
});
if (!membership) {
throw new NotFoundException('Organization not found');
}
return membership;
}
}

View File

@@ -1,9 +1,13 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpCode,
HttpStatus,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post,
Put, Put,
Query, Query,
UseGuards, UseGuards,
@@ -11,9 +15,13 @@ import {
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { import {
archiveFilesSchema,
filePathSchema,
listFilesQuerySchema, listFilesQuerySchema,
readFileContentQuerySchema, readFileContentQuerySchema,
unarchiveFileSchema,
updateFileContentSchema, updateFileContentSchema,
uploadFileSchema,
} from '@hexahost/contracts'; } from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator'; import { CurrentUser } from '../../auth/decorators/current-user.decorator';
@@ -30,7 +38,6 @@ export class FilesController {
@Get() @Get()
@ApiOperation({ summary: 'List files in the server data directory' }) @ApiOperation({ summary: 'List files in the server data directory' })
@ApiResponse({ status: 200, description: 'Directory listing' })
list( list(
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string, @Param('serverId', ParseUUIDPipe) serverId: string,
@@ -41,7 +48,6 @@ export class FilesController {
@Get('content') @Get('content')
@ApiOperation({ summary: 'Read a text file from the server' }) @ApiOperation({ summary: 'Read a text file from the server' })
@ApiResponse({ status: 200, description: 'File content' })
read( read(
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string, @Param('serverId', ParseUUIDPipe) serverId: string,
@@ -53,7 +59,6 @@ export class FilesController {
@Put('content') @Put('content')
@ApiOperation({ summary: 'Write a text file on the server' }) @ApiOperation({ summary: 'Write a text file on the server' })
@ApiResponse({ status: 200, description: 'Updated file content' })
write( write(
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string, @Param('serverId', ParseUUIDPipe) serverId: string,
@@ -65,4 +70,59 @@ export class FilesController {
body as Parameters<FilesService['writeFile']>[2], body as Parameters<FilesService['writeFile']>[2],
); );
} }
@Post('upload')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Upload a file (base64) to the server' })
upload(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(uploadFileSchema)) body: unknown,
) {
return this.filesService.uploadFile(
user.id,
serverId,
body as Parameters<FilesService['uploadFile']>[2],
);
}
@Delete()
@ApiOperation({ summary: 'Delete a file or directory' })
delete(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Query(new ZodValidationPipe(filePathSchema)) query: { path: string },
) {
return this.filesService.deleteFile(user.id, serverId, query.path);
}
@Post('archive')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a tar.gz archive from selected paths' })
archive(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(archiveFilesSchema)) body: unknown,
) {
return this.filesService.archiveFiles(
user.id,
serverId,
body as Parameters<FilesService['archiveFiles']>[2],
);
}
@Post('unarchive')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Extract a tar.gz archive on the server' })
unarchive(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(unarchiveFileSchema)) body: unknown,
) {
return this.filesService.unarchiveFile(
user.id,
serverId,
body as Parameters<FilesService['unarchiveFile']>[2],
);
}
} }

View File

@@ -5,9 +5,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
ArchiveFiles,
FileContent, FileContent,
FileListResponse, FileListResponse,
UnarchiveFile,
UpdateFileContent, UpdateFileContent,
UploadFile,
} from '@hexahost/contracts'; } from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database'; import type { GameServer } from '@hexahost/database';
@@ -19,6 +22,15 @@ import { assertSafeServerPath } from '../shared/server-path.util';
const FILE_ACCESS_STATUSES = new Set<GameServer['status']>(['RUNNING', 'STOPPED']); const FILE_ACCESS_STATUSES = new Set<GameServer['status']>(['RUNNING', 'STOPPED']);
interface AgentFileEntry {
name: string;
path: string;
isDir?: boolean;
type?: 'file' | 'directory';
size?: number;
modifiedAt?: string;
}
@Injectable() @Injectable()
export class FilesService { export class FilesService {
constructor( constructor(
@@ -51,7 +63,18 @@ export class FilesService {
); );
} }
return response.result as FileListResponse; const raw = response.result as { entries?: AgentFileEntry[] };
const entries = (raw.entries ?? []).map((entry) => ({
name: entry.name,
path: entry.path,
type: (entry.isDir ?? entry.type === 'directory' ? 'directory' : 'file') as
| 'file'
| 'directory',
size: entry.size,
modifiedAt: entry.modifiedAt,
}));
return { path: safePath, entries };
} }
async readFile( async readFile(
@@ -78,7 +101,12 @@ export class FilesService {
); );
} }
return response.result as FileContent; const raw = response.result as { path: string; content: string };
return {
path: raw.path,
content: raw.content,
encoding: 'utf-8',
};
} }
async writeFile( async writeFile(
@@ -98,6 +126,7 @@ export class FilesService {
path: safePath, path: safePath,
content: input.content, content: input.content,
encoding: input.encoding, encoding: input.encoding,
create: true,
}, },
); );
@@ -115,7 +144,140 @@ export class FilesService {
metadata: { path: safePath }, metadata: { path: safePath },
}); });
return response.result as FileContent; return {
path: safePath,
content: input.content,
encoding: input.encoding ?? 'utf-8',
};
}
async uploadFile(
userId: string,
serverId: string,
input: UploadFile,
): Promise<FileContent> {
const content =
input.encoding === 'base64'
? Buffer.from(input.content, 'base64').toString('utf-8')
: input.content;
return this.writeFile(userId, serverId, {
path: input.path,
content,
encoding: 'utf-8',
});
}
async deleteFile(userId: string, serverId: string, path: string): Promise<{ path: string }> {
const server = await this.requireFileAccess(userId, serverId);
const safePath = assertSafeServerPath(path);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.delete',
{
serverId,
generation: server.version,
path: safePath,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to delete file',
);
}
await this.auditService.record({
action: 'server.file.delete',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { path: safePath },
});
return { path: safePath };
}
async archiveFiles(
userId: string,
serverId: string,
input: ArchiveFiles,
): Promise<{ archivePath: string; sha256?: string; size?: number }> {
const server = await this.requireFileAccess(userId, serverId);
const paths = input.paths.map((p) => assertSafeServerPath(p));
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.archive',
{
serverId,
generation: server.version,
paths,
archiveName: input.archiveName,
},
120_000,
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to create archive',
);
}
const result = response.result as {
archivePath: string;
sha256?: string;
size?: number;
};
await this.auditService.record({
action: 'server.file.archive',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { paths, archivePath: result.archivePath },
});
return result;
}
async unarchiveFile(
userId: string,
serverId: string,
input: UnarchiveFile,
): Promise<{ destinationPath: string }> {
const server = await this.requireFileAccess(userId, serverId);
const archivePath = assertSafeServerPath(input.archivePath);
const destinationPath = assertSafeServerPath(input.destinationPath);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.unarchive',
{
serverId,
generation: server.version,
archivePath,
destinationPath,
},
120_000,
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to extract archive',
);
}
await this.auditService.record({
action: 'server.file.unarchive',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { archivePath, destinationPath },
});
return { destinationPath };
} }
private async requireFileAccess( private async requireFileAccess(

View File

@@ -0,0 +1,119 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { PrismaService } from '../../prisma/prisma.service';
import { findAccessibleServer } from '../shared/server-ownership.util';
const inviteSchema = z.object({
email: z.string().email(),
role: z.enum(['ADMIN', 'OPERATOR', 'DEVELOPER', 'VIEWER']).default('VIEWER'),
});
@ApiTags('servers')
@Controller('servers/:serverId/members')
@UseGuards(SessionGuard)
export class MembersController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: 'List server members' })
async list(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
): Promise<{
members: Array<{
id: string;
userId: string;
role: string;
email: string;
username: string;
displayName: string | null;
acceptedAt: string | null;
}>;
}> {
await findAccessibleServer(this.prisma, user.id, serverId, 'server.view');
const members = await this.prisma.gameServerMember.findMany({
where: { serverId },
include: {
user: { select: { id: true, email: true, username: true, displayName: true } },
},
orderBy: { createdAt: 'asc' },
});
return {
members: members.map((member) => ({
id: member.id,
userId: member.userId,
role: member.role,
email: member.user.email,
username: member.user.username,
displayName: member.user.displayName,
acceptedAt: member.acceptedAt?.toISOString() ?? null,
})),
};
}
@Post()
@ApiOperation({ summary: 'Invite a user to the server' })
async invite(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(inviteSchema)) body: unknown,
): Promise<{ status?: string; message?: string; id?: string; userId?: string; role?: string }> {
await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage');
const input = body as z.infer<typeof inviteSchema>;
const invitedUser = await this.prisma.user.findUnique({
where: { email: input.email.toLowerCase() },
});
if (!invitedUser) {
return { status: 'pending', message: 'User must register before accepting invite' };
}
const member = await this.prisma.gameServerMember.upsert({
where: {
serverId_userId: { serverId, userId: invitedUser.id },
},
create: {
serverId,
userId: invitedUser.id,
role: input.role,
acceptedAt: new Date(),
},
update: { role: input.role },
});
return { id: member.id, userId: member.userId, role: member.role };
}
@Delete(':memberId')
@ApiOperation({ summary: 'Remove a server member' })
async remove(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('memberId', ParseUUIDPipe) memberId: string,
) {
await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage');
await this.prisma.gameServerMember.deleteMany({
where: { id: memberId, serverId },
});
return { removed: true };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MembersController } from './members.controller';
@Module({
controllers: [MembersController],
})
export class MembersModule {}

View File

@@ -1,14 +1,27 @@
import { import {
Body,
Controller, Controller,
Delete,
Get, Get,
HttpCode,
HttpStatus,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
addBanSchema,
addOperatorSchema,
addWhitelistSchema,
removePlayerEntrySchema,
} from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator'; import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard'; import { SessionGuard } from '../../auth/guards/session.guard';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { PlayersService } from './players.service'; import { PlayersService } from './players.service';
@@ -19,12 +32,105 @@ export class PlayersController {
constructor(private readonly playersService: PlayersService) {} constructor(private readonly playersService: PlayersService) {}
@Get() @Get()
@ApiOperation({ summary: 'List online players, whitelist, and operators' }) @ApiOperation({ summary: 'List online players, whitelist, operators, and bans' })
@ApiResponse({ status: 200, description: 'Player overview' })
list( list(
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string, @Param('serverId', ParseUUIDPipe) serverId: string,
) { ) {
return this.playersService.listPlayers(user.id, serverId); return this.playersService.listPlayers(user.id, serverId);
} }
@Post('whitelist')
addWhitelist(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(addWhitelistSchema)) body: unknown,
) {
return this.playersService.addWhitelist(
user.id,
serverId,
body as Parameters<PlayersService['addWhitelist']>[2],
);
}
@Delete('whitelist')
removeWhitelist(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown,
) {
return this.playersService.removeWhitelist(
user.id,
serverId,
body as Parameters<PlayersService['removeWhitelist']>[2],
);
}
@Post('operators')
addOperator(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(addOperatorSchema)) body: unknown,
) {
return this.playersService.addOperator(
user.id,
serverId,
body as Parameters<PlayersService['addOperator']>[2],
);
}
@Delete('operators')
removeOperator(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown,
) {
return this.playersService.removeOperator(
user.id,
serverId,
body as Parameters<PlayersService['removeOperator']>[2],
);
}
@Post('bans')
addBan(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(addBanSchema)) body: unknown,
) {
return this.playersService.addBan(
user.id,
serverId,
body as Parameters<PlayersService['addBan']>[2],
);
}
@Delete('bans')
removeBan(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(addBanSchema.pick({ name: true }))) body: unknown,
) {
return this.playersService.removeBan(
user.id,
serverId,
body as { name: string },
);
}
@Post('kick')
@HttpCode(HttpStatus.ACCEPTED)
kick(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(addBanSchema.pick({ name: true }).extend({
reason: addBanSchema.shape.reason,
}))) body: unknown,
) {
return this.playersService.kickPlayer(
user.id,
serverId,
body as { name: string; reason?: string },
);
}
} }

View File

@@ -3,7 +3,13 @@ import {
Injectable, Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PlayerListResponse } from '@hexahost/contracts'; import type {
AddBan,
AddOperator,
AddWhitelist,
PlayerListResponse,
RemovePlayerEntry,
} from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database'; import type { GameServer } from '@hexahost/database';
import { NodeBridgeService } from '../../nodes/node-bridge.service'; import { NodeBridgeService } from '../../nodes/node-bridge.service';
@@ -13,6 +19,7 @@ import { findOwnedServer } from '../shared/server-ownership.util';
const WHITELIST_FILE = 'whitelist.json'; const WHITELIST_FILE = 'whitelist.json';
const OPS_FILE = 'ops.json'; const OPS_FILE = 'ops.json';
const BANS_FILE = 'banned-players.json';
@Injectable() @Injectable()
export class PlayersService { export class PlayersService {
@@ -32,17 +39,114 @@ export class PlayersService {
throw new BadRequestException('Server is not assigned to a node'); throw new BadRequestException('Server is not assigned to a node');
} }
const [online, whitelist, operators] = await Promise.all([ const [online, whitelist, operators, bans] = await Promise.all([
this.fetchOnlinePlayers(server), this.fetchOnlinePlayers(server),
this.readJsonFile(userId, serverId, WHITELIST_FILE, []), this.readJsonFile(userId, serverId, WHITELIST_FILE, []),
this.readJsonFile(userId, serverId, OPS_FILE, []), this.readJsonFile(userId, serverId, OPS_FILE, []),
this.readJsonFile(userId, serverId, BANS_FILE, []),
]); ]);
return { return { online, whitelist, operators, bans };
online, }
whitelist,
operators, async addWhitelist(userId: string, serverId: string, input: AddWhitelist) {
}; const list = await this.readJsonFile<Array<{ uuid: string; name: string }>>(
userId,
serverId,
WHITELIST_FILE,
[],
);
const next = list.filter((e) => e.uuid !== input.uuid);
next.push({ uuid: input.uuid, name: input.name });
await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next);
await this.runCommandIfRunning(userId, serverId, `whitelist add ${input.name}`);
return { whitelist: next };
}
async removeWhitelist(userId: string, serverId: string, input: RemovePlayerEntry) {
const list = await this.readJsonFile<Array<{ uuid: string; name: string }>>(
userId,
serverId,
WHITELIST_FILE,
[],
);
const entry = list.find((e) => e.uuid === input.uuid);
const next = list.filter((e) => e.uuid !== input.uuid);
await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next);
if (entry) {
await this.runCommandIfRunning(userId, serverId, `whitelist remove ${entry.name}`);
}
return { whitelist: next };
}
async addOperator(userId: string, serverId: string, input: AddOperator) {
const list = await this.readJsonFile<
Array<{ uuid: string; name: string; level?: number; bypassesPlayerLimit?: boolean }>
>(userId, serverId, OPS_FILE, []);
const next = list.filter((e) => e.uuid !== input.uuid);
next.push({
uuid: input.uuid,
name: input.name,
level: input.level,
bypassesPlayerLimit: input.bypassesPlayerLimit,
});
await this.writeJsonFile(userId, serverId, OPS_FILE, next);
await this.runCommandIfRunning(
userId,
serverId,
`op ${input.name}`,
);
return { operators: next };
}
async removeOperator(userId: string, serverId: string, input: RemovePlayerEntry) {
const list = await this.readJsonFile<Array<{ uuid: string; name: string }>>(
userId,
serverId,
OPS_FILE,
[],
);
const entry = list.find((e) => e.uuid === input.uuid);
const next = list.filter((e) => e.uuid !== input.uuid);
await this.writeJsonFile(userId, serverId, OPS_FILE, next);
if (entry) {
await this.runCommandIfRunning(userId, serverId, `deop ${entry.name}`);
}
return { operators: next };
}
async addBan(userId: string, serverId: string, input: AddBan) {
const list = await this.readJsonFile<
Array<{ uuid?: string; name: string; reason?: string; source?: string }>
>(userId, serverId, BANS_FILE, []);
const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase());
next.push({
name: input.name,
reason: input.reason ?? 'Banned by panel',
source: 'HexaHost Panel',
});
await this.writeJsonFile(userId, serverId, BANS_FILE, next);
await this.runCommandIfRunning(userId, serverId, `ban ${input.name}`);
return { bans: next };
}
async removeBan(userId: string, serverId: string, input: { name: string }) {
const list = await this.readJsonFile<Array<{ name: string }>>(
userId,
serverId,
BANS_FILE,
[],
);
const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase());
await this.writeJsonFile(userId, serverId, BANS_FILE, next);
await this.runCommandIfRunning(userId, serverId, `pardon ${input.name}`);
return { bans: next };
}
async kickPlayer(userId: string, serverId: string, input: { name: string; reason?: string }) {
const reason = input.reason ? ` ${input.reason}` : '';
await this.runCommandIfRunning(userId, serverId, `kick ${input.name}${reason}`);
return { kicked: input.name };
} }
private async fetchOnlinePlayers( private async fetchOnlinePlayers(
@@ -106,4 +210,39 @@ export class PlayersService {
return fallback; return fallback;
} }
} }
private async writeJsonFile<T>(
userId: string,
serverId: string,
path: string,
data: T,
): Promise<void> {
await this.filesService.writeFile(userId, serverId, {
path,
content: `${JSON.stringify(data, null, 2)}\n`,
encoding: 'utf-8',
});
}
private async runCommandIfRunning(
userId: string,
serverId: string,
command: string,
): Promise<void> {
const server = await findOwnedServer(this.prisma, userId, serverId);
if (server.status !== 'RUNNING' || !server.nodeId) {
return;
}
await this.nodeBridge.sendAgentRequest(
server.nodeId,
'server.command',
{
serverId,
generation: server.version,
command,
},
10_000,
);
}
} }

View File

@@ -0,0 +1,77 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { createScheduleSchema, updateScheduleSchema } from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { SchedulesService } from './schedules.service';
@ApiTags('servers')
@Controller('servers/:serverId/schedules')
@UseGuards(SessionGuard)
export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {}
@Get()
@ApiOperation({ summary: 'List server schedules' })
list(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
return this.schedulesService.list(user.id, serverId);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(createScheduleSchema)) body: unknown,
) {
return this.schedulesService.create(
user.id,
serverId,
body as Parameters<SchedulesService['create']>[2],
);
}
@Patch(':scheduleId')
update(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body(new ZodValidationPipe(updateScheduleSchema)) body: unknown,
) {
return this.schedulesService.update(
user.id,
serverId,
scheduleId,
body as Parameters<SchedulesService['update']>[3],
);
}
@Delete(':scheduleId')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
) {
await this.schedulesService.remove(user.id, serverId, scheduleId);
}
}

View File

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

View File

@@ -0,0 +1,150 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CronExpressionParser } from 'cron-parser';
import type {
CreateSchedule,
Schedule as ScheduleDto,
ScheduleListResponse,
UpdateSchedule,
} from '@hexahost/contracts';
import type { Prisma, Schedule, ScheduledTask } from '@hexahost/database';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
function mapSchedule(
schedule: Schedule & { tasks: ScheduledTask[] },
): ScheduleDto {
return {
id: schedule.id,
name: schedule.name,
timezone: schedule.timezone,
cronExpr: schedule.cronExpr,
enabled: schedule.enabled,
nextRunAt: schedule.nextRunAt?.toISOString() ?? null,
lastRunAt: schedule.lastRunAt?.toISOString() ?? null,
tasks: schedule.tasks.map((task) => ({
id: task.id,
taskType: task.taskType,
payload: (task.payload as Record<string, unknown> | null) ?? null,
})),
};
}
@Injectable()
export class SchedulesService {
constructor(private readonly prisma: PrismaService) {}
async list(userId: string, serverId: string): Promise<ScheduleListResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const schedules = await this.prisma.schedule.findMany({
where: { serverId },
include: { tasks: true },
orderBy: { createdAt: 'asc' },
});
return { schedules: schedules.map(mapSchedule) };
}
async create(userId: string, serverId: string, input: CreateSchedule) {
await findOwnedServer(this.prisma, userId, serverId);
const schedule = await this.prisma.schedule.create({
data: {
serverId,
name: input.name,
timezone: input.timezone,
cronExpr: input.cronExpr,
enabled: input.enabled,
nextRunAt: this.computeNextRun(input.cronExpr, input.timezone),
tasks: {
create: input.tasks.map((task) => ({
taskType: task.taskType,
payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined,
})),
},
},
include: { tasks: true },
});
return mapSchedule(schedule);
}
async update(
userId: string,
serverId: string,
scheduleId: string,
input: UpdateSchedule,
) {
await this.requireSchedule(userId, serverId, scheduleId);
const existing = await this.prisma.schedule.findUniqueOrThrow({
where: { id: scheduleId },
});
await this.prisma.$transaction(async (tx) => {
if (input.tasks) {
await tx.scheduledTask.deleteMany({ where: { scheduleId } });
await tx.scheduledTask.createMany({
data: input.tasks.map((task) => ({
scheduleId,
taskType: task.taskType,
payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined,
})),
});
}
await tx.schedule.update({
where: { id: scheduleId },
data: {
name: input.name,
timezone: input.timezone,
cronExpr: input.cronExpr,
enabled: input.enabled,
nextRunAt:
input.cronExpr !== undefined || input.timezone !== undefined
? this.computeNextRun(
input.cronExpr ?? existing.cronExpr,
input.timezone ?? existing.timezone,
)
: undefined,
},
});
});
const schedule = await this.prisma.schedule.findUniqueOrThrow({
where: { id: scheduleId },
include: { tasks: true },
});
return mapSchedule(schedule);
}
async remove(userId: string, serverId: string, scheduleId: string) {
await this.requireSchedule(userId, serverId, scheduleId);
await this.prisma.schedule.delete({ where: { id: scheduleId } });
return { deleted: true };
}
private async requireSchedule(userId: string, serverId: string, scheduleId: string) {
await findOwnedServer(this.prisma, userId, serverId);
const schedule = await this.prisma.schedule.findFirst({
where: { id: scheduleId, serverId },
});
if (!schedule) {
throw new NotFoundException('Schedule not found');
}
return schedule;
}
private computeNextRun(cronExpr: string, timezone = 'UTC'): Date {
try {
const interval = CronExpressionParser.parse(cronExpr, {
tz: timezone,
currentDate: new Date(),
});
return interval.next().toDate();
} catch {
throw new BadRequestException('Invalid cron expression or timezone');
}
}
}

View File

@@ -1,34 +1,15 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import type { GameServer } from '@hexahost/database'; import type { GameServer } from '@hexahost/database';
import {
type GameServerStatus = GameServer['status']; ALLOWED_TRANSITIONS,
IN_PROGRESS_STATUSES,
const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = { assertTransition,
DRAFT: ['PROVISIONING', 'DELETING'], isInProgress,
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'], resolveKillTarget,
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'], resolveStartTarget,
STOPPED: ['STARTING', 'QUEUED', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'], resolveStopTarget,
QUEUED: ['STARTING', 'STOPPED', 'DELETING'], type GameServerStatus,
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], } from '@hexahost/server-state';
RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'UNKNOWN', 'DELETING'],
STOPPING: ['STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'],
BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'],
RESTORING: ['STOPPED', 'ERROR'],
UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'],
ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'],
DELETING: ['DELETED'],
DELETED: [],
};
const IN_PROGRESS_STATUSES: GameServerStatus[] = [
'PROVISIONING',
'INSTALLING',
'STARTING',
'STOPPING',
'BACKING_UP',
'RESTORING',
'DELETING',
];
@Injectable() @Injectable()
export class ServerStateService { export class ServerStateService {
@@ -36,44 +17,56 @@ export class ServerStateService {
fromStatus: GameServerStatus, fromStatus: GameServerStatus,
toStatus: GameServerStatus, toStatus: GameServerStatus,
): void { ): void {
const allowed = ALLOWED_TRANSITIONS[fromStatus]; try {
assertTransition(fromStatus, toStatus);
if (!allowed.includes(toStatus)) { } catch (error) {
throw new BadRequestException( throw new BadRequestException(
`Invalid state transition from ${fromStatus} to ${toStatus}`, error instanceof Error ? error.message : 'Invalid state transition',
); );
} }
} }
assertNotInProgress(status: GameServerStatus): void { assertNotInProgress(status: GameServerStatus): void {
if (IN_PROGRESS_STATUSES.includes(status)) { if (isInProgress(status)) {
throw new BadRequestException('Server operation already in progress'); throw new BadRequestException('Server operation already in progress');
} }
} }
resolveStartTarget(status: GameServerStatus): GameServerStatus { resolveStartTarget(status: GameServerStatus): GameServerStatus {
switch (status) { try {
case 'DRAFT': return resolveStartTarget(status);
case 'ERROR': } catch (error) {
return 'PROVISIONING'; throw new BadRequestException(
case 'STOPPED': error instanceof Error ? error.message : 'Cannot start server',
case 'UNKNOWN': );
case 'QUEUED':
return 'STARTING';
default:
throw new BadRequestException(
`Cannot start server while in status ${status}`,
);
} }
} }
resolveStopTarget(status: GameServerStatus): GameServerStatus { resolveStopTarget(status: GameServerStatus): GameServerStatus {
if (status !== 'RUNNING') { try {
return resolveStopTarget(status);
} catch (error) {
throw new BadRequestException( throw new BadRequestException(
`Cannot stop server while in status ${status}`, error instanceof Error ? error.message : 'Cannot stop server',
); );
} }
}
return 'STOPPING'; resolveKillTarget(status: GameServerStatus): GameServerStatus {
try {
return resolveKillTarget(status);
} catch (error) {
throw new BadRequestException(
error instanceof Error ? error.message : 'Cannot kill server',
);
}
}
allowedTransitions(): typeof ALLOWED_TRANSITIONS {
return ALLOWED_TRANSITIONS;
}
inProgressStatuses(): GameServerStatus[] {
return IN_PROGRESS_STATUSES;
} }
} }

View File

@@ -1,18 +1,20 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch,
Post, Post,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { createServerRequestSchema } from '@hexahost/contracts'; import { createServerRequestSchema, updateServerRequestSchema } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard'; import { SessionGuard } from '../auth/guards/session.guard';
@@ -90,6 +92,40 @@ export class ServersController {
return this.serversService.restartServer(user.id, id, skip === 'true'); return this.serversService.restartServer(user.id, id, skip === 'true');
} }
@Post(':id/kill')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Force-kill a running game server' })
kill(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.killServer(user.id, id);
}
@Delete(':id')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Delete a game server' })
delete(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.deleteServer(user.id, id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update server metadata' })
patch(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(updateServerRequestSchema)) body: unknown,
) {
return this.serversService.updateServer(
user.id,
id,
body as Parameters<ServersService['updateServer']>[2],
);
}
@Get(':id/start-queue') @Get(':id/start-queue')
@ApiOperation({ summary: 'Get start queue status for a server' }) @ApiOperation({ summary: 'Get start queue status for a server' })
startQueue( startQueue(

View File

@@ -9,8 +9,12 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.module'; import { AppConfigModule } from '../config/app-config.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { FeatureFlagsModule } from '../feature-flags/feature-flags.module';
import { SchedulingModule } from '../scheduling/scheduling.module'; import { SchedulingModule } from '../scheduling/scheduling.module';
import { MembersModule } from './members/members.module';
import { SchedulesModule } from './schedules/schedules.module';
import { SoftwareModule } from './software/software.module';
import { ConsoleModule } from './console/console.module'; import { ConsoleModule } from './console/console.module';
import { AddonsModule } from './addons/addons.module'; import { AddonsModule } from './addons/addons.module';
import { BackupsModule } from './backups/backups.module'; import { BackupsModule } from './backups/backups.module';
@@ -34,6 +38,7 @@ import { PlansService } from './plans.service';
imports: [ imports: [
AppConfigModule, AppConfigModule,
AuthModule, AuthModule,
FeatureFlagsModule,
SchedulingModule, SchedulingModule,
BillingModule, BillingModule,
IdleModule, IdleModule,
@@ -44,6 +49,9 @@ import { PlansService } from './plans.service';
forwardRef(() => FilesModule), forwardRef(() => FilesModule),
forwardRef(() => PropertiesModule), forwardRef(() => PropertiesModule),
forwardRef(() => PlayersModule), forwardRef(() => PlayersModule),
forwardRef(() => MembersModule),
forwardRef(() => SchedulesModule),
forwardRef(() => SoftwareModule),
forwardRef(() => NodeBridgeModule), forwardRef(() => NodeBridgeModule),
], ],
controllers: [ServersController, PlansController], controllers: [ServersController, PlansController],
@@ -88,6 +96,7 @@ import { PlansService } from './plans.service';
exports: [ exports: [
ServersService, ServersService,
REDIS_CLIENT, REDIS_CLIENT,
SERVER_LIFECYCLE_QUEUE,
ConsoleModule, ConsoleModule,
NodeBridgeModule, NodeBridgeModule,
], ],

View File

@@ -5,6 +5,7 @@ import {
ForbiddenException, ForbiddenException,
Inject, Inject,
Injectable, Injectable,
ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import type Redis from 'ioredis'; import type Redis from 'ioredis';
@@ -16,10 +17,12 @@ import type {
ServerResponse, ServerResponse,
} from '@hexahost/contracts'; } from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database'; import type { GameServer } from '@hexahost/database';
import { FeatureFlagService } from '@hexahost/feature-flags';
import { formatJoinAddress } from '@hexahost/dns'; import { formatJoinAddress } from '@hexahost/dns';
type GameServerStatus = GameServer['status']; type GameServerStatus = GameServer['status'];
import { FEATURE_FLAG_SERVICE } from '../feature-flags/feature-flags.module';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { SchedulingService } from '../scheduling/scheduling.service'; import { SchedulingService } from '../scheduling/scheduling.service';
@@ -45,12 +48,14 @@ export class ServersService {
private readonly lifecycleQueue: Queue, private readonly lifecycleQueue: Queue,
@Inject(SERVER_PROVISIONING_QUEUE) @Inject(SERVER_PROVISIONING_QUEUE)
private readonly provisioningQueue: Queue, private readonly provisioningQueue: Queue,
@Inject(FEATURE_FLAG_SERVICE) private readonly featureFlags: FeatureFlagService,
) {} ) {}
async createServer( async createServer(
userId: string, userId: string,
input: CreateServerRequest, input: CreateServerRequest,
): Promise<ServerResponse> { ): Promise<ServerResponse> {
await this.assertMaintenanceAllowed('create');
await this.billing.assertCanCreateServer(userId, input.planId); await this.billing.assertCanCreateServer(userId, input.planId);
const server = await this.prisma.gameServer.create({ const server = await this.prisma.gameServer.create({
@@ -108,6 +113,7 @@ export class ServersService {
userId: string, userId: string,
serverId: string, serverId: string,
): Promise<ServerActionResponse> { ): Promise<ServerActionResponse> {
await this.assertMaintenanceAllowed('start');
const server = await findOwnedServer(this.prisma, userId, serverId); const server = await findOwnedServer(this.prisma, userId, serverId);
this.assertServerNotBillingSuspended(server); this.assertServerNotBillingSuspended(server);
this.serverState.assertNotInProgress(server.status); this.serverState.assertNotInProgress(server.status);
@@ -323,6 +329,180 @@ export class ServersService {
}; };
} }
async killServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.assertServerNotBillingSuspended(server);
const targetStatus = this.serverState.resolveKillTarget(server.status);
this.serverState.assertTransition(server.status, targetStatus);
const correlationId = randomUUID();
const updated = await this.transitionServer(
server,
targetStatus,
'kill requested',
userId,
correlationId,
);
const job = await this.lifecycleQueue.add(
'kill-server',
{ serverId, action: 'kill', correlationId },
{ jobId: correlationId },
);
if (updated.nodeId) {
await this.publishNodeCommand(updated.nodeId, {
protocolVersion: 1,
messageId: randomUUID(),
correlationId,
type: 'server.kill',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: updated.version,
},
});
}
return {
server: this.toResponse(updated),
jobId: job.id,
};
}
async deleteServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (server.status === 'RUNNING' || server.status === 'STARTING') {
throw new ConflictException('Stop the server before deleting it');
}
this.serverState.assertTransition(server.status, 'DELETING');
const correlationId = randomUUID();
const updated = await this.transitionServer(
server,
'DELETING',
'delete requested',
userId,
correlationId,
);
const job = await this.lifecycleQueue.add(
'delete-server',
{ serverId, action: 'delete', correlationId },
{ jobId: correlationId },
);
if (updated.nodeId) {
await this.publishNodeCommand(updated.nodeId, {
protocolVersion: 1,
messageId: randomUUID(),
correlationId,
type: 'server.delete',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: updated.version,
},
});
}
return {
server: this.toResponse(updated),
jobId: job.id,
};
}
async updateServer(
userId: string,
serverId: string,
input: { name?: string; idleShutdownEnabled?: boolean },
): Promise<ServerResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const updated = await this.prisma.gameServer.update({
where: { id: serverId },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.idleShutdownEnabled !== undefined
? { idleShutdownEnabled: input.idleShutdownEnabled }
: {}),
},
});
return this.toResponse(updated);
}
async reinstallServer(
userId: string,
serverId: string,
input: {
softwareFamily: string;
minecraftVersion: string;
createBackup?: boolean;
},
): Promise<{ serverId: string; jobId: string; status: string }> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (input.createBackup) {
await this.lifecycleQueue.add('backup-server', {
serverId,
type: 'MANUAL',
reason: 'pre-reinstall',
});
}
const correlationId = randomUUID();
const updated = await this.prisma.gameServer.update({
where: { id: serverId },
data: {
softwareFamily: input.softwareFamily as GameServer['softwareFamily'],
minecraftVersion: input.minecraftVersion,
status: 'PROVISIONING',
version: { increment: 1 },
},
});
await this.prisma.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: server.status,
toStatus: 'PROVISIONING',
reason: 'Software reinstall requested',
actorId: userId,
correlationId,
},
});
const job = await this.provisioningQueue.add(
'provision-server',
{
serverId,
correlationId,
reinstall: true,
softwareFamily: input.softwareFamily,
minecraftVersion: input.minecraftVersion,
},
{ jobId: correlationId },
);
return {
serverId,
jobId: job.id ?? correlationId,
status: updated.status,
};
}
async publishNodeCommand( async publishNodeCommand(
nodeId: string, nodeId: string,
envelope: Record<string, unknown>, envelope: Record<string, unknown>,
@@ -422,6 +602,15 @@ export class ServersService {
} }
} }
private async assertMaintenanceAllowed(action: string): Promise<void> {
const maintenance = await this.featureFlags.isEnabled('maintenance_mode');
if (maintenance) {
throw new ServiceUnavailableException(
`Platform maintenance mode is active; cannot ${action} servers`,
);
}
}
private async recordTransition( private async recordTransition(
gameServerId: string, gameServerId: string,
fromStatus: GameServerStatus | null, fromStatus: GameServerStatus | null,

View File

@@ -3,25 +3,75 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { GameServer } from '@hexahost/database'; import type { GameServer } from '@hexahost/database';
import {
assertPermission,
type ServerMemberRole,
type ServerPermission,
} from '@hexahost/permissions';
import type { PrismaService } from '../../prisma/prisma.service'; import type { PrismaService } from '../../prisma/prisma.service';
export interface ServerAccessContext {
server: GameServer;
role: ServerMemberRole;
permissionOverrides: ServerPermission[] | null;
}
export async function findOwnedServer( export async function findOwnedServer(
prisma: PrismaService, prisma: PrismaService,
userId: string, userId: string,
serverId: string, serverId: string,
): Promise<GameServer> { ): Promise<GameServer> {
const access = await findAccessibleServer(prisma, userId, serverId, 'server.view');
return access.server;
}
export async function findAccessibleServer(
prisma: PrismaService,
userId: string,
serverId: string,
permission: ServerPermission,
): Promise<ServerAccessContext> {
const server = await prisma.gameServer.findUnique({ const server = await prisma.gameServer.findUnique({
where: { id: serverId }, where: { id: serverId },
include: {
members: {
where: { userId },
},
},
}); });
if (!server) { if (!server) {
throw new NotFoundException('Server not found'); throw new NotFoundException('Server not found');
} }
if (server.userId !== userId) { let role: ServerMemberRole;
throw new ForbiddenException('You do not own this server'); let overrides: ServerPermission[] | null = null;
if (server.userId === userId) {
role = 'OWNER';
} else {
const membership = server.members[0];
if (!membership) {
throw new ForbiddenException('You do not have access to this server');
}
role = membership.role as ServerMemberRole;
overrides = Array.isArray(membership.permissions)
? (membership.permissions as ServerPermission[])
: null;
} }
return server; try {
assertPermission(role, permission, overrides);
} catch {
throw new ForbiddenException(`Missing permission: ${permission}`);
}
return {
server,
role,
permissionOverrides: overrides,
};
} }

View File

@@ -0,0 +1,40 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { reinstallServerSchema } from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { ServersService } from '../servers.service';
@ApiTags('servers')
@Controller('servers/:serverId/software')
@UseGuards(SessionGuard)
export class SoftwareController {
constructor(private readonly serversService: ServersService) {}
@Post('reinstall')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Reinstall server software' })
reinstall(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(reinstallServerSchema)) body: unknown,
) {
return this.serversService.reinstallServer(
user.id,
serverId,
body as Parameters<ServersService['reinstallServer']>[2],
);
}
}

View File

@@ -0,0 +1,12 @@
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { ServersModule } from '../servers.module';
import { SoftwareController } from './software.controller';
@Module({
imports: [AuthModule, forwardRef(() => ServersModule)],
controllers: [SoftwareController],
})
export class SoftwareModule {}

View File

@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
addSupportTicketMessageSchema,
createSupportTicketSchema,
} from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { SupportService } from './support.service';
@ApiTags('support')
@Controller('support/tickets')
@UseGuards(SessionGuard)
export class SupportController {
constructor(private readonly supportService: SupportService) {}
@Post()
@ApiOperation({ summary: 'Create a support ticket' })
create(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(createSupportTicketSchema)) body: unknown,
) {
return this.supportService.create(
user.id,
body as Parameters<SupportService['create']>[1],
);
}
@Get()
@ApiOperation({ summary: "List current user's support tickets" })
list(@CurrentUser() user: { id: string }) {
return this.supportService.list(user.id);
}
@Get(':ticketId')
@ApiOperation({ summary: 'Get a support ticket with messages' })
get(
@CurrentUser() user: { id: string },
@Param('ticketId', ParseUUIDPipe) ticketId: string,
) {
return this.supportService.get(user.id, ticketId);
}
@Post(':ticketId/messages')
@ApiOperation({ summary: 'Add a message to a support ticket' })
addMessage(
@CurrentUser() user: { id: string },
@Param('ticketId', ParseUUIDPipe) ticketId: string,
@Body(new ZodValidationPipe(addSupportTicketMessageSchema)) body: unknown,
) {
return this.supportService.addMessage(
user.id,
ticketId,
body as Parameters<SupportService['addMessage']>[2],
);
}
}

View File

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

View File

@@ -0,0 +1,143 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
AddSupportTicketMessage,
CreateSupportTicket,
SupportTicket,
SupportTicketListResponse,
} from '@hexahost/contracts';
import type {
SupportTicket as SupportTicketRow,
SupportTicketMessage,
} from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
function mapTicket(
ticket: SupportTicketRow & { messages?: SupportTicketMessage[] },
includeMessages = false,
): SupportTicket {
return {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
serverId: ticket.serverId,
createdAt: ticket.createdAt.toISOString(),
updatedAt: ticket.updatedAt.toISOString(),
...(includeMessages && ticket.messages
? {
messages: ticket.messages.map((message) => ({
id: message.id,
authorId: message.authorId,
isInternal: message.isInternal,
body: message.body,
createdAt: message.createdAt.toISOString(),
})),
}
: {}),
};
}
@Injectable()
export class SupportService {
constructor(private readonly prisma: PrismaService) {}
async create(userId: string, input: CreateSupportTicket): Promise<SupportTicket> {
if (input.serverId) {
const server = await this.prisma.gameServer.findFirst({
where: { id: input.serverId, userId },
});
if (!server) {
throw new ForbiddenException('Server not found or not owned by user');
}
}
const ticket = await this.prisma.supportTicket.create({
data: {
userId,
subject: input.subject.trim(),
priority: input.priority,
serverId: input.serverId,
messages: {
create: {
authorId: userId,
body: input.body.trim(),
},
},
},
include: { messages: true },
});
return mapTicket(ticket, true);
}
async list(userId: string): Promise<SupportTicketListResponse> {
const tickets = await this.prisma.supportTicket.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
take: 100,
});
return { tickets: tickets.map((ticket) => mapTicket(ticket)) };
}
async get(userId: string, ticketId: string): Promise<SupportTicket> {
const ticket = await this.prisma.supportTicket.findFirst({
where: { id: ticketId, userId },
include: {
messages: {
where: { isInternal: false },
orderBy: { createdAt: 'asc' },
},
},
});
if (!ticket) {
throw new NotFoundException('Support ticket not found');
}
return mapTicket(ticket, true);
}
async addMessage(
userId: string,
ticketId: string,
input: AddSupportTicketMessage,
): Promise<SupportTicket> {
const ticket = await this.prisma.supportTicket.findFirst({
where: { id: ticketId, userId },
});
if (!ticket) {
throw new NotFoundException('Support ticket not found');
}
if (ticket.status === 'CLOSED' || ticket.status === 'RESOLVED') {
throw new ForbiddenException('Cannot add messages to a closed ticket');
}
await this.prisma.$transaction([
this.prisma.supportTicketMessage.create({
data: {
ticketId,
authorId: userId,
body: input.body.trim(),
},
}),
this.prisma.supportTicket.update({
where: { id: ticketId },
data: {
status: ticket.status === 'WAITING' ? 'OPEN' : ticket.status,
updatedAt: new Date(),
},
}),
]);
return this.get(userId, ticketId);
}
}

28
apps/docs/README.md Normal file
View File

@@ -0,0 +1,28 @@
# HexaHost GameCloud — static docs site
Minimal static documentation hub with links to the live API docs and key product pages.
## Serve locally
From the repository root:
```bash
pnpm install
pnpm --filter @hexahost/docs dev
```
Or from this directory:
```bash
pnpm install
pnpm dev
```
The site is served at http://localhost:4000.
## Prerequisites for linked resources
- **Web app** (`http://localhost:3000`) — run `pnpm --filter @hexahost/web dev`
- **API Swagger** (`http://localhost:3001/api/v1/docs`) — run `pnpm --filter @hexahost/api dev`
Repository markdown under `docs/` is referenced by path; open those files in your editor or on GitHub.

130
apps/docs/index.html Normal file
View File

@@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HexaHost GameCloud Documentation</title>
<style>
:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
body {
margin: 0;
background: #fafafa;
color: #18181b;
}
@media (prefers-color-scheme: dark) {
body {
background: #09090b;
color: #fafafa;
}
}
main {
max-width: 48rem;
margin: 0 auto;
padding: 3rem 1.5rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
}
p.lead {
margin: 0 0 2rem;
color: #52525b;
}
@media (prefers-color-scheme: dark) {
p.lead {
color: #a1a1aa;
}
}
section {
margin-bottom: 2rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.125rem;
font-weight: 600;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li + li {
margin-top: 0.5rem;
}
a {
color: #0284c7;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
font-family: ui-monospace, monospace;
font-size: 0.875em;
}
</style>
</head>
<body>
<main>
<h1>HexaHost GameCloud</h1>
<p class="lead">Developer documentation hub for the GameCloud control plane.</p>
<section>
<h2>API</h2>
<ul>
<li>
<a href="http://localhost:3001/api/v1/docs">Interactive API docs (Swagger)</a>
— OpenAPI UI when the API is running locally
</li>
<li>
<a href="http://localhost:3001/api/v1/docs-json">OpenAPI JSON spec</a>
</li>
</ul>
</section>
<section>
<h2>Key features</h2>
<ul>
<li>
<a href="http://localhost:3000/features">Product features overview</a>
— server control, nodes, security, billing, backups, WHMCS
</li>
<li>
<a href="http://localhost:3000/pricing">Pricing</a>
</li>
<li>
<a href="http://localhost:3000/status">Platform status</a>
</li>
</ul>
</section>
<section>
<h2>Repository docs</h2>
<ul>
<li><code>docs/api/README.md</code> — API authentication and versioning</li>
<li><code>docs/operations/</code> — backups, upgrades, node drain, disaster recovery</li>
<li><code>docs/integrations/whmcs/</code> — WHMCS module installation and security</li>
<li><code>docs/security/</code> — container isolation, secrets, data retention</li>
</ul>
</section>
</main>
</body>
</html>

14
apps/docs/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@hexahost/docs",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "serve . -p 4000",
"start": "serve . -p 4000",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "node -e \"process.exit(0)\""
},
"devDependencies": {
"serve": "^14.2.4"
}
}

18
apps/e2e/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/e2e",
"version": "0.0.1",
"private": true,
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"@playwright/test": "^1.51.1",
"@types/node": "^22.13.10",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,22 @@
import { defineConfig, devices } from "@playwright/test";
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: appUrl,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

View File

@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test";
test("home page loads", async ({ page }) => {
const response = await page.goto("/");
expect(response?.ok()).toBeTruthy();
await expect(page.locator("h1")).toBeVisible();
});
test("login page loads", async ({ page }) => {
const response = await page.goto("/login");
expect(response?.ok()).toBeTruthy();
await expect(page.getByRole("heading", { name: /Anmelden|Sign in|Log in/i })).toBeVisible();
await expect(page.getByLabel(/E-Mail|Email/i)).toBeVisible();
await expect(page.getByLabel(/^Passwort$|^Password$/i)).toBeVisible();
});
test("register page loads", async ({ page }) => {
const response = await page.goto("/register");
expect(response?.ok()).toBeTruthy();
await expect(
page.getByRole("heading", { name: /Konto erstellen|Create account/i }),
).toBeVisible();
await expect(page.getByLabel(/E-Mail|Email/i)).toBeVisible();
await expect(page.getByLabel(/^Passwort$|^Password$/i)).toBeVisible();
});
test("docs page loads", async ({ page }) => {
const response = await page.goto("/docs");
expect(response?.ok()).toBeTruthy();
await expect(
page.getByRole("heading", { name: /Dokumentation|Documentation/i }),
).toBeVisible();
});
test("API health if reachable", async ({ request }) => {
const apiUrl = process.env.API_URL ?? "http://localhost:3001";
let response;
try {
response = await request.get(`${apiUrl}/api/v1/health/live`, { timeout: 3000 });
} catch {
test.info().annotations.push({
type: "skip-reason",
description: "API not reachable",
});
return;
}
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { status?: string };
expect(body.status).toBe("ok");
});

10
apps/e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"types": ["node", "@playwright/test"]
},
"include": ["**/*.ts"]
}

View File

@@ -95,6 +95,99 @@ func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, er
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
} }
// CreateTarGzFromPaths archives one or more paths under dataRoot into outputPath.
func CreateTarGzFromPaths(dataRoot string, relativePaths []string, outputPath string) (sha256sum string, size int64, err error) {
dataRoot = filepath.Clean(dataRoot)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return "", 0, err
}
file, err := os.Create(outputPath)
if err != nil {
return "", 0, err
}
defer file.Close()
hasher := sha256.New()
writer := io.MultiWriter(file, hasher)
gzipWriter := gzip.NewWriter(writer)
tarWriter := tar.NewWriter(gzipWriter)
for _, rel := range relativePaths {
abs := filepath.Join(dataRoot, filepath.FromSlash(rel))
abs = filepath.Clean(abs)
if !strings.HasPrefix(abs, dataRoot+string(os.PathSeparator)) && abs != dataRoot {
return "", 0, fmt.Errorf("path escapes data root: %s", rel)
}
info, err := os.Stat(abs)
if err != nil {
return "", 0, err
}
baseName := filepath.Base(abs)
if info.IsDir() {
err = filepath.Walk(abs, func(path string, entry os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
innerRel, err := filepath.Rel(abs, path)
if err != nil {
return err
}
if innerRel == "." {
return nil
}
headerName := filepath.ToSlash(filepath.Join(baseName, innerRel))
return writeTarEntry(tarWriter, path, entry, headerName)
})
if err != nil {
return "", 0, err
}
continue
}
if err := writeTarEntry(tarWriter, abs, info, baseName); err != nil {
return "", 0, err
}
}
if err := tarWriter.Close(); err != nil {
return "", 0, err
}
if err := gzipWriter.Close(); err != nil {
return "", 0, err
}
stat, err := file.Stat()
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
}
func writeTarEntry(tarWriter *tar.Writer, path string, entry os.FileInfo, headerName string) error {
header, err := tar.FileInfoHeader(entry, "")
if err != nil {
return err
}
header.Name = headerName
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if entry.IsDir() {
return nil
}
sourceFile, err := os.Open(path)
if err != nil {
return err
}
defer sourceFile.Close()
_, err = io.Copy(tarWriter, sourceFile)
return err
}
// ExtractTarGz extracts archivePath into destDir. // ExtractTarGz extracts archivePath into destDir.
func ExtractTarGz(archivePath, destDir string) error { func ExtractTarGz(archivePath, destDir string) error {
destDir = filepath.Clean(destDir) destDir = filepath.Clean(destDir)

View File

@@ -113,9 +113,19 @@ func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServer
Target: "/data", Target: "/data",
}}, }},
Resources: container.Resources{ Resources: container.Resources{
Memory: int64(spec.RamMb) * 1024 * 1024, Memory: int64(spec.RamMb) * 1024 * 1024,
PidsLimit: ptrInt64(256),
},
Privileged: false,
CapDrop: []string{"ALL"},
SecurityOpt: []string{"no-new-privileges:true"},
LogConfig: container.LogConfig{
Type: "json-file",
Config: map[string]string{
"max-size": "10m",
"max-file": "3",
},
}, },
Privileged: false,
}, },
nil, nil,
nil, nil,
@@ -198,6 +208,10 @@ func mapSoftwareFamily(family string) string {
} }
} }
func ptrInt64(value int64) *int64 {
return &value
}
// StreamLogs streams container logs and invokes callback for each line. // StreamLogs streams container logs and invokes callback for each line.
func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error { func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error {
opts := container.LogsOptions{ opts := container.LogsOptions{

View File

@@ -229,6 +229,31 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
return nil return nil
} }
// RemovePath deletes a file or directory relative to dataRoot.
func RemovePath(dataRoot, relativePath string) error {
target, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return err
}
if err := rejectSymlinks(dataRoot, target); err != nil {
return err
}
info, err := os.Lstat(target)
if err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return err
}
if info.IsDir() {
return os.RemoveAll(target)
}
return os.Remove(target)
}
// DeleteFile removes a file relative to dataRoot. // DeleteFile removes a file relative to dataRoot.
func DeleteFile(dataRoot, relativePath string) error { func DeleteFile(dataRoot, relativePath string) error {
filePath, err := ResolvePath(dataRoot, relativePath) filePath, err := ResolvePath(dataRoot, relativePath)

View File

@@ -38,6 +38,9 @@ const (
TypeServerFilesList MessageType = "server.files.list" TypeServerFilesList MessageType = "server.files.list"
TypeServerFilesRead MessageType = "server.files.read" TypeServerFilesRead MessageType = "server.files.read"
TypeServerFilesWrite MessageType = "server.files.write" TypeServerFilesWrite MessageType = "server.files.write"
TypeServerFilesDelete MessageType = "server.files.delete"
TypeServerFilesArchive MessageType = "server.files.archive"
TypeServerFilesUnarchive MessageType = "server.files.unarchive"
TypeServerFilesUploadDone MessageType = "server.files.upload.complete" TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
TypeServerWorldValidate MessageType = "server.world.validate" TypeServerWorldValidate MessageType = "server.world.validate"
TypeServerWorldArchive MessageType = "server.world.archive" TypeServerWorldArchive MessageType = "server.world.archive"
@@ -76,6 +79,9 @@ var controlToAgentTypes = map[MessageType]struct{}{
TypeServerFilesList: {}, TypeServerFilesList: {},
TypeServerFilesRead: {}, TypeServerFilesRead: {},
TypeServerFilesWrite: {}, TypeServerFilesWrite: {},
TypeServerFilesDelete: {},
TypeServerFilesArchive: {},
TypeServerFilesUnarchive: {},
TypeServerFilesUploadDone: {}, TypeServerFilesUploadDone: {},
TypeServerWorldValidate: {}, TypeServerWorldValidate: {},
TypeServerWorldArchive: {}, TypeServerWorldArchive: {},
@@ -185,9 +191,30 @@ type ServerFilesReadPayload struct {
// ServerFilesWritePayload writes content to a file in the server data directory. // ServerFilesWritePayload writes content to a file in the server data directory.
type ServerFilesWritePayload struct { type ServerFilesWritePayload struct {
OperationMeta OperationMeta
Path string `json:"path"` Path string `json:"path"`
Content string `json:"content"` Content string `json:"content"`
Create bool `json:"create"` Create bool `json:"create"`
Encoding string `json:"encoding,omitempty"`
}
// ServerFilesDeletePayload deletes a file or directory relative to data root.
type ServerFilesDeletePayload struct {
OperationMeta
Path string `json:"path"`
}
// ServerFilesArchivePayload creates a tar.gz archive from paths.
type ServerFilesArchivePayload struct {
OperationMeta
Paths []string `json:"paths"`
ArchiveName string `json:"archiveName,omitempty"`
}
// ServerFilesUnarchivePayload extracts a tar.gz archive.
type ServerFilesUnarchivePayload struct {
OperationMeta
ArchivePath string `json:"archivePath"`
DestinationPath string `json:"destinationPath"`
} }
// ServerWorldValidatePayload validates a Minecraft world directory. // ServerWorldValidatePayload validates a Minecraft world directory.

View File

@@ -4,10 +4,13 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time"
"github.com/hexahost/gamecloud/node-agent/internal/archive" "github.com/hexahost/gamecloud/node-agent/internal/archive"
"github.com/hexahost/gamecloud/node-agent/internal/docker" "github.com/hexahost/gamecloud/node-agent/internal/docker"
@@ -357,6 +360,88 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa
}) })
} }
// HandleFilesDelete removes a file or directory from the server data directory.
func (m *Manager) HandleFilesDelete(ctx context.Context, correlationID string, payload protocol.ServerFilesDeletePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.delete", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if err := files.RemovePath(rec.DataPath, payload.Path); err != nil {
m.failOperation(ctx, correlationID, meta, "files.delete", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.delete", map[string]string{
"path": payload.Path,
})
}
// HandleFilesArchive creates a tar.gz archive from selected paths.
func (m *Manager) HandleFilesArchive(ctx context.Context, correlationID string, payload protocol.ServerFilesArchivePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
archiveName := payload.ArchiveName
if archiveName == "" {
archiveName = fmt.Sprintf("archive-%d.tar.gz", time.Now().Unix())
}
if !strings.HasSuffix(archiveName, ".tar.gz") {
archiveName += ".tar.gz"
}
outputPath := filepath.Join(rec.DataPath, ".hexahost-archives", archiveName)
checksum, size, err := archive.CreateTarGzFromPaths(rec.DataPath, payload.Paths, outputPath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.archive", "ARCHIVE_FAILED", err.Error())
return
}
rel, _ := filepath.Rel(rec.DataPath, outputPath)
m.completeOperation(ctx, correlationID, meta, "files.archive", map[string]any{
"archivePath": filepath.ToSlash(rel),
"sha256": checksum,
"size": size,
})
}
// HandleFilesUnarchive extracts a tar.gz archive into a destination path.
func (m *Manager) HandleFilesUnarchive(ctx context.Context, correlationID string, payload protocol.ServerFilesUnarchivePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.unarchive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
archiveAbs, err := files.ResolvePath(rec.DataPath, payload.ArchivePath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error())
return
}
destAbs, err := files.ResolvePath(rec.DataPath, payload.DestinationPath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error())
return
}
if err := archive.ExtractTarGz(archiveAbs, destAbs); err != nil {
m.failOperation(ctx, correlationID, meta, "files.unarchive", "UNARCHIVE_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.unarchive", map[string]string{
"destinationPath": payload.DestinationPath,
})
}
// HandleBackupPrepare runs save-off and save-all flush via RCON. // HandleBackupPrepare runs save-off and save-all flush via RCON.
func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) { func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
rec, ok := m.getServer(meta.ServerID) rec, ok := m.getServer(meta.ServerID)

View File

@@ -314,6 +314,30 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
} }
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload) c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
case protocol.TypeServerFilesDelete:
var payload protocol.ServerFilesDeletePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.delete", "error", err)
return
}
c.runtime.HandleFilesDelete(ctx, env.MessageID, payload)
case protocol.TypeServerFilesArchive:
var payload protocol.ServerFilesArchivePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.archive", "error", err)
return
}
c.runtime.HandleFilesArchive(ctx, env.MessageID, payload)
case protocol.TypeServerFilesUnarchive:
var payload protocol.ServerFilesUnarchivePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.unarchive", "error", err)
return
}
c.runtime.HandleFilesUnarchive(ctx, env.MessageID, payload)
case protocol.TypeServerBackupPrepare: case protocol.TypeServerBackupPrepare:
var meta protocol.OperationMeta var meta protocol.OperationMeta
if err := json.Unmarshal(env.Payload, &meta); err != nil { if err := json.Unmarshal(env.Payload, &meta); err != nil {

View File

@@ -15,10 +15,19 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"back": "Zurück", "back": "Zurück",
"footer": "Selbstgehostete Minecraft-Server-Plattform.", "footer": "Selbstgehostete Minecraft-Server-Plattform.",
"servers": "Server" "servers": "Server",
"features": "Funktionen",
"pricing": "Preise",
"docs": "Dokumentation",
"status": "Status",
"impressum": "Impressum",
"privacy": "Datenschutz",
"terms": "AGB",
"admin": "Admin",
"phaseBadge": "MVP · Phasen 09"
}, },
"landing": { "landing": {
"badge": "Phase 0 · Control Plane", "badge": "MVP · Phase 9 abgeschlossen",
"title": "Minecraft-Server on demand", "title": "Minecraft-Server on demand",
"subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.", "subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.",
"ctaPrimary": "Jetzt starten", "ctaPrimary": "Jetzt starten",
@@ -205,7 +214,36 @@
"players": "Spieler", "players": "Spieler",
"worlds": "Welten", "worlds": "Welten",
"backups": "Backups", "backups": "Backups",
"addons": "Add-ons" "addons": "Add-ons",
"software": "Software",
"schedules": "Zeitpläne",
"access": "Zugriffe"
},
"wizard": {
"stepsLabel": "Erstellungsschritte",
"steps": {
"basics": "Grundlagen",
"software": "Software",
"review": "Prüfen"
},
"next": "Weiter",
"back": "Zurück"
},
"software": {
"title": "Software",
"description": "Installierte Server-Software und Version.",
"loading": "Wird geladen…",
"loadError": "Software-Informationen konnten nicht geladen werden.",
"family": "Software-Familie",
"version": "Minecraft-Version",
"edition": "Edition",
"changeHint": "Software-Wechsel wird in einer zukünftigen Version unterstützt.",
"reinstall": "Software neu installieren",
"reinstalling": "Wird neu installiert…",
"reinstallConfirm": "Server-Software wirklich neu installieren? Zuerst wird ein Backup erstellt.",
"reinstallStarted": "Neuinstallation gestartet. Der Server ist während der Installation nicht verfügbar.",
"reinstallError": "Neuinstallation konnte nicht gestartet werden.",
"reinstallStoppedHint": "Stoppen Sie den Server, bevor Sie die Software neu installieren."
}, },
"console": { "console": {
"loading": "Wird geladen…", "loading": "Wird geladen…",
@@ -233,10 +271,27 @@
"save": "Speichern", "save": "Speichern",
"saving": "Wird gespeichert…", "saving": "Wird gespeichert…",
"saved": "Datei gespeichert.", "saved": "Datei gespeichert.",
"upload": "Hochladen",
"uploading": "Wird hochgeladen…",
"uploaded": "Datei hochgeladen.",
"delete": "Löschen",
"deleted": "Gelöscht.",
"deleteConfirm": "{name} wirklich löschen?",
"archive": "Auswahl archivieren",
"archiving": "Wird archiviert…",
"archived": "Archiv erstellt.",
"unarchive": "Archiv entpacken",
"unarchiving": "Wird entpackt…",
"unarchived": "Archiv-Entpackung gestartet.",
"unarchiveConfirm": "{name} ins aktuelle Verzeichnis entpacken?",
"errors": { "errors": {
"loadFailed": "Verzeichnis konnte nicht geladen werden.", "loadFailed": "Verzeichnis konnte nicht geladen werden.",
"readFailed": "Datei konnte nicht gelesen werden.", "readFailed": "Datei konnte nicht gelesen werden.",
"saveFailed": "Datei konnte nicht gespeichert werden." "saveFailed": "Datei konnte nicht gespeichert werden.",
"deleteFailed": "Datei konnte nicht gelöscht werden.",
"uploadFailed": "Datei konnte nicht hochgeladen werden.",
"archiveFailed": "Archiv konnte nicht erstellt werden.",
"unarchiveFailed": "Archiv konnte nicht entpackt werden."
} }
}, },
"properties": { "properties": {
@@ -280,14 +335,32 @@
"loading": "Wird geladen…", "loading": "Wird geladen…",
"onlineCount": "Spieler online", "onlineCount": "Spieler online",
"whitelistCount": "Whitelist-Einträge", "whitelistCount": "Whitelist-Einträge",
"operatorsCount": "Operatoren",
"onlineTitle": "Spieler online",
"whitelistTitle": "Whitelist",
"operatorsTitle": "Operatoren",
"bansTitle": "Gebannte Spieler",
"bansEmpty": "Keine gebannten Spieler.",
"empty": "Keine Spieler online.", "empty": "Keine Spieler online.",
"retry": "Erneut versuchen", "retry": "Erneut versuchen",
"kick": "Kicken",
"addWhitelist": "Zur Whitelist hinzufügen",
"addOperator": "Operator hinzufügen",
"addBan": "Spieler bannen",
"remove": "Entfernen",
"fields": {
"name": "Spielername",
"uuid": "UUID",
"reason": "Grund"
},
"columns": { "columns": {
"name": "Spieler", "name": "Spieler",
"ping": "Ping" "ping": "Ping",
"actions": "Aktionen"
}, },
"errors": { "errors": {
"loadFailed": "Spielerliste konnte nicht geladen werden." "loadFailed": "Spielerliste konnte nicht geladen werden.",
"actionFailed": "Aktion fehlgeschlagen."
} }
}, },
"worlds": { "worlds": {
@@ -347,6 +420,473 @@
"remove": "Entfernen", "remove": "Entfernen",
"installed": "Installiert", "installed": "Installiert",
"empty": "Noch keine Add-ons installiert." "empty": "Noch keine Add-ons installiert."
},
"schedules": {
"title": "Zeitpläne",
"description": "Geplante Start-, Stopp- und Wartungsaktionen.",
"loading": "Wird geladen…",
"empty": "Noch keine Zeitpläne vorhanden.",
"create": "Zeitplan erstellen",
"edit": "Bearbeiten",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"saving": "Wird gespeichert…",
"deleteConfirm": "Zeitplan \"{name}\" wirklich löschen?",
"fields": {
"name": "Name",
"cron": "Cron-Ausdruck",
"timezone": "Zeitzone",
"taskType": "Aufgabe",
"enabled": "Aktiv",
"command": "Konsolenbefehl",
"commandPlaceholder": "say Server startet neu"
},
"columns": {
"name": "Name",
"cron": "Cron",
"task": "Aufgabe",
"status": "Status",
"actions": "Aktionen"
},
"status": {
"enabled": "Aktiv",
"disabled": "Inaktiv"
},
"taskTypes": {
"START": "Server starten",
"STOP": "Server stoppen",
"RESTART": "Server neu starten",
"BACKUP": "Backup erstellen",
"CONSOLE_COMMAND": "Befehl ausführen",
"UPDATE_CHECK": "Updates prüfen",
"MAINTENANCE": "Wartung"
},
"errors": {
"loadFailed": "Zeitpläne konnten nicht geladen werden.",
"saveFailed": "Zeitplan konnte nicht gespeichert werden.",
"deleteFailed": "Zeitplan konnte nicht gelöscht werden."
}
},
"access": {
"title": "Zugriffe",
"description": "Freigaben und Berechtigungen für diesen Server.",
"loading": "Wird geladen…",
"empty": "Noch keine Mitglieder. Laden Sie jemanden ein, um diesen Server zu teilen.",
"invite": "Mitglied einladen",
"sendInvite": "Einladung senden",
"inviting": "Wird gesendet…",
"cancel": "Abbrechen",
"remove": "Entfernen",
"inviteSuccess": "Mitglied erfolgreich eingeladen.",
"invitePending": "Einladung vermerkt. Der Benutzer muss sich registrieren, bevor er auf diesen Server zugreifen kann.",
"removeConfirm": "{name} von diesem Server entfernen?",
"fields": {
"email": "E-Mail-Adresse",
"emailPlaceholder": "benutzer@beispiel.de",
"role": "Rolle"
},
"columns": {
"member": "Mitglied",
"email": "E-Mail",
"role": "Rolle",
"status": "Status",
"actions": "Aktionen"
},
"roles": {
"OWNER": "Inhaber",
"ADMIN": "Administrator",
"OPERATOR": "Operator",
"DEVELOPER": "Entwickler",
"VIEWER": "Betrachter"
},
"status": {
"active": "Aktiv",
"pending": "Ausstehend"
},
"errors": {
"loadFailed": "Mitglieder konnten nicht geladen werden.",
"inviteFailed": "Einladung konnte nicht gesendet werden.",
"removeFailed": "Mitglied konnte nicht entfernt werden."
}
}
},
"account": {
"nav": {
"label": "Kontonavigation",
"dashboard": "Dashboard",
"servers": "Server",
"billing": "Abrechnung",
"profile": "Profil",
"notifications": "Benachrichtigungen",
"security": "Sicherheit"
},
"billing": {
"title": "Abrechnung",
"subtitle": "Credits und Transaktionsverlauf.",
"loading": "Wird geladen…",
"loadError": "Abrechnungsdaten konnten nicht geladen werden.",
"balanceTitle": "Aktuelles Guthaben",
"periodStart": "Abrechnungszeitraum ab {date}",
"creditsLabel": "Verbleibende Credits",
"transactionsTitle": "Transaktionen",
"transactionsEmpty": "Noch keine Transaktionen in diesem Zeitraum.",
"columns": {
"date": "Datum",
"type": "Typ",
"amount": "Betrag",
"reference": "Referenz"
}
},
"profile": {
"title": "Profil",
"subtitle": "Ihre Kontoinformationen.",
"loading": "Wird geladen…",
"notSignedIn": "Nicht angemeldet.",
"accountInfo": "Kontodaten",
"accountInfoDescription": "Profilinformationen aus Ihrem Konto.",
"fields": {
"email": "E-Mail",
"username": "Benutzername",
"displayName": "Anzeigename",
"emailVerified": "E-Mail verifiziert",
"twoFactor": "Zwei-Faktor-Auth"
},
"yes": "Ja",
"no": "Nein",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"securityHint": "Passwort, 2FA und Sitzungen verwalten unter",
"securityLink": "Sicherheit"
},
"notifications": {
"title": "Benachrichtigungen",
"subtitle": "E-Mail- und In-App-Benachrichtigungen.",
"loading": "Wird geladen…",
"listTitle": "Neueste Benachrichtigungen",
"unreadCount": "{count, plural, one {# ungelesene Benachrichtigung} other {# ungelesene Benachrichtigungen}}",
"allRead": "Alles gelesen.",
"markRead": "Als gelesen markieren",
"markAllRead": "Alle als gelesen markieren",
"emptyTitle": "Keine Benachrichtigungen",
"emptyDescription": "Sie haben noch keine Benachrichtigungen erhalten.",
"preferencesTitle": "Einstellungen",
"preferencesDescription": "Wählen Sie, welche Benachrichtigungen Sie pro Kanal erhalten.",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"channels": {
"IN_APP": "In-App",
"EMAIL": "E-Mail",
"WEBHOOK": "Webhook",
"DISCORD": "Discord"
},
"types": {
"SERVER_READY": "Server bereit",
"SERVER_START_FAILED": "Serverstart fehlgeschlagen",
"SERVER_STOPPED": "Server gestoppt",
"IDLE_STOP_WARNING": "Leerlauf-Stopp-Warnung",
"BACKUP_SUCCESS": "Backup erfolgreich",
"BACKUP_FAILED": "Backup fehlgeschlagen",
"RESTORE_SUCCESS": "Wiederherstellung erfolgreich",
"RESTORE_FAILED": "Wiederherstellung fehlgeschlagen",
"CREDITS_LOW": "Wenig Credits",
"BILLING_ISSUE": "Abrechnungsproblem",
"INVITE": "Einladung",
"NODE_INCIDENT": "Node-Vorfall",
"MAINTENANCE": "Wartung",
"SECURITY": "Sicherheit",
"NEW_LOGIN": "Neue Anmeldung",
"TWO_FA_CHANGED": "Zwei-Faktor geändert"
},
"errors": {
"loadFailed": "Benachrichtigungen konnten nicht geladen werden.",
"markReadFailed": "Benachrichtigung konnte nicht aktualisiert werden.",
"preferencesFailed": "Einstellungen konnten nicht gespeichert werden."
}
}
},
"admin": {
"backToPanel": "Zurück zum Panel",
"nav": {
"label": "Admin-Navigation",
"dashboard": "Dashboard",
"nodes": "Nodes",
"users": "Benutzer",
"jobs": "Jobs",
"audit": "Audit"
},
"dashboard": {
"title": "Admin-Dashboard",
"subtitle": "Plattformweite Kennzahlen und Systemstatus.",
"loading": "Wird geladen…",
"loadError": "Dashboard-Daten konnten nicht geladen werden.",
"generatedAt": "Stand: {time}",
"stats": {
"activeUsers": "Aktive Benutzer",
"totalServers": "Server gesamt",
"runningServers": "Laufende Server",
"queueLength": "Warteschlange",
"onlineNodes": "Online-Nodes",
"failedJobs24h": "Fehlgeschlagene Jobs (24h)",
"openAbuseReports": "Offene Abuse-Reports",
"openTickets": "Offene Tickets"
}
},
"nodes": {
"title": "Game-Nodes",
"subtitle": "Node-Status und Wartungsaktionen.",
"loading": "Wird geladen…",
"loadError": "Nodes konnten nicht geladen werden.",
"empty": "Keine Nodes registriert.",
"drain": "Drain",
"maintenance": "Wartung",
"exitMaintenance": "Wartung beenden",
"actionError": "Aktion fehlgeschlagen.",
"columns": {
"name": "Node",
"status": "Status",
"servers": "Server",
"ram": "RAM",
"actions": "Aktionen"
}
},
"users": {
"title": "Benutzer",
"subtitle": "Benutzerkonten suchen und verwalten.",
"loading": "Wird geladen…",
"loadError": "Benutzer konnten nicht geladen werden.",
"empty": "Keine Benutzer gefunden.",
"searchPlaceholder": "E-Mail oder Benutzername…",
"search": "Suchen",
"suspend": "Sperren",
"unsuspend": "Entsperren",
"actionError": "Aktion fehlgeschlagen.",
"columns": {
"user": "Benutzer",
"status": "Status",
"roles": "Rollen",
"servers": "Server",
"actions": "Aktionen"
}
},
"jobs": {
"title": "Hintergrund-Jobs",
"subtitle": "Aktuelle und kürzlich abgeschlossene Queue-Jobs.",
"loading": "Wird geladen…",
"loadError": "Jobs konnten nicht geladen werden.",
"empty": "Keine Jobs gefunden.",
"columns": {
"queue": "Queue",
"job": "Job",
"status": "Status",
"attempts": "Versuche",
"created": "Erstellt"
}
},
"audit": {
"title": "Audit-Log",
"subtitle": "Plattformweite Sicherheits- und Lifecycle-Ereignisse.",
"loading": "Wird geladen…",
"loadError": "Audit-Ereignisse konnten nicht geladen werden.",
"empty": "Keine Ereignisse gefunden.",
"loadMore": "Mehr laden",
"columns": {
"time": "Zeit",
"action": "Aktion",
"user": "Benutzer",
"entity": "Entität"
}
}
},
"public": {
"pricing": {
"title": "Preise",
"description": "Transparente Tarife für Minecraft-Server-Hosting.",
"cta": "Jetzt registrieren →",
"tiers": {
"starter": {
"name": "Starter",
"description": "Ideal zum Ausprobieren.",
"price": "Kostenlos",
"features": ["2 GB RAM", "1 Server", "Idle-Shutdown", "Community-Support"]
},
"standard": {
"name": "Standard",
"description": "Für kleine Communities.",
"price": "Credits",
"features": ["4 GB RAM", "3 Server", "Backups", "Modrinth Add-ons"]
},
"pro": {
"name": "Pro",
"description": "Für anspruchsvolle Welten.",
"price": "Credits",
"features": ["8+ GB RAM", "Unbegrenzte Server*", "Prioritäts-Queue", "WHMCS-Integration"]
}
}
},
"features": {
"title": "Funktionen",
"description": "Alles für professionelles Minecraft-Server-Hosting.",
"items": {
"control": {
"title": "Zentrale Steuerung",
"description": "Lifecycle, Konsole, Dateien und Properties über ein einheitliches Panel."
},
"nodes": {
"title": "Multi-Node",
"description": "Horizontale Skalierung mit Agent-basierter Orchestrierung."
},
"security": {
"title": "Sicherheit",
"description": "Sessions, 2FA, CSRF-Schutz und auditierbare Aktionen."
},
"billing": {
"title": "Credit-Abrechnung",
"description": "Laufzeitbasierte Abrechnung mit transparentem Guthaben."
},
"backups": {
"title": "Backups & Welten",
"description": "Manuelle und geplante Backups, Welt-Upload und -Download."
},
"whmcs": {
"title": "WHMCS-Integration",
"description": "Provisioning, SSO und Abrechnung über WHMCS."
}
}
},
"status": {
"title": "Systemstatus",
"description": "Aktueller Betriebsstatus der Plattformkomponenten.",
"operational": "Betriebsbereit",
"selfHostedNote": "Status basiert auf Self-Hosted-Deployment. Konfigurieren Sie Health-Checks für Live-Monitoring.",
"components": {
"api": {
"name": "API",
"description": "REST Control Plane unter /api/v1"
},
"worker": {
"name": "Worker",
"description": "BullMQ Hintergrundverarbeitung"
},
"panel": {
"name": "Web-Panel",
"description": "Next.js Benutzeroberfläche"
},
"dns": {
"name": "DNS / Edge",
"description": "Join-to-Start und Edge-Gateway"
}
}
},
"docs": {
"title": "Dokumentation",
"description": "Einstieg, API-Referenz und Betriebshinweise.",
"openapi": "Interaktive API-Dokumentation:",
"openapiLink": "OpenAPI / Swagger",
"cta": "Konto erstellen →",
"sections": {
"gettingStarted": {
"title": "Erste Schritte",
"body": "Registrieren Sie sich, verifizieren Sie Ihre E-Mail, erstellen Sie einen Server über den mehrstufigen Assistenten und starten Sie ihn über das Dashboard."
},
"api": {
"title": "API",
"body": "Die REST-API unter /api/v1 bietet Endpunkte für Auth, Server-Lifecycle, Dateien, Backups und Admin-Funktionen. Authentifizierung erfolgt über Session-Cookies."
},
"operations": {
"title": "Betrieb",
"body": "Deployen Sie Control Plane, Worker und Node-Agent über Docker Compose oder Ansible. Siehe docs/operations im Repository."
},
"whmcs": {
"title": "WHMCS",
"body": "Das Server-Modul provisioniert Dienste automatisch. SSO ermöglicht nahtlosen Zugang aus dem WHMCS Client Area."
}
}
},
"impressum": {
"title": "Impressum",
"description": "Angaben gemäß § 5 TMG.",
"notice": "Dies ist ein Impressums-Entwurf. Ersetzen Sie die Platzhalterangaben vor dem Produktivbetrieb.",
"sections": {
"operator": {
"title": "Anbieter",
"body": "HexaHost\nPlattform für Game-Server-Hosting"
},
"address": {
"title": "Anschrift",
"body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nDeutschland"
},
"contact": {
"title": "Kontakt",
"body": "E-Mail: kontakt@hexahost.example\nTelefon: +49 (0) 123 456789"
},
"responsible": {
"title": "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV",
"body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt"
},
"register": {
"title": "Registereintrag",
"body": "Registergericht: Amtsgericht Musterstadt\nRegisternummer: HRB 12345\nUSt-IdNr.: DE123456789"
},
"dispute": {
"title": "Streitbeilegung",
"body": "Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."
}
}
},
"privacy": {
"title": "Datenschutzerklärung",
"description": "Informationen zur Verarbeitung personenbezogener Daten.",
"sections": {
"controller": {
"title": "Verantwortlicher",
"body": "Der Betreiber dieser selbstgehosteten Instanz ist für die Datenverarbeitung verantwortlich. Passen Sie diese Erklärung an Ihre Organisation an."
},
"data": {
"title": "Erhobene Daten",
"body": "Wir verarbeiten Kontodaten (E-Mail, Benutzername), Sitzungsinformationen, Server-Metadaten und Nutzungsdaten zur Abrechnung."
},
"cookies": {
"title": "Cookies",
"body": "Session-Cookies (hgc_session) und CSRF-Cookies (hgc_csrf) sind für die Authentifizierung erforderlich."
},
"rights": {
"title": "Ihre Rechte",
"body": "Sie haben das Recht auf Auskunft, Berichtigung, Löschung und Datenübertragbarkeit gemäß DSGVO."
},
"contact": {
"title": "Kontakt",
"body": "Wenden Sie sich für Datenschutzanfragen an den in Ihrem Impressum genannten Betreiber."
}
}
},
"terms": {
"title": "Allgemeine Geschäftsbedingungen",
"description": "Nutzungsbedingungen der Plattform.",
"sections": {
"scope": {
"title": "Geltungsbereich",
"body": "Diese AGB gelten für die Nutzung der Minecraft-Server-Hosting-Plattform durch registrierte Benutzer."
},
"usage": {
"title": "Nutzung",
"body": "Server dürfen nur für legale Zwecke betrieben werden. Missbrauch kann zur Sperrung führen."
},
"billing": {
"title": "Abrechnung",
"body": "Credits werden nach Laufzeit verbraucht. Guthaben ist nicht übertragbar, sofern nicht anders vereinbart."
},
"liability": {
"title": "Haftung",
"body": "Der Betreiber haftet nur bei Vorsatz und grober Fahrlässigkeit. Datenverluste sind durch eigene Backups abzusichern."
},
"changes": {
"title": "Änderungen",
"body": "Der Betreiber kann diese Bedingungen mit angemessener Frist ändern. Fortgesetzte Nutzung gilt als Zustimmung."
}
}
} }
} }
} }

View File

@@ -15,10 +15,19 @@
"cancel": "Cancel", "cancel": "Cancel",
"back": "Back", "back": "Back",
"footer": "Self-hosted Minecraft server platform.", "footer": "Self-hosted Minecraft server platform.",
"servers": "Servers" "servers": "Servers",
"features": "Features",
"pricing": "Pricing",
"docs": "Documentation",
"status": "Status",
"impressum": "Legal notice",
"privacy": "Privacy",
"terms": "Terms",
"admin": "Admin",
"phaseBadge": "MVP · Phases 09"
}, },
"landing": { "landing": {
"badge": "Phase 0 · Control Plane", "badge": "MVP · Phase 9 complete",
"title": "Minecraft servers on demand", "title": "Minecraft servers on demand",
"subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.", "subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.",
"ctaPrimary": "Get started", "ctaPrimary": "Get started",
@@ -205,7 +214,36 @@
"players": "Players", "players": "Players",
"worlds": "Worlds", "worlds": "Worlds",
"backups": "Backups", "backups": "Backups",
"addons": "Add-ons" "addons": "Add-ons",
"software": "Software",
"schedules": "Schedules",
"access": "Access"
},
"wizard": {
"stepsLabel": "Creation steps",
"steps": {
"basics": "Basics",
"software": "Software",
"review": "Review"
},
"next": "Next",
"back": "Back"
},
"software": {
"title": "Software",
"description": "Installed server software and version.",
"loading": "Loading…",
"loadError": "Could not load software information.",
"family": "Software family",
"version": "Minecraft version",
"edition": "Edition",
"changeHint": "Software switching will be supported in a future release.",
"reinstall": "Reinstall software",
"reinstalling": "Reinstalling…",
"reinstallConfirm": "Reinstall server software? A backup will be created first.",
"reinstallStarted": "Reinstall started. The server will be unavailable during installation.",
"reinstallError": "Could not start reinstall.",
"reinstallStoppedHint": "Stop the server before reinstalling software."
}, },
"console": { "console": {
"loading": "Loading…", "loading": "Loading…",
@@ -233,10 +271,27 @@
"save": "Save", "save": "Save",
"saving": "Saving…", "saving": "Saving…",
"saved": "File saved.", "saved": "File saved.",
"upload": "Upload",
"uploading": "Uploading…",
"uploaded": "File uploaded.",
"delete": "Delete",
"deleted": "Deleted.",
"deleteConfirm": "Delete {name}?",
"archive": "Archive selected",
"archiving": "Archiving…",
"archived": "Archive created.",
"unarchive": "Extract archive",
"unarchiving": "Extracting…",
"unarchived": "Archive extraction started.",
"unarchiveConfirm": "Extract {name} into the current directory?",
"errors": { "errors": {
"loadFailed": "Could not load directory.", "loadFailed": "Could not load directory.",
"readFailed": "Could not read file.", "readFailed": "Could not read file.",
"saveFailed": "Could not save file." "saveFailed": "Could not save file.",
"deleteFailed": "Could not delete file.",
"uploadFailed": "Could not upload file.",
"archiveFailed": "Could not create archive.",
"unarchiveFailed": "Could not extract archive."
} }
}, },
"properties": { "properties": {
@@ -280,14 +335,32 @@
"loading": "Loading…", "loading": "Loading…",
"onlineCount": "Online players", "onlineCount": "Online players",
"whitelistCount": "Whitelist entries", "whitelistCount": "Whitelist entries",
"operatorsCount": "Operators",
"onlineTitle": "Online players",
"whitelistTitle": "Whitelist",
"operatorsTitle": "Operators",
"bansTitle": "Banned players",
"bansEmpty": "No banned players.",
"empty": "No players online.", "empty": "No players online.",
"retry": "Retry", "retry": "Retry",
"kick": "Kick",
"addWhitelist": "Add to whitelist",
"addOperator": "Add operator",
"addBan": "Ban player",
"remove": "Remove",
"fields": {
"name": "Player name",
"uuid": "UUID",
"reason": "Reason"
},
"columns": { "columns": {
"name": "Player", "name": "Player",
"ping": "Ping" "ping": "Ping",
"actions": "Actions"
}, },
"errors": { "errors": {
"loadFailed": "Could not load player list." "loadFailed": "Could not load player list.",
"actionFailed": "Action failed."
} }
}, },
"worlds": { "worlds": {
@@ -347,6 +420,473 @@
"remove": "Remove", "remove": "Remove",
"installed": "Installed", "installed": "Installed",
"empty": "No add-ons installed yet." "empty": "No add-ons installed yet."
},
"schedules": {
"title": "Schedules",
"description": "Scheduled start, stop, and maintenance actions.",
"loading": "Loading…",
"empty": "No schedules yet.",
"create": "Create schedule",
"edit": "Edit",
"save": "Save changes",
"cancel": "Cancel",
"delete": "Delete",
"enable": "Enable",
"disable": "Disable",
"saving": "Saving…",
"deleteConfirm": "Delete schedule \"{name}\"?",
"fields": {
"name": "Name",
"cron": "Cron expression",
"timezone": "Timezone",
"taskType": "Task",
"enabled": "Enabled",
"command": "Console command",
"commandPlaceholder": "say Server restarting"
},
"columns": {
"name": "Name",
"cron": "Cron",
"task": "Task",
"status": "Status",
"actions": "Actions"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"taskTypes": {
"START": "Start server",
"STOP": "Stop server",
"RESTART": "Restart server",
"BACKUP": "Create backup",
"CONSOLE_COMMAND": "Run command",
"UPDATE_CHECK": "Check for updates",
"MAINTENANCE": "Maintenance"
},
"errors": {
"loadFailed": "Could not load schedules.",
"saveFailed": "Could not save schedule.",
"deleteFailed": "Could not delete schedule."
}
},
"access": {
"title": "Access",
"description": "Sharing and permissions for this server.",
"loading": "Loading…",
"empty": "No members yet. Invite someone to share this server.",
"invite": "Invite member",
"sendInvite": "Send invite",
"inviting": "Sending…",
"cancel": "Cancel",
"remove": "Remove",
"inviteSuccess": "Member invited successfully.",
"invitePending": "Invite noted. The user must register before they can access this server.",
"removeConfirm": "Remove {name} from this server?",
"fields": {
"email": "Email address",
"emailPlaceholder": "user@example.com",
"role": "Role"
},
"columns": {
"member": "Member",
"email": "Email",
"role": "Role",
"status": "Status",
"actions": "Actions"
},
"roles": {
"OWNER": "Owner",
"ADMIN": "Admin",
"OPERATOR": "Operator",
"DEVELOPER": "Developer",
"VIEWER": "Viewer"
},
"status": {
"active": "Active",
"pending": "Pending"
},
"errors": {
"loadFailed": "Could not load members.",
"inviteFailed": "Could not send invite.",
"removeFailed": "Could not remove member."
}
}
},
"account": {
"nav": {
"label": "Account navigation",
"dashboard": "Dashboard",
"servers": "Servers",
"billing": "Billing",
"profile": "Profile",
"notifications": "Notifications",
"security": "Security"
},
"billing": {
"title": "Billing",
"subtitle": "Credits and transaction history.",
"loading": "Loading…",
"loadError": "Could not load billing data.",
"balanceTitle": "Current balance",
"periodStart": "Billing period from {date}",
"creditsLabel": "Credits remaining",
"transactionsTitle": "Transactions",
"transactionsEmpty": "No transactions in this period yet.",
"columns": {
"date": "Date",
"type": "Type",
"amount": "Amount",
"reference": "Reference"
}
},
"profile": {
"title": "Profile",
"subtitle": "Your account information.",
"loading": "Loading…",
"notSignedIn": "Not signed in.",
"accountInfo": "Account details",
"accountInfoDescription": "Profile information from your account.",
"fields": {
"email": "Email",
"username": "Username",
"displayName": "Display name",
"emailVerified": "Email verified",
"twoFactor": "Two-factor auth"
},
"yes": "Yes",
"no": "No",
"enabled": "Enabled",
"disabled": "Disabled",
"securityHint": "Manage password, 2FA, and sessions at",
"securityLink": "Security"
},
"notifications": {
"title": "Notifications",
"subtitle": "Email and in-app notifications.",
"loading": "Loading…",
"listTitle": "Recent notifications",
"unreadCount": "{count, plural, one {# unread notification} other {# unread notifications}}",
"allRead": "All caught up.",
"markRead": "Mark read",
"markAllRead": "Mark all read",
"emptyTitle": "No notifications",
"emptyDescription": "You have not received any notifications yet.",
"preferencesTitle": "Preferences",
"preferencesDescription": "Choose which notifications you receive on each channel.",
"enabled": "Enabled",
"disabled": "Disabled",
"channels": {
"IN_APP": "In-app",
"EMAIL": "Email",
"WEBHOOK": "Webhook",
"DISCORD": "Discord"
},
"types": {
"SERVER_READY": "Server ready",
"SERVER_START_FAILED": "Server start failed",
"SERVER_STOPPED": "Server stopped",
"IDLE_STOP_WARNING": "Idle stop warning",
"BACKUP_SUCCESS": "Backup succeeded",
"BACKUP_FAILED": "Backup failed",
"RESTORE_SUCCESS": "Restore succeeded",
"RESTORE_FAILED": "Restore failed",
"CREDITS_LOW": "Low credits",
"BILLING_ISSUE": "Billing issue",
"INVITE": "Invite",
"NODE_INCIDENT": "Node incident",
"MAINTENANCE": "Maintenance",
"SECURITY": "Security",
"NEW_LOGIN": "New login",
"TWO_FA_CHANGED": "Two-factor changed"
},
"errors": {
"loadFailed": "Could not load notifications.",
"markReadFailed": "Could not update notification.",
"preferencesFailed": "Could not save preferences."
}
}
},
"admin": {
"backToPanel": "Back to panel",
"nav": {
"label": "Admin navigation",
"dashboard": "Dashboard",
"nodes": "Nodes",
"users": "Users",
"jobs": "Jobs",
"audit": "Audit"
},
"dashboard": {
"title": "Admin dashboard",
"subtitle": "Platform-wide metrics and system status.",
"loading": "Loading…",
"loadError": "Could not load dashboard data.",
"generatedAt": "As of {time}",
"stats": {
"activeUsers": "Active users",
"totalServers": "Total servers",
"runningServers": "Running servers",
"queueLength": "Queue length",
"onlineNodes": "Online nodes",
"failedJobs24h": "Failed jobs (24h)",
"openAbuseReports": "Open abuse reports",
"openTickets": "Open tickets"
}
},
"nodes": {
"title": "Game nodes",
"subtitle": "Node status and maintenance actions.",
"loading": "Loading…",
"loadError": "Could not load nodes.",
"empty": "No nodes registered.",
"drain": "Drain",
"maintenance": "Maintenance",
"exitMaintenance": "Exit maintenance",
"actionError": "Action failed.",
"columns": {
"name": "Node",
"status": "Status",
"servers": "Servers",
"ram": "RAM",
"actions": "Actions"
}
},
"users": {
"title": "Users",
"subtitle": "Search and manage user accounts.",
"loading": "Loading…",
"loadError": "Could not load users.",
"empty": "No users found.",
"searchPlaceholder": "Email or username…",
"search": "Search",
"suspend": "Suspend",
"unsuspend": "Unsuspend",
"actionError": "Action failed.",
"columns": {
"user": "User",
"status": "Status",
"roles": "Roles",
"servers": "Servers",
"actions": "Actions"
}
},
"jobs": {
"title": "Background jobs",
"subtitle": "Current and recently completed queue jobs.",
"loading": "Loading…",
"loadError": "Could not load jobs.",
"empty": "No jobs found.",
"columns": {
"queue": "Queue",
"job": "Job",
"status": "Status",
"attempts": "Attempts",
"created": "Created"
}
},
"audit": {
"title": "Audit log",
"subtitle": "Platform-wide security and lifecycle events.",
"loading": "Loading…",
"loadError": "Could not load audit events.",
"empty": "No events found.",
"loadMore": "Load more",
"columns": {
"time": "Time",
"action": "Action",
"user": "User",
"entity": "Entity"
}
}
},
"public": {
"pricing": {
"title": "Pricing",
"description": "Transparent plans for Minecraft server hosting.",
"cta": "Register now →",
"tiers": {
"starter": {
"name": "Starter",
"description": "Great for trying things out.",
"price": "Free",
"features": ["2 GB RAM", "1 server", "Idle shutdown", "Community support"]
},
"standard": {
"name": "Standard",
"description": "For small communities.",
"price": "Credits",
"features": ["4 GB RAM", "3 servers", "Backups", "Modrinth add-ons"]
},
"pro": {
"name": "Pro",
"description": "For demanding worlds.",
"price": "Credits",
"features": ["8+ GB RAM", "Unlimited servers*", "Priority queue", "WHMCS integration"]
}
}
},
"features": {
"title": "Features",
"description": "Everything for professional Minecraft server hosting.",
"items": {
"control": {
"title": "Central control",
"description": "Lifecycle, console, files, and properties through a unified panel."
},
"nodes": {
"title": "Multi-node",
"description": "Horizontal scaling with agent-based orchestration."
},
"security": {
"title": "Security",
"description": "Sessions, 2FA, CSRF protection, and auditable actions."
},
"billing": {
"title": "Credit billing",
"description": "Runtime-based billing with transparent balance."
},
"backups": {
"title": "Backups & worlds",
"description": "Manual and scheduled backups, world upload and download."
},
"whmcs": {
"title": "WHMCS integration",
"description": "Provisioning, SSO, and billing through WHMCS."
}
}
},
"status": {
"title": "System status",
"description": "Current operational status of platform components.",
"operational": "Operational",
"selfHostedNote": "Status reflects self-hosted deployment. Configure health checks for live monitoring.",
"components": {
"api": {
"name": "API",
"description": "REST control plane at /api/v1"
},
"worker": {
"name": "Worker",
"description": "BullMQ background processing"
},
"panel": {
"name": "Web panel",
"description": "Next.js user interface"
},
"dns": {
"name": "DNS / Edge",
"description": "Join-to-start and edge gateway"
}
}
},
"docs": {
"title": "Documentation",
"description": "Getting started, API reference, and operations guides.",
"openapi": "Interactive API documentation:",
"openapiLink": "OpenAPI / Swagger",
"cta": "Create account →",
"sections": {
"gettingStarted": {
"title": "Getting started",
"body": "Register, verify your email, create a server through the multi-step wizard, and start it from the dashboard."
},
"api": {
"title": "API",
"body": "The REST API at /api/v1 provides endpoints for auth, server lifecycle, files, backups, and admin functions. Authentication uses session cookies."
},
"operations": {
"title": "Operations",
"body": "Deploy control plane, worker, and node agent via Docker Compose or Ansible. See docs/operations in the repository."
},
"whmcs": {
"title": "WHMCS",
"body": "The server module provisions services automatically. SSO enables seamless access from the WHMCS client area."
}
}
},
"impressum": {
"title": "Legal notice",
"description": "Information according to applicable law (German Impressum).",
"notice": "This is a template legal notice. Replace placeholder details before production use.",
"sections": {
"operator": {
"title": "Service provider",
"body": "HexaHost\nGame server hosting platform"
},
"address": {
"title": "Address",
"body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nGermany"
},
"contact": {
"title": "Contact",
"body": "Email: kontakt@hexahost.example\nPhone: +49 (0) 123 456789"
},
"responsible": {
"title": "Responsible for content (§ 55 Abs. 2 RStV)",
"body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt"
},
"register": {
"title": "Commercial register",
"body": "Register court: Amtsgericht Musterstadt\nRegistration number: HRB 12345\nVAT ID: DE123456789"
},
"dispute": {
"title": "Dispute resolution",
"body": "We are not obliged or willing to participate in dispute resolution proceedings before a consumer arbitration board."
}
}
},
"privacy": {
"title": "Privacy policy",
"description": "Information on processing personal data.",
"sections": {
"controller": {
"title": "Controller",
"body": "The operator of this self-hosted instance is responsible for data processing. Adapt this policy to your organization."
},
"data": {
"title": "Data collected",
"body": "We process account data (email, username), session information, server metadata, and usage data for billing."
},
"cookies": {
"title": "Cookies",
"body": "Session cookies (hgc_session) and CSRF cookies (hgc_csrf) are required for authentication."
},
"rights": {
"title": "Your rights",
"body": "You have the right to access, rectify, delete, and port your data under applicable privacy law."
},
"contact": {
"title": "Contact",
"body": "For privacy requests, contact the operator listed in your legal notice."
}
}
},
"terms": {
"title": "Terms of service",
"description": "Terms of use for the platform.",
"sections": {
"scope": {
"title": "Scope",
"body": "These terms apply to use of the Minecraft server hosting platform by registered users."
},
"usage": {
"title": "Usage",
"body": "Servers may only be operated for lawful purposes. Abuse may result in suspension."
},
"billing": {
"title": "Billing",
"body": "Credits are consumed based on runtime. Balance is non-transferable unless otherwise agreed."
},
"liability": {
"title": "Liability",
"body": "The operator is liable only for intent and gross negligence. Protect against data loss with your own backups."
},
"changes": {
"title": "Changes",
"body": "The operator may change these terms with reasonable notice. Continued use constitutes acceptance."
}
}
} }
} }
} }

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { BillingContent } from "@/components/account/billing-content";
interface BillingPageProps {
params: Promise<{ locale: string }>;
}
export default async function BillingPage({ params }: BillingPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <BillingContent />;
}

View File

@@ -0,0 +1,24 @@
import { setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { AccountNav } from "@/components/layout/account-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface AccountLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function AccountLayout({ children, params }: AccountLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<AccountNav />
<DashboardUserInfo />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { NotificationsContent } from "@/components/account/notifications-content";
interface NotificationsPageProps {
params: Promise<{ locale: string }>;
}
export default async function NotificationsPage({ params }: NotificationsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <NotificationsContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ProfileContent } from "@/components/account/profile-content";
interface ProfilePageProps {
params: Promise<{ locale: string }>;
}
export default async function ProfilePage({ params }: ProfilePageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <ProfileContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminAuditContent } from "@/components/admin/admin-audit-content";
interface AdminAuditPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminAuditPage({ params }: AdminAuditPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminAuditContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminJobsContent } from "@/components/admin/admin-jobs-content";
interface AdminJobsPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminJobsPage({ params }: AdminJobsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminJobsContent />;
}

View File

@@ -0,0 +1,34 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { AdminNav } from "@/components/layout/admin-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
import { Link } from "@/i18n/navigation";
interface AdminLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function AdminLayout({ children, params }: AdminLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("admin");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-2">
<Link
href="/dashboard"
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{t("backToPanel")}
</Link>
</div>
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<AdminNav />
<DashboardUserInfo />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminNodesContent } from "@/components/admin/admin-nodes-content";
interface AdminNodesPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminNodesPage({ params }: AdminNodesPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminNodesContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminDashboardContent } from "@/components/admin/admin-dashboard-content";
interface AdminPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminPage({ params }: AdminPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminDashboardContent />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { AdminUsersContent } from "@/components/admin/admin-users-content";
interface AdminUsersPageProps {
params: Promise<{ locale: string }>;
}
export default async function AdminUsersPage({ params }: AdminUsersPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <AdminUsersContent />;
}

View File

@@ -1,6 +1,6 @@
import { getTranslations, setRequestLocale } from "next-intl/server"; import { setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation"; import { AccountNav } from "@/components/layout/account-nav";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface DashboardLayoutProps { interface DashboardLayoutProps {
@@ -14,32 +14,11 @@ export default async function DashboardLayout({
}: DashboardLayoutProps) { }: DashboardLayoutProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations("common");
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6"> <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="Dashboard navigation"> <AccountNav />
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("dashboard")}
</Link>
</li>
<li>
<Link
href="/servers"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{t("servers")}
</Link>
</li>
</ul>
</nav>
<DashboardUserInfo /> <DashboardUserInfo />
</div> </div>
{children} {children}

View File

@@ -0,0 +1,48 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
import { Link } from "@/i18n/navigation";
import { getApiUrl } from "@/lib/env";
interface DocsPageProps {
params: Promise<{ locale: string }>;
}
export default async function DocsPage({ params }: DocsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.docs");
const apiUrl = getApiUrl();
const sections = ["gettingStarted", "api", "operations", "whmcs"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="space-y-6">
{sections.map((section) => (
<section key={section}>
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t(`sections.${section}.title`)}
</h2>
<p className="mt-2 text-sm">{t(`sections.${section}.body`)}</p>
</section>
))}
<p className="text-sm">
{t("openapi")}{" "}
<a
href={`${apiUrl}/docs`}
className="font-medium text-sky-600 hover:underline dark:text-sky-400"
target="_blank"
rel="noopener noreferrer"
>
{t("openapiLink")}
</a>
</p>
<p className="text-sm">
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("cta")}
</Link>
</p>
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
interface FeaturesPageProps {
params: Promise<{ locale: string }>;
}
export default async function FeaturesPage({ params }: FeaturesPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.features");
const items = ["control", "nodes", "security", "billing", "backups", "whmcs"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="grid gap-6 md:grid-cols-2">
{items.map((item) => (
<Card key={item}>
<CardHeader>
<CardTitle>{t(`items.${item}.title`)}</CardTitle>
<CardDescription>{t(`items.${item}.description`)}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,37 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
interface ImpressumPageProps {
params: Promise<{ locale: string }>;
}
export default async function ImpressumPage({ params }: ImpressumPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.impressum");
const sections = [
"operator",
"address",
"contact",
"responsible",
"register",
"dispute",
] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="space-y-6 text-sm">
{sections.map((section) => (
<section key={section}>
<h2 className="text-base font-medium text-zinc-900 dark:text-zinc-50">
{t(`sections.${section}.title`)}
</h2>
<p className="mt-2 whitespace-pre-line">{t(`sections.${section}.body`)}</p>
</section>
))}
<p className="text-zinc-500">{t("notice")}</p>
</div>
</PublicPageShell>
);
}

View File

@@ -0,0 +1,51 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { PublicPageShell } from "@/components/layout/public-page-shell";
import { Link } from "@/i18n/navigation";
interface PricingPageProps {
params: Promise<{ locale: string }>;
}
export default async function PricingPage({ params }: PricingPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("public.pricing");
const tiers = ["starter", "standard", "pro"] as const;
return (
<PublicPageShell title={t("title")} description={t("description")}>
<div className="grid gap-6 md:grid-cols-3">
{tiers.map((tier) => (
<Card key={tier}>
<CardHeader>
<CardTitle>{t(`tiers.${tier}.name`)}</CardTitle>
<CardDescription>{t(`tiers.${tier}.description`)}</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{t(`tiers.${tier}.price`)}
</p>
<ul className="mt-4 space-y-2 text-sm">
{(t.raw(`tiers.${tier}.features`) as string[]).map((feature) => (
<li key={feature} className="flex gap-2">
<span className="text-sky-600 dark:text-sky-400" aria-hidden="true">
</span>
{feature}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
<p className="text-center text-sm">
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("cta")}
</Link>
</p>
</PublicPageShell>
);
}

Some files were not shown because too many files have changed in this diff Show More