diff --git a/app.ts b/app.ts index 242726de..bec323c6 100644 --- a/app.ts +++ b/app.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { handleZodError } from "@/api"; +import { applyToHono } from "@/bull-board.ts"; import { configureLoggers } from "@/loggers"; import { sentry } from "@/sentry"; import { cors } from "@hono/hono/cors"; @@ -160,6 +161,7 @@ export const appFactory = async (): Promise> => { }, }); app.get("/docs", swaggerUI({ url: "/openapi.json" })); + applyToHono(app); app.options("*", (context) => { return context.text("", 204); diff --git a/build.ts b/build.ts index 59dd4c24..2fa0ed8f 100644 --- a/build.ts +++ b/build.ts @@ -35,6 +35,10 @@ await Bun.build({ buildSpinner.text = "Transforming"; +// Fix Bun incorrectly transforming aliased imports +await $`sed -i 's/var serveStatic = (options) => {/var serveStaticBase = (options) => {/g' dist/index.js`; +await $`sed -i 's/ return serveStatic({/ return serveStaticBase({/g' dist/index.js`; + // Copy Drizzle migrations to dist await $`cp -r drizzle dist/drizzle`; diff --git a/bun.lockb b/bun.lockb index 33e17849..36980e77 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 774460e6..2456f93b 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,8 @@ }, "dependencies": { "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client", + "@bull-board/api": "^6.5.3", + "@bull-board/hono": "^6.5.3", "@hackmd/markdown-it-task-lists": "^2.1.4", "@hono/hono": "npm:@jsr/hono__hono@4.6.11", "@hono/prometheus": "^1.0.1", @@ -164,5 +166,8 @@ "xss": "^1.0.15", "zod": "^3.23.8", "zod-validation-error": "^3.4.0" + }, + "patchedDependencies": { + "@bull-board/api@6.5.3": "patches/@bull-board%2Fapi@6.5.3.patch" } } diff --git a/patches/@bull-board%2Fapi@6.5.3.patch b/patches/@bull-board%2Fapi@6.5.3.patch new file mode 100644 index 00000000..81b87d71 --- /dev/null +++ b/patches/@bull-board%2Fapi@6.5.3.patch @@ -0,0 +1,13 @@ +diff --git a/dist/src/index.js b/dist/src/index.js +index b7fa76b4861aefc96e27b3167b1511c3723ad318..c56d37721672b9ede3c85c84cb40c91c4ed2cc83 100644 +--- a/dist/src/index.js ++++ b/dist/src/index.js +@@ -10,7 +10,7 @@ const queuesApi_1 = require("./queuesApi"); + const routes_1 = require("./routes"); + function createBullBoard({ queues, serverAdapter, options = { uiConfig: {} }, }) { + const { bullBoardQueues, setQueues, replaceQueues, addQueue, removeQueue } = (0, queuesApi_1.getQueuesApi)(queues); +- const uiBasePath = options.uiBasePath || path_1.default.dirname(eval(`require.resolve('@bull-board/ui/package.json')`)); ++ const uiBasePath = options.uiBasePath || path_1.default.dirname(import.meta.require.resolve('@bull-board/ui/package.json')); + serverAdapter + .setQueues(bullBoardQueues) + .setViewsPath(path_1.default.join(uiBasePath, 'dist')) diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts index fb9674ae..96eed0c8 100644 --- a/plugins/openid/index.ts +++ b/plugins/openid/index.ts @@ -1,7 +1,11 @@ +import { getCookie } from "@hono/hono/cookie"; import { Hooks, Plugin } from "@versia/kit"; import { User } from "@versia/kit/db"; import chalk from "chalk"; +import { jwtVerify } from "jose"; +import { JOSEError, JWTExpired } from "jose/errors"; import { z } from "zod"; +import { RolePermissions } from "~/drizzle/schema.ts"; import authorizeRoute from "./routes/authorize.ts"; import jwksRoute from "./routes/jwks.ts"; import ssoLoginCallbackRoute from "./routes/oauth/callback.ts"; @@ -11,78 +15,77 @@ import tokenRoute from "./routes/oauth/token.ts"; import ssoIdRoute from "./routes/sso/:id/index.ts"; import ssoRoute from "./routes/sso/index.ts"; -const plugin = new Plugin( - z.object({ - forced: z.boolean().default(false), - allow_registration: z.boolean().default(true), - providers: z - .array( - z.object({ - name: z.string().min(1), - id: z.string().min(1), - url: z.string().min(1), - client_id: z.string().min(1), - client_secret: z.string().min(1), - icon: z.string().min(1).optional(), - }), - ) - .default([]), - keys: z - .object({ - public: z - .string() - .min(1) - .transform(async (v) => { - try { - return await crypto.subtle.importKey( - "spki", - Buffer.from(v, "base64"), - "Ed25519", - true, - ["verify"], - ); - } catch { - throw new Error( - "Public key at oidc.keys.public is invalid", - ); - } - }), - private: z - .string() - .min(1) - .transform(async (v) => { - try { - return await crypto.subtle.importKey( - "pkcs8", - Buffer.from(v, "base64"), - "Ed25519", - true, - ["sign"], - ); - } catch { - throw new Error( - "Private key at oidc.keys.private is invalid", - ); - } - }), - }) - .optional() - .transform(async (v, ctx) => { - if (!(v?.private && v?.public)) { - const { public_key, private_key } = - await User.generateKeys(); - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Keys are missing, please add the following to your config:\n\nkeys.public: ${chalk.gray(public_key)}\nkeys.private: ${chalk.gray(private_key)} - `, - }); - } - - return v as Exclude; +const configSchema = z.object({ + forced: z.boolean().default(false), + allow_registration: z.boolean().default(true), + providers: z + .array( + z.object({ + name: z.string().min(1), + id: z.string().min(1), + url: z.string().min(1), + client_id: z.string().min(1), + client_secret: z.string().min(1), + icon: z.string().min(1).optional(), }), - }), -); + ) + .default([]), + keys: z + .object({ + public: z + .string() + .min(1) + .transform(async (v) => { + try { + return await crypto.subtle.importKey( + "spki", + Buffer.from(v, "base64"), + "Ed25519", + true, + ["verify"], + ); + } catch { + throw new Error( + "Public key at oidc.keys.public is invalid", + ); + } + }), + private: z + .string() + .min(1) + .transform(async (v) => { + try { + return await crypto.subtle.importKey( + "pkcs8", + Buffer.from(v, "base64"), + "Ed25519", + true, + ["sign"], + ); + } catch { + throw new Error( + "Private key at oidc.keys.private is invalid", + ); + } + }), + }) + .optional() + .transform(async (v, ctx) => { + if (!(v?.private && v?.public)) { + const { public_key, private_key } = await User.generateKeys(); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Keys are missing, please add the following to your config:\n\nkeys.public: ${chalk.gray(public_key)}\nkeys.private: ${chalk.gray(private_key)} + `, + }); + } + + return v as Exclude; + }), +}); + +const plugin = new Plugin(configSchema); // Test hook for screenshots plugin.registerHandler(Hooks.Response, (req) => { @@ -99,5 +102,84 @@ jwksRoute(plugin); ssoLoginRoute(plugin); ssoLoginCallbackRoute(plugin); +plugin.registerRoute("/admin/*", (app) => { + // Check for JWT when accessing the admin panel + app.use("/admin/*", async (context, next) => { + const jwtCookie = getCookie(context, "jwt"); + + if (!jwtCookie) { + return context.json( + { + error: "Unauthorized", + message: "No JWT cookie provided", + }, + 401, + ); + } + + const { keys } = context.get("pluginConfig"); + + const result = await jwtVerify(jwtCookie, keys.public, { + algorithms: ["EdDSA"], + issuer: new URL(context.get("config").http.base_url).origin, + }).catch((error) => { + if (error instanceof JOSEError) { + return error; + } + + throw error; + }); + + if (result instanceof JOSEError) { + if (result instanceof JWTExpired) { + return context.json( + { + error: "Unauthorized", + message: "JWT has expired. Please log in again.", + }, + 401, + ); + } + + return context.json( + { + error: "Unauthorized", + message: "Invalid JWT", + }, + 401, + ); + } + + const { + payload: { sub }, + } = result; + + if (!sub) { + return context.json( + { + error: "Unauthorized", + message: "Invalid JWT (no sub)", + }, + 401, + ); + } + + const user = await User.fromId(sub); + + if (!user?.hasPermission(RolePermissions.ManageInstanceFederation)) { + return context.json( + { + error: "Unauthorized", + message: + "You do not have permission to access this resource", + }, + 403, + ); + } + + await next(); + }); +}); + export type PluginType = typeof plugin; export default plugin; diff --git a/utils/bull-board.ts b/utils/bull-board.ts new file mode 100644 index 00000000..38c33395 --- /dev/null +++ b/utils/bull-board.ts @@ -0,0 +1,38 @@ +import { createBullBoard } from "@bull-board/api"; +import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; +import { HonoAdapter } from "@bull-board/hono"; +import { serveStatic } from "@hono/hono/bun"; +import type { OpenAPIHono } from "@hono/zod-openapi"; +import { config } from "~/packages/config-manager"; +import type { HonoEnv } from "~/types/api"; +import { deliveryQueue, inboxQueue } from "~/worker"; + +export const applyToHono = (app: OpenAPIHono): void => { + const serverAdapter = new HonoAdapter(serveStatic); + + createBullBoard({ + queues: [ + new BullMQAdapter(inboxQueue), + new BullMQAdapter(deliveryQueue), + ], + serverAdapter, + options: { + uiConfig: { + boardTitle: "Server Queues", + favIcon: { + default: "/favicon.png", + alternative: "/favicon.ico", + }, + boardLogo: { + path: + config.instance.logo ?? + "https://cdn.versia.pub/branding/icon.svg", + height: 40, + }, + }, + }, + }); + + serverAdapter.setBasePath("/admin/queues"); + app.route("/admin/queues", serverAdapter.registerPlugin()); +};