dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes

This commit is contained in:
2026-03-21 19:26:35 +02:00
parent a8ac768e3f
commit d52088a88b
37 changed files with 1653 additions and 48 deletions

View File

@@ -21,6 +21,7 @@
"@fastify/static": "^8.0.0",
"@prisma/client": "^6.0.0",
"@simplewebauthn/server": "^11.0.0",
"adm-zip": "^0.5.16",
"altcha-lib": "^0.5.0",
"bcrypt": "^5.1.0",
"fastify": "^5.0.0",
@@ -32,6 +33,7 @@
"zod": "^3.23.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.8",
"@types/bcrypt": "^5.0.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^22.0.0",

View File

@@ -459,3 +459,34 @@ model EditHistory {
@@index([postId, createdAt])
@@index([commentId, createdAt])
}
model Plugin {
id String @id @default(cuid())
name String @unique
version String
description String?
author String?
enabled Boolean @default(false)
dirPath String
entryPoint String @default("index.js")
config Json @default("{}")
configSchema Json @default("[]")
installedAt DateTime @default(now())
updatedAt DateTime @updatedAt
data PluginData[]
}
model PluginData {
id String @id @default(cuid())
pluginId String
key String
value Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)
@@unique([pluginId, key])
@@index([pluginId])
}

View File

@@ -0,0 +1,36 @@
import prisma from "../lib/prisma.js";
import type { FastifyBaseLogger } from "fastify";
import type { PluginContext, PluginStore } from "./types.js";
function createStore(pluginId: string): PluginStore {
return {
async get(key) {
const row = await prisma.pluginData.findUnique({ where: { pluginId_key: { pluginId, key } } });
return row ? row.value : null;
},
async set(key, value) {
await prisma.pluginData.upsert({
where: { pluginId_key: { pluginId, key } },
create: { pluginId, key, value: value as any },
update: { value: value as any },
});
},
async delete(key) {
await prisma.pluginData.deleteMany({ where: { pluginId, key } });
},
async list() {
const rows = await prisma.pluginData.findMany({ where: { pluginId }, select: { key: true, value: true } });
return rows;
},
};
}
export function createPluginContext(pluginId: string, config: Record<string, unknown>, logger: FastifyBaseLogger): PluginContext {
return {
prisma: prisma as any,
store: createStore(pluginId),
config,
logger,
pluginId,
};
}

View File

