/** * Fastify API Server for FUSE operations * * Exposes the VirtualFileSystem over HTTP for consumption by the FUSE driver */ import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import cors from '@fastify/cors'; import { Logger } from 'tslog'; import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js'; import type { KVManager } from '../kv/KVManager.js'; import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types/RenamingRuleTypes.js'; import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js'; import { TemplateEngine } from '../vfs/template/TemplateEngine.js'; import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js'; import { TokenStore } from '../webdav/TokenStore.js'; const logger = new Logger({ name: 'APIServer' }); export interface APIServerConfig { port?: number; host?: string; } interface PathBody { path: string; } export class APIServer { private app: FastifyInstance; private vfs: VirtualFileSystem; private kvManager: KVManager | null = null; private config: Required; private tokenStore: TokenStore; constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) { this.vfs = vfs; this.kvManager = kvManager || null; this.config = { port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10), host: config.host ?? process.env.API_HOST ?? '0.0.0.0', }; // CONFIG_DIR is the same dir ConfigStorage uses for renaming rules. this.tokenStore = new TokenStore({ configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config', }); this.app = Fastify({ logger: false, }); this.setupRoutes(); } /** * Setup API routes */ private setupRoutes(): void { // CORS this.app.register(cors, { origin: true, methods: ['GET', 'POST', 'OPTIONS'], }); // Health check this.app.get('/health', this.handleHealth.bind(this)); this.app.get('/api/health', this.handleHealth.bind(this)); this.app.get('/api/fuse/health', this.handleHealth.bind(this)); // Stats this.app.get('/api/fuse/stats', this.handleStats.bind(this)); // FUSE operations this.app.post('/api/fuse/readdir', this.handleReaddir.bind(this)); this.app.post('/api/fuse/getattr', this.handleGetattr.bind(this)); this.app.post('/api/fuse/exists', this.handleExists.bind(this)); this.app.post('/api/fuse/read', this.handleRead.bind(this)); this.app.post('/api/fuse/metadata', this.handleMetadata.bind(this)); // Directory listing endpoints this.app.get('/api/fuse/files', this.handleFiles.bind(this)); this.app.get('/api/fuse/directories', this.handleDirectories.bind(this)); // Refresh this.app.post('/api/fuse/refresh', this.handleRefresh.bind(this)); // Renaming rules configuration this.app.get('/api/fuse/rules', this.handleGetRules.bind(this)); this.app.put('/api/fuse/rules', this.handleUpdateRules.bind(this)); this.app.post('/api/fuse/rules/preview', this.handlePreviewRules.bind(this)); this.app.post('/api/fuse/rules/validate', this.handleValidateRule.bind(this)); this.app.get('/api/fuse/rules/variables', this.handleGetVariables.bind(this)); // meta-discovery v1: neighbours heard over UDP, from this service's own // map — no meta-core needed, so the nav renders even when the core is down. this.app.get('/api/neighbors', this.handleNeighbors.bind(this)); // WebDAV access tokens (per-device basic-auth passwords for /webdav). this.app.get('/api/webdav-tokens', this.handleListTokens.bind(this)); this.app.post('/api/webdav-tokens', this.handleCreateToken.bind(this)); this.app.delete('/api/webdav-tokens/:id', this.handleRevokeToken.bind(this)); } private async handleListTokens(_req: FastifyRequest, reply: FastifyReply): Promise { reply.send({ tokens: this.tokenStore.list() }); } private async handleCreateToken( req: FastifyRequest<{ Body: { label?: string } }>, reply: FastifyReply ): Promise { const label = (req.body?.label ?? '').toString(); if (label.length > 200) { reply.status(400).send({ error: 'label too long (max 200 chars)' }); return; } const { plaintext, view } = this.tokenStore.create(label); // The plaintext is included exactly once in this response — the dashboard // surfaces it to the user and then discards it. Subsequent GETs do not // include it. reply.status(201).send({ ...view, token: plaintext }); } private async handleRevokeToken( req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply ): Promise { const ok = this.tokenStore.revoke(req.params.id); if (!ok) { reply.status(404).send({ error: 'token not found' }); return; } reply.status(204).send(); } /** * meta-discovery v1 neighbours, served from this service's own UDP map. * `services` is an alias kept while older dashboard builds are around. */ private async handleNeighbors(_request: FastifyRequest, reply: FastifyReply): Promise { const leaderClient = this.kvManager?.getLeaderClient(); if (!leaderClient) { void reply.send({ current: 'meta-fuse', enabled: false, count: 0, neighbors: [] }); return; } const neighbors = leaderClient.getNeighbors(); void reply.send({ current: 'meta-fuse', enabled: true, count: neighbors.length, neighbors, services: neighbors, self: leaderClient.self(), }); } /** * Start the server */ async start(): Promise { try { await this.app.listen({ port: this.config.port, host: this.config.host, }); logger.info(`API Server listening on http://${this.config.host}:${this.config.port}`); } catch (error) { logger.error('Failed to start API server:', error); throw error; } } /** * Stop the server */ async stop(): Promise { await this.app.close(); logger.info('API Server stopped'); } // Route handlers private async handleHealth(_req: FastifyRequest, reply: FastifyReply): Promise { reply.send({ status: 'ok', timestamp: new Date().toISOString(), service: 'meta-fuse', }); } private async handleStats(_req: FastifyRequest, reply: FastifyReply): Promise { const stats = this.vfs.getStats(); reply.send(stats); } private async handleReaddir( req: FastifyRequest<{ Body: PathBody }>, reply: FastifyReply ): Promise { const { path } = req.body; if (typeof path !== 'string') { reply.status(400).send({ error: 'Missing or invalid "path" parameter' }); return; } const entries = this.vfs.readdir(path); if (entries === null) { reply.status(404).send({ error: 'Directory not found' }); return; } reply.send({ entries }); } private async handleGetattr( req: FastifyRequest<{ Body: PathBody }>, reply: FastifyReply ): Promise { const { path } = req.body; if (typeof path !== 'string') { reply.status(400).send({ error: 'Missing or invalid "path" parameter' }); return; } const attrs = await this.vfs.getattr(path); if (attrs === null) { reply.status(404).send({ error: 'Path not found' }); return; } reply.send(attrs); } private async handleExists( req: FastifyRequest<{ Body: PathBody }>, reply: FastifyReply ): Promise { const { path } = req.body; if (typeof path !== 'string') { reply.status(400).send({ error: 'Missing or invalid "path" parameter' }); return; } const exists = this.vfs.exists(path); reply.send({ exists }); } private async handleRead( req: FastifyRequest<{ Body: PathBody }>, reply: FastifyReply ): Promise { const { path } = req.body; if (typeof path !== 'string') { reply.status(400).send({ error: 'Missing or invalid "path" parameter' }); return; } const result = await this.vfs.read(path); if (result === null) { reply.status(404).send({ error: 'File not found' }); return; } // Return source path and/or WebDAV URL for FUSE driver to read from const response: Record = { sourcePath: result.sourcePath, size: result.size, }; // Include WebDAV URL if available (for remote file access) if (result.webdavUrl) { response.webdavUrl = result.webdavUrl; } // If content is provided (virtual file), encode as base64 if (result.content !== null) { response.content = result.content.toString('base64'); response.contentEncoding = 'base64'; } reply.send(response); } private async handleMetadata( req: FastifyRequest<{ Body: PathBody }>, reply: FastifyReply ): Promise { const { path } = req.body; if (typeof path !== 'string') { reply.status(400).send({ error: 'Missing or invalid "path" parameter' }); return; } const metadata = this.vfs.getMetadata(path); if (metadata === null) { reply.status(404).send({ error: 'Metadata not found' }); return; } reply.send(metadata); } private async handleFiles(_req: FastifyRequest, reply: FastifyReply): Promise { const files = this.vfs.getAllFiles(); reply.send({ files }); } private async handleDirectories(_req: FastifyRequest, reply: FastifyReply): Promise { const directories = this.vfs.getAllDirectories(); reply.send({ directories }); } private async handleRefresh(_req: FastifyRequest, reply: FastifyReply): Promise { await this.vfs.refresh(); reply.send({ status: 'ok' }); } // ============================================ // Renaming Rules API Handlers // ============================================ /** * Get current renaming rules configuration */ private async handleGetRules(_req: FastifyRequest, reply: FastifyReply): Promise { try { const config = this.vfs.getRulesConfig(); reply.send({ config, lastModified: config.lastModified || new Date().toISOString(), }); } catch (error: any) { logger.error('Failed to get rules:', error); reply.status(500).send({ error: 'Failed to get rules configuration' }); } } /** * Update renaming rules configuration */ private async handleUpdateRules( req: FastifyRequest<{ Body: { config: RenamingConfig } }>, reply: FastifyReply ): Promise { try { const { config } = req.body; if (!config || typeof config !== 'object') { reply.status(400).send({ error: 'Missing or invalid config in request body' }); return; } // Validate rules const errors: string[] = []; const templateEngine = new TemplateEngine(); const conditionEvaluator = new ConditionEvaluator(); for (const rule of config.rules) { // Validate template const templateValidation = templateEngine.validate(rule.template); if (!templateValidation.valid) { errors.push(`Rule "${rule.name}": ${templateValidation.errors.join(', ')}`); } // Validate conditions const conditionValidation = conditionEvaluator.validate(rule.conditions); if (!conditionValidation.valid) { errors.push(`Rule "${rule.name}": ${conditionValidation.errors.join(', ')}`); } } if (errors.length > 0) { reply.status(400).send({ success: false, errors }); return; } // Save configuration this.vfs.saveRulesConfig(config); // Trigger immediate VFS refresh await this.vfs.refresh(); reply.send({ success: true, refreshed: true }); } catch (error: any) { logger.error('Failed to update rules:', error); reply.status(500).send({ error: 'Failed to update rules configuration' }); } } /** * Preview how files would be renamed with given rules */ private async handlePreviewRules( req: FastifyRequest<{ Body: { rules?: RenamingRule[]; limit?: number } }>, reply: FastifyReply ): Promise { try { const { rules, limit = 100 } = req.body || {}; const preview = await this.vfs.previewRules(rules, limit); reply.send(preview); } catch (error: any) { logger.error('Failed to preview rules:', error); reply.status(500).send({ error: 'Failed to generate preview' }); } } /** * Validate a single rule */ private async handleValidateRule( req: FastifyRequest<{ Body: { rule: RenamingRule; sampleMetadata?: Record } }>, reply: FastifyReply ): Promise { try { const { rule, sampleMetadata } = req.body; if (!rule || typeof rule !== 'object') { reply.status(400).send({ error: 'Missing or invalid rule in request body' }); return; } const templateEngine = new TemplateEngine(); const conditionEvaluator = new ConditionEvaluator(); const errors: string[] = []; const warnings: string[] = []; // Validate template const templateValidation = templateEngine.validate(rule.template); errors.push(...templateValidation.errors); warnings.push(...templateValidation.warnings); // Validate conditions const conditionValidation = conditionEvaluator.validate(rule.conditions); errors.push(...conditionValidation.errors); // Generate sample output if metadata provided let sampleOutput: string | undefined; if (sampleMetadata && errors.length === 0) { sampleOutput = templateEngine.interpolate(rule.template, sampleMetadata) || undefined; } reply.send({ valid: errors.length === 0, errors, warnings, sampleOutput, }); } catch (error: any) { logger.error('Failed to validate rule:', error); reply.status(500).send({ error: 'Failed to validate rule' }); } } /** * Get list of available template variables */ private async handleGetVariables(_req: FastifyRequest, reply: FastifyReply): Promise { reply.send({ variables: TEMPLATE_VARIABLES }); } }