185 lines
5.2 KiB
TypeScript
185 lines
5.2 KiB
TypeScript
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<string, unknown> | 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<string, unknown>)
|
|
: 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 };
|
|
},
|
|
);
|
|
}
|