diff --git a/.gitignore b/.gitignore index 7cc7e94..a58fae0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist/ # Build output Overlord-Server/public/assets/tailwind.css Overlord-Server/public/vendor/ +Overlord-Server/public/assets/generated/ Overlord-Desktop/src/vendor/ # Tauri diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 78c58d3..561d109 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -15,10 +15,12 @@ "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", + "hono": "^4.12.33", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", "uuid": "^9.0.1", + "valibot": "^1.4.2", "web-push": "^3.6.7", "winston": "^3.11.0", }, @@ -342,6 +344,8 @@ "highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="], + "hono": ["hono@4.12.33", "", {}, "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ=="], + "html-minifier-terser": ["html-minifier-terser@7.2.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", "commander": "^10.0.0", "entities": "^4.4.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.15.1" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA=="], "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], @@ -554,6 +558,8 @@ "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 4842eb0..dc2583e 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -6,13 +6,15 @@ "scripts": { "test": "bun run scripts/run-tests-isolated.ts", "test:e2e": "bun run test:e2e:prepare && playwright test", - "test:e2e:prepare": "bun run build:css && bun run vendor", + "test:e2e:prepare": "bun run build:css && bun run build:web && bun run vendor", "test:e2e:install": "playwright install chromium", "dev": "bun run scripts/dev.ts", "start": "bun run build && bun dist/index.js", - "build": "bun run build:css && bun run vendor && bun run build:bundle", - "build:public:prod": "bun run build:css && bun run vendor && bun run minify", + "build": "bun run build:css && bun run build:web && bun run vendor && bun run build:bundle", + "build:public:prod": "bun run build:css && bun run build:web && bun run vendor && bun run minify", "build:bundle": "bun build --production src/index.ts --target bun --outfile dist/index.js && bun build --production src/server/plugin-runtime/worker-host.ts --target bun --outfile dist/server/plugin-runtime/worker-host.js", + "build:web": "bun build src/web/shared-ui-settings.ts --outdir public/assets/generated --target browser --format esm --sourcemap=external", + "watch:web": "bun run build:web --watch", "vendor": "bun run scripts/vendor.ts", "build:prod:win": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-windows-x64 --outfile dist/overlord-server.exe", "build:prod:linux": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-linux-x64 --outfile dist/overlord-server-linux-x64", @@ -35,10 +37,12 @@ "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", + "hono": "^4.12.33", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", "uuid": "^9.0.1", + "valibot": "^1.4.2", "web-push": "^3.6.7", "winston": "^3.11.0" }, diff --git a/Overlord-Server/public/assets/backstage.js b/Overlord-Server/public/assets/backstage.js index 6845375..b5ac554 100644 --- a/Overlord-Server/public/assets/backstage.js +++ b/Overlord-Server/public/assets/backstage.js @@ -3,7 +3,7 @@ import { checkFeatureAccess } from "./feature-gate.js"; import { createKeyboardCapture } from "./keyboard-capture.js"; import { WhepClient } from "./whep.js"; import { P2PClient } from "./webrtc-p2p.js"; -import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; +import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/shared-ui-settings.js"; (async function () { const urlParams = new URLSearchParams(location.search); diff --git a/Overlord-Server/public/assets/easter-egg.js b/Overlord-Server/public/assets/easter-egg.js new file mode 100644 index 0000000..4ee862b --- /dev/null +++ b/Overlord-Server/public/assets/easter-egg.js @@ -0,0 +1,65 @@ +const EASTER_EGG_ENABLED_KEY = "overlord_easter_egg_enabled"; +const EASTER_EGG_SHOWN_KEY = "overlord_easter_egg_shown"; +const EASTER_EGG_URL = "/assets/console.gif"; + +let activeRender = null; + +export function isEasterEggEnabled() { + try { + return localStorage.getItem(EASTER_EGG_ENABLED_KEY) === "true"; + } catch { + return false; + } +} + +export function setEasterEggEnabled(enabled) { + try { + localStorage.setItem(EASTER_EGG_ENABLED_KEY, String(Boolean(enabled))); + } catch {} + + if (!enabled) { + try { + sessionStorage.removeItem(EASTER_EGG_SHOWN_KEY); + } catch {} + } +} + +function wasShownThisSession() { + try { + return sessionStorage.getItem(EASTER_EGG_SHOWN_KEY) === "true"; + } catch { + return false; + } +} + +function markShownThisSession() { + try { + sessionStorage.setItem(EASTER_EGG_SHOWN_KEY, "true"); + } catch {} +} + +export async function showEnabledEasterEgg({ force = false } = {}) { + if (!isEasterEggEnabled() || (!force && wasShownThisSession())) return false; + if (activeRender) return activeRender; + + activeRender = (async () => { + const response = await fetch(EASTER_EGG_URL); + if (!response.ok) throw new Error(`Easter egg failed to load (${response.status})`); + + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) throw new Error("Easter egg response was not an image"); + + const objectUrl = URL.createObjectURL(blob); + console.log( + "%c ", + `background:url("${objectUrl}") left top/contain no-repeat;font-size:320px;background-color:transparent`, + ); + markShownThisSession(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); + return true; + })().finally(() => { + activeRender = null; + }); + + return activeRender; +} diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 4fa8d83..ef0484e 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -15,6 +15,7 @@ import { } from "./data.js"; import { ThumbnailLoader } from "./thumbnail-loader.js"; import { initDashboardStats, updateDashboardStatsFromClients } from "./dashboard-stats.js"; +import { showEnabledEasterEgg } from "./easter-egg.js"; const grid = document.getElementById("grid"); const totalPill = document.getElementById("total-pill"); @@ -2243,40 +2244,5 @@ menu.addEventListener("click", async (e) => { loadCurrentUser(); -// console easter egg -(function () { - const runWhenIdle = (fn) => { - if ("requestIdleCallback" in window) { - window.requestIdleCallback(fn, { timeout: 5000 }); - return; - } - window.addEventListener("load", () => setTimeout(fn, 1500), { once: true }); - }; - - const consoleimg = { - load: function (src, { size = 320, color = "transparent" } = {}) { - const reader = new FileReader(); - reader.addEventListener("load", function () { - const style = - "background: url('" + reader.result + "') left top no-repeat; font-size: " + - size + - "px; background-size: contain; background-color:" + - color; - console.log("%c ", style); - }, false); - fetch(src) - .then((r) => r.blob()) - .then((blob) => { - if (blob.type.indexOf("image") === 0) { - if (blob.size > 8192 && navigator.userAgent.indexOf("Firefox") > 0) - throw new Error("Image size too big to be displayed in Firefox."); - return blob; - } - throw new Error("Valid image not found."); - }) - .then((blob) => reader.readAsDataURL(blob)) - .catch((err) => console.warn(err.message)); - }, - }; - runWhenIdle(() => consoleimg.load("/assets/console.gif", { size: 320, color: "transparent" })); -})(); +// Opt-in console easter egg. Disabled users never request the large image. +showEnabledEasterEgg().catch((err) => console.warn(err.message)); diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index bed6e81..2cce2a0 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -4,7 +4,7 @@ import { WhepClient } from "./whep.js"; import { P2PClient } from "./webrtc-p2p.js"; import { AdaptiveDesktopQuality, normalizeAdaptiveProfiles } from "./rd-adaptive-quality.js"; import { createKeyboardCapture } from "./keyboard-capture.js"; -import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; +import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/shared-ui-settings.js"; (async function () { const clientId = new URLSearchParams(location.search).get("clientId"); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 604a949..f5d5c59 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -7,6 +7,11 @@ import { } from "./notify-client.js"; import { escapeHtml, formatBytes as formatSharedBytes, formatDate as formatSharedDate } from "./format.js"; import { getCertificateTrustGuide } from "./cert-banner.js"; +import { + isEasterEggEnabled, + setEasterEggEnabled, + showEnabledEasterEgg, +} from "./easter-egg.js"; const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds"; const NAV_MODE_KEY = "sb_mode"; @@ -40,6 +45,8 @@ const prefNotificationsInput = document.getElementById("pref-notifications"); const prefDesktopNotificationsInput = document.getElementById("pref-desktop-notifications"); const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint"); const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds"); +const easterEggEnabledInput = document.getElementById("easter-egg-enabled"); +const easterEggStatus = document.getElementById("easter-egg-status"); const inputArchiveUserForm = document.getElementById("input-archive-user-form"); const inputArchiveMyEnabledInput = document.getElementById("input-archive-my-enabled"); @@ -656,6 +663,38 @@ function loadPrefs() { } } +function initEasterEggPreference() { + if (!easterEggEnabledInput) return; + easterEggEnabledInput.checked = isEasterEggEnabled(); + easterEggEnabledInput.addEventListener("change", async () => { + const enabled = easterEggEnabledInput.checked; + setEasterEggEnabled(enabled); + if (!easterEggStatus) return; + + if (!enabled) { + easterEggStatus.textContent = "Disabled. The image will not be requested."; + easterEggStatus.className = "text-xs text-slate-500"; + return; + } + + easterEggEnabledInput.disabled = true; + easterEggStatus.textContent = "Loading the easter egg…"; + easterEggStatus.className = "text-xs text-violet-300"; + try { + await showEnabledEasterEgg({ force: true }); + easterEggStatus.textContent = "Enabled and shown in the browser console."; + easterEggStatus.className = "text-xs text-emerald-400"; + } catch (error) { + setEasterEggEnabled(false); + easterEggEnabledInput.checked = false; + easterEggStatus.textContent = `Could not load the easter egg: ${error.message}`; + easterEggStatus.className = "text-xs text-rose-400"; + } finally { + easterEggEnabledInput.disabled = false; + } + }); +} + async function loadCurrentUser() { const res = await fetch("/api/auth/me", { credentials: "include" }); if (!res.ok) { @@ -2926,6 +2965,7 @@ async function init() { await loadCurrentUser(); applyPermissionVisibility(); loadPrefs(); + initEasterEggPreference(); await loadMfaStatus(); if (userHas("clients:build")) { diff --git a/Overlord-Server/public/assets/webcam.js b/Overlord-Server/public/assets/webcam.js index d6a6cec..10b0cda 100644 --- a/Overlord-Server/public/assets/webcam.js +++ b/Overlord-Server/public/assets/webcam.js @@ -2,7 +2,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.js"; import { WhepClient } from "./whep.js"; import { P2PClient } from "./webrtc-p2p.js"; -import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; +import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/shared-ui-settings.js"; (async function () { const clientId = new URLSearchParams(location.search).get("clientId"); diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 19a8d6e..6c7d832 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -1781,6 +1781,28 @@ + +
+
+
+ +
+
+

