Add panel feature updates across API, daemon, and web
This commit is contained in:
@@ -1,12 +1,197 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, desc, count } from 'drizzle-orm';
|
||||
import { users, games, nodes, auditLogs } from '@source/database';
|
||||
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 { CreateGameSchema, UpdateGameSchema, GameIdParamSchema } from './schemas.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) => {
|
||||
@@ -100,6 +285,617 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
|
||||
// === 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
|
||||
|
||||
Reference in New Issue
Block a user