locadot 2.2.0 → 2.4.0
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/CHANGELOG.md +27 -0
- package/README.md +34 -2
- package/dist/cli/commands/hosts.js +29 -1
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/protect.js +76 -0
- package/dist/cli/commands/tunnel.js +20 -3
- package/dist/cli/commands/ui.js +6 -3
- package/dist/constants/index.js +1 -0
- package/dist/dashboard/api.js +32 -5
- package/dist/dashboard/index.js +4 -0
- package/dist/dashboard/page.js +1176 -83
- package/dist/index.js +23 -2
- package/dist/lib/allow.js +60 -0
- package/dist/lib/host-auth.js +123 -0
- package/dist/lib/hosts.js +105 -4
- package/dist/proxy/cloudflare-logins.js +197 -0
- package/dist/proxy/guard.js +30 -1
- package/dist/proxy/options.js +10 -2
- package/dist/proxy/protect.js +215 -0
- package/dist/proxy/router.js +32 -7
- package/dist/proxy/tunnel.js +69 -24
- package/dist/server/index.js +1 -0
- package/package.json +1 -1
package/dist/dashboard/page.js
CHANGED
|
@@ -348,11 +348,16 @@ td.badges .badge + .badge { margin-left: 4px; }
|
|
|
348
348
|
.btn:hover:not(:disabled) { background: var(--surface-2); border-color: var(--muted-dim); }
|
|
349
349
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
350
350
|
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
|
|
351
|
+
.btn-sm.btn-icon { width: 28px; padding: 0; color: var(--muted); }
|
|
352
|
+
.btn-icon svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
353
|
+
.btn-icon.is-set { color: var(--accent); border-color: transparent; background: var(--accent-bg); }
|
|
351
354
|
.btn-block { width: 100%; }
|
|
352
355
|
.btn-primary { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
|
353
356
|
.btn-primary:hover:not(:disabled) { background: var(--accent-strong); border-color: var(--accent-strong); }
|
|
354
357
|
.btn-danger { border-color: var(--down); color: var(--down); background: transparent; }
|
|
355
358
|
.btn-danger:hover:not(:disabled) { background: var(--down-bg); border-color: var(--down); }
|
|
359
|
+
.modal-foot .btn-danger { background: var(--down); border-color: var(--down); color: #fff; }
|
|
360
|
+
.modal-foot .btn-danger:hover:not(:disabled) { background: var(--down); filter: brightness(0.92); }
|
|
356
361
|
.btn-ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--muted); }
|
|
357
362
|
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); border-color: transparent; color: var(--fg); }
|
|
358
363
|
.btn-ghost-danger { background: transparent; border-color: transparent; box-shadow: none; color: var(--muted); }
|
|
@@ -575,6 +580,8 @@ td .inline-check + .inline-check { margin-top: 4px; }
|
|
|
575
580
|
.inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
|
576
581
|
.tunnel-cell { white-space: normal; }
|
|
577
582
|
.tunnel-status { display: flex; align-items: center; gap: 6px; max-width: 240px; }
|
|
583
|
+
.tunnel-login { display: flex; flex-direction: column; gap: 2px; font-size: 12px; white-space: nowrap; }
|
|
584
|
+
.tunnel-login a { font-weight: 500; }
|
|
578
585
|
.tunnel-status a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; display: inline-block; vertical-align: middle; font-size: 12px; }
|
|
579
586
|
.tunnel-error { color: var(--down); cursor: help; }
|
|
580
587
|
.tunnel-error-text {
|
|
@@ -729,6 +736,192 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
|
|
|
729
736
|
margin: 0;
|
|
730
737
|
}
|
|
731
738
|
|
|
739
|
+
/* ---------- allow dialog ---------- */
|
|
740
|
+
#allow-dialog {
|
|
741
|
+
width: min(520px, calc(100vw - 32px));
|
|
742
|
+
padding: 0;
|
|
743
|
+
border: 1px solid var(--border);
|
|
744
|
+
border-radius: var(--radius);
|
|
745
|
+
background: var(--surface);
|
|
746
|
+
color: var(--fg);
|
|
747
|
+
box-shadow: var(--shadow-md);
|
|
748
|
+
}
|
|
749
|
+
#allow-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
|
750
|
+
.modal-head {
|
|
751
|
+
display: flex;
|
|
752
|
+
align-items: flex-start;
|
|
753
|
+
justify-content: space-between;
|
|
754
|
+
gap: 12px;
|
|
755
|
+
padding: 16px 20px;
|
|
756
|
+
border-bottom: 1px solid var(--border);
|
|
757
|
+
}
|
|
758
|
+
.modal-title { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: -0.01em; }
|
|
759
|
+
.modal-sub { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
|
|
760
|
+
.modal-close { width: 28px; height: 28px; padding: 0; border-radius: 999px; flex-shrink: 0; }
|
|
761
|
+
.modal-close svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
|
|
762
|
+
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
|
|
763
|
+
.modal-desc { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; }
|
|
764
|
+
.modal-warn {
|
|
765
|
+
font-size: 12.5px;
|
|
766
|
+
color: var(--warn);
|
|
767
|
+
background: var(--warn-bg);
|
|
768
|
+
border: 1px solid var(--warn);
|
|
769
|
+
border-radius: var(--radius-sm);
|
|
770
|
+
padding: 8px 12px;
|
|
771
|
+
line-height: 1.5;
|
|
772
|
+
}
|
|
773
|
+
.allow-list { display: flex; flex-direction: column; gap: 6px; max-height: 220px; overflow-y: auto; }
|
|
774
|
+
.allow-row {
|
|
775
|
+
display: flex;
|
|
776
|
+
align-items: center;
|
|
777
|
+
justify-content: space-between;
|
|
778
|
+
gap: 10px;
|
|
779
|
+
padding: 6px 10px;
|
|
780
|
+
border: 1px solid var(--border);
|
|
781
|
+
border-radius: var(--radius-sm);
|
|
782
|
+
background: var(--surface-2);
|
|
783
|
+
}
|
|
784
|
+
.allow-row span { font-size: 13px; word-break: break-all; }
|
|
785
|
+
.allow-remove {
|
|
786
|
+
display: inline-flex;
|
|
787
|
+
align-items: center;
|
|
788
|
+
justify-content: center;
|
|
789
|
+
width: 22px;
|
|
790
|
+
height: 22px;
|
|
791
|
+
padding: 0;
|
|
792
|
+
font-size: 15px;
|
|
793
|
+
line-height: 1;
|
|
794
|
+
border-radius: var(--radius-xs);
|
|
795
|
+
border: 1px solid transparent;
|
|
796
|
+
background: transparent;
|
|
797
|
+
color: var(--muted);
|
|
798
|
+
cursor: pointer;
|
|
799
|
+
flex-shrink: 0;
|
|
800
|
+
}
|
|
801
|
+
.allow-remove:hover { color: var(--down); background: var(--down-bg); border-color: transparent; }
|
|
802
|
+
.allow-empty { font-size: 13px; padding: 4px 0; }
|
|
803
|
+
.allow-add-row { display: flex; gap: 8px; }
|
|
804
|
+
.allow-add-row .input { flex: 1; }
|
|
805
|
+
.allow-add-row .btn { height: auto; align-self: stretch; padding: 0 14px; }
|
|
806
|
+
.modal-hint { margin: 0; font-size: 12px; color: var(--muted-dim); }
|
|
807
|
+
.modal-foot {
|
|
808
|
+
display: flex;
|
|
809
|
+
justify-content: flex-end;
|
|
810
|
+
gap: 8px;
|
|
811
|
+
padding: 16px 20px;
|
|
812
|
+
border-top: 1px solid var(--border);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/* ---------- confirm / prompt / share / protect dialogs ---------- */
|
|
816
|
+
#confirm-dialog, #prompt-dialog, #share-dialog, #protect-dialog {
|
|
817
|
+
padding: 0;
|
|
818
|
+
border: 1px solid var(--border);
|
|
819
|
+
border-radius: var(--radius);
|
|
820
|
+
background: var(--surface);
|
|
821
|
+
color: var(--fg);
|
|
822
|
+
box-shadow: var(--shadow-md);
|
|
823
|
+
}
|
|
824
|
+
#confirm-dialog::backdrop, #prompt-dialog::backdrop, #share-dialog::backdrop, #protect-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
|
825
|
+
#confirm-dialog { width: min(420px, calc(100vw - 32px)); }
|
|
826
|
+
#prompt-dialog { width: min(420px, calc(100vw - 32px)); }
|
|
827
|
+
#share-dialog { width: min(480px, calc(100vw - 32px)); }
|
|
828
|
+
#protect-dialog { width: min(480px, calc(100vw - 32px)); }
|
|
829
|
+
.field-password { position: relative; }
|
|
830
|
+
.field-password .input { width: 100%; padding-right: 40px; }
|
|
831
|
+
.field-toggle {
|
|
832
|
+
position: absolute;
|
|
833
|
+
top: 4px;
|
|
834
|
+
right: 4px;
|
|
835
|
+
width: 28px;
|
|
836
|
+
height: 28px;
|
|
837
|
+
padding: 0;
|
|
838
|
+
border: 1px solid transparent;
|
|
839
|
+
border-radius: var(--radius-xs);
|
|
840
|
+
background: transparent;
|
|
841
|
+
color: var(--muted);
|
|
842
|
+
cursor: pointer;
|
|
843
|
+
display: inline-flex;
|
|
844
|
+
align-items: center;
|
|
845
|
+
justify-content: center;
|
|
846
|
+
}
|
|
847
|
+
.field-toggle:hover { background: var(--surface-2); color: var(--fg); }
|
|
848
|
+
.field-toggle svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
849
|
+
.option-check {
|
|
850
|
+
width: 16px;
|
|
851
|
+
height: 16px;
|
|
852
|
+
margin-top: 2px;
|
|
853
|
+
flex-shrink: 0;
|
|
854
|
+
border-radius: 4px;
|
|
855
|
+
border: 1.5px solid var(--border-strong);
|
|
856
|
+
background: var(--surface);
|
|
857
|
+
position: relative;
|
|
858
|
+
}
|
|
859
|
+
.option-card[aria-checked="true"] .option-check { border-color: var(--accent); background: var(--accent); }
|
|
860
|
+
.option-card[aria-checked="true"] .option-check::after {
|
|
861
|
+
content: "";
|
|
862
|
+
position: absolute;
|
|
863
|
+
left: 4px;
|
|
864
|
+
top: 1px;
|
|
865
|
+
width: 4px;
|
|
866
|
+
height: 8px;
|
|
867
|
+
border: solid var(--accent-fg);
|
|
868
|
+
border-width: 0 2px 2px 0;
|
|
869
|
+
transform: rotate(45deg);
|
|
870
|
+
}
|
|
871
|
+
#protect-remove { margin-right: auto; }
|
|
872
|
+
.share-options { display: flex; flex-direction: column; gap: 10px; }
|
|
873
|
+
.option-card {
|
|
874
|
+
display: flex;
|
|
875
|
+
align-items: flex-start;
|
|
876
|
+
gap: 12px;
|
|
877
|
+
width: 100%;
|
|
878
|
+
text-align: left;
|
|
879
|
+
padding: 14px;
|
|
880
|
+
border: 1px solid var(--border);
|
|
881
|
+
border-radius: var(--radius);
|
|
882
|
+
background: var(--surface-2);
|
|
883
|
+
color: inherit;
|
|
884
|
+
font: inherit;
|
|
885
|
+
cursor: pointer;
|
|
886
|
+
}
|
|
887
|
+
.option-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
|
|
888
|
+
.option-card[aria-checked="true"] { border-color: var(--accent); background: var(--accent-bg); }
|
|
889
|
+
.option-dot {
|
|
890
|
+
width: 16px;
|
|
891
|
+
height: 16px;
|
|
892
|
+
margin-top: 2px;
|
|
893
|
+
flex-shrink: 0;
|
|
894
|
+
border-radius: 999px;
|
|
895
|
+
border: 1.5px solid var(--border-strong);
|
|
896
|
+
background: var(--surface);
|
|
897
|
+
position: relative;
|
|
898
|
+
}
|
|
899
|
+
.option-card[aria-checked="true"] .option-dot { border-color: var(--accent); }
|
|
900
|
+
.option-card[aria-checked="true"] .option-dot::after {
|
|
901
|
+
content: "";
|
|
902
|
+
position: absolute;
|
|
903
|
+
top: 3px;
|
|
904
|
+
left: 3px;
|
|
905
|
+
width: 8px;
|
|
906
|
+
height: 8px;
|
|
907
|
+
border-radius: 999px;
|
|
908
|
+
background: var(--accent);
|
|
909
|
+
}
|
|
910
|
+
.option-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
911
|
+
.option-title { font-size: 13.5px; font-weight: 600; }
|
|
912
|
+
.option-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
|
|
913
|
+
.option-extra { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
|
914
|
+
.option-extra .field-error { margin-top: 4px; }
|
|
915
|
+
.share-progress { display: flex; gap: 14px; align-items: flex-start; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
|
|
916
|
+
.share-progress-icon { width: 36px; height: 36px; flex-shrink: 0; border-radius: 999px; display: grid; place-items: center; background: var(--accent-bg); color: var(--accent); }
|
|
917
|
+
.share-progress-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
918
|
+
.share-progress-icon .spinner { margin: 0; }
|
|
919
|
+
.share-progress-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
920
|
+
.share-progress-title { font-size: 14px; font-weight: 600; margin: 0; overflow-wrap: anywhere; }
|
|
921
|
+
.share-progress-text { font-size: 12.5px; color: var(--muted); line-height: 1.5; margin: 0; }
|
|
922
|
+
.share-progress-body .btn { align-self: flex-start; margin-top: 6px; text-decoration: none; }
|
|
923
|
+
.share-steps { margin: 2px 0 0; padding-left: 18px; font-size: 12.5px; color: var(--muted); line-height: 1.6; }
|
|
924
|
+
|
|
732
925
|
/* ---------- toasts ---------- */
|
|
733
926
|
.toast-container {
|
|
734
927
|
position: fixed;
|
|
@@ -1080,6 +1273,8 @@ const clientJs = `
|
|
|
1080
1273
|
var startupBusy = false;
|
|
1081
1274
|
var cfInstallBusy = false;
|
|
1082
1275
|
var cloudflaredInstalled = false;
|
|
1276
|
+
var cloudflareLoggedIn = false;
|
|
1277
|
+
var cloudflareZones = [];
|
|
1083
1278
|
var proxyStopped = false;
|
|
1084
1279
|
var pollTimer = null;
|
|
1085
1280
|
var remotesPollTimer = null;
|
|
@@ -1297,6 +1492,8 @@ const clientJs = `
|
|
|
1297
1492
|
|
|
1298
1493
|
var cf = sys.cloudflared || { installed: false };
|
|
1299
1494
|
cloudflaredInstalled = !!cf.installed;
|
|
1495
|
+
cloudflareLoggedIn = !!cf.loggedIn;
|
|
1496
|
+
cloudflareZones = Array.isArray(cf.zones) ? cf.zones : [];
|
|
1300
1497
|
if (cf.installed) {
|
|
1301
1498
|
cfValueEl.textContent = "Installed";
|
|
1302
1499
|
cfValueEl.className = "tile-value ok";
|
|
@@ -1378,11 +1575,13 @@ const clientJs = `
|
|
|
1378
1575
|
revokeBtn.className = "btn btn-sm btn-danger";
|
|
1379
1576
|
revokeBtn.textContent = "Revoke";
|
|
1380
1577
|
revokeBtn.addEventListener("click", function () {
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1578
|
+
confirmDialog({ title: "Revoke " + peer.name + "?", message: "This peer will lose access immediately.", confirmLabel: "Revoke", danger: true }).then(function (ok) {
|
|
1579
|
+
if (!ok) return;
|
|
1580
|
+
revokeBtn.disabled = true;
|
|
1581
|
+
apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "DELETE" })
|
|
1582
|
+
.then(function () { showToast("Revoked " + peer.name, "success"); return loadHub(); })
|
|
1583
|
+
.catch(function (err) { apiError(err, "Couldn't revoke peer"); revokeBtn.disabled = false; });
|
|
1584
|
+
});
|
|
1386
1585
|
});
|
|
1387
1586
|
controls.appendChild(revokeBtn);
|
|
1388
1587
|
row.appendChild(controls);
|
|
@@ -1565,13 +1764,15 @@ const clientJs = `
|
|
|
1565
1764
|
});
|
|
1566
1765
|
|
|
1567
1766
|
hubStopBtn.addEventListener("click", function () {
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1767
|
+
confirmDialog({ title: "Stop remote access?", message: "Connected peers will be disconnected.", confirmLabel: "Stop remote access", danger: true }).then(function (ok) {
|
|
1768
|
+
if (!ok) return;
|
|
1769
|
+
hubStopBtn.disabled = true;
|
|
1770
|
+
hubStopBtn.classList.add("busy");
|
|
1771
|
+
apiFetch("/api/hub", { method: "POST", body: JSON.stringify({ mode: "off" }) })
|
|
1772
|
+
.then(function () { showToast("Remote access stopped", "success"); return loadHub(); })
|
|
1773
|
+
.catch(function (err) { apiError(err, "Couldn't stop remote access"); })
|
|
1774
|
+
.then(function () { hubStopBtn.disabled = false; hubStopBtn.classList.remove("busy"); });
|
|
1775
|
+
});
|
|
1575
1776
|
});
|
|
1576
1777
|
|
|
1577
1778
|
// ---------- remote access: connected machines (receiver) ----------
|
|
@@ -1633,11 +1834,12 @@ const clientJs = `
|
|
|
1633
1834
|
urlEditBtn.className = "btn btn-sm btn-ghost";
|
|
1634
1835
|
urlEditBtn.textContent = "Update URL";
|
|
1635
1836
|
urlEditBtn.addEventListener("click", function () {
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1837
|
+
promptDialog({ title: "Update URL", message: "New URL for " + remote.name, label: "URL", value: remote.url, confirmLabel: "Save" }).then(function (next) {
|
|
1838
|
+
if (!next || !next.trim() || next.trim() === remote.url) return;
|
|
1839
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "PUT", body: JSON.stringify({ url: next.trim() }) })
|
|
1840
|
+
.then(function () { showToast("Updated URL for " + remote.name, "success"); return loadRemotes(); })
|
|
1841
|
+
.catch(function (err) { apiError(err, "Couldn't update URL"); });
|
|
1842
|
+
});
|
|
1641
1843
|
});
|
|
1642
1844
|
urlRow.appendChild(urlEditBtn);
|
|
1643
1845
|
item.appendChild(urlRow);
|
|
@@ -1663,11 +1865,13 @@ const clientJs = `
|
|
|
1663
1865
|
disconnectBtn.className = "btn btn-sm btn-danger";
|
|
1664
1866
|
disconnectBtn.textContent = "Disconnect";
|
|
1665
1867
|
disconnectBtn.addEventListener("click", function () {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1868
|
+
confirmDialog({ title: "Disconnect from " + remote.name + "?", message: "This removes its hosts too.", confirmLabel: "Disconnect", danger: true }).then(function (ok) {
|
|
1869
|
+
if (!ok) return;
|
|
1870
|
+
disconnectBtn.disabled = true;
|
|
1871
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name), { method: "DELETE" })
|
|
1872
|
+
.then(function () { showToast("Disconnected " + remote.name, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
|
|
1873
|
+
.catch(function (err) { apiError(err, "Couldn't disconnect"); disconnectBtn.disabled = false; });
|
|
1874
|
+
});
|
|
1671
1875
|
});
|
|
1672
1876
|
actions.appendChild(disconnectBtn);
|
|
1673
1877
|
item.appendChild(actions);
|
|
@@ -1718,11 +1922,13 @@ const clientJs = `
|
|
|
1718
1922
|
delBtn.className = "btn btn-sm btn-danger";
|
|
1719
1923
|
delBtn.textContent = "Delete";
|
|
1720
1924
|
delBtn.addEventListener("click", function () {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1925
|
+
confirmDialog({ title: "Delete " + h.host + " on " + remote.name + "?", message: "This can't be undone.", confirmLabel: "Delete", danger: true }).then(function (ok) {
|
|
1926
|
+
if (!ok) return;
|
|
1927
|
+
delBtn.disabled = true;
|
|
1928
|
+
apiFetch("/api/remotes/" + encodeURIComponent(remote.name) + "/hosts/" + encodeURIComponent(h.host), { method: "DELETE" })
|
|
1929
|
+
.then(function () { showToast("Deleted " + h.host, "success"); return Promise.all([loadRemotes(), loadHosts()]); })
|
|
1930
|
+
.catch(function (err) { apiError(err, "Couldn't delete host"); delBtn.disabled = false; });
|
|
1931
|
+
});
|
|
1726
1932
|
});
|
|
1727
1933
|
hostRow.appendChild(delBtn);
|
|
1728
1934
|
}
|
|
@@ -1961,29 +2167,31 @@ const clientJs = `
|
|
|
1961
2167
|
|
|
1962
2168
|
stopProxyBtn.addEventListener("click", function () {
|
|
1963
2169
|
if (proxyStopped) return;
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2170
|
+
confirmDialog({ title: "Stop the locadot proxy?", message: "Run \\"locadot start\\" to bring it back up.", confirmLabel: "Stop proxy", danger: true }).then(function (ok) {
|
|
2171
|
+
if (!ok) return;
|
|
2172
|
+
stopProxyBtn.disabled = true;
|
|
2173
|
+
stopProxyBtn.classList.add("busy");
|
|
2174
|
+
apiFetch("/api/proxy/stop", { method: "POST" })
|
|
2175
|
+
.then(function () {
|
|
2176
|
+
proxyStopped = true;
|
|
2177
|
+
clear(stoppedBanner);
|
|
2178
|
+
stoppedBanner.appendChild(document.createTextNode("Proxy stopped. Run "));
|
|
2179
|
+
var code = document.createElement("code");
|
|
2180
|
+
code.className = "mono";
|
|
2181
|
+
code.textContent = "locadot start";
|
|
2182
|
+
stoppedBanner.appendChild(code);
|
|
2183
|
+
stoppedBanner.appendChild(document.createTextNode(" to bring it back."));
|
|
2184
|
+
stoppedBanner.hidden = false;
|
|
2185
|
+
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
2186
|
+
if (remotesPollTimer) { clearInterval(remotesPollTimer); remotesPollTimer = null; }
|
|
2187
|
+
showToast("Proxy stopped", "warn");
|
|
2188
|
+
})
|
|
2189
|
+
.catch(function (err) {
|
|
2190
|
+
apiError(err, "Couldn't stop the proxy");
|
|
2191
|
+
stopProxyBtn.disabled = false;
|
|
2192
|
+
stopProxyBtn.classList.remove("busy");
|
|
2193
|
+
});
|
|
2194
|
+
});
|
|
1987
2195
|
});
|
|
1988
2196
|
|
|
1989
2197
|
cfInstallBtn.addEventListener("click", function () {
|
|
@@ -2132,12 +2340,36 @@ const clientJs = `
|
|
|
2132
2340
|
link.textContent = t.url.replace("https://", "");
|
|
2133
2341
|
link.title = t.url;
|
|
2134
2342
|
statusWrap.appendChild(link);
|
|
2343
|
+
if (t.mode === "custom") {
|
|
2344
|
+
var modeBadge = document.createElement("span");
|
|
2345
|
+
modeBadge.className = "badge badge-accent";
|
|
2346
|
+
modeBadge.textContent = "custom";
|
|
2347
|
+
statusWrap.appendChild(modeBadge);
|
|
2348
|
+
}
|
|
2135
2349
|
statusWrap.appendChild(makeCopyButton(function () { return t.url; }));
|
|
2136
2350
|
} else if (t.status === "starting") {
|
|
2137
2351
|
var spin = document.createElement("span");
|
|
2138
2352
|
spin.className = "spinner";
|
|
2139
2353
|
statusWrap.appendChild(spin);
|
|
2140
2354
|
statusWrap.appendChild(document.createTextNode("Starting\\u2026"));
|
|
2355
|
+
} else if (t.status === "login") {
|
|
2356
|
+
var loginDot = document.createElement("span");
|
|
2357
|
+
loginDot.className = "dot warn";
|
|
2358
|
+
statusWrap.appendChild(loginDot);
|
|
2359
|
+
var loginText = document.createElement("span");
|
|
2360
|
+
loginText.className = "tunnel-login";
|
|
2361
|
+
var loginLabel = document.createElement("span");
|
|
2362
|
+
loginLabel.textContent = "Waiting for Cloudflare login";
|
|
2363
|
+
loginText.appendChild(loginLabel);
|
|
2364
|
+
if (t.loginUrl) {
|
|
2365
|
+
var loginLink = document.createElement("a");
|
|
2366
|
+
loginLink.href = t.loginUrl;
|
|
2367
|
+
loginLink.target = "_blank";
|
|
2368
|
+
loginLink.rel = "noopener noreferrer";
|
|
2369
|
+
loginLink.textContent = "Log in to Cloudflare \u2197";
|
|
2370
|
+
loginText.appendChild(loginLink);
|
|
2371
|
+
}
|
|
2372
|
+
statusWrap.appendChild(loginText);
|
|
2141
2373
|
} else if (t.status === "error") {
|
|
2142
2374
|
var errDot = document.createElement("span");
|
|
2143
2375
|
errDot.className = "dot down";
|
|
@@ -2159,35 +2391,692 @@ const clientJs = `
|
|
|
2159
2391
|
|
|
2160
2392
|
function shareButton(row) {
|
|
2161
2393
|
var t = tunnelInfo(row);
|
|
2162
|
-
var isOn = t.enabled || t.status === "up" || t.status === "starting";
|
|
2394
|
+
var isOn = t.enabled || t.status === "up" || t.status === "starting" || t.status === "login";
|
|
2163
2395
|
var isRetry = !isOn && t.status === "error";
|
|
2164
2396
|
var shareBtn = document.createElement("button");
|
|
2165
2397
|
shareBtn.type = "button";
|
|
2166
2398
|
shareBtn.className = "btn btn-sm" + (isOn ? " btn-danger" : "");
|
|
2167
2399
|
shareBtn.textContent = isOn ? "Unshare" : (isRetry ? "Retry" : "Share");
|
|
2168
|
-
shareBtn.title = isOn ? "Stop the public tunnel" :
|
|
2400
|
+
shareBtn.title = isOn ? "Stop the public tunnel" : "Share on a public URL";
|
|
2169
2401
|
if (!cloudflaredInstalled && !isOn) {
|
|
2170
2402
|
shareBtn.disabled = true;
|
|
2171
2403
|
shareBtn.title = "Install cloudflared first";
|
|
2172
2404
|
}
|
|
2173
2405
|
shareBtn.addEventListener("click", function () {
|
|
2174
|
-
if (
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2406
|
+
if (isOn) {
|
|
2407
|
+
confirmDialog({ title: "Stop sharing " + row.host + "?", message: "The public URL for " + row.host + " stops working.", confirmLabel: "Stop sharing", danger: true }).then(function (ok) {
|
|
2408
|
+
if (!ok) return;
|
|
2409
|
+
shareBtn.disabled = true;
|
|
2410
|
+
shareBtn.classList.add("busy");
|
|
2411
|
+
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ tunnel: false }) })
|
|
2412
|
+
.then(function () {
|
|
2413
|
+
showToast("Stopping tunnel for " + row.host, "success");
|
|
2414
|
+
return loadHosts();
|
|
2415
|
+
})
|
|
2416
|
+
.catch(function (err) {
|
|
2417
|
+
apiError(err, "Couldn't change sharing for " + row.host);
|
|
2418
|
+
shareBtn.disabled = false;
|
|
2419
|
+
shareBtn.classList.remove("busy");
|
|
2420
|
+
});
|
|
2421
|
+
});
|
|
2422
|
+
} else {
|
|
2423
|
+
openShareDialog(row);
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
return shareBtn;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// ---------- share dialog ----------
|
|
2430
|
+
|
|
2431
|
+
var shareDialogEl = document.getElementById("share-dialog");
|
|
2432
|
+
var shareHostEl = document.getElementById("share-host");
|
|
2433
|
+
var shareCloseBtn = document.getElementById("share-close");
|
|
2434
|
+
var shareCancelBtn = document.getElementById("share-cancel");
|
|
2435
|
+
var shareSubmitBtn = document.getElementById("share-submit");
|
|
2436
|
+
var shareOptionRandom = document.getElementById("share-option-random");
|
|
2437
|
+
var shareOptionCustom = document.getElementById("share-option-custom");
|
|
2438
|
+
var shareCustomExtra = document.getElementById("share-custom-extra");
|
|
2439
|
+
var shareDomainInput = document.getElementById("share-domain-input");
|
|
2440
|
+
var shareDomainError = document.getElementById("share-domain-error");
|
|
2441
|
+
var shareDomainHint = document.getElementById("share-domain-hint");
|
|
2442
|
+
var shareErrorEl = document.getElementById("share-error");
|
|
2443
|
+
var shareOptionsEl = document.getElementById("share-options");
|
|
2444
|
+
var shareProgressEl = document.getElementById("share-progress");
|
|
2445
|
+
var shareProgressIcon = document.getElementById("share-progress-icon");
|
|
2446
|
+
var shareProgressTitle = document.getElementById("share-progress-title");
|
|
2447
|
+
var shareProgressText = document.getElementById("share-progress-text");
|
|
2448
|
+
var shareStepsEl = document.getElementById("share-steps");
|
|
2449
|
+
var shareStepsDomain = document.getElementById("share-steps-domain");
|
|
2450
|
+
var shareAuthLink = document.getElementById("share-auth-link");
|
|
2451
|
+
var shareDescEl = shareDialogEl.querySelector(".modal-desc");
|
|
2452
|
+
var sharePoll = null;
|
|
2453
|
+
var shareAuthWin = null;
|
|
2454
|
+
var shareAuthOpened = false;
|
|
2455
|
+
var shareRow = null;
|
|
2456
|
+
var shareMode = "random";
|
|
2457
|
+
var HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/;
|
|
2458
|
+
|
|
2459
|
+
function normalizeHostname(value) {
|
|
2460
|
+
return String(value || "").trim().toLowerCase().replace(/^https?:\\/\\//, "").replace(/\\/$/, "");
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
function validateHostname(raw) {
|
|
2464
|
+
var value = normalizeHostname(raw);
|
|
2465
|
+
if (!value) return "Enter a hostname.";
|
|
2466
|
+
if (value.slice(-10) === ".localhost") return "Hostname can't be a .localhost address.";
|
|
2467
|
+
if (!HOSTNAME_RE.test(value)) return "Enter a valid hostname, e.g. app.example.com.";
|
|
2468
|
+
return null;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
function setShareMode(mode) {
|
|
2472
|
+
shareMode = mode;
|
|
2473
|
+
shareOptionRandom.setAttribute("aria-checked", String(mode === "random"));
|
|
2474
|
+
shareOptionCustom.setAttribute("aria-checked", String(mode === "custom"));
|
|
2475
|
+
shareCustomExtra.hidden = mode !== "custom";
|
|
2476
|
+
updateDomainHint();
|
|
2477
|
+
shareDomainError.hidden = true;
|
|
2478
|
+
shareDomainError.textContent = "";
|
|
2479
|
+
shareErrorEl.hidden = true;
|
|
2480
|
+
shareErrorEl.textContent = "";
|
|
2481
|
+
if (mode === "custom") setTimeout(function () { shareDomainInput.focus(); }, 0);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function coveringZone(hostname) {
|
|
2485
|
+
for (var i = 0; i < cloudflareZones.length; i++) {
|
|
2486
|
+
var zone = cloudflareZones[i];
|
|
2487
|
+
if (hostname === zone || hostname.slice(-(zone.length + 1)) === "." + zone) return zone;
|
|
2488
|
+
}
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function updateDomainHint() {
|
|
2493
|
+
var value = normalizeHostname(shareDomainInput.value);
|
|
2494
|
+
var zone = value && !validateHostname(value) ? coveringZone(value) : null;
|
|
2495
|
+
shareDomainHint.textContent = zone
|
|
2496
|
+
? "Uses your Cloudflare login for " + zone + ". The DNS record is created or updated for you."
|
|
2497
|
+
: "Cloudflare opens in a new tab so you can authorize this domain's zone. Each zone you authorize is remembered.";
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
var ICON_CLOUD = '<svg viewBox="0 0 24 24"><path d="M7 18a4.5 4.5 0 0 1-.5-8.97A6 6 0 0 1 18 9.5a4 4 0 0 1-.5 8.5z"/></svg>';
|
|
2501
|
+
|
|
2502
|
+
function stopSharePoll() {
|
|
2503
|
+
if (sharePoll) clearInterval(sharePoll);
|
|
2504
|
+
sharePoll = null;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function closeAuthWindow() {
|
|
2508
|
+
if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) shareAuthWin.close();
|
|
2509
|
+
shareAuthWin = null;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function showShareOptions() {
|
|
2513
|
+
shareOptionsEl.hidden = false;
|
|
2514
|
+
shareDescEl.hidden = false;
|
|
2515
|
+
shareProgressEl.hidden = true;
|
|
2516
|
+
shareSubmitBtn.hidden = false;
|
|
2517
|
+
shareCancelBtn.textContent = "Cancel";
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function showShareProgress(domain, t) {
|
|
2521
|
+
shareOptionsEl.hidden = true;
|
|
2522
|
+
shareDescEl.hidden = true;
|
|
2523
|
+
shareProgressEl.hidden = false;
|
|
2524
|
+
shareSubmitBtn.hidden = true;
|
|
2525
|
+
shareCancelBtn.textContent = "Close";
|
|
2526
|
+
shareCancelBtn.disabled = false;
|
|
2527
|
+
var login = t.status === "login";
|
|
2528
|
+
if (login) {
|
|
2529
|
+
shareProgressIcon.innerHTML = ICON_CLOUD;
|
|
2530
|
+
shareProgressTitle.textContent = "Authorize " + domain + " on Cloudflare";
|
|
2531
|
+
shareProgressText.textContent = "locadot doesn't have a Cloudflare login for this domain yet.";
|
|
2532
|
+
} else {
|
|
2533
|
+
shareProgressIcon.innerHTML = '<span class="spinner"></span>';
|
|
2534
|
+
shareProgressTitle.textContent = "Setting up " + domain;
|
|
2535
|
+
shareProgressText.textContent = t.loginUrl === undefined && shareAuthOpened
|
|
2536
|
+
? "Authorized. Creating the tunnel and the DNS record\u2026"
|
|
2537
|
+
: "Checking your Cloudflare login, then creating the tunnel and the DNS record\u2026";
|
|
2538
|
+
}
|
|
2539
|
+
shareStepsEl.hidden = !login;
|
|
2540
|
+
shareStepsDomain.textContent = domain;
|
|
2541
|
+
shareAuthLink.hidden = !(login && t.loginUrl);
|
|
2542
|
+
if (login && t.loginUrl) {
|
|
2543
|
+
shareAuthLink.href = t.loginUrl;
|
|
2544
|
+
if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) {
|
|
2545
|
+
shareAuthWin.location.href = t.loginUrl;
|
|
2546
|
+
shareAuthOpened = true;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function watchShare(host, domain) {
|
|
2552
|
+
stopSharePoll();
|
|
2553
|
+
showShareProgress(domain, { status: "starting" });
|
|
2554
|
+
var tick = function () {
|
|
2555
|
+
fetch("/api/hosts").then(parseJsonOrThrow).then(function (hosts) {
|
|
2556
|
+
if (shareRow === null || shareRow.host !== host) return;
|
|
2557
|
+
var row = null;
|
|
2558
|
+
for (var i = 0; i < hosts.length; i++) if (hosts[i].host === host) row = hosts[i];
|
|
2559
|
+
var t = row ? tunnelInfo(row) : { status: "error", error: host + " was removed." };
|
|
2560
|
+
if (t.status === "up") {
|
|
2561
|
+
stopSharePoll();
|
|
2562
|
+
closeAuthWindow();
|
|
2563
|
+
closeShareDialog();
|
|
2564
|
+
showToast(host + " is live at " + t.url, "success");
|
|
2565
|
+
renderHosts(hosts);
|
|
2566
|
+
} else if (t.status === "error" || t.status === "off") {
|
|
2567
|
+
stopSharePoll();
|
|
2568
|
+
closeAuthWindow();
|
|
2569
|
+
showShareOptions();
|
|
2570
|
+
shareErrorEl.textContent = t.error || "Sharing stopped.";
|
|
2571
|
+
shareErrorEl.hidden = false;
|
|
2572
|
+
shareSubmitBtn.textContent = "Try again";
|
|
2573
|
+
renderHosts(hosts);
|
|
2574
|
+
} else {
|
|
2575
|
+
showShareProgress(domain, t);
|
|
2576
|
+
}
|
|
2577
|
+
}).catch(function () {});
|
|
2578
|
+
};
|
|
2579
|
+
sharePoll = setInterval(tick, 1500);
|
|
2580
|
+
tick();
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function closeShareDialog() {
|
|
2584
|
+
stopSharePoll();
|
|
2585
|
+
closeAuthWindow();
|
|
2586
|
+
shareRow = null;
|
|
2587
|
+
shareDialogEl.close();
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function openShareDialog(row) {
|
|
2591
|
+
shareRow = row;
|
|
2592
|
+
shareHostEl.textContent = row.host;
|
|
2593
|
+
shareErrorEl.hidden = true;
|
|
2594
|
+
shareErrorEl.textContent = "";
|
|
2595
|
+
shareDomainError.hidden = true;
|
|
2596
|
+
shareDomainError.textContent = "";
|
|
2597
|
+
var hasDomain = typeof row.tunnelDomain === "string" && !!row.tunnelDomain;
|
|
2598
|
+
shareDomainInput.value = hasDomain ? row.tunnelDomain : "";
|
|
2599
|
+
showShareOptions();
|
|
2600
|
+
shareSubmitBtn.textContent = "Start sharing";
|
|
2601
|
+
shareAuthOpened = false;
|
|
2602
|
+
setShareMode(hasDomain ? "custom" : "random");
|
|
2603
|
+
shareSubmitBtn.disabled = false;
|
|
2604
|
+
shareSubmitBtn.classList.remove("busy");
|
|
2605
|
+
shareCancelBtn.disabled = false;
|
|
2606
|
+
shareDialogEl.showModal();
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
shareOptionRandom.addEventListener("click", function () { setShareMode("random"); });
|
|
2610
|
+
shareOptionCustom.addEventListener("click", function () { setShareMode("custom"); });
|
|
2611
|
+
shareOptionRandom.addEventListener("keydown", function (e) {
|
|
2612
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); setShareMode("custom"); shareOptionCustom.focus(); }
|
|
2613
|
+
});
|
|
2614
|
+
shareOptionCustom.addEventListener("keydown", function (e) {
|
|
2615
|
+
if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); setShareMode("random"); shareOptionRandom.focus(); }
|
|
2616
|
+
});
|
|
2617
|
+
shareCancelBtn.addEventListener("click", closeShareDialog);
|
|
2618
|
+
shareCloseBtn.addEventListener("click", closeShareDialog);
|
|
2619
|
+
shareDialogEl.addEventListener("click", function (e) {
|
|
2620
|
+
if (e.target === shareDialogEl) closeShareDialog();
|
|
2621
|
+
});
|
|
2622
|
+
shareDialogEl.addEventListener("close", function () {
|
|
2623
|
+
stopSharePoll();
|
|
2624
|
+
closeAuthWindow();
|
|
2625
|
+
shareRow = null;
|
|
2626
|
+
});
|
|
2627
|
+
shareDomainInput.addEventListener("input", updateDomainHint);
|
|
2628
|
+
shareDomainInput.addEventListener("keydown", function (e) {
|
|
2629
|
+
if (e.key === "Enter") { e.preventDefault(); shareSubmitBtn.click(); }
|
|
2630
|
+
});
|
|
2631
|
+
|
|
2632
|
+
shareSubmitBtn.addEventListener("click", function () {
|
|
2633
|
+
if (!shareRow) return;
|
|
2634
|
+
var row = shareRow;
|
|
2635
|
+
var body = { tunnel: true };
|
|
2636
|
+
if (shareMode === "custom") {
|
|
2637
|
+
var err = validateHostname(shareDomainInput.value);
|
|
2638
|
+
if (err) {
|
|
2639
|
+
shareDomainError.textContent = err;
|
|
2640
|
+
shareDomainError.hidden = false;
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
body.domain = normalizeHostname(shareDomainInput.value);
|
|
2644
|
+
}
|
|
2645
|
+
shareSubmitBtn.disabled = true;
|
|
2646
|
+
shareSubmitBtn.classList.add("busy");
|
|
2647
|
+
shareCancelBtn.disabled = true;
|
|
2648
|
+
shareErrorEl.hidden = true;
|
|
2649
|
+
// Opened now, inside the click, so the browser allows it; pointed at Cloudflare once the sign-in URL arrives.
|
|
2650
|
+
shareAuthOpened = false;
|
|
2651
|
+
closeAuthWindow();
|
|
2652
|
+
if (body.domain && !coveringZone(body.domain)) {
|
|
2653
|
+
shareAuthWin = window.open("", "_blank");
|
|
2654
|
+
if (shareAuthWin) {
|
|
2655
|
+
try {
|
|
2656
|
+
shareAuthWin.document.title = "Connecting to Cloudflare\u2026";
|
|
2657
|
+
shareAuthWin.document.body.style.cssText = "font:14px system-ui,sans-serif;color:#666;display:grid;place-items:center;height:100vh;margin:0";
|
|
2658
|
+
shareAuthWin.document.body.textContent = "Connecting to Cloudflare\u2026";
|
|
2659
|
+
} catch (e) {}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify(body) })
|
|
2663
|
+
.then(function () {
|
|
2664
|
+
if (body.domain) {
|
|
2665
|
+
watchShare(row.host, body.domain);
|
|
2666
|
+
return loadHosts();
|
|
2667
|
+
}
|
|
2668
|
+
showToast("Starting tunnel for " + row.host, "success");
|
|
2669
|
+
closeShareDialog();
|
|
2670
|
+
return loadHosts();
|
|
2671
|
+
})
|
|
2672
|
+
.catch(function (err2) {
|
|
2673
|
+
closeAuthWindow();
|
|
2674
|
+
shareErrorEl.textContent = (err2 && err2.message) ? err2.message : String(err2);
|
|
2675
|
+
shareErrorEl.hidden = false;
|
|
2676
|
+
})
|
|
2677
|
+
.then(function () {
|
|
2678
|
+
shareSubmitBtn.disabled = false;
|
|
2679
|
+
shareSubmitBtn.classList.remove("busy");
|
|
2680
|
+
shareCancelBtn.disabled = false;
|
|
2681
|
+
});
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
var allowDialog = document.getElementById("allow-dialog");
|
|
2685
|
+
var allowHostEl = document.getElementById("allow-host");
|
|
2686
|
+
var allowWarnEl = document.getElementById("allow-warn");
|
|
2687
|
+
var allowListEl = document.getElementById("allow-list");
|
|
2688
|
+
var allowInput = document.getElementById("allow-input");
|
|
2689
|
+
var allowAddBtn = document.getElementById("allow-add");
|
|
2690
|
+
var allowErrorEl = document.getElementById("allow-error");
|
|
2691
|
+
var allowCancelBtn = document.getElementById("allow-cancel");
|
|
2692
|
+
var allowSaveBtn = document.getElementById("allow-save");
|
|
2693
|
+
var allowCloseBtn = document.getElementById("allow-close");
|
|
2694
|
+
var allowOriginal = [];
|
|
2695
|
+
var allowCurrent = [];
|
|
2696
|
+
var allowRow = null;
|
|
2697
|
+
|
|
2698
|
+
function allowListsEqual(a, b) {
|
|
2699
|
+
if (a.length !== b.length) return false;
|
|
2700
|
+
for (var i = 0; i < a.length; i++) {
|
|
2701
|
+
if (a[i] !== b[i]) return false;
|
|
2702
|
+
}
|
|
2703
|
+
return true;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
function updateAllowSave() {
|
|
2707
|
+
allowSaveBtn.disabled = allowListsEqual(allowCurrent, allowOriginal);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
function renderAllowList() {
|
|
2711
|
+
clear(allowListEl);
|
|
2712
|
+
if (!allowCurrent.length) {
|
|
2713
|
+
var empty = document.createElement("div");
|
|
2714
|
+
empty.className = "dim allow-empty";
|
|
2715
|
+
empty.textContent = "No internal addresses. Shared visitors reach public hosts only.";
|
|
2716
|
+
allowListEl.appendChild(empty);
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
allowCurrent.forEach(function (entry) {
|
|
2720
|
+
var entryRow = document.createElement("div");
|
|
2721
|
+
entryRow.className = "allow-row";
|
|
2722
|
+
var text = document.createElement("span");
|
|
2723
|
+
text.className = "mono";
|
|
2724
|
+
text.textContent = entry;
|
|
2725
|
+
entryRow.appendChild(text);
|
|
2726
|
+
var removeBtn = document.createElement("button");
|
|
2727
|
+
removeBtn.type = "button";
|
|
2728
|
+
removeBtn.className = "allow-remove";
|
|
2729
|
+
removeBtn.textContent = "\\u00d7";
|
|
2730
|
+
removeBtn.setAttribute("aria-label", "Remove " + entry);
|
|
2731
|
+
removeBtn.addEventListener("click", function () {
|
|
2732
|
+
allowCurrent = allowCurrent.filter(function (item) { return item !== entry; });
|
|
2733
|
+
renderAllowList();
|
|
2734
|
+
updateAllowSave();
|
|
2735
|
+
});
|
|
2736
|
+
entryRow.appendChild(removeBtn);
|
|
2737
|
+
allowListEl.appendChild(entryRow);
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function allowAddEntry() {
|
|
2742
|
+
var value = allowInput.value.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\\/\\//, "").replace(/\\/$/, "");
|
|
2743
|
+
allowErrorEl.hidden = true;
|
|
2744
|
+
allowErrorEl.textContent = "";
|
|
2745
|
+
if (!value || /\\s/.test(value)) {
|
|
2746
|
+
allowErrorEl.textContent = "Enter a host or host:port, e.g. localhost:3000.";
|
|
2747
|
+
allowErrorEl.hidden = false;
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (allowCurrent.indexOf(value) !== -1) {
|
|
2751
|
+
allowErrorEl.textContent = value + " is already in the list.";
|
|
2752
|
+
allowErrorEl.hidden = false;
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
allowCurrent.push(value);
|
|
2756
|
+
allowInput.value = "";
|
|
2757
|
+
renderAllowList();
|
|
2758
|
+
updateAllowSave();
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
function closeAllowDialog() {
|
|
2762
|
+
allowDialog.close();
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function openAllowDialog(row) {
|
|
2766
|
+
allowRow = row;
|
|
2767
|
+
allowOriginal = (row.allow || []).slice();
|
|
2768
|
+
allowCurrent = allowOriginal.slice();
|
|
2769
|
+
allowHostEl.textContent = row.host;
|
|
2770
|
+
allowWarnEl.hidden = !!row.cors;
|
|
2771
|
+
allowErrorEl.hidden = true;
|
|
2772
|
+
allowErrorEl.textContent = "";
|
|
2773
|
+
allowInput.value = "";
|
|
2774
|
+
renderAllowList();
|
|
2775
|
+
allowSaveBtn.disabled = true;
|
|
2776
|
+
allowSaveBtn.classList.remove("busy");
|
|
2777
|
+
allowCancelBtn.disabled = false;
|
|
2778
|
+
allowDialog.showModal();
|
|
2779
|
+
setTimeout(function () { allowInput.focus(); }, 0);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
allowAddBtn.addEventListener("click", allowAddEntry);
|
|
2783
|
+
allowInput.addEventListener("keydown", function (e) {
|
|
2784
|
+
if (e.key === "Enter") { e.preventDefault(); allowAddEntry(); }
|
|
2785
|
+
});
|
|
2786
|
+
allowCancelBtn.addEventListener("click", closeAllowDialog);
|
|
2787
|
+
allowCloseBtn.addEventListener("click", closeAllowDialog);
|
|
2788
|
+
allowDialog.addEventListener("click", function (e) {
|
|
2789
|
+
if (e.target === allowDialog) closeAllowDialog();
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
allowSaveBtn.addEventListener("click", function () {
|
|
2793
|
+
if (!allowRow) return;
|
|
2794
|
+
var host = allowRow.host;
|
|
2795
|
+
allowSaveBtn.disabled = true;
|
|
2796
|
+
allowSaveBtn.classList.add("busy");
|
|
2797
|
+
allowCancelBtn.disabled = true;
|
|
2798
|
+
apiFetch("/api/hosts/" + encodeURIComponent(host), { method: "PUT", body: JSON.stringify({ allow: allowCurrent }) })
|
|
2799
|
+
.then(function () {
|
|
2800
|
+
showToast("Saved internal access for " + host, "success");
|
|
2801
|
+
closeAllowDialog();
|
|
2802
|
+
return loadHosts();
|
|
2803
|
+
})
|
|
2804
|
+
.catch(function (err) {
|
|
2805
|
+
allowErrorEl.textContent = err && err.message ? err.message : String(err);
|
|
2806
|
+
allowErrorEl.hidden = false;
|
|
2807
|
+
allowSaveBtn.disabled = false;
|
|
2808
|
+
allowSaveBtn.classList.remove("busy");
|
|
2809
|
+
allowCancelBtn.disabled = false;
|
|
2810
|
+
});
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
// ---------- protect dialog ----------
|
|
2814
|
+
|
|
2815
|
+
var PROTECT_SCOPES = ["shared", "remote", "local"];
|
|
2816
|
+
var PROTECT_SCOPE_LABELS = { shared: "Shared link", remote: "Remote access", local: "This machine & network" };
|
|
2817
|
+
|
|
2818
|
+
function protectScopeNames(scopes) {
|
|
2819
|
+
return scopes.map(function (s) { return PROTECT_SCOPE_LABELS[s] || s; }).join(", ");
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
var protectDialogEl = document.getElementById("protect-dialog");
|
|
2823
|
+
var protectHostEl = document.getElementById("protect-host");
|
|
2824
|
+
var protectCloseBtn = document.getElementById("protect-close");
|
|
2825
|
+
var protectCancelBtn = document.getElementById("protect-cancel");
|
|
2826
|
+
var protectSubmitBtn = document.getElementById("protect-submit");
|
|
2827
|
+
var protectRemoveBtn = document.getElementById("protect-remove");
|
|
2828
|
+
var protectPasswordInput = document.getElementById("protect-password");
|
|
2829
|
+
var protectToggleBtn = document.getElementById("protect-toggle");
|
|
2830
|
+
var protectErrorEl = document.getElementById("protect-error");
|
|
2831
|
+
var protectHintEl = document.getElementById("protect-hint");
|
|
2832
|
+
var protectScopeErrorEl = document.getElementById("protect-scope-error");
|
|
2833
|
+
var protectOptionEls = PROTECT_SCOPES.map(function (s) { return document.getElementById("protect-option-" + s); });
|
|
2834
|
+
var protectRow = null;
|
|
2835
|
+
var protectScopes = [];
|
|
2836
|
+
|
|
2837
|
+
function protectRenderScopes() {
|
|
2838
|
+
protectOptionEls.forEach(function (el) {
|
|
2839
|
+
el.setAttribute("aria-checked", String(protectScopes.indexOf(el.getAttribute("data-scope")) !== -1));
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
function protectToggleScope(el) {
|
|
2844
|
+
var scope = el.getAttribute("data-scope");
|
|
2845
|
+
var on = protectScopes.indexOf(scope) === -1;
|
|
2846
|
+
if (on) protectScopes.push(scope);
|
|
2847
|
+
else protectScopes = protectScopes.filter(function (s) { return s !== scope; });
|
|
2848
|
+
protectRenderScopes();
|
|
2849
|
+
protectScopeErrorEl.hidden = true;
|
|
2850
|
+
protectScopeErrorEl.textContent = "";
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
protectOptionEls.forEach(function (el) {
|
|
2854
|
+
el.addEventListener("click", function () { protectToggleScope(el); });
|
|
2855
|
+
});
|
|
2856
|
+
|
|
2857
|
+
protectToggleBtn.addEventListener("click", function () {
|
|
2858
|
+
var showing = protectPasswordInput.type === "text";
|
|
2859
|
+
protectPasswordInput.type = showing ? "password" : "text";
|
|
2860
|
+
protectToggleBtn.setAttribute("aria-label", showing ? "Show password" : "Hide password");
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
function closeProtectDialog() {
|
|
2864
|
+
protectRow = null;
|
|
2865
|
+
protectDialogEl.close();
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function openProtectDialog(row) {
|
|
2869
|
+
protectRow = row;
|
|
2870
|
+
protectHostEl.textContent = row.host;
|
|
2871
|
+
protectPasswordInput.value = "";
|
|
2872
|
+
protectPasswordInput.type = "password";
|
|
2873
|
+
protectToggleBtn.setAttribute("aria-label", "Show password");
|
|
2874
|
+
protectErrorEl.hidden = true;
|
|
2875
|
+
protectErrorEl.textContent = "";
|
|
2876
|
+
protectScopeErrorEl.hidden = true;
|
|
2877
|
+
protectScopeErrorEl.textContent = "";
|
|
2878
|
+
protectScopes = row.protect ? row.protect.scopes.slice() : [];
|
|
2879
|
+
protectRenderScopes();
|
|
2880
|
+
if (row.protect) {
|
|
2881
|
+
protectPasswordInput.placeholder = "Leave blank to keep the current password";
|
|
2882
|
+
protectHintEl.textContent = "Set " + fmtRelative(row.protect.updatedAt);
|
|
2883
|
+
protectHintEl.hidden = false;
|
|
2884
|
+
protectRemoveBtn.hidden = false;
|
|
2885
|
+
protectSubmitBtn.textContent = "Save";
|
|
2886
|
+
} else {
|
|
2887
|
+
protectPasswordInput.placeholder = "";
|
|
2888
|
+
protectHintEl.hidden = true;
|
|
2889
|
+
protectRemoveBtn.hidden = true;
|
|
2890
|
+
protectSubmitBtn.textContent = "Protect host";
|
|
2891
|
+
}
|
|
2892
|
+
protectSubmitBtn.disabled = false;
|
|
2893
|
+
protectSubmitBtn.classList.remove("busy");
|
|
2894
|
+
protectCancelBtn.disabled = false;
|
|
2895
|
+
protectRemoveBtn.disabled = false;
|
|
2896
|
+
protectDialogEl.showModal();
|
|
2897
|
+
setTimeout(function () { protectPasswordInput.focus(); }, 0);
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
protectCancelBtn.addEventListener("click", closeProtectDialog);
|
|
2901
|
+
protectCloseBtn.addEventListener("click", closeProtectDialog);
|
|
2902
|
+
protectDialogEl.addEventListener("click", function (e) {
|
|
2903
|
+
if (e.target === protectDialogEl) closeProtectDialog();
|
|
2904
|
+
});
|
|
2905
|
+
protectDialogEl.addEventListener("close", function () { protectRow = null; });
|
|
2906
|
+
|
|
2907
|
+
protectRemoveBtn.addEventListener("click", function () {
|
|
2908
|
+
if (!protectRow) return;
|
|
2909
|
+
var row = protectRow;
|
|
2910
|
+
confirmDialog({ title: "Remove password protection?", message: "Anyone will be able to reach " + row.host + " without a password.", confirmLabel: "Remove protection", danger: true }).then(function (ok) {
|
|
2911
|
+
if (!ok) return;
|
|
2912
|
+
protectRemoveBtn.disabled = true;
|
|
2913
|
+
protectSubmitBtn.disabled = true;
|
|
2914
|
+
protectCancelBtn.disabled = true;
|
|
2915
|
+
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: null }) })
|
|
2178
2916
|
.then(function () {
|
|
2179
|
-
showToast(
|
|
2917
|
+
showToast("Removed password protection for " + row.host, "success");
|
|
2918
|
+
closeProtectDialog();
|
|
2180
2919
|
return loadHosts();
|
|
2181
2920
|
})
|
|
2182
2921
|
.catch(function (err) {
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2922
|
+
protectErrorEl.textContent = err && err.message ? err.message : String(err);
|
|
2923
|
+
protectErrorEl.hidden = false;
|
|
2924
|
+
protectRemoveBtn.disabled = false;
|
|
2925
|
+
protectSubmitBtn.disabled = false;
|
|
2926
|
+
protectCancelBtn.disabled = false;
|
|
2186
2927
|
});
|
|
2187
2928
|
});
|
|
2188
|
-
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2931
|
+
protectSubmitBtn.addEventListener("click", function () {
|
|
2932
|
+
if (!protectRow) return;
|
|
2933
|
+
var row = protectRow;
|
|
2934
|
+
var password = protectPasswordInput.value;
|
|
2935
|
+
protectErrorEl.hidden = true;
|
|
2936
|
+
protectErrorEl.textContent = "";
|
|
2937
|
+
protectScopeErrorEl.hidden = true;
|
|
2938
|
+
protectScopeErrorEl.textContent = "";
|
|
2939
|
+
var required = !row.protect;
|
|
2940
|
+
if ((required || password) && password.length < 8) {
|
|
2941
|
+
protectErrorEl.textContent = "Use at least 8 characters.";
|
|
2942
|
+
protectErrorEl.hidden = false;
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
if (!protectScopes.length) {
|
|
2946
|
+
protectScopeErrorEl.textContent = "Pick at least one place to ask for the password.";
|
|
2947
|
+
protectScopeErrorEl.hidden = false;
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
var protectBody = { scopes: protectScopes.slice() };
|
|
2951
|
+
if (password) protectBody.password = password;
|
|
2952
|
+
protectSubmitBtn.disabled = true;
|
|
2953
|
+
protectSubmitBtn.classList.add("busy");
|
|
2954
|
+
protectCancelBtn.disabled = true;
|
|
2955
|
+
protectRemoveBtn.disabled = true;
|
|
2956
|
+
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: protectBody }) })
|
|
2957
|
+
.then(function () {
|
|
2958
|
+
showToast("Password protection saved for " + row.host, "success");
|
|
2959
|
+
closeProtectDialog();
|
|
2960
|
+
return loadHosts();
|
|
2961
|
+
})
|
|
2962
|
+
.catch(function (err) {
|
|
2963
|
+
protectErrorEl.textContent = err && err.message ? err.message : String(err);
|
|
2964
|
+
protectErrorEl.hidden = false;
|
|
2965
|
+
})
|
|
2966
|
+
.then(function () {
|
|
2967
|
+
protectSubmitBtn.disabled = false;
|
|
2968
|
+
protectSubmitBtn.classList.remove("busy");
|
|
2969
|
+
protectCancelBtn.disabled = false;
|
|
2970
|
+
protectRemoveBtn.disabled = false;
|
|
2971
|
+
});
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
// ---------- confirm / prompt dialogs ----------
|
|
2975
|
+
|
|
2976
|
+
var confirmDialogEl = document.getElementById("confirm-dialog");
|
|
2977
|
+
var confirmTitleEl = document.getElementById("confirm-title");
|
|
2978
|
+
var confirmMessageEl = document.getElementById("confirm-message");
|
|
2979
|
+
var confirmCancelBtn = document.getElementById("confirm-cancel");
|
|
2980
|
+
var confirmOkBtn = document.getElementById("confirm-ok");
|
|
2981
|
+
var confirmCloseBtn = document.getElementById("confirm-close");
|
|
2982
|
+
var confirmResolve = null;
|
|
2983
|
+
|
|
2984
|
+
function closeConfirmDialog(result) {
|
|
2985
|
+
var resolve = confirmResolve;
|
|
2986
|
+
confirmResolve = null;
|
|
2987
|
+
confirmDialogEl.close();
|
|
2988
|
+
if (resolve) resolve(result);
|
|
2189
2989
|
}
|
|
2190
2990
|
|
|
2991
|
+
function confirmDialog(opts) {
|
|
2992
|
+
opts = opts || {};
|
|
2993
|
+
return new Promise(function (resolve) {
|
|
2994
|
+
confirmResolve = resolve;
|
|
2995
|
+
confirmTitleEl.textContent = opts.title || "Are you sure?";
|
|
2996
|
+
confirmMessageEl.textContent = opts.message || "";
|
|
2997
|
+
confirmOkBtn.textContent = opts.confirmLabel || "OK";
|
|
2998
|
+
confirmOkBtn.className = "btn " + (opts.danger ? "btn-danger" : "btn-primary");
|
|
2999
|
+
confirmDialogEl.showModal();
|
|
3000
|
+
setTimeout(function () { confirmOkBtn.focus(); }, 0);
|
|
3001
|
+
});
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
confirmCancelBtn.addEventListener("click", function () { closeConfirmDialog(false); });
|
|
3005
|
+
confirmCloseBtn.addEventListener("click", function () { closeConfirmDialog(false); });
|
|
3006
|
+
confirmOkBtn.addEventListener("click", function () { closeConfirmDialog(true); });
|
|
3007
|
+
confirmDialogEl.addEventListener("click", function (e) {
|
|
3008
|
+
if (e.target === confirmDialogEl) closeConfirmDialog(false);
|
|
3009
|
+
});
|
|
3010
|
+
confirmDialogEl.addEventListener("close", function () {
|
|
3011
|
+
if (confirmResolve) { var resolve = confirmResolve; confirmResolve = null; resolve(false); }
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
var promptDialogEl = document.getElementById("prompt-dialog");
|
|
3015
|
+
var promptTitleEl = document.getElementById("prompt-title");
|
|
3016
|
+
var promptMessageEl = document.getElementById("prompt-message");
|
|
3017
|
+
var promptLabelEl = document.getElementById("prompt-label");
|
|
3018
|
+
var promptInput = document.getElementById("prompt-input");
|
|
3019
|
+
var promptErrorEl = document.getElementById("prompt-error");
|
|
3020
|
+
var promptCancelBtn = document.getElementById("prompt-cancel");
|
|
3021
|
+
var promptConfirmBtn = document.getElementById("prompt-confirm");
|
|
3022
|
+
var promptCloseBtn = document.getElementById("prompt-close");
|
|
3023
|
+
var promptResolve = null;
|
|
3024
|
+
var promptValidate = null;
|
|
3025
|
+
|
|
3026
|
+
function closePromptDialog(result) {
|
|
3027
|
+
var resolve = promptResolve;
|
|
3028
|
+
promptResolve = null;
|
|
3029
|
+
promptValidate = null;
|
|
3030
|
+
promptDialogEl.close();
|
|
3031
|
+
if (resolve) resolve(result);
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
function promptDialog(opts) {
|
|
3035
|
+
opts = opts || {};
|
|
3036
|
+
return new Promise(function (resolve) {
|
|
3037
|
+
promptResolve = resolve;
|
|
3038
|
+
promptValidate = typeof opts.validate === "function" ? opts.validate : null;
|
|
3039
|
+
promptTitleEl.textContent = opts.title || "";
|
|
3040
|
+
promptMessageEl.textContent = opts.message || "";
|
|
3041
|
+
promptMessageEl.hidden = !opts.message;
|
|
3042
|
+
promptLabelEl.textContent = opts.label || "";
|
|
3043
|
+
promptLabelEl.hidden = !opts.label;
|
|
3044
|
+
promptInput.placeholder = opts.placeholder || "";
|
|
3045
|
+
promptInput.value = opts.value || "";
|
|
3046
|
+
promptConfirmBtn.textContent = opts.confirmLabel || "OK";
|
|
3047
|
+
promptErrorEl.hidden = true;
|
|
3048
|
+
promptErrorEl.textContent = "";
|
|
3049
|
+
promptDialogEl.showModal();
|
|
3050
|
+
setTimeout(function () { promptInput.focus(); promptInput.select(); }, 0);
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
function submitPromptDialog() {
|
|
3055
|
+
var value = promptInput.value;
|
|
3056
|
+
if (promptValidate) {
|
|
3057
|
+
var err = promptValidate(value);
|
|
3058
|
+
if (err) {
|
|
3059
|
+
promptErrorEl.textContent = err;
|
|
3060
|
+
promptErrorEl.hidden = false;
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
closePromptDialog(value);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
promptCancelBtn.addEventListener("click", function () { closePromptDialog(null); });
|
|
3068
|
+
promptCloseBtn.addEventListener("click", function () { closePromptDialog(null); });
|
|
3069
|
+
promptConfirmBtn.addEventListener("click", submitPromptDialog);
|
|
3070
|
+
promptInput.addEventListener("keydown", function (e) {
|
|
3071
|
+
if (e.key === "Enter") { e.preventDefault(); submitPromptDialog(); }
|
|
3072
|
+
});
|
|
3073
|
+
promptDialogEl.addEventListener("click", function (e) {
|
|
3074
|
+
if (e.target === promptDialogEl) closePromptDialog(null);
|
|
3075
|
+
});
|
|
3076
|
+
promptDialogEl.addEventListener("close", function () {
|
|
3077
|
+
if (promptResolve) { var resolve = promptResolve; promptResolve = null; resolve(null); }
|
|
3078
|
+
});
|
|
3079
|
+
|
|
2191
3080
|
function labelCells(tr, labels) {
|
|
2192
3081
|
for (var i = 0; i < tr.children.length; i++) tr.children[i].setAttribute("data-label", labels[i] || "");
|
|
2193
3082
|
}
|
|
@@ -2228,13 +3117,15 @@ const clientJs = `
|
|
|
2228
3117
|
|
|
2229
3118
|
var optionsTd = document.createElement("td");
|
|
2230
3119
|
var flags = [];
|
|
2231
|
-
if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified"]);
|
|
2232
|
-
if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host"]);
|
|
3120
|
+
if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified", "badge-warn"]);
|
|
3121
|
+
if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
|
|
3122
|
+
if (row.allow && row.allow.length) flags.push(["allow " + row.allow.length, row.allow.join(", "), "badge-accent"]);
|
|
3123
|
+
if (row.protect) flags.push(["password", "Password protected on: " + protectScopeNames(row.protect.scopes), "badge-accent"]);
|
|
2233
3124
|
if (flags.length) {
|
|
2234
3125
|
optionsTd.className = "badges";
|
|
2235
3126
|
flags.forEach(function (flag) {
|
|
2236
3127
|
var badge = document.createElement("span");
|
|
2237
|
-
badge.className = "badge " +
|
|
3128
|
+
badge.className = "badge " + flag[2];
|
|
2238
3129
|
badge.textContent = flag[0];
|
|
2239
3130
|
badge.title = flag[1];
|
|
2240
3131
|
optionsTd.appendChild(badge);
|
|
@@ -2265,6 +3156,34 @@ const clientJs = `
|
|
|
2265
3156
|
var actionsWrap = document.createElement("div");
|
|
2266
3157
|
actionsWrap.className = "row-actions";
|
|
2267
3158
|
actionsTd.appendChild(actionsWrap);
|
|
3159
|
+
var accessBtn = document.createElement("button");
|
|
3160
|
+
accessBtn.type = "button";
|
|
3161
|
+
var allowCount = row.allow ? row.allow.length : 0;
|
|
3162
|
+
accessBtn.className = "btn btn-sm btn-icon" + (allowCount ? " is-set" : "");
|
|
3163
|
+
accessBtn.title = allowCount ? "Internal access: " + row.allow.join(", ") : "Internal access: let shared visitors reach addresses such as localhost:3000";
|
|
3164
|
+
accessBtn.setAttribute("aria-label", "Internal access for " + row.host);
|
|
3165
|
+
var accessSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
3166
|
+
accessSvg.setAttribute("viewBox", "0 0 24 24");
|
|
3167
|
+
accessSvg.setAttribute("aria-hidden", "true");
|
|
3168
|
+
var accessPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
3169
|
+
accessPath.setAttribute("d", "M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6l7-3zM9.5 12l1.8 1.8L15 10");
|
|
3170
|
+
accessSvg.appendChild(accessPath);
|
|
3171
|
+
accessBtn.appendChild(accessSvg);
|
|
3172
|
+
accessBtn.addEventListener("click", function () { openAllowDialog(row); });
|
|
3173
|
+
var protectBtn = document.createElement("button");
|
|
3174
|
+
protectBtn.type = "button";
|
|
3175
|
+
var protectScopesSet = row.protect ? row.protect.scopes : [];
|
|
3176
|
+
protectBtn.className = "btn btn-sm btn-icon" + (protectScopesSet.length ? " is-set" : "");
|
|
3177
|
+
protectBtn.title = protectScopesSet.length ? "Password protected on: " + protectScopeNames(protectScopesSet) : "Password protection: require a password before visitors reach this host";
|
|
3178
|
+
protectBtn.setAttribute("aria-label", "Password protection for " + row.host);
|
|
3179
|
+
var protectSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
3180
|
+
protectSvg.setAttribute("viewBox", "0 0 24 24");
|
|
3181
|
+
protectSvg.setAttribute("aria-hidden", "true");
|
|
3182
|
+
var protectPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
3183
|
+
protectPath.setAttribute("d", "M7 11V8a5 5 0 0 1 10 0v3M5 11h14v10H5z");
|
|
3184
|
+
protectSvg.appendChild(protectPath);
|
|
3185
|
+
protectBtn.appendChild(protectSvg);
|
|
3186
|
+
protectBtn.addEventListener("click", function () { openProtectDialog(row); });
|
|
2268
3187
|
var editBtn = document.createElement("button");
|
|
2269
3188
|
editBtn.type = "button";
|
|
2270
3189
|
editBtn.className = "btn btn-sm";
|
|
@@ -2275,20 +3194,24 @@ const clientJs = `
|
|
|
2275
3194
|
removeBtn.className = "btn btn-sm btn-danger";
|
|
2276
3195
|
removeBtn.textContent = "Remove";
|
|
2277
3196
|
removeBtn.addEventListener("click", function () {
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
3197
|
+
(prefs.confirm ? confirmDialog({ title: "Remove host " + row.host + "?", message: "This can't be undone.", confirmLabel: "Remove host", danger: true }) : Promise.resolve(true)).then(function (ok) {
|
|
3198
|
+
if (!ok) return;
|
|
3199
|
+
removeBtn.disabled = true;
|
|
3200
|
+
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
|
|
3201
|
+
.then(function () {
|
|
3202
|
+
showToast("Removed " + row.host, "success");
|
|
3203
|
+
return loadHosts();
|
|
3204
|
+
})
|
|
3205
|
+
.catch(function (err) {
|
|
3206
|
+
apiError(err, "Couldn't remove " + row.host);
|
|
3207
|
+
removeBtn.disabled = false;
|
|
3208
|
+
});
|
|
3209
|
+
});
|
|
2289
3210
|
});
|
|
2290
3211
|
if (!row.remote) {
|
|
2291
3212
|
actionsWrap.appendChild(shareButton(row));
|
|
3213
|
+
actionsWrap.appendChild(accessBtn);
|
|
3214
|
+
actionsWrap.appendChild(protectBtn);
|
|
2292
3215
|
actionsWrap.appendChild(editBtn);
|
|
2293
3216
|
}
|
|
2294
3217
|
actionsWrap.appendChild(removeBtn);
|
|
@@ -2454,13 +3377,15 @@ const clientJs = `
|
|
|
2454
3377
|
|
|
2455
3378
|
logsRefreshBtn.addEventListener("click", loadLogs);
|
|
2456
3379
|
logsClearBtn.addEventListener("click", function () {
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
3380
|
+
confirmDialog({ title: "Clear logs?", message: "This can't be undone.", confirmLabel: "Clear logs", danger: true }).then(function (ok) {
|
|
3381
|
+
if (!ok) return;
|
|
3382
|
+
apiFetch("/api/logs/clear", { method: "POST" })
|
|
3383
|
+
.then(function () {
|
|
3384
|
+
showToast("Logs cleared", "success");
|
|
3385
|
+
return loadLogs();
|
|
3386
|
+
})
|
|
3387
|
+
.catch(function (err) { apiError(err, "Couldn't clear logs"); });
|
|
3388
|
+
});
|
|
2464
3389
|
});
|
|
2465
3390
|
|
|
2466
3391
|
cliAddCopy.addEventListener("click", function () { copyText(cliAddPre.textContent, cliAddCopy); });
|
|
@@ -2886,7 +3811,8 @@ const clientJs = `
|
|
|
2886
3811
|
});
|
|
2887
3812
|
|
|
2888
3813
|
restartBtn.addEventListener("click", function () {
|
|
2889
|
-
|
|
3814
|
+
confirmDialog({ title: "Restart the proxy?", message: "Hosts are unavailable for a few seconds.", confirmLabel: "Restart", danger: true }).then(function (ok) {
|
|
3815
|
+
if (!ok) return;
|
|
2890
3816
|
var s = settingsLoaded;
|
|
2891
3817
|
var nextPort = s ? ((s.env && s.env.httpPort) ? s.httpPort : ((s.saved && s.saved.httpPort) || s.httpPort)) : null;
|
|
2892
3818
|
restartBtn.disabled = true;
|
|
@@ -2919,6 +3845,7 @@ const clientJs = `
|
|
|
2919
3845
|
restartBtn.disabled = false;
|
|
2920
3846
|
restartBtn.classList.remove("busy");
|
|
2921
3847
|
});
|
|
3848
|
+
});
|
|
2922
3849
|
});
|
|
2923
3850
|
|
|
2924
3851
|
document.getElementById("set-home-copy").addEventListener("click", function (e) { copyText(setHome.textContent, e.currentTarget); });
|
|
@@ -3649,6 +4576,172 @@ function renderPage(nonce, token, uiAuth = false) {
|
|
|
3649
4576
|
<a href="https://github.com/avinashid/locadot" target="_blank" rel="noopener noreferrer">github.com/avinashid/locadot</a>
|
|
3650
4577
|
</footer>
|
|
3651
4578
|
</div>
|
|
4579
|
+
<dialog id="allow-dialog">
|
|
4580
|
+
<div class="modal-head">
|
|
4581
|
+
<div>
|
|
4582
|
+
<h3 class="modal-title">Internal access</h3>
|
|
4583
|
+
<p id="allow-host" class="modal-sub mono"></p>
|
|
4584
|
+
</div>
|
|
4585
|
+
<button type="button" id="allow-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
4586
|
+
</div>
|
|
4587
|
+
<div class="modal-body">
|
|
4588
|
+
<p class="modal-desc">People who open this mapping through a tunnel or remote access can only reach public hosts. Allow the internal addresses this app calls, such as its API on localhost.</p>
|
|
4589
|
+
<div id="allow-warn" class="modal-warn" hidden>Bypass CORS is off for this mapping. These addresses take effect once it's on, because the page reaches them through locadot's pass-through.</div>
|
|
4590
|
+
<div id="allow-list" class="allow-list"></div>
|
|
4591
|
+
<div class="allow-add-row">
|
|
4592
|
+
<input type="text" id="allow-input" class="input" placeholder="localhost:3000" aria-label="Address" autocomplete="off">
|
|
4593
|
+
<button type="button" id="allow-add" class="btn btn-sm">Add</button>
|
|
4594
|
+
</div>
|
|
4595
|
+
<p class="modal-hint">host:port, or a host alone for any port. locadot's own ports are always blocked.</p>
|
|
4596
|
+
<div id="allow-error" class="field-error" role="alert" hidden></div>
|
|
4597
|
+
</div>
|
|
4598
|
+
<div class="modal-foot">
|
|
4599
|
+
<button type="button" id="allow-cancel" class="btn">Cancel</button>
|
|
4600
|
+
<button type="button" id="allow-save" class="btn btn-primary" disabled>Save changes</button>
|
|
4601
|
+
</div>
|
|
4602
|
+
</dialog>
|
|
4603
|
+
<dialog id="confirm-dialog">
|
|
4604
|
+
<div class="modal-head">
|
|
4605
|
+
<div>
|
|
4606
|
+
<h3 class="modal-title" id="confirm-title"></h3>
|
|
4607
|
+
</div>
|
|
4608
|
+
<button type="button" id="confirm-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
4609
|
+
</div>
|
|
4610
|
+
<div class="modal-body">
|
|
4611
|
+
<p class="modal-desc" id="confirm-message"></p>
|
|
4612
|
+
</div>
|
|
4613
|
+
<div class="modal-foot">
|
|
4614
|
+
<button type="button" id="confirm-cancel" class="btn">Cancel</button>
|
|
4615
|
+
<button type="button" id="confirm-ok" class="btn btn-primary">OK</button>
|
|
4616
|
+
</div>
|
|
4617
|
+
</dialog>
|
|
4618
|
+
<dialog id="prompt-dialog">
|
|
4619
|
+
<div class="modal-head">
|
|
4620
|
+
<div>
|
|
4621
|
+
<h3 class="modal-title" id="prompt-title"></h3>
|
|
4622
|
+
</div>
|
|
4623
|
+
<button type="button" id="prompt-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
4624
|
+
</div>
|
|
4625
|
+
<div class="modal-body">
|
|
4626
|
+
<p class="modal-desc" id="prompt-message"></p>
|
|
4627
|
+
<div class="field">
|
|
4628
|
+
<label id="prompt-label" for="prompt-input"></label>
|
|
4629
|
+
<input type="text" id="prompt-input" class="input" autocomplete="off">
|
|
4630
|
+
</div>
|
|
4631
|
+
<div id="prompt-error" class="field-error" role="alert" hidden></div>
|
|
4632
|
+
</div>
|
|
4633
|
+
<div class="modal-foot">
|
|
4634
|
+
<button type="button" id="prompt-cancel" class="btn">Cancel</button>
|
|
4635
|
+
<button type="button" id="prompt-confirm" class="btn btn-primary">OK</button>
|
|
4636
|
+
</div>
|
|
4637
|
+
</dialog>
|
|
4638
|
+
<dialog id="share-dialog">
|
|
4639
|
+
<div class="modal-head">
|
|
4640
|
+
<div>
|
|
4641
|
+
<h3 class="modal-title">Share publicly</h3>
|
|
4642
|
+
<p id="share-host" class="modal-sub mono"></p>
|
|
4643
|
+
</div>
|
|
4644
|
+
<button type="button" id="share-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
4645
|
+
</div>
|
|
4646
|
+
<div class="modal-body">
|
|
4647
|
+
<p class="modal-desc">Anyone with the link will be able to reach this host, without signing in.</p>
|
|
4648
|
+
<div id="share-options" class="share-options" role="radiogroup" aria-label="Sharing method">
|
|
4649
|
+
<button type="button" id="share-option-random" class="option-card" role="radio" aria-checked="false">
|
|
4650
|
+
<span class="option-dot" aria-hidden="true"></span>
|
|
4651
|
+
<span class="option-body">
|
|
4652
|
+
<span class="option-title">Random URL</span>
|
|
4653
|
+
<span class="option-desc">https://<random>.trycloudflare.com · No account needed. Changes each time sharing restarts.</span>
|
|
4654
|
+
</span>
|
|
4655
|
+
</button>
|
|
4656
|
+
<button type="button" id="share-option-custom" class="option-card" role="radio" aria-checked="false">
|
|
4657
|
+
<span class="option-dot" aria-hidden="true"></span>
|
|
4658
|
+
<span class="option-body">
|
|
4659
|
+
<span class="option-title">Custom domain</span>
|
|
4660
|
+
<span class="option-desc">Your own hostname on a domain in your Cloudflare account. Stable URL.</span>
|
|
4661
|
+
<span id="share-custom-extra" class="option-extra" hidden>
|
|
4662
|
+
<label for="share-domain-input" class="dim">Hostname</label>
|
|
4663
|
+
<input type="text" id="share-domain-input" class="input mono" placeholder="app.example.com" autocomplete="off">
|
|
4664
|
+
<div id="share-domain-error" class="field-error" role="alert" hidden></div>
|
|
4665
|
+
<p id="share-domain-hint" class="modal-hint"></p>
|
|
4666
|
+
</span>
|
|
4667
|
+
</span>
|
|
4668
|
+
</button>
|
|
4669
|
+
</div>
|
|
4670
|
+
<div id="share-progress" class="share-progress" hidden>
|
|
4671
|
+
<div id="share-progress-icon" class="share-progress-icon" aria-hidden="true"></div>
|
|
4672
|
+
<div class="share-progress-body">
|
|
4673
|
+
<p id="share-progress-title" class="share-progress-title"></p>
|
|
4674
|
+
<p id="share-progress-text" class="share-progress-text"></p>
|
|
4675
|
+
<ol id="share-steps" class="share-steps" hidden>
|
|
4676
|
+
<li>Sign in to Cloudflare in the tab that opens.</li>
|
|
4677
|
+
<li>Pick the zone <span id="share-steps-domain" class="mono"></span> is in, then <strong>Authorize</strong>.</li>
|
|
4678
|
+
<li>Come back here. This dialog updates by itself.</li>
|
|
4679
|
+
</ol>
|
|
4680
|
+
<a id="share-auth-link" class="btn btn-primary" target="_blank" rel="noopener noreferrer" hidden>Open Cloudflare ↗</a>
|
|
4681
|
+
</div>
|
|
4682
|
+
</div>
|
|
4683
|
+
<div id="share-error" class="field-error" role="alert" hidden></div>
|
|
4684
|
+
</div>
|
|
4685
|
+
<div class="modal-foot">
|
|
4686
|
+
<button type="button" id="share-cancel" class="btn">Cancel</button>
|
|
4687
|
+
<button type="button" id="share-submit" class="btn btn-primary">Start sharing</button>
|
|
4688
|
+
</div>
|
|
4689
|
+
</dialog>
|
|
4690
|
+
<dialog id="protect-dialog">
|
|
4691
|
+
<div class="modal-head">
|
|
4692
|
+
<div>
|
|
4693
|
+
<h3 class="modal-title">Password protection</h3>
|
|
4694
|
+
<p id="protect-host" class="modal-sub mono"></p>
|
|
4695
|
+
</div>
|
|
4696
|
+
<button type="button" id="protect-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
4697
|
+
</div>
|
|
4698
|
+
<div class="modal-body">
|
|
4699
|
+
<p class="modal-desc">Visitors see a sign-in page and need this password before they reach the app.</p>
|
|
4700
|
+
<div class="field">
|
|
4701
|
+
<label for="protect-password">Password</label>
|
|
4702
|
+
<div class="field-password">
|
|
4703
|
+
<input type="password" id="protect-password" class="input" autocomplete="new-password">
|
|
4704
|
+
<button type="button" id="protect-toggle" class="field-toggle" aria-label="Show password">
|
|
4705
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
4706
|
+
</button>
|
|
4707
|
+
</div>
|
|
4708
|
+
<p id="protect-hint" class="modal-hint" hidden></p>
|
|
4709
|
+
<div id="protect-error" class="field-error" role="alert" hidden></div>
|
|
4710
|
+
</div>
|
|
4711
|
+
<div class="field">
|
|
4712
|
+
<label>Ask for it on</label>
|
|
4713
|
+
<div id="protect-options" class="share-options" role="group" aria-label="Ask for the password on">
|
|
4714
|
+
<button type="button" id="protect-option-shared" class="option-card" role="checkbox" aria-checked="false" data-scope="shared">
|
|
4715
|
+
<span class="option-check" aria-hidden="true"></span>
|
|
4716
|
+
<span class="option-body">
|
|
4717
|
+
<span class="option-title">Shared link</span>
|
|
4718
|
+
<span class="option-desc">Visitors on the public tunnel URL (Share).</span>
|
|
4719
|
+
</span>
|
|
4720
|
+
</button>
|
|
4721
|
+
<button type="button" id="protect-option-remote" class="option-card" role="checkbox" aria-checked="false" data-scope="remote">
|
|
4722
|
+
<span class="option-check" aria-hidden="true"></span>
|
|
4723
|
+
<span class="option-body">
|
|
4724
|
+
<span class="option-title">Remote access</span>
|
|
4725
|
+
<span class="option-desc">Machines connected to this one through remote access.</span>
|
|
4726
|
+
</span>
|
|
4727
|
+
</button>
|
|
4728
|
+
<button type="button" id="protect-option-local" class="option-card" role="checkbox" aria-checked="false" data-scope="local">
|
|
4729
|
+
<span class="option-check" aria-hidden="true"></span>
|
|
4730
|
+
<span class="option-body">
|
|
4731
|
+
<span class="option-title">This machine & network</span>
|
|
4732
|
+
<span class="option-desc">Browsers on this computer or your local network.</span>
|
|
4733
|
+
</span>
|
|
4734
|
+
</button>
|
|
4735
|
+
</div>
|
|
4736
|
+
<div id="protect-scope-error" class="field-error" role="alert" hidden></div>
|
|
4737
|
+
</div>
|
|
4738
|
+
</div>
|
|
4739
|
+
<div class="modal-foot">
|
|
4740
|
+
<button type="button" id="protect-remove" class="btn btn-danger" hidden>Remove protection</button>
|
|
4741
|
+
<button type="button" id="protect-cancel" class="btn">Cancel</button>
|
|
4742
|
+
<button type="button" id="protect-submit" class="btn btn-primary">Protect host</button>
|
|
4743
|
+
</div>
|
|
4744
|
+
</dialog>
|
|
3652
4745
|
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
|
3653
4746
|
<script nonce="${safeNonce}">${clientJs}</script>
|
|
3654
4747
|
</body>
|