Easter egg

+

Optionally show a hidden surprise in the browser console.

+
+
+ + +

Disabled by default.

+
+ diff --git a/Overlord-Server/scripts/dev.ts b/Overlord-Server/scripts/dev.ts index 8a54e4d..c0a38f9 100644 --- a/Overlord-Server/scripts/dev.ts +++ b/Overlord-Server/scripts/dev.ts @@ -26,6 +26,7 @@ function runPreparation(command: string): void { } runPreparation("build:css"); +runPreparation("build:web"); runPreparation("vendor"); const backend = Bun.spawn(["bun", "--watch", "src/index.ts"], { @@ -44,6 +45,14 @@ const styles = Bun.spawn(["bun", "run", "watch:css"], { stderr: "inherit", }); +const web = Bun.spawn(["bun", "run", "watch:web"], { + cwd, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}); + let stopping = false; function stop(exitCode = 0): void { @@ -51,6 +60,7 @@ function stop(exitCode = 0): void { stopping = true; backend.kill(); styles.kill(); + web.kill(); process.exitCode = exitCode; } @@ -60,6 +70,7 @@ process.on("SIGTERM", () => stop(143)); const result = await Promise.race([ backend.exited.then((exitCode) => ({ process: "backend", exitCode })), styles.exited.then((exitCode) => ({ process: "Tailwind watcher", exitCode })), + web.exited.then((exitCode) => ({ process: "browser TypeScript watcher", exitCode })), ]); if (!stopping) { @@ -67,4 +78,4 @@ if (!stopping) { stop(result.exitCode || 1); } -await Promise.allSettled([backend.exited, styles.exited]); +await Promise.allSettled([backend.exited, styles.exited, web.exited]); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 31b7838..8bba5ad 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -58,6 +58,7 @@ import { handleRegistrationRoutes } from "./server/routes/registration-routes"; import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer"; import { startBuildProcess as runBuildProcess } from "./server/build-process"; import { createHttpFetchHandler } from "./server/http-dispatch"; +import { createHonoRouteHandler } from "./server/hono-router"; import { startMaintenanceLoops } from "./server/maintenance-loops"; import { deliverNotificationWithScreenshot, @@ -711,6 +712,13 @@ async function startServer() { handleCrashReport: notificationPluginHandlers.handleCrashReport, }; + const routedHttpHandler = createHonoRouteHandler([ + { + basePath: "/api/users", + handler: (req, url, srv) => handleUsersRoutes(req, url, srv as any), + }, + ]); + const server = Bun.serve({ port: PORT, hostname: HOST, @@ -742,7 +750,7 @@ async function startServer() { (req, url) => handleEnrollmentRoutes(req, url), (req, url) => handleChatRoutes(req, url), (req, url) => handleSolRoutes(req, url), - (req, url, srv) => handleUsersRoutes(req, url, srv as any), + routedHttpHandler, (req, url, srv) => handlePermissionGroupsRoutes(req, url, srv as any), (req, url, srv) => handleBuildRoutes(req, url, srv as any, routeDeps.build), (req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy), diff --git a/Overlord-Server/src/server/hono-router.test.ts b/Overlord-Server/src/server/hono-router.test.ts new file mode 100644 index 0000000..650db7b --- /dev/null +++ b/Overlord-Server/src/server/hono-router.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { createHonoRouteHandler } from "./hono-router"; + +describe("createHonoRouteHandler", () => { + const routed = createHonoRouteHandler([ + { + basePath: "/api/users", + handler: async (_req, url, server) => Response.json({ + path: url.pathname, + server, + }), + }, + ]); + + test("routes exact and nested group paths", async () => { + const server = { name: "test-server" }; + const exactUrl = new URL("https://localhost/api/users"); + const exact = await routed(new Request(exactUrl), exactUrl, server); + expect(exact?.status).toBe(200); + expect(await exact?.json()).toEqual({ path: "/api/users", server }); + expect(exact?.headers.get("x-request-id")).toBeTruthy(); + + const nestedUrl = new URL("https://localhost/api/users/42/role"); + const nested = await routed(new Request(nestedUrl), nestedUrl, server); + expect(nested?.status).toBe(200); + expect((await nested?.json()).path).toBe("/api/users/42/role"); + }); + + test("does not claim similar or unrelated paths", async () => { + for (const path of ["/api/users-other", "/api/clients"]) { + const url = new URL(`https://localhost${path}`); + expect(await routed(new Request(url), url, {})).toBeNull(); + } + }); + + test("falls through when a migrated handler does not own a nested route", async () => { + const fallthrough = createHonoRouteHandler([ + { + basePath: "/api/users", + handler: async () => null, + }, + ]); + const url = new URL("https://localhost/api/users/42/permission-groups"); + expect(await fallthrough(new Request(url), url, {})).toBeNull(); + }); +}); diff --git a/Overlord-Server/src/server/hono-router.ts b/Overlord-Server/src/server/hono-router.ts new file mode 100644 index 0000000..788081c --- /dev/null +++ b/Overlord-Server/src/server/hono-router.ts @@ -0,0 +1,57 @@ +import { Hono, type Handler } from "hono"; +import { requestId } from "hono/request-id"; +import type { RouteHandler } from "./http-dispatch"; + +type RouterEnv = { + Bindings: { + server: unknown; + }; +}; + +const FALLTHROUGH_HEADER = "X-Overlord-Route-Fallthrough"; + +export type HonoRouteGroup = { + basePath: `/${string}`; + handler: RouteHandler; +}; + +function ownsPath(basePath: string, pathname: string): boolean { + return pathname === basePath || pathname.startsWith(`${basePath}/`); +} + +export function createHonoRouteHandler(groups: readonly HonoRouteGroup[]): RouteHandler { + const app = new Hono(); + app.use("*", requestId()); + app.use("*", async (context, next) => { + await next(); + context.res.headers.set("X-Request-Id", context.get("requestId")); + }); + + for (const group of groups) { + const run: Handler = async (context) => { + const req = context.req.raw; + const response = await group.handler( + req, + new URL(req.url), + context.env.server, + ); + return response ?? new Response(null, { + status: 404, + headers: { [FALLTHROUGH_HEADER]: "1" }, + }); + }; + app.all(group.basePath, run); + app.all(`${group.basePath}/*`, run); + } + + return async (req, url, server) => { + if (!groups.some((group) => ownsPath(group.basePath, url.pathname))) { + return null; + } + const response = await app.fetch(req, { server }); + if (response.headers.get(FALLTHROUGH_HEADER) === "1") { + return null; + } + return response; + }; +} diff --git a/Overlord-Server/src/server/request-validation.ts b/Overlord-Server/src/server/request-validation.ts new file mode 100644 index 0000000..175fbf5 --- /dev/null +++ b/Overlord-Server/src/server/request-validation.ts @@ -0,0 +1,42 @@ +import type { BaseIssue, BaseSchema, InferOutput } from "valibot"; +import { safeParse } from "valibot"; + +type ValidationIssue = { + path?: string; + message: string; +}; + +function formatIssue(issue: BaseIssue): ValidationIssue { + const path = issue.path + ?.map((item) => String(item.key)) + .filter(Boolean) + .join("."); + return path ? { path, message: issue.message } : { message: issue.message }; +} + +export async function parseJsonBody< + const TSchema extends BaseSchema>, +>(req: Request, schema: TSchema): Promise> { + let input: unknown; + try { + input = await req.json(); + } catch { + throw Response.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + const result = safeParse(schema, input); + if (!result.success) { + throw Response.json( + { + error: "Invalid request body", + issues: result.issues.map(formatIssue), + }, + { status: 400 }, + ); + } + + return result.output; +} diff --git a/Overlord-Server/src/server/routes/permission-groups-routes.test.ts b/Overlord-Server/src/server/routes/permission-groups-routes.test.ts index 3efe339..f1e3ad7 100644 --- a/Overlord-Server/src/server/routes/permission-groups-routes.test.ts +++ b/Overlord-Server/src/server/routes/permission-groups-routes.test.ts @@ -9,6 +9,7 @@ import { setUserGroups, } from "../../users"; import { createHttpFetchHandler } from "../http-dispatch"; +import { createHonoRouteHandler } from "../hono-router"; import { handlePermissionGroupsRoutes } from "./permission-groups-routes"; import { handleUsersRoutes } from "./users-routes"; @@ -55,7 +56,10 @@ function callThroughServerRoutes(method: string, path: string, token: string | n metrics: { withHttpMetrics: (fn) => fn() }, CORS_HEADERS: {}, routes: [ - (req, routeUrl, server) => handleUsersRoutes(req, routeUrl, server as typeof mockServer), + createHonoRouteHandler([{ + basePath: "/api/users", + handler: (req, routeUrl, server) => handleUsersRoutes(req, routeUrl, server as typeof mockServer), + }]), (req, routeUrl, server) => handlePermissionGroupsRoutes(req, routeUrl, server as typeof mockServer), ], }); diff --git a/Overlord-Server/src/server/routes/user-request-schemas.ts b/Overlord-Server/src/server/routes/user-request-schemas.ts new file mode 100644 index 0000000..e78a250 --- /dev/null +++ b/Overlord-Server/src/server/routes/user-request-schemas.ts @@ -0,0 +1,59 @@ +import { + array, + boolean, + minLength, + object, + optional, + partialCheck, + picklist, + pipe, + record, + string, +} from "valibot"; + +const nonEmptyString = pipe(string(), minLength(1, "Must not be empty")); + +export const createUserBodySchema = object({ + username: nonEmptyString, + password: nonEmptyString, + role: picklist(["admin", "operator", "viewer"], "Invalid role"), + mustChangePassword: optional(boolean()), +}); + +export const updatePasswordBodySchema = pipe( + object({ + password: optional(nonEmptyString), + newPassword: optional(nonEmptyString), + currentPassword: optional(string()), + }), + partialCheck( + [["password"], ["newPassword"]], + (input) => Boolean(input.password || input.newPassword), + "Password required", + ), +); + +export const updateRoleBodySchema = object({ + role: picklist(["admin", "operator", "viewer"], "Invalid role"), +}); + +export const updateClientAccessBodySchema = object({ + scope: picklist(["all", "allowlist", "denylist", "none"], "Invalid client access scope"), +}); + +export const clientAccessRuleBodySchema = object({ + clientId: nonEmptyString, + access: picklist(["allow", "deny"], "Invalid client access rule"), +}); + +export const updateCanBuildBodySchema = object({ canBuild: boolean() }); +export const updateCanUploadFilesBodySchema = object({ canUploadFiles: boolean() }); + +export const updateFeaturePermissionsBodySchema = object({ + permissions: record(string(), boolean()), +}); + +export const updatePluginAccessBodySchema = object({ + scope: picklist(["all", "allowlist", "none"], "Invalid plugin access scope"), + pluginIds: optional(array(nonEmptyString)), +}); diff --git a/Overlord-Server/src/server/routes/users-routes.must-change-password.test.ts b/Overlord-Server/src/server/routes/users-routes.must-change-password.test.ts index 1cacbed..92cc0ae 100644 --- a/Overlord-Server/src/server/routes/users-routes.must-change-password.test.ts +++ b/Overlord-Server/src/server/routes/users-routes.must-change-password.test.ts @@ -30,6 +30,48 @@ afterEach(() => { }); describe("admin-created users must change password", () => { + test("POST /api/users rejects malformed JSON as a client error", async () => { + const admin = await createAuthedAdmin(); + const url = new URL("https://localhost/api/users"); + const res = await handleUsersRoutes( + new Request(url, { + method: "POST", + headers: { + Authorization: `Bearer ${admin.token}`, + "Content-Type": "application/json", + }, + body: "{not-json", + }), + url, + mockServer, + ); + + expect(res?.status).toBe(400); + expect(await res?.json()).toEqual({ error: "Invalid JSON body" }); + }); + + test("POST /api/users reports schema validation issues", async () => { + const admin = await createAuthedAdmin(); + const url = new URL("https://localhost/api/users"); + const res = await handleUsersRoutes( + new Request(url, { + method: "POST", + headers: { + Authorization: `Bearer ${admin.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ username: "new-user", password: PASSWORD, role: "owner" }), + }), + url, + mockServer, + ); + + expect(res?.status).toBe(400); + const body = await res?.json(); + expect(body.error).toBe("Invalid request body"); + expect(body.issues).toContainEqual({ path: "role", message: "Invalid role" }); + }); + test("POST /api/users marks new users for password change", async () => { const admin = await createAuthedAdmin(); const username = `mcp_user_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`; diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts index 9665230..a3b2c0f 100644 --- a/Overlord-Server/src/server/routes/users-routes.ts +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -35,6 +35,18 @@ import { updateUserRole, } from "../../users"; import { makeAuthCookie } from "./auth-cookie"; +import { parseJsonBody } from "../request-validation"; +import { + clientAccessRuleBodySchema, + createUserBodySchema, + updateCanBuildBodySchema, + updateCanUploadFilesBodySchema, + updateClientAccessBodySchema, + updateFeaturePermissionsBodySchema, + updatePasswordBodySchema, + updatePluginAccessBodySchema, + updateRoleBodySchema, +} from "./user-request-schemas"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -83,17 +95,9 @@ export async function handleUsersRoutes( if (req.method === "POST" && url.pathname === "/api/users") { const authedUser = requirePermission(user, "users:manage"); - const body = await req.json(); + const body = await parseJsonBody(req, createUserBodySchema); const { username, password, role, mustChangePassword } = body; - if (!username || !password || !role) { - return Response.json({ error: "Missing required fields" }, { status: 400 }); - } - - if (!["admin", "operator", "viewer"].includes(role)) { - return Response.json({ error: "Invalid role" }, { status: 400 }); - } - const result = await createUser( username, password, @@ -123,7 +127,7 @@ export async function handleUsersRoutes( return Response.json({ error: "Not authenticated" }, { status: 401 }); } const userId = parseInt(url.pathname.split("/")[3]); - const body = await req.json(); + const body = await parseJsonBody(req, updatePasswordBodySchema); const { password, newPassword, currentPassword } = body; const canChange = user.userId === userId || user.role === "admin"; @@ -209,13 +213,9 @@ export async function handleUsersRoutes( if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/role$/)) { const authedUser = requirePermission(user, "users:manage"); const userId = parseInt(url.pathname.split("/")[3]); - const body = await req.json(); + const body = await parseJsonBody(req, updateRoleBodySchema); const { role } = body; - if (!role || !["admin", "operator", "viewer"].includes(role)) { - return Response.json({ error: "Invalid role" }, { status: 400 }); - } - if (userId === authedUser.userId) { return Response.json({ error: "Cannot change your own role" }, { status: 400 }); } @@ -286,8 +286,8 @@ export async function handleUsersRoutes( return Response.json({ error: "User not found" }, { status: 404 }); } - const body = await req.json(); - const scope = body?.scope as ClientAccessScope; + const body = await parseJsonBody(req, updateClientAccessBodySchema); + const scope = body.scope as ClientAccessScope; const result = setUserClientAccessScope(userId, scope); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); @@ -314,9 +314,9 @@ export async function handleUsersRoutes( return Response.json({ error: "User not found" }, { status: 404 }); } - const body = await req.json(); - const clientId = String(body?.clientId || "").trim(); - const access = body?.access as ClientAccessRuleKind; + const body = await parseJsonBody(req, clientAccessRuleBodySchema); + const clientId = body.clientId.trim(); + const access = body.access as ClientAccessRuleKind; const result = setUserClientAccessRule(userId, clientId, access); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); @@ -411,8 +411,8 @@ export async function handleUsersRoutes( return Response.json({ error: "User not found" }, { status: 404 }); } - const body = await req.json(); - const canBuild = !!body?.canBuild; + const body = await parseJsonBody(req, updateCanBuildBodySchema); + const canBuild = body.canBuild; const previous = Boolean(targetUser.can_build); const result = setUserCanBuild(userId, canBuild); if (!result.success) { @@ -445,8 +445,8 @@ export async function handleUsersRoutes( return Response.json({ error: "User not found" }, { status: 404 }); } - const body = await req.json(); - const canUploadFiles = !!body?.canUploadFiles; + const body = await parseJsonBody(req, updateCanUploadFilesBodySchema); + const canUploadFiles = body.canUploadFiles; const previous = Boolean(targetUser.can_upload_files); const result = setUserCanUploadFiles(userId, canUploadFiles); if (!result.success) { @@ -491,11 +491,8 @@ export async function handleUsersRoutes( return Response.json({ error: "User not found" }, { status: 404 }); } - const body = await req.json(); - const permissions = body?.permissions; - if (!permissions || typeof permissions !== "object") { - return Response.json({ error: "Invalid permissions object" }, { status: 400 }); - } + const body = await parseJsonBody(req, updateFeaturePermissionsBodySchema); + const permissions = body.permissions; const validated: Partial> = {}; for (const [key, value] of Object.entries(permissions)) { @@ -603,9 +600,9 @@ export async function handleUsersRoutes( return Response.json({ error: "User not found" }, { status: 404 }); } - const body = await req.json(); - const scope = body?.scope as PluginAccessScope; - const pluginIds = Array.isArray(body?.pluginIds) ? body.pluginIds.filter((id: any) => typeof id === "string" && id.trim()) : undefined; + const body = await parseJsonBody(req, updatePluginAccessBodySchema); + const scope = body.scope as PluginAccessScope; + const pluginIds = body.pluginIds?.map((id) => id.trim()); const scopeResult = setUserPluginAccessScope(userId, scope); if (!scopeResult.success) { diff --git a/Overlord-Server/src/web/README.md b/Overlord-Server/src/web/README.md new file mode 100644 index 0000000..c6afa81 --- /dev/null +++ b/Overlord-Server/src/web/README.md @@ -0,0 +1,21 @@ +# Browser TypeScript + +Browser source lives here and is bundled into `public/assets/generated/` by +`bun run build:web`. Generated assets are ignored by Git and must not be edited +directly. + +The development supervisor runs the browser build once and watches this folder. +Production and E2E preparation also run the build before serving assets. + +Migrate existing `public/assets/*.js` modules incrementally: + +1. Move one leaf/shared module into this directory and add it as a browser + entrypoint in `package.json`. +2. Keep its public export names stable and update consumers to import from + `/assets/generated/` (or the equivalent relative path). +3. Add runtime schemas for data crossing HTTP, WebSocket, storage, or plugin + boundaries; TypeScript types alone do not validate external data. +4. Run `bun run typecheck`, `bun run build:web`, and the affected browser tests. + +Do not duplicate or replace the generated wire-protocol types here. Wire changes +must continue through `protocol/wire-contract.json` and its generator. diff --git a/Overlord-Server/public/assets/shared-ui-settings.js b/Overlord-Server/src/web/shared-ui-settings.ts similarity index 55% rename from Overlord-Server/public/assets/shared-ui-settings.js rename to Overlord-Server/src/web/shared-ui-settings.ts index a98e312..8ace801 100644 --- a/Overlord-Server/public/assets/shared-ui-settings.js +++ b/Overlord-Server/src/web/shared-ui-settings.ts @@ -1,30 +1,44 @@ -export async function loadSharedUiSettings(scope) { +import { object, record, safeParse, string, unknown } from "valibot"; + +export type SharedUiSettings = Record; + +const sharedUiSettingsResponseSchema = object({ + settings: record(string(), unknown()), +}); + +export async function loadSharedUiSettings(scope: string): Promise { try { const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, { credentials: "include", }); if (!res.ok) return {}; - const data = await res.json().catch(() => ({})); - return data && typeof data.settings === "object" && data.settings ? data.settings : {}; + + const input: unknown = await res.json().catch(() => null); + const result = safeParse(sharedUiSettingsResponseSchema, input); + return result.success ? result.output.settings : {}; } catch (err) { console.warn(`ui-settings: failed to load ${scope}`, err); return {}; } } -export function createSharedUiSettingsSaver(scope, readSettings, delayMs = 350) { - let timer = null; +export function createSharedUiSettingsSaver( + scope: string, + readSettings: () => SharedUiSettings | null | undefined, + delayMs = 350, +) { + let timer: ReturnType | null = null; let lastPayload = ""; - async function saveNow() { + async function saveNow(): Promise { if (timer) { clearTimeout(timer); timer = null; } - let settings = {}; + let settings: SharedUiSettings; try { - settings = readSettings() || {}; + settings = readSettings() ?? {}; } catch (err) { console.warn(`ui-settings: failed to read ${scope}`, err); return; @@ -42,16 +56,14 @@ export function createSharedUiSettingsSaver(scope, readSettings, delayMs = 350) headers: { "Content-Type": "application/json" }, body: JSON.stringify({ settings }), }); - if (!res.ok) { - lastPayload = ""; - } + if (!res.ok) lastPayload = ""; } catch (err) { lastPayload = ""; console.warn(`ui-settings: failed to save ${scope}`, err); } } - function scheduleSave() { + function scheduleSave(): void { if (timer) clearTimeout(timer); timer = setTimeout(saveNow, delayMs); } diff --git a/Overlord-Server/test/e2e/easter-egg.e2e.ts b/Overlord-Server/test/e2e/easter-egg.e2e.ts new file mode 100644 index 0000000..2432176 --- /dev/null +++ b/Overlord-Server/test/e2e/easter-egg.e2e.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; +import { login } from "./helpers"; + +const TRANSPARENT_GIF = Buffer.from( + "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", + "base64", +); + +test("easter egg image is requested only after browser-local opt-in", async ({ page }) => { + let imageRequests = 0; + await page.route("**/assets/console.gif", async (route) => { + imageRequests += 1; + await route.fulfill({ + status: 200, + contentType: "image/gif", + body: TRANSPARENT_GIF, + }); + }); + + await login(page); + expect(imageRequests).toBe(0); + + await page.goto("/settings"); + const toggle = page.locator("#easter-egg-enabled"); + const status = page.locator("#easter-egg-status"); + await expect(toggle).not.toBeChecked(); + + await toggle.check(); + await expect(status).toContainText("Enabled and shown"); + expect(imageRequests).toBe(1); + + await page.reload(); + await expect(toggle).toBeChecked(); + expect(imageRequests).toBe(1); + + await toggle.uncheck(); + await expect(status).toContainText("will not be requested"); + await page.reload(); + await expect(toggle).not.toBeChecked(); + expect(imageRequests).toBe(1); +});