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.
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 12s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
TheOnlyMace
2026-07-05 18:39:53 +02:00
parent bf36cb3159
commit 50cd4b3ffd
225 changed files with 17824 additions and 436 deletions

View File

@@ -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],
);
}
}

View File

@@ -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(