import type { FastifyInstance } from 'fastify'; import { Type } from '@sinclair/typebox'; import { and, eq } from 'drizzle-orm'; import { nodes, servers } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { daemonDeleteFiles, daemonListFiles, daemonReadFile, daemonWriteFile, type DaemonNodeConnection, } from '../../lib/daemon.js'; const FileParamSchema = { params: Type.Object({ orgId: Type.String({ format: 'uuid' }), serverId: Type.String({ format: 'uuid' }), }), }; function decodeBase64Payload(data: string): Buffer { const normalized = data.trim(); if (!normalized) return Buffer.alloc(0); if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) { throw AppError.badRequest('Invalid base64 payload'); } return Buffer.from(normalized, 'base64'); } export default async function fileRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); app.get( '/', { schema: { ...FileParamSchema, querystring: Type.Object({ path: Type.Optional(Type.String()), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { path } = request.query as { path?: string }; await requirePermission(request, orgId, 'files.read'); const serverContext = await getServerContext(app, orgId, serverId); const files = await daemonListFiles( serverContext.node, serverContext.serverUuid, path?.trim() || '/', ); return { files }; }, ); app.get( '/read', { schema: { ...FileParamSchema, querystring: Type.Object({ path: Type.String({ minLength: 1 }), encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { path, encoding } = request.query as { path: string; encoding?: 'utf8' | 'base64'; }; await requirePermission(request, orgId, 'files.read'); const serverContext = await getServerContext(app, orgId, serverId); const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path); const requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8'; return { data: requestedEncoding === 'base64' ? content.data.toString('base64') : content.data.toString('utf8'), encoding: requestedEncoding, mimeType: content.mimeType, }; }, ); app.post( '/write', { bodyLimit: 128 * 1024 * 1024, schema: { ...FileParamSchema, body: Type.Object({ path: Type.String({ minLength: 1 }), data: Type.String(), encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { path, data, encoding } = request.body as { path: string; data: string; encoding?: 'utf8' | 'base64'; }; await requirePermission(request, orgId, 'files.write'); const serverContext = await getServerContext(app, orgId, serverId); const payload = encoding === 'base64' ? decodeBase64Payload(data) : data; await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload); return { success: true, path }; }, ); app.post( '/delete', { schema: { ...FileParamSchema, body: Type.Object({ paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { paths } = request.body as { paths: string[] }; await requirePermission(request, orgId, 'files.delete'); const serverContext = await getServerContext(app, orgId, serverId); await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, paths); return { success: true, paths }; }, ); } async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{ serverUuid: string; node: DaemonNodeConnection; }> { const [server] = await app.db .select({ uuid: servers.uuid, nodeFqdn: nodes.fqdn, nodeGrpcPort: nodes.grpcPort, nodeDaemonToken: nodes.daemonToken, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) { throw AppError.notFound('Server not found'); } return { serverUuid: server.uuid, node: { fqdn: server.nodeFqdn, grpcPort: server.nodeGrpcPort, daemonToken: server.nodeDaemonToken, }, }; }