Phase3
This commit is contained in:
68
apps/api/src/servers/files/files.controller.ts
Normal file
68
apps/api/src/servers/files/files.controller.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/servers/files/files.module.ts
Normal file
15
apps/api/src/servers/files/files.module.ts
Normal 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 {}
|
||||
139
apps/api/src/servers/files/files.service.ts
Normal file
139
apps/api/src/servers/files/files.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user