From fee0ae2420ac469060f6acfc21aaffb80a1e2d64 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 21 Feb 2026 16:10:32 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20files=20module=20=E2=80=94=20VueF?= =?UTF-8?q?inder-compatible=20REST=20API=20over=20NATS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend-nest/src/app.module.ts | 2 + .../src/modules/files/files.controller.ts | 121 ++++++++++++ .../src/modules/files/files.module.ts | 10 + .../src/modules/files/files.service.ts | 176 ++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 backend-nest/src/modules/files/files.controller.ts create mode 100644 backend-nest/src/modules/files/files.module.ts create mode 100644 backend-nest/src/modules/files/files.service.ts diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index cffefc1..9c7bb0f 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -34,6 +34,7 @@ import { AdminModule } from './modules/admin/admin.module'; import { SetupModule } from './modules/setup/setup.module'; import { MigrationModule } from './modules/migration/migration.module'; import { ChangelogModule } from './modules/changelog/changelog.module'; +import { FilesModule } from './modules/files/files.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -103,6 +104,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; SetupModule, MigrationModule, ChangelogModule, + FilesModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/modules/files/files.controller.ts b/backend-nest/src/modules/files/files.controller.ts new file mode 100644 index 0000000..12cd9d8 --- /dev/null +++ b/backend-nest/src/modules/files/files.controller.ts @@ -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, + @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, + ); + } + } +} diff --git a/backend-nest/src/modules/files/files.module.ts b/backend-nest/src/modules/files/files.module.ts new file mode 100644 index 0000000..48c55e9 --- /dev/null +++ b/backend-nest/src/modules/files/files.module.ts @@ -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 {} diff --git a/backend-nest/src/modules/files/files.service.ts b/backend-nest/src/modules/files/files.service.ts new file mode 100644 index 0000000..6553bfd --- /dev/null +++ b/backend-nest/src/modules/files/files.service.ts @@ -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, + ): Promise { + 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 { + return this.sendFileCommand(licenseId, { func: 'fm_list', path }); + } + + async search( + licenseId: string, + path: string, + filter: string, + ): Promise { + return this.sendFileCommand(licenseId, { func: 'fm_search', path, filter }); + } + + async preview(licenseId: string, path: string): Promise { + return this.sendFileCommand(licenseId, { func: 'fm_preview', path }); + } + + async download(licenseId: string, path: string): Promise { + 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 { + return this.sendFileCommand(licenseId, { func: 'fm_delete', path, items }); + } + + async rename( + licenseId: string, + path: string, + item: string, + name: string, + ): Promise { + return this.sendFileCommand(licenseId, { + func: 'fm_rename', + path, + items: [item], + name, + }); + } + + async move( + licenseId: string, + path: string, + items: string[], + destination: string, + ): Promise { + return this.sendFileCommand(licenseId, { + func: 'fm_move', + path, + items, + destination, + }); + } + + async copy( + licenseId: string, + path: string, + items: string[], + destination: string, + ): Promise { + return this.sendFileCommand(licenseId, { + func: 'fm_copy', + path, + items, + destination, + }); + } + + async createFolder( + licenseId: string, + path: string, + name: string, + ): Promise { + return this.sendFileCommand(licenseId, { func: 'fm_mkdir', path, name }); + } + + async createFile( + licenseId: string, + path: string, + name: string, + ): Promise { + return this.sendFileCommand(licenseId, { func: 'fm_mkfile', path, name }); + } + + async save( + licenseId: string, + path: string, + content: string, + ): Promise { + return this.sendFileCommand(licenseId, { func: 'fm_save', path, content }); + } + + async upload( + licenseId: string, + path: string, + file: Express.Multer.File, + ): Promise { + // 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, + }); + } +}