Phase3
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:17:26 +02:00
parent c4077d4673
commit 9b061c3ee7
92 changed files with 4128 additions and 146 deletions

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
listFilesQuerySchema,
readFileContentQuerySchema,
updateFileContentSchema,
} 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 { FilesService } from './files.service';
@ApiTags('servers')
@Controller('servers/:serverId/files')
@UseGuards(SessionGuard)
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@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,
@Query(new ZodValidationPipe(listFilesQuerySchema)) query: { path: string },
) {
return this.filesService.listFiles(user.id, serverId, query.path);
}
@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,
@Query(new ZodValidationPipe(readFileContentQuerySchema))
query: { path: string },
) {
return this.filesService.readFile(user.id, serverId, query.path);
}
@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,
@Body(new ZodValidationPipe(updateFileContentSchema)) body: unknown,
) {
return this.filesService.writeFile(
user.id,
serverId,
body as Parameters<FilesService['writeFile']>[2],
);
}
}

View File

@@ -0,0 +1,15 @@
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
@Module({
imports: [AuthModule, forwardRef(() => NodeBridgeModule)],
controllers: [FilesController],
providers: [FilesService],
exports: [FilesService],
})
export class FilesModule {}

View File

@@ -0,0 +1,139 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import type {
FileContent,
FileListResponse,
UpdateFileContent,
} from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
import { AuditService } from '../../auth/audit.service';
import { NodeBridgeService } from '../../nodes/node-bridge.service';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import { assertSafeServerPath } from '../shared/server-path.util';
const FILE_ACCESS_STATUSES = new Set<GameServer['status']>(['RUNNING', 'STOPPED']);
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaService,
private readonly nodeBridge: NodeBridgeService,
private readonly auditService: AuditService,
) {}
async listFiles(
userId: string,
serverId: string,
path: string,
): Promise<FileListResponse> {
const server = await this.requireFileAccess(userId, serverId);
const safePath = assertSafeServerPath(path);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.list',
{
serverId,
generation: server.version,
path: safePath,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to list files',
);
}
return response.result as FileListResponse;
}
async readFile(
userId: string,
serverId: string,
path: string,
): Promise<FileContent> {
const server = await this.requireFileAccess(userId, serverId);
const safePath = assertSafeServerPath(path);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.read',
{
serverId,
generation: server.version,
path: safePath,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to read file',
);
}
return response.result as FileContent;
}
async writeFile(
userId: string,
serverId: string,
input: UpdateFileContent,
): Promise<FileContent> {
const server = await this.requireFileAccess(userId, serverId);
const safePath = assertSafeServerPath(input.path);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.write',
{
serverId,
generation: server.version,
path: safePath,
content: input.content,
encoding: input.encoding,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to write file',
);
}
await this.auditService.record({
action: 'server.file.write',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { path: safePath },
});
return response.result as FileContent;
}
private async requireFileAccess(
userId: string,
serverId: string,
): Promise<GameServer> {
const server = await findOwnedServer(this.prisma, userId, serverId);
if (!server.nodeId) {
throw new BadRequestException('Server is not assigned to a node');
}
if (!FILE_ACCESS_STATUSES.has(server.status)) {
throw new BadRequestException(
`File access is not available while server is ${server.status}`,
);
}
return server;
}
}