Files
meta-fuse/packages/meta-fuse-core/src/api/APIServer.ts
T
Worph daf464261d
Build and Push Docker Image / build (push) Failing after 13s
replace file discovery by udp discovery
2026-09-14 11:25:03 +02:00

472 lines
14 KiB
TypeScript

/**
* 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<APIServerConfig>;
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<void> {
reply.send({ tokens: this.tokenStore.list() });
}
private async handleCreateToken(
req: FastifyRequest<{ Body: { label?: string } }>,
reply: FastifyReply
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.app.close();
logger.info('API Server stopped');
}
// Route handlers
private async handleHealth(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
reply.send({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'meta-fuse',
});
}
private async handleStats(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const stats = this.vfs.getStats();
reply.send(stats);
}
private async handleReaddir(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown> = {
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<void> {
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<void> {
const files = this.vfs.getAllFiles();
reply.send({ files });
}
private async handleDirectories(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const directories = this.vfs.getAllDirectories();
reply.send({ directories });
}
private async handleRefresh(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown> } }>,
reply: FastifyReply
): Promise<void> {
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<void> {
reply.send({ variables: TEMPLATE_VARIABLES });
}
}