Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
This commit is contained in:
10
.env.example
10
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
29
apps/api/src/abuse/abuse.controller.ts
Normal file
29
apps/api/src/abuse/abuse.controller.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/abuse/abuse.module.ts
Normal file
15
apps/api/src/abuse/abuse.module.ts
Normal 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 {}
|
||||||
78
apps/api/src/abuse/abuse.service.ts
Normal file
78
apps/api/src/abuse/abuse.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/api/src/admin/admin-abuse.controller.ts
Normal file
58
apps/api/src/admin/admin-abuse.controller.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/admin/admin-audit.controller.ts
Normal file
31
apps/api/src/admin/admin-audit.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/api/src/admin/admin-audit.service.ts
Normal file
62
apps/api/src/admin/admin-audit.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/api/src/admin/admin-auth.util.ts
Normal file
18
apps/api/src/admin/admin-auth.util.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/api/src/admin/admin-dashboard.controller.ts
Normal file
22
apps/api/src/admin/admin-dashboard.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/api/src/admin/admin-dashboard.service.ts
Normal file
51
apps/api/src/admin/admin-dashboard.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/admin/admin-jobs.controller.ts
Normal file
31
apps/api/src/admin/admin-jobs.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/api/src/admin/admin-jobs.service.ts
Normal file
47
apps/api/src/admin/admin-jobs.service.ts
Normal 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(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/api/src/admin/admin-users.controller.ts
Normal file
57
apps/api/src/admin/admin-users.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/api/src/admin/admin-users.service.ts
Normal file
77
apps/api/src/admin/admin-users.service.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
apps/api/src/common/csrf/csrf.controller.ts
Normal file
20
apps/api/src/common/csrf/csrf.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/api/src/common/csrf/csrf.guard.ts
Normal file
19
apps/api/src/common/csrf/csrf.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/common/csrf/csrf.module.ts
Normal file
15
apps/api/src/common/csrf/csrf.module.ts
Normal 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 {}
|
||||||
77
apps/api/src/common/csrf/csrf.service.ts
Normal file
77
apps/api/src/common/csrf/csrf.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/api/src/common/interceptors/idempotency.interceptor.ts
Normal file
71
apps/api/src/common/interceptors/idempotency.interceptor.ts
Normal 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;
|
||||||
|
}
|
||||||
63
apps/api/src/compliance/compliance.controller.ts
Normal file
63
apps/api/src/compliance/compliance.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/api/src/compliance/compliance.module.ts
Normal file
16
apps/api/src/compliance/compliance.module.ts
Normal 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 {}
|
||||||
400
apps/api/src/compliance/compliance.service.ts
Normal file
400
apps/api/src/compliance/compliance.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/api/src/feature-flags/feature-flags.controller.ts
Normal file
37
apps/api/src/feature-flags/feature-flags.controller.ts
Normal 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/api/src/feature-flags/feature-flags.module.ts
Normal file
25
apps/api/src/feature-flags/feature-flags.module.ts
Normal 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 {}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
61
apps/api/src/notifications/notifications.controller.ts
Normal file
61
apps/api/src/notifications/notifications.controller.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/notifications/notifications.module.ts
Normal file
15
apps/api/src/notifications/notifications.module.ts
Normal 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 {}
|
||||||
166
apps/api/src/notifications/notifications.service.ts
Normal file
166
apps/api/src/notifications/notifications.service.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/api/src/organizations/organizations.controller.ts
Normal file
69
apps/api/src/organizations/organizations.controller.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/organizations/organizations.module.ts
Normal file
15
apps/api/src/organizations/organizations.module.ts
Normal 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 {}
|
||||||
217
apps/api/src/organizations/organizations.service.ts
Normal file
217
apps/api/src/organizations/organizations.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
119
apps/api/src/servers/members/members.controller.ts
Normal file
119
apps/api/src/servers/members/members.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/servers/members/members.module.ts
Normal file
8
apps/api/src/servers/members/members.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MembersController } from './members.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MembersController],
|
||||||
|
})
|
||||||
|
export class MembersModule {}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
apps/api/src/servers/schedules/schedules.controller.ts
Normal file
77
apps/api/src/servers/schedules/schedules.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/servers/schedules/schedules.module.ts
Normal file
15
apps/api/src/servers/schedules/schedules.module.ts
Normal 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 {}
|
||||||
150
apps/api/src/servers/schedules/schedules.service.ts
Normal file
150
apps/api/src/servers/schedules/schedules.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
40
apps/api/src/servers/software/software.controller.ts
Normal file
40
apps/api/src/servers/software/software.controller.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/servers/software/software.module.ts
Normal file
12
apps/api/src/servers/software/software.module.ts
Normal 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 {}
|
||||||
69
apps/api/src/support/support.controller.ts
Normal file
69
apps/api/src/support/support.controller.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/support/support.module.ts
Normal file
15
apps/api/src/support/support.module.ts
Normal 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 {}
|
||||||
143
apps/api/src/support/support.service.ts
Normal file
143
apps/api/src/support/support.service.ts
Normal 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
28
apps/docs/README.md
Normal 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
130
apps/docs/index.html
Normal 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
14
apps/docs/package.json
Normal 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
18
apps/e2e/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/e2e/playwright.config.ts
Normal file
22
apps/e2e/playwright.config.ts
Normal 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"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
52
apps/e2e/tests/smoke.spec.ts
Normal file
52
apps/e2e/tests/smoke.spec.ts
Normal 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
10
apps/e2e/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@hexahost/typescript-config/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "@playwright/test"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 0–9"
|
||||||
},
|
},
|
||||||
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 0–9"
|
||||||
},
|
},
|
||||||
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/web/src/app/[locale]/(account)/billing/page.tsx
Normal file
13
apps/web/src/app/[locale]/(account)/billing/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
24
apps/web/src/app/[locale]/(account)/layout.tsx
Normal file
24
apps/web/src/app/[locale]/(account)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/(account)/notifications/page.tsx
Normal file
13
apps/web/src/app/[locale]/(account)/notifications/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/(account)/profile/page.tsx
Normal file
13
apps/web/src/app/[locale]/(account)/profile/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/admin/audit/page.tsx
Normal file
13
apps/web/src/app/[locale]/admin/audit/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/admin/jobs/page.tsx
Normal file
13
apps/web/src/app/[locale]/admin/jobs/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
34
apps/web/src/app/[locale]/admin/layout.tsx
Normal file
34
apps/web/src/app/[locale]/admin/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/admin/nodes/page.tsx
Normal file
13
apps/web/src/app/[locale]/admin/nodes/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/admin/page.tsx
Normal file
13
apps/web/src/app/[locale]/admin/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/admin/users/page.tsx
Normal file
13
apps/web/src/app/[locale]/admin/users/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
48
apps/web/src/app/[locale]/docs/page.tsx
Normal file
48
apps/web/src/app/[locale]/docs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/web/src/app/[locale]/features/page.tsx
Normal file
30
apps/web/src/app/[locale]/features/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/app/[locale]/impressum/page.tsx
Normal file
37
apps/web/src/app/[locale]/impressum/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/web/src/app/[locale]/pricing/page.tsx
Normal file
51
apps/web/src/app/[locale]/pricing/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/app/[locale]/privacy/page.tsx
Normal file
29
apps/web/src/app/[locale]/privacy/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { PublicPageShell } from "@/components/layout/public-page-shell";
|
||||||
|
|
||||||
|
interface PrivacyPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PrivacyPage({ params }: PrivacyPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations("public.privacy");
|
||||||
|
|
||||||
|
const sections = ["controller", "data", "cookies", "rights", "contact"] 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">{t(`sections.${section}.body`)}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PublicPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { setRequestLocale } from "next-intl/server";
|
import { setRequestLocale } from "next-intl/server";
|
||||||
import { SecurityContent } from "@/components/auth/security-content";
|
import { SecurityContent } from "@/components/auth/security-content";
|
||||||
|
import { AccountNav } from "@/components/layout/account-nav";
|
||||||
|
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
|
||||||
|
|
||||||
interface SecurityPageProps {
|
interface SecurityPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
@@ -11,6 +13,10 @@ export default async function SecurityPage({ params }: SecurityPageProps) {
|
|||||||
|
|
||||||
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">
|
||||||
|
<AccountNav />
|
||||||
|
<DashboardUserInfo />
|
||||||
|
</div>
|
||||||
<SecurityContent />
|
<SecurityContent />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
13
apps/web/src/app/[locale]/servers/[id]/access/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/access/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { ServerAccess } from "@/components/servers/server-access";
|
||||||
|
|
||||||
|
interface ServerAccessPageProps {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServerAccessPage({ params }: ServerAccessPageProps) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return <ServerAccess serverId={id} />;
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { ServerSchedules } from "@/components/servers/server-schedules";
|
||||||
|
|
||||||
|
interface ServerSchedulesPageProps {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServerSchedulesPage({ params }: ServerSchedulesPageProps) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return <ServerSchedules serverId={id} />;
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/servers/[id]/software/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/software/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { ServerSoftware } from "@/components/servers/server-software";
|
||||||
|
|
||||||
|
interface ServerSoftwarePageProps {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServerSoftwarePage({ params }: ServerSoftwarePageProps) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return <ServerSoftware serverId={id} />;
|
||||||
|
}
|
||||||
@@ -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 ServersLayoutProps {
|
interface ServersLayoutProps {
|
||||||
@@ -11,32 +11,11 @@ interface ServersLayoutProps {
|
|||||||
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
|
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
|
||||||
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="App navigation">
|
<AccountNav />
|
||||||
<ul className="flex gap-4 text-sm">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
{t("dashboard")}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/servers"
|
|
||||||
className="font-medium text-zinc-900 dark:text-zinc-50"
|
|
||||||
aria-current="page"
|
|
||||||
>
|
|
||||||
{t("servers")}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<DashboardUserInfo />
|
<DashboardUserInfo />
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
38
apps/web/src/app/[locale]/status/page.tsx
Normal file
38
apps/web/src/app/[locale]/status/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { PublicPageShell } from "@/components/layout/public-page-shell";
|
||||||
|
|
||||||
|
interface StatusPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StatusPage({ params }: StatusPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations("public.status");
|
||||||
|
|
||||||
|
const components = ["api", "worker", "panel", "dns"] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicPageShell title={t("title")} description={t("description")}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{components.map((component) => (
|
||||||
|
<Card key={component}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t(`components.${component}.name`)}</CardTitle>
|
||||||
|
<CardDescription>{t(`components.${component}.description`)}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex rounded-full bg-emerald-100 px-3 py-1 font-mono text-xs text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
{t("operational")}
|
||||||
|
</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="font-mono text-xs text-zinc-500">{t("selfHostedNote")}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PublicPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/app/[locale]/terms/page.tsx
Normal file
29
apps/web/src/app/[locale]/terms/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { PublicPageShell } from "@/components/layout/public-page-shell";
|
||||||
|
|
||||||
|
interface TermsPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TermsPage({ params }: TermsPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations("public.terms");
|
||||||
|
|
||||||
|
const sections = ["scope", "usage", "billing", "liability", "changes"] 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">{t(`sections.${section}.body`)}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PublicPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user