472 lines
14 KiB
TypeScript
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 });
|
|
}
|
|
}
|