diff --git a/.env.example b/.env.example index d9df30a..f5d6216 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,16 @@ NUXT_OG_IMAGE_SECRET= # Get it from: GA4 property → Admin → Data Streams → Web stream → Measurement ID. # Leave empty to disable analytics entirely (no script loaded, no consent banner). NUXT_PUBLIC_GA_MEASUREMENT_ID= + +# Backend URL for the /dash-api proxy (server-side only, never sent to browser). +# In production, point this to the internal API address (e.g. http://schedule-api:8080). +# Default: http://localhost:8080 +NUXT_DASH_API_BASE_URL= + +# Shared secret sent as x-dash-key to the backend. Must match DASH_API_KEY in schedule-api. +# Generate with: openssl rand -hex 32 +NUXT_DASH_API_KEY=change_me_to_a_strong_random_secret + +# HTTP Basic Auth credentials for the /dash page (browser prompt). +NUXT_DASH_USER=admin +NUXT_DASH_PASSWORD=change_me diff --git a/app/layouts/dash.vue b/app/layouts/dash.vue new file mode 100644 index 0000000..312873a --- /dev/null +++ b/app/layouts/dash.vue @@ -0,0 +1,9 @@ + diff --git a/app/pages/dash.vue b/app/pages/dash.vue new file mode 100644 index 0000000..ab5e326 --- /dev/null +++ b/app/pages/dash.vue @@ -0,0 +1,501 @@ + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 8e09318..637f663 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -35,6 +35,16 @@ export default defineNuxtConfig({ ], }, runtimeConfig: { + // Backend URL for the dashboard proxy — server-side only, never exposed to client. + // Override via NUXT_DASH_API_BASE_URL env var. + dashApiBaseUrl: "http://localhost:8080", + // Shared secret forwarded to the backend as x-dash-key. + // Override via NUXT_DASH_API_KEY env var. + dashApiKey: "change_me_to_a_strong_random_secret", + // HTTP Basic Auth for the /dash page — browser prompt. + // Override via NUXT_DASH_USER / NUXT_DASH_PASSWORD env vars. + dashUser: "admin", + dashPassword: "change_me", public: { maintenance: false, // Bumped on every build — used to bust the IndexedDB query cache on deploy @@ -131,6 +141,10 @@ export default defineNuxtConfig({ // check the SW byte-for-byte on every page load, but a stale cached response // would prevent the browser from seeing an updated worker. "/sw.js": { headers: { "cache-control": "no-cache, no-store, must-revalidate" } }, + // Dashboard — internal ops page, no robots, no SSR, no caching. + "/dash": { robots: false, sitemap: false, ssr: false }, + // Proxy route for dashboard API — same auth boundary as /dash. + "/dash-api/**": { robots: false }, }, robots: { sitemap: "/sitemap.xml", diff --git a/server/middleware/dash-auth.ts b/server/middleware/dash-auth.ts new file mode 100644 index 0000000..0f0c16c --- /dev/null +++ b/server/middleware/dash-auth.ts @@ -0,0 +1,54 @@ +import { createHash, timingSafeEqual } from "node:crypto" + +const hash = (s: string) => createHash("sha256").update(s).digest() + +/** + * HTTP Basic Auth for /dash and /dash-api/* routes. + * + * Browser sends credentials as "Basic base64(user:pass)". + * We hash both sides before comparing so the check is timing-safe. + * + * Fails closed: if NUXT_DASH_USER or NUXT_DASH_PASSWORD is not set, + * returns 503 on /dash* instead of serving an unprotected dashboard. + */ +export default defineEventHandler((event) => { + if (!event.path.startsWith("/dash")) return + + const config = useRuntimeConfig() + const expectedUser = config.dashUser as string | undefined + const expectedPass = config.dashPassword as string | undefined + + if (!expectedUser || !expectedPass) { + setResponseStatus(event, 503) + return "Dashboard auth not configured" + } + + const authHeader = getRequestHeader(event, "authorization") ?? "" + const [scheme, encoded] = authHeader.split(" ") + + if (scheme?.toLowerCase() !== "basic" || !encoded) { + setResponseStatus(event, 401) + setResponseHeader(event, "WWW-Authenticate", 'Basic realm="Schedule Dashboard"') + return "Unauthorized" + } + + const decoded = Buffer.from(encoded, "base64").toString("utf8") + const colonIdx = decoded.indexOf(":") + if (colonIdx === -1) { + setResponseStatus(event, 401) + setResponseHeader(event, "WWW-Authenticate", 'Basic realm="Schedule Dashboard"') + return "Unauthorized" + } + + const providedUser = decoded.slice(0, colonIdx) + const providedPass = decoded.slice(colonIdx + 1) + + const userOk = timingSafeEqual(hash(providedUser), hash(expectedUser)) + const passOk = timingSafeEqual(hash(providedPass), hash(expectedPass)) + + if (!userOk || !passOk) { + setResponseStatus(event, 401) + setResponseHeader(event, "WWW-Authenticate", 'Basic realm="Schedule Dashboard"') + return "Unauthorized" + } +}) diff --git a/server/routes/dash-api/[...].ts b/server/routes/dash-api/[...].ts new file mode 100644 index 0000000..40f0a7f --- /dev/null +++ b/server/routes/dash-api/[...].ts @@ -0,0 +1,24 @@ +/** + * Server-side proxy for the internal dashboard API. + * + * Requests to /dash-api/* are forwarded to the backend API server at + * NUXT_DASH_API_BASE_URL/api/dash/*. The base URL stays server-side only — + * the browser never sees it, which is what lets Caddy guard /dash-api/* with + * basic_auth at the edge without exposing the backend origin. + * + * Default base URL points to the same host the schedule backend runs on in + * local dev (http://localhost:8080). Override via NUXT_DASH_API_BASE_URL env. + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const base = (config.dashApiBaseUrl as string | undefined) || "http://localhost:8080" + const apiKey = (config.dashApiKey as string | undefined) || "" + + // Strip /dash-api prefix, keep the rest (e.g. /summary, /runs, /runs/123/groups) + const path = event.path.replace(/^\/dash-api/, "") || "/" + const target = `${base.replace(/\/$/, "")}/api/dash${path}` + + return proxyRequest(event, target, { + headers: { "x-dash-key": apiKey }, + }) +})