941 lines
29 KiB
TypeScript
941 lines
29 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import multipart from '@fastify/multipart';
|
|
import { eq, desc, count, and } from 'drizzle-orm';
|
|
import { Type } from '@sinclair/typebox';
|
|
import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source/database';
|
|
import { AppError } from '../../lib/errors.js';
|
|
import { requireSuperAdmin } from '../../lib/permissions.js';
|
|
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
|
import { uploadPluginArtifact } from '../../lib/cdn.js';
|
|
import * as yazl from 'yazl';
|
|
import {
|
|
CreateGameSchema,
|
|
UpdateGameSchema,
|
|
GameIdParamSchema,
|
|
PluginIdParamSchema,
|
|
PluginReleaseIdParamSchema,
|
|
CreateGlobalPluginSchema,
|
|
UpdateGlobalPluginSchema,
|
|
ImportPluginsSchema,
|
|
CreatePluginReleaseSchema,
|
|
UpdatePluginReleaseSchema,
|
|
} from './schemas.js';
|
|
|
|
type ReleaseChannel = 'stable' | 'beta' | 'alpha';
|
|
|
|
interface UploadArtifactFile {
|
|
relativePath: string;
|
|
data: Buffer;
|
|
}
|
|
|
|
interface UploadJsonFile {
|
|
filename: string;
|
|
data: Buffer;
|
|
}
|
|
|
|
function toSlug(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '')
|
|
.slice(0, 200);
|
|
}
|
|
|
|
function sanitizeRelativeSegments(path: string): string[] {
|
|
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
const normalized: string[] = [];
|
|
|
|
for (const segment of segments) {
|
|
if (segment === '.' || segment === '') continue;
|
|
if (segment === '..') {
|
|
throw AppError.badRequest('Invalid artifact path segment');
|
|
}
|
|
normalized.push(segment);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeRelativePath(path: string, fallbackName: string): string {
|
|
const segments = sanitizeRelativeSegments(path);
|
|
if (segments.length === 0) {
|
|
return sanitizeRelativeSegments(fallbackName).join('/');
|
|
}
|
|
return segments.join('/');
|
|
}
|
|
|
|
function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
|
|
if (rawValue === undefined || rawValue === null || rawValue === '') return [];
|
|
if (typeof rawValue !== 'string') {
|
|
throw AppError.badRequest(`${fieldName} must be a JSON string`);
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(rawValue);
|
|
} catch {
|
|
throw AppError.badRequest(`${fieldName} is not valid JSON`);
|
|
}
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
throw AppError.badRequest(`${fieldName} must be a JSON array`);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function parseJsonArrayUploadFile(
|
|
file: UploadJsonFile | null,
|
|
fieldName: string,
|
|
): unknown[] {
|
|
if (!file) return [];
|
|
|
|
let rawValue = file.data.toString('utf8');
|
|
if (rawValue.charCodeAt(0) === 0xfeff) {
|
|
rawValue = rawValue.slice(1);
|
|
}
|
|
|
|
return parseJsonArrayField(rawValue, fieldName);
|
|
}
|
|
|
|
function parseJsonArrayInput(
|
|
rawValue: unknown,
|
|
file: UploadJsonFile | null,
|
|
fieldName: string,
|
|
): unknown[] {
|
|
if (file) return parseJsonArrayUploadFile(file, fieldName);
|
|
return parseJsonArrayField(rawValue, fieldName);
|
|
}
|
|
|
|
function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
|
|
if (rawValue === undefined || rawValue === null || rawValue === '') return undefined;
|
|
if (typeof rawValue === 'boolean') return rawValue;
|
|
if (typeof rawValue !== 'string') return undefined;
|
|
|
|
const normalized = rawValue.trim().toLowerCase();
|
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
|
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
|
|
return undefined;
|
|
}
|
|
|
|
function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
|
|
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
|
|
if (typeof rawValue === 'string') {
|
|
const normalized = rawValue.trim().toLowerCase();
|
|
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized;
|
|
}
|
|
return 'stable';
|
|
}
|
|
|
|
async function zipArtifacts(files: UploadArtifactFile[]): Promise<Buffer> {
|
|
return await new Promise<Buffer>((resolve, reject) => {
|
|
const archive = new yazl.ZipFile();
|
|
const chunks: Buffer[] = [];
|
|
|
|
archive.outputStream.on('data', (chunk: Buffer) => {
|
|
chunks.push(chunk);
|
|
});
|
|
archive.outputStream.on('error', reject);
|
|
archive.outputStream.on('end', () => {
|
|
resolve(Buffer.concat(chunks));
|
|
});
|
|
|
|
for (const file of files) {
|
|
archive.addBuffer(file.data, file.relativePath.replace(/^\/+/g, ''));
|
|
}
|
|
|
|
archive.end();
|
|
});
|
|
}
|
|
|
|
async function resolveImportGame(
|
|
app: FastifyInstance,
|
|
{
|
|
gameId,
|
|
gameSlug,
|
|
}: {
|
|
gameId?: string;
|
|
gameSlug?: string;
|
|
},
|
|
) {
|
|
if (gameId) {
|
|
const game = await app.db.query.games.findFirst({
|
|
where: eq(games.id, gameId),
|
|
});
|
|
if (!game) {
|
|
throw AppError.notFound(`Game not found: ${gameId}`);
|
|
}
|
|
return game;
|
|
}
|
|
|
|
const normalizedSlug = gameSlug?.trim().toLowerCase();
|
|
if (normalizedSlug) {
|
|
const game = await app.db.query.games.findFirst({
|
|
where: eq(games.slug, normalizedSlug),
|
|
});
|
|
if (!game) {
|
|
throw AppError.notFound(`Game not found: ${normalizedSlug}`);
|
|
}
|
|
return game;
|
|
}
|
|
|
|
throw AppError.badRequest('gameId or gameSlug is required for each import item');
|
|
}
|
|
|
|
export default async function adminRoutes(app: FastifyInstance) {
|
|
await app.register(multipart, {
|
|
limits: {
|
|
files: 200,
|
|
parts: 600,
|
|
fileSize: 512 * 1024 * 1024,
|
|
},
|
|
});
|
|
|
|
// All admin routes require auth + super admin
|
|
app.addHook('onRequest', app.authenticate);
|
|
app.addHook('onRequest', async (request) => {
|
|
requireSuperAdmin(request);
|
|
});
|
|
|
|
// === Users ===
|
|
|
|
// GET /api/admin/users
|
|
app.get('/users', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
|
|
const { page, perPage, offset, limit } = paginate(request.query as any);
|
|
|
|
const [totalResult] = await app.db.select({ count: count() }).from(users);
|
|
|
|
const userList = await app.db
|
|
.select({
|
|
id: users.id,
|
|
email: users.email,
|
|
username: users.username,
|
|
isSuperAdmin: users.isSuperAdmin,
|
|
avatarUrl: users.avatarUrl,
|
|
createdAt: users.createdAt,
|
|
})
|
|
.from(users)
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.orderBy(users.createdAt);
|
|
|
|
return paginatedResponse(userList, totalResult!.count, page, perPage);
|
|
});
|
|
|
|
// === Games ===
|
|
|
|
// GET /api/admin/games
|
|
app.get('/games', async () => {
|
|
const gameList = await app.db
|
|
.select()
|
|
.from(games)
|
|
.orderBy(games.name);
|
|
|
|
return { data: gameList };
|
|
});
|
|
|
|
// POST /api/admin/games
|
|
app.post('/games', { schema: CreateGameSchema }, async (request, reply) => {
|
|
const body = request.body as {
|
|
slug: string;
|
|
name: string;
|
|
dockerImage: string;
|
|
defaultPort: number;
|
|
startupCommand: string;
|
|
stopCommand?: string;
|
|
stopTimeoutSeconds?: number;
|
|
containerDataPath?: string;
|
|
configFiles?: unknown[];
|
|
environmentVars?: unknown[];
|
|
automationRules?: unknown[];
|
|
};
|
|
|
|
const existing = await app.db.query.games.findFirst({
|
|
where: eq(games.slug, body.slug),
|
|
});
|
|
if (existing) throw AppError.conflict('Game slug already exists');
|
|
|
|
const [game] = await app.db
|
|
.insert(games)
|
|
.values({
|
|
...body,
|
|
configFiles: body.configFiles ?? [],
|
|
environmentVars: body.environmentVars ?? [],
|
|
automationRules: body.automationRules ?? [],
|
|
})
|
|
.returning();
|
|
|
|
return reply.code(201).send(game);
|
|
});
|
|
|
|
// PATCH /api/admin/games/:gameId
|
|
app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => {
|
|
const { gameId } = request.params as { gameId: string };
|
|
const body = request.body as Record<string, unknown>;
|
|
|
|
const [updated] = await app.db
|
|
.update(games)
|
|
.set({ ...body, updatedAt: new Date() })
|
|
.where(eq(games.id, gameId))
|
|
.returning();
|
|
|
|
if (!updated) throw AppError.notFound('Game not found');
|
|
|
|
return updated;
|
|
});
|
|
|
|
// === Nodes (global view) ===
|
|
|
|
// === Global Plugins ===
|
|
|
|
app.get(
|
|
'/plugins',
|
|
{
|
|
schema: {
|
|
querystring: Type.Object({
|
|
gameId: Type.Optional(Type.String({ format: 'uuid' })),
|
|
}),
|
|
},
|
|
},
|
|
async (request) => {
|
|
const { gameId } = request.query as { gameId?: string };
|
|
|
|
const rows = await app.db
|
|
.select({
|
|
id: plugins.id,
|
|
gameId: plugins.gameId,
|
|
name: plugins.name,
|
|
slug: plugins.slug,
|
|
description: plugins.description,
|
|
source: plugins.source,
|
|
isGlobal: plugins.isGlobal,
|
|
updatedAt: plugins.updatedAt,
|
|
gameName: games.name,
|
|
gameSlug: games.slug,
|
|
})
|
|
.from(plugins)
|
|
.innerJoin(games, eq(plugins.gameId, games.id))
|
|
.where(gameId ? eq(plugins.gameId, gameId) : undefined)
|
|
.orderBy(plugins.name);
|
|
|
|
return { data: rows };
|
|
},
|
|
);
|
|
|
|
app.post('/plugins', { schema: CreateGlobalPluginSchema }, async (request, reply) => {
|
|
const body = request.body as {
|
|
gameId: string;
|
|
name: string;
|
|
slug?: string;
|
|
description?: string;
|
|
source?: 'manual' | 'spiget';
|
|
};
|
|
|
|
const game = await app.db.query.games.findFirst({
|
|
where: eq(games.id, body.gameId),
|
|
});
|
|
if (!game) throw AppError.notFound('Game not found');
|
|
|
|
const slug = toSlug(body.slug ?? body.name);
|
|
if (!slug) throw AppError.badRequest('Plugin slug is invalid');
|
|
|
|
const existing = await app.db.query.plugins.findFirst({
|
|
where: and(eq(plugins.gameId, body.gameId), eq(plugins.slug, slug)),
|
|
});
|
|
if (existing) throw AppError.conflict('Plugin slug already exists for this game');
|
|
|
|
const [created] = await app.db
|
|
.insert(plugins)
|
|
.values({
|
|
gameId: body.gameId,
|
|
name: body.name,
|
|
slug,
|
|
description: body.description ?? null,
|
|
source: body.source ?? 'manual',
|
|
isGlobal: true,
|
|
})
|
|
.returning();
|
|
|
|
return reply.code(201).send(created);
|
|
});
|
|
|
|
app.post('/plugins/import', { schema: ImportPluginsSchema }, async (request) => {
|
|
const body = request.body as {
|
|
defaultGameId?: string;
|
|
defaultGameSlug?: string;
|
|
stopOnError?: boolean;
|
|
items: Array<{
|
|
gameId?: string;
|
|
gameSlug?: string;
|
|
plugin: {
|
|
name: string;
|
|
slug?: string;
|
|
description?: string;
|
|
source?: 'manual' | 'spiget';
|
|
isGlobal?: boolean;
|
|
};
|
|
release?: {
|
|
version: string;
|
|
channel?: 'stable' | 'beta' | 'alpha';
|
|
artifactType?: 'file' | 'zip';
|
|
artifactUrl: string;
|
|
destination?: string;
|
|
fileName?: string;
|
|
changelog?: string;
|
|
installSchema?: unknown[];
|
|
configTemplates?: unknown[];
|
|
isPublished?: boolean;
|
|
};
|
|
}>;
|
|
};
|
|
|
|
const results: Array<{
|
|
index: number;
|
|
success: boolean;
|
|
gameId?: string;
|
|
gameSlug?: string;
|
|
pluginId?: string;
|
|
pluginSlug?: string;
|
|
pluginAction?: 'created' | 'updated';
|
|
releaseId?: string;
|
|
releaseVersion?: string;
|
|
releaseAction?: 'created' | 'updated' | 'skipped';
|
|
error?: string;
|
|
}> = [];
|
|
|
|
for (const [index, item] of body.items.entries()) {
|
|
try {
|
|
const game = await resolveImportGame(app, {
|
|
gameId: item.gameId ?? body.defaultGameId,
|
|
gameSlug: item.gameSlug ?? body.defaultGameSlug,
|
|
});
|
|
|
|
const pluginPayload = item.plugin;
|
|
const pluginSlug = toSlug(pluginPayload.slug ?? pluginPayload.name);
|
|
if (!pluginSlug) {
|
|
throw AppError.badRequest('Plugin slug is invalid');
|
|
}
|
|
|
|
const existingPlugin = await app.db.query.plugins.findFirst({
|
|
where: and(eq(plugins.gameId, game.id), eq(plugins.slug, pluginSlug)),
|
|
});
|
|
|
|
let pluginRecord: typeof plugins.$inferSelect;
|
|
let pluginAction: 'created' | 'updated';
|
|
|
|
if (existingPlugin) {
|
|
const [updatedPlugin] = await app.db
|
|
.update(plugins)
|
|
.set({
|
|
name: pluginPayload.name,
|
|
slug: pluginSlug,
|
|
description:
|
|
pluginPayload.description !== undefined
|
|
? pluginPayload.description
|
|
: existingPlugin.description,
|
|
source: pluginPayload.source ?? existingPlugin.source,
|
|
isGlobal: pluginPayload.isGlobal ?? existingPlugin.isGlobal,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(plugins.id, existingPlugin.id))
|
|
.returning();
|
|
|
|
if (!updatedPlugin) {
|
|
throw AppError.notFound('Plugin not found');
|
|
}
|
|
|
|
pluginRecord = updatedPlugin;
|
|
pluginAction = 'updated';
|
|
} else {
|
|
const [createdPlugin] = await app.db
|
|
.insert(plugins)
|
|
.values({
|
|
gameId: game.id,
|
|
name: pluginPayload.name,
|
|
slug: pluginSlug,
|
|
description: pluginPayload.description ?? null,
|
|
source: pluginPayload.source ?? 'manual',
|
|
isGlobal: pluginPayload.isGlobal ?? true,
|
|
})
|
|
.returning();
|
|
|
|
if (!createdPlugin) {
|
|
throw new AppError(500, 'Failed to create plugin');
|
|
}
|
|
|
|
pluginRecord = createdPlugin;
|
|
pluginAction = 'created';
|
|
}
|
|
|
|
let releaseAction: 'created' | 'updated' | 'skipped' = 'skipped';
|
|
let releaseRecord: typeof pluginReleases.$inferSelect | null = null;
|
|
|
|
if (item.release) {
|
|
const releasePayload = item.release;
|
|
const existingRelease = await app.db.query.pluginReleases.findFirst({
|
|
where: and(
|
|
eq(pluginReleases.pluginId, pluginRecord.id),
|
|
eq(pluginReleases.version, releasePayload.version),
|
|
),
|
|
});
|
|
|
|
if (existingRelease) {
|
|
const [updatedRelease] = await app.db
|
|
.update(pluginReleases)
|
|
.set({
|
|
channel: releasePayload.channel ?? existingRelease.channel,
|
|
artifactType: releasePayload.artifactType ?? existingRelease.artifactType,
|
|
artifactUrl: releasePayload.artifactUrl,
|
|
destination:
|
|
releasePayload.destination !== undefined
|
|
? releasePayload.destination
|
|
: existingRelease.destination,
|
|
fileName:
|
|
releasePayload.fileName !== undefined
|
|
? releasePayload.fileName
|
|
: existingRelease.fileName,
|
|
changelog:
|
|
releasePayload.changelog !== undefined
|
|
? releasePayload.changelog
|
|
: existingRelease.changelog,
|
|
installSchema: releasePayload.installSchema ?? existingRelease.installSchema,
|
|
configTemplates: releasePayload.configTemplates ?? existingRelease.configTemplates,
|
|
isPublished: releasePayload.isPublished ?? existingRelease.isPublished,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(pluginReleases.id, existingRelease.id))
|
|
.returning();
|
|
|
|
if (!updatedRelease) {
|
|
throw AppError.notFound('Plugin release not found');
|
|
}
|
|
|
|
releaseRecord = updatedRelease;
|
|
releaseAction = 'updated';
|
|
} else {
|
|
const [createdRelease] = await app.db
|
|
.insert(pluginReleases)
|
|
.values({
|
|
pluginId: pluginRecord.id,
|
|
version: releasePayload.version,
|
|
channel: releasePayload.channel ?? 'stable',
|
|
artifactType: releasePayload.artifactType ?? 'file',
|
|
artifactUrl: releasePayload.artifactUrl,
|
|
destination: releasePayload.destination ?? null,
|
|
fileName: releasePayload.fileName ?? null,
|
|
changelog: releasePayload.changelog ?? null,
|
|
installSchema: releasePayload.installSchema ?? [],
|
|
configTemplates: releasePayload.configTemplates ?? [],
|
|
isPublished: releasePayload.isPublished ?? true,
|
|
createdByUserId: request.user.sub,
|
|
})
|
|
.returning();
|
|
|
|
if (!createdRelease) {
|
|
throw new AppError(500, 'Failed to create plugin release');
|
|
}
|
|
|
|
releaseRecord = createdRelease;
|
|
releaseAction = 'created';
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
index,
|
|
success: true,
|
|
gameId: game.id,
|
|
gameSlug: game.slug,
|
|
pluginId: pluginRecord.id,
|
|
pluginSlug: pluginRecord.slug,
|
|
pluginAction,
|
|
releaseId: releaseRecord?.id,
|
|
releaseVersion: releaseRecord?.version,
|
|
releaseAction,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (body.stopOnError) {
|
|
throw AppError.badRequest(`Import failed at item ${index}: ${message}`);
|
|
}
|
|
|
|
results.push({
|
|
index,
|
|
success: false,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
const succeeded = results.filter((result) => result.success).length;
|
|
const failed = results.length - succeeded;
|
|
|
|
return {
|
|
results,
|
|
summary: {
|
|
total: results.length,
|
|
succeeded,
|
|
failed,
|
|
},
|
|
};
|
|
});
|
|
|
|
app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => {
|
|
const { pluginId } = request.params as { pluginId: string };
|
|
const body = request.body as {
|
|
name?: string;
|
|
slug?: string;
|
|
description?: string;
|
|
source?: 'manual' | 'spiget';
|
|
isGlobal?: boolean;
|
|
};
|
|
|
|
const existing = await app.db.query.plugins.findFirst({
|
|
where: eq(plugins.id, pluginId),
|
|
});
|
|
if (!existing) throw AppError.notFound('Plugin not found');
|
|
|
|
const nextSlug = body.slug !== undefined
|
|
? toSlug(body.slug)
|
|
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
|
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
|
|
|
|
const duplicate = await app.db.query.plugins.findFirst({
|
|
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
|
|
});
|
|
if (duplicate && duplicate.id !== existing.id) {
|
|
throw AppError.conflict('Plugin slug already exists for this game');
|
|
}
|
|
|
|
const [updated] = await app.db
|
|
.update(plugins)
|
|
.set({
|
|
name: body.name ?? existing.name,
|
|
slug: nextSlug,
|
|
description: body.description ?? existing.description,
|
|
source: body.source ?? existing.source,
|
|
isGlobal: body.isGlobal ?? existing.isGlobal,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(plugins.id, existing.id))
|
|
.returning();
|
|
|
|
if (!updated) throw AppError.notFound('Plugin not found');
|
|
return updated;
|
|
});
|
|
|
|
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
|
|
const { pluginId } = request.params as { pluginId: string };
|
|
|
|
const plugin = await app.db.query.plugins.findFirst({
|
|
where: eq(plugins.id, pluginId),
|
|
});
|
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
|
|
|
const releases = await app.db
|
|
.select()
|
|
.from(pluginReleases)
|
|
.where(eq(pluginReleases.pluginId, pluginId))
|
|
.orderBy(desc(pluginReleases.createdAt));
|
|
|
|
return { plugin, releases };
|
|
});
|
|
|
|
app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => {
|
|
const { pluginId } = request.params as { pluginId: string };
|
|
|
|
const plugin = await app.db.query.plugins.findFirst({
|
|
where: eq(plugins.id, pluginId),
|
|
});
|
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
|
|
|
if (!request.isMultipart()) {
|
|
throw AppError.badRequest('Content-Type must be multipart/form-data');
|
|
}
|
|
|
|
const fields: Record<string, unknown> = {};
|
|
const files: UploadArtifactFile[] = [];
|
|
let installSchemaFile: UploadJsonFile | null = null;
|
|
let configTemplatesFile: UploadJsonFile | null = null;
|
|
const relativePathQueue: string[] = [];
|
|
|
|
for await (const part of request.parts()) {
|
|
if (part.type === 'file') {
|
|
if (part.fieldname === 'installSchemaFile') {
|
|
const data = await part.toBuffer();
|
|
if (data.length > 0) {
|
|
installSchemaFile = {
|
|
filename: part.filename || 'install-schema.json',
|
|
data,
|
|
};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (part.fieldname === 'configTemplatesFile') {
|
|
const data = await part.toBuffer();
|
|
if (data.length > 0) {
|
|
configTemplatesFile = {
|
|
filename: part.filename || 'config-templates.json',
|
|
data,
|
|
};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const fallbackName = `artifact-${files.length + 1}.bin`;
|
|
const queuedPath = relativePathQueue.shift();
|
|
const relativePath = normalizeRelativePath(
|
|
queuedPath ?? part.filename ?? '',
|
|
fallbackName,
|
|
);
|
|
const data = await part.toBuffer();
|
|
if (data.length === 0) continue;
|
|
files.push({ relativePath, data });
|
|
} else {
|
|
if (part.fieldname === 'relativePath') {
|
|
const raw = typeof part.value === 'string' ? part.value : '';
|
|
relativePathQueue.push(raw);
|
|
continue;
|
|
}
|
|
|
|
fields[part.fieldname] = part.value;
|
|
}
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
throw AppError.badRequest('At least one file is required');
|
|
}
|
|
|
|
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
|
|
if (!version) {
|
|
throw AppError.badRequest('version is required');
|
|
}
|
|
|
|
const channel = parseReleaseChannel(fields.channel);
|
|
const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0
|
|
? fields.destination.trim()
|
|
: null;
|
|
const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
|
|
? fields.changelog
|
|
: null;
|
|
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
|
|
const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema');
|
|
const configTemplates = parseJsonArrayInput(
|
|
fields.configTemplates,
|
|
configTemplatesFile,
|
|
'configTemplates',
|
|
);
|
|
|
|
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
|
|
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
|
|
const shouldZip = files.length > 1 || hasNestedPaths;
|
|
|
|
let artifactType: 'file' | 'zip';
|
|
let artifactContent: Buffer;
|
|
let uploadFileName: string;
|
|
let releaseFileName: string | null;
|
|
|
|
if (shouldZip) {
|
|
artifactType = 'zip';
|
|
artifactContent = await zipArtifacts(files);
|
|
|
|
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
|
|
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
|
|
? suggestedName
|
|
: `${suggestedName}.zip`;
|
|
releaseFileName = null;
|
|
} else {
|
|
artifactType = 'file';
|
|
const [singleFile] = files;
|
|
if (!singleFile) {
|
|
throw AppError.badRequest('No artifact file received');
|
|
}
|
|
|
|
artifactContent = singleFile.data;
|
|
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
|
|
uploadFileName = rawFileName || originalName;
|
|
releaseFileName = uploadFileName;
|
|
}
|
|
|
|
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
|
|
pluginId: plugin.id,
|
|
pluginSlug: plugin.slug,
|
|
releaseVersion: version,
|
|
uploadedBy: request.user.sub,
|
|
uploadMode: shouldZip ? 'archive' : 'single',
|
|
sourceFileCount: files.length,
|
|
});
|
|
|
|
const [created] = await app.db
|
|
.insert(pluginReleases)
|
|
.values({
|
|
pluginId: plugin.id,
|
|
version,
|
|
channel,
|
|
artifactType,
|
|
artifactUrl: uploaded.artifactPointer,
|
|
destination,
|
|
fileName: releaseFileName,
|
|
changelog,
|
|
installSchema,
|
|
configTemplates,
|
|
isPublished,
|
|
createdByUserId: request.user.sub,
|
|
})
|
|
.returning();
|
|
|
|
return reply.code(201).send({
|
|
release: created,
|
|
artifact: {
|
|
bucket: uploaded.bucket,
|
|
fileId: uploaded.file.id,
|
|
storedName: uploaded.file.storedName,
|
|
originalName: uploaded.file.originalName,
|
|
pointer: uploaded.artifactPointer,
|
|
},
|
|
});
|
|
});
|
|
|
|
app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => {
|
|
const { pluginId } = request.params as { pluginId: string };
|
|
const body = request.body as {
|
|
version: string;
|
|
channel?: 'stable' | 'beta' | 'alpha';
|
|
artifactType?: 'file' | 'zip';
|
|
artifactUrl: string;
|
|
destination?: string;
|
|
fileName?: string;
|
|
changelog?: string;
|
|
installSchema?: unknown[];
|
|
configTemplates?: unknown[];
|
|
isPublished?: boolean;
|
|
cloneFromReleaseId?: string;
|
|
};
|
|
|
|
const plugin = await app.db.query.plugins.findFirst({
|
|
where: eq(plugins.id, pluginId),
|
|
});
|
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
|
|
|
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
|
|
if (body.cloneFromReleaseId) {
|
|
baseRelease = await app.db.query.pluginReleases.findFirst({
|
|
where: and(
|
|
eq(pluginReleases.id, body.cloneFromReleaseId),
|
|
eq(pluginReleases.pluginId, pluginId),
|
|
),
|
|
}) ?? null;
|
|
if (!baseRelease) {
|
|
throw AppError.notFound('Clone source release not found');
|
|
}
|
|
}
|
|
|
|
const [created] = await app.db
|
|
.insert(pluginReleases)
|
|
.values({
|
|
pluginId,
|
|
version: body.version,
|
|
channel: body.channel ?? baseRelease?.channel ?? 'stable',
|
|
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
|
|
artifactUrl: body.artifactUrl,
|
|
destination: body.destination ?? baseRelease?.destination ?? null,
|
|
fileName: body.fileName ?? baseRelease?.fileName ?? null,
|
|
changelog: body.changelog ?? baseRelease?.changelog ?? null,
|
|
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
|
|
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
|
|
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
|
|
createdByUserId: request.user.sub,
|
|
})
|
|
.returning();
|
|
|
|
return reply.code(201).send(created);
|
|
});
|
|
|
|
app.patch(
|
|
'/plugins/:pluginId/releases/:releaseId',
|
|
{ schema: { ...PluginReleaseIdParamSchema, ...UpdatePluginReleaseSchema } },
|
|
async (request) => {
|
|
const { pluginId, releaseId } = request.params as { pluginId: string; releaseId: string };
|
|
const body = request.body as {
|
|
version?: string;
|
|
channel?: 'stable' | 'beta' | 'alpha';
|
|
artifactType?: 'file' | 'zip';
|
|
artifactUrl?: string;
|
|
destination?: string;
|
|
fileName?: string;
|
|
changelog?: string;
|
|
installSchema?: unknown[];
|
|
configTemplates?: unknown[];
|
|
isPublished?: boolean;
|
|
};
|
|
|
|
const release = await app.db.query.pluginReleases.findFirst({
|
|
where: and(eq(pluginReleases.id, releaseId), eq(pluginReleases.pluginId, pluginId)),
|
|
});
|
|
if (!release) throw AppError.notFound('Plugin release not found');
|
|
|
|
const [updated] = await app.db
|
|
.update(pluginReleases)
|
|
.set({
|
|
version: body.version ?? release.version,
|
|
channel: body.channel ?? release.channel,
|
|
artifactType: body.artifactType ?? release.artifactType,
|
|
artifactUrl: body.artifactUrl ?? release.artifactUrl,
|
|
destination: body.destination ?? release.destination,
|
|
fileName: body.fileName ?? release.fileName,
|
|
changelog: body.changelog ?? release.changelog,
|
|
installSchema: body.installSchema ?? release.installSchema,
|
|
configTemplates: body.configTemplates ?? release.configTemplates,
|
|
isPublished: body.isPublished ?? release.isPublished,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(pluginReleases.id, release.id))
|
|
.returning();
|
|
|
|
if (!updated) throw AppError.notFound('Plugin release not found');
|
|
return updated;
|
|
},
|
|
);
|
|
|
|
// GET /api/admin/nodes
|
|
app.get('/nodes', async () => {
|
|
const nodeList = await app.db
|
|
.select()
|
|
.from(nodes)
|
|
.orderBy(nodes.createdAt);
|
|
|
|
return { data: nodeList };
|
|
});
|
|
|
|
// === Audit Logs ===
|
|
|
|
// GET /api/admin/audit-logs
|
|
app.get('/audit-logs', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
|
|
const { page, perPage, offset, limit } = paginate(request.query as any);
|
|
|
|
const [totalResult] = await app.db.select({ count: count() }).from(auditLogs);
|
|
|
|
const logs = await app.db
|
|
.select({
|
|
id: auditLogs.id,
|
|
organizationId: auditLogs.organizationId,
|
|
userId: auditLogs.userId,
|
|
serverId: auditLogs.serverId,
|
|
action: auditLogs.action,
|
|
metadata: auditLogs.metadata,
|
|
ipAddress: auditLogs.ipAddress,
|
|
createdAt: auditLogs.createdAt,
|
|
userEmail: users.email,
|
|
userName: users.username,
|
|
})
|
|
.from(auditLogs)
|
|
.innerJoin(users, eq(auditLogs.userId, users.id))
|
|
.orderBy(desc(auditLogs.createdAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
return paginatedResponse(logs, totalResult!.count, page, perPage);
|
|
});
|
|
}
|