From d52088a88be649232f687ee27ed6e8b05ebd4c9d Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 21 Mar 2026 19:26:35 +0200 Subject: [PATCH] dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes --- package-lock.json | 21 + packages/api/package.json | 2 + packages/api/prisma/schema.prisma | 31 ++ packages/api/src/plugins/context.ts | 36 ++ packages/api/src/plugins/loader.ts | 4 +- packages/api/src/plugins/registry.ts | 93 ++++ packages/api/src/plugins/types.ts | 51 ++ packages/api/src/routes/admin/boards.ts | 14 +- packages/api/src/routes/admin/plugins.ts | 267 ++++++++++ packages/api/src/routes/admin/posts.ts | 6 +- packages/api/src/routes/plugins-api.ts | 32 ++ packages/api/src/routes/posts.ts | 4 +- packages/api/src/server.ts | 17 +- packages/api/src/services/webhooks.ts | 14 + packages/web/src/App.tsx | 5 + packages/web/src/app.css | 26 + packages/web/src/components/AdminSidebar.tsx | 1 + packages/web/src/components/PasskeyModal.tsx | 3 + .../web/src/components/RecoveryCodeModal.tsx | 3 + packages/web/src/hooks/useToast.tsx | 174 +++++++ packages/web/src/lib/api.ts | 4 + packages/web/src/pages/BoardFeed.tsx | 8 +- packages/web/src/pages/IdentitySettings.tsx | 23 +- packages/web/src/pages/PostDetail.tsx | 35 +- packages/web/src/pages/admin/AdminBoards.tsx | 42 +- .../web/src/pages/admin/AdminCategories.tsx | 9 +- .../web/src/pages/admin/AdminChangelog.tsx | 9 +- packages/web/src/pages/admin/AdminPlugins.tsx | 472 ++++++++++++++++++ packages/web/src/pages/admin/AdminPosts.tsx | 41 +- .../web/src/pages/admin/AdminSettings.tsx | 7 +- .../web/src/pages/admin/AdminStatuses.tsx | 3 + packages/web/src/pages/admin/AdminTags.tsx | 11 +- packages/web/src/pages/admin/AdminTeam.tsx | 22 +- .../web/src/pages/admin/AdminTemplates.tsx | 6 + .../web/src/pages/admin/AdminWebhooks.tsx | 14 +- plugins/gitea-sync/index.js | 169 +++++++ plugins/gitea-sync/manifest.json | 22 + 37 files changed, 1653 insertions(+), 48 deletions(-) create mode 100644 packages/api/src/plugins/context.ts create mode 100644 packages/api/src/plugins/registry.ts create mode 100644 packages/api/src/routes/admin/plugins.ts create mode 100644 packages/api/src/routes/plugins-api.ts create mode 100644 packages/web/src/hooks/useToast.tsx create mode 100644 packages/web/src/pages/admin/AdminPlugins.tsx create mode 100644 plugins/gitea-sync/index.js create mode 100644 plugins/gitea-sync/manifest.json diff --git a/package-lock.json b/package-lock.json index 319383b..5f91431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2011,6 +2011,16 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2247,6 +2257,15 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -7444,6 +7463,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", @@ -7455,6 +7475,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", diff --git a/packages/api/package.json b/packages/api/package.json index 86c8bfc..c7e0f1b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 774abb9..79af37b 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -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]) +} diff --git a/packages/api/src/plugins/context.ts b/packages/api/src/plugins/context.ts new file mode 100644 index 0000000..af7b17d --- /dev/null +++ b/packages/api/src/plugins/context.ts @@ -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, logger: FastifyBaseLogger): PluginContext { + return { + prisma: prisma as any, + store: createStore(pluginId), + config, + logger, + pluginId, + }; +} diff --git a/packages/api/src/plugins/loader.ts b/packages/api/src/plugins/loader.ts index ba1af84..df6585f 100644 --- a/packages/api/src/plugins/loader.ts +++ b/packages/api/src/plugins/loader.ts @@ -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() { diff --git a/packages/api/src/plugins/registry.ts b/packages/api/src/plugins/registry.ts new file mode 100644 index 0000000..33ca12c --- /dev/null +++ b/packages/api/src/plugins/registry.ts @@ -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(); +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 { + 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 { + 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 { + const plugins = await prisma.plugin.findMany({ where: { enabled: true } }); + for (const p of plugins) { + await loadDynamicPlugin(p); + } +} + +export async function shutdownDynamicPlugins(): Promise { + 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; 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; +} diff --git a/packages/api/src/plugins/types.ts b/packages/api/src/plugins/types.ts index 792ae15..2360a19 100644 --- a/packages/api/src/plugins/types.ts +++ b/packages/api/src/plugins/types.ts @@ -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; +} + +export interface PluginAdminPage { + path: string; + label: string; +} + +export interface PluginDescriptor { + routes?: PluginRouteDefinition[]; + events?: Record void | Promise>; + adminPages?: PluginAdminPage[]; + onEnable?: (ctx: PluginContext) => Promise; + onDisable?: (ctx: PluginContext) => Promise; +} + +export interface PluginStore { + get(key: string): Promise; + set(key: string, value: unknown): Promise; + delete(key: string): Promise; + list(): Promise>; +} + +export interface PluginContext { + prisma: PrismaClient; + store: PluginStore; + config: Record; + 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; +} diff --git a/packages/api/src/routes/admin/boards.ts b/packages/api/src/routes/admin/boards.ts index d628e89..4251a63 100644 --- a/packages/api/src/routes/admin/boards.ts +++ b/packages/api/src/routes/admin/boards.ts @@ -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(); diff --git a/packages/api/src/routes/admin/plugins.ts b/packages/api/src/routes/admin/plugins.ts new file mode 100644 index 0000000..6673e34 --- /dev/null +++ b/packages/api/src/routes/admin/plugins.ts @@ -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; + 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 = { + ".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)); + } + ); +} diff --git a/packages/api/src/routes/admin/posts.ts b/packages/api/src/routes/admin/posts.ts index 4bbf9bd..4dc2f0a 100644 --- a/packages/api/src/routes/admin/posts.ts +++ b/packages/api/src/routes/admin/posts.ts @@ -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, diff --git a/packages/api/src/routes/plugins-api.ts b/packages/api/src/routes/plugins-api.ts new file mode 100644 index 0000000..584084c --- /dev/null +++ b/packages/api/src/routes/plugins-api.ts @@ -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" }); + } + } + ); +} diff --git a/packages/api/src/routes/posts.ts b/packages/api/src/routes/posts.ts index 3b26d59..c067185 100644 --- a/packages/api/src/routes/posts.ts +++ b/packages/api/src/routes/posts.ts @@ -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, diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 9454b48..d818297 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -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; diff --git a/packages/api/src/services/webhooks.ts b/packages/api/src/services/webhooks.ts index 6d6b565..8c1be9a 100644 --- a/packages/api/src/services/webhooks.ts +++ b/packages/api/src/services/webhooks.ts @@ -102,3 +102,17 @@ export async function fireWebhook(event: string, payload: Record) { + 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}`); + } + } +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 3f2d943..e0c7f22 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -7,6 +7,7 @@ import { ThemeProvider, useThemeState } from './hooks/useTheme' import { TranslationProvider, useTranslationState } from './i18n' import { BrandingProvider } from './hooks/useBranding' import { ConfirmProvider } from './hooks/useConfirm' +import { ToastProvider } from './hooks/useToast' import Sidebar from './components/Sidebar' import AdminSidebar from './components/AdminSidebar' import MobileNav from './components/MobileNav' @@ -38,6 +39,7 @@ import AdminExport from './pages/admin/AdminExport' import AdminTemplates from './pages/admin/AdminTemplates' import AdminSettings from './pages/admin/AdminSettings' import AdminTeam from './pages/admin/AdminTeam' +import AdminPlugins from './pages/admin/AdminPlugins' import AdminJoin from './pages/admin/AdminJoin' import ProfilePage from './pages/ProfilePage' import RecoverPage from './pages/RecoverPage' @@ -507,6 +509,7 @@ function Layout() { } /> } /> } /> + } /> } /> @@ -543,6 +546,7 @@ export default function App() { return ( + @@ -559,6 +563,7 @@ export default function App() { + ) } diff --git a/packages/web/src/app.css b/packages/web/src/app.css index 2156d98..ae2d8d5 100644 --- a/packages/web/src/app.css +++ b/packages/web/src/app.css @@ -550,6 +550,7 @@ from { opacity: 0; } to { opacity: 1; } } + .spin { animation: spin 0.8s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } @@ -562,6 +563,22 @@ from { transform: scale(0.97); opacity: 0; } to { transform: scale(1); opacity: 1; } } + .expand-in { + animation: expandIn var(--duration-normal) var(--ease-out); + overflow: hidden; + } + .expand-out { + animation: expandOut 200ms var(--ease-out) forwards; + overflow: hidden; + } + @keyframes expandIn { + from { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; } + to { max-height: 500px; opacity: 1; } + } + @keyframes expandOut { + from { max-height: 500px; opacity: 1; } + to { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; } + } @keyframes slideDown { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } @@ -601,6 +618,15 @@ .count-tick { animation: countTick 300ms var(--ease-spring); } + + @keyframes toastIn { + from { transform: translateY(-12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } + } } .sr-only { diff --git a/packages/web/src/components/AdminSidebar.tsx b/packages/web/src/components/AdminSidebar.tsx index 7dbbd5a..86501eb 100644 --- a/packages/web/src/components/AdminSidebar.tsx +++ b/packages/web/src/components/AdminSidebar.tsx @@ -22,6 +22,7 @@ const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [ { to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 }, { to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 }, { to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 }, + { to: '/admin/plugins', label: 'Plugins', icon: IconPlug, minLevel: 3 }, { to: '/admin/statuses', label: 'Custom Statuses', icon: IconPalette, minLevel: 2 }, { to: '/admin/templates', label: 'Templates', icon: IconTemplate, minLevel: 2 }, { to: '/admin/embed', label: 'Embed Widget', icon: IconCode, minLevel: 2 }, diff --git a/packages/web/src/components/PasskeyModal.tsx b/packages/web/src/components/PasskeyModal.tsx index 23e9722..8e7653f 100644 --- a/packages/web/src/components/PasskeyModal.tsx +++ b/packages/web/src/components/PasskeyModal.tsx @@ -4,6 +4,7 @@ import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import { api } from '../lib/api' import { useAuth } from '../hooks/useAuth' import { useFocusTrap } from '../hooks/useFocusTrap' +import { useToast } from '../hooks/useToast' import { IconX, IconCheck, IconAlertTriangle, IconFingerprint } from '@tabler/icons-react' interface Props { @@ -14,6 +15,7 @@ interface Props { export default function PasskeyModal({ mode, open, onClose }: Props) { const auth = useAuth() + const toast = useToast() const trapRef = useFocusTrap(open) const [username, setUsername] = useState('') const [checking, setChecking] = useState(false) @@ -88,6 +90,7 @@ export default function PasskeyModal({ mode, open, onClose }: Props) { await api.post('/auth/passkey/register/verify', { username, response: attestation }) setSuccess(true) await auth.refresh() + toast.success('Passkey registered') setTimeout(onClose, 2000) } catch (e: any) { setError(e?.message || 'Registration failed. Please try again.') diff --git a/packages/web/src/components/RecoveryCodeModal.tsx b/packages/web/src/components/RecoveryCodeModal.tsx index 1984930..edef9b2 100644 --- a/packages/web/src/components/RecoveryCodeModal.tsx +++ b/packages/web/src/components/RecoveryCodeModal.tsx @@ -3,6 +3,7 @@ import { IconShieldCheck, IconCopy, IconCheck, IconRefresh } from '@tabler/icons import { api } from '../lib/api' import { useAuth } from '../hooks/useAuth' import { useFocusTrap } from '../hooks/useFocusTrap' +import { useToast } from '../hooks/useToast' interface Props { open: boolean @@ -11,6 +12,7 @@ interface Props { export default function RecoveryCodeModal({ open, onClose }: Props) { const auth = useAuth() + const toast = useToast() const trapRef = useFocusTrap(open) const [phrase, setPhrase] = useState(null) const [expiresAt, setExpiresAt] = useState(null) @@ -28,6 +30,7 @@ export default function RecoveryCodeModal({ open, onClose }: Props) { setPhrase(res.phrase) setExpiresAt(res.expiresAt) auth.refresh() + toast.success('Recovery code generated') } catch { setError('Failed to generate recovery code') } finally { diff --git a/packages/web/src/hooks/useToast.tsx b/packages/web/src/hooks/useToast.tsx new file mode 100644 index 0000000..7005a92 --- /dev/null +++ b/packages/web/src/hooks/useToast.tsx @@ -0,0 +1,174 @@ +import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react' +import { IconCheck, IconX, IconInfoCircle, IconAlertTriangle } from '@tabler/icons-react' + +type ToastType = 'success' | 'error' | 'info' | 'warning' + +interface Toast { + id: string + message: string + type: ToastType + duration: number +} + +interface ToastAPI { + success: (msg: string, duration?: number) => void + error: (msg: string, duration?: number) => void + info: (msg: string, duration?: number) => void + warning: (msg: string, duration?: number) => void +} + +const ToastContext = createContext({ + success: () => {}, + error: () => {}, + info: () => {}, + warning: () => {}, +}) + +export function useToast() { + return useContext(ToastContext) +} + +// global emitter for non-React code (api.ts etc) +type ToastFn = (msg: string, type?: string) => void +let globalToast: ToastFn | null = null +export function setGlobalToast(fn: ToastFn) { globalToast = fn } +export function emitToast(msg: string, type: ToastType = 'info') { + if (globalToast) globalToast(msg, type) +} + +let idCounter = 0 + +const TYPE_COLORS: Record = { + success: 'var(--success)', + error: 'var(--error)', + warning: 'var(--warning)', + info: 'var(--info)', +} + +const TYPE_ICONS: Record = { + success: IconCheck, + error: IconX, + warning: IconAlertTriangle, + info: IconInfoCircle, +} + +const MAX_VISIBLE = 5 + +function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) { + const [exiting, setExiting] = useState(false) + const timerRef = useRef>() + const Icon = TYPE_ICONS[toast.type] + + useEffect(() => { + timerRef.current = setTimeout(() => { + setExiting(true) + setTimeout(() => onDismiss(toast.id), 200) + }, toast.duration) + return () => clearTimeout(timerRef.current) + }, [toast.id, toast.duration, onDismiss]) + + const handleClose = () => { + clearTimeout(timerRef.current) + setExiting(true) + setTimeout(() => onDismiss(toast.id), 200) + } + + return ( +
+
+ ) +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]) + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const add = useCallback((message: string, type: ToastType, duration = 4000) => { + const id = `toast-${++idCounter}` + setToasts((prev) => { + const next = [...prev, { id, message, type, duration }] + return next.slice(-MAX_VISIBLE) + }) + }, []) + + const api: ToastAPI = { + success: useCallback((msg: string, dur?: number) => add(msg, 'success', dur), [add]), + error: useCallback((msg: string, dur?: number) => add(msg, 'error', dur), [add]), + info: useCallback((msg: string, dur?: number) => add(msg, 'info', dur), [add]), + warning: useCallback((msg: string, dur?: number) => add(msg, 'warning', dur), [add]), + } + + useEffect(() => { + setGlobalToast((msg, type) => add(msg, (type as ToastType) || 'info')) + return () => { globalToast = null } + }, [add]) + + return ( + + {children} +
+ {toasts.map((t) => ( + + ))} +
+
+ ) +} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 8f34ab2..8af16ac 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import { emitToast } from '../hooks/useToast' + const BASE = '/api/v1' class ApiError extends Error { @@ -29,6 +31,8 @@ async function request(path: string, opts: RequestInit = {}): Promise { } catch { body = await res.text() } + if (res.status === 429) emitToast('Too many requests, try again in a moment', 'warning') + if (res.status >= 500) emitToast('Something went wrong', 'error') throw new ApiError(res.status, body) } diff --git a/packages/web/src/pages/BoardFeed.tsx b/packages/web/src/pages/BoardFeed.tsx index 0038600..f801b4e 100644 --- a/packages/web/src/pages/BoardFeed.tsx +++ b/packages/web/src/pages/BoardFeed.tsx @@ -11,6 +11,7 @@ import { IconExternalLink, IconRss, IconX, IconFilter, IconChevronDown, IconPlus import BoardIcon from '../components/BoardIcon' import Dropdown from '../components/Dropdown' import { solveAltcha } from '../lib/altcha' +import { useToast } from '../hooks/useToast' interface Post { id: string @@ -100,6 +101,7 @@ export default function BoardFeed() { const [formOpen, setFormOpen] = useState(false) const [filtersOpen, setFiltersOpen] = useState(false) const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([]) + const toast = useToast() const [subscribed, setSubscribed] = useState(false) const [subLoading, setSubLoading] = useState(false) @@ -157,6 +159,7 @@ export default function BoardFeed() { if (subscribed) { await api.delete(`/boards/${boardSlug}/subscribe`) setSubscribed(false) + toast.info('Unsubscribed from notifications') } else { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { alert('Push notifications are not supported in this browser') @@ -176,8 +179,11 @@ export default function BoardFeed() { keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth }, }) setSubscribed(true) + toast.success('Subscribed to board notifications') } - } catch {} finally { + } catch { + toast.error('Failed to update subscription') + } finally { setSubLoading(false) } } diff --git a/packages/web/src/pages/IdentitySettings.tsx b/packages/web/src/pages/IdentitySettings.tsx index fdf30fc..17a17f5 100644 --- a/packages/web/src/pages/IdentitySettings.tsx +++ b/packages/web/src/pages/IdentitySettings.tsx @@ -3,6 +3,7 @@ import { useAuth } from '../hooks/useAuth' import { useDocumentTitle } from '../hooks/useDocumentTitle' import { useFocusTrap } from '../hooks/useFocusTrap' import { useConfirm } from '../hooks/useConfirm' +import { useToast } from '../hooks/useToast' import { api } from '../lib/api' import { solveAltcha } from '../lib/altcha' import { IconCheck, IconLock, IconKey, IconDownload, IconCamera, IconTrash, IconShieldCheck, IconArrowBack } from '@tabler/icons-react' @@ -20,6 +21,7 @@ interface PasskeyInfo { export default function IdentitySettings() { useDocumentTitle('Settings') const confirm = useConfirm() + const toast = useToast() const auth = useAuth() const [name, setName] = useState(auth.displayName) const [saving, setSaving] = useState(false) @@ -90,8 +92,11 @@ export default function IdentitySettings() { if (res.ok) { const data = await res.json() setAvatarUrl(data.avatarUrl + '?t=' + Date.now()) + toast.success('Avatar updated') } - } catch {} finally { + } catch { + toast.error('Failed to upload avatar') + } finally { setUploadingAvatar(false) if (avatarInputRef.current) avatarInputRef.current.value = '' } @@ -101,7 +106,10 @@ export default function IdentitySettings() { try { await api.delete('/me/avatar') setAvatarUrl(null) - } catch {} + toast.success('Avatar removed') + } catch { + toast.error('Failed to remove avatar') + } } const saveName = async () => { @@ -112,7 +120,10 @@ export default function IdentitySettings() { await auth.updateProfile({ displayName: name, altcha }) setSaved(true) setTimeout(() => setSaved(false), 2000) - } catch {} finally { + toast.success('Display name saved') + } catch { + toast.error('Failed to save name') + } finally { setSaving(false) } } @@ -130,6 +141,7 @@ export default function IdentitySettings() { await api.post('/auth/recover', { phrase: clean, altcha }) setRedeemSuccess(true) auth.refresh() + toast.success('Identity recovered') setTimeout(() => { setShowRedeemInput(false) setRedeemPhrase('') @@ -160,8 +172,11 @@ export default function IdentitySettings() { try { const altcha = await solveAltcha() await auth.deleteIdentity(altcha) + toast.info('Identity deleted') window.location.href = '/' - } catch {} finally { + } catch { + toast.error('Failed to delete identity') + } finally { setDeleting(false) } } diff --git a/packages/web/src/pages/PostDetail.tsx b/packages/web/src/pages/PostDetail.tsx index 6e07a1b..37b807f 100644 --- a/packages/web/src/pages/PostDetail.tsx +++ b/packages/web/src/pages/PostDetail.tsx @@ -16,6 +16,7 @@ import MarkdownEditor from '../components/MarkdownEditor' import FileUpload from '../components/FileUpload' import { solveAltcha } from '../lib/altcha' import { useConfirm } from '../hooks/useConfirm' +import { useToast } from '../hooks/useToast' import { IMPORTANCE_OPTIONS } from '../components/PostCard' interface ImportanceCounts { @@ -181,6 +182,7 @@ export default function PostDetail() { const auth = useAuth() const admin = useAdmin() const confirm = useConfirm() + const toast = useToast() const [post, setPost] = useState(null) useDocumentTitle(post?.title) const [boardName, setBoardName] = useState('') @@ -236,12 +238,15 @@ export default function PostDetail() { try { if (wasVoted) { await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`) + toast.success('Vote removed') } else { const altcha = await solveAltcha('light') await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha }) + toast.success('Vote added') } } catch { setPost({ ...post, voted: wasVoted, voteCount: post.voteCount }) + toast.error('Failed to vote') } } @@ -278,7 +283,10 @@ export default function PostDetail() { setReplyTo(null) setCommentAttachments([]) fetchPost() - } catch {} finally { + toast.success('Comment posted') + } catch { + toast.error('Failed to post comment') + } finally { setSubmitting(false) } } @@ -313,7 +321,10 @@ export default function PostDetail() { try { const res = await api.put<{ isEditLocked: boolean }>(`/admin/posts/${postId}/lock-edits`) setPost({ ...post, isEditLocked: res.isEditLocked }) - } catch {} + toast.success(res.isEditLocked ? 'Post edits locked' : 'Post edits unlocked') + } catch { + toast.error('Failed to toggle edit lock') + } } const toggleThreadLock = async (lockVoting?: boolean) => { @@ -324,7 +335,10 @@ export default function PostDetail() { { lockVoting } ) setPost({ ...post, isThreadLocked: res.isThreadLocked, isVotingLocked: res.isVotingLocked }) - } catch {} + toast.success(res.isThreadLocked ? 'Thread locked' : 'Thread unlocked') + } catch { + toast.error('Failed to toggle thread lock') + } } const toggleCommentEditLock = async (commentId: string) => { @@ -366,7 +380,10 @@ export default function PostDetail() { } setHistoryModal(null) fetchPost() - } catch {} + toast.success('Version restored') + } catch { + toast.error('Failed to restore version') + } } const startEdit = () => { @@ -386,7 +403,10 @@ export default function PostDetail() { }) setEditing(false) fetchPost() - } catch {} finally { + toast.success('Post updated') + } catch { + toast.error('Failed to update post') + } finally { setEditSaving(false) } } @@ -625,8 +645,11 @@ export default function PostDetail() { if (!await confirm('Delete this post?')) return try { await api.delete(`/boards/${boardSlug}/posts/${postId}`) + toast.success('Post deleted') navigate(`/b/${boardSlug}`) - } catch {} + } catch { + toast.error('Failed to delete post') + } }} className="inline-flex items-center gap-1 px-2.5 action-btn" style={{ diff --git a/packages/web/src/pages/admin/AdminBoards.tsx b/packages/web/src/pages/admin/AdminBoards.tsx index d4c4076..1e70404 100644 --- a/packages/web/src/pages/admin/AdminBoards.tsx +++ b/packages/web/src/pages/admin/AdminBoards.tsx @@ -3,6 +3,9 @@ import { Link } from 'react-router-dom' import { api } from '../../lib/api' import { useDocumentTitle } from '../../hooks/useDocumentTitle' import { useFocusTrap } from '../../hooks/useFocusTrap' +import { useAdmin } from '../../hooks/useAdmin' +import { useConfirm } from '../../hooks/useConfirm' +import { useToast } from '../../hooks/useToast' import Dropdown from '../../components/Dropdown' import NumberInput from '../../components/NumberInput' import IconPicker from '../../components/IconPicker' @@ -26,6 +29,9 @@ interface Board { export default function AdminBoards() { useDocumentTitle('Manage Boards') + const { isSuperAdmin } = useAdmin() + const confirm = useConfirm() + const toast = useToast() const [boards, setBoards] = useState([]) const [loading, setLoading] = useState(true) const [editBoard, setEditBoard] = useState(null) @@ -84,12 +90,16 @@ export default function AdminBoards() { try { if (editBoard) { await api.put(`/admin/boards/${editBoard.id}`, form) + toast.success('Board updated') } else { await api.post('/admin/boards', form) + toast.success('Board created') } resetForm() fetchBoards() - } catch {} finally { + } catch { + toast.error('Failed to save board') + } finally { setSaving(false) } } @@ -98,7 +108,24 @@ export default function AdminBoards() { try { await api.put(`/admin/boards/${id}`, { isArchived: !isArchived }) fetchBoards() - } catch {} + toast.info(isArchived ? 'Board restored' : 'Board archived') + } catch { + toast.error('Failed to update board') + } + } + + const handleDelete = async (board: Board) => { + const ok = await confirm( + `Permanently delete "${board.name}" and all its posts, comments, and votes? This cannot be undone.` + ) + if (!ok) return + try { + await api.delete(`/admin/boards/${board.id}`) + fetchBoards() + toast.success('Board deleted') + } catch { + toast.error('Failed to delete board') + } } const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') @@ -165,6 +192,15 @@ export default function AdminBoards() { > {board.isArchived ? 'Restore' : 'Archive'} + {isSuperAdmin && ( + + )} ))} @@ -291,7 +327,7 @@ export default function AdminBoards() { onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))} min={1} max={200} - style={{ width: 100 }} + style={{ minWidth: 120 }} /> )} diff --git a/packages/web/src/pages/admin/AdminCategories.tsx b/packages/web/src/pages/admin/AdminCategories.tsx index bb0352a..5d663b1 100644 --- a/packages/web/src/pages/admin/AdminCategories.tsx +++ b/packages/web/src/pages/admin/AdminCategories.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { api } from '../../lib/api' import { useDocumentTitle } from '../../hooks/useDocumentTitle' +import { useToast } from '../../hooks/useToast' interface Category { id: string @@ -10,6 +11,7 @@ interface Category { export default function AdminCategories() { useDocumentTitle('Categories') + const toast = useToast() const [categories, setCategories] = useState([]) const [name, setName] = useState('') const [slug, setSlug] = useState('') @@ -33,8 +35,10 @@ export default function AdminCategories() { setName('') setSlug('') fetch() + toast.success('Category created') } catch { setError('Failed to create category') + toast.error('Failed to create category') } } @@ -42,7 +46,10 @@ export default function AdminCategories() { try { await api.delete(`/admin/categories/${id}`) fetch() - } catch {} + toast.success('Category deleted') + } catch { + toast.error('Failed to delete category') + } } return ( diff --git a/packages/web/src/pages/admin/AdminChangelog.tsx b/packages/web/src/pages/admin/AdminChangelog.tsx index 993d15a..0b08fa5 100644 --- a/packages/web/src/pages/admin/AdminChangelog.tsx +++ b/packages/web/src/pages/admin/AdminChangelog.tsx @@ -4,6 +4,7 @@ import { IconPlus, IconTrash, IconPencil } from '@tabler/icons-react' import { api } from '../../lib/api' import { useDocumentTitle } from '../../hooks/useDocumentTitle' import { useConfirm } from '../../hooks/useConfirm' +import { useToast } from '../../hooks/useToast' import MarkdownEditor from '../../components/MarkdownEditor' import Dropdown from '../../components/Dropdown' @@ -25,6 +26,7 @@ interface Entry { export default function AdminChangelog() { useDocumentTitle('Manage Changelog') const confirm = useConfirm() + const toast = useToast() const [entries, setEntries] = useState([]) const [boards, setBoards] = useState([]) const [loading, setLoading] = useState(true) @@ -73,8 +75,10 @@ export default function AdminChangelog() { setEditId(null) setShowForm(false) fetchEntries() + toast.success('Changelog entry saved') } catch { setError('Failed to save entry') + toast.error('Failed to save entry') } } @@ -83,7 +87,10 @@ export default function AdminChangelog() { try { await api.delete(`/admin/changelog/${id}`) fetchEntries() - } catch {} + toast.success('Entry deleted') + } catch { + toast.error('Failed to delete entry') + } } const startEdit = (entry: Entry) => { diff --git a/packages/web/src/pages/admin/AdminPlugins.tsx b/packages/web/src/pages/admin/AdminPlugins.tsx new file mode 100644 index 0000000..75e39b9 --- /dev/null +++ b/packages/web/src/pages/admin/AdminPlugins.tsx @@ -0,0 +1,472 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { IconPlug, IconUpload, IconTrash, IconSettings, IconAlertTriangle, IconRefresh } from '@tabler/icons-react' +import { api } from '../../lib/api' +import { useDocumentTitle } from '../../hooks/useDocumentTitle' +import { useConfirm } from '../../hooks/useConfirm' +import { useToast } from '../../hooks/useToast' + +interface ConfigField { + key: string + type: 'text' | 'password' | 'number' | 'boolean' | 'select' + label: string + placeholder?: string + required?: boolean + options?: Array<{ value: string; label: string }> +} + +interface PluginRecord { + id: string + name: string + version: string + description: string | null + author: string | null + enabled: boolean + config: Record + configSchema: ConfigField[] + installedAt: string +} + +export default function AdminPlugins() { + useDocumentTitle('Plugins') + const confirm = useConfirm() + const toast = useToast() + + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showUpload, setShowUpload] = useState(false) + const [uploading, setUploading] = useState(false) + const [dragOver, setDragOver] = useState(false) + const [configEditing, setConfigEditing] = useState(null) + const [configClosing, setConfigClosing] = useState(null) + const [configValues, setConfigValues] = useState>({}) + const fileInputRef = useRef(null) + + const fetchPlugins = useCallback(() => { + api.get<{ plugins: PluginRecord[] }>('/admin/plugins') + .then((r) => setPlugins(r.plugins)) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { fetchPlugins() }, [fetchPlugins]) + + const uploadFile = async (file: File) => { + if (!file.name.endsWith('.zip')) { + setError('Only .zip files are allowed') + return + } + setUploading(true) + setError('') + try { + const form = new FormData() + form.append('file', file) + const r = await fetch('/api/v1/admin/plugins/upload', { + method: 'POST', + body: form, + credentials: 'include', + }) + if (!r.ok) { + const body = await r.json().catch(() => ({})) + throw new Error((body as { error?: string }).error || 'Upload failed') + } + setShowUpload(false) + fetchPlugins() + toast.success('Plugin installed') + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Upload failed') + toast.error('Failed to install plugin') + } finally { + setUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + const togglePlugin = async (id: string, enabled: boolean) => { + try { + await api.put(`/admin/plugins/${id}/${enabled ? 'disable' : 'enable'}`) + fetchPlugins() + toast.success(enabled ? 'Plugin disabled' : 'Plugin enabled') + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to toggle plugin') + toast.error('Failed to toggle plugin') + } + } + + const deletePlugin = async (id: string, name: string) => { + if (!await confirm(`Delete plugin "${name}"? This removes all plugin data.`)) return + try { + await api.delete(`/admin/plugins/${id}`) + fetchPlugins() + toast.success('Plugin deleted') + } catch { + setError('Failed to delete plugin') + toast.error('Failed to delete plugin') + } + } + + const [syncing, setSyncing] = useState(null) + const [syncResult, setSyncResult] = useState('') + + const triggerSync = async (pluginId: string) => { + setSyncing(pluginId) + setSyncResult('') + try { + const res = await fetch(`/api/v1/plugins/${pluginId}/sync`, { method: 'POST', credentials: 'include' }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Sync failed') + setSyncResult(`Synced ${data.synced ?? 0} repos (${data.created ?? 0} created, ${data.updated ?? 0} updated)`) + toast.success(`Sync complete - ${data.synced ?? 0} repos`) + } catch (e: unknown) { + setSyncResult(e instanceof Error ? e.message : 'Sync failed') + } finally { + setSyncing(null) + } + } + + const closeConfig = () => { + if (!configEditing) return + setConfigClosing(configEditing) + setTimeout(() => { setConfigEditing(null); setConfigClosing(null) }, 200) + } + + const saveConfig = async (id: string) => { + try { + await api.put(`/admin/plugins/${id}/config`, configValues) + closeConfig() + fetchPlugins() + toast.success('Config saved') + } catch { + setError('Failed to save config') + toast.error('Failed to save config') + } + } + + const openConfig = (plugin: PluginRecord) => { + if (configEditing === plugin.id) { + closeConfig() + return + } + setConfigClosing(null) + setConfigEditing(plugin.id) + setConfigValues({ ...plugin.config }) + } + + const setConfigField = (key: string, value: unknown) => { + setConfigValues((prev) => ({ ...prev, [key]: value })) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer.files[0] + if (file) uploadFile(file) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(true) + } + + const formatDate = (iso: string) => { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } + + return ( +
+
+

+ Plugins +

+
+ + Back +
+
+ + {/* Warning banner */} +
+ + Plugins run with full server access. Only install plugins you trust. +
+ + {error && ( +

{error}

+ )} + + {/* Upload section */} + {showUpload && ( +
+
setDragOver(false)} + onClick={() => fileInputRef.current?.click()} + style={{ + border: `2px dashed ${dragOver ? 'var(--admin-accent)' : 'var(--border)'}`, + borderRadius: 'var(--radius-md)', + padding: '32px 24px', + textAlign: 'center', + cursor: uploading ? 'wait' : 'pointer', + background: dragOver ? 'var(--admin-subtle)' : 'transparent', + transition: 'all var(--duration-fast) ease-out', + }} + > + { + const file = e.target.files?.[0] + if (file) uploadFile(file) + }} + /> + {uploading ? ( + <> +
+

Uploading...

+ + ) : ( + <> + +

+ Drop a .zip file here or click to browse +

+

+ Only .zip plugin packages are accepted +

+ + )} +
+
+ +
+
+ )} + + {/* Plugin list */} + {loading ? ( +
+
+ {[0, 1].map((i) => ( +
+
+
+
+ ))} +
+ ) : plugins.length === 0 ? ( +
+
+ +
+

+ No plugins installed +

+

+ Upload a plugin package to get started +

+
+ ) : ( +
+ {plugins.map((plugin) => ( +
+
+
+
+
+ + {plugin.name} + + + v{plugin.version} + + {!plugin.enabled && ( + + disabled + + )} +
+ {plugin.description && ( +

+ {plugin.description} +

+ )} +
+ {plugin.author && by {plugin.author}} + Installed {formatDate(plugin.installedAt)} +
+
+
+ + + +
+
+ + {/* Config editor */} + {configEditing === plugin.id && ( +
+ {plugin.configSchema && plugin.configSchema.length > 0 ? ( +
+ {plugin.configSchema.map((field) => ( +
+ + {field.type === 'boolean' ? ( + + ) : field.type === 'select' && field.options ? ( + + ) : ( + setConfigField(field.key, field.type === 'number' ? Number(e.target.value) : e.target.value)} + placeholder={field.placeholder} + /> + )} +
+ ))} +
+ ) : ( +
+ +