import { Type } from '@sinclair/typebox'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import { and, eq, lte } from 'drizzle-orm'; import { nodes, scheduledTasks, servers } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { computeNextRun } from '../../lib/schedule-utils.js'; function extractBearerToken(authHeader?: string): string | null { if (!authHeader) return null; const [scheme, token] = authHeader.split(' '); if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null; return token; } function extractCdnWebhookSecret(request: FastifyRequest): string | null { const byHeader = request.headers['x-cdn-webhook-secret'] ?? request.headers['x-webhook-secret']; if (typeof byHeader === 'string' && byHeader.trim().length > 0) { return byHeader.trim(); } const authHeader = typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined; return extractBearerToken(authHeader); } async function requireDaemonToken( app: FastifyInstance, request: FastifyRequest, ): Promise<{ id: string }> { const token = extractBearerToken( typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined, ); if (!token) { throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING'); } const node = await app.db.query.nodes.findFirst({ where: eq(nodes.daemonToken, token), columns: { id: true }, }); if (!node) { throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID'); } return node; } export default async function internalRoutes(app: FastifyInstance) { app.post( '/cdn/webhook/plugins', { schema: { body: Type.Optional(Type.Unknown()), }, }, async (request, reply) => { const configuredSecret = process.env.CDN_WEBHOOK_SECRET?.trim(); if (configuredSecret) { const providedSecret = extractCdnWebhookSecret(request); if (!providedSecret || providedSecret !== configuredSecret) { throw AppError.unauthorized('Invalid CDN webhook secret', 'CDN_WEBHOOK_AUTH_INVALID'); } } const body = request.body as Record | undefined; const eventType = typeof body?.eventType === 'string' ? body.eventType : (typeof body?.type === 'string' ? body.type : 'unknown'); request.log.info( { eventType, payload: body }, 'Received CDN plugin webhook event', ); return reply.code(202).send({ accepted: true }); }, ); app.get('/schedules/due', async (request) => { const node = await requireDaemonToken(app, request); const now = new Date(); const dueTasks = await app.db .select({ id: scheduledTasks.id, serverUuid: servers.uuid, action: scheduledTasks.action, payload: scheduledTasks.payload, scheduleType: scheduledTasks.scheduleType, isActive: scheduledTasks.isActive, nextRunAt: scheduledTasks.nextRunAt, }) .from(scheduledTasks) .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(and( eq(servers.nodeId, node.id), eq(scheduledTasks.isActive, true), lte(scheduledTasks.nextRunAt, now), )); return { tasks: dueTasks.map((task) => ({ id: task.id, server_uuid: task.serverUuid, action: task.action, payload: task.payload, schedule_type: task.scheduleType, is_active: task.isActive, next_run_at: task.nextRunAt?.toISOString() ?? null, })), }; }); app.post( '/schedules/:taskId/ack', { schema: { params: Type.Object({ taskId: Type.String(), }), }, }, async (request) => { const node = await requireDaemonToken(app, request); const { taskId } = request.params as { taskId: string }; const [task] = await app.db .select({ id: scheduledTasks.id, isActive: scheduledTasks.isActive, scheduleType: scheduledTasks.scheduleType, scheduleData: scheduledTasks.scheduleData, }) .from(scheduledTasks) .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(and( eq(scheduledTasks.id, taskId), eq(servers.nodeId, node.id), )); if (!task) { throw AppError.notFound('Scheduled task not found'); } const now = new Date(); const nextRunAt = task.isActive ? computeNextRun(task.scheduleType, task.scheduleData as Record) : null; await app.db .update(scheduledTasks) .set({ lastRunAt: now, nextRunAt, updatedAt: now, }) .where(eq(scheduledTasks.id, taskId)); return { success: true, taskId }; }, ); app.post( '/servers/:serverUuid/backup', { schema: { params: Type.Object({ serverUuid: Type.String(), }), }, }, async (request) => { await requireDaemonToken(app, request); const { serverUuid } = request.params as { serverUuid: string }; return { success: true, serverUuid }; }, ); }