@@ -4,6 +4,7 @@ import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { PluginManifest, EchoboardPlugin } from "./types.js";
import { getDynamicPluginInfo, getDynamicPluginAdminRoutes } from "./registry.js";
const loadedPlugins: EchoboardPlugin[] = [];
@@ -93,7 +94,8 @@ export function getPluginCronJobs() {
}
export function getPluginAdminRoutes() {
return loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []);
const staticRoutes = loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []);
return staticRoutes;
}
export function getPluginComponents() {

View File

@@ -0,0 +1,93 @@
import { pathToFileURL } from "node:url";
import prisma from "../lib/prisma.js";
import { createPluginContext } from "./context.js";
import type { LoadedDynamicPlugin, PluginDescriptor, PluginContext } from "./types.js";
import type { FastifyBaseLogger } from "fastify";
const registry = new Map<string, LoadedDynamicPlugin>();
let appLogger: FastifyBaseLogger;
export function setLogger(logger: FastifyBaseLogger) {
appLogger = logger;
}
export async function loadDynamicPlugin(plugin: { id: string; name: string; dirPath: string; entryPoint: string; config: any; enabled: boolean }): Promise<boolean> {
if (!plugin.enabled) return false;
try {
const entryPath = `${plugin.dirPath}/${plugin.entryPoint}`;
const url = pathToFileURL(entryPath).href + `?v=${Date.now()}`;
const mod = await import(url);
const descriptor: PluginDescriptor = mod.default ?? mod;
const ctx = createPluginContext(plugin.id, plugin.config ?? {}, appLogger);
if (descriptor.onEnable) await descriptor.onEnable(ctx);
registry.set(plugin.id, { id: plugin.id, name: plugin.name, descriptor, context: ctx });
appLogger?.info({ pluginId: plugin.id, name: plugin.name }, "dynamic plugin loaded");
return true;
} catch (err: any) {
appLogger?.error({ pluginId: plugin.id, error: err.message }, "failed to load dynamic plugin");
return false;
}
}
export async function unloadDynamicPlugin(pluginId: string): Promise<void> {
const loaded = registry.get(pluginId);
if (!loaded) return;
try {
if (loaded.descriptor.onDisable) await loaded.descriptor.onDisable(loaded.context);
} catch (err: any) {
appLogger?.warn({ pluginId, error: err.message }, "error during plugin disable");
}
registry.delete(pluginId);
}
export async function initDynamicPlugins(): Promise<void> {
const plugins = await prisma.plugin.findMany({ where: { enabled: true } });
for (const p of plugins) {
await loadDynamicPlugin(p);
}
}
export async function shutdownDynamicPlugins(): Promise<void> {
for (const [id] of registry) {
await unloadDynamicPlugin(id);
}
}
export function findPluginRoute(pluginId: string, method: string, path: string) {
const loaded = registry.get(pluginId);
if (!loaded) return null;
return loaded.descriptor.routes?.find(
(r) => r.method.toUpperCase() === method.toUpperCase() && r.path === path
) ?? null;
}
export function getPluginEventHandlers(event: string): Array<{ handler: (data: any, ctx: PluginContext) => void | Promise<void>; ctx: PluginContext }> {
const handlers: Array<{ handler: any; ctx: PluginContext }> = [];
for (const [, loaded] of registry) {
const handler = loaded.descriptor.events?.[event];
if (handler) handlers.push({ handler, ctx: loaded.context });
}
return handlers;
}
export function getDynamicPluginInfo() {
return [...registry.values()].map((p) => ({
name: p.name,
id: p.id,
adminRoutes: p.descriptor.adminPages ?? [],
}));
}
export function getDynamicPluginAdminRoutes() {
const routes: Array<{ path: string; label: string }> = [];
for (const [, loaded] of registry) {
for (const page of loaded.descriptor.adminPages ?? []) {
routes.push({ path: `/plugins/${loaded.id}${page.path}`, label: page.label });
}
}
return routes;
}
export function getLoadedPlugin(pluginId: string) {
return registry.get(pluginId) ?? null;
}

View File

@@ -1,4 +1,6 @@
import { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
export interface AdminRoute {
path: string;
@@ -64,3 +66,52 @@ export interface PluginConfig {
export interface PluginManifest {
plugins: PluginConfig[];
}
export interface PluginRouteDefinition {
method: string;
path: string;
handler: (req: any, reply: any, ctx: PluginContext) => void | Promise<void>;
}
export interface PluginAdminPage {
path: string;
label: string;
}
export interface PluginDescriptor {
routes?: PluginRouteDefinition[];
events?: Record<string, (data: any, ctx: PluginContext) => void | Promise<void>>;
adminPages?: PluginAdminPage[];
onEnable?: (ctx: PluginContext) => Promise<void>;
onDisable?: (ctx: PluginContext) => Promise<void>;
}
export interface PluginStore {
get(key: string): Promise<unknown | null>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
list(): Promise<Array<{ key: string; value: unknown }>>;
}
export interface PluginContext {
prisma: PrismaClient;
store: PluginStore;
config: Record<string, unknown>;
logger: FastifyBaseLogger;
pluginId: string;
}
export interface DynamicPluginManifest {
name: string;
version: string;
description?: string;
author?: string;
entryPoint?: string;
}
export interface LoadedDynamicPlugin {
id: string;
name: string;
descriptor: PluginDescriptor;
context: PluginContext;
}

View File

@@ -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();

View 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));
}
);
}

View File

@@ -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,

View File

