feat: Add files module — VueFinder-compatible REST API over NATS
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:
Vantz Stockwell
2026-02-21 16:10:32 -05:00
parent 2b45413c20
commit fee0ae2420
4 changed files with 309 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ import { AdminModule } from './modules/admin/admin.module';
import { SetupModule } from './modules/setup/setup.module'; import { SetupModule } from './modules/setup/setup.module';
import { MigrationModule } from './modules/migration/migration.module'; import { MigrationModule } from './modules/migration/migration.module';
import { ChangelogModule } from './modules/changelog/changelog.module'; import { ChangelogModule } from './modules/changelog/changelog.module';
import { FilesModule } from './modules/files/files.module';
// Shared Services // Shared Services
import { NatsService } from './services/nats.service'; import { NatsService } from './services/nats.service';
@@ -103,6 +104,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
SetupModule, SetupModule,
MigrationModule, MigrationModule,
ChangelogModule, ChangelogModule,
FilesModule,
], ],
providers: [ providers: [
// Global guards (order matters: auth first, then license, then permissions) // Global guards (order matters: auth first, then license, then permissions)

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

View 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 {}

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