Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user