dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes
This commit is contained in:
36
packages/api/src/plugins/context.ts
Normal file
36
packages/api/src/plugins/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
93
packages/api/src/plugins/registry.ts
Normal file
93
packages/api/src/plugins/registry.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
packages/api/src/routes/plugins-api.ts
Normal file
32
packages/api/src/routes/plugins-api.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user