feat: Add files module — VueFinder-compatible REST API over NATS
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Implements GET/POST /api/files proxy for all VueFinder operations
(list, search, preview, download, delete, rename, move, copy, mkdir,
new-file, save, upload). Routes via NATS request-reply to the companion
agent on corrosion.{license_id}.files.cmd with 30s timeout. Gated
behind files.view (GET) and files.manage (POST) permissions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
121
backend-nest/src/modules/files/files.controller.ts
Normal file
121
backend-nest/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@ApiTags('Files')
|
||||
@ApiBearerAuth()
|
||||
@Controller('files')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
// VueFinder GET operations: ?q=index (list), ?q=search, ?q=preview, ?q=download
|
||||
@Get()
|
||||
@RequirePermission('files.view')
|
||||
async handleGet(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Query('path') path: string,
|
||||
@Query('filter') filter: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'index':
|
||||
case undefined:
|
||||
case '':
|
||||
return this.filesService.list(licenseId, path || 'server://');
|
||||
case 'search':
|
||||
return this.filesService.search(licenseId, path || 'server://', filter);
|
||||
case 'preview':
|
||||
return this.filesService.preview(licenseId, path);
|
||||
case 'download': {
|
||||
const result = await this.filesService.download(licenseId, path);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${result.filename}"`,
|
||||
);
|
||||
res.send(result.content);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VueFinder POST operations: ?q=delete, rename, move, copy, new-folder, new-file, save, upload
|
||||
@Post()
|
||||
@RequirePermission('files.manage')
|
||||
@UseInterceptors(FileInterceptor('upload'))
|
||||
async handlePost(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Body() body: Record<string, any>,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'delete':
|
||||
return this.filesService.delete(licenseId, body.path, body.items);
|
||||
case 'rename':
|
||||
return this.filesService.rename(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.item,
|
||||
body.name,
|
||||
);
|
||||
case 'move':
|
||||
return this.filesService.move(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'copy':
|
||||
return this.filesService.copy(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'new-folder':
|
||||
case 'mkdir':
|
||||
return this.filesService.createFolder(licenseId, body.path, body.name);
|
||||
case 'new-file':
|
||||
case 'newfile':
|
||||
return this.filesService.createFile(licenseId, body.path, body.name);
|
||||
case 'save':
|
||||
return this.filesService.save(licenseId, body.path, body.content);
|
||||
case 'upload':
|
||||
if (!file) {
|
||||
throw new HttpException('No file provided', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.filesService.upload(licenseId, body.path, file);
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/modules/files/files.module.ts
Normal file
10
backend-nest/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesService } from './files.service';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService, NatsService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
176
backend-nest/src/modules/files/files.service.ts
Normal file
176
backend-nest/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface AgentFileResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
filename: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
private readonly logger = new Logger(FilesService.name);
|
||||
|
||||
constructor(private readonly natsService: NatsService) {}
|
||||
|
||||
// Send a file manager command to the companion agent via NATS request-reply.
|
||||
// The agent returns { success: bool, error?: string, data?: VueFinderResponse }.
|
||||
private async sendFileCommand(
|
||||
licenseId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const subject = `corrosion.${licenseId}.files.cmd`;
|
||||
try {
|
||||
const response = (await this.natsService.request(
|
||||
subject,
|
||||
payload,
|
||||
30000,
|
||||
)) as AgentFileResponse | null;
|
||||
|
||||
// Offline mode — agent unreachable, return null rather than crashing
|
||||
if (response === null) {
|
||||
throw new HttpException(
|
||||
'Agent not reachable (offline mode)',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new HttpException(
|
||||
response.error || 'File operation failed',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
`File command failed on subject ${subject}: ${(error as Error).message}`,
|
||||
);
|
||||
throw new HttpException(
|
||||
'Agent not reachable or timed out',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async list(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_list', path });
|
||||
}
|
||||
|
||||
async search(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
filter: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_search', path, filter });
|
||||
}
|
||||
|
||||
async preview(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_preview', path });
|
||||
}
|
||||
|
||||
async download(licenseId: string, path: string): Promise<DownloadResult> {
|
||||
const result = await this.sendFileCommand(licenseId, {
|
||||
func: 'fm_preview',
|
||||
path,
|
||||
});
|
||||
const basename = path.split('/').pop() || 'download';
|
||||
return { filename: basename, content: result };
|
||||
}
|
||||
|
||||
async delete(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_delete', path, items });
|
||||
}
|
||||
|
||||
async rename(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
item: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_rename',
|
||||
path,
|
||||
items: [item],
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
async move(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_move',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async copy(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_copy',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkdir', path, name });
|
||||
}
|
||||
|
||||
async createFile(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkfile', path, name });
|
||||
}
|
||||
|
||||
async save(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_save', path, content });
|
||||
}
|
||||
|
||||
async upload(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<unknown> {
|
||||
// Encode binary content as base64 so it survives JSON serialization over NATS
|
||||
const content = file.buffer.toString('base64');
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_upload',
|
||||
path,
|
||||
filename: file.originalname,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user