feat(federation): Add UI to view BullMQ metadata

This commit is contained in:
Jesse Wierzbinski 2024-11-25 13:09:28 +01:00
parent 8a920218ea
commit ecc7d1eee7
No known key found for this signature in database
7 changed files with 215 additions and 71 deletions

2
app.ts
View file

@ -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<OpenAPIHono<HonoEnv>> => {
},
});
app.get("/docs", swaggerUI({ url: "/openapi.json" }));
applyToHono(app);
app.options("*", (context) => {
return context.text("", 204);

View file

@ -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`;

BIN
bun.lockb

Binary file not shown.

View file

@ -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"
}
}

View file

@ -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'))

View file

@ -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<typeof v, undefined>;
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<typeof v, undefined>;
}),
});
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;

38
utils/bull-board.ts Normal file
View file

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