@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.
- package/dist/api/static/spa-mount.js +123 -0
- package/dist/auth/session-cookie.js +11 -3
- package/dist/main.js +9 -8
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
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 {
|