import * as fs from 'fs'; import * as path from 'path'; import type { FastifyInstance } from 'fastify'; import { NexusError, NordRouterClient, NordRouterMediaProvider, MediaStorage, createLogger, } from '@nexusai/core'; import type { AdminContext } from './context.js'; const log = createLogger('admin:api'); const KNOWN_TYPES = ['nordrouter', 'openai']; function errorReply(err: unknown): { statusCode: number; body: { error: string; code: string } } { const e = NexusError.from(err); const statusCode = e.code === 'NOT_FOUND' ? 404 : e.code === 'CONFIG' || e.code === 'BAD_REQUEST' ? 400 : 500; return { statusCode, body: { error: e.message, code: e.code } }; } export function registerRoutes(app: FastifyInstance, ctx: AdminContext): void { // --- Providers --- app.get('/api/providers', async () => { return { providers: ctx.store.listViews(), knownTypes: KNOWN_TYPES, cryptoEnabled: ctx.cipher.enabled }; }); app.post('/api/providers', async (req, reply) => { try { const body = req.body as { name?: string; type?: string; baseUrl?: string; apiKey?: string; enabled?: boolean; isDefault?: boolean; }; if (!body?.name || !body?.type) { throw new NexusError('BAD_REQUEST', 'name and type are required'); } if (body.apiKey && !ctx.cipher.enabled) { throw new NexusError('CONFIG', 'NEXUS_MASTER_KEY must be set to store an API key'); } ctx.store.upsert({ name: body.name, type: body.type, baseUrl: body.baseUrl ?? null, apiKey: body.apiKey, enabled: body.enabled, isDefault: body.isDefault, }); return { ok: true, provider: ctx.store.get(body.name) }; } catch (err) { const { statusCode, body } = errorReply(err); return reply.code(statusCode).send(body); } }); app.post('/api/providers/:name/enabled', async (req, reply) => { try { const { name } = req.params as { name: string }; const { enabled } = req.body as { enabled: boolean }; ctx.store.setEnabled(name, enabled); return { ok: true }; } catch (err) { const { statusCode, body } = errorReply(err); return reply.code(statusCode).send(body); } }); app.post('/api/providers/:name/default', async (req, reply) => { try { const { name } = req.params as { name: string }; ctx.store.setDefault(name); return { ok: true }; } catch (err) { const { statusCode, body } = errorReply(err); return reply.code(statusCode).send(body); } }); app.delete('/api/providers/:name', async (req, reply) => { try { const { name } = req.params as { name: string }; ctx.store.remove(name); return { ok: true }; } catch (err) { const { statusCode, body } = errorReply(err); return reply.code(statusCode).send(body); } }); // Validate a key: uses provided key, or the stored one, or env fallback. app.post('/api/providers/:name/check', async (req, reply) => { try { const { name } = req.params as { name: string }; const body = (req.body ?? {}) as { apiKey?: string; baseUrl?: string }; const row = ctx.store.get(name); const type = row?.type ?? name; if (!KNOWN_TYPES.includes(type)) { throw new NexusError('UNSUPPORTED', `Key check not implemented for type "${type}"`); } const apiKey = body.apiKey || ctx.store.getApiKey(name) || process.env[`${name.toUpperCase()}_API_KEY`]; if (!apiKey) throw new NexusError('CONFIG', 'No API key available to check'); const baseUrl = body.baseUrl || row?.baseUrl || 'https://nordrouter.com'; const client = new NordRouterClient(apiKey, baseUrl); const provider = new NordRouterMediaProvider(client, { save: false }); const result = await provider.checkKey(); return result; } catch (err) { const { statusCode, body } = errorReply(err); return reply.code(statusCode).send(body); } }); // --- Usage / stats --- app.get('/api/stats', async (req) => { const q = req.query as { sinceDays?: string }; const sinceDays = q.sinceDays ? Number.parseInt(q.sinceDays, 10) : undefined; return ctx.journal.stats(Number.isFinite(sinceDays as number) ? sinceDays : undefined); }); // --- Generations (gallery) --- app.get('/api/generations', async (req) => { const q = req.query as { limit?: string; offset?: string; capability?: string }; const limit = Math.min(200, q.limit ? Number.parseInt(q.limit, 10) : 50); const offset = q.offset ? Number.parseInt(q.offset, 10) : 0; const items = ctx.journal.list(limit, offset, q.capability); // Add a media URL for locally-saved files that still exist. const withUrls = items.map((it) => ({ ...it, mediaUrl: it.filePath && fs.existsSync(it.filePath) ? `/media/${path.basename(it.filePath)}` : null, })); return { items: withUrls, limit, offset }; }); app.get('/api/generations/:id', async (req, reply) => { const { id } = req.params as { id: string }; const rec = ctx.journal.getById(id); if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' }); return { ...rec, mediaUrl: rec.filePath && fs.existsSync(rec.filePath) ? `/media/${path.basename(rec.filePath)}` : null, }; }); app.delete('/api/generations/:id', async (req, reply) => { try { const { id } = req.params as { id: string }; const rec = ctx.journal.getById(id); if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' }); let fileRemoved = false; if (rec.filePath) { const mediaDir = path.resolve(ctx.config.mediaDir); const resolved = path.resolve(rec.filePath); // Only delete files that live inside the media dir. if (resolved.startsWith(mediaDir + path.sep) && fs.existsSync(resolved)) { try { fs.unlinkSync(resolved); fileRemoved = true; } catch (e) { log.warn('failed to delete file', e instanceof Error ? e.message : String(e)); } } } const recordRemoved = ctx.journal.deleteById(id); return { ok: true, fileRemoved, recordRemoved }; } catch (err) { const { statusCode, body } = errorReply(err); return reply.code(statusCode).send(body); } }); log.info('routes registered'); } /** Guard: only serve files that live inside the media dir. */ export function isInsideMediaDir(mediaDir: string, requested: string): boolean { const resolved = path.resolve(mediaDir, requested); const rel = path.relative(path.resolve(mediaDir), resolved); return !rel.startsWith('..') && !path.isAbsolute(rel); } export function ensureStorage(mediaDir: string): MediaStorage { return new MediaStorage(mediaDir); }