Compare commits
2 Commits
2126e7bebf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50cd4b3ffd | ||
|
|
bf36cb3159 |
10
.env.example
10
.env.example
@@ -89,6 +89,16 @@ STRIPE_WEBHOOK_SECRET=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
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) ---
|
||||
API_PORT=3001
|
||||
WEB_PORT=3000
|
||||
|
||||
2
.turbo/cache/083c8105a3916fd9-manifest.json
vendored
2
.turbo/cache/083c8105a3916fd9-manifest.json
vendored
@@ -1 +1 @@
|
||||
{"files":{"packages/auth/dist/username.d.ts":{"size":442,"mtime_nanos":1783255151494048909,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js":{"size":1089,"mtime_nanos":1783255151493710470,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts.map":{"size":281,"mtime_nanos":1783255151493885819,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts.map":{"size":430,"mtime_nanos":1783255151494073119,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts":{"size":200,"mtime_nanos":1783255151493536190,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts.map":{"size":306,"mtime_nanos":1783255151493686290,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts.map":{"size":266,"mtime_nanos":1783255151493572620,"mode":420,"is_dir":false},"packages/auth/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1783255052127378550,"mode":420,"is_dir":false},"packages/auth/dist/session.js":{"size":672,"mtime_nanos":1783255151493811820,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts.map":{"size":303,"mtime_nanos":1783255151493978659,"mode":420,"is_dir":false},"packages/auth/dist/index.js.map":{"size":824,"mtime_nanos":1783255151493485050,"mode":420,"is_dir":false},"packages/auth/dist/totp.js.map":{"size":569,"mtime_nanos":1783255151494024989,"mode":420,"is_dir":false},"packages/auth/dist/username.js":{"size":2787,"mtime_nanos":1783255151494096559,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts.map":{"size":300,"mtime_nanos":1783255151493784580,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts":{"size":1570,"mtime_nanos":1783255151493314190,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts.map":{"size":1443,"mtime_nanos":1783255151493364820,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js.map":{"size":760,"mtime_nanos":1783255151493932809,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts":{"size":269,"mtime_nanos":1783255151493955819,"mode":420,"is_dir":false},"packages/auth/dist/index.js":{"size":4135,"mtime_nanos":1783255151493407900,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js.map":{"size":949,"mtime_nanos":1783255151493735800,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts":{"size":244,"mtime_nanos":1783255151493861049,"mode":420,"is_dir":false},"packages/auth/dist/totp.js":{"size":683,"mtime_nanos":1783255151494002079,"mode":420,"is_dir":false},"packages/auth/dist/username.js.map":{"size":2294,"mtime_nanos":1783255151494119969,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js":{"size":864,"mtime_nanos":1783255151493909599,"mode":420,"is_dir":false},"packages/auth/dist/session.js.map":{"size":493,"mtime_nanos":1783255151493836729,"mode":420,"is_dir":false},"packages/auth/dist/password.js":{"size":482,"mtime_nanos":1783255151493603670,"mode":420,"is_dir":false},"packages/auth/dist/password.js.map":{"size":404,"mtime_nanos":1783255151493633310,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts":{"size":264,"mtime_nanos":1783255151493659970,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts":{"size":334,"mtime_nanos":1783255151493759480,"mode":420,"is_dir":false}},"order":["packages/auth/.turbo/turbo-build.log","packages/auth/dist/index.d.ts","packages/auth/dist/index.d.ts.map","packages/auth/dist/index.js","packages/auth/dist/index.js.map","packages/auth/dist/password.d.ts","packages/auth/dist/password.d.ts.map","packages/auth/dist/password.js","packages/auth/dist/password.js.map","packages/auth/dist/recovery-codes.d.ts","packages/auth/dist/recovery-codes.d.ts.map","packages/auth/dist/recovery-codes.js","packages/auth/dist/recovery-codes.js.map","packages/auth/dist/session.d.ts","packages/auth/dist/session.d.ts.map","packages/auth/dist/session.js","packages/auth/dist/session.js.map","packages/auth/dist/tokens.d.ts","packages/auth/dist/tokens.d.ts.map","packages/auth/dist/tokens.js","packages/auth/dist/tokens.js.map","packages/auth/dist/totp.d.ts","packages/auth/dist/totp.d.ts.map","packages/auth/dist/totp.js","packages/auth/dist/totp.js.map","packages/auth/dist/username.d.ts","packages/auth/dist/username.d.ts.map","packages/auth/dist/username.js","packages/auth/dist/username.js.map"]}
|
||||
{"files":{"packages/auth/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1783255052127378550,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts":{"size":200,"mtime_nanos":1783255318234684822,"mode":420,"is_dir":false},"packages/auth/dist/totp.js":{"size":683,"mtime_nanos":1783255318235066252,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts":{"size":269,"mtime_nanos":1783255318235033232,"mode":420,"is_dir":false},"packages/auth/dist/session.js":{"size":672,"mtime_nanos":1783255318234925202,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js.map":{"size":949,"mtime_nanos":1783255318234865402,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts":{"size":1570,"mtime_nanos":1783255318234480642,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts":{"size":264,"mtime_nanos":1783255318234800762,"mode":420,"is_dir":false},"packages/auth/dist/username.js":{"size":2787,"mtime_nanos":1783255318235144382,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js.map":{"size":760,"mtime_nanos":1783255318235017152,"mode":420,"is_dir":false},"packages/auth/dist/username.js.map":{"size":2294,"mtime_nanos":1783255318235160872,"mode":420,"is_dir":false},"packages/auth/dist/password.d.ts.map":{"size":266,"mtime_nanos":1783255318234717722,"mode":420,"is_dir":false},"packages/auth/dist/index.js.map":{"size":824,"mtime_nanos":1783255318234638252,"mode":420,"is_dir":false},"packages/auth/dist/session.js.map":{"size":493,"mtime_nanos":1783255318234944292,"mode":420,"is_dir":false},"packages/auth/dist/index.d.ts.map":{"size":1443,"mtime_nanos":1783255318234518132,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts":{"size":334,"mtime_nanos":1783255318234883722,"mode":420,"is_dir":false},"packages/auth/dist/password.js":{"size":482,"mtime_nanos":1783255318234755462,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.js":{"size":1089,"mtime_nanos":1783255318234843272,"mode":420,"is_dir":false},"packages/auth/dist/recovery-codes.d.ts.map":{"size":306,"mtime_nanos":1783255318234822282,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts.map":{"size":430,"mtime_nanos":1783255318235123102,"mode":420,"is_dir":false},"packages/auth/dist/password.js.map":{"size":404,"mtime_nanos":1783255318234775572,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts.map":{"size":281,"mtime_nanos":1783255318234984402,"mode":420,"is_dir":false},"packages/auth/dist/username.d.ts":{"size":442,"mtime_nanos":1783255318235106382,"mode":420,"is_dir":false},"packages/auth/dist/index.js":{"size":4135,"mtime_nanos":1783255318234572542,"mode":420,"is_dir":false},"packages/auth/dist/totp.d.ts.map":{"size":303,"mtime_nanos":1783255318235050102,"mode":420,"is_dir":false},"packages/auth/dist/tokens.js":{"size":864,"mtime_nanos":1783255318235000782,"mode":420,"is_dir":false},"packages/auth/dist/session.d.ts.map":{"size":300,"mtime_nanos":1783255318234901542,"mode":420,"is_dir":false},"packages/auth/dist/totp.js.map":{"size":569,"mtime_nanos":1783255318235085572,"mode":420,"is_dir":false},"packages/auth/dist/tokens.d.ts":{"size":244,"mtime_nanos":1783255318234964842,"mode":420,"is_dir":false}},"order":["packages/auth/.turbo/turbo-build.log","packages/auth/dist/index.d.ts","packages/auth/dist/index.d.ts.map","packages/auth/dist/index.js","packages/auth/dist/index.js.map","packages/auth/dist/password.d.ts","packages/auth/dist/password.d.ts.map","packages/auth/dist/password.js","packages/auth/dist/password.js.map","packages/auth/dist/recovery-codes.d.ts","packages/auth/dist/recovery-codes.d.ts.map","packages/auth/dist/recovery-codes.js","packages/auth/dist/recovery-codes.js.map","packages/auth/dist/session.d.ts","packages/auth/dist/session.d.ts.map","packages/auth/dist/session.js","packages/auth/dist/session.js.map","packages/auth/dist/tokens.d.ts","packages/auth/dist/tokens.d.ts.map","packages/auth/dist/tokens.js","packages/auth/dist/tokens.js.map","packages/auth/dist/totp.d.ts","packages/auth/dist/totp.d.ts.map","packages/auth/dist/totp.js","packages/auth/dist/totp.js.map","packages/auth/dist/username.d.ts","packages/auth/dist/username.d.ts.map","packages/auth/dist/username.js","packages/auth/dist/username.js.map"]}
|
||||
1
.turbo/cache/1b4ddb8b5e9b564a-manifest.json
vendored
Normal file
1
.turbo/cache/1b4ddb8b5e9b564a-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/.turbo/turbo-build.log":{"size":549,"mtime_nanos":1783255321226890055,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts":{"size":991,"mtime_nanos":1783255321208338929,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":733,"mtime_nanos":1783255321207890090,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1783255321192311503,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1783255321191890119,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/1b4ddb8b5e9b564a-meta.json
vendored
Normal file
1
.turbo/cache/1b4ddb8b5e9b564a-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"1b4ddb8b5e9b564a","duration":2992,"sha":"2126e7bebf9ff0796ac77f8548cba399f45e759a","dirty_hash":null}
|
||||
BIN
.turbo/cache/1b4ddb8b5e9b564a.tar.zst
vendored
Normal file
BIN
.turbo/cache/1b4ddb8b5e9b564a.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/8de6070230e6cb7f-manifest.json
vendored
Normal file
1
.turbo/cache/8de6070230e6cb7f-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/8de6070230e6cb7f-meta.json
vendored
Normal file
1
.turbo/cache/8de6070230e6cb7f-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"8de6070230e6cb7f","duration":1207,"sha":"2126e7bebf9ff0796ac77f8548cba399f45e759a","dirty_hash":null}
|
||||
BIN
.turbo/cache/8de6070230e6cb7f.tar.zst
vendored
Normal file
BIN
.turbo/cache/8de6070230e6cb7f.tar.zst
vendored
Normal file
Binary file not shown.
@@ -137,7 +137,7 @@ pnpm db:migrate
|
||||
pnpm dev
|
||||
|
||||
# Admin anlegen (keine Default-Zugangsdaten)
|
||||
pnpm bootstrap:admin -- --email admin@localhost --password 'min-12-chars-here'
|
||||
pnpm bootstrap:admin -- --email admin@example.com --password 'min-12-chars-here'
|
||||
```
|
||||
|
||||
| Flow | URL |
|
||||
|
||||
@@ -21,16 +21,22 @@
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/dns": "workspace:*",
|
||||
"@hexahost/feature-flags": "workspace:*",
|
||||
"@hexahost/otel": "workspace:*",
|
||||
"@hexahost/integration-auth": "workspace:*",
|
||||
"@hexahost/metering": "workspace:*",
|
||||
"@hexahost/permissions": "workspace:*",
|
||||
"@hexahost/scheduler": "workspace:*",
|
||||
"@hexahost/server-state": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@nestjs/common": "^11.0.7",
|
||||
"@nestjs/core": "^11.0.7",
|
||||
"@nestjs/platform-fastify": "^11.0.7",
|
||||
"@nestjs/swagger": "^11.0.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"bullmq": "^5.34.8",
|
||||
"cron-parser": "^5.0.4",
|
||||
"fastify": "^5.2.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"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 { AbuseModule } from '../abuse/abuse.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 { AdminNodesService } from './admin-nodes.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [AdminNodesController],
|
||||
providers: [AdminNodesService],
|
||||
imports: [AuthModule, AbuseModule],
|
||||
controllers: [
|
||||
AdminNodesController,
|
||||
AdminDashboardController,
|
||||
AdminAuditController,
|
||||
AdminJobsController,
|
||||
AdminUsersController,
|
||||
AdminAbuseController,
|
||||
],
|
||||
providers: [
|
||||
AdminNodesService,
|
||||
AdminDashboardService,
|
||||
AdminAuditService,
|
||||
AdminJobsService,
|
||||
AdminUsersService,
|
||||
],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -13,10 +19,14 @@ import { EdgeModule } from './edge/edge.module';
|
||||
import { ServersModule } from './servers/servers.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 { 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({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
@@ -51,12 +61,27 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
ServersModule,
|
||||
NodesModule,
|
||||
AdminModule,
|
||||
NotificationsModule,
|
||||
ComplianceModule,
|
||||
FeatureFlagsModule,
|
||||
OrganizationsModule,
|
||||
SupportModule,
|
||||
AbuseModule,
|
||||
CsrfModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: CsrfGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: IdempotencyInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
@@ -221,4 +222,35 @@ export class AuthController {
|
||||
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 {}
|
||||
|
||||
@@ -712,4 +712,77 @@ export class AuthService {
|
||||
|
||||
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';
|
||||
|
||||
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')
|
||||
@Controller('catalog')
|
||||
@UseGuards(SessionGuard)
|
||||
@@ -17,6 +30,55 @@ export class CatalogController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Search Modrinth projects' })
|
||||
search(@Query() query: Record<string, string | undefined>) {
|
||||
@@ -64,4 +126,17 @@ export class CatalogController {
|
||||
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,
|
||||
resolveModrinthLoader,
|
||||
resolveProjectType,
|
||||
searchCurseForgeMods,
|
||||
searchModrinthProjects,
|
||||
type SoftwareFamily,
|
||||
} 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')
|
||||
provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
|
||||
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')
|
||||
createServiceSso(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@@ -284,6 +314,54 @@ export class WhmcsIntegrationController {
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { BackupsModule } from '../../servers/backups/backups.module';
|
||||
import { BillingModule } from '../../billing/billing.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { ServersModule } from '../../servers/servers.module';
|
||||
@@ -20,7 +21,7 @@ import { WhmcsUsageService } from './whmcs-usage.service';
|
||||
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||
|
||||
@Module({
|
||||
imports: [ServersModule, BillingModule, AuthModule],
|
||||
imports: [ServersModule, BackupsModule, BillingModule, AuthModule],
|
||||
controllers: [WhmcsIntegrationController],
|
||||
providers: [
|
||||
WhmcsIntegrationService,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { GameServer } from '@hexahost/database';
|
||||
import { BillingService } from '../../billing/billing.service';
|
||||
import { getBillingProvider } from '../../billing/billing-provider';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { BackupsService } from '../../servers/backups/backups.service';
|
||||
import { ServersService } from '../../servers/servers.service';
|
||||
|
||||
import type { WhmcsInstallationContext } from './whmcs-integration.guard';
|
||||
@@ -34,6 +35,7 @@ export class WhmcsIntegrationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serversService: ServersService,
|
||||
private readonly backupsService: BackupsService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
@@ -356,6 +358,67 @@ export class WhmcsIntegrationService {
|
||||
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(
|
||||
installation: InstallationRef,
|
||||
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 { validateConfig } from '@hexahost/config';
|
||||
import { initOpenTelemetry, shutdownOpenTelemetry } from '@hexahost/otel';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { Rfc7807ExceptionFilter } from './common/filters/rfc7807-exception.filter';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const config = validateConfig();
|
||||
initOpenTelemetry('hexahost-api');
|
||||
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
|
||||
@@ -167,6 +167,9 @@ export class NodesService {
|
||||
case 'server.files.list.result':
|
||||
case 'server.files.read.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':
|
||||
await this.handleDirectResult(envelope);
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
@@ -11,9 +15,13 @@ import {
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
archiveFilesSchema,
|
||||
filePathSchema,
|
||||
listFilesQuerySchema,
|
||||
readFileContentQuerySchema,
|
||||
unarchiveFileSchema,
|
||||
updateFileContentSchema,
|
||||
uploadFileSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
@@ -30,7 +38,6 @@ export class FilesController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List files in the server data directory' })
|
||||
@ApiResponse({ status: 200, description: 'Directory listing' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@@ -41,7 +48,6 @@ export class FilesController {
|
||||
|
||||
@Get('content')
|
||||
@ApiOperation({ summary: 'Read a text file from the server' })
|
||||
@ApiResponse({ status: 200, description: 'File content' })
|
||||
read(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@@ -53,7 +59,6 @@ export class FilesController {
|
||||
|
||||
@Put('content')
|
||||
@ApiOperation({ summary: 'Write a text file on the server' })
|
||||
@ApiResponse({ status: 200, description: 'Updated file content' })
|
||||
write(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@@ -65,4 +70,59 @@ export class FilesController {
|
||||
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';
|
||||
|
||||
import type {
|
||||
ArchiveFiles,
|
||||
FileContent,
|
||||
FileListResponse,
|
||||
UnarchiveFile,
|
||||
UpdateFileContent,
|
||||
UploadFile,
|
||||
} from '@hexahost/contracts';
|
||||
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']);
|
||||
|
||||
interface AgentFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir?: boolean;
|
||||
type?: 'file' | 'directory';
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
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(
|
||||
@@ -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(
|
||||
@@ -98,6 +126,7 @@ export class FilesService {
|
||||
path: safePath,
|
||||
content: input.content,
|
||||
encoding: input.encoding,
|
||||
create: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -115,7 +144,140 @@ export class FilesService {
|
||||
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(
|
||||
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} 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 { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { PlayersService } from './players.service';
|
||||
|
||||
@@ -19,12 +32,105 @@ export class PlayersController {
|
||||
constructor(private readonly playersService: PlayersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List online players, whitelist, and operators' })
|
||||
@ApiResponse({ status: 200, description: 'Player overview' })
|
||||
@ApiOperation({ summary: 'List online players, whitelist, operators, and bans' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
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,
|
||||
} 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 { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
@@ -13,6 +19,7 @@ import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
const WHITELIST_FILE = 'whitelist.json';
|
||||
const OPS_FILE = 'ops.json';
|
||||
const BANS_FILE = 'banned-players.json';
|
||||
|
||||
@Injectable()
|
||||
export class PlayersService {
|
||||
@@ -32,17 +39,114 @@ export class PlayersService {
|
||||
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.readJsonFile(userId, serverId, WHITELIST_FILE, []),
|
||||
this.readJsonFile(userId, serverId, OPS_FILE, []),
|
||||
this.readJsonFile(userId, serverId, BANS_FILE, []),
|
||||
]);
|
||||
|
||||
return {
|
||||
online,
|
||||
whitelist,
|
||||
operators,
|
||||
};
|
||||
return { online, whitelist, operators, bans };
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -106,4 +210,39 @@ export class PlayersService {
|
||||
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 type { GameServer } from '@hexahost/database';
|
||||
|
||||
type GameServerStatus = GameServer['status'];
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
|
||||
DRAFT: ['PROVISIONING', 'DELETING'],
|
||||
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
|
||||
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||
STOPPED: ['STARTING', 'QUEUED', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'],
|
||||
QUEUED: ['STARTING', 'STOPPED', 'DELETING'],
|
||||
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'],
|
||||
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',
|
||||
];
|
||||
import {
|
||||
ALLOWED_TRANSITIONS,
|
||||
IN_PROGRESS_STATUSES,
|
||||
assertTransition,
|
||||
isInProgress,
|
||||
resolveKillTarget,
|
||||
resolveStartTarget,
|
||||
resolveStopTarget,
|
||||
type GameServerStatus,
|
||||
} from '@hexahost/server-state';
|
||||
|
||||
@Injectable()
|
||||
export class ServerStateService {
|
||||
@@ -36,44 +17,56 @@ export class ServerStateService {
|
||||
fromStatus: GameServerStatus,
|
||||
toStatus: GameServerStatus,
|
||||
): void {
|
||||
const allowed = ALLOWED_TRANSITIONS[fromStatus];
|
||||
|
||||
if (!allowed.includes(toStatus)) {
|
||||
try {
|
||||
assertTransition(fromStatus, toStatus);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`Invalid state transition from ${fromStatus} to ${toStatus}`,
|
||||
error instanceof Error ? error.message : 'Invalid state transition',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertNotInProgress(status: GameServerStatus): void {
|
||||
if (IN_PROGRESS_STATUSES.includes(status)) {
|
||||
if (isInProgress(status)) {
|
||||
throw new BadRequestException('Server operation already in progress');
|
||||
}
|
||||
}
|
||||
|
||||
resolveStartTarget(status: GameServerStatus): GameServerStatus {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
case 'ERROR':
|
||||
return 'PROVISIONING';
|
||||
case 'STOPPED':
|
||||
case 'UNKNOWN':
|
||||
case 'QUEUED':
|
||||
return 'STARTING';
|
||||
default:
|
||||
try {
|
||||
return resolveStartTarget(status);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`Cannot start server while in status ${status}`,
|
||||
error instanceof Error ? error.message : 'Cannot start server',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resolveStopTarget(status: GameServerStatus): GameServerStatus {
|
||||
if (status !== 'RUNNING') {
|
||||
try {
|
||||
return resolveStopTarget(status);
|
||||
} catch (error) {
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 { SessionGuard } from '../auth/guards/session.guard';
|
||||
@@ -90,6 +92,40 @@ export class ServersController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Get start queue status for a server' })
|
||||
startQueue(
|
||||
|
||||
@@ -9,8 +9,12 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FeatureFlagsModule } from '../feature-flags/feature-flags.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 { AddonsModule } from './addons/addons.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
@@ -34,6 +38,7 @@ import { PlansService } from './plans.service';
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
FeatureFlagsModule,
|
||||
SchedulingModule,
|
||||
BillingModule,
|
||||
IdleModule,
|
||||
@@ -44,6 +49,9 @@ import { PlansService } from './plans.service';
|
||||
forwardRef(() => FilesModule),
|
||||
forwardRef(() => PropertiesModule),
|
||||
forwardRef(() => PlayersModule),
|
||||
forwardRef(() => MembersModule),
|
||||
forwardRef(() => SchedulesModule),
|
||||
forwardRef(() => SoftwareModule),
|
||||
forwardRef(() => NodeBridgeModule),
|
||||
],
|
||||
controllers: [ServersController, PlansController],
|
||||
@@ -88,6 +96,7 @@ import { PlansService } from './plans.service';
|
||||
exports: [
|
||||
ServersService,
|
||||
REDIS_CLIENT,
|
||||
SERVER_LIFECYCLE_QUEUE,
|
||||
ConsoleModule,
|
||||
NodeBridgeModule,
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import type Redis from 'ioredis';
|
||||
@@ -16,10 +17,12 @@ import type {
|
||||
ServerResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
import { FeatureFlagService } from '@hexahost/feature-flags';
|
||||
import { formatJoinAddress } from '@hexahost/dns';
|
||||
|
||||
type GameServerStatus = GameServer['status'];
|
||||
|
||||
import { FEATURE_FLAG_SERVICE } from '../feature-flags/feature-flags.module';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { SchedulingService } from '../scheduling/scheduling.service';
|
||||
@@ -45,12 +48,14 @@ export class ServersService {
|
||||
private readonly lifecycleQueue: Queue,
|
||||
@Inject(SERVER_PROVISIONING_QUEUE)
|
||||
private readonly provisioningQueue: Queue,
|
||||
@Inject(FEATURE_FLAG_SERVICE) private readonly featureFlags: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async createServer(
|
||||
userId: string,
|
||||
input: CreateServerRequest,
|
||||
): Promise<ServerResponse> {
|
||||
await this.assertMaintenanceAllowed('create');
|
||||
await this.billing.assertCanCreateServer(userId, input.planId);
|
||||
|
||||
const server = await this.prisma.gameServer.create({
|
||||
@@ -108,6 +113,7 @@ export class ServersService {
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
await this.assertMaintenanceAllowed('start');
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.assertServerNotBillingSuspended(server);
|
||||
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(
|
||||
nodeId: string,
|
||||
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(
|
||||
gameServerId: string,
|
||||
fromStatus: GameServerStatus | null,
|
||||
|
||||
@@ -3,25 +3,75 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
import {
|
||||
assertPermission,
|
||||
type ServerMemberRole,
|
||||
type ServerPermission,
|
||||
} from '@hexahost/permissions';
|
||||
|
||||
import type { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface ServerAccessContext {
|
||||
server: GameServer;
|
||||
role: ServerMemberRole;
|
||||
permissionOverrides: ServerPermission[] | null;
|
||||
}
|
||||
|
||||
export async function findOwnedServer(
|
||||
prisma: PrismaService,
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): 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({
|
||||
where: { id: serverId },
|
||||
include: {
|
||||
members: {
|
||||
where: { userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
throw new NotFoundException('Server not found');
|
||||
}
|
||||
|
||||
if (server.userId !== userId) {
|
||||
throw new ForbiddenException('You do not own this server');
|
||||
let role: ServerMemberRole;
|
||||
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');
|
||||
}
|
||||
|
||||
return server;
|
||||
role = membership.role as ServerMemberRole;
|
||||
overrides = Array.isArray(membership.permissions)
|
||||
? (membership.permissions as ServerPermission[])
|
||||
: null;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func ExtractTarGz(archivePath, destDir string) error {
|
||||
destDir = filepath.Clean(destDir)
|
||||
|
||||
@@ -114,8 +114,18 @@ func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServer
|
||||
}},
|
||||
Resources: container.Resources{
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
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.
|
||||
func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error {
|
||||
opts := container.LogsOptions{
|
||||
|
||||
@@ -229,6 +229,31 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
|
||||
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.
|
||||
func DeleteFile(dataRoot, relativePath string) error {
|
||||
filePath, err := ResolvePath(dataRoot, relativePath)
|
||||
|
||||
@@ -38,6 +38,9 @@ const (
|
||||
TypeServerFilesList MessageType = "server.files.list"
|
||||
TypeServerFilesRead MessageType = "server.files.read"
|
||||
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"
|
||||
TypeServerWorldValidate MessageType = "server.world.validate"
|
||||
TypeServerWorldArchive MessageType = "server.world.archive"
|
||||
@@ -76,6 +79,9 @@ var controlToAgentTypes = map[MessageType]struct{}{
|
||||
TypeServerFilesList: {},
|
||||
TypeServerFilesRead: {},
|
||||
TypeServerFilesWrite: {},
|
||||
TypeServerFilesDelete: {},
|
||||
TypeServerFilesArchive: {},
|
||||
TypeServerFilesUnarchive: {},
|
||||
TypeServerFilesUploadDone: {},
|
||||
TypeServerWorldValidate: {},
|
||||
TypeServerWorldArchive: {},
|
||||
@@ -188,6 +194,27 @@ type ServerFilesWritePayload struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
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.
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/archive"
|
||||
"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.
|
||||
func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
|
||||
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)
|
||||
|
||||
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:
|
||||
var meta protocol.OperationMeta
|
||||
if err := json.Unmarshal(env.Payload, &meta); err != nil {
|
||||
|
||||
@@ -15,10 +15,19 @@
|
||||
"cancel": "Abbrechen",
|
||||
"back": "Zurück",
|
||||
"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": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
"badge": "MVP · Phase 9 abgeschlossen",
|
||||
"title": "Minecraft-Server on demand",
|
||||
"subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.",
|
||||
"ctaPrimary": "Jetzt starten",
|
||||
@@ -205,7 +214,36 @@
|
||||
"players": "Spieler",
|
||||
"worlds": "Welten",
|
||||
"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": {
|
||||
"loading": "Wird geladen…",
|
||||
@@ -233,10 +271,27 @@
|
||||
"save": "Speichern",
|
||||
"saving": "Wird 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": {
|
||||
"loadFailed": "Verzeichnis konnte nicht geladen 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": {
|
||||
@@ -280,14 +335,32 @@
|
||||
"loading": "Wird geladen…",
|
||||
"onlineCount": "Spieler online",
|
||||
"whitelistCount": "Whitelist-Einträge",
|
||||
"operatorsCount": "Operatoren",
|
||||
"onlineTitle": "Spieler online",
|
||||
"whitelistTitle": "Whitelist",
|
||||
"operatorsTitle": "Operatoren",
|
||||
"bansTitle": "Gebannte Spieler",
|
||||
"bansEmpty": "Keine gebannten Spieler.",
|
||||
"empty": "Keine Spieler online.",
|
||||
"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": {
|
||||
"name": "Spieler",
|
||||
"ping": "Ping"
|
||||
"ping": "Ping",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Spielerliste konnte nicht geladen werden."
|
||||
"loadFailed": "Spielerliste konnte nicht geladen werden.",
|
||||
"actionFailed": "Aktion fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"worlds": {
|
||||
@@ -347,6 +420,473 @@
|
||||
"remove": "Entfernen",
|
||||
"installed": "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",
|
||||
"back": "Back",
|
||||
"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": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
"badge": "MVP · Phase 9 complete",
|
||||
"title": "Minecraft servers on demand",
|
||||
"subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.",
|
||||
"ctaPrimary": "Get started",
|
||||
@@ -205,7 +214,36 @@
|
||||
"players": "Players",
|
||||
"worlds": "Worlds",
|
||||
"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": {
|
||||
"loading": "Loading…",
|
||||
@@ -233,10 +271,27 @@
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"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": {
|
||||
"loadFailed": "Could not load directory.",
|
||||
"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": {
|
||||
@@ -280,14 +335,32 @@
|
||||
"loading": "Loading…",
|
||||
"onlineCount": "Online players",
|
||||
"whitelistCount": "Whitelist entries",
|
||||
"operatorsCount": "Operators",
|
||||
"onlineTitle": "Online players",
|
||||
"whitelistTitle": "Whitelist",
|
||||
"operatorsTitle": "Operators",
|
||||
"bansTitle": "Banned players",
|
||||
"bansEmpty": "No banned players.",
|
||||
"empty": "No players online.",
|
||||
"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": {
|
||||
"name": "Player",
|
||||
"ping": "Ping"
|
||||
"ping": "Ping",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Could not load player list."
|
||||
"loadFailed": "Could not load player list.",
|
||||
"actionFailed": "Action failed."
|
||||
}
|
||||
},
|
||||
"worlds": {
|
||||
@@ -347,6 +420,473 @@
|
||||
"remove": "Remove",
|
||||
"installed": "Installed",
|
||||
"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 { Link } from "@/i18n/navigation";
|
||||
import { AccountNav } from "@/components/layout/account-nav";
|
||||
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
@@ -14,32 +14,11 @@ export default async function DashboardLayout({
|
||||
}: DashboardLayoutProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("common");
|
||||
|
||||
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">
|
||||
<nav aria-label="Dashboard navigation">
|
||||
<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>
|
||||
<AccountNav />
|
||||
<DashboardUserInfo />
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user