@yezack/dsh-remote-mobile 1.9.2 → 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.
@@ -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 { getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
35
- import { isLoopbackRequest } from '../auth/gate.js';
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 响应流中的 <head> 首行注入 Crypto Polyfill
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
- let html = chunk;
381
- if (isMobile)
382
- html = applyDataMobileAttr(html);
383
- if (mobileStyleCss) {
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
- let str = chunk.toString('utf8');
401
- if (str.includes('<head>')) {
402
- if (isMobile)
403
- str = applyDataMobileAttr(str);
404
- if (mobileStyleCss) {
405
- str = str.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
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
- // 防截断守卫:仅当头部尚未发出时移除显式的 Content-Length,
418
- // 让 Node 按最终 body 重新计算长度(writeHead 已发出后 removeHeader 无效,属不可挽救场景)
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 realIp = getClientIp(req);
432
- req.headers['x-dsh-real-ip'] = realIp;
433
- req.headers['x-real-ip'] = realIp;
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
- // 回环 socket(本机浏览器 / 本机程序)不做 Host/Origin/sec-fetch-site 洗白:
438
- // 保留浏览器原始同源信号,让下游自身的 CSRF 与 DNS-Rebinding 校验继续生效,
439
- // 防御恶意网页借道本机发起的 drive-by 跨站写与 Rebinding 攻击;虚拟化仅服务于外部合法流量。
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
- // 虚拟化 Host 与 Origin(解决外部合法流量的 DNS-Rebinding 400 阻断与同源 host === origin 严格检查)
557
+ // Direct 外部直连模式下的 Host/Origin 虚拟化
558
+ const externalProto = getRequestProtocol(req);
443
559
  const hostHeader = req.headers.host || '';
444
- const port = hostHeader.includes(':') ? hostHeader.split(':')[1] : '3080';
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 = `http://127.0.0.1:${port}`;
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
- // 仅归一化本地 Host 变体(localhost / [::1] → 127.0.0.1),其余原样保留供下游校验
570
+ // 本地回环连接:仅成对规范化本地 Host 变体(localhost / [::1] → 127.0.0.1)
571
+ // 关键安全原则:仅当 Origin 自身也是本地回环变体时才成对规范化;恶意的跨站 Origin(如 http://evil.com)必须 100% 原样保留!
456
572
  const hostHeader = req.headers.host || '';
457
- let bareHost = hostHeader;
458
- if (bareHost.startsWith('[')) {
459
- bareHost = bareHost.slice(1).split(']')[0];
460
- }
461
- else {
462
- bareHost = bareHost.split(':')[0];
463
- }
464
- bareHost = bareHost.toLowerCase();
465
- if (bareHost === 'localhost' || bareHost === '::1' || /^127\./.test(bareHost)) {
466
- const portMatch = hostHeader.match(/:(\d+)$/);
467
- req.headers.host = `127.0.0.1:${portMatch ? portMatch[1] : '3080'}`;
468
- if (req.headers.origin) {
469
- req.headers.origin = `http://${req.headers.host}`;
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 模板层的 Polyfill 注入钩子
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((html) => {
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
  }