@@ -0,0 +1,32 @@
import { FastifyInstance } from "fastify";
import { findPluginRoute, getLoadedPlugin } from "../plugins/registry.js";
export default async function pluginApiRoutes(app: FastifyInstance) {
app.all<{ Params: { pluginId: string; '*': string } }>(
"/plugins/:pluginId/*",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const { pluginId } = req.params;
const subPath = "/" + (req.params['*'] || "");
const loaded = getLoadedPlugin(pluginId);
if (!loaded) {
reply.status(404).send({ error: "Plugin not found" });
return;
}
const route = findPluginRoute(pluginId, req.method, subPath);
if (!route) {
reply.status(404).send({ error: "Route not found" });
return;
}
try {
await route.handler(req, reply, loaded.context);
} catch (err: any) {
req.log.error({ pluginId, error: err.message }, "plugin route error");
reply.status(500).send({ error: "Plugin error" });
}
}
);
}

View File

@@ -5,7 +5,7 @@ import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.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 { shouldCount } from "../lib/view-tracker.js";
@@ -542,7 +542,7 @@ export default async function postRoutes(app: FastifyInstance) {
},
});
fireWebhook("post_created", {
firePluginEvent("post_created", {
postId: post.id,
title: post.title,
type: post.type,

View File

@@ -13,6 +13,7 @@ import prisma from "./lib/prisma.js";
import securityPlugin from "./middleware/security.js";
import authPlugin from "./middleware/auth.js";
import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js";
import { setLogger, initDynamicPlugins, shutdownDynamicPlugins, getDynamicPluginInfo, getDynamicPluginAdminRoutes } from "./plugins/registry.js";
import { seedAllBoardTemplates } from "./lib/default-templates.js";
import boardRoutes from "./routes/boards.js";
@@ -50,6 +51,8 @@ import avatarRoutes from "./routes/avatars.js";
import recoveryRoutes from "./routes/recovery.js";
import settingsRoutes from "./routes/admin/settings.js";
import adminTeamRoutes from "./routes/admin/team.js";
import adminPluginRoutes from "./routes/admin/plugins.js";
import pluginApiRoutes from "./routes/plugins-api.js";
export async function createServer() {
const app = Fastify({
@@ -160,6 +163,8 @@ export async function createServer() {
await api.register(recoveryRoutes);
await api.register(settingsRoutes);
await api.register(adminTeamRoutes);
await api.register(adminPluginRoutes);
await api.register(pluginApiRoutes);
}, { prefix: "/api/v1" });
// serve static frontend build in production
@@ -177,6 +182,8 @@ export async function createServer() {
await loadPlugins(app);
await startupPlugins();
setLogger(app.log);
await initDynamicPlugins();
// seed default templates for boards that have none
await seedAllBoardTemplates(prisma);
@@ -185,7 +192,7 @@ export async function createServer() {
await app.register(async (api) => {
api.get("/plugins/active", {
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
}, async () => getActivePluginInfo());
}, async () => [...getActivePluginInfo(), ...getDynamicPluginInfo()]);
// register plugin-provided admin routes
for (const route of getPluginAdminRoutes()) {
@@ -194,10 +201,18 @@ export async function createServer() {
component: route.component,
}));
}
// register dynamic plugin admin routes
for (const route of getDynamicPluginAdminRoutes()) {
api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({
label: route.label,
}));
}
}, { prefix: "/api/v1" });
app.addHook("onClose", async () => {
await shutdownPlugins();
await shutdownDynamicPlugins();
});
return app;

View File

@@ -102,3 +102,17 @@ export async function fireWebhook(event: string, payload: Record<string, unknown
req.end();
}
}
import { getPluginEventHandlers } from "../plugins/registry.js";
export async function firePluginEvent(event: string, payload: Record<string, unknown>) {
fireWebhook(event, payload);
const handlers = getPluginEventHandlers(event);
for (const { handler, ctx } of handlers) {
try {
await handler(payload, ctx);
} catch (err: any) {
console.warn(`plugin event handler failed for ${event}: ${err.message}`);
}
}
}