source-gamepanel/apps/api/src/routes/servers/files.ts

180 lines
5.0 KiB
TypeScript

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,
},
};
}