@camstack/server 1.2.237 → 1.2.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FORWARDED_PREFIX_HEADER = void 0;
37
+ exports.parseForwardedPrefix = parseForwardedPrefix;
38
+ exports.injectSpaMount = injectSpaMount;
39
+ exports.sendSpaShell = sendSpaShell;
40
+ /**
41
+ * spa-mount — serving the admin-UI shell under a reverse-proxy path prefix.
42
+ *
43
+ * The Home Assistant component frames the admin UI through a same-origin
44
+ * relay, `https://<ha>/api/camstack/p/<grant>/…` → `https://<hub>:4443/…`, so
45
+ * the browser never has to trust the hub's local certificate. The relay
46
+ * forwards the path WITHOUT the prefix (the hub sees `/`, `/settings`,
47
+ * `/assets/x.js`, `/trpc`) and says where it mounted us with
48
+ * `X-Forwarded-Prefix: /api/camstack/p/<grant>` on every request.
49
+ *
50
+ * The shell is the one response that has to differ: it tells the page where
51
+ * it lives, and every URL the page composes derives from that
52
+ * (`@camstack/ui-library` `lib/hub-mount.ts`). Assets are already relative
53
+ * (`vite base: './'`), so they need nothing from us.
54
+ *
55
+ * Without the header nothing changes — the shell is served exactly as before.
56
+ */
57
+ const fs = __importStar(require("node:fs/promises"));
58
+ const spa_static_1 = require("./spa-static");
59
+ /** Node lower-cases incoming header names. */
60
+ exports.FORWARDED_PREFIX_HEADER = 'x-forwarded-prefix';
61
+ /** The canonical spelling, for `Vary`. */
62
+ const FORWARDED_PREFIX_VARY = 'X-Forwarded-Prefix';
63
+ const MOUNT_META_NAME = 'camstack-mount';
64
+ /**
65
+ * A prefix is one or more clean path segments. No scheme, no `//`, no `..`,
66
+ * no query, no whitespace, no markup — it is going into an HTML attribute.
67
+ */
68
+ const PREFIX_RE = /^\/[A-Za-z0-9_\-.]+(?:\/[A-Za-z0-9_\-.]+)*$/;
69
+ const MAX_PREFIX_LENGTH = 512;
70
+ /**
71
+ * The mount prefix the relay named, without a trailing slash, or `null` when
72
+ * the header is absent, names the root, or is anything we would not put in
73
+ * an attribute. Invalid is treated as absent — a relay that gets this wrong
74
+ * gets the root shell, never a 4xx and never a partial injection.
75
+ */
76
+ function parseForwardedPrefix(value) {
77
+ if (typeof value !== 'string')
78
+ return null;
79
+ if (value.length === 0 || value.length > MAX_PREFIX_LENGTH)
80
+ return null;
81
+ const trimmed = value.endsWith('/') ? value.slice(0, -1) : value;
82
+ if (trimmed === '')
83
+ return null;
84
+ if (!PREFIX_RE.test(trimmed))
85
+ return null;
86
+ if (trimmed.split('/').some((segment) => segment === '..' || segment === '.'))
87
+ return null;
88
+ return trimmed;
89
+ }
90
+ /**
91
+ * The shell with `<base href>` and the mount meta as the FIRST children of
92
+ * `<head>` — `<base>` must precede every URL-bearing element to apply to it.
93
+ * A shell with no `<head>` is returned untouched: guessing where to put a
94
+ * `<base>` is worse than serving the root shell.
95
+ */
96
+ function injectSpaMount(html, prefix) {
97
+ const match = /<head(?:\s[^>]*)?>/i.exec(html);
98
+ if (match === null)
99
+ return html;
100
+ const mount = `${prefix}/`;
101
+ const injected = `<base href="${mount}"><meta name="${MOUNT_META_NAME}" content="${mount}">`;
102
+ const at = match.index + match[0].length;
103
+ return `${html.slice(0, at)}${injected}${html.slice(at)}`;
104
+ }
105
+ /**
106
+ * Send the SPA shell for a page navigation. Always `no-store` and always
107
+ * `Vary: X-Forwarded-Prefix` — a cache that ignores `no-store` must still
108
+ * never hand a root shell to a relayed request or vice versa.
109
+ */
110
+ async function sendSpaShell(reply, indexPath, headers) {
111
+ reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
112
+ reply.header('vary', FORWARDED_PREFIX_VARY);
113
+ let html;
114
+ try {
115
+ html = await fs.readFile(indexPath, 'utf8');
116
+ }
117
+ catch {
118
+ // A redeploy replaced the dist under us; the next request finds the new one.
119
+ return reply.status(503).send({ error: 'Admin UI shell unavailable' });
120
+ }
121
+ const prefix = parseForwardedPrefix(headers[exports.FORWARDED_PREFIX_HEADER]);
122
+ return reply.type('text/html').send(prefix === null ? html : injectSpaMount(html, prefix));
123
+ }
@@ -68,9 +68,17 @@ function readSessionCookieFromHeader(header) {
68
68
  function shouldRedirectToLogin(method, accept) {
69
69
  return method === 'GET' && typeof accept === 'string' && accept.includes('text/html');
70
70
  }
71
- /** Build the `/login?next=…` URL for an unauthenticated browser request. */
72
- function loginRedirectUrl(originalUrl) {
73
- return `/login?next=${encodeURIComponent(originalUrl)}`;
71
+ /**
72
+ * Build the `/login?next=…` URL for an unauthenticated browser request.
73
+ *
74
+ * `mountPrefix` is the path a reverse proxy mounted the admin UI under
75
+ * (`X-Forwarded-Prefix`, already validated — see `api/static/spa-mount.ts`):
76
+ * the login PAGE lives under it, while `next` stays hub-root-relative because
77
+ * the SPA re-mounts it itself (`hubPath`) — so one value serves both the
78
+ * relayed and the direct case.
79
+ */
80
+ function loginRedirectUrl(originalUrl, mountPrefix = null) {
81
+ return `${mountPrefix ?? ''}/login?next=${encodeURIComponent(originalUrl)}`;
74
82
  }
75
83
  /** Allowed redirect target for `GET /api/embed-auth`: a same-origin RELATIVE
76
84
  * path to a stream-broker embed page. Defeats open-redirects — the endpoint
package/dist/main.js CHANGED
@@ -66,6 +66,7 @@ const server_upload_1 = require("./api/server-upload");
66
66
  const sse_no_compression_js_1 = require("./api/sse-no-compression.js");
67
67
  const precompressed_asset_js_1 = require("./api/static/precompressed-asset.js");
68
68
  const spa_static_1 = require("./api/static/spa-static");
69
+ const spa_mount_1 = require("./api/static/spa-mount");
69
70
  const cap_providers_1 = require("./api/core/cap-providers");
70
71
  const logging_settings_1 = require("./api/core/logging-settings");
71
72
  const request_census_settings_1 = require("./api/core/request-census-settings");
@@ -1053,10 +1054,7 @@ async function bootstrap() {
1053
1054
  // send used to carry no cache header at all, so browsers kept the
1054
1055
  // index.html of a previous build (and its dead hashed asset names)
1055
1056
  // across deploys until a force refresh.
1056
- return reply
1057
- .header('cache-control', (0, spa_static_1.spaShellCacheControl)())
1058
- .type('text/html')
1059
- .send(fs.createReadStream(spaIndexHtml));
1057
+ return (0, spa_mount_1.sendSpaShell)(reply, spaIndexHtml, request.headers);
1060
1058
  }
1061
1059
  return reply.status(404).send({ error: 'Not found' });
1062
1060
  }
@@ -1069,7 +1067,7 @@ async function bootstrap() {
1069
1067
  if (!authHeader && !cookieToken) {
1070
1068
  if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
1071
1069
  const qs = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '';
1072
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(fullPath + qs));
1070
+ return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(fullPath + qs, (0, spa_mount_1.parseForwardedPrefix)(request.headers['x-forwarded-prefix'])));
1073
1071
  }
1074
1072
  return reply.status(401).send({ error: 'Unauthorized' });
1075
1073
  }
@@ -1336,7 +1334,9 @@ async function bootstrap() {
1336
1334
  return reply.status(503).send({ error: 'Admin UI is starting' });
1337
1335
  }
1338
1336
  const pathOnly = url.split('?')[0] ?? url;
1339
- if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
1337
+ // The shell asked for BY NAME is still the shell: it gets the mount
1338
+ // injection and the no-store policy, never the asset branch.
1339
+ if (pathOnly !== '/index.html' && /\.[a-zA-Z0-9]+$/.test(pathOnly)) {
1340
1340
  const rel = pathOnly.replace(/^\/+/, '');
1341
1341
  const abs = path.join(staticDir, rel);
1342
1342
  if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
@@ -1345,8 +1345,9 @@ async function bootstrap() {
1345
1345
  }
1346
1346
  return reply.callNotFound();
1347
1347
  }
1348
- reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
1349
- return reply.type('text/html').send(fs.createReadStream(indexPath));
1348
+ // A relayed navigation (`X-Forwarded-Prefix`) gets a shell that says where
1349
+ // it is mounted; without the header the shell is served as before.
1350
+ return (0, spa_mount_1.sendSpaShell)(reply, indexPath, request.headers);
1350
1351
  });
1351
1352
  const resolveAdminUi = async () => {
1352
1353
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.237",
3
+ "version": "1.2.238",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",