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:
@@ -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)
|
||||||
|
|||||||
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