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>
177 lines
4.3 KiB
TypeScript
177 lines
4.3 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|