homebridge-smartsystem 7.1.21 → 7.1.22

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/server/webapp.js CHANGED
@@ -69,6 +69,18 @@ class Context {
69
69
  this.parseCookies();
70
70
  // can be used for sending different answers
71
71
  this.jsonrequest = !!(((_a = req.headers['content-type']) === null || _a === void 0 ? void 0 : _a.toLowerCase().indexOf('application/json')) > -1);
72
+ // set by the cloud proxy (server/proxy.ts + masters.duotecno.eu) when this request came in
73
+ // through /connect/<uniqueId>/... -- every generated link/redirect must be prefixed with it
74
+ // so it keeps pointing back through the tunnel instead of at the cloud server's own root.
75
+ // The cloud server sends this header twice (once as "X-Forwarded-Prefix", once lowercase),
76
+ // which Node joins into a single comma-separated value -- take just the first occurrence.
77
+ let forwardedPrefix = req.headers['x-forwarded-prefix'];
78
+ if (typeof forwardedPrefix === "string" && forwardedPrefix.includes(",")) {
79
+ forwardedPrefix = forwardedPrefix.split(",")[0].trim();
80
+ }
81
+ this.basePath = (typeof forwardedPrefix === "string" && forwardedPrefix.startsWith("/connect/"))
82
+ ? forwardedPrefix.replace(/\/+$/, "")
83
+ : "";
72
84
  // helpers for ejs
73
85
  this.today = new Date();
74
86
  this.year = this.today.getFullYear();
@@ -232,6 +244,18 @@ class WebApp extends base_1.Base {
232
244
  this.user = "";
233
245
  this.password = "";
234
246
  this.mdnsService = null; // mDNS service for hostname and service discovery
247
+ // Cache of validated "Authorization: Basic ..." header values -> expiry timestamp (ms).
248
+ // Used when a request comes in through the cloud proxy, where cookies don't survive the
249
+ // tunnel (see Context.basePath): avoids re-checking with pwOK() (which calls out to
250
+ // Homebridge UI) on every single request as long as the browser keeps resending the same
251
+ // Basic-Auth credentials. Entries expire after kBasicAuthCacheMs and are re-validated then.
252
+ this.authorizedBasicAuth = new Map();
253
+ // In-flight validations, keyed the same way, so that a burst of concurrent requests using
254
+ // the same not-yet-cached credentials (e.g. the handful of asset requests a single page
255
+ // load fires) share one pwOK()/Homebridge-login call instead of each firing their own --
256
+ // pwOK() also overwrites the shared this.token, so piling up parallel calls to it is not
257
+ // just wasteful but can stomp on other, unrelated sessions using that same token.
258
+ this.pendingBasicAuth = new Map();
235
259
  this.port = 80;
236
260
  (0, logger_1.log)("server", "Creating http server");
237
261
  this.files = {};
@@ -440,6 +464,19 @@ class WebApp extends base_1.Base {
440
464
  }
441
465
  }
442
466
  if (file) {
467
+ // layout assets (bundled CSS/JS, inline logo, favicon data URI) -- registered once at
468
+ // startup via addContent() under these fixed names, so any subclass that has them
469
+ // available gets them injected into every template automatically. Merged before the
470
+ // per-call objects so a call site can still override them explicitly if it ever needs to.
471
+ const bundleFile = this.getFile("bundle");
472
+ if (bundleFile)
473
+ context["bundleUrl"] = `${context.basePath}/files/bundle.${exports.kVersion}.js`;
474
+ const logoFile = this.getFile("logoWhiteInline");
475
+ if (logoFile)
476
+ context["logoWhite"] = logoFile.content;
477
+ const faviconFile = this.getFile("faviconDataUri");
478
+ if (faviconFile)
479
+ context["faviconDataUri"] = faviconFile.content;
443
480
  // copy objects into context
444
481
  for (const key in objects)
445
482
  context[key] = objects[key];
@@ -468,8 +505,22 @@ class WebApp extends base_1.Base {
468
505
  try {
469
506
  if (this.needsLogin(context)) {
470
507
  logger.debug("webapp", "checking ticket: " + context.cookies["DTicket"] + " = " + this.token + " for " + context.request + "/" + context.action + " => " + (context.cookies["DTicket"] == this.token));
471
- // if a password is set, check if the tickets is valid, if not: redirect to login page
472
- if (!this.token || (context.cookies["DTicket"] != this.token)) {
508
+ // if a password is set, check if the tickets is valid...
509
+ let authorized = !!this.token && (context.cookies["DTicket"] === this.token);
510
+ // the DTicket cookie never survives the cloud proxy (masters.duotecno.eu strips
511
+ // cookies on every forwarded request), so fall back to HTTP Basic-Auth there instead.
512
+ if (!authorized)
513
+ authorized = yield this.checkBasicAuth(context);
514
+ if (!authorized) {
515
+ if (context.basePath) {
516
+ // ask the browser natively for credentials instead of our own cookie-based
517
+ // /login page, since that page can't establish a session through the tunnel
518
+ return {
519
+ status: 401, type: "text/html",
520
+ header: { "WWW-Authenticate": 'Basic realm="Duotecno Gateway"' },
521
+ data: "<html><head><title>Login required</title></head><body>Login required</body></html>"
522
+ };
523
+ }
473
524
  context.request = "login";
474
525
  context.action = "";
475
526
  }
@@ -513,10 +564,11 @@ class WebApp extends base_1.Base {
513
564
  data: "<html><head><title>File " + filename + " not found</title></head><body>These are not the droids your are looking for</body></html>"
514
565
  };
515
566
  }
516
- redirect(url, header = {}) {
567
+ redirect(context, url, header = {}) {
568
+ const target = url.startsWith("http") ? url : context.basePath + url;
517
569
  return {
518
- status: 303, type: "text/html", header: Object.assign({ "Location": url }, header),
519
- data: "<html><head><meta http-equiv=\"Refresh\" content=\"0; URL=" + url + "\"></head></html>"
570
+ status: 303, type: "text/html", header: Object.assign({ "Location": target }, header),
571
+ data: "<html><head><meta http-equiv=\"Refresh\" content=\"0; URL=" + target + "\"></head></html>"
520
572
  };
521
573
  }
522
574
  error(context, msg = "", json = false) {
@@ -534,6 +586,43 @@ class WebApp extends base_1.Base {
534
586
  return true;
535
587
  });
536
588
  }
589
+ // Validates (and caches for kBasicAuthCacheMs) an "Authorization: Basic ..." header via
590
+ // pwOK(). Used as the login fallback for requests coming in through the cloud proxy.
591
+ checkBasicAuth(context) {
592
+ return __awaiter(this, void 0, void 0, function* () {
593
+ const authHeader = context.req.headers.authorization;
594
+ if (!authHeader || !authHeader.startsWith("Basic "))
595
+ return false;
596
+ const cachedUntil = this.authorizedBasicAuth.get(authHeader);
597
+ if (cachedUntil && (cachedUntil > Date.now()))
598
+ return true;
599
+ // collapse concurrent requests bearing the same (not-yet-cached) credentials onto one
600
+ // in-flight validation instead of each calling pwOK() independently
601
+ let pending = this.pendingBasicAuth.get(authHeader);
602
+ if (!pending) {
603
+ pending = this.validateBasicAuth(context, authHeader);
604
+ this.pendingBasicAuth.set(authHeader, pending);
605
+ pending.finally(() => this.pendingBasicAuth.delete(authHeader));
606
+ }
607
+ return pending;
608
+ });
609
+ }
610
+ validateBasicAuth(context, authHeader) {
611
+ return __awaiter(this, void 0, void 0, function* () {
612
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf-8");
613
+ const sep = decoded.indexOf(":");
614
+ if (sep < 0)
615
+ return false;
616
+ const user = decoded.slice(0, sep);
617
+ const pw = decoded.slice(sep + 1);
618
+ if (yield this.pwOK(context, user, pw)) {
619
+ this.authorizedBasicAuth.set(authHeader, Date.now() + WebApp.kBasicAuthCacheMs);
620
+ return true;
621
+ }
622
+ this.authorizedBasicAuth.delete(authHeader);
623
+ return false;
624
+ });
625
+ }
537
626
  doLogin(context) {
538
627
  return __awaiter(this, void 0, void 0, function* () {
539
628
  if (context.action === "logout") {
@@ -553,7 +642,7 @@ class WebApp extends base_1.Base {
553
642
  const user = context.getParam({ name: "user", type: "string" });
554
643
  if (yield this.pwOK(context, user, pw)) {
555
644
  const path = context.getCookie("DPath") || "/";
556
- return this.redirect(path, { "Set-Cookie": "DTicket=" + this.token });
645
+ return this.redirect(context, path, { "Set-Cookie": "DTicket=" + this.token });
557
646
  }
558
647
  else {
559
648
  return this.ejs("login", context, { message: "authentication failed", user }, { "Set-Cookie": "DTicket=x" });
@@ -563,7 +652,8 @@ class WebApp extends base_1.Base {
563
652
  doLogout(context) {
564
653
  return __awaiter(this, void 0, void 0, function* () {
565
654
  this.token = "";
566
- return this.redirect("/", { "Set-Cookie": "DTicket=x" });
655
+ this.authorizedBasicAuth.clear();
656
+ return this.redirect(context, "/", { "Set-Cookie": "DTicket=x" });
567
657
  });
568
658
  }
569
659
  doRestart(json) {
@@ -622,4 +712,5 @@ class WebApp extends base_1.Base {
622
712
  }
623
713
  }
624
714
  exports.WebApp = WebApp;
715
+ WebApp.kBasicAuthCacheMs = 2 * 60 * 1000;
625
716
  //# sourceMappingURL=webapp.js.map
@@ -1,65 +0,0 @@
1
- <style>
2
- /* Page layout with sticky footer */
3
- html, body { height: 100%; margin: 0; display: flex; flex-direction: column }
4
- body { min-height: 100vh }
5
-
6
- /* Main content area - grows to fill space */
7
- body > *:not(nav):not(.page-footer) { flex: 1 0 auto }
8
-
9
- form { padding-left: 7px; padding-right: 9px; padding-bottom: 20px }
10
- form:last-of-type { padding-bottom: 80px }
11
-
12
- nav, nav .nav-wrapper { height: 64px !important; line-height: 64px !important; flex-shrink: 0 }
13
- nav .brand-logo { left: 10px !important; -webkit-transform: none !important; transform: none !important }
14
-
15
- h1 { font-size: 30px }
16
- h1.btn { margin-top: 18px }
17
- h1 .btn { margin-left: 10px }
18
- a span { margin-bottom: 7px; display: inline-block }
19
-
20
- /* Sticky footer */
21
- .page-footer { position: fixed; bottom: 0; left: 0; right: 0; width: 100%; flex-shrink: 0; z-index: 100; }
22
- .page-footer p { text-align: right; padding-bottom: 4px; margin-right: 6px }
23
- .page-footer a { float: left; margin-left: 10px; color: white; }
24
- .page-footer { padding-top: 5px; padding-right: 6px }
25
- .range-field { margin-bottom: -1em }
26
- b { color: #ee6e73 }
27
- .btn-small i { font-size: 1.1rem }
28
- small { font-size: 70% }
29
- .btn-floating.btn-small { width: 32px; height: 31px }
30
- .rules p { display: inline-block; margin-bottom: 0px }
31
- div.select-wrapper { max-width: 212px }
32
- div.select-wrapper input.select-dropdown { font-size: 16px !important; height: 3rem !important; }
33
- span.used { color: darkgrey }
34
- .message { margin-left: 10px; font-size: 15px }
35
-
36
-
37
- /* Hamburger menu icon - positioned on right side */
38
- nav .sidenav-trigger {
39
- position: absolute; right: 10px; top: 0;
40
- height: 64px; line-height: 64px; padding: 0 15px; margin: 0;
41
- }
42
- nav .sidenav-trigger i { line-height: 64px; font-size: 2rem }
43
-
44
- /* Hide hamburger on desktop, show desktop menu */
45
- @media only screen and (min-width: 993px) {
46
- nav .sidenav-trigger { display: none !important; }
47
- }
48
-
49
- /* Show hamburger on mobile, hide desktop menu */
50
- @media only screen and (max-width: 992px) {
51
- nav .sidenav-trigger { display: block !important; }
52
- }
53
-
54
- /* Desktop navigation active state */
55
- nav ul li.active { background-color: rgba(0, 0, 0, 0.1) }
56
-
57
- /* Sidenav styling */
58
- .sidenav { width: 280px; }
59
- .sidenav .user-view { padding: 1px 32px 16px 16px }
60
- .sidenav .user-view .name { font-size: 18px; font-weight: 500; margin-top: 16px; display: block; }
61
- .sidenav li > a { height: auto; line-height: 1px; padding: 14px 32px; }
62
- .sidenav .divider { margin: 8px 0; }
63
- .sidenav li.active { background-color: rgba(0,0,0,0.05); }
64
-
65
- </style>