dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes
This commit is contained in:
@@ -115,24 +115,14 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/boards/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { _count: { select: { posts: true } } },
|
||||
});
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (board._count.posts > 0) {
|
||||
reply.status(409).send({
|
||||
error: `Board has ${board._count.posts} posts. Archive it or delete posts first.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.board.delete({ where: { id: board.id } });
|
||||
req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted");
|
||||
reply.status(204).send();
|
||||
|
||||
267
packages/api/src/routes/admin/plugins.ts
Normal file
267
packages/api/src/routes/admin/plugins.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { resolve, sep, extname } from "node:path";
|
||||
import { existsSync, mkdirSync, createReadStream } from "node:fs";
|
||||
import { rm, realpath } from "node:fs/promises";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import AdmZip from "adm-zip";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { loadDynamicPlugin, unloadDynamicPlugin, getLoadedPlugin } from "../../plugins/registry.js";
|
||||
|
||||
const PLUGINS_DIR = resolve(process.cwd(), "plugins-installed");
|
||||
const MAX_ZIP_SIZE = 50 * 1024 * 1024;
|
||||
const SAFE_ASSET_EXTS = new Set([".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".eot", ".json"]);
|
||||
|
||||
const configFieldSchema = z.object({
|
||||
key: z.string().min(1).max(50),
|
||||
type: z.enum(["text", "password", "number", "boolean", "select"]),
|
||||
label: z.string().min(1).max(100),
|
||||
placeholder: z.string().max(200).optional(),
|
||||
required: z.boolean().optional(),
|
||||
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
|
||||
});
|
||||
|
||||
const manifestSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, "lowercase alphanumeric with dashes"),
|
||||
version: z.string().min(1).max(30),
|
||||
description: z.string().max(500).optional(),
|
||||
author: z.string().max(100).optional(),
|
||||
entryPoint: z.string().max(100).optional(),
|
||||
configSchema: z.array(configFieldSchema).optional(),
|
||||
});
|
||||
|
||||
if (!existsSync(PLUGINS_DIR)) mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
|
||||
export default async function adminPluginRoutes(app: FastifyInstance) {
|
||||
// list plugins
|
||||
app.get(
|
||||
"/admin/plugins",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const plugins = await prisma.plugin.findMany({ orderBy: { installedAt: "desc" } });
|
||||
reply.send({ plugins });
|
||||
}
|
||||
);
|
||||
|
||||
// upload plugin zip
|
||||
app.post(
|
||||
"/admin/plugins/upload",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const data = await req.file();
|
||||
if (!data) {
|
||||
reply.status(400).send({ error: "No file uploaded" });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of data.file) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_ZIP_SIZE) {
|
||||
reply.status(400).send({ error: "File too large (max 50MB)" });
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
// validate zip magic bytes
|
||||
if (buffer[0] !== 0x50 || buffer[1] !== 0x4B || buffer[2] !== 0x03 || buffer[3] !== 0x04) {
|
||||
reply.status(400).send({ error: "Not a valid zip file" });
|
||||
return;
|
||||
}
|
||||
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(buffer);
|
||||
} catch {
|
||||
reply.status(400).send({ error: "Failed to read zip file" });
|
||||
return;
|
||||
}
|
||||
|
||||
// find manifest
|
||||
const manifestEntry = zip.getEntry("manifest.json");
|
||||
if (!manifestEntry) {
|
||||
reply.status(400).send({ error: "Missing manifest.json in zip root" });
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = manifestSchema.parse(JSON.parse(manifestEntry.getData().toString("utf8")));
|
||||
} catch {
|
||||
reply.status(400).send({ error: "Invalid manifest.json" });
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPoint = manifest.entryPoint || "index.js";
|
||||
const entryEntry = zip.getEntry(entryPoint);
|
||||
if (!entryEntry) {
|
||||
reply.status(400).send({ error: `Entry point ${entryPoint} not found in zip` });
|
||||
return;
|
||||
}
|
||||
|
||||
// check for duplicate name
|
||||
const existing = await prisma.plugin.findUnique({ where: { name: manifest.name } });
|
||||
if (existing) {
|
||||
reply.status(409).send({ error: `Plugin "${manifest.name}" is already installed` });
|
||||
return;
|
||||
}
|
||||
|
||||
// extract
|
||||
const pluginId = randomBytes(8).toString("hex");
|
||||
const pluginDir = resolve(PLUGINS_DIR, pluginId);
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
try {
|
||||
zip.extractAllTo(pluginDir, true);
|
||||
} catch {
|
||||
await rm(pluginDir, { recursive: true, force: true });
|
||||
reply.status(500).send({ error: "Failed to extract plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = await prisma.plugin.create({
|
||||
data: {
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description ?? null,
|
||||
author: manifest.author ?? null,
|
||||
dirPath: pluginDir,
|
||||
entryPoint,
|
||||
enabled: false,
|
||||
configSchema: (manifest.configSchema ?? []) as any,
|
||||
},
|
||||
});
|
||||
|
||||
req.log.info({ pluginId: plugin.id, name: plugin.name }, "plugin installed");
|
||||
reply.status(201).send(plugin);
|
||||
}
|
||||
);
|
||||
|
||||
// enable
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/plugins/:id/enable",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||
if (plugin.enabled) { reply.send(plugin); return; }
|
||||
|
||||
await prisma.plugin.update({ where: { id: plugin.id }, data: { enabled: true } });
|
||||
const ok = await loadDynamicPlugin({ ...plugin, enabled: true });
|
||||
if (!ok) {
|
||||
await prisma.plugin.update({ where: { id: plugin.id }, data: { enabled: false } });
|
||||
reply.status(500).send({ error: "Failed to load plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.send({ ...plugin, enabled: true });
|
||||
}
|
||||
);
|
||||
|
||||
// disable
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/plugins/:id/disable",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||
|
||||
await unloadDynamicPlugin(plugin.id);
|
||||
await prisma.plugin.update({ where: { id: plugin.id }, data: { enabled: false } });
|
||||
reply.send({ ...plugin, enabled: false });
|
||||
}
|
||||
);
|
||||
|
||||
// delete
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/plugins/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||
|
||||
await unloadDynamicPlugin(plugin.id);
|
||||
await prisma.plugin.delete({ where: { id: plugin.id } });
|
||||
|
||||
if (plugin.dirPath && existsSync(plugin.dirPath)) {
|
||||
await rm(plugin.dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
req.log.info({ pluginId: plugin.id, name: plugin.name }, "plugin deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// update config
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/plugins/:id/config",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||
|
||||
const config = req.body as Record<string, unknown>;
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
reply.status(400).send({ error: "Config must be a JSON object" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.plugin.update({ where: { id: plugin.id }, data: { config: config as any } });
|
||||
|
||||
// reload if enabled
|
||||
if (plugin.enabled) {
|
||||
await unloadDynamicPlugin(plugin.id);
|
||||
await loadDynamicPlugin({ ...plugin, config, enabled: true });
|
||||
}
|
||||
|
||||
reply.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
// serve plugin assets
|
||||
app.get<{ Params: { id: string; '*': string } }>(
|
||||
"/admin/plugins/:id/assets/*",
|
||||
{ config: { rateLimit: { max: 100, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||
if (!plugin) { reply.status(404).send({ error: "Not found" }); return; }
|
||||
|
||||
const assetPath = req.params['*'];
|
||||
if (!assetPath) { reply.status(400).send({ error: "No asset path" }); return; }
|
||||
|
||||
const ext = extname(assetPath).toLowerCase();
|
||||
if (!SAFE_ASSET_EXTS.has(ext)) { reply.status(403).send({ error: "File type not allowed" }); return; }
|
||||
|
||||
const fullPath = resolve(plugin.dirPath, "assets", assetPath);
|
||||
try {
|
||||
const realFile = await realpath(fullPath);
|
||||
const realPluginDir = await realpath(plugin.dirPath);
|
||||
if (!realFile.startsWith(realPluginDir + sep)) {
|
||||
reply.status(404).send({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
reply.status(404).send({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeMap: Record<string, string> = {
|
||||
".css": "text/css", ".js": "application/javascript",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
|
||||
".woff": "font/woff", ".woff2": "font/woff2",
|
||||
".ttf": "font/ttf", ".eot": "application/vnd.ms-fontobject",
|
||||
".json": "application/json",
|
||||
};
|
||||
|
||||
reply
|
||||
.header("Content-Type", mimeMap[ext] || "application/octet-stream")
|
||||
.header("Cache-Control", "public, max-age=86400")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.send(createReadStream(fullPath));
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { notifyPostSubscribers } from "../../services/push.js";
|
||||
import { fireWebhook } from "../../services/webhooks.js";
|
||||
import { firePluginEvent } from "../../services/webhooks.js";
|
||||
import { decrypt } from "../../services/encryption.js";
|
||||
import { masterKey } from "../../config.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
@@ -215,7 +215,7 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
tag: `status-${post.id}`,
|
||||
});
|
||||
|
||||
fireWebhook("status_changed", {
|
||||
firePluginEvent("status_changed", {
|
||||
postId: post.id,
|
||||
title: post.title,
|
||||
boardId: post.boardId,
|
||||
@@ -360,7 +360,7 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
fireWebhook("post_created", {
|
||||
firePluginEvent("post_created", {
|
||||
postId: post.id,
|
||||
title: post.title,
|
||||
type: post.type,
|
||||
|
||||
Reference in New Issue
Block a user