dsh-mini-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/client/client.js +387 -0
- package/client/verify.mjs +514 -0
- package/cordis.patch.yml +5 -0
- package/lib/art/work-1-ready.webp +0 -0
- package/lib/art/work-2-reading.webp +0 -0
- package/lib/art/work-3-typing.webp +0 -0
- package/lib/art/work-4-checking.webp +0 -0
- package/lib/art/work-5-thinking.webp +0 -0
- package/lib/art/work-6-running.webp +0 -0
- package/lib/art/work-7-waiting.webp +0 -0
- package/lib/art/work-8-sprinting.webp +0 -0
- package/lib/build.js +59 -0
- package/lib/cloudflared.js +165 -0
- package/lib/events.js +165 -0
- package/lib/index.js +1206 -0
- package/lib/net.js +104 -0
- package/lib/notify.js +82 -0
- package/lib/page.html +1891 -0
- package/lib/page.js +46 -0
- package/lib/pairing.js +172 -0
- package/lib/server.js +590 -0
- package/lib/store.js +334 -0
- package/lib/tree.js +185 -0
- package/lib/tunnel.js +144 -0
- package/package.json +74 -0
package/lib/server.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自带的轻量 HTTP 服务。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不挂在 ctx.webServer 上:DSH 主服务的 host 只能是 127.0.0.1 或
|
|
5
|
+
* 0.0.0.0(见 dsh-host-webserver 的 Config 声明),没法只绑 Tailscale 虚拟 IP。
|
|
6
|
+
* 而把主 GUI 暴露到 0.0.0.0 是不可接受的。所以这里自己起一个 node:http,
|
|
7
|
+
* 绑定地址完全由插件配置决定。
|
|
8
|
+
*
|
|
9
|
+
* 传输用 SSE 而不是 WebSocket:
|
|
10
|
+
* - 零依赖(不用 ws)
|
|
11
|
+
* - EventSource 自带断线重连,移动端切后台回来能自愈
|
|
12
|
+
* - 只需要服务端→手机单向推送;手机发指令用普通 POST 即可
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'node:http'
|
|
15
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
16
|
+
import { renderPage, readArt } from './page.js'
|
|
17
|
+
|
|
18
|
+
const COOKIE = 'dsh_mini_token'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 配置的端口被占时,往后最多再试几个。
|
|
22
|
+
*
|
|
23
|
+
* 5 是个折中:够躲开「某个程序占了 3090」这种最常见的冲突,又不会在端口段整个
|
|
24
|
+
* 被防火墙或别的服务吃掉时,在这里白白等一圈。
|
|
25
|
+
*/
|
|
26
|
+
const PORT_TRIES = 5
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 手机上传的单个文件上限。
|
|
30
|
+
*
|
|
31
|
+
* DSH 那边**不设**文件大小上限(图片才有),所以这个数是我们的取舍:手机上常见的
|
|
32
|
+
* 是照片、截图、PDF,几十兆足够覆盖;再大就是视频了,而这条路上没有进度条,传一个
|
|
33
|
+
* 几百兆的文件会让用户对着一个转圈的界面干等,还不知道要等多久。宁可早一点、
|
|
34
|
+
* 明确地拒绝,也不要让它悬着。
|
|
35
|
+
*/
|
|
36
|
+
const MAX_UPLOAD = 50 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
/** 把字节数说成人话。 */
|
|
39
|
+
function mb(bytes) {
|
|
40
|
+
return `${Math.round(bytes / 1024 / 1024)} MB`
|
|
41
|
+
}
|
|
42
|
+
const MAX_BODY = 64 * 1024
|
|
43
|
+
const HEARTBEAT_MS = 25_000
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 密码试错的限速。
|
|
47
|
+
*
|
|
48
|
+
* 默认放开到内网之后,同一个 Wi-Fi 下的任何设备都能连到这个端口。token 是
|
|
49
|
+
* 128 位随机值,猜不出来;但万一它从别处漏了(比如二维码截图被转发),
|
|
50
|
+
* 限速是最后一道闸。同一来源一分钟内错 5 次,就挡一分钟。
|
|
51
|
+
*/
|
|
52
|
+
const RATE_WINDOW_MS = 60_000
|
|
53
|
+
const RATE_MAX_FAILURES = 5
|
|
54
|
+
|
|
55
|
+
/** 定长比较,避免 token 被逐字节试探。 */
|
|
56
|
+
function tokenEquals(a, b) {
|
|
57
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false
|
|
58
|
+
const ba = Buffer.from(a)
|
|
59
|
+
const bb = Buffer.from(b)
|
|
60
|
+
if (ba.length !== bb.length) return false
|
|
61
|
+
return timingSafeEqual(ba, bb)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 限速分桶用的键。
|
|
66
|
+
*
|
|
67
|
+
* 走隧道进来的请求,源地址全是 127.0.0.1(cloudflared 在本机回环上回连我们)。
|
|
68
|
+
* 要是按源地址分桶,随便谁在公网上错 5 次密码,就能把你自己手机也一起锁在
|
|
69
|
+
* 外面一分钟;反复触发就是一条拒绝服务的路子。所以隧道流量改按 cloudflared
|
|
70
|
+
* 填进来的真实客户端地址分桶。
|
|
71
|
+
*
|
|
72
|
+
* 隧道流量靠 Host 头识别——cloudflared 会把 Host 设成公网域名。Host 是可以
|
|
73
|
+
* 伪造的,但伪造它最多只能绕开限速;限速本来就是 128 位 token 之外的第二道
|
|
74
|
+
* 保险,绕开它照样猜不出 token。
|
|
75
|
+
*/
|
|
76
|
+
export function clientKey(req, tunnelHost) {
|
|
77
|
+
const host = String(req.headers?.host ?? '').toLowerCase()
|
|
78
|
+
if (tunnelHost && host && (host === tunnelHost || host.startsWith(`${tunnelHost}:`))) {
|
|
79
|
+
const forwarded = forwardedClient(req)
|
|
80
|
+
if (forwarded) return `fwd:${forwarded}`
|
|
81
|
+
}
|
|
82
|
+
return `src:${req.socket?.remoteAddress ?? 'unknown'}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function forwardedClient(req) {
|
|
86
|
+
const cf = req.headers['cf-connecting-ip']
|
|
87
|
+
if (typeof cf === 'string' && cf.trim()) return cf.trim()
|
|
88
|
+
const xff = req.headers['x-forwarded-for']
|
|
89
|
+
if (typeof xff === 'string' && xff.trim()) return xff.split(',')[0].trim()
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseCookies(header) {
|
|
94
|
+
const out = {}
|
|
95
|
+
if (!header) return out
|
|
96
|
+
for (const part of header.split(';')) {
|
|
97
|
+
const i = part.indexOf('=')
|
|
98
|
+
if (i < 0) continue
|
|
99
|
+
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim())
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function readBody(req) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
let size = 0
|
|
107
|
+
const chunks = []
|
|
108
|
+
req.on('data', (chunk) => {
|
|
109
|
+
size += chunk.length
|
|
110
|
+
if (size > MAX_BODY) {
|
|
111
|
+
reject(new Error('请求体过大'))
|
|
112
|
+
req.destroy()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
chunks.push(chunk)
|
|
116
|
+
})
|
|
117
|
+
req.on('end', () => {
|
|
118
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
119
|
+
if (!raw) return resolve({})
|
|
120
|
+
try {
|
|
121
|
+
resolve(JSON.parse(raw))
|
|
122
|
+
} catch {
|
|
123
|
+
reject(new Error('请求体不是合法 JSON'))
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
req.on('error', reject)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function sendJson(res, status, payload) {
|
|
131
|
+
const body = JSON.stringify(payload)
|
|
132
|
+
res.writeHead(status, {
|
|
133
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
134
|
+
'Content-Length': Buffer.byteLength(body),
|
|
135
|
+
'Cache-Control': 'no-store',
|
|
136
|
+
})
|
|
137
|
+
res.end(body)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {object} opts
|
|
142
|
+
* @param {import('./store.js').createStore extends (...a:any)=>infer R ? R : never} opts.store
|
|
143
|
+
* @param {{port:number, defaultMode:string}} opts.config
|
|
144
|
+
* @param {string[]} opts.bindAddresses 要监听的地址;第一个应是 127.0.0.1
|
|
145
|
+
* @param {string} opts.token
|
|
146
|
+
* @param {() => string | null} [opts.tunnelHost] 当前公网域名(没开隧道时返回 null),只用于限速分桶
|
|
147
|
+
* @param {(text:string)=>Promise<{ok:boolean, error?:string, sessionId?:string}>} opts.onInstruction
|
|
148
|
+
* @param {()=>Promise<{ok:boolean, error?:string, sessionId?:string}>|{ok:boolean, error?:string, sessionId?:string}} opts.onStop
|
|
149
|
+
* @param {(id:string)=>{ok:boolean, error?:string, sessionId?:string}} opts.onUnqueue
|
|
150
|
+
* @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void}>}
|
|
151
|
+
*/
|
|
152
|
+
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onStop, onUnqueue, onUpload, log, tree, build }) {
|
|
153
|
+
/** @type {Set<import('node:http').ServerResponse>} */
|
|
154
|
+
const clients = new Set()
|
|
155
|
+
|
|
156
|
+
// 没传 tree(测试里就是这样,headless 组合里也拿不到那两个服务)时给个空壳,
|
|
157
|
+
// 导航栏显示「取不到」,遥控本身照常。
|
|
158
|
+
const nav = tree ?? { listWorkspaces: async () => [], listSessionsOf: async () => null }
|
|
159
|
+
|
|
160
|
+
function broadcast(event, data) {
|
|
161
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
162
|
+
for (const res of clients) {
|
|
163
|
+
try {
|
|
164
|
+
res.write(frame)
|
|
165
|
+
} catch {
|
|
166
|
+
clients.delete(res)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isAuthorized(req, url) {
|
|
172
|
+
const fromQuery = url.searchParams.get('token')
|
|
173
|
+
const fromCookie = parseCookies(req.headers.cookie)[COOKIE]
|
|
174
|
+
const fromHeader = req.headers['x-mini-token']
|
|
175
|
+
return tokenEquals(fromQuery, token)
|
|
176
|
+
|| tokenEquals(fromCookie, token)
|
|
177
|
+
|| tokenEquals(fromHeader, token)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 这次请求到底有没有带凭据。没带只是没登录,不算试错。 */
|
|
181
|
+
function hasCredential(req, url) {
|
|
182
|
+
return Boolean(
|
|
183
|
+
url.searchParams.get('token')
|
|
184
|
+
|| parseCookies(req.headers.cookie)[COOKIE]
|
|
185
|
+
|| req.headers['x-mini-token'],
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 来源 -> { firstAt, count, blockedUntil } */
|
|
190
|
+
const attempts = new Map()
|
|
191
|
+
|
|
192
|
+
function isBlocked(req) {
|
|
193
|
+
const rec = attempts.get(clientKey(req, tunnelHost?.()))
|
|
194
|
+
return Boolean(rec && rec.blockedUntil > Date.now())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function noteFailure(req) {
|
|
198
|
+
const key = clientKey(req, tunnelHost?.())
|
|
199
|
+
const now = Date.now()
|
|
200
|
+
let rec = attempts.get(key)
|
|
201
|
+
if (!rec || now - rec.firstAt > RATE_WINDOW_MS) {
|
|
202
|
+
rec = { firstAt: now, count: 0, blockedUntil: 0 }
|
|
203
|
+
attempts.set(key, rec)
|
|
204
|
+
}
|
|
205
|
+
rec.count += 1
|
|
206
|
+
if (rec.count >= RATE_MAX_FAILURES) rec.blockedUntil = now + RATE_WINDOW_MS
|
|
207
|
+
// 别让这张表无限长大;正常情况下它只有个位数条目
|
|
208
|
+
if (attempts.size >= 512) {
|
|
209
|
+
for (const [k, v] of attempts) {
|
|
210
|
+
if (now - v.firstAt > RATE_WINDOW_MS && v.blockedUntil <= now) attempts.delete(k)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function noteSuccess(req) {
|
|
216
|
+
attempts.delete(clientKey(req, tunnelHost?.()))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handle(req, res) {
|
|
220
|
+
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`)
|
|
221
|
+
const path = url.pathname
|
|
222
|
+
|
|
223
|
+
// 试错太多的来源一律挡住,页面和接口都一样——否则可以拿页面接口
|
|
224
|
+
// 当测密码的探针。
|
|
225
|
+
if (isBlocked(req)) {
|
|
226
|
+
sendJson(res, 429, { error: '密码错误次数太多,等一分钟再试。' })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- 页面本身 -------------------------------------------------------
|
|
231
|
+
// 允许用 ?token=xxx 直接进(二维码扫的就是这个地址),
|
|
232
|
+
// 校验通过后写 cookie,后续请求就不必再带参数。
|
|
233
|
+
if (req.method === 'GET' && (path === '/' || path === '/mini' || path === '/mini/')) {
|
|
234
|
+
const provided = url.searchParams.get('token')
|
|
235
|
+
if (provided && tokenEquals(provided, token)) {
|
|
236
|
+
noteSuccess(req)
|
|
237
|
+
res.writeHead(200, {
|
|
238
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
239
|
+
'Cache-Control': 'no-store',
|
|
240
|
+
'Set-Cookie': `${COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=31536000; SameSite=Lax`,
|
|
241
|
+
})
|
|
242
|
+
res.end(renderPage({ defaultMode: config.defaultMode, build, maxUpload: MAX_UPLOAD }))
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (isAuthorized(req, url)) {
|
|
246
|
+
noteSuccess(req)
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
249
|
+
'Cache-Control': 'no-store',
|
|
250
|
+
})
|
|
251
|
+
res.end(renderPage({ defaultMode: config.defaultMode, build, maxUpload: MAX_UPLOAD }))
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
// 带了密码但不对,记一次失败;压根没带不算——那只是打开页面而已
|
|
255
|
+
if (hasCredential(req, url)) noteFailure(req)
|
|
256
|
+
// 没带 token:给一个只用于输入 token 的极简页面。
|
|
257
|
+
res.writeHead(200, {
|
|
258
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
259
|
+
'Cache-Control': 'no-store',
|
|
260
|
+
})
|
|
261
|
+
res.end(renderPage({ defaultMode: config.defaultMode, needsToken: true, build, maxUpload: MAX_UPLOAD }))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---- 立绘 -----------------------------------------------------------
|
|
266
|
+
// 静态资源,但同样要鉴权:它只服务于这个页面,没必要对没登录的人开放。
|
|
267
|
+
// 页面给 <img> 拼的是 /mini/art/x.webp?token=...&v=<指纹>,所以走 query 就够,
|
|
268
|
+
// 不依赖 cookie(从门禁页手输 token 进来时是没有 cookie 的)。
|
|
269
|
+
if (req.method === 'GET' && path.startsWith('/mini/art/')) {
|
|
270
|
+
if (!isAuthorized(req, url)) {
|
|
271
|
+
if (hasCredential(req, url)) noteFailure(req)
|
|
272
|
+
sendJson(res, 401, { error: '未授权:token 不正确' })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
noteSuccess(req)
|
|
276
|
+
const buf = readArt(path.slice('/mini/art/'.length))
|
|
277
|
+
if (!buf) {
|
|
278
|
+
sendJson(res, 404, { error: 'not found' })
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
res.writeHead(200, {
|
|
282
|
+
'Content-Type': 'image/webp',
|
|
283
|
+
'Content-Length': buf.length,
|
|
284
|
+
// 地址里带着构建指纹,内容不会在同一个地址下变化,可以放心长缓存。
|
|
285
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
286
|
+
})
|
|
287
|
+
res.end(buf)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- 其余一律要鉴权 -------------------------------------------------
|
|
292
|
+
if (!path.startsWith('/mini/api/')) {
|
|
293
|
+
sendJson(res, 404, { error: 'not found' })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (!isAuthorized(req, url)) {
|
|
297
|
+
// 只有「带了密码但不对」才算试错;压根没带只是没登录。
|
|
298
|
+
if (hasCredential(req, url)) noteFailure(req)
|
|
299
|
+
sendJson(res, 401, { error: '未授权:token 不正确' })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
noteSuccess(req)
|
|
303
|
+
|
|
304
|
+
// ---- SSE 推送流 ----------------------------------------------------
|
|
305
|
+
if (req.method === 'GET' && path === '/mini/api/stream') {
|
|
306
|
+
res.writeHead(200, {
|
|
307
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
308
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
309
|
+
Connection: 'keep-alive',
|
|
310
|
+
'X-Accel-Buffering': 'no',
|
|
311
|
+
})
|
|
312
|
+
res.write(': connected\n\n')
|
|
313
|
+
clients.add(res)
|
|
314
|
+
|
|
315
|
+
const beat = setInterval(() => {
|
|
316
|
+
try {
|
|
317
|
+
res.write(': ping\n\n')
|
|
318
|
+
} catch {
|
|
319
|
+
clearInterval(beat)
|
|
320
|
+
clients.delete(res)
|
|
321
|
+
}
|
|
322
|
+
}, HEARTBEAT_MS)
|
|
323
|
+
if (beat.unref) beat.unref()
|
|
324
|
+
|
|
325
|
+
// 连上就先补一次全量状态,手机刷新后不用再单独拉一次。
|
|
326
|
+
res.write(`event: state\ndata: ${JSON.stringify(store.snapshot())}\n\n`)
|
|
327
|
+
|
|
328
|
+
const cleanup = () => {
|
|
329
|
+
clearInterval(beat)
|
|
330
|
+
clients.delete(res)
|
|
331
|
+
}
|
|
332
|
+
req.on('close', cleanup)
|
|
333
|
+
res.on('close', cleanup)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (req.method === 'GET' && path === '/mini/api/state') {
|
|
338
|
+
sendJson(res, 200, store.snapshot())
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 构建指纹。重启期间旧进程还在应答,这个接口用来确认「跑的是不是新代码」。
|
|
343
|
+
if (req.method === 'GET' && path === '/mini/api/version') {
|
|
344
|
+
sendJson(res, 200, { ok: true, build: build ?? 'unknown' })
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// latest / history 都取**当前绑定会话**的那一份,不取全局的。
|
|
349
|
+
if (req.method === 'GET' && path === '/mini/api/latest') {
|
|
350
|
+
const snap = store.snapshot()
|
|
351
|
+
sendJson(res, 200, { latest: snap.latest, sessionId: snap.boundSessionId })
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (req.method === 'GET' && path === '/mini/api/history') {
|
|
356
|
+
sendJson(res, 200, { history: store.snapshot().history })
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 左侧导航栏:先列工作区(便宜),展开某个再取它的会话(要读日志折标题,贵)。
|
|
361
|
+
// `refresh=1` 是导航栏那个 ⟳ 按钮用的:绕过服务端缓存,真去读一遍。
|
|
362
|
+
if (req.method === 'GET' && path === '/mini/api/workspaces') {
|
|
363
|
+
const runningIds = new Set(
|
|
364
|
+
(store.snapshot().sessions ?? []).filter((s) => s.running).map((s) => s.id),
|
|
365
|
+
)
|
|
366
|
+
const workspaces = await nav.listWorkspaces(runningIds, url.searchParams.get('refresh') === '1')
|
|
367
|
+
sendJson(res, 200, { ok: true, workspaces })
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const wsSessions = /^\/mini\/api\/workspaces\/([^/]+)\/sessions$/.exec(path)
|
|
372
|
+
if (req.method === 'GET' && wsSessions) {
|
|
373
|
+
const asked = Number(url.searchParams.get('limit'))
|
|
374
|
+
const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, 100) : undefined
|
|
375
|
+
const result = await nav.listSessionsOf(
|
|
376
|
+
decodeURIComponent(wsSessions[1]),
|
|
377
|
+
limit,
|
|
378
|
+
url.searchParams.get('refresh') === '1',
|
|
379
|
+
)
|
|
380
|
+
if (!result) {
|
|
381
|
+
sendJson(res, 404, { ok: false, error: '没有这个工作区。' })
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---- 上传文件 -------------------------------------------------------
|
|
389
|
+
// 这一段**必须排在下面那个 readBody 前面**:readBody 既只认 JSON,又把请求体
|
|
390
|
+
// 限死在 64 KB(MAX_BODY)。手机拍一张照片就有好几兆,走它必然失败,而且失败得
|
|
391
|
+
// 很难看——先是「请求体过大」,用户完全不知道该怎么办。这里直接把请求流喂给
|
|
392
|
+
// 插件的保存回调,DSH 那边边收边落盘,不在内存里攒整份。
|
|
393
|
+
if (req.method === 'POST' && path === '/mini/api/upload') {
|
|
394
|
+
if (!onUpload) {
|
|
395
|
+
sendJson(res, 503, { error: '这台电脑上的 DSH 没装附件服务,传不了文件。' })
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
const name = url.searchParams.get('name') ?? undefined
|
|
399
|
+
// 客户端声明的长度只能当提示:够长就早点拒绝,省得白白传上来再失败。
|
|
400
|
+
// 但它不算依据——真正的闸门在下面那道数着字节的闸门上。
|
|
401
|
+
const declared = Number(url.searchParams.get('size') ?? '')
|
|
402
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD) {
|
|
403
|
+
sendJson(res, 413, { error: `文件太大了(${mb(declared)}),最多 ${mb(MAX_UPLOAD)}。` })
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
let seen = 0
|
|
407
|
+
async function* capped() {
|
|
408
|
+
for await (const chunk of req) {
|
|
409
|
+
seen += chunk.length
|
|
410
|
+
if (seen > MAX_UPLOAD) throw new Error(`超过 ${mb(MAX_UPLOAD)}`)
|
|
411
|
+
yield chunk
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const result = await onUpload({ data: capped(), name })
|
|
416
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
417
|
+
} catch (err) {
|
|
418
|
+
// 手机中途断了、或者文件太大,都会走到这里。这不是「服务坏了」,
|
|
419
|
+
// 如实把原因说出来就行。
|
|
420
|
+
sendJson(res, 400, { error: `文件没存下:${err?.message ?? err}` })
|
|
421
|
+
}
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (req.method === 'POST') {
|
|
426
|
+
let body
|
|
427
|
+
try {
|
|
428
|
+
body = await readBody(req)
|
|
429
|
+
} catch (err) {
|
|
430
|
+
sendJson(res, 400, { error: err.message })
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (path === '/mini/api/send') {
|
|
435
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
436
|
+
const uploadIds = Array.isArray(body.uploadIds) ? body.uploadIds : []
|
|
437
|
+
// 只传文件不写字也是合法的——「这份文件你看一下」本来就是常见用法。
|
|
438
|
+
// 两样都没有才算空。
|
|
439
|
+
if (!text && !uploadIds.length) {
|
|
440
|
+
sendJson(res, 400, { error: '指令为空' })
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
const result = await onInstruction(text, uploadIds)
|
|
444
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (path === '/mini/api/stop') {
|
|
449
|
+
// 停的是「手机正在遥控的那个会话」,和 send 同一套口径——
|
|
450
|
+
// 手机上按的停止,不该停掉你在电脑上另开的那个任务。
|
|
451
|
+
const result = await onStop()
|
|
452
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (path === '/mini/api/unqueue') {
|
|
457
|
+
// 撤掉一条还在排队的指令。撤的是「手机正在遥控的那个会话」队列里的那一条,
|
|
458
|
+
// 和 send / stop 同一套口径。
|
|
459
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
460
|
+
if (!id) {
|
|
461
|
+
sendJson(res, 400, { error: 'id 为空' })
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
const result = onUnqueue(id)
|
|
465
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (path === '/mini/api/bind') {
|
|
470
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
471
|
+
if (!sessionId) {
|
|
472
|
+
sendJson(res, 400, { error: 'sessionId 为空' })
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
store.bind(sessionId)
|
|
476
|
+
const snap = store.snapshot()
|
|
477
|
+
broadcast('state', snap)
|
|
478
|
+
// 顺手把快照放回响应里。手机拿到就能立刻渲染,不用等 SSE 那一趟;
|
|
479
|
+
// 万一那一刻连接正好断了,切换也还是自足的。
|
|
480
|
+
sendJson(res, 200, { ok: true, boundSessionId: sessionId, state: snap })
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (path === '/mini/api/presence') {
|
|
485
|
+
store.markSeen(body.state === 'background' ? 'background' : 'foreground')
|
|
486
|
+
sendJson(res, 200, { ok: true })
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (path === '/mini/api/heartbeat') {
|
|
491
|
+
store.markSeen()
|
|
492
|
+
sendJson(res, 200, { ok: true })
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
sendJson(res, 404, { error: 'not found' })
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const onRequest = (req, res) => {
|
|
501
|
+
handle(req, res).catch((err) => {
|
|
502
|
+
log?.warn?.(`dsh-mini-remote: 请求处理失败 ${req.url} — ${err?.message ?? err}`)
|
|
503
|
+
if (!res.headersSent) {
|
|
504
|
+
sendJson(res, 500, { error: '内部错误' })
|
|
505
|
+
} else {
|
|
506
|
+
res.destroy()
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 一个地址一个监听。127.0.0.1 排在第一个,配置成 0 端口时由它把真实端口
|
|
512
|
+
// 定下来,其余地址复用同一个端口。
|
|
513
|
+
const wanted = Array.isArray(bindAddresses) && bindAddresses.length
|
|
514
|
+
? bindAddresses
|
|
515
|
+
: ['127.0.0.1']
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* 在某个端口上把地址逐个绑一遍。
|
|
519
|
+
*
|
|
520
|
+
* 逐个绑是刻意的:某个地址绑不上(比如 DHCP 换过 IP、Tailscale 退出了)不该
|
|
521
|
+
* 拖垮整个服务——其余的照常工作,只是这个地址用不了。
|
|
522
|
+
*/
|
|
523
|
+
async function bindOn(startPort) {
|
|
524
|
+
const listeners = []
|
|
525
|
+
const failed = []
|
|
526
|
+
let port = startPort
|
|
527
|
+
for (const address of wanted) {
|
|
528
|
+
const server = createServer(onRequest)
|
|
529
|
+
try {
|
|
530
|
+
await new Promise((resolve, reject) => {
|
|
531
|
+
server.once('error', reject)
|
|
532
|
+
server.listen(port, address, resolve)
|
|
533
|
+
})
|
|
534
|
+
port = server.address().port
|
|
535
|
+
listeners.push({ address, server })
|
|
536
|
+
} catch (err) {
|
|
537
|
+
failed.push({ address, message: err?.message ?? String(err) })
|
|
538
|
+
server.close()
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return { listeners, failed, port }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 端口被占就往后挪一个再试。
|
|
545
|
+
//
|
|
546
|
+
// 一个非技术用户撞上「3090 被别的程序占了」时,自己是没有办法的:设置界面里
|
|
547
|
+
// 没有改端口的地方,日志他也不会去看,而端口号对他毫无意义。所以这里自己让开,
|
|
548
|
+
// 别让他去配。只往后试 PORT_TRIES 个——万一整个端口段都被占,不能在这儿转圈。
|
|
549
|
+
let listeners = []
|
|
550
|
+
let failed = []
|
|
551
|
+
let port = config.port
|
|
552
|
+
for (let i = 0; i < PORT_TRIES; i += 1) {
|
|
553
|
+
const attempt = await bindOn(config.port + i)
|
|
554
|
+
listeners = attempt.listeners
|
|
555
|
+
failed = attempt.failed
|
|
556
|
+
port = attempt.port
|
|
557
|
+
if (listeners.length) {
|
|
558
|
+
if (i > 0) log?.info?.(`端口 ${config.port} 被占用,改用 ${port}。`)
|
|
559
|
+
break
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!listeners.length) {
|
|
564
|
+
// 分清两种成因:端口被占(用户改个端口就行)和地址都不可用(网络的事)。
|
|
565
|
+
// 这两件事的下一步动作完全不同,合成一句等于什么都没说。
|
|
566
|
+
const busy = failed.length > 0 && failed.every((f) => /EADDRINUSE/.test(f.message))
|
|
567
|
+
throw new Error(busy
|
|
568
|
+
? `端口 ${config.port} 往后连续 ${PORT_TRIES} 个都被占用了。`
|
|
569
|
+
: `没有任何地址能监听:${failed.map((f) => `${f.address}(${f.message})`).join('、')}`)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
port,
|
|
574
|
+
/** 真正绑定成功的地址。 */
|
|
575
|
+
addresses: listeners.map((l) => l.address),
|
|
576
|
+
/** 想绑但失败的地址,交给调用方决定怎么提示。 */
|
|
577
|
+
failed,
|
|
578
|
+
broadcast,
|
|
579
|
+
close: async () => {
|
|
580
|
+
for (const res of clients) {
|
|
581
|
+
try { res.end() } catch { /* 已经断了 */ }
|
|
582
|
+
}
|
|
583
|
+
clients.clear()
|
|
584
|
+
await Promise.all(listeners.map(({ server }) => new Promise((resolve) => {
|
|
585
|
+
server.close(() => resolve())
|
|
586
|
+
server.closeAllConnections?.()
|
|
587
|
+
})))
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
}
|