From a64363258dd21de3970dcf11a3923d8dd8fb52e6 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sun, 10 May 2026 02:50:16 +0200 Subject: [PATCH] fix: use Response wrapper instead of h3 tagged template html() h3 v2's html() is a tagged template literal, not a function that accepts a string. JSX-rendered markup passed directly causes "first.reduce is not a function". Created htmlPage() helper that wraps markup in a proper Response with text/html content type. --- .../service-admin-http/html-response.ts | 12 +++++++++ .../service-admin-http/routes-account.ts | 23 ++++++++-------- .../service-admin-http/routes-admin.ts | 21 ++++++++------- .../plugins/service-admin-http/routes-auth.ts | 27 ++++++++++--------- .../service-admin-http/routes-setup.ts | 7 ++--- 5 files changed, 53 insertions(+), 37 deletions(-) create mode 100644 server/src/plugins/service-admin-http/html-response.ts diff --git a/server/src/plugins/service-admin-http/html-response.ts b/server/src/plugins/service-admin-http/html-response.ts new file mode 100644 index 0000000..8c69276 --- /dev/null +++ b/server/src/plugins/service-admin-http/html-response.ts @@ -0,0 +1,12 @@ +/** + * Return an HTML response from JSX-rendered markup. + * + * h3 v2's html() is a tagged template literal only — can't pass + * a string/object directly. This helper wraps JSX output in a + * proper Response with text/html content type. + */ +export function htmlPage(markup: unknown): Response { + return new Response(String(markup), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} diff --git a/server/src/plugins/service-admin-http/routes-account.ts b/server/src/plugins/service-admin-http/routes-account.ts index 9eb9cb7..bbd6af4 100644 --- a/server/src/plugins/service-admin-http/routes-account.ts +++ b/server/src/plugins/service-admin-http/routes-account.ts @@ -1,7 +1,8 @@ /** * Account management routes — password change, TOTP enrollment. */ -import { type H3, html, readBody } from "h3"; +import { type H3, readBody } from "h3"; +import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { AccountPage, TotpEnrollPage } from "../../web-templates/admin-pages.js"; @@ -10,7 +11,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/account", (event) => { const user = event.context.user!; - return html(AccountPage({ user: user.username, totpEnabled: user.totp_enabled })); + return htmlPage(AccountPage({ user: user.username, totpEnabled: user.totp_enabled })); }); // ---- Change password ------------------------------------------------------ @@ -22,7 +23,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { const newPw = body?.new_password ?? ""; if (!current || !newPw) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: user.totp_enabled, error: "Both current and new password required.", @@ -30,7 +31,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { } if (newPw.length < 12) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: user.totp_enabled, error: "New password must be at least 12 characters.", @@ -39,7 +40,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { const valid = await deps.auth.verifyPassword(current, user.password_hash); if (!valid) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: user.totp_enabled, error: "Current password incorrect.", @@ -64,7 +65,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { const user = event.context.user!; if (user.totp_enabled) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: true, error: "TOTP already enabled.", @@ -81,7 +82,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { totp_secret_encrypted: encrypted, }); - return html(TotpEnrollPage({ + return htmlPage(TotpEnrollPage({ user: user.username, secret, provisioningUri: uri, @@ -97,7 +98,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { const code = (body?.code ?? "").trim().replace(/\s/g, ""); if (!code || code.length !== 6) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: false, error: "Enter a valid 6-digit code.", @@ -105,7 +106,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { } if (!user.totp_secret_encrypted) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: false, error: "No TOTP enrollment in progress. Start again.", @@ -115,7 +116,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted); const valid = deps.auth.verifyTotpCode(secret, code); if (!valid) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: false, error: "Invalid code. Scan the QR code again and enter the current code.", @@ -147,7 +148,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { const valid = await deps.auth.verifyPassword(password, user.password_hash); if (!valid) { - return html(AccountPage({ + return htmlPage(AccountPage({ user: user.username, totpEnabled: true, error: "Password incorrect.", diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 7fbd07d..195318a 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1,7 +1,8 @@ /** * Admin page routes — overview, cameras, kiosks, labels, etc. */ -import { type H3, html, readBody } from "h3"; +import { type H3, readBody } from "h3"; +import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { OverviewPage, @@ -25,7 +26,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000; }); - return html(OverviewPage({ + return htmlPage(OverviewPage({ user: user.username, cameraCount: cameras.length, kioskCount: kiosks.length, @@ -49,12 +50,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { for (const cam of cameras) { streamCounts.set(cam.id, deps.repo.listCameraStreams(cam.id).length); } - return html(CamerasPage({ user: user.username, cameras, streamCounts })); + return htmlPage(CamerasPage({ user: user.username, cameras, streamCounts })); }); app.get("/admin/cameras/new", (event) => { const user = event.context.user!; - return html(CameraNewPage({ user: user.username })); + return htmlPage(CameraNewPage({ user: user.username })); }); app.post("/admin/cameras/new", async (event) => { @@ -92,7 +93,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } if (errors.length > 0) { - return html(CameraNewPage({ + return htmlPage(CameraNewPage({ user: user.username, error: errors.join(" "), values: body, @@ -131,14 +132,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const user = event.context.user!; const kiosks = deps.repo.listKiosks(); const pending = deps.repo.listPendingPairingCodes(); - return html(KiosksPage({ user: user.username, kiosks, pendingCodes: pending })); + return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending })); }); // ---- Simple list pages (templates, layouts, displays, labels) ------------- app.get("/admin/templates", (event) => { const user = event.context.user!; - return html(SimpleListPage({ + return htmlPage(SimpleListPage({ user: user.username, pageTitle: "Layout Templates", description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.", @@ -149,7 +150,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/layouts", (event) => { const user = event.context.user!; - return html(SimpleListPage({ + return htmlPage(SimpleListPage({ user: user.username, pageTitle: "Layouts", description: "A layout binds cameras and other content into a template's regions for one display.", @@ -161,7 +162,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/displays", (event) => { const user = event.context.user!; const displays = deps.repo.listDisplays(); - return html(SimpleListPage({ + return htmlPage(SimpleListPage({ user: user.username, pageTitle: "Displays", description: "Physical HDMI displays. Primary display created during setup.", @@ -176,7 +177,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/labels", (event) => { const user = event.context.user!; const labels = deps.repo.listLabels(); - return html(SimpleListPage({ + return htmlPage(SimpleListPage({ user: user.username, pageTitle: "Labels", description: "Labels route cameras, layouts, and kiosks to each other across sites.", diff --git a/server/src/plugins/service-admin-http/routes-auth.ts b/server/src/plugins/service-admin-http/routes-auth.ts index 0851f73..7e25644 100644 --- a/server/src/plugins/service-admin-http/routes-auth.ts +++ b/server/src/plugins/service-admin-http/routes-auth.ts @@ -1,7 +1,8 @@ /** * Authentication routes: login, TOTP, recovery, logout. */ -import { type H3, readBody, html, getCookie, setCookie, deleteCookie, getQuery, getRequestHeader } from "h3"; +import { type H3, readBody, getCookie, setCookie, deleteCookie, getQuery, getRequestHeader } from "h3"; +import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js"; @@ -18,7 +19,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { app.get("/auth/login", (event) => { const q = getQuery(event) as Record; const welcome = q["welcome"] === "1"; - return html(LoginPage({ welcome })); + return htmlPage(LoginPage({ welcome })); }); app.post("/auth/login", async (event) => { @@ -27,18 +28,18 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { const password = body?.password ?? ""; if (!username || !password) { - return html(LoginPage({ error: "Username and password required.", username })); + return htmlPage(LoginPage({ error: "Username and password required.", username })); } const user = deps.repo.getUserByUsername(username); if (!user || !user.is_active) { - return html(LoginPage({ error: "Invalid credentials.", username })); + return htmlPage(LoginPage({ error: "Invalid credentials.", username })); } if (user.locked_until) { const lockEnd = new Date(user.locked_until); if (lockEnd > new Date()) { - return html(LoginPage({ error: "Account locked. Try again later.", username })); + return htmlPage(LoginPage({ error: "Account locked. Try again later.", username })); } } @@ -50,7 +51,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString(); } deps.repo.updateUser(user.id, patch); - return html(LoginPage({ error: "Invalid credentials.", username })); + return htmlPage(LoginPage({ error: "Invalid credentials.", username })); } deps.repo.updateUser(user.id, { @@ -91,7 +92,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { if (!resolved || !resolved.session.totp_pending) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } - return html(TotpPage({})); + return htmlPage(TotpPage({})); }); app.post("/auth/totp", async (event) => { @@ -108,18 +109,18 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { const code = (body?.code ?? "").trim().replace(/\s/g, ""); if (!code || code.length !== 6) { - return html(TotpPage({ error: "Enter a 6-digit code." })); + return htmlPage(TotpPage({ error: "Enter a 6-digit code." })); } const { user, session } = resolved; if (!user.totp_secret_encrypted) { - return html(TotpPage({ error: "TOTP not configured for this account." })); + return htmlPage(TotpPage({ error: "TOTP not configured for this account." })); } const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted); const valid = deps.auth.verifyTotpCode(secret, code); if (!valid) { - return html(TotpPage({ error: "Invalid code. Try again." })); + return htmlPage(TotpPage({ error: "Invalid code. Try again." })); } deps.repo.setSessionTotpPending(session.id, false); @@ -137,7 +138,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { if (!resolved || !resolved.session.totp_pending) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } - return html(RecoveryPage({})); + return htmlPage(RecoveryPage({})); }); app.post("/auth/recovery", async (event) => { @@ -154,7 +155,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { const code = (body?.code ?? "").trim().toUpperCase().replace(/\s/g, ""); if (!code) { - return html(RecoveryPage({ error: "Enter a recovery code." })); + return htmlPage(RecoveryPage({ error: "Enter a recovery code." })); } const { user, session } = resolved; @@ -162,7 +163,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { const result = await deps.auth.consumeRecoveryCode(code, hashedCodes); if (!result.ok) { - return html(RecoveryPage({ error: "Invalid recovery code." })); + return htmlPage(RecoveryPage({ error: "Invalid recovery code." })); } deps.repo.updateUser(user.id, { diff --git a/server/src/plugins/service-admin-http/routes-setup.ts b/server/src/plugins/service-admin-http/routes-setup.ts index 3a01570..c3ac4c9 100644 --- a/server/src/plugins/service-admin-http/routes-setup.ts +++ b/server/src/plugins/service-admin-http/routes-setup.ts @@ -1,7 +1,8 @@ /** * First-run setup routes. */ -import { type H3, readBody, html } from "h3"; +import { type H3, readBody } from "h3"; +import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { SetupPage } from "../../web-templates/auth-pages.js"; @@ -10,7 +11,7 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void { if (deps.repo.isSetupComplete()) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } - return html(SetupPage({})); + return htmlPage(SetupPage({})); }); app.post("/setup", async (event) => { @@ -33,7 +34,7 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void { } if (errors.length > 0) { - return html(SetupPage({ error: errors.join(" "), username })); + return htmlPage(SetupPage({ error: errors.join(" "), username })); } const hash = await deps.auth.hashPassword(password);