@yezack/dsh-remote-mobile 1.9.1 → 1.10.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 +76 -2
- package/README.md +113 -34
- package/README_EN.md +119 -40
- package/cordis.patch.yml +1 -1
- package/lib/auth/crypto.js +3 -1
- package/lib/auth/gate.d.ts +39 -9
- package/lib/auth/gate.js +231 -33
- package/lib/auth/tailscale.d.ts +24 -2
- package/lib/auth/tailscale.js +38 -3
- package/lib/auth/token.d.ts +22 -0
- package/lib/auth/token.js +58 -2
- package/lib/bridge/compat.d.ts +22 -2
- package/lib/bridge/compat.js +579 -336
- package/lib/client.js +68 -15
- package/lib/gateway/detector.d.ts +42 -0
- package/lib/gateway/detector.js +88 -0
- package/lib/gateway/secret.d.ts +23 -0
- package/lib/gateway/secret.js +65 -0
- package/lib/gateway/server.d.ts +108 -0
- package/lib/gateway/server.js +568 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +143 -15
- package/lib/routes/api.js +16 -10
- package/lib/routes/login-page.js +399 -398
- package/lib/styles/style-snippets.js +639 -639
- package/package.json +3 -3
package/lib/bridge/compat.js
CHANGED
|
@@ -30,29 +30,33 @@
|
|
|
30
30
|
* - 判定基于 Cordis 公开运行时结构(服务占用 + fiber 运行态),不针对特定插件;
|
|
31
31
|
* - 保证与其他远程接入类插件共存时必定可以正常启动(不再触发整树回滚致命错误)。
|
|
32
32
|
*/
|
|
33
|
+
import net from 'node:net';
|
|
33
34
|
import { timingSafeEqual } from 'node:crypto';
|
|
34
|
-
import
|
|
35
|
-
import {
|
|
35
|
+
import zlib from 'node:zlib';
|
|
36
|
+
import { getPhysicalSocketIp, getClientIp, isTailscaleIp, isLanIp, getAllNetworkIps } from '../auth/tailscale.js';
|
|
37
|
+
import { getGatewaySecret, safeCompareSecret } from '../gateway/secret.js';
|
|
38
|
+
import { extractBareHostname, extractPortString, isLoopbackRequest, isTrustedHost, hasCrossSiteSignal, getRequestProtocol, } from '../auth/gate.js';
|
|
39
|
+
export { extractBareHostname, extractPortString };
|
|
36
40
|
import { isMobileUserAgent, applyDataMobileAttr, buildMobileStyleTag, } from '../styles/style-snippets.js';
|
|
37
41
|
/**
|
|
38
42
|
* 注入到 index.html <head> 最前列的 Polyfill 脚本代码片段
|
|
39
43
|
* 针对非安全上下文(HTTP + 外部 IP)提供轻量高精度的 UUID v4 生成能力
|
|
40
44
|
*/
|
|
41
|
-
export const CRYPTO_POLYFILL_SNIPPET = `
|
|
42
|
-
(function() {
|
|
43
|
-
if (typeof window !== "undefined") {
|
|
44
|
-
if (!window.crypto) window.crypto = {};
|
|
45
|
-
if (typeof window.crypto.randomUUID !== "function") {
|
|
46
|
-
window.crypto.randomUUID = function() {
|
|
47
|
-
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
48
|
-
var r = Math.random() * 16 | 0;
|
|
49
|
-
var v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
50
|
-
return v.toString(16);
|
|
51
|
-
});
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
})();
|
|
45
|
+
export const CRYPTO_POLYFILL_SNIPPET = `
|
|
46
|
+
(function() {
|
|
47
|
+
if (typeof window !== "undefined") {
|
|
48
|
+
if (!window.crypto) window.crypto = {};
|
|
49
|
+
if (typeof window.crypto.randomUUID !== "function") {
|
|
50
|
+
window.crypto.randomUUID = function() {
|
|
51
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
52
|
+
var r = Math.random() * 16 | 0;
|
|
53
|
+
var v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
54
|
+
return v.toString(16);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
56
60
|
`;
|
|
57
61
|
/**
|
|
58
62
|
* 注入到 index.html <head> 最前列的 Clipboard API Polyfill 脚本
|
|
@@ -62,192 +66,192 @@ export const CRYPTO_POLYFILL_SNIPPET = `
|
|
|
62
66
|
* "Cannot read properties of undefined (reading 'writeText')" 崩溃。
|
|
63
67
|
* 原生可用时不做任何替换。
|
|
64
68
|
*/
|
|
65
|
-
export const CLIPBOARD_POLYFILL_SNIPPET = `
|
|
66
|
-
(function() {
|
|
67
|
-
if (typeof navigator === "undefined") return;
|
|
68
|
-
try {
|
|
69
|
-
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") return;
|
|
70
|
-
var writeText = function(text) {
|
|
71
|
-
try {
|
|
72
|
-
var ta = document.createElement('textarea');
|
|
73
|
-
ta.value = String(text == null ? '' : text);
|
|
74
|
-
ta.setAttribute('readonly', '');
|
|
75
|
-
ta.style.position = 'fixed';
|
|
76
|
-
ta.style.top = '-9999px';
|
|
77
|
-
ta.style.left = '-9999px';
|
|
78
|
-
document.body.appendChild(ta);
|
|
79
|
-
ta.select();
|
|
80
|
-
var ok = document.execCommand('copy');
|
|
81
|
-
document.body.removeChild(ta);
|
|
82
|
-
return ok ? Promise.resolve() : Promise.reject(new Error('copy failed'));
|
|
83
|
-
} catch (err) {
|
|
84
|
-
return Promise.reject(err);
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
var readText = function() {
|
|
88
|
-
return Promise.reject(new Error('clipboard readText is not supported in insecure context'));
|
|
89
|
-
};
|
|
90
|
-
try {
|
|
91
|
-
if (!navigator.clipboard) navigator.clipboard = {};
|
|
92
|
-
if (typeof navigator.clipboard.writeText !== "function") navigator.clipboard.writeText = writeText;
|
|
93
|
-
if (typeof navigator.clipboard.readText !== "function") navigator.clipboard.readText = readText;
|
|
94
|
-
} catch (err) {
|
|
95
|
-
try {
|
|
96
|
-
Object.defineProperty(navigator, 'clipboard', {
|
|
97
|
-
value: { writeText: writeText, readText: readText },
|
|
98
|
-
configurable: true,
|
|
99
|
-
});
|
|
100
|
-
} catch (err2) {}
|
|
101
|
-
}
|
|
102
|
-
} catch (err) {}
|
|
103
|
-
})();
|
|
69
|
+
export const CLIPBOARD_POLYFILL_SNIPPET = `
|
|
70
|
+
(function() {
|
|
71
|
+
if (typeof navigator === "undefined") return;
|
|
72
|
+
try {
|
|
73
|
+
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") return;
|
|
74
|
+
var writeText = function(text) {
|
|
75
|
+
try {
|
|
76
|
+
var ta = document.createElement('textarea');
|
|
77
|
+
ta.value = String(text == null ? '' : text);
|
|
78
|
+
ta.setAttribute('readonly', '');
|
|
79
|
+
ta.style.position = 'fixed';
|
|
80
|
+
ta.style.top = '-9999px';
|
|
81
|
+
ta.style.left = '-9999px';
|
|
82
|
+
document.body.appendChild(ta);
|
|
83
|
+
ta.select();
|
|
84
|
+
var ok = document.execCommand('copy');
|
|
85
|
+
document.body.removeChild(ta);
|
|
86
|
+
return ok ? Promise.resolve() : Promise.reject(new Error('copy failed'));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return Promise.reject(err);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var readText = function() {
|
|
92
|
+
return Promise.reject(new Error('clipboard readText is not supported in insecure context'));
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
if (!navigator.clipboard) navigator.clipboard = {};
|
|
96
|
+
if (typeof navigator.clipboard.writeText !== "function") navigator.clipboard.writeText = writeText;
|
|
97
|
+
if (typeof navigator.clipboard.readText !== "function") navigator.clipboard.readText = readText;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
try {
|
|
100
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
101
|
+
value: { writeText: writeText, readText: readText },
|
|
102
|
+
configurable: true,
|
|
103
|
+
});
|
|
104
|
+
} catch (err2) {}
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {}
|
|
107
|
+
})();
|
|
104
108
|
`;
|
|
105
109
|
/**
|
|
106
110
|
* 注入到 index.html <head> 最前列的生态插件 remote-web-ui 移动端冲突防护脚本
|
|
107
111
|
* 写入 sessionStorage 'dsh-remote-force-desktop' = '1',触发对方内置的强制桌面跳过机制,
|
|
108
112
|
* 避免生态插件重复渲染小鲸鱼按钮 (#dshRemoteWhale) 以及引发手势拦截冲突
|
|
109
113
|
*/
|
|
110
|
-
export const FORCE_DESKTOP_SUPPRESSION_SNIPPET = `
|
|
111
|
-
(function() {
|
|
112
|
-
try {
|
|
113
|
-
if (typeof window !== "undefined" && window.sessionStorage) {
|
|
114
|
-
window.sessionStorage.setItem('dsh-remote-force-desktop', '1');
|
|
115
|
-
}
|
|
116
|
-
} catch (e) {}
|
|
117
|
-
})();
|
|
114
|
+
export const FORCE_DESKTOP_SUPPRESSION_SNIPPET = `
|
|
115
|
+
(function() {
|
|
116
|
+
try {
|
|
117
|
+
if (typeof window !== "undefined" && window.sessionStorage) {
|
|
118
|
+
window.sessionStorage.setItem('dsh-remote-force-desktop', '1');
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {}
|
|
121
|
+
})();
|
|
118
122
|
`;
|
|
119
123
|
/**
|
|
120
124
|
* 注入到 index.html 的移动端悬浮入口拖拽与位置记忆脚本
|
|
121
125
|
* 允许用户在移动端上下随心拖动左上角入口把手,避免遮挡聊天内容,并自动保存位置
|
|
122
126
|
*/
|
|
123
|
-
export const DRAGGABLE_NAV_SNIPPET = `
|
|
124
|
-
(function() {
|
|
125
|
-
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
126
|
-
|
|
127
|
-
/* ===== 防误收起守卫 =====
|
|
128
|
-
* 第三方生态插件(如 @linxin666/dsh-web-ui-all 的移动端抽屉增强)会在用户点击
|
|
129
|
-
* 侧边栏条目(role=treeitem)后,于下一帧程序化点击「收起侧边栏」按钮自动收回抽屉。
|
|
130
|
-
* 该判定无法区分「选中条目回主区」与「点击条目内的次级操作图标」(更多操作 ⋯ /
|
|
131
|
-
* 展开箭头等),导致后者抽屉被误关。
|
|
132
|
-
* 这里记录最近一次对条目内任意部位(项目名称主体 / 次级按钮 / 箭头图标)的触摸时间,
|
|
133
|
-
* 并包装 HTMLButtonElement.prototype.click:短时间窗口内的程序化收起调用直接忽略。
|
|
134
|
-
* 覆盖条目主体是为了让选中高亮后浮现的展开/收起箭头与新增/更多操作图标有足够的
|
|
135
|
-
* 停留操作时间;用户真实点击收起按钮(浏览器合成事件不走 prototype.click)与
|
|
136
|
-
* 点击抽屉外暗处收起的官方行为均不受影响。 */
|
|
137
|
-
if (!window.__dshRmAntiCollapseInstalled__) {
|
|
138
|
-
window.__dshRmAntiCollapseInstalled__ = true;
|
|
139
|
-
var lastEntryTapAt = 0;
|
|
140
|
-
document.addEventListener('pointerdown', function (e) {
|
|
141
|
-
try {
|
|
142
|
-
var t = e.target;
|
|
143
|
-
if (!t || !t.closest) return;
|
|
144
|
-
if (!t.closest('[data-pane="sidebar"], [class*="_sidebarCol"]')) return;
|
|
145
|
-
|
|
146
|
-
// 会话条目(_sessionRow)除操作按钮(_rowActions/button等)以外的主体点击:
|
|
147
|
-
// 用户意在进入/切换会话并查看正文,不拦截程序化收起(放行自动回主区)
|
|
148
|
-
var isSessionRow = t.closest('[class*="_sessionRow"]');
|
|
149
|
-
var isActionOrToggle = t.closest('[class*="_rowActions"], [class*="_chevron"], [class*="_arrow"], button, [role="button"]');
|
|
150
|
-
if (isSessionRow && !isActionOrToggle) {
|
|
151
|
-
lastEntryTapAt = 0;
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
var entry = t.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]');
|
|
156
|
-
if (!entry) return;
|
|
157
|
-
lastEntryTapAt = Date.now();
|
|
158
|
-
} catch (err) {}
|
|
159
|
-
}, { capture: true, passive: true });
|
|
160
|
-
|
|
161
|
-
var protoClick = HTMLButtonElement.prototype.click;
|
|
162
|
-
HTMLButtonElement.prototype.click = function () {
|
|
163
|
-
try {
|
|
164
|
-
var isSidebarToggle =
|
|
165
|
-
this.matches('[data-dsh-responsive-part="sidebar-toggle"]') ||
|
|
166
|
-
/收起侧边栏|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');
|
|
167
|
-
if (isSidebarToggle && Date.now() - lastEntryTapAt < 900) {
|
|
168
|
-
return; // 吞掉紧随工作区/操作控件点击之后的程序化收起
|
|
169
|
-
}
|
|
170
|
-
} catch (err) {}
|
|
171
|
-
return protoClick.apply(this, arguments);
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function initDraggableNav() {
|
|
176
|
-
var logoRow = document.querySelector('[data-pane="sidebar"] [class*="_logoRow"], [class*="_sidebarCol"] [class*="_logoRow"]');
|
|
177
|
-
if (!logoRow || logoRow.__dsh_drag_init__) return;
|
|
178
|
-
logoRow.__dsh_drag_init__ = true;
|
|
179
|
-
|
|
180
|
-
var savedTop = localStorage.getItem('dsh_mobile_nav_top');
|
|
181
|
-
if (savedTop && !isNaN(Number(savedTop))) {
|
|
182
|
-
var topVal = Math.max(10, Math.min(window.innerHeight - 60, Number(savedTop)));
|
|
183
|
-
logoRow.style.setProperty('top', topVal + 'px', 'important');
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
var startY = 0;
|
|
187
|
-
var initialTop = 0;
|
|
188
|
-
var isDragging = false;
|
|
189
|
-
var hasMoved = false;
|
|
190
|
-
|
|
191
|
-
function onTouchStart(e) {
|
|
192
|
-
var sidebar = document.querySelector('[data-pane="sidebar"], [class*="_sidebarCol"]');
|
|
193
|
-
if (sidebar && !sidebar.querySelector('[class*="_collapsed"]')) return;
|
|
194
|
-
|
|
195
|
-
var touch = e.touches ? e.touches[0] : e;
|
|
196
|
-
startY = touch.clientY;
|
|
197
|
-
var rect = logoRow.getBoundingClientRect();
|
|
198
|
-
initialTop = rect.top;
|
|
199
|
-
isDragging = true;
|
|
200
|
-
hasMoved = false;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function onTouchMove(e) {
|
|
204
|
-
if (!isDragging) return;
|
|
205
|
-
var touch = e.touches ? e.touches[0] : e;
|
|
206
|
-
var deltaY = touch.clientY - startY;
|
|
207
|
-
|
|
208
|
-
if (!hasMoved && Math.abs(deltaY) > 5) {
|
|
209
|
-
hasMoved = true;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (hasMoved) {
|
|
213
|
-
if (e.cancelable) e.preventDefault();
|
|
214
|
-
var newTop = initialTop + deltaY;
|
|
215
|
-
var minTop = 10;
|
|
216
|
-
var maxTop = window.innerHeight - 56;
|
|
217
|
-
newTop = Math.max(minTop, Math.min(maxTop, newTop));
|
|
218
|
-
logoRow.style.setProperty('top', newTop + 'px', 'important');
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function onTouchEnd(e) {
|
|
223
|
-
if (!isDragging) return;
|
|
224
|
-
isDragging = false;
|
|
225
|
-
if (hasMoved) {
|
|
226
|
-
e.preventDefault();
|
|
227
|
-
e.stopPropagation();
|
|
228
|
-
var currentRect = logoRow.getBoundingClientRect();
|
|
229
|
-
try {
|
|
230
|
-
localStorage.setItem('dsh_mobile_nav_top', String(Math.round(currentRect.top)));
|
|
231
|
-
} catch (err) {}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
logoRow.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
236
|
-
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
237
|
-
window.addEventListener('touchend', onTouchEnd, { capture: true });
|
|
238
|
-
|
|
239
|
-
logoRow.addEventListener('mousedown', onTouchStart);
|
|
240
|
-
window.addEventListener('mousemove', onTouchMove);
|
|
241
|
-
window.addEventListener('mouseup', onTouchEnd, { capture: true });
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (document.readyState === 'loading') {
|
|
245
|
-
document.addEventListener('DOMContentLoaded', initDraggableNav);
|
|
246
|
-
} else {
|
|
247
|
-
initDraggableNav();
|
|
248
|
-
}
|
|
249
|
-
setInterval(initDraggableNav, 800);
|
|
250
|
-
})();
|
|
127
|
+
export const DRAGGABLE_NAV_SNIPPET = `
|
|
128
|
+
(function() {
|
|
129
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
130
|
+
|
|
131
|
+
/* ===== 防误收起守卫 =====
|
|
132
|
+
* 第三方生态插件(如 @linxin666/dsh-web-ui-all 的移动端抽屉增强)会在用户点击
|
|
133
|
+
* 侧边栏条目(role=treeitem)后,于下一帧程序化点击「收起侧边栏」按钮自动收回抽屉。
|
|
134
|
+
* 该判定无法区分「选中条目回主区」与「点击条目内的次级操作图标」(更多操作 ⋯ /
|
|
135
|
+
* 展开箭头等),导致后者抽屉被误关。
|
|
136
|
+
* 这里记录最近一次对条目内任意部位(项目名称主体 / 次级按钮 / 箭头图标)的触摸时间,
|
|
137
|
+
* 并包装 HTMLButtonElement.prototype.click:短时间窗口内的程序化收起调用直接忽略。
|
|
138
|
+
* 覆盖条目主体是为了让选中高亮后浮现的展开/收起箭头与新增/更多操作图标有足够的
|
|
139
|
+
* 停留操作时间;用户真实点击收起按钮(浏览器合成事件不走 prototype.click)与
|
|
140
|
+
* 点击抽屉外暗处收起的官方行为均不受影响。 */
|
|
141
|
+
if (!window.__dshRmAntiCollapseInstalled__) {
|
|
142
|
+
window.__dshRmAntiCollapseInstalled__ = true;
|
|
143
|
+
var lastEntryTapAt = 0;
|
|
144
|
+
document.addEventListener('pointerdown', function (e) {
|
|
145
|
+
try {
|
|
146
|
+
var t = e.target;
|
|
147
|
+
if (!t || !t.closest) return;
|
|
148
|
+
if (!t.closest('[data-pane="sidebar"], [class*="_sidebarCol"]')) return;
|
|
149
|
+
|
|
150
|
+
// 会话条目(_sessionRow)除操作按钮(_rowActions/button等)以外的主体点击:
|
|
151
|
+
// 用户意在进入/切换会话并查看正文,不拦截程序化收起(放行自动回主区)
|
|
152
|
+
var isSessionRow = t.closest('[class*="_sessionRow"]');
|
|
153
|
+
var isActionOrToggle = t.closest('[class*="_rowActions"], [class*="_chevron"], [class*="_arrow"], button, [role="button"]');
|
|
154
|
+
if (isSessionRow && !isActionOrToggle) {
|
|
155
|
+
lastEntryTapAt = 0;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
var entry = t.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]');
|
|
160
|
+
if (!entry) return;
|
|
161
|
+
lastEntryTapAt = Date.now();
|
|
162
|
+
} catch (err) {}
|
|
163
|
+
}, { capture: true, passive: true });
|
|
164
|
+
|
|
165
|
+
var protoClick = HTMLButtonElement.prototype.click;
|
|
166
|
+
HTMLButtonElement.prototype.click = function () {
|
|
167
|
+
try {
|
|
168
|
+
var isSidebarToggle =
|
|
169
|
+
this.matches('[data-dsh-responsive-part="sidebar-toggle"]') ||
|
|
170
|
+
/收起侧边栏|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');
|
|
171
|
+
if (isSidebarToggle && Date.now() - lastEntryTapAt < 900) {
|
|
172
|
+
return; // 吞掉紧随工作区/操作控件点击之后的程序化收起
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {}
|
|
175
|
+
return protoClick.apply(this, arguments);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function initDraggableNav() {
|
|
180
|
+
var logoRow = document.querySelector('[data-pane="sidebar"] [class*="_logoRow"], [class*="_sidebarCol"] [class*="_logoRow"]');
|
|
181
|
+
if (!logoRow || logoRow.__dsh_drag_init__) return;
|
|
182
|
+
logoRow.__dsh_drag_init__ = true;
|
|
183
|
+
|
|
184
|
+
var savedTop = localStorage.getItem('dsh_mobile_nav_top');
|
|
185
|
+
if (savedTop && !isNaN(Number(savedTop))) {
|
|
186
|
+
var topVal = Math.max(10, Math.min(window.innerHeight - 60, Number(savedTop)));
|
|
187
|
+
logoRow.style.setProperty('top', topVal + 'px', 'important');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
var startY = 0;
|
|
191
|
+
var initialTop = 0;
|
|
192
|
+
var isDragging = false;
|
|
193
|
+
var hasMoved = false;
|
|
194
|
+
|
|
195
|
+
function onTouchStart(e) {
|
|
196
|
+
var sidebar = document.querySelector('[data-pane="sidebar"], [class*="_sidebarCol"]');
|
|
197
|
+
if (sidebar && !sidebar.querySelector('[class*="_collapsed"]')) return;
|
|
198
|
+
|
|
199
|
+
var touch = e.touches ? e.touches[0] : e;
|
|
200
|
+
startY = touch.clientY;
|
|
201
|
+
var rect = logoRow.getBoundingClientRect();
|
|
202
|
+
initialTop = rect.top;
|
|
203
|
+
isDragging = true;
|
|
204
|
+
hasMoved = false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function onTouchMove(e) {
|
|
208
|
+
if (!isDragging) return;
|
|
209
|
+
var touch = e.touches ? e.touches[0] : e;
|
|
210
|
+
var deltaY = touch.clientY - startY;
|
|
211
|
+
|
|
212
|
+
if (!hasMoved && Math.abs(deltaY) > 5) {
|
|
213
|
+
hasMoved = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (hasMoved) {
|
|
217
|
+
if (e.cancelable) e.preventDefault();
|
|
218
|
+
var newTop = initialTop + deltaY;
|
|
219
|
+
var minTop = 10;
|
|
220
|
+
var maxTop = window.innerHeight - 56;
|
|
221
|
+
newTop = Math.max(minTop, Math.min(maxTop, newTop));
|
|
222
|
+
logoRow.style.setProperty('top', newTop + 'px', 'important');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onTouchEnd(e) {
|
|
227
|
+
if (!isDragging) return;
|
|
228
|
+
isDragging = false;
|
|
229
|
+
if (hasMoved) {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
e.stopPropagation();
|
|
232
|
+
var currentRect = logoRow.getBoundingClientRect();
|
|
233
|
+
try {
|
|
234
|
+
localStorage.setItem('dsh_mobile_nav_top', String(Math.round(currentRect.top)));
|
|
235
|
+
} catch (err) {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
logoRow.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
240
|
+
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
241
|
+
window.addEventListener('touchend', onTouchEnd, { capture: true });
|
|
242
|
+
|
|
243
|
+
logoRow.addEventListener('mousedown', onTouchStart);
|
|
244
|
+
window.addEventListener('mousemove', onTouchMove);
|
|
245
|
+
window.addEventListener('mouseup', onTouchEnd, { capture: true });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (document.readyState === 'loading') {
|
|
249
|
+
document.addEventListener('DOMContentLoaded', initDraggableNav);
|
|
250
|
+
} else {
|
|
251
|
+
initDraggableNav();
|
|
252
|
+
}
|
|
253
|
+
setInterval(initDraggableNav, 800);
|
|
254
|
+
})();
|
|
251
255
|
`;
|
|
252
256
|
/**
|
|
253
257
|
* 注入到 index.html 的移动端抽屉「点外部收起」脚本
|
|
@@ -260,83 +264,124 @@ export const DRAGGABLE_NAV_SNIPPET = `
|
|
|
260
264
|
* 后缀稳定),不依赖 web-ui 注入的 data-pane / data-dsh-frame 属性,因此装/不装 web-ui
|
|
261
265
|
* 两种环境行为一致。折叠状态沿用官方写入的 data-sidebar-collapsed 属性。
|
|
262
266
|
*/
|
|
263
|
-
export const MOBILE_DISMISS_SNIPPET = `
|
|
264
|
-
(function() {
|
|
265
|
-
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
266
|
-
|
|
267
|
-
var frameSelector = '[class*="_frame"]';
|
|
268
|
-
var sidebarSelector = '[class*="_sidebarCol"]';
|
|
269
|
-
|
|
270
|
-
function webUiOwnsDrawer() {
|
|
271
|
-
return !!document.querySelector('style[data-dsh-compat="responsive"]');
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function findToggle(frame) {
|
|
275
|
-
return frame.querySelector('[class*="_toggle"]') ||
|
|
276
|
-
Array.prototype.filter.call(frame.querySelectorAll('button'), function (b) {
|
|
277
|
-
return /收起侧边栏|Collapse sidebar|Open sidebar/i.test(b.getAttribute('aria-label') || '');
|
|
278
|
-
})[0] || null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function install(frame) {
|
|
282
|
-
if (frame.__dshRmDismissInstalled__) return;
|
|
283
|
-
frame.__dshRmDismissInstalled__ = true;
|
|
284
|
-
var raf = 0;
|
|
285
|
-
|
|
286
|
-
frame.addEventListener('click', function (event) {
|
|
287
|
-
try {
|
|
288
|
-
// web-ui 可能运行中才注入 responsive style:每次事件都复查,动态让位
|
|
289
|
-
if (webUiOwnsDrawer()) return;
|
|
290
|
-
var target = event.target;
|
|
291
|
-
if (!target || !(target instanceof Element)) return;
|
|
292
|
-
if (typeof window.matchMedia !== 'function' || !window.matchMedia('(max-width: 900px)').matches) return;
|
|
293
|
-
var sidebar = frame.querySelector(sidebarSelector);
|
|
294
|
-
var toggle = findToggle(frame);
|
|
295
|
-
if (!sidebar || !toggle) return;
|
|
296
|
-
|
|
297
|
-
// 抽屉展开时点击正文(sidebar 外)→ 触发官方收起按钮,并拦截被吞掉的点击;
|
|
298
|
-
// 找不到收起按钮时上面已 return,绝不误拦正文交互;
|
|
299
|
-
// 增加对 sidebar 内部 _collapsed 的状态校验,双重保证仅在侧边栏真正展开时才拦截正文点击
|
|
300
|
-
var isSidebarOpen = !frame.hasAttribute('data-sidebar-collapsed') && !sidebar.querySelector('[class*="_collapsed"]');
|
|
301
|
-
if (isSidebarOpen && !sidebar.contains(target)) {
|
|
302
|
-
event.preventDefault();
|
|
303
|
-
event.stopPropagation();
|
|
304
|
-
toggle.click();
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
// 点击侧边栏内次级操作图标(更多操作 ⋯ / 展开箭头等)、工作区行(_projectRow)或原生切换按钮 → 不收起抽屉
|
|
308
|
-
if (target.closest('[class*="_toggle"], [class*="_rowActions"], [class*="_projectRow"], [class*="_chevron"], [class*="_arrow"], button, [role="button"]')) return;
|
|
309
|
-
// 点击会话条目(_sessionRow)主体(选中会话后回主区)→ 下一帧程序化收起
|
|
310
|
-
if (!target.closest('[class*="_sessionRow"], [role="treeitem"], [data-dsh-part="sidebar-entry"]')) return;
|
|
311
|
-
if (raf !== 0) cancelAnimationFrame(raf);
|
|
312
|
-
raf = requestAnimationFrame(function () {
|
|
313
|
-
raf = 0;
|
|
314
|
-
if (!frame.hasAttribute('data-sidebar-collapsed')) toggle.click();
|
|
315
|
-
});
|
|
316
|
-
} catch (err) {}
|
|
317
|
-
}, true);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function ensure() {
|
|
321
|
-
if (webUiOwnsDrawer()) return;
|
|
322
|
-
var frame = document.querySelector(frameSelector);
|
|
323
|
-
if (frame) install(frame);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (document.readyState === 'loading') {
|
|
327
|
-
document.addEventListener('DOMContentLoaded', ensure);
|
|
328
|
-
} else {
|
|
329
|
-
ensure();
|
|
330
|
-
}
|
|
331
|
-
setInterval(ensure, 800);
|
|
332
|
-
})();
|
|
267
|
+
export const MOBILE_DISMISS_SNIPPET = `
|
|
268
|
+
(function() {
|
|
269
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
270
|
+
|
|
271
|
+
var frameSelector = '[class*="_frame"]';
|
|
272
|
+
var sidebarSelector = '[class*="_sidebarCol"]';
|
|
273
|
+
|
|
274
|
+
function webUiOwnsDrawer() {
|
|
275
|
+
return !!document.querySelector('style[data-dsh-compat="responsive"]');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function findToggle(frame) {
|
|
279
|
+
return frame.querySelector('[class*="_toggle"]') ||
|
|
280
|
+
Array.prototype.filter.call(frame.querySelectorAll('button'), function (b) {
|
|
281
|
+
return /收起侧边栏|Collapse sidebar|Open sidebar/i.test(b.getAttribute('aria-label') || '');
|
|
282
|
+
})[0] || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function install(frame) {
|
|
286
|
+
if (frame.__dshRmDismissInstalled__) return;
|
|
287
|
+
frame.__dshRmDismissInstalled__ = true;
|
|
288
|
+
var raf = 0;
|
|
289
|
+
|
|
290
|
+
frame.addEventListener('click', function (event) {
|
|
291
|
+
try {
|
|
292
|
+
// web-ui 可能运行中才注入 responsive style:每次事件都复查,动态让位
|
|
293
|
+
if (webUiOwnsDrawer()) return;
|
|
294
|
+
var target = event.target;
|
|
295
|
+
if (!target || !(target instanceof Element)) return;
|
|
296
|
+
if (typeof window.matchMedia !== 'function' || !window.matchMedia('(max-width: 900px)').matches) return;
|
|
297
|
+
var sidebar = frame.querySelector(sidebarSelector);
|
|
298
|
+
var toggle = findToggle(frame);
|
|
299
|
+
if (!sidebar || !toggle) return;
|
|
300
|
+
|
|
301
|
+
// 抽屉展开时点击正文(sidebar 外)→ 触发官方收起按钮,并拦截被吞掉的点击;
|
|
302
|
+
// 找不到收起按钮时上面已 return,绝不误拦正文交互;
|
|
303
|
+
// 增加对 sidebar 内部 _collapsed 的状态校验,双重保证仅在侧边栏真正展开时才拦截正文点击
|
|
304
|
+
var isSidebarOpen = !frame.hasAttribute('data-sidebar-collapsed') && !sidebar.querySelector('[class*="_collapsed"]');
|
|
305
|
+
if (isSidebarOpen && !sidebar.contains(target)) {
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
event.stopPropagation();
|
|
308
|
+
toggle.click();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// 点击侧边栏内次级操作图标(更多操作 ⋯ / 展开箭头等)、工作区行(_projectRow)或原生切换按钮 → 不收起抽屉
|
|
312
|
+
if (target.closest('[class*="_toggle"], [class*="_rowActions"], [class*="_projectRow"], [class*="_chevron"], [class*="_arrow"], button, [role="button"]')) return;
|
|
313
|
+
// 点击会话条目(_sessionRow)主体(选中会话后回主区)→ 下一帧程序化收起
|
|
314
|
+
if (!target.closest('[class*="_sessionRow"], [role="treeitem"], [data-dsh-part="sidebar-entry"]')) return;
|
|
315
|
+
if (raf !== 0) cancelAnimationFrame(raf);
|
|
316
|
+
raf = requestAnimationFrame(function () {
|
|
317
|
+
raf = 0;
|
|
318
|
+
if (!frame.hasAttribute('data-sidebar-collapsed')) toggle.click();
|
|
319
|
+
});
|
|
320
|
+
} catch (err) {}
|
|
321
|
+
}, true);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ensure() {
|
|
325
|
+
if (webUiOwnsDrawer()) return;
|
|
326
|
+
var frame = document.querySelector(frameSelector);
|
|
327
|
+
if (frame) install(frame);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (document.readyState === 'loading') {
|
|
331
|
+
document.addEventListener('DOMContentLoaded', ensure);
|
|
332
|
+
} else {
|
|
333
|
+
ensure();
|
|
334
|
+
}
|
|
335
|
+
setInterval(ensure, 800);
|
|
336
|
+
})();
|
|
333
337
|
`;
|
|
338
|
+
/**
|
|
339
|
+
* 向 HTML 文档的 <head> 顶部安全注入样式片段与 Polyfill 脚本(自带幂等防重守卫)
|
|
340
|
+
*
|
|
341
|
+
* @param html - 待注入的原始 HTML 字符串
|
|
342
|
+
* @param isMobile - 是否为移动端(用于追加 data-dsh-mobile 标记)
|
|
343
|
+
* @param mobileStyleCss - 动态收集的样式片段 CSS(支持按视口宽度生效)
|
|
344
|
+
*/
|
|
345
|
+
export function injectHtmlPayload(html, isMobile = false, mobileStyleCss = '') {
|
|
346
|
+
if (!html || !html.includes('<head>'))
|
|
347
|
+
return html;
|
|
348
|
+
let result = html;
|
|
349
|
+
if (isMobile) {
|
|
350
|
+
result = applyDataMobileAttr(result);
|
|
351
|
+
}
|
|
352
|
+
// 1. 样式片段覆写(按视口宽度生效,优先且幂等)
|
|
353
|
+
if (mobileStyleCss && !result.includes('id="dsh-remote-mobile-style-snippets"')) {
|
|
354
|
+
result = result.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
|
|
355
|
+
}
|
|
356
|
+
// 2. 悬浮把手拖拽脚本(全端注入,内部自带仅折叠时生效守卫)
|
|
357
|
+
if (!result.includes('id="dsh-draggable-nav"')) {
|
|
358
|
+
result = result.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
|
|
359
|
+
}
|
|
360
|
+
// 3. 移动端抽屉「点外部收起」脚本
|
|
361
|
+
if (!result.includes('id="dsh-mobile-dismiss"')) {
|
|
362
|
+
result = result.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
|
|
363
|
+
}
|
|
364
|
+
// 4. 非安全上下文 Crypto Polyfill 脚本
|
|
365
|
+
if (!result.includes('id="dsh-crypto-polyfill"')) {
|
|
366
|
+
result = result.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
|
|
367
|
+
}
|
|
368
|
+
// 5. 非安全上下文 Clipboard Polyfill 脚本
|
|
369
|
+
if (!result.includes('id="dsh-clipboard-polyfill"')) {
|
|
370
|
+
result = result.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
|
|
371
|
+
}
|
|
372
|
+
// 6. 桌面端全家桶冲突防护脚本
|
|
373
|
+
if (!result.includes('id="dsh-force-desktop-suppression"')) {
|
|
374
|
+
result = result.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
334
378
|
/**
|
|
335
379
|
* 挂载底层 http.Server 门禁劫持与回环上下文虚拟化代理
|
|
336
380
|
*
|
|
337
381
|
* @param server - 底层 Node.js http.Server 实例
|
|
338
382
|
* @param gateMiddleware - 全局安全门禁拦截器函数
|
|
339
383
|
* @param store - 会话与配置管理器实例
|
|
384
|
+
* @param styleStore - 样式片段管理器实例
|
|
340
385
|
*/
|
|
341
386
|
export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, styleStore) {
|
|
342
387
|
if (!server || server.__dsh_tailscale_gate_patched__)
|
|
@@ -357,7 +402,7 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
|
|
|
357
402
|
if (req.socket && !req.socket.__dsh_real_remote_address__) {
|
|
358
403
|
req.socket.__dsh_real_remote_address__ = req.socket.remoteAddress;
|
|
359
404
|
}
|
|
360
|
-
// 1. 响应层拦截:在主文档 index.html
|
|
405
|
+
// 1. 响应层拦截:在主文档 index.html 响应流中注入样式片段与 Polyfill 脚本
|
|
361
406
|
const originalEnd = res.end;
|
|
362
407
|
let isHtml = false;
|
|
363
408
|
res.writeHead = ((origWriteHead) => {
|
|
@@ -373,100 +418,174 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
|
|
|
373
418
|
res.end = function (chunk, ...args) {
|
|
374
419
|
const currentCt = this.getHeader?.('content-type') || this.getHeader?.('Content-Type') || '';
|
|
375
420
|
const shouldInject = isHtml || String(currentCt).includes('text/html');
|
|
421
|
+
const contentEncoding = String(this.getHeader?.('content-encoding') || this.getHeader?.('Content-Encoding') || '').toLowerCase();
|
|
376
422
|
// 是否实际改写了响应体(注入会改变 body 字节长度)
|
|
377
423
|
let injected = false;
|
|
378
424
|
if (shouldInject && chunk) {
|
|
379
425
|
if (typeof chunk === 'string' && chunk.includes('<head>')) {
|
|
380
|
-
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
html = html.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
|
|
426
|
+
const updated = injectHtmlPayload(chunk, isMobile, mobileStyleCss);
|
|
427
|
+
if (updated !== chunk) {
|
|
428
|
+
chunk = updated;
|
|
429
|
+
injected = true;
|
|
385
430
|
}
|
|
386
|
-
// 悬浮把手拖拽脚本全端注入:脚本自带“仅折叠时工作”守卫,
|
|
387
|
-
// 预设按视口宽度生效后 PC 窄窗口同样需要可拖动的展开把手
|
|
388
|
-
html = html.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
|
|
389
|
-
// 移动端抽屉「点外部收起」全端注入:脚本自带 web-ui 让位守卫,
|
|
390
|
-
// web-ui 在场时完全交给它处理,未装时复刻等价行为
|
|
391
|
-
html = html.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
|
|
392
|
-
html = html.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
|
|
393
|
-
html = html.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
|
|
394
|
-
// 冲突防护脚本后 replace → 位于 <head> 最前,优先写入 sessionStorage 避免小鲸鱼重复挂载
|
|
395
|
-
html = html.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
|
|
396
|
-
chunk = html;
|
|
397
|
-
injected = true;
|
|
398
431
|
}
|
|
399
432
|
else if (Buffer.isBuffer(chunk)) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
433
|
+
// 判定是否经过压缩:Gzip (Magic Number 0x1f 0x8b) 或 Deflate
|
|
434
|
+
const isGzip = contentEncoding.includes('gzip') || (chunk.length >= 2 && chunk[0] === 0x1f && chunk[1] === 0x8b);
|
|
435
|
+
const isDeflate = contentEncoding.includes('deflate');
|
|
436
|
+
if (isGzip) {
|
|
437
|
+
try {
|
|
438
|
+
const decompressed = zlib.gunzipSync(chunk).toString('utf8');
|
|
439
|
+
if (decompressed.includes('<head>')) {
|
|
440
|
+
const updated = injectHtmlPayload(decompressed, isMobile, mobileStyleCss);
|
|
441
|
+
if (updated !== decompressed) {
|
|
442
|
+
chunk = zlib.gzipSync(Buffer.from(updated, 'utf8'));
|
|
443
|
+
injected = true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
catch { }
|
|
448
|
+
}
|
|
449
|
+
else if (isDeflate) {
|
|
450
|
+
try {
|
|
451
|
+
const decompressed = zlib.inflateSync(chunk).toString('utf8');
|
|
452
|
+
if (decompressed.includes('<head>')) {
|
|
453
|
+
const updated = injectHtmlPayload(decompressed, isMobile, mobileStyleCss);
|
|
454
|
+
if (updated !== decompressed) {
|
|
455
|
+
chunk = zlib.deflateSync(Buffer.from(updated, 'utf8'));
|
|
456
|
+
injected = true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch { }
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
// 普通未压缩 Buffer
|
|
464
|
+
const str = chunk.toString('utf8');
|
|
465
|
+
if (str.includes('<head>')) {
|
|
466
|
+
const updated = injectHtmlPayload(str, isMobile, mobileStyleCss);
|
|
467
|
+
if (updated !== str) {
|
|
468
|
+
chunk = Buffer.from(updated, 'utf8');
|
|
469
|
+
injected = true;
|
|
470
|
+
}
|
|
406
471
|
}
|
|
407
|
-
str = str.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
|
|
408
|
-
str = str.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
|
|
409
|
-
str = str.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
|
|
410
|
-
str = str.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
|
|
411
|
-
str = str.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
|
|
412
|
-
chunk = Buffer.from(str, 'utf8');
|
|
413
|
-
injected = true;
|
|
414
472
|
}
|
|
415
473
|
}
|
|
416
474
|
}
|
|
417
|
-
//
|
|
418
|
-
// 让 Node 按最终 body
|
|
475
|
+
// 长度防截断守卫:仅当头部尚未发出时移除显式的 Content-Length,
|
|
476
|
+
// 让 Node 按最终 body 重新计算长度或转为分块传输
|
|
419
477
|
if (injected && !this.headersSent) {
|
|
420
478
|
try {
|
|
421
|
-
if (typeof this.removeHeader === 'function')
|
|
479
|
+
if (typeof this.removeHeader === 'function') {
|
|
422
480
|
this.removeHeader('content-length');
|
|
481
|
+
this.removeHeader('Content-Length');
|
|
482
|
+
}
|
|
423
483
|
}
|
|
424
484
|
catch { }
|
|
425
485
|
}
|
|
426
486
|
return originalEnd.apply(this, [chunk, ...args]);
|
|
427
487
|
};
|
|
488
|
+
// 1.5 在门禁判定前校验受信网关 Secret。
|
|
489
|
+
// 网关转发请求同样来自物理回环,且带有 X-Forwarded-*,不能仅凭 Host/Forwarded
|
|
490
|
+
// 把它误判为同机反向代理;这里用 Secret 打上受信标记,供 isLoopbackRequest 识别。
|
|
491
|
+
try {
|
|
492
|
+
const trustSocketIp = getPhysicalSocketIp(req);
|
|
493
|
+
const trustIsLoopback = trustSocketIp === '127.0.0.1' || trustSocketIp === '::1';
|
|
494
|
+
const trustSecret = getGatewaySecret(server);
|
|
495
|
+
if (trustIsLoopback &&
|
|
496
|
+
trustSecret &&
|
|
497
|
+
safeCompareSecret(req.headers['x-dsh-gateway-secret'], trustSecret)) {
|
|
498
|
+
;
|
|
499
|
+
req.__dsh_rm_trusted_gateway__ = true;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch { }
|
|
428
503
|
// 2. 请求层门禁判定与上下文虚拟化
|
|
429
504
|
const allowed = gateMiddleware(req, res, () => {
|
|
430
|
-
//
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
505
|
+
// 物理 Socket 连接真实 IP(严格取底层物理连接,仅去除 ::ffff: 前缀,绝不受任何头篡改)
|
|
506
|
+
const physicalSocketIp = getPhysicalSocketIp(req);
|
|
507
|
+
const isLoopbackSocket = physicalSocketIp === '127.0.0.1' || physicalSocketIp === '::1' || physicalSocketIp === 'localhost';
|
|
508
|
+
// 网关受信密钥核验:仅当底层物理连接为本地回环且 Secret 比对一致时,才采纳网关上报的有效真实客户端 IP
|
|
509
|
+
const currentSecret = getGatewaySecret(server);
|
|
510
|
+
const candidateSecret = req.headers['x-dsh-gateway-secret'];
|
|
511
|
+
const candidateRealIp = req.headers['x-dsh-real-ip'];
|
|
512
|
+
const candidateIpStr = Array.isArray(candidateRealIp) ? candidateRealIp[0] : candidateRealIp;
|
|
513
|
+
const trustedGateway = isLoopbackSocket &&
|
|
514
|
+
Boolean(currentSecret) &&
|
|
515
|
+
safeCompareSecret(candidateSecret, currentSecret);
|
|
516
|
+
let effectiveClientIp = physicalSocketIp;
|
|
517
|
+
if (trustedGateway &&
|
|
518
|
+
typeof candidateIpStr === 'string' &&
|
|
519
|
+
candidateIpStr.trim() !== '' &&
|
|
520
|
+
net.isIP(candidateIpStr.replace(/^::ffff:/, '').trim()) !== 0) {
|
|
521
|
+
effectiveClientIp = candidateIpStr.replace(/^::ffff:/, '').trim();
|
|
522
|
+
}
|
|
523
|
+
else if (isLoopbackSocket && !isLoopbackRequest(req)) {
|
|
524
|
+
// 同机反向代理:物理来源是 127,但 Host/Forwarded 表明是外部流量;
|
|
525
|
+
// 采用代理透传的 X-Forwarded-For 首个合法 IP,恢复防暴破/审计真实来源。
|
|
526
|
+
const rawXff = req.headers['x-forwarded-for'];
|
|
527
|
+
const value = Array.isArray(rawXff) ? rawXff[0] : rawXff;
|
|
528
|
+
if (typeof value === 'string') {
|
|
529
|
+
const first = value.split(',')[0].trim().replace(/^::ffff:/, '');
|
|
530
|
+
if (net.isIP(first))
|
|
531
|
+
effectiveClientIp = first;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else if (!isLoopbackSocket && req.headers['x-forwarded-for']) {
|
|
535
|
+
// 反向代理位于 LAN/Tailscale 网段:同样使用代理透传的真实 IP
|
|
536
|
+
const rawXff = req.headers['x-forwarded-for'];
|
|
537
|
+
const value = Array.isArray(rawXff) ? rawXff[0] : rawXff;
|
|
538
|
+
if (typeof value === 'string') {
|
|
539
|
+
const first = value.split(',')[0].trim().replace(/^::ffff:/, '');
|
|
540
|
+
if (net.isIP(first))
|
|
541
|
+
effectiveClientIp = first;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// 同步有效客户端 IP 到三头(外部伪造的头在此被强制净化,下游生态插件与审计均可准确读取)
|
|
545
|
+
req.headers['x-dsh-real-ip'] = effectiveClientIp;
|
|
546
|
+
req.headers['x-real-ip'] = effectiveClientIp;
|
|
547
|
+
req.headers['x-forwarded-for'] = effectiveClientIp;
|
|
548
|
+
// 消费完毕后立即抹除内部私密密钥头,防止向下游泄露
|
|
549
|
+
delete req.headers['x-dsh-gateway-secret'];
|
|
434
550
|
if (!req.headers['x-forwarded-host'] && req.headers.host) {
|
|
435
551
|
req.headers['x-forwarded-host'] = req.headers.host;
|
|
436
552
|
}
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
const isLoopbackSocket = realIp === '127.0.0.1' || realIp === '::1';
|
|
553
|
+
// 仅当底层物理 socket 为外部来源(Direct 模式直接对公网开放)时才由底座补丁进行外部虚拟化;
|
|
554
|
+
// 若底层物理 socket 为本地回环(包括本机直接访问或来自受信 Gateway 的回环转发),底座绝不无条件洗白 Origin,
|
|
555
|
+
// 保证下游生态插件的 CSRF / DNS-Rebinding 校验全额生效。
|
|
441
556
|
if (!isLoopbackSocket) {
|
|
442
|
-
//
|
|
557
|
+
// Direct 外部直连模式下的 Host/Origin 虚拟化
|
|
558
|
+
const externalProto = getRequestProtocol(req);
|
|
443
559
|
const hostHeader = req.headers.host || '';
|
|
444
|
-
const port =
|
|
560
|
+
const port = extractPortString(hostHeader) || '3080';
|
|
445
561
|
req.headers.host = `127.0.0.1:${port}`;
|
|
446
562
|
if (req.headers.origin) {
|
|
447
|
-
req.headers.origin =
|
|
563
|
+
req.headers.origin = `${externalProto}://127.0.0.1:${port}`;
|
|
448
564
|
}
|
|
449
|
-
// 补齐浏览器同源探针标记(解决 task-board 等插件对 sec-fetch-site 的检查)
|
|
450
565
|
if (!req.headers['sec-fetch-site'] || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
451
566
|
req.headers['sec-fetch-site'] = 'same-origin';
|
|
452
567
|
}
|
|
453
568
|
}
|
|
454
569
|
else {
|
|
455
|
-
//
|
|
570
|
+
// 本地回环连接:仅成对规范化本地 Host 变体(localhost / [::1] → 127.0.0.1)
|
|
571
|
+
// 关键安全原则:仅当 Origin 自身也是本地回环变体时才成对规范化;恶意的跨站 Origin(如 http://evil.com)必须 100% 原样保留!
|
|
456
572
|
const hostHeader = req.headers.host || '';
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
573
|
+
const bareHost = extractBareHostname(hostHeader);
|
|
574
|
+
const isLocalHostVariant = bareHost === 'localhost' || bareHost === '::1' || /^127\./.test(bareHost);
|
|
575
|
+
if (isLocalHostVariant) {
|
|
576
|
+
const targetPort = extractPortString(hostHeader) || '3080';
|
|
577
|
+
req.headers.host = `127.0.0.1:${targetPort}`;
|
|
578
|
+
if (req.headers.origin && typeof req.headers.origin === 'string') {
|
|
579
|
+
try {
|
|
580
|
+
const origUrl = new URL(req.headers.origin);
|
|
581
|
+
const origHostname = origUrl.hostname.toLowerCase();
|
|
582
|
+
const isLocalOrigin = origHostname === 'localhost' || origHostname === '::1' || /^127\./.test(origHostname);
|
|
583
|
+
if (isLocalOrigin) {
|
|
584
|
+
req.headers.origin = `${origUrl.protocol}//127.0.0.1:${targetPort}`;
|
|
585
|
+
}
|
|
586
|
+
// 非本地 Origin(如 evil.com)原样保留,供下游 CSRF 检查
|
|
587
|
+
}
|
|
588
|
+
catch { }
|
|
470
589
|
}
|
|
471
590
|
}
|
|
472
591
|
}
|
|
@@ -492,27 +611,54 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
|
|
|
492
611
|
// 请求未通过安全门禁,已在门禁层执行重定向或返回 401 拦截
|
|
493
612
|
}
|
|
494
613
|
});
|
|
614
|
+
// WebSocket Upgrade 不经过上面的 request 监听器;同样需要在校验网关 Secret 后打受信标记,
|
|
615
|
+
// 否则网关转发的 WS 会因为 X-Forwarded-* 被误判为同机反代,从而在底座再次要求 token。
|
|
616
|
+
server.prependListener('upgrade', (req) => {
|
|
617
|
+
try {
|
|
618
|
+
const upgradeSocketIp = getPhysicalSocketIp(req);
|
|
619
|
+
const upgradeIsLoopback = upgradeSocketIp === '127.0.0.1' || upgradeSocketIp === '::1';
|
|
620
|
+
const upgradeSecret = getGatewaySecret(server);
|
|
621
|
+
if (upgradeIsLoopback &&
|
|
622
|
+
upgradeSecret &&
|
|
623
|
+
safeCompareSecret(req.headers['x-dsh-gateway-secret'], upgradeSecret)) {
|
|
624
|
+
;
|
|
625
|
+
req.__dsh_rm_trusted_gateway__ = true;
|
|
626
|
+
delete req.headers['x-dsh-gateway-secret'];
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch { }
|
|
630
|
+
});
|
|
495
631
|
}
|
|
496
632
|
/**
|
|
497
|
-
* 注册 WebServer HTML
|
|
633
|
+
* 注册 WebServer HTML 模板层的样式与 Polyfill 注入钩子(在 Gzip 压缩前执行)
|
|
498
634
|
*
|
|
499
635
|
* @param ctx - Cordis 上下文对象
|
|
636
|
+
* @param styleStore - 样式片段管理器实例
|
|
500
637
|
*/
|
|
501
|
-
export function mountIndexInjections(ctx) {
|
|
638
|
+
export function mountIndexInjections(ctx, styleStore) {
|
|
639
|
+
if (!ctx || ctx.__dsh_rm_index_mounted__)
|
|
640
|
+
return;
|
|
641
|
+
ctx.__dsh_rm_index_mounted__ = true;
|
|
642
|
+
const injectFn = (html) => {
|
|
643
|
+
const mobileStyleCss = styleStore ? styleStore.collectCss() : '';
|
|
644
|
+
// tapIndex 阶段在服务端模板渲染期执行,尚未绑定单次请求的 UA,
|
|
645
|
+
// 因此 isMobile 设为 false,样式的 collectCss() 自带 @media 视口查询,
|
|
646
|
+
// PC 与移动端均能完美自适应生效。
|
|
647
|
+
return injectHtmlPayload(html, false, mobileStyleCss);
|
|
648
|
+
};
|
|
502
649
|
if (ctx.webServer && typeof ctx.webServer.tapIndex === 'function') {
|
|
503
|
-
ctx.webServer.tapIndex(
|
|
504
|
-
if (html.includes('<head>')) {
|
|
505
|
-
return html
|
|
506
|
-
.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`)
|
|
507
|
-
.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`)
|
|
508
|
-
// 冲突防护脚本后 replace → 位于 <head> 最前,优先写入 sessionStorage 避免小鲸鱼重复挂载
|
|
509
|
-
.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
|
|
510
|
-
}
|
|
511
|
-
return html;
|
|
512
|
-
});
|
|
650
|
+
ctx.webServer.tapIndex(injectFn);
|
|
513
651
|
}
|
|
514
652
|
ctx.on?.('webserver/index-inject', (table) => {
|
|
515
653
|
if (Array.isArray(table)) {
|
|
654
|
+
const mobileStyleCss = styleStore ? styleStore.collectCss() : '';
|
|
655
|
+
if (mobileStyleCss) {
|
|
656
|
+
table.push({
|
|
657
|
+
kind: 'style',
|
|
658
|
+
placement: 'head',
|
|
659
|
+
text: mobileStyleCss,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
516
662
|
table.push({
|
|
517
663
|
kind: 'script',
|
|
518
664
|
placement: 'head',
|
|
@@ -528,6 +674,16 @@ export function mountIndexInjections(ctx) {
|
|
|
528
674
|
placement: 'head',
|
|
529
675
|
text: FORCE_DESKTOP_SUPPRESSION_SNIPPET,
|
|
530
676
|
});
|
|
677
|
+
table.push({
|
|
678
|
+
kind: 'script',
|
|
679
|
+
placement: 'head',
|
|
680
|
+
text: DRAGGABLE_NAV_SNIPPET,
|
|
681
|
+
});
|
|
682
|
+
table.push({
|
|
683
|
+
kind: 'script',
|
|
684
|
+
placement: 'head',
|
|
685
|
+
text: MOBILE_DISMISS_SNIPPET,
|
|
686
|
+
});
|
|
531
687
|
}
|
|
532
688
|
});
|
|
533
689
|
}
|
|
@@ -755,16 +911,30 @@ function safeStringEqual(a, b) {
|
|
|
755
911
|
export function isRequestAuthorizedByGate(req, store) {
|
|
756
912
|
if (!req)
|
|
757
913
|
return false;
|
|
914
|
+
// 0. WebSocket Upgrade 直连(direct 模式)必须先通过 Host 白名单与 Origin 同源检查。
|
|
915
|
+
// 网关模式在 GatewayServer.handleUpgrade 中已完成同样的检查,并打上 __dsh_rm_host_allowed__,
|
|
916
|
+
// 此处避免重复校验,同时确保 direct 模式的核心 WS 不会被 DNS-Rebinding / CSWSH 绕过。
|
|
917
|
+
const isWebSocketUpgrade = String(req.headers?.upgrade || '').toLowerCase().includes('websocket');
|
|
918
|
+
if (isWebSocketUpgrade && !req.__dsh_rm_host_allowed__) {
|
|
919
|
+
if (!isTrustedHost(req.headers.host, store) || hasCrossSiteSignal(req)) {
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
758
923
|
// 1. 本机 127.0.0.1 / ::1 回环无条件放行
|
|
759
924
|
if (isLoopbackRequest(req))
|
|
760
925
|
return true;
|
|
761
926
|
const clientIp = getClientIp(req);
|
|
762
927
|
const options = store.getOptions();
|
|
928
|
+
// 反向代理位于 LAN/Tailscale 网段时不能被当作真实客户端免密放行
|
|
929
|
+
const hasForwardedHeaders = Boolean(req.headers?.['x-forwarded-for'] ||
|
|
930
|
+
req.headers?.['x-forwarded-host'] ||
|
|
931
|
+
req.headers?.['x-forwarded-proto'] ||
|
|
932
|
+
req.headers?.['x-real-ip']);
|
|
763
933
|
// 2. Tailscale 私网免密直连放行
|
|
764
|
-
if (options.allowTailscale && isTailscaleIp(clientIp))
|
|
934
|
+
if (!hasForwardedHeaders && options.allowTailscale && isTailscaleIp(clientIp))
|
|
765
935
|
return true;
|
|
766
936
|
// 3. 局域网 LAN 免密直连放行
|
|
767
|
-
if (options.allowLan && isLanIp(clientIp))
|
|
937
|
+
if (!hasForwardedHeaders && options.allowLan && isLanIp(clientIp))
|
|
768
938
|
return true;
|
|
769
939
|
// 4. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
|
|
770
940
|
const token = store.extractTokenFromRequest(req);
|
|
@@ -834,4 +1004,77 @@ export function takeOverConnectionAuth(connection, store) {
|
|
|
834
1004
|
return origAuthorizeIndex(req, res);
|
|
835
1005
|
};
|
|
836
1006
|
}
|
|
1007
|
+
// 4. 动态向 DSH 底座 connection.trustedHosts 注入当前本机所有可用 IP 与网关端口
|
|
1008
|
+
// (适配 DSH 0.2.1+ 底层 Host/Origin 信任围栏,使外部网关合规直通,免去伪造 Host 头带来的副作用)
|
|
1009
|
+
injectTrustedHosts(connection, [store.getActivePort(), 3080, 3081], store.getOptions().trustedHosts);
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* 向 DSH 运行时(webRuntime、connection、webStartup)的 trustedHosts 数组注入受信任的主机与端口
|
|
1013
|
+
*
|
|
1014
|
+
* @param target - Cordis 上下文对象或包含 trustedHosts 的服务对象
|
|
1015
|
+
* @param ports - 允许访问的活跃端口(包含底座端口与网关实际绑定端口)
|
|
1016
|
+
* @param staticTrustedHosts - 用户静态配置的白名单(如 Tailscale MagicDNS 域名等)
|
|
1017
|
+
*/
|
|
1018
|
+
export function injectTrustedHosts(target, ports = [3080, 3081], staticTrustedHosts = []) {
|
|
1019
|
+
if (!target)
|
|
1020
|
+
return;
|
|
1021
|
+
const arraysToInject = [];
|
|
1022
|
+
// 1. 如果 target 本身带有 trustedHosts 数组(如 connection 或 webRuntime)
|
|
1023
|
+
if (Array.isArray(target.trustedHosts)) {
|
|
1024
|
+
arraysToInject.push(target.trustedHosts);
|
|
1025
|
+
}
|
|
1026
|
+
// 2. 如果 target 是 Cordis 上下文 ctx,检查并提取各子服务的 trustedHosts
|
|
1027
|
+
try {
|
|
1028
|
+
const wr = (typeof target.get === 'function' ? target.get('webRuntime') : undefined) || target.webRuntime;
|
|
1029
|
+
if (wr && Array.isArray(wr.trustedHosts) && !arraysToInject.includes(wr.trustedHosts)) {
|
|
1030
|
+
arraysToInject.push(wr.trustedHosts);
|
|
1031
|
+
}
|
|
1032
|
+
const conn = (typeof target.get === 'function' ? target.get('connection') : undefined) || target.connection;
|
|
1033
|
+
if (conn && Array.isArray(conn.trustedHosts) && !arraysToInject.includes(conn.trustedHosts)) {
|
|
1034
|
+
arraysToInject.push(conn.trustedHosts);
|
|
1035
|
+
}
|
|
1036
|
+
const ws = (typeof target.get === 'function' ? target.get('webStartup') : undefined) || target.webStartup;
|
|
1037
|
+
if (ws && Array.isArray(ws.trustedHosts) && !arraysToInject.includes(ws.trustedHosts)) {
|
|
1038
|
+
arraysToInject.push(ws.trustedHosts);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
catch { }
|
|
1042
|
+
if (arraysToInject.length === 0)
|
|
1043
|
+
return;
|
|
1044
|
+
try {
|
|
1045
|
+
const netIps = getAllNetworkIps().map((i) => i.ip);
|
|
1046
|
+
// 规范化静态配置的主机名:去除端口、去除末尾点、过滤通配符
|
|
1047
|
+
const cleanStatics = (staticTrustedHosts || [])
|
|
1048
|
+
.map((h) => {
|
|
1049
|
+
let raw = String(h).trim().toLowerCase().replace(/\.$/, '');
|
|
1050
|
+
if (raw.startsWith('[')) {
|
|
1051
|
+
const closeBracket = raw.indexOf(']');
|
|
1052
|
+
if (closeBracket !== -1)
|
|
1053
|
+
raw = raw.slice(1, closeBracket);
|
|
1054
|
+
}
|
|
1055
|
+
else if (raw.includes(':')) {
|
|
1056
|
+
raw = raw.split(':')[0];
|
|
1057
|
+
}
|
|
1058
|
+
return raw.trim();
|
|
1059
|
+
})
|
|
1060
|
+
.filter((h) => h && h !== '0.0.0.0' && h !== '::');
|
|
1061
|
+
const candidates = ['127.0.0.1', 'localhost', '::1', ...netIps, ...cleanStatics];
|
|
1062
|
+
for (const arr of arraysToInject) {
|
|
1063
|
+
for (const hostItem of candidates) {
|
|
1064
|
+
if (!arr.includes(hostItem)) {
|
|
1065
|
+
arr.push(hostItem);
|
|
1066
|
+
}
|
|
1067
|
+
for (const port of ports) {
|
|
1068
|
+
if (!port)
|
|
1069
|
+
continue;
|
|
1070
|
+
const isIpv6 = hostItem.includes(':') && !hostItem.startsWith('[');
|
|
1071
|
+
const withPort = isIpv6 ? `[${hostItem}]:${port}` : `${hostItem}:${port}`;
|
|
1072
|
+
if (!arr.includes(withPort)) {
|
|
1073
|
+
arr.push(withPort);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch { }
|
|
837
1080
|
}
|