dsh-mcloud 0.3.0 → 0.3.1
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 +26 -2
- package/README.md +34 -13
- package/lib/index.js +2505 -2482
- package/lib/request-guard.js +154 -0
- package/package.json +5 -2
package/lib/index.js
CHANGED
|
@@ -1,2482 +1,2505 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-mcloud — Host half(宿主端)
|
|
3
|
-
*
|
|
4
|
-
* 把「中国移动云盘 / 139」接进 DeepSeek Harness:
|
|
5
|
-
* · 工具(ctx.tools.register):容量、列目录、搜索、下载直链、新建/改名/移动/删除
|
|
6
|
-
* · HTTP 路由(/api/dsh-mcloud/*):给会话页那个「移动云盘」标签面板用
|
|
7
|
-
* · 凭据:Windows 走当前用户 DPAPI 加密,存在 ~/.dsh-mcloud/
|
|
8
|
-
*
|
|
9
|
-
* 协议说明:官方没有公开 MCP,这里按社区逆向出来的 personal_new(新的个人盘)接口实现,
|
|
10
|
-
* 主要参考 OpenList 的 drivers/139(AGPL-3.0)与 cloud_driver_sdk 的 Yun139。
|
|
11
|
-
* 关键点:
|
|
12
|
-
* · 请求要带 Mcloud-Sign:对「实际发出的 JSON 字符串」做 encodeURIComponent → 逐字符排序 →
|
|
13
|
-
* base64 → md5(b64) + md5(ts:rand) → 再 md5 取大写。签名和 body 必须来自同一个字符串。
|
|
14
|
-
* · Authorization: Basic <base64("pc:账号:authToken|...|到期毫秒")>
|
|
15
|
-
* · 业务 host 要先问 /user/route/qryRoutePolicy 拿,失败再回落到 personal-kd-njs。
|
|
16
|
-
*
|
|
17
|
-
* 写入类操作全部默认要过 Harness 审批。
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
readFileSync,
|
|
22
|
-
writeFileSync,
|
|
23
|
-
mkdirSync,
|
|
24
|
-
renameSync,
|
|
25
|
-
rmSync,
|
|
26
|
-
statSync,
|
|
27
|
-
createWriteStream,
|
|
28
|
-
} from 'node:fs'
|
|
29
|
-
import { readdir } from 'node:fs/promises'
|
|
30
|
-
import { homedir, tmpdir } from 'node:os'
|
|
31
|
-
import { join, resolve, basename, dirname } from 'node:path'
|
|
32
|
-
import { randomUUID, randomBytes, createHash } from 'node:crypto'
|
|
33
|
-
import { spawnSync, spawn } from 'node:child_process'
|
|
34
|
-
import { pipeline } from 'node:stream/promises'
|
|
35
|
-
import {
|
|
36
|
-
PART_BATCH,
|
|
37
|
-
UPLOAD_CONTENT_TYPE,
|
|
38
|
-
buildPartInfos,
|
|
39
|
-
partSizeFor,
|
|
40
|
-
putParts,
|
|
41
|
-
sha256File,
|
|
42
|
-
streamToFile,
|
|
43
|
-
} from './transfer.js'
|
|
44
|
-
import { loginWithPassword } from './login.js'
|
|
45
|
-
import { scanLogin } from './scan-login.js'
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
export const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* pageSize=
|
|
71
|
-
* pageSize=
|
|
72
|
-
* pageSize=
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
TOOL_PREFIX + '
|
|
113
|
-
TOOL_PREFIX + '
|
|
114
|
-
TOOL_PREFIX + '
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
TOOL_PREFIX + '
|
|
118
|
-
TOOL_PREFIX + '
|
|
119
|
-
TOOL_PREFIX + '
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (raw
|
|
129
|
-
return raw
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`${
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
map.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
map.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
*
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
if (code) return
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
*
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (error
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
*
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
'
|
|
273
|
-
'$
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
'
|
|
280
|
-
'
|
|
281
|
-
'
|
|
282
|
-
'
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
return
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
'
|
|
395
|
-
'
|
|
396
|
-
'
|
|
397
|
-
'
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
Add-Type -AssemblyName System.
|
|
420
|
-
Add-Type -
|
|
421
|
-
|
|
422
|
-
[DllImport("user32.dll")] public static extern bool
|
|
423
|
-
[DllImport("user32.dll")] public static extern IntPtr
|
|
424
|
-
[DllImport("user32.dll")] public static extern
|
|
425
|
-
[DllImport("
|
|
426
|
-
[DllImport("
|
|
427
|
-
[DllImport("user32.dll"
|
|
428
|
-
public
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
$script:
|
|
434
|
-
$script:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
$
|
|
441
|
-
$
|
|
442
|
-
$
|
|
443
|
-
$
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
$
|
|
458
|
-
|
|
459
|
-
$
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
$script:
|
|
474
|
-
$script:
|
|
475
|
-
$script:timer
|
|
476
|
-
$script:timer.
|
|
477
|
-
|
|
478
|
-
$
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
$d
|
|
495
|
-
$d.
|
|
496
|
-
$d.
|
|
497
|
-
$d.
|
|
498
|
-
$
|
|
499
|
-
$
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
$d
|
|
508
|
-
$d.
|
|
509
|
-
$
|
|
510
|
-
$
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
$d
|
|
523
|
-
$d.
|
|
524
|
-
$
|
|
525
|
-
$
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
let
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
child.
|
|
557
|
-
child.
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
.
|
|
576
|
-
.
|
|
577
|
-
.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
let
|
|
617
|
-
let
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if (remaining
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const
|
|
676
|
-
const
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
'mcloud-
|
|
683
|
-
'mcloud-
|
|
684
|
-
'mcloud-
|
|
685
|
-
'
|
|
686
|
-
'x-
|
|
687
|
-
'x-
|
|
688
|
-
'x-
|
|
689
|
-
'x-m4c-
|
|
690
|
-
'x-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
'
|
|
706
|
-
'
|
|
707
|
-
'x-yun-
|
|
708
|
-
'x-yun-channel
|
|
709
|
-
'x-yun-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
'x-yun-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
{
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
if (data.
|
|
876
|
-
if (data.
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
job.
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
job.
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
.
|
|
1045
|
-
.
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
job.
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
*
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
//
|
|
1080
|
-
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
job.
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
job.
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
const
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
job.
|
|
1201
|
-
|
|
1202
|
-
const
|
|
1203
|
-
idMap.
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
job.
|
|
1210
|
-
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
*
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
job.
|
|
1274
|
-
job.
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
job.
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
//
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
{
|
|
1297
|
-
{
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
const
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
let
|
|
1320
|
-
let
|
|
1321
|
-
let
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
//
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
let
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
if (!
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
const
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
next.
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
let
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
return { hasSaved:
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
*
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
session.
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
session.
|
|
1471
|
-
session.
|
|
1472
|
-
session.
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
session.
|
|
1478
|
-
session.
|
|
1479
|
-
session.
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
return {
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
* ·
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
const
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
let
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
let
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
const
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
if (item.
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
const
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
const
|
|
2110
|
-
|
|
2111
|
-
const
|
|
2112
|
-
const
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
const
|
|
2129
|
-
const
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
const
|
|
2253
|
-
if (!
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
)
|
|
2257
|
-
job
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
)
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
const
|
|
2309
|
-
const
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
}
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
store = createCredentialStore(
|
|
2412
|
-
}
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
(
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mcloud — Host half(宿主端)
|
|
3
|
+
*
|
|
4
|
+
* 把「中国移动云盘 / 139」接进 DeepSeek Harness:
|
|
5
|
+
* · 工具(ctx.tools.register):容量、列目录、搜索、下载直链、新建/改名/移动/删除
|
|
6
|
+
* · HTTP 路由(/api/dsh-mcloud/*):给会话页那个「移动云盘」标签面板用
|
|
7
|
+
* · 凭据:Windows 走当前用户 DPAPI 加密,存在 ~/.dsh-mcloud/
|
|
8
|
+
*
|
|
9
|
+
* 协议说明:官方没有公开 MCP,这里按社区逆向出来的 personal_new(新的个人盘)接口实现,
|
|
10
|
+
* 主要参考 OpenList 的 drivers/139(AGPL-3.0)与 cloud_driver_sdk 的 Yun139。
|
|
11
|
+
* 关键点:
|
|
12
|
+
* · 请求要带 Mcloud-Sign:对「实际发出的 JSON 字符串」做 encodeURIComponent → 逐字符排序 →
|
|
13
|
+
* base64 → md5(b64) + md5(ts:rand) → 再 md5 取大写。签名和 body 必须来自同一个字符串。
|
|
14
|
+
* · Authorization: Basic <base64("pc:账号:authToken|...|到期毫秒")>
|
|
15
|
+
* · 业务 host 要先问 /user/route/qryRoutePolicy 拿,失败再回落到 personal-kd-njs。
|
|
16
|
+
*
|
|
17
|
+
* 写入类操作全部默认要过 Harness 审批。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
statSync,
|
|
27
|
+
createWriteStream,
|
|
28
|
+
} from 'node:fs'
|
|
29
|
+
import { readdir } from 'node:fs/promises'
|
|
30
|
+
import { homedir, tmpdir } from 'node:os'
|
|
31
|
+
import { join, resolve, basename, dirname } from 'node:path'
|
|
32
|
+
import { randomUUID, randomBytes, createHash } from 'node:crypto'
|
|
33
|
+
import { spawnSync, spawn } from 'node:child_process'
|
|
34
|
+
import { pipeline } from 'node:stream/promises'
|
|
35
|
+
import {
|
|
36
|
+
PART_BATCH,
|
|
37
|
+
UPLOAD_CONTENT_TYPE,
|
|
38
|
+
buildPartInfos,
|
|
39
|
+
partSizeFor,
|
|
40
|
+
putParts,
|
|
41
|
+
sha256File,
|
|
42
|
+
streamToFile,
|
|
43
|
+
} from './transfer.js'
|
|
44
|
+
import { loginWithPassword } from './login.js'
|
|
45
|
+
import { scanLogin } from './scan-login.js'
|
|
46
|
+
import { requestRejection } from './request-guard.js'
|
|
47
|
+
|
|
48
|
+
export const name = 'dsh-mcloud'
|
|
49
|
+
export const inject = ['tools']
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------ 常量
|
|
52
|
+
|
|
53
|
+
const USER_HOST = 'https://user-njs.yun.139.com'
|
|
54
|
+
const FALLBACK_PERSONAL_HOST = 'https://personal-kd-njs.yun.139.com/hcy'
|
|
55
|
+
const REFRESH_URL = 'https://aas.caiyun.feixin.10086.cn:443/tellin/authTokenRefresh.do'
|
|
56
|
+
const WRITE_HEADER = 'x-dsh-mcloud'
|
|
57
|
+
const REQUEST_TIMEOUT_MS = 25_000
|
|
58
|
+
|
|
59
|
+
/** 瞬时网络故障的重试策略(移动云盘偶发 ECONNRESET / 超时 / 5xx) */
|
|
60
|
+
const MCLOUD_RETRY_ATTEMPTS = 3
|
|
61
|
+
const MCLOUD_RETRY_DELAY_MS = 400
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 目录分页大小。
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ 这里的数据修正过一次。早期测出来的结论是「pageSize 越大越慢,几乎二次方恶化」
|
|
67
|
+
* (10 → 1.3s,100 → 25s),于是定成了 10。但后来在真实的大目录
|
|
68
|
+
* (手机图片,几百上千张)上重测,结果完全不同:
|
|
69
|
+
*
|
|
70
|
+
* pageSize= 10 → 218 ms 每 100 项约 2180 ms
|
|
71
|
+
* pageSize= 30 → 248 ms 每 100 项约 827 ms
|
|
72
|
+
* pageSize= 50 → 296 ms 每 100 项约 592 ms
|
|
73
|
+
* pageSize=100 → 451 ms 每 100 项约 451 ms
|
|
74
|
+
*
|
|
75
|
+
* 也就是说:**瓶颈是每次请求的固定开销,不是条目数**。
|
|
76
|
+
* 一次多拿几十条几乎不要钱,但每页只拿 10 条就要多跑好几倍的往返。
|
|
77
|
+
*
|
|
78
|
+
* 取 50:相比 10 把「单位成本」降到 1/3.7,同时给单次响应留了余量
|
|
79
|
+
* (某些目录条目体积大时,100 档的尾部延迟会更明显)。
|
|
80
|
+
*/
|
|
81
|
+
const LIST_PAGE_SIZE = 50
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 目录列表缓存。点文件夹「慢」的很大一部分原因是每次进出都重新拉一遍,
|
|
85
|
+
* 而移动云盘对同一目录的重复请求还会限流。缓存 60 秒,任何写操作都会清掉。
|
|
86
|
+
*/
|
|
87
|
+
const LIST_CACHE_TTL_MS = 60_000
|
|
88
|
+
const LIST_CACHE_MAX = 200
|
|
89
|
+
|
|
90
|
+
const ROOT_ID = '/'
|
|
91
|
+
const MB = 1024 * 1024
|
|
92
|
+
|
|
93
|
+
const DEFAULT_CREDENTIAL_DIR = process.env.DSH_MCLOUD_CREDENTIAL_DIR
|
|
94
|
+
? resolve(process.env.DSH_MCLOUD_CREDENTIAL_DIR)
|
|
95
|
+
: join(homedir(), '.dsh-mcloud')
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 老版本(插件还叫 dsh-mobile-cloud 时)把凭据存在这里。
|
|
99
|
+
* 新目录没有凭据时回落到它,免得升级后用户还得重新授权一次。
|
|
100
|
+
*/
|
|
101
|
+
const LEGACY_CREDENTIAL_DIRS = [
|
|
102
|
+
process.env.DSH_MOBILE_CLOUD_CREDENTIAL_DIR
|
|
103
|
+
? resolve(process.env.DSH_MOBILE_CLOUD_CREDENTIAL_DIR)
|
|
104
|
+
: join(homedir(), '.dsh-mobile-cloud'),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
/** 「下载到本地」不填路径时的默认落地目录 */
|
|
108
|
+
const DEFAULT_DOWNLOAD_DIR = join(homedir(), 'Downloads', 'dsh-mcloud')
|
|
109
|
+
|
|
110
|
+
const TOOL_PREFIX = 'mcloud_'
|
|
111
|
+
const WRITE_TOOL_NAMES = new Set([
|
|
112
|
+
TOOL_PREFIX + 'mkdir',
|
|
113
|
+
TOOL_PREFIX + 'rename',
|
|
114
|
+
TOOL_PREFIX + 'move',
|
|
115
|
+
TOOL_PREFIX + 'remove',
|
|
116
|
+
// 上传会改网盘内容;下载会往本机磁盘写文件(路径是模型给的),都值得确认一下
|
|
117
|
+
TOOL_PREFIX + 'upload',
|
|
118
|
+
TOOL_PREFIX + 'upload_folder',
|
|
119
|
+
TOOL_PREFIX + 'download',
|
|
120
|
+
TOOL_PREFIX + 'download_folder',
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
const windows = process.platform === 'win32'
|
|
124
|
+
|
|
125
|
+
// ------------------------------------------------------------------ 小工具
|
|
126
|
+
|
|
127
|
+
function unwrapConfig(raw) {
|
|
128
|
+
if (!raw || typeof raw !== 'object') return {}
|
|
129
|
+
if (raw.config && typeof raw.config === 'object') return raw.config
|
|
130
|
+
return raw
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function md5(input) {
|
|
134
|
+
return createHash('md5').update(input, 'utf8').digest('hex')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function randomString(length) {
|
|
138
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
|
139
|
+
const bytes = randomBytes(length)
|
|
140
|
+
let out = ''
|
|
141
|
+
for (let i = 0; i < length; i += 1) out += alphabet[bytes[i] % alphabet.length]
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 对应 Go 的 time.Now().Format("2006-01-02 15:04:05"),本地时间 */
|
|
146
|
+
function formatTimestamp(date = new Date()) {
|
|
147
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
148
|
+
return (
|
|
149
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
150
|
+
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Mcloud-Sign 算法。
|
|
156
|
+
* 注意 encodeURIComponent 与 OpenList 那个辅助函数等价(Go 侧是把 QueryEscape 的
|
|
157
|
+
* + %21 %27 %28 %29 %2A 再改回原字符),所以这里直接用 encodeURIComponent。
|
|
158
|
+
*/
|
|
159
|
+
function calcSign(bodyString, timestamp, randStr) {
|
|
160
|
+
const encoded = encodeURIComponent(bodyString)
|
|
161
|
+
const sorted = encoded.split('').sort().join('')
|
|
162
|
+
const b64 = Buffer.from(sorted, 'utf8').toString('base64')
|
|
163
|
+
const mixed = md5(b64) + md5(`${timestamp}:${randStr}`)
|
|
164
|
+
return md5(mixed).toUpperCase()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function errorMessage(error) {
|
|
168
|
+
return error instanceof Error ? error.message : String(error)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sleep(ms) {
|
|
172
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function clampPageSize(value) {
|
|
176
|
+
const n = Number(value)
|
|
177
|
+
if (!Number.isFinite(n) || n <= 0) return LIST_PAGE_SIZE
|
|
178
|
+
return Math.min(Math.max(Math.trunc(n), 1), 100)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 极简 LRU:Map 的插入顺序就是访问顺序,超量就删最旧的。 */
|
|
182
|
+
function createCache(ttlMs, maxEntries) {
|
|
183
|
+
const map = new Map()
|
|
184
|
+
return {
|
|
185
|
+
get(key) {
|
|
186
|
+
const hit = map.get(key)
|
|
187
|
+
if (!hit) return null
|
|
188
|
+
if (Date.now() - hit.at > ttlMs) {
|
|
189
|
+
map.delete(key)
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
// 命中就挪到末尾(最近使用)
|
|
193
|
+
map.delete(key)
|
|
194
|
+
map.set(key, hit)
|
|
195
|
+
return hit.value
|
|
196
|
+
},
|
|
197
|
+
set(key, value) {
|
|
198
|
+
map.set(key, { at: Date.now(), value })
|
|
199
|
+
while (map.size > maxEntries) {
|
|
200
|
+
const oldest = map.keys().next().value
|
|
201
|
+
map.delete(oldest)
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
clear() {
|
|
205
|
+
map.clear()
|
|
206
|
+
},
|
|
207
|
+
get size() {
|
|
208
|
+
return map.size
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* undici 的 fetch 失败只会说 "fetch failed",真正的原因(ECONNRESET / ENOTFOUND /
|
|
215
|
+
* ETIMEDOUT…)藏在 error.cause 里。把它挖出来,用户才看得懂到底怎么了。
|
|
216
|
+
*/
|
|
217
|
+
function describeFetchError(error) {
|
|
218
|
+
if (!error) return '未知网络错误'
|
|
219
|
+
const cause = error.cause
|
|
220
|
+
if (cause) {
|
|
221
|
+
const code = cause.code || cause.errno
|
|
222
|
+
const detail = cause.message || ''
|
|
223
|
+
if (code && detail) return `${code}:${detail}`
|
|
224
|
+
if (code) return String(code)
|
|
225
|
+
if (detail) return detail
|
|
226
|
+
}
|
|
227
|
+
return errorMessage(error)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 判断「重试也没用」的错误,别白等:
|
|
232
|
+
* 授权过期、参数不合法、文件不存在这类都得用户动手,重试纯浪费时间。
|
|
233
|
+
*/
|
|
234
|
+
function isPermanentError(error) {
|
|
235
|
+
if (!error) return true
|
|
236
|
+
if (error.name === 'AbortError') return true
|
|
237
|
+
const message = errorMessage(error)
|
|
238
|
+
// 认证/授权类错误:重试一百次也没用,必须让用户重新登录
|
|
239
|
+
if (/认证失败|暂无权限|授权已过期|还没有授权|不是合法|结构不对|参数不合法|不存在|没有权限|已取消|登录失败/.test(message)) {
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 账号脱敏。移动云盘这边没有能查昵称的接口(试过 /user/info 等,全是「资源不存在」),
|
|
247
|
+
* 而 Authorization 里带的账号就是一个手机号,所以面板默认只显示脱敏串。
|
|
248
|
+
*/
|
|
249
|
+
function maskAccount(value) {
|
|
250
|
+
if (!value) return null
|
|
251
|
+
const text = String(value)
|
|
252
|
+
// 手机号:只露前 3 位和后 2 位,中间全隐藏
|
|
253
|
+
if (/^\d{11}$/.test(text)) {
|
|
254
|
+
return `${text.slice(0, 3)}${'*'.repeat(6)}${text.slice(-2)}`
|
|
255
|
+
}
|
|
256
|
+
if (text.includes('@')) {
|
|
257
|
+
const at = text.indexOf('@')
|
|
258
|
+
const name = text.slice(0, at)
|
|
259
|
+
const domain = text.slice(at)
|
|
260
|
+
const head = name.slice(0, Math.min(2, name.length))
|
|
261
|
+
return `${head}${'*'.repeat(Math.max(1, name.length - head.length))}${domain}`
|
|
262
|
+
}
|
|
263
|
+
if (text.length <= 5) return '*'.repeat(text.length)
|
|
264
|
+
return `${text.slice(0, 3)}${'*'.repeat(text.length - 5)}${text.slice(-2)}`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ------------------------------------------------------------------ 凭据
|
|
268
|
+
|
|
269
|
+
function dpapi(input, decrypt = false) {
|
|
270
|
+
const script = [
|
|
271
|
+
"$ErrorActionPreference = 'Stop'",
|
|
272
|
+
'Add-Type -AssemblyName System.Security',
|
|
273
|
+
'$bytes = [Convert]::FromBase64String([Console]::In.ReadToEnd())',
|
|
274
|
+
'$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
|
|
275
|
+
`$result = [Security.Cryptography.ProtectedData]::${decrypt ? 'Unprotect' : 'Protect'}($bytes, $null, $scope)`,
|
|
276
|
+
'[Console]::Out.Write([Convert]::ToBase64String($result))',
|
|
277
|
+
].join('; ')
|
|
278
|
+
const executable = join(
|
|
279
|
+
process.env.SystemRoot ?? 'C:\\Windows',
|
|
280
|
+
'System32',
|
|
281
|
+
'WindowsPowerShell',
|
|
282
|
+
'v1.0',
|
|
283
|
+
'powershell.exe',
|
|
284
|
+
)
|
|
285
|
+
const result = spawnSync(executable, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
286
|
+
input: input.toString('base64'),
|
|
287
|
+
encoding: 'utf8',
|
|
288
|
+
windowsHide: true,
|
|
289
|
+
timeout: 20_000,
|
|
290
|
+
})
|
|
291
|
+
if (result.status !== 0 || !result.stdout?.trim()) {
|
|
292
|
+
throw new Error('Windows 无法访问凭据存储(DPAPI 调用失败)')
|
|
293
|
+
}
|
|
294
|
+
return Buffer.from(result.stdout.trim(), 'base64')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function createCredentialStore(directory, legacyDirectories = []) {
|
|
298
|
+
const fileName = windows ? 'authorization.dpapi' : 'authorization.json'
|
|
299
|
+
const file = join(directory, fileName)
|
|
300
|
+
|
|
301
|
+
/** 把某个文件解成凭据;解不开就返回 null。 */
|
|
302
|
+
function readFrom(target) {
|
|
303
|
+
let bytes
|
|
304
|
+
try {
|
|
305
|
+
bytes = readFileSync(target)
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error.code === 'ENOENT') return null
|
|
308
|
+
throw error
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const text = (windows ? dpapi(bytes, true) : bytes).toString('utf8')
|
|
312
|
+
const parsed = JSON.parse(text)
|
|
313
|
+
if (!parsed || typeof parsed.authorization !== 'string' || !parsed.authorization) return null
|
|
314
|
+
return {
|
|
315
|
+
authorization: parsed.authorization,
|
|
316
|
+
userDomainId: typeof parsed.userDomainId === 'string' ? parsed.userDomainId : '',
|
|
317
|
+
// 只有用户主动勾选「保存密码」时才有;明文密码同样走 DPAPI 加密落盘
|
|
318
|
+
savedAccount: typeof parsed.savedAccount === 'string' ? parsed.savedAccount : '',
|
|
319
|
+
savedPassword: typeof parsed.savedPassword === 'string' ? parsed.savedPassword : '',
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
// 存储坏了就当没授权,别把整个插件拖死
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
file,
|
|
329
|
+
read() {
|
|
330
|
+
// 先看新位置
|
|
331
|
+
const current = readFrom(file)
|
|
332
|
+
if (current) return current
|
|
333
|
+
// 再看旧位置(插件改名前叫 dsh-mobile-cloud)
|
|
334
|
+
for (const legacyDir of legacyDirectories) {
|
|
335
|
+
const legacy = readFrom(join(legacyDir, fileName))
|
|
336
|
+
if (legacy) return legacy
|
|
337
|
+
}
|
|
338
|
+
return null
|
|
339
|
+
},
|
|
340
|
+
write(value) {
|
|
341
|
+
const payload = Buffer.from(JSON.stringify(value), 'utf8')
|
|
342
|
+
const stored = windows ? dpapi(payload) : payload
|
|
343
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
344
|
+
const temporary = join(directory, randomUUID())
|
|
345
|
+
try {
|
|
346
|
+
writeFileSync(temporary, stored, { mode: 0o600, flag: 'wx' })
|
|
347
|
+
renameSync(temporary, file)
|
|
348
|
+
} finally {
|
|
349
|
+
rmSync(temporary, { force: true })
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
clear() {
|
|
353
|
+
rmSync(file, { force: true })
|
|
354
|
+
// 老位置也一起清掉,否则「清除授权」看起来没生效
|
|
355
|
+
for (const legacyDir of legacyDirectories) {
|
|
356
|
+
rmSync(join(legacyDir, fileName), { force: true })
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ------------------------------------------------------------------ Authorization 解析 / 刷新
|
|
363
|
+
|
|
364
|
+
function decodeAuthorization(authorization) {
|
|
365
|
+
let decoded
|
|
366
|
+
try {
|
|
367
|
+
decoded = Buffer.from(authorization, 'base64').toString('utf8')
|
|
368
|
+
} catch {
|
|
369
|
+
throw new Error('Authorization 不是合法的 base64')
|
|
370
|
+
}
|
|
371
|
+
const firstColon = decoded.indexOf(':')
|
|
372
|
+
const secondColon = firstColon < 0 ? -1 : decoded.indexOf(':', firstColon + 1)
|
|
373
|
+
if (firstColon < 0 || secondColon < 0) {
|
|
374
|
+
throw new Error('Authorization 结构不对,应该是 base64 的 pc:账号:令牌')
|
|
375
|
+
}
|
|
376
|
+
const account = decoded.slice(firstColon + 1, secondColon)
|
|
377
|
+
const tokenPart = decoded.slice(secondColon + 1)
|
|
378
|
+
const fields = tokenPart.split('|')
|
|
379
|
+
const expiration = fields.length >= 4 ? Number(fields[3]) : Number.NaN
|
|
380
|
+
return { account, tokenPart, expiration: Number.isFinite(expiration) ? expiration : null }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function readXmlField(xml, tag) {
|
|
384
|
+
const matched = new RegExp(`<${tag}>\\s*([\\s\\S]*?)\\s*</${tag}>`, 'i').exec(xml)
|
|
385
|
+
return matched ? matched[1] : ''
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ------------------------------------------------------------------ 原生文件对话框
|
|
389
|
+
|
|
390
|
+
const PICKER_TIMEOUT_MS = 5 * 60 * 1000
|
|
391
|
+
|
|
392
|
+
function powershellPath() {
|
|
393
|
+
return join(
|
|
394
|
+
process.env.SystemRoot ?? 'C:\\Windows',
|
|
395
|
+
'System32',
|
|
396
|
+
'WindowsPowerShell',
|
|
397
|
+
'v1.0',
|
|
398
|
+
'powershell.exe',
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 弹系统对话框踩过的坑,一次说清:
|
|
403
|
+
//
|
|
404
|
+
// 1) **对话框会比浏览器低,用户看不见**。Windows 前台锁定规则不允许后台进程抢焦点,
|
|
405
|
+
// SetForegroundWindow 直接返回 False。做法:对话框一出来就 SetWindowPos(HWND_TOPMOST)
|
|
406
|
+
// 顶上去;因为 ShowDialog 是模态的,得靠 WinForms Timer 在模态消息循环里抢。
|
|
407
|
+
// (实测 z 序:修复前「对话框 54 / DSH 45」,修复后「对话框 13 / DSH 57」。)
|
|
408
|
+
// 2) **千万别给对话框传一个 1x1 的 helper 窗体当 owner**。WinForms 会把对话框
|
|
409
|
+
// **居中到 owner 上**,owner 在 (0,0) 就导致对话框被挤到屏幕左上角、大半在屏幕外
|
|
410
|
+
// —— 用户看到的就是「左上角一块白色长方形」,而且完全不像正常的文件对话框。
|
|
411
|
+
// 所以这里**不加 owner**:ShowDialog() 让系统自己居中。
|
|
412
|
+
// (实测:RECT L=220 T=161 W=664 H=572,1536x864 屏幕上完全可见。)
|
|
413
|
+
// 3) **定时器要耐心**。早期版本只找 6 秒,机器一忙对话框还没出来就放弃了,
|
|
414
|
+
// 于是「第一次能弹、后面点几次才出来」。现在最多找 60 秒,出现后再顶住约 3 秒。
|
|
415
|
+
// 4) **路径不能用管道明文回传**。PowerShell 写管道用控制台代码页(国区 GBK),
|
|
416
|
+
// Node 按 UTF-8 解会乱码 —— 文件名一带中文就废。所以用 base64(纯 ASCII)。
|
|
417
|
+
const PICKER_PRELUDE = `
|
|
418
|
+
$ErrorActionPreference = 'Stop'
|
|
419
|
+
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
|
420
|
+
Add-Type -AssemblyName System.Drawing | Out-Null
|
|
421
|
+
Add-Type -Namespace MC -Name Win -MemberDefinition @'
|
|
422
|
+
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
|
423
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
424
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
425
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
426
|
+
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
|
427
|
+
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach);
|
|
428
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassNameW(IntPtr hWnd, System.Text.StringBuilder s, int n);
|
|
429
|
+
public delegate bool EnumProc(IntPtr h, IntPtr l);
|
|
430
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);
|
|
431
|
+
'@
|
|
432
|
+
|
|
433
|
+
$script:me = [uint32][System.Diagnostics.Process]::GetCurrentProcess().Id
|
|
434
|
+
$script:TOPMOST = [IntPtr]::new([int64](-1))
|
|
435
|
+
$script:FLAGS = 0x0001 -bor 0x0002 -bor 0x0010 -bor 0x0040
|
|
436
|
+
|
|
437
|
+
function Set-MyTopmost([IntPtr]$h) {
|
|
438
|
+
if ($h -eq [IntPtr]::Zero) { return }
|
|
439
|
+
[void][MC.Win]::SetWindowPos($h, $script:TOPMOST, 0, 0, 0, 0, $script:FLAGS)
|
|
440
|
+
$fg = [MC.Win]::GetForegroundWindow()
|
|
441
|
+
$fgPid = 0
|
|
442
|
+
$fgTid = [MC.Win]::GetWindowThreadProcessId($fg, [ref]$fgPid)
|
|
443
|
+
$curTid = [MC.Win]::GetCurrentThreadId()
|
|
444
|
+
$attached = $false
|
|
445
|
+
if ($fgTid -ne 0 -and $fgTid -ne $curTid) {
|
|
446
|
+
$attached = [MC.Win]::AttachThreadInput($curTid, $fgTid, $true)
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
[void][MC.Win]::SetForegroundWindow($h)
|
|
450
|
+
} finally {
|
|
451
|
+
if ($attached) { [void][MC.Win]::AttachThreadInput($curTid, $fgTid, $false) }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# 本进程当前开着的那个 #32770 对话框(OpenFileDialog / FolderBrowserDialog 都是这个类)
|
|
456
|
+
function Get-MyDialog {
|
|
457
|
+
$script:hit = [IntPtr]::Zero
|
|
458
|
+
$cb = [MC.Win+EnumProc]{
|
|
459
|
+
param([IntPtr]$h, [IntPtr]$l)
|
|
460
|
+
$p = 0
|
|
461
|
+
[void][MC.Win]::GetWindowThreadProcessId($h, [ref]$p)
|
|
462
|
+
if ($p -eq $script:me) {
|
|
463
|
+
$sb = New-Object System.Text.StringBuilder 64
|
|
464
|
+
[void][MC.Win]::GetClassNameW($h, $sb, 64)
|
|
465
|
+
if ($sb.ToString() -eq '#32770') { $script:hit = $h; return $false }
|
|
466
|
+
}
|
|
467
|
+
return $true
|
|
468
|
+
}
|
|
469
|
+
[void][MC.Win]::EnumWindows($cb, [IntPtr]::Zero)
|
|
470
|
+
return $script:hit
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
$script:ticks = 0
|
|
474
|
+
$script:pushed = 0
|
|
475
|
+
$script:timer = New-Object System.Windows.Forms.Timer
|
|
476
|
+
$script:timer.Interval = 150
|
|
477
|
+
$script:timer.Add_Tick({
|
|
478
|
+
$script:ticks = $script:ticks + 1
|
|
479
|
+
$h = Get-MyDialog
|
|
480
|
+
if ($h -ne [IntPtr]::Zero) {
|
|
481
|
+
Set-MyTopmost $h
|
|
482
|
+
$script:pushed = $script:pushed + 1
|
|
483
|
+
if ($script:pushed -ge 20) { $script:timer.Stop() }
|
|
484
|
+
} elseif ($script:ticks -ge 400) {
|
|
485
|
+
$script:timer.Stop()
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
$script:timer.Start()
|
|
489
|
+
|
|
490
|
+
$emit = { param($p) [Console]::Out.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($p))) }
|
|
491
|
+
`
|
|
492
|
+
|
|
493
|
+
const PICKER_FILES = `${PICKER_PRELUDE}
|
|
494
|
+
$d = New-Object System.Windows.Forms.OpenFileDialog
|
|
495
|
+
$d.AutoUpgradeEnabled = $true
|
|
496
|
+
$d.Title = '选择要上传到移动云盘的文件(可多选)'
|
|
497
|
+
$d.Multiselect = $true
|
|
498
|
+
$d.CheckFileExists = $true
|
|
499
|
+
$r = $d.ShowDialog()
|
|
500
|
+
$script:timer.Stop()
|
|
501
|
+
if ($r -eq [System.Windows.Forms.DialogResult]::OK) {
|
|
502
|
+
foreach ($f in $d.FileNames) { & $emit $f }
|
|
503
|
+
}
|
|
504
|
+
`
|
|
505
|
+
|
|
506
|
+
const PICKER_FOLDER = `${PICKER_PRELUDE}
|
|
507
|
+
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
508
|
+
$d.Description = '选择要上传到移动云盘的文件夹'
|
|
509
|
+
$d.ShowNewFolderButton = $false
|
|
510
|
+
$r = $d.ShowDialog()
|
|
511
|
+
$script:timer.Stop()
|
|
512
|
+
if ($r -eq [System.Windows.Forms.DialogResult]::OK) { & $emit $d.SelectedPath }
|
|
513
|
+
`
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 选「下载目录」专用的对话框。
|
|
517
|
+
*
|
|
518
|
+
* 和上传那个分开:文案不同,而且这里允许「新建文件夹」——
|
|
519
|
+
* 用户选下载目录时往往是想就地建一个,不给这个按钮反而别扭。
|
|
520
|
+
*/
|
|
521
|
+
const PICKER_DOWNLOAD_DIR = `${PICKER_PRELUDE}
|
|
522
|
+
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
523
|
+
$d.Description = '选择文件下载到本地的目录'
|
|
524
|
+
$d.ShowNewFolderButton = $true
|
|
525
|
+
$r = $d.ShowDialog()
|
|
526
|
+
$script:timer.Stop()
|
|
527
|
+
if ($r -eq [System.Windows.Forms.DialogResult]::OK) { & $emit $d.SelectedPath }
|
|
528
|
+
`
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 弹系统原生的「打开文件 / 选择文件夹」对话框,返回选中的真实路径。
|
|
532
|
+
* 走的是宿主端进程,所以拿到的是磁盘真实路径 —— 上传时直接从磁盘流式读,
|
|
533
|
+
* 不用把文件内容先搬到浏览器再搬回来。
|
|
534
|
+
*/
|
|
535
|
+
function pickLocalPaths(mode) {
|
|
536
|
+
if (!windows) {
|
|
537
|
+
return Promise.reject(new Error('原生文件对话框目前只支持 Windows,请直接手填路径'))
|
|
538
|
+
}
|
|
539
|
+
const script =
|
|
540
|
+
mode === 'download-dir' ? PICKER_DOWNLOAD_DIR : mode === 'folder' ? PICKER_FOLDER : PICKER_FILES
|
|
541
|
+
return new Promise((resolvePick, rejectPick) => {
|
|
542
|
+
const child = spawn(powershellPath(), ['-NoProfile', '-STA', '-Command', script], {
|
|
543
|
+
windowsHide: true,
|
|
544
|
+
})
|
|
545
|
+
let stdout = ''
|
|
546
|
+
let stderr = ''
|
|
547
|
+
const timer = setTimeout(() => {
|
|
548
|
+
try {
|
|
549
|
+
child.kill()
|
|
550
|
+
} catch {
|
|
551
|
+
/* 已经退了 */
|
|
552
|
+
}
|
|
553
|
+
rejectPick(new Error('等待选择超时(5 分钟)'))
|
|
554
|
+
}, PICKER_TIMEOUT_MS)
|
|
555
|
+
|
|
556
|
+
child.stdout.setEncoding('utf8')
|
|
557
|
+
child.stderr.setEncoding('utf8')
|
|
558
|
+
child.stdout.on('data', (chunk) => {
|
|
559
|
+
stdout += chunk
|
|
560
|
+
})
|
|
561
|
+
child.stderr.on('data', (chunk) => {
|
|
562
|
+
stderr += chunk
|
|
563
|
+
})
|
|
564
|
+
child.on('error', (error) => {
|
|
565
|
+
clearTimeout(timer)
|
|
566
|
+
rejectPick(error)
|
|
567
|
+
})
|
|
568
|
+
child.on('close', (code) => {
|
|
569
|
+
clearTimeout(timer)
|
|
570
|
+
if (code !== 0 && !stdout.trim()) {
|
|
571
|
+
rejectPick(new Error(`对话框异常退出(code=${code}):${stderr.trim().slice(0, 300) || '无输出'}`))
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
const paths = stdout
|
|
575
|
+
.split(/\r?\n/)
|
|
576
|
+
.map((line) => line.trim())
|
|
577
|
+
.filter(Boolean)
|
|
578
|
+
.map((line) => {
|
|
579
|
+
try {
|
|
580
|
+
return Buffer.from(line, 'base64').toString('utf8')
|
|
581
|
+
} catch {
|
|
582
|
+
return line // 万一不是 base64,原样返回也好过直接丢掉
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
.filter(Boolean)
|
|
586
|
+
resolvePick(paths)
|
|
587
|
+
})
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** 递归列出本地目录,用来做「上传整个文件夹」 */
|
|
592
|
+
async function walkLocal(dir, prefix, tree, depth = 0) {
|
|
593
|
+
if (depth > 32) throw new Error('本地目录层级过深,已中止')
|
|
594
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
595
|
+
for (const entry of entries) {
|
|
596
|
+
const full = join(dir, entry.name)
|
|
597
|
+
const relative = prefix ? join(prefix, entry.name) : entry.name
|
|
598
|
+
if (entry.isDirectory()) {
|
|
599
|
+
tree.dirs.push(relative)
|
|
600
|
+
await walkLocal(full, relative, tree, depth + 1)
|
|
601
|
+
} else if (entry.isFile()) {
|
|
602
|
+
let size = 0
|
|
603
|
+
try {
|
|
604
|
+
size = statSync(full).size
|
|
605
|
+
} catch {
|
|
606
|
+
continue // 读不到就跳过,别让一个坏文件毁掉整次上传
|
|
607
|
+
}
|
|
608
|
+
tree.files.push({ local: full, relative, size })
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ------------------------------------------------------------------ 客户
|
|
614
|
+
|
|
615
|
+
function createClient(store, logger, config) {
|
|
616
|
+
let credential = store.read()
|
|
617
|
+
let host = null
|
|
618
|
+
let quirkNoUserDomainId = false
|
|
619
|
+
|
|
620
|
+
function current() {
|
|
621
|
+
return credential
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function update(next) {
|
|
625
|
+
credential = next
|
|
626
|
+
store.write(next)
|
|
627
|
+
quirkNoUserDomainId = false
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function forget() {
|
|
631
|
+
credential = null
|
|
632
|
+
store.clear()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function assertAuthorized() {
|
|
636
|
+
if (!credential?.authorization) {
|
|
637
|
+
throw new Error('还没有登录。请打开「移动云盘」面板,用扫码或账号密码登录。')
|
|
638
|
+
}
|
|
639
|
+
return credential
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 令牌快到期(<15 天)就换新的,跟 OpenList 一个判断 */
|
|
643
|
+
async function ensureFresh() {
|
|
644
|
+
const value = assertAuthorized()
|
|
645
|
+
const info = decodeAuthorization(value.authorization)
|
|
646
|
+
if (info.expiration !== null) {
|
|
647
|
+
const remaining = info.expiration - Date.now()
|
|
648
|
+
if (remaining < 0) throw new Error('登录已过期,请重新登录')
|
|
649
|
+
if (remaining > 15 * 24 * 60 * 60 * 1000) return value
|
|
650
|
+
}
|
|
651
|
+
const body =
|
|
652
|
+
`<root><token>${info.tokenPart}</token><account>${info.account}</account><clienttype>656</clienttype></root>`
|
|
653
|
+
const response = await fetch(REFRESH_URL, {
|
|
654
|
+
method: 'POST',
|
|
655
|
+
headers: { 'content-type': 'application/xml' },
|
|
656
|
+
body,
|
|
657
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
658
|
+
})
|
|
659
|
+
const xml = await response.text()
|
|
660
|
+
if (readXmlField(xml, 'return') !== '0') {
|
|
661
|
+
throw new Error(`刷新授权失败:${readXmlField(xml, 'desc') || xml.slice(0, 200)}`)
|
|
662
|
+
}
|
|
663
|
+
const token = readXmlField(xml, 'token')
|
|
664
|
+
if (!token) throw new Error('刷新授权失败:返回里没有 token')
|
|
665
|
+
const next = {
|
|
666
|
+
authorization: Buffer.from(`pc:${info.account}:${token}`, 'utf8').toString('base64'),
|
|
667
|
+
userDomainId: value.userDomainId,
|
|
668
|
+
}
|
|
669
|
+
update(next)
|
|
670
|
+
logger?.info?.('mobile-cloud: 授权已自动续期')
|
|
671
|
+
return next
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function buildHeaders(bodyString, personal) {
|
|
675
|
+
const timestamp = formatTimestamp()
|
|
676
|
+
const randStr = randomString(16)
|
|
677
|
+
const sign = calcSign(bodyString, timestamp, randStr)
|
|
678
|
+
const base = {
|
|
679
|
+
accept: 'application/json, text/plain, */*',
|
|
680
|
+
'content-type': 'application/json;charset=UTF-8',
|
|
681
|
+
authorization: `Basic ${assertAuthorized().authorization}`,
|
|
682
|
+
'mcloud-channel': '1000101',
|
|
683
|
+
'mcloud-client': '10701',
|
|
684
|
+
'mcloud-sign': `${timestamp},${randStr},${sign}`,
|
|
685
|
+
'mcloud-version': '7.14.0',
|
|
686
|
+
'x-deviceinfo': '||9|7.14.0|chrome|120.0.0.0|||windows 10||zh-CN|||',
|
|
687
|
+
'x-huawei-channelsrc': '10000034',
|
|
688
|
+
'x-inner-ntwk': '2',
|
|
689
|
+
'x-m4c-caller': 'PC',
|
|
690
|
+
'x-m4c-src': '10002',
|
|
691
|
+
'x-svctype': '1',
|
|
692
|
+
}
|
|
693
|
+
if (!personal) {
|
|
694
|
+
return {
|
|
695
|
+
...base,
|
|
696
|
+
'cms-device': 'default',
|
|
697
|
+
origin: 'https://yun.139.com',
|
|
698
|
+
referer: 'https://yun.139.com/w/',
|
|
699
|
+
'inner-hcy-router-https': '1',
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
...base,
|
|
704
|
+
caller: 'web',
|
|
705
|
+
'cms-device': 'default',
|
|
706
|
+
'mcloud-route': '001',
|
|
707
|
+
'x-yun-api-version': 'v1',
|
|
708
|
+
'x-yun-app-channel': '10000034',
|
|
709
|
+
'x-yun-channel-source': '10000034',
|
|
710
|
+
'x-yun-client-info':
|
|
711
|
+
'||9|7.14.0|chrome|120.0.0.0|||windows 10||zh-CN|||dW5kZWZpbmVk||',
|
|
712
|
+
'x-yun-module-type': '100',
|
|
713
|
+
'x-yun-svc-type': '1',
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* 发一个带签名的请求。
|
|
719
|
+
* 签名和 body 一定来自同一个字符串 —— 这是这个接口唯一容易踩的坑。
|
|
720
|
+
*
|
|
721
|
+
* 网络层带重试:移动云盘偶发 ECONNRESET / 超时 / 5xx,以前一抖就把
|
|
722
|
+
* 「fetch failed」原样抛给界面(目录加载不出来、删除失败都是它)。
|
|
723
|
+
* 只对**瞬时错误**重试;业务错误(success:false)立即抛出,不浪费用户时间。
|
|
724
|
+
*/
|
|
725
|
+
async function call(url, payload, { personal = true, signal } = {}) {
|
|
726
|
+
const bodyString = JSON.stringify(payload ?? {})
|
|
727
|
+
const headers = buildHeaders(bodyString, personal)
|
|
728
|
+
|
|
729
|
+
let lastError = null
|
|
730
|
+
for (let attempt = 1; attempt <= MCLOUD_RETRY_ATTEMPTS; attempt += 1) {
|
|
731
|
+
try {
|
|
732
|
+
const response = await fetch(url, {
|
|
733
|
+
method: 'POST',
|
|
734
|
+
headers,
|
|
735
|
+
body: bodyString,
|
|
736
|
+
signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
// 5xx / 429 属于「待会儿再试可能就好」,值得重试
|
|
740
|
+
if ((response.status >= 500 || response.status === 429) && attempt < MCLOUD_RETRY_ATTEMPTS) {
|
|
741
|
+
lastError = new Error(`移动云盘暂时不可用(HTTP ${response.status})`)
|
|
742
|
+
await sleep(MCLOUD_RETRY_DELAY_MS * attempt)
|
|
743
|
+
continue
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const text = await response.text()
|
|
747
|
+
let json = null
|
|
748
|
+
try {
|
|
749
|
+
json = JSON.parse(text)
|
|
750
|
+
} catch {
|
|
751
|
+
throw new Error(`移动云盘返回了非 JSON 内容(HTTP ${response.status}):${text.slice(0, 200)}`)
|
|
752
|
+
}
|
|
753
|
+
if (json && json.success === false) {
|
|
754
|
+
// 业务错误:重试没用,直接抛。
|
|
755
|
+
throw new Error(json.message || json.code || '移动云盘拒绝了这次请求')
|
|
756
|
+
}
|
|
757
|
+
return json
|
|
758
|
+
} catch (error) {
|
|
759
|
+
lastError = error
|
|
760
|
+
if (isPermanentError(error) || attempt >= MCLOUD_RETRY_ATTEMPTS) {
|
|
761
|
+
// 三次都失败:把真正的原因翻出来,别只给一句 "fetch failed"。
|
|
762
|
+
if (/fetch failed/i.test(errorMessage(error))) {
|
|
763
|
+
throw new Error(`连不上移动云盘(已重试 ${attempt} 次):${describeFetchError(error)}`)
|
|
764
|
+
}
|
|
765
|
+
throw error
|
|
766
|
+
}
|
|
767
|
+
logger?.warn?.(`mobile-cloud: 第 ${attempt} 次请求失败,准备重试(${describeFetchError(error)})`)
|
|
768
|
+
await sleep(MCLOUD_RETRY_DELAY_MS * attempt)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
throw lastError ?? new Error('移动云盘请求失败')
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function personalUrl(pathname) {
|
|
775
|
+
if (!host) throw new Error('移动云盘 host 还没解析出来')
|
|
776
|
+
return host + pathname
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function resolveHost() {
|
|
780
|
+
const value = assertAuthorized()
|
|
781
|
+
const account = decodeAuthorization(value.authorization).account
|
|
782
|
+
try {
|
|
783
|
+
const json = await call(
|
|
784
|
+
`${USER_HOST}/user/route/qryRoutePolicy`,
|
|
785
|
+
{
|
|
786
|
+
userInfo: { userType: 1, accountType: 1, accountName: account },
|
|
787
|
+
modAddrType: 1,
|
|
788
|
+
},
|
|
789
|
+
{ personal: false },
|
|
790
|
+
)
|
|
791
|
+
const list = json?.data?.routePolicyList
|
|
792
|
+
if (Array.isArray(list)) {
|
|
793
|
+
for (const item of list) {
|
|
794
|
+
if (item?.modName === 'personal' && typeof item.httpsUrl === 'string' && item.httpsUrl) {
|
|
795
|
+
host = item.httpsUrl.replace(/\/+$/, '')
|
|
796
|
+
logger?.info?.(`mobile-cloud: 个人云 host = ${host}`)
|
|
797
|
+
return host
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
} catch (error) {
|
|
802
|
+
logger?.warn?.(`mobile-cloud: 路由查询失败,回落到默认 host(${errorMessage(error)})`)
|
|
803
|
+
}
|
|
804
|
+
host = FALLBACK_PERSONAL_HOST
|
|
805
|
+
return host
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function ensureHost() {
|
|
809
|
+
if (!host) await resolveHost()
|
|
810
|
+
return host
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function withHostRetry(operation) {
|
|
814
|
+
await ensureHost()
|
|
815
|
+
try {
|
|
816
|
+
return await operation()
|
|
817
|
+
} catch (error) {
|
|
818
|
+
// host 可能是过期的路由,重解析一次再试
|
|
819
|
+
const message = errorMessage(error)
|
|
820
|
+
if (/host|404|not found|权限|无效/i.test(message)) {
|
|
821
|
+
host = null
|
|
822
|
+
await ensureHost()
|
|
823
|
+
return await operation()
|
|
824
|
+
}
|
|
825
|
+
throw error
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ---------------------------------------------------------- 具体接口
|
|
830
|
+
|
|
831
|
+
const listCache = createCache(LIST_CACHE_TTL_MS, LIST_CACHE_MAX)
|
|
832
|
+
|
|
833
|
+
async function listFiles(parentFileId, cursor = '', pageSize = LIST_PAGE_SIZE, { force = false } = {}) {
|
|
834
|
+
const key = `${parentFileId || ROOT_ID}|${cursor || ''}|${clampPageSize(pageSize)}`
|
|
835
|
+
if (!force) {
|
|
836
|
+
const cached = listCache.get(key)
|
|
837
|
+
if (cached) return cached
|
|
838
|
+
}
|
|
839
|
+
const result = await withHostRetry(async () => {
|
|
840
|
+
// ⚠️ 不要带 imageThumbnailStyleList!
|
|
841
|
+
// 实测:100 张图的目录带上它,服务端 3 秒直接 ECONNRESET;不带它虽然也要 6 秒但能成功。
|
|
842
|
+
// 而且本插件的界面根本不显示缩略图 —— 纯纯的无谓负担。
|
|
843
|
+
const json = await call(personalUrl('/file/list'), {
|
|
844
|
+
orderBy: 'updated_at',
|
|
845
|
+
orderDirection: 'DESC',
|
|
846
|
+
pageInfo: { pageCursor: cursor || '', pageSize: clampPageSize(pageSize) },
|
|
847
|
+
parentFileId: parentFileId || ROOT_ID,
|
|
848
|
+
})
|
|
849
|
+
const items = Array.isArray(json?.data?.items) ? json.data.items : []
|
|
850
|
+
return {
|
|
851
|
+
items: items.map((item) => ({
|
|
852
|
+
id: String(item.fileId ?? ''),
|
|
853
|
+
name: String(item.name ?? ''),
|
|
854
|
+
size: Number(item.size ?? 0),
|
|
855
|
+
type: String(item.type ?? '').toLowerCase() === 'folder' ? 'folder' : 'file',
|
|
856
|
+
updatedAt: String(item.updatedAt ?? ''),
|
|
857
|
+
thumbnail: null,
|
|
858
|
+
})),
|
|
859
|
+
nextCursor: json?.data?.nextPageCursor ? String(json.data.nextPageCursor) : null,
|
|
860
|
+
}
|
|
861
|
+
})
|
|
862
|
+
listCache.set(key, result)
|
|
863
|
+
return result
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** 任何会改动网盘内容的操作之后,缓存一律作废(宁可多拉一次,也不能给旧数据)。 */
|
|
867
|
+
function invalidateListings() {
|
|
868
|
+
listCache.clear()
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function getDownloadUrl(fileId) {
|
|
872
|
+
return withHostRetry(async () => {
|
|
873
|
+
const json = await call(personalUrl('/file/getDownloadUrl'), { fileId })
|
|
874
|
+
const data = json?.data ?? {}
|
|
875
|
+
if (data.cdnSwitch && data.cdnUrl) return String(data.cdnUrl)
|
|
876
|
+
if (data.url) return String(data.url)
|
|
877
|
+
if (data.cdnUrl) return String(data.cdnUrl)
|
|
878
|
+
throw new Error('移动云盘没有返回下载直链')
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function makeDir(parentFileId, dirName) {
|
|
883
|
+
try {
|
|
884
|
+
return await withHostRetry(() =>
|
|
885
|
+
call(personalUrl('/file/create'), {
|
|
886
|
+
parentFileId: parentFileId || ROOT_ID,
|
|
887
|
+
name: dirName,
|
|
888
|
+
description: '',
|
|
889
|
+
type: 'folder',
|
|
890
|
+
fileRenameMode: 'force_rename',
|
|
891
|
+
}),
|
|
892
|
+
)
|
|
893
|
+
} finally {
|
|
894
|
+
invalidateListings()
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function renameNode(fileId, nextName) {
|
|
899
|
+
try {
|
|
900
|
+
return await withHostRetry(() =>
|
|
901
|
+
call(personalUrl('/file/update'), { fileId, name: nextName, description: '' }),
|
|
902
|
+
)
|
|
903
|
+
} finally {
|
|
904
|
+
invalidateListings()
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function moveNodes(fileIds, toParentFileId) {
|
|
909
|
+
try {
|
|
910
|
+
return await withHostRetry(() =>
|
|
911
|
+
call(personalUrl('/file/batchMove'), {
|
|
912
|
+
fileIds: fileIds.map(String),
|
|
913
|
+
toParentFileId: toParentFileId || ROOT_ID,
|
|
914
|
+
}),
|
|
915
|
+
)
|
|
916
|
+
} finally {
|
|
917
|
+
invalidateListings()
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function removeNodes(fileIds) {
|
|
922
|
+
try {
|
|
923
|
+
return await withHostRetry(() =>
|
|
924
|
+
call(personalUrl('/recyclebin/batchTrash'), { fileIds: fileIds.map(String) }),
|
|
925
|
+
)
|
|
926
|
+
} finally {
|
|
927
|
+
invalidateListings()
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** 确保父目录下有个叫 name 的文件夹,返回它的 id。已存在就复用,不重复创建。 */
|
|
932
|
+
async function ensureFolder(parentFileId, name) {
|
|
933
|
+
const parent = parentFileId || ROOT_ID
|
|
934
|
+
const findExisting = async () => {
|
|
935
|
+
let cursor = ''
|
|
936
|
+
do {
|
|
937
|
+
const page = await listFiles(parent, cursor)
|
|
938
|
+
const hit = page.items.find((item) => item.type === 'folder' && item.name === name)
|
|
939
|
+
if (hit) return hit.id
|
|
940
|
+
cursor = page.nextCursor || ''
|
|
941
|
+
} while (cursor)
|
|
942
|
+
return null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const existing = await findExisting()
|
|
946
|
+
if (existing) return existing
|
|
947
|
+
|
|
948
|
+
const created = await withHostRetry(() =>
|
|
949
|
+
call(personalUrl('/file/create'), {
|
|
950
|
+
parentFileId: parent,
|
|
951
|
+
name,
|
|
952
|
+
description: '',
|
|
953
|
+
type: 'folder',
|
|
954
|
+
fileRenameMode: 'force_rename',
|
|
955
|
+
}),
|
|
956
|
+
)
|
|
957
|
+
const id = created?.data?.fileId
|
|
958
|
+
if (id) return String(id)
|
|
959
|
+
|
|
960
|
+
// 有些响应不回 id,再列一次兜底
|
|
961
|
+
const after = await findExisting()
|
|
962
|
+
if (after) return after
|
|
963
|
+
throw new Error(`建好文件夹「${name}」之后没能拿到它的 ID`)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ---------------------------------------------------------- 传输(上传 / 下载)
|
|
967
|
+
|
|
968
|
+
const jobs = new Map()
|
|
969
|
+
|
|
970
|
+
function accountOf() {
|
|
971
|
+
return decodeAuthorization(assertAuthorized().authorization).account
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function snapshot(job) {
|
|
975
|
+
return {
|
|
976
|
+
id: job.id,
|
|
977
|
+
kind: job.kind,
|
|
978
|
+
label: job.label,
|
|
979
|
+
phase: job.phase,
|
|
980
|
+
done: job.done,
|
|
981
|
+
total: job.total,
|
|
982
|
+
state: job.state,
|
|
983
|
+
error: job.error,
|
|
984
|
+
result: job.result,
|
|
985
|
+
currentFile: job.currentFile ?? null,
|
|
986
|
+
fileCount: job.fileCount ?? null,
|
|
987
|
+
startedAt: job.startedAt,
|
|
988
|
+
finishedAt: job.finishedAt,
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** 起一个后台传输任务。job.promise 给工具用(可以 await 到结束) */
|
|
993
|
+
function startJob(kind, label, runner) {
|
|
994
|
+
const job = {
|
|
995
|
+
id: randomUUID().slice(0, 8),
|
|
996
|
+
kind,
|
|
997
|
+
label,
|
|
998
|
+
phase: 'prepare',
|
|
999
|
+
done: 0,
|
|
1000
|
+
total: 0,
|
|
1001
|
+
state: 'running',
|
|
1002
|
+
error: null,
|
|
1003
|
+
result: null,
|
|
1004
|
+
startedAt: Date.now(),
|
|
1005
|
+
finishedAt: null,
|
|
1006
|
+
controller: new AbortController(),
|
|
1007
|
+
}
|
|
1008
|
+
job.signal = job.controller.signal
|
|
1009
|
+
jobs.set(job.id, job)
|
|
1010
|
+
|
|
1011
|
+
// 顺手清掉半小时前就结束的任务,别让 Map 无限长
|
|
1012
|
+
for (const [id, old] of jobs) {
|
|
1013
|
+
if (old.state !== 'running' && Date.now() - (old.finishedAt ?? old.startedAt) > 30 * 60 * 1000) {
|
|
1014
|
+
jobs.delete(id)
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
job.promise = (async () => {
|
|
1019
|
+
try {
|
|
1020
|
+
const result = await runner(job)
|
|
1021
|
+
job.state = 'done'
|
|
1022
|
+
job.result = result ?? null
|
|
1023
|
+
return result
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
job.state = 'error'
|
|
1026
|
+
job.error = errorMessage(error)
|
|
1027
|
+
throw error
|
|
1028
|
+
} finally {
|
|
1029
|
+
job.finishedAt = Date.now()
|
|
1030
|
+
}
|
|
1031
|
+
})()
|
|
1032
|
+
// 面板只轮询不用 await,这里兜一下避免 unhandled rejection
|
|
1033
|
+
job.promise.catch(() => {})
|
|
1034
|
+
return job
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function getJob(id) {
|
|
1038
|
+
const job = jobs.get(id)
|
|
1039
|
+
return job ? snapshot(job) : null
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function listJobs() {
|
|
1043
|
+
return [...jobs.values()]
|
|
1044
|
+
.sort((left, right) => right.startedAt - left.startedAt)
|
|
1045
|
+
.slice(0, 20)
|
|
1046
|
+
.map(snapshot)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function cancelJob(id) {
|
|
1050
|
+
const job = jobs.get(id)
|
|
1051
|
+
if (!job || job.state !== 'running') return false
|
|
1052
|
+
job.controller.abort()
|
|
1053
|
+
return true
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* 清空「已经结束」的任务记录。
|
|
1058
|
+
*
|
|
1059
|
+
* ⚠️ 只动内存里的记录表,**不碰网盘和本地文件**。
|
|
1060
|
+
* 正在跑的任务不能删 —— 删了界面就再也看不到它的进度了,
|
|
1061
|
+
* 而且用户点「清空」时想清的显然是历史,不是眼前这条。
|
|
1062
|
+
*/
|
|
1063
|
+
function clearFinishedJobs() {
|
|
1064
|
+
let removed = 0
|
|
1065
|
+
for (const [id, job] of jobs) {
|
|
1066
|
+
if (job.state !== 'running') {
|
|
1067
|
+
jobs.delete(id)
|
|
1068
|
+
removed += 1
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return removed
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* 上传本地文件。流程见 transfer.js 顶部的注释。
|
|
1076
|
+
* 注意:整个文件只流式读一遍算哈希,分片也是流式 PUT,不把文件读进内存。
|
|
1077
|
+
*/
|
|
1078
|
+
async function uploadFile(localPath, parentFileId, nameOverride, job) {
|
|
1079
|
+
// 单文件任务是「这个任务就是这个文件」,所以它负责维护 total / phase;
|
|
1080
|
+
// 文件夹任务由 uploadFolder 统一管进度,子文件不要抢。
|
|
1081
|
+
const solo = job.kind === 'upload'
|
|
1082
|
+
const stats = statSync(localPath)
|
|
1083
|
+
if (!stats.isFile()) throw new Error(`不是一个文件:${localPath}`)
|
|
1084
|
+
const size = stats.size
|
|
1085
|
+
const fileName = String(nameOverride || basename(localPath))
|
|
1086
|
+
const partInfos = buildPartInfos(size, partSizeFor(size))
|
|
1087
|
+
|
|
1088
|
+
// 每个文件对进度的贡献恒等于它的字节数 —— 不管是真传了、秒传了,
|
|
1089
|
+
// 还是服务端压根没给分片地址。否则 total(按本地文件大小算出来的)
|
|
1090
|
+
// 永远对不上 done,进度条就卡在中间到不了 100%。
|
|
1091
|
+
const startDone = job.done
|
|
1092
|
+
const settle = () => {
|
|
1093
|
+
const gained = job.done - startDone
|
|
1094
|
+
if (gained < size) job.done += size - gained
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (solo) {
|
|
1098
|
+
job.total = size
|
|
1099
|
+
job.phase = 'hash'
|
|
1100
|
+
}
|
|
1101
|
+
const contentHash = await sha256File(localPath)
|
|
1102
|
+
if (job.signal.aborted) throw new Error('已取消')
|
|
1103
|
+
|
|
1104
|
+
if (solo) job.phase = 'create'
|
|
1105
|
+
const created = await withHostRetry(() =>
|
|
1106
|
+
call(personalUrl('/file/create'), {
|
|
1107
|
+
contentHash,
|
|
1108
|
+
contentHashAlgorithm: 'SHA256',
|
|
1109
|
+
contentType: UPLOAD_CONTENT_TYPE,
|
|
1110
|
+
parallelUpload: false,
|
|
1111
|
+
partInfos: partInfos.slice(0, PART_BATCH),
|
|
1112
|
+
size,
|
|
1113
|
+
parentFileId: parentFileId || ROOT_ID,
|
|
1114
|
+
name: fileName,
|
|
1115
|
+
type: 'file',
|
|
1116
|
+
fileRenameMode: 'auto_rename',
|
|
1117
|
+
}),
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
const task = created?.data ?? {}
|
|
1121
|
+
if (task.exist === true || task.rapidUpload === true) {
|
|
1122
|
+
settle() // 秒传:字节没走网络,但这个文件确实「传完了」
|
|
1123
|
+
return {
|
|
1124
|
+
dedup: true,
|
|
1125
|
+
fileId: task.fileId ?? null,
|
|
1126
|
+
name: task.fileName ?? fileName,
|
|
1127
|
+
size,
|
|
1128
|
+
message: '网盘里已有完全相同的内容,服务端直接秒传,没有真的传数据。',
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const fileId = task.fileId
|
|
1133
|
+
const uploadId = task.uploadId
|
|
1134
|
+
if (!fileId || !uploadId) {
|
|
1135
|
+
throw new Error('创建上传任务失败:服务端没返回 fileId / uploadId')
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (solo) job.phase = 'upload'
|
|
1139
|
+
const account = accountOf()
|
|
1140
|
+
await putParts(partInfos, task.partInfos, localPath, (n) => {
|
|
1141
|
+
job.done += n
|
|
1142
|
+
}, job.signal)
|
|
1143
|
+
|
|
1144
|
+
// 超过 100 个分片要分批取上传地址
|
|
1145
|
+
for (let index = PART_BATCH; index < partInfos.length; index += PART_BATCH) {
|
|
1146
|
+
const batch = partInfos.slice(index, index + PART_BATCH)
|
|
1147
|
+
const more = await withHostRetry(() =>
|
|
1148
|
+
call(personalUrl('/file/getUploadUrl'), {
|
|
1149
|
+
fileId,
|
|
1150
|
+
uploadId,
|
|
1151
|
+
partInfos: batch,
|
|
1152
|
+
commonAccountInfo: { account, accountType: 1 },
|
|
1153
|
+
}),
|
|
1154
|
+
)
|
|
1155
|
+
await putParts(partInfos, more?.data?.partInfos, localPath, (n) => {
|
|
1156
|
+
job.done += n
|
|
1157
|
+
}, job.signal)
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (solo) job.phase = 'complete'
|
|
1161
|
+
await withHostRetry(() =>
|
|
1162
|
+
call(personalUrl('/file/complete'), {
|
|
1163
|
+
contentHash,
|
|
1164
|
+
contentHashAlgorithm: 'SHA256',
|
|
1165
|
+
fileId,
|
|
1166
|
+
uploadId,
|
|
1167
|
+
}),
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
settle()
|
|
1171
|
+
invalidateListings()
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
dedup: false,
|
|
1175
|
+
fileId,
|
|
1176
|
+
name: task.fileName ?? fileName,
|
|
1177
|
+
size,
|
|
1178
|
+
parts: partInfos.length,
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** 上传整个本地文件夹:远端按同样结构建好(同名文件夹直接复用) */
|
|
1183
|
+
async function uploadFolder(localPath, parentFileId, job) {
|
|
1184
|
+
const stats = statSync(localPath)
|
|
1185
|
+
if (!stats.isDirectory()) throw new Error(`不是一个文件夹:${localPath}`)
|
|
1186
|
+
|
|
1187
|
+
job.phase = 'scan'
|
|
1188
|
+
const tree = { dirs: [], files: [] }
|
|
1189
|
+
await walkLocal(localPath, '', tree)
|
|
1190
|
+
job.total = tree.files.reduce((sum, file) => sum + file.size, 0)
|
|
1191
|
+
job.fileCount = tree.files.length
|
|
1192
|
+
if (job.signal.aborted) throw new Error('已取消')
|
|
1193
|
+
|
|
1194
|
+
const rootName = basename(localPath)
|
|
1195
|
+
const rootId = await ensureFolder(parentFileId || ROOT_ID, rootName)
|
|
1196
|
+
const idMap = new Map([['', rootId]])
|
|
1197
|
+
|
|
1198
|
+
job.phase = 'create'
|
|
1199
|
+
for (const dir of tree.dirs) {
|
|
1200
|
+
if (job.signal.aborted) throw new Error('已取消')
|
|
1201
|
+
job.currentFile = dir
|
|
1202
|
+
const parentRel = dirname(dir)
|
|
1203
|
+
const parentRemote = idMap.get(parentRel === '.' ? '' : parentRel) ?? rootId
|
|
1204
|
+
idMap.set(dir, await ensureFolder(parentRemote, basename(dir)))
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
job.phase = 'upload'
|
|
1208
|
+
for (const file of tree.files) {
|
|
1209
|
+
if (job.signal.aborted) throw new Error('已取消')
|
|
1210
|
+
job.currentFile = file.relative
|
|
1211
|
+
const parentRel = dirname(file.relative)
|
|
1212
|
+
const parentRemote = idMap.get(parentRel === '.' ? '' : parentRel) ?? rootId
|
|
1213
|
+
await uploadFile(file.local, parentRemote, basename(file.relative), job)
|
|
1214
|
+
}
|
|
1215
|
+
job.currentFile = null
|
|
1216
|
+
invalidateListings()
|
|
1217
|
+
return { rootId, name: rootName, files: tree.files.length, bytes: job.done }
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* 给「拖拽上传」用:拖进来的文件浏览器只给字节、不给真实路径,
|
|
1222
|
+
* 所以先把请求体落到临时文件,走完正常上传流程再删掉。
|
|
1223
|
+
*/
|
|
1224
|
+
async function uploadFromTempPath(tempPath, parentFileId, name, job) {
|
|
1225
|
+
try {
|
|
1226
|
+
return await uploadFile(tempPath, parentFileId, name, job)
|
|
1227
|
+
} finally {
|
|
1228
|
+
rmSync(tempPath, { force: true })
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** 把网盘文件流式下载到本地路径(先写 .part-xxx 再改名,失败不留半个文件) */
|
|
1233
|
+
async function downloadFile(fileId, localPath, job) {
|
|
1234
|
+
const url = await getDownloadUrl(fileId)
|
|
1235
|
+
const response = await fetch(url, { redirect: 'follow', signal: job.signal })
|
|
1236
|
+
if (!response.ok) throw new Error(`下载失败 HTTP ${response.status}`)
|
|
1237
|
+
const total = Number(response.headers.get('content-length')) || 0
|
|
1238
|
+
// 文件夹模式下 total 已经是整棵树的合计,别被单个文件覆盖
|
|
1239
|
+
if (total && !job.total) job.total = total
|
|
1240
|
+
|
|
1241
|
+
job.phase = 'download'
|
|
1242
|
+
await streamToFile(response.body, localPath, (bytes) => {
|
|
1243
|
+
job.done += bytes
|
|
1244
|
+
}, job.signal)
|
|
1245
|
+
return { localPath, bytes: job.done }
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* 递归列出一棵目录树。
|
|
1250
|
+
* 移动云盘没有「打包下载」的公开端点(OpenList 的驱动遇到目录直接 NotFile),
|
|
1251
|
+
* 所以文件夹下载只能先把树走一遍,再逐个文件下。
|
|
1252
|
+
*/
|
|
1253
|
+
async function collectFolder(folderId, prefix, out, depth = 0) {
|
|
1254
|
+
if (depth > 32) throw new Error('目录层级过深,可能在打转,已中止')
|
|
1255
|
+
let cursor = ''
|
|
1256
|
+
do {
|
|
1257
|
+
const page = await listFiles(folderId, cursor)
|
|
1258
|
+
for (const item of page.items) {
|
|
1259
|
+
if (item.type === 'folder') {
|
|
1260
|
+
await collectFolder(item.id, join(prefix, item.name), out, depth + 1)
|
|
1261
|
+
} else {
|
|
1262
|
+
out.push({ fileId: item.id, relative: join(prefix, item.name), size: item.size })
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
cursor = page.nextCursor || ''
|
|
1266
|
+
} while (cursor)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
async function downloadFolder(folderId, localDir, job) {
|
|
1270
|
+
job.phase = 'scan'
|
|
1271
|
+
const files = []
|
|
1272
|
+
await collectFolder(folderId, '', files)
|
|
1273
|
+
job.total = files.reduce((sum, file) => sum + (Number(file.size) || 0), 0)
|
|
1274
|
+
job.fileCount = files.length
|
|
1275
|
+
job.phase = 'download'
|
|
1276
|
+
|
|
1277
|
+
if (!files.length) {
|
|
1278
|
+
mkdirSync(localDir, { recursive: true })
|
|
1279
|
+
return { localDir, files: 0, bytes: 0, message: '这个文件夹是空的。' }
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
for (const file of files) {
|
|
1283
|
+
if (job.signal.aborted) throw new Error('已取消')
|
|
1284
|
+
job.currentFile = file.relative
|
|
1285
|
+
await downloadFile(file.fileId, join(localDir, file.relative), job)
|
|
1286
|
+
}
|
|
1287
|
+
job.currentFile = null
|
|
1288
|
+
return { localDir, files: files.length, bytes: job.done }
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
async function getQuota() {
|
|
1292
|
+
const value = assertAuthorized()
|
|
1293
|
+
// 注意:OpenList 在没有 userDomainId 时直接拒绝查询,但那是它自己的保守判断,
|
|
1294
|
+
// 接口本身未必校验。所以这里照样发出去,让上游自己决定。
|
|
1295
|
+
const json = await call(
|
|
1296
|
+
`${USER_HOST}/user/disk/quota/detail`,
|
|
1297
|
+
{ userDomainId: value.userDomainId || '' },
|
|
1298
|
+
{ personal: false },
|
|
1299
|
+
)
|
|
1300
|
+
const data = json?.data ?? {}
|
|
1301
|
+
const totalBytes = Number(data.diskSize ?? 0) * MB
|
|
1302
|
+
const freeBytes = Number(data.freeDiskSize ?? 0) * MB
|
|
1303
|
+
if (!totalBytes) {
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
value.userDomainId
|
|
1306
|
+
? '容量接口没返回 diskSize'
|
|
1307
|
+
: '容量接口返回空(大概率确实需要 userDomainId):请在面板「用户域 ID」里填 Cookie 的 ud_id',
|
|
1308
|
+
)
|
|
1309
|
+
}
|
|
1310
|
+
return {
|
|
1311
|
+
totalBytes,
|
|
1312
|
+
freeBytes,
|
|
1313
|
+
usedBytes: Math.max(0, totalBytes - freeBytes),
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
async function status() {
|
|
1318
|
+
const value = credential
|
|
1319
|
+
let account = null
|
|
1320
|
+
let expiresAt = null
|
|
1321
|
+
let quota = null
|
|
1322
|
+
let quotaError = null
|
|
1323
|
+
if (value?.authorization) {
|
|
1324
|
+
try {
|
|
1325
|
+
const info = decodeAuthorization(value.authorization)
|
|
1326
|
+
account = info.account
|
|
1327
|
+
expiresAt = info.expiration
|
|
1328
|
+
await ensureFresh()
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
quotaError = errorMessage(error)
|
|
1331
|
+
}
|
|
1332
|
+
if (!quotaError) {
|
|
1333
|
+
try {
|
|
1334
|
+
quota = await getQuota()
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
quotaError = errorMessage(error)
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
try {
|
|
1340
|
+
await ensureHost()
|
|
1341
|
+
} catch {
|
|
1342
|
+
/* host 解析失败不影响状态展示 */
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return {
|
|
1346
|
+
ok: true,
|
|
1347
|
+
authorized: Boolean(value?.authorization),
|
|
1348
|
+
// 只给脱敏版:完整账号(手机号)不出这个接口,免得随手 F12 就能看到。
|
|
1349
|
+
accountMasked: maskAccount(account),
|
|
1350
|
+
expiresAt,
|
|
1351
|
+
// 凭据被服务端判定失效(过期/改密/踢下线)→ 界面直接引导重新登录,
|
|
1352
|
+
// 而不是把「认证失败(05050006)」这种原始错误码丢给用户。
|
|
1353
|
+
needsReauth: Boolean(quotaError && /认证失败|暂无权限|授权已过期|登录失败/.test(quotaError)),
|
|
1354
|
+
userDomainId: value?.userDomainId ?? null,
|
|
1355
|
+
credentialPath: store.file,
|
|
1356
|
+
quota,
|
|
1357
|
+
quotaError,
|
|
1358
|
+
host,
|
|
1359
|
+
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
|
|
1360
|
+
storageType: 'personal_new',
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async function authorize(authorization, userDomainId) {
|
|
1365
|
+
const cleaned = String(authorization ?? '').trim().replace(/^Basic\s+/i, '')
|
|
1366
|
+
if (!cleaned) throw new Error('Authorization 不能为空')
|
|
1367
|
+
const info = decodeAuthorization(cleaned)
|
|
1368
|
+
update({ authorization: cleaned, userDomainId: String(userDomainId ?? '').trim() })
|
|
1369
|
+
host = null
|
|
1370
|
+
await ensureHost()
|
|
1371
|
+
let quota = null
|
|
1372
|
+
let quotaError = null
|
|
1373
|
+
try {
|
|
1374
|
+
quota = await getQuota()
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
quotaError = errorMessage(error)
|
|
1377
|
+
}
|
|
1378
|
+
return { accountMasked: maskAccount(info.account), quota, quotaError }
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* 用账号密码登录,换出 Authorization。
|
|
1383
|
+
*
|
|
1384
|
+
* rememberPassword = true 时把账号密码也存进凭据文件(DPAPI 加密),
|
|
1385
|
+
* 否则**只在内存里用一次,用完即弃**。
|
|
1386
|
+
*/
|
|
1387
|
+
async function login(account, password, rememberPassword) {
|
|
1388
|
+
const cleanAccount = String(account ?? '').trim()
|
|
1389
|
+
const cleanPassword = String(password ?? '')
|
|
1390
|
+
if (!cleanAccount) throw new Error('请填写手机号或邮箱')
|
|
1391
|
+
if (!cleanPassword) throw new Error('请填写密码')
|
|
1392
|
+
|
|
1393
|
+
const result = await loginWithPassword(cleanAccount, cleanPassword)
|
|
1394
|
+
const next = {
|
|
1395
|
+
authorization: result.authorization,
|
|
1396
|
+
userDomainId: result.userDomainId || '',
|
|
1397
|
+
}
|
|
1398
|
+
if (rememberPassword === true) {
|
|
1399
|
+
next.savedAccount = cleanAccount
|
|
1400
|
+
next.savedPassword = cleanPassword
|
|
1401
|
+
}
|
|
1402
|
+
update(next)
|
|
1403
|
+
host = null
|
|
1404
|
+
await ensureHost()
|
|
1405
|
+
|
|
1406
|
+
let quota = null
|
|
1407
|
+
let quotaError = null
|
|
1408
|
+
try {
|
|
1409
|
+
quota = await getQuota()
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
quotaError = errorMessage(error)
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
accountMasked: maskAccount(result.account),
|
|
1415
|
+
quota,
|
|
1416
|
+
quotaError,
|
|
1417
|
+
rememberPassword: rememberPassword === true,
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** 读回已保存的账号(密码不回传,只告诉前端「有没有存」)。 */
|
|
1422
|
+
function savedLogin() {
|
|
1423
|
+
const value = credential
|
|
1424
|
+
if (!value || !value.savedAccount) return { hasSaved: false, account: null }
|
|
1425
|
+
return { hasSaved: true, account: value.savedAccount }
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// ---------------------------------------------------------- 扫码登录
|
|
1429
|
+
/**
|
|
1430
|
+
* 扫码登录:拉起浏览器 → 等用户扫码 → 抓 cookie 里的 authorization。
|
|
1431
|
+
* 这是个长任务(最长 5 分钟),所以做成「后台跑 + 前端轮询进度」。
|
|
1432
|
+
*/
|
|
1433
|
+
let scanSession = null
|
|
1434
|
+
|
|
1435
|
+
function startScan() {
|
|
1436
|
+
if (scanSession && scanSession.state === 'running') {
|
|
1437
|
+
return { ok: true, already: true, ...snapshotScan() }
|
|
1438
|
+
}
|
|
1439
|
+
const session = {
|
|
1440
|
+
id: randomUUID().slice(0, 8),
|
|
1441
|
+
state: 'running',
|
|
1442
|
+
phase: 'launching',
|
|
1443
|
+
message: '正在启动浏览器…',
|
|
1444
|
+
startedAt: Date.now(),
|
|
1445
|
+
error: null,
|
|
1446
|
+
controller: new AbortController(),
|
|
1447
|
+
}
|
|
1448
|
+
scanSession = session
|
|
1449
|
+
|
|
1450
|
+
session.promise = (async () => {
|
|
1451
|
+
try {
|
|
1452
|
+
const result = await scanLogin({
|
|
1453
|
+
signal: session.controller.signal,
|
|
1454
|
+
onProgress: (p) => {
|
|
1455
|
+
session.phase = p.phase || session.phase
|
|
1456
|
+
session.message = p.message || session.message
|
|
1457
|
+
},
|
|
1458
|
+
})
|
|
1459
|
+
// 存凭据(扫码拿到的令牌同样支持自动续期)
|
|
1460
|
+
update({
|
|
1461
|
+
authorization: result.authorization,
|
|
1462
|
+
userDomainId: result.udId || '',
|
|
1463
|
+
})
|
|
1464
|
+
host = null
|
|
1465
|
+
try {
|
|
1466
|
+
await ensureHost()
|
|
1467
|
+
} catch {
|
|
1468
|
+
/* host 解析失败不影响保存 */
|
|
1469
|
+
}
|
|
1470
|
+
session.state = 'done'
|
|
1471
|
+
session.phase = 'done'
|
|
1472
|
+
session.message = '登录成功'
|
|
1473
|
+
session.result = {
|
|
1474
|
+
accountMasked: maskAccount(decodeAuthorization(result.authorization).account),
|
|
1475
|
+
}
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
session.state = error?.message === '已取消扫码登录' ? 'cancelled' : 'error'
|
|
1478
|
+
session.phase = 'error'
|
|
1479
|
+
session.error = errorMessage(error)
|
|
1480
|
+
session.message = session.error
|
|
1481
|
+
}
|
|
1482
|
+
})()
|
|
1483
|
+
session.promise.catch(() => {})
|
|
1484
|
+
return { ok: true, ...snapshotScan() }
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function snapshotScan() {
|
|
1488
|
+
if (!scanSession) return { state: 'idle', phase: 'idle', message: '' }
|
|
1489
|
+
return {
|
|
1490
|
+
id: scanSession.id,
|
|
1491
|
+
state: scanSession.state,
|
|
1492
|
+
phase: scanSession.phase,
|
|
1493
|
+
message: scanSession.message,
|
|
1494
|
+
error: scanSession.error,
|
|
1495
|
+
result: scanSession.result ?? null,
|
|
1496
|
+
elapsedMs: Date.now() - scanSession.startedAt,
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function cancelScan() {
|
|
1501
|
+
if (!scanSession || scanSession.state !== 'running') return { cancelled: false }
|
|
1502
|
+
try {
|
|
1503
|
+
scanSession.controller.abort()
|
|
1504
|
+
} catch {
|
|
1505
|
+
/* 已经停了 */
|
|
1506
|
+
}
|
|
1507
|
+
return { cancelled: true }
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* 界面用的搜索:在当前目录及其子目录里按名字找。
|
|
1512
|
+
*
|
|
1513
|
+
* 和模型工具 `mcloud_search` 的差别:
|
|
1514
|
+
* · 工具版是 BFS 走 10 页,适合「帮我找找」,用户等得起;
|
|
1515
|
+
* · 界面版是人在输入框里打字,必须**尽快出第一批结果**,并且要能中途取消。
|
|
1516
|
+
*
|
|
1517
|
+
* 所以这里:先只搜当前这一层(最快),够了就停;不够再往下走,
|
|
1518
|
+
* 且总量封顶,避免用户搜个常见字把整个网盘翻一遍。
|
|
1519
|
+
*/
|
|
1520
|
+
async function searchFiles(keyword, parentFileId = ROOT_ID, options = {}) {
|
|
1521
|
+
const needle = String(keyword ?? '').trim().toLowerCase()
|
|
1522
|
+
if (!needle) return { items: [], truncated: false }
|
|
1523
|
+
|
|
1524
|
+
// 只在磁盘上跑:limit 是「返回上限」,pages 是「最多翻多少页」
|
|
1525
|
+
const limit = Math.max(1, Math.min(500, Number(options.limit) || 120))
|
|
1526
|
+
const maxPages = Math.max(1, Math.min(60, Number(options.maxPages) || 24))
|
|
1527
|
+
const depthLimit = Math.max(0, Math.min(8, Number(options.depth) ?? 4))
|
|
1528
|
+
|
|
1529
|
+
const matches = []
|
|
1530
|
+
let pages = 0
|
|
1531
|
+
let truncated = false
|
|
1532
|
+
|
|
1533
|
+
/** 走一层:返回这一层里的文件夹 id,供下一层继续。 */
|
|
1534
|
+
async function scanFolder(folderId, depth, pathLabel) {
|
|
1535
|
+
const children = []
|
|
1536
|
+
let cursor = ''
|
|
1537
|
+
do {
|
|
1538
|
+
if (pages >= maxPages || matches.length >= limit) {
|
|
1539
|
+
truncated = true
|
|
1540
|
+
return children
|
|
1541
|
+
}
|
|
1542
|
+
const page = await listFiles(folderId, cursor)
|
|
1543
|
+
pages += 1
|
|
1544
|
+
for (const item of page.items) {
|
|
1545
|
+
const hit = String(item.name || '').toLowerCase().includes(needle)
|
|
1546
|
+
if (hit) {
|
|
1547
|
+
matches.push({
|
|
1548
|
+
...item,
|
|
1549
|
+
parent: folderId,
|
|
1550
|
+
parentPath: pathLabel,
|
|
1551
|
+
})
|
|
1552
|
+
if (matches.length >= limit) {
|
|
1553
|
+
truncated = true
|
|
1554
|
+
return children
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (item.type === 'folder') children.push(item)
|
|
1558
|
+
}
|
|
1559
|
+
cursor = page.nextCursor || ''
|
|
1560
|
+
} while (cursor)
|
|
1561
|
+
return children
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// 第一层(当前目录):优先、同步完成
|
|
1565
|
+
const level1 = await scanFolder(parentFileId, 0, '/')
|
|
1566
|
+
|
|
1567
|
+
if (depthLimit > 0 && matches.length < limit && pages < maxPages) {
|
|
1568
|
+
let frontier = level1.map((folder) => ({ id: folder.id, path: '/' + folder.name }))
|
|
1569
|
+
for (let depth = 1; depth <= depthLimit && frontier.length && matches.length < limit; depth += 1) {
|
|
1570
|
+
const next = []
|
|
1571
|
+
for (const folder of frontier) {
|
|
1572
|
+
if (pages >= maxPages || matches.length >= limit) {
|
|
1573
|
+
truncated = true
|
|
1574
|
+
break
|
|
1575
|
+
}
|
|
1576
|
+
const kids = await scanFolder(folder.id, depth, folder.path)
|
|
1577
|
+
for (const kid of kids) next.push({ id: kid.id, path: folder.path + '/' + kid.name })
|
|
1578
|
+
}
|
|
1579
|
+
frontier = next
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
return { items: matches, truncated, pages }
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
return {
|
|
1587
|
+
status,
|
|
1588
|
+
authorize,
|
|
1589
|
+
login,
|
|
1590
|
+
savedLogin,
|
|
1591
|
+
startScan,
|
|
1592
|
+
snapshotScan,
|
|
1593
|
+
cancelScan,
|
|
1594
|
+
forget,
|
|
1595
|
+
listFiles,
|
|
1596
|
+
searchFiles,
|
|
1597
|
+
getDownloadUrl,
|
|
1598
|
+
makeDir,
|
|
1599
|
+
renameNode,
|
|
1600
|
+
moveNodes,
|
|
1601
|
+
removeNodes,
|
|
1602
|
+
getQuota,
|
|
1603
|
+
startJob,
|
|
1604
|
+
getJob,
|
|
1605
|
+
listJobs,
|
|
1606
|
+
cancelJob,
|
|
1607
|
+
clearFinishedJobs,
|
|
1608
|
+
ensureFolder,
|
|
1609
|
+
uploadFile,
|
|
1610
|
+
uploadFolder,
|
|
1611
|
+
uploadFromTempPath,
|
|
1612
|
+
downloadFile,
|
|
1613
|
+
downloadFolder,
|
|
1614
|
+
isAuthorized: () => Boolean(credential?.authorization),
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// ------------------------------------------------------------------ 工具定义
|
|
1619
|
+
|
|
1620
|
+
const outputSpec = {
|
|
1621
|
+
schema: {
|
|
1622
|
+
type: 'object',
|
|
1623
|
+
properties: {
|
|
1624
|
+
content: { type: 'array', items: {} },
|
|
1625
|
+
structuredContent: {},
|
|
1626
|
+
},
|
|
1627
|
+
required: ['content'],
|
|
1628
|
+
additionalProperties: false,
|
|
1629
|
+
},
|
|
1630
|
+
render(_args, result) {
|
|
1631
|
+
return result.content.map((block) =>
|
|
1632
|
+
block.type === 'text' ? block : { type: 'text', text: `[移动云盘返回 ${block.type} 内容]` },
|
|
1633
|
+
)
|
|
1634
|
+
},
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function textResult(text) {
|
|
1638
|
+
return { content: [{ type: 'text', text }] }
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function humanSize(bytes) {
|
|
1642
|
+
const n = Number(bytes) || 0
|
|
1643
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
1644
|
+
let value = n
|
|
1645
|
+
let index = 0
|
|
1646
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
1647
|
+
value /= 1024
|
|
1648
|
+
index += 1
|
|
1649
|
+
}
|
|
1650
|
+
return `${index === 0 ? value : value.toFixed(1)} ${units[index]}`
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function toolDefinitions(client) {
|
|
1654
|
+
return [
|
|
1655
|
+
{
|
|
1656
|
+
name: TOOL_PREFIX + 'quota',
|
|
1657
|
+
description: '查询中国移动云盘(139)的容量使用情况:总量、已用、剩余。',
|
|
1658
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
1659
|
+
async execute() {
|
|
1660
|
+
const quota = await client.getQuota()
|
|
1661
|
+
if (!quota) {
|
|
1662
|
+
return textResult(
|
|
1663
|
+
'拿不到容量:当前凭据里没有 userDomainId(ud_id)。请在「移动云盘」面板补上 Cookie 里的 ud_id 再试。',
|
|
1664
|
+
)
|
|
1665
|
+
}
|
|
1666
|
+
return textResult(
|
|
1667
|
+
`总容量 ${humanSize(quota.totalBytes)}|已用 ${humanSize(quota.usedBytes)}|剩余 ${humanSize(quota.freeBytes)}`,
|
|
1668
|
+
)
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
name: TOOL_PREFIX + 'list',
|
|
1673
|
+
description:
|
|
1674
|
+
'列出中国移动云盘某个目录下的文件和文件夹。parentFileId 用 "/" 表示根目录,从结果里拿子目录的 id 继续深入。',
|
|
1675
|
+
parameters: {
|
|
1676
|
+
type: 'object',
|
|
1677
|
+
properties: {
|
|
1678
|
+
parentFileId: { type: 'string', description: '目录 ID,根目录为 "/"' },
|
|
1679
|
+
cursor: { type: 'string', description: '分页游标,首页留空' },
|
|
1680
|
+
},
|
|
1681
|
+
additionalProperties: false,
|
|
1682
|
+
},
|
|
1683
|
+
async execute(args) {
|
|
1684
|
+
const page = await client.listFiles(args?.parentFileId || ROOT_ID, args?.cursor || '')
|
|
1685
|
+
if (!page.items.length) return textResult('这个目录是空的。')
|
|
1686
|
+
const lines = page.items.map((item) => {
|
|
1687
|
+
const kind = item.type === 'folder' ? '文件夹' : '文件'
|
|
1688
|
+
const size = item.type === 'folder' ? '' : ` ${humanSize(item.size)}`
|
|
1689
|
+
return `- [${kind}] ${item.name}${size} (id=${item.id})`
|
|
1690
|
+
})
|
|
1691
|
+
const more = page.nextCursor ? `\n\n还有下一页,cursor=${page.nextCursor}` : ''
|
|
1692
|
+
return textResult(`共 ${page.items.length} 项:\n${lines.join('\n')}${more}`)
|
|
1693
|
+
},
|
|
1694
|
+
},
|
|
1695
|
+
{
|
|
1696
|
+
name: TOOL_PREFIX + 'search',
|
|
1697
|
+
description: '在中国移动云盘里按文件名关键词搜索(会翻若干页目录,最多 10 页)。',
|
|
1698
|
+
parameters: {
|
|
1699
|
+
type: 'object',
|
|
1700
|
+
properties: {
|
|
1701
|
+
keyword: { type: 'string', description: '文件名关键词' },
|
|
1702
|
+
parentFileId: { type: 'string', description: '从哪个目录开始搜,默认根目录 "/"' },
|
|
1703
|
+
},
|
|
1704
|
+
required: ['keyword'],
|
|
1705
|
+
additionalProperties: false,
|
|
1706
|
+
},
|
|
1707
|
+
async execute(args) {
|
|
1708
|
+
const keyword = String(args?.keyword ?? '').trim()
|
|
1709
|
+
if (!keyword) return textResult('请给出关键词。')
|
|
1710
|
+
const queue = [args?.parentFileId || ROOT_ID]
|
|
1711
|
+
const matches = []
|
|
1712
|
+
let pages = 0
|
|
1713
|
+
while (queue.length && pages < 10) {
|
|
1714
|
+
const current = queue.shift()
|
|
1715
|
+
let cursor = ''
|
|
1716
|
+
do {
|
|
1717
|
+
const page = await client.listFiles(current, cursor)
|
|
1718
|
+
pages += 1
|
|
1719
|
+
for (const item of page.items) {
|
|
1720
|
+
if (item.name.includes(keyword)) matches.push({ ...item, parent: current })
|
|
1721
|
+
if (item.type === 'folder') queue.push(item.id)
|
|
1722
|
+
}
|
|
1723
|
+
cursor = page.nextCursor || ''
|
|
1724
|
+
} while (cursor && pages < 10)
|
|
1725
|
+
}
|
|
1726
|
+
if (!matches.length) return textResult(`没找到文件名包含「${keyword}」的内容。`)
|
|
1727
|
+
const lines = matches.map(
|
|
1728
|
+
(item) => `- ${item.type === 'folder' ? '📁' : '📄'} ${item.name} (id=${item.id}, 在 ${item.parent})`,
|
|
1729
|
+
)
|
|
1730
|
+
return textResult(`找到 ${matches.length} 项:\n${lines.join('\n')}`)
|
|
1731
|
+
},
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
name: TOOL_PREFIX + 'download_link',
|
|
1735
|
+
description: '拿到中国移动云盘里某个文件的下载直链(302/CDN 地址,有时效)。',
|
|
1736
|
+
parameters: {
|
|
1737
|
+
type: 'object',
|
|
1738
|
+
properties: { fileId: { type: 'string', description: '文件 ID' } },
|
|
1739
|
+
required: ['fileId'],
|
|
1740
|
+
additionalProperties: false,
|
|
1741
|
+
},
|
|
1742
|
+
async execute(args) {
|
|
1743
|
+
const url = await client.getDownloadUrl(String(args?.fileId ?? ''))
|
|
1744
|
+
return textResult(url)
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
name: TOOL_PREFIX + 'mkdir',
|
|
1749
|
+
description: '在中国移动云盘里新建文件夹。',
|
|
1750
|
+
parameters: {
|
|
1751
|
+
type: 'object',
|
|
1752
|
+
properties: {
|
|
1753
|
+
parentFileId: { type: 'string', description: '父目录 ID,根目录为 "/"' },
|
|
1754
|
+
name: { type: 'string', description: '新文件夹名字' },
|
|
1755
|
+
},
|
|
1756
|
+
required: ['name'],
|
|
1757
|
+
additionalProperties: false,
|
|
1758
|
+
},
|
|
1759
|
+
async execute(args) {
|
|
1760
|
+
const dirName = String(args?.name ?? '').trim()
|
|
1761
|
+
if (!dirName) return textResult('文件夹名不能为空。')
|
|
1762
|
+
await client.makeDir(args?.parentFileId || ROOT_ID, dirName)
|
|
1763
|
+
return textResult(`已创建文件夹「${dirName}」。`)
|
|
1764
|
+
},
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
name: TOOL_PREFIX + 'rename',
|
|
1768
|
+
description: '重命名中国移动云盘里的文件或文件夹。',
|
|
1769
|
+
parameters: {
|
|
1770
|
+
type: 'object',
|
|
1771
|
+
properties: {
|
|
1772
|
+
fileId: { type: 'string', description: '文件/文件夹 ID' },
|
|
1773
|
+
name: { type: 'string', description: '新名字' },
|
|
1774
|
+
},
|
|
1775
|
+
required: ['fileId', 'name'],
|
|
1776
|
+
additionalProperties: false,
|
|
1777
|
+
},
|
|
1778
|
+
async execute(args) {
|
|
1779
|
+
const nextName = String(args?.name ?? '').trim()
|
|
1780
|
+
if (!nextName) return textResult('新名字不能为空。')
|
|
1781
|
+
await client.renameNode(String(args?.fileId ?? ''), nextName)
|
|
1782
|
+
return textResult(`已改名为「${nextName}」。`)
|
|
1783
|
+
},
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
name: TOOL_PREFIX + 'move',
|
|
1787
|
+
description: '把一个或多个文件/文件夹移动到另一个目录(移动前请先确认目标 ID)。',
|
|
1788
|
+
parameters: {
|
|
1789
|
+
type: 'object',
|
|
1790
|
+
properties: {
|
|
1791
|
+
fileIds: { type: 'array', items: { type: 'string' }, description: '要移动的 ID 列表' },
|
|
1792
|
+
toParentFileId: { type: 'string', description: '目标目录 ID,根目录为 "/"' },
|
|
1793
|
+
},
|
|
1794
|
+
required: ['fileIds', 'toParentFileId'],
|
|
1795
|
+
additionalProperties: false,
|
|
1796
|
+
},
|
|
1797
|
+
async execute(args) {
|
|
1798
|
+
const ids = Array.isArray(args?.fileIds) ? args.fileIds.map(String) : []
|
|
1799
|
+
if (!ids.length) return textResult('fileIds 不能为空。')
|
|
1800
|
+
await client.moveNodes(ids, args?.toParentFileId || ROOT_ID)
|
|
1801
|
+
return textResult(`已移动 ${ids.length} 项。`)
|
|
1802
|
+
},
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
name: TOOL_PREFIX + 'remove',
|
|
1806
|
+
description: '把文件/文件夹移入移动云盘回收站(不是永久删除,可在回收站恢复)。',
|
|
1807
|
+
parameters: {
|
|
1808
|
+
type: 'object',
|
|
1809
|
+
properties: {
|
|
1810
|
+
fileIds: { type: 'array', items: { type: 'string' }, description: '要删除的 ID 列表' },
|
|
1811
|
+
},
|
|
1812
|
+
required: ['fileIds'],
|
|
1813
|
+
additionalProperties: false,
|
|
1814
|
+
},
|
|
1815
|
+
async execute(args) {
|
|
1816
|
+
const ids = Array.isArray(args?.fileIds) ? args.fileIds.map(String) : []
|
|
1817
|
+
if (!ids.length) return textResult('fileIds 不能为空。')
|
|
1818
|
+
await client.removeNodes(ids)
|
|
1819
|
+
return textResult(`已把 ${ids.length} 项移入回收站。`)
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1822
|
+
{
|
|
1823
|
+
name: TOOL_PREFIX + 'upload',
|
|
1824
|
+
timeoutMs: 6 * 60 * 60 * 1000,
|
|
1825
|
+
description:
|
|
1826
|
+
'把一个本地文件上传到中国移动云盘(自动分片、支持秒传)。localPath 是运行 DSH 这台机器上的绝对路径。',
|
|
1827
|
+
parameters: {
|
|
1828
|
+
type: 'object',
|
|
1829
|
+
properties: {
|
|
1830
|
+
localPath: { type: 'string', description: '本地文件绝对路径' },
|
|
1831
|
+
parentFileId: { type: 'string', description: '传到哪个目录,根目录为 "/"' },
|
|
1832
|
+
name: { type: 'string', description: '可选:上传后的文件名' },
|
|
1833
|
+
},
|
|
1834
|
+
required: ['localPath'],
|
|
1835
|
+
additionalProperties: false,
|
|
1836
|
+
},
|
|
1837
|
+
async execute(args) {
|
|
1838
|
+
const localPath = String(args?.localPath ?? '').trim()
|
|
1839
|
+
if (!localPath) return textResult('localPath 不能为空。')
|
|
1840
|
+
const job = client.startJob('upload', basename(localPath), (active) =>
|
|
1841
|
+
client.uploadFile(localPath, args?.parentFileId, args?.name, active),
|
|
1842
|
+
)
|
|
1843
|
+
const result = await job.promise
|
|
1844
|
+
return textResult(
|
|
1845
|
+
result.dedup
|
|
1846
|
+
? `「${result.name}」命中秒传(网盘里已有完全相同的内容),没有真的传数据。`
|
|
1847
|
+
: `已上传「${result.name}」(${humanSize(result.size)},${result.parts} 个分片)。`,
|
|
1848
|
+
)
|
|
1849
|
+
},
|
|
1850
|
+
},
|
|
1851
|
+
{
|
|
1852
|
+
name: TOOL_PREFIX + 'upload_folder',
|
|
1853
|
+
timeoutMs: 6 * 60 * 60 * 1000,
|
|
1854
|
+
description:
|
|
1855
|
+
'把一个本地文件夹整个上传到中国移动云盘,远端会按同样的目录结构建好(同名文件夹复用,不会重复创建)。',
|
|
1856
|
+
parameters: {
|
|
1857
|
+
type: 'object',
|
|
1858
|
+
properties: {
|
|
1859
|
+
localPath: { type: 'string', description: '本地文件夹绝对路径' },
|
|
1860
|
+
parentFileId: { type: 'string', description: '传到哪个目录,根目录为 "/"' },
|
|
1861
|
+
},
|
|
1862
|
+
required: ['localPath'],
|
|
1863
|
+
additionalProperties: false,
|
|
1864
|
+
},
|
|
1865
|
+
async execute(args) {
|
|
1866
|
+
const localPath = String(args?.localPath ?? '').trim()
|
|
1867
|
+
if (!localPath) return textResult('localPath 不能为空。')
|
|
1868
|
+
const job = client.startJob('upload-folder', basename(localPath), (active) =>
|
|
1869
|
+
client.uploadFolder(localPath, args?.parentFileId, active),
|
|
1870
|
+
)
|
|
1871
|
+
const result = await job.promise
|
|
1872
|
+
return textResult(
|
|
1873
|
+
`已把文件夹「${result.name}」上传到网盘(${result.files} 个文件,${humanSize(result.bytes)})。`,
|
|
1874
|
+
)
|
|
1875
|
+
},
|
|
1876
|
+
},
|
|
1877
|
+
{
|
|
1878
|
+
name: TOOL_PREFIX + 'download',
|
|
1879
|
+
timeoutMs: 6 * 60 * 60 * 1000,
|
|
1880
|
+
description: '把移动云盘里的一个文件下载到本地磁盘(流式写入,先写临时文件再改名)。',
|
|
1881
|
+
parameters: {
|
|
1882
|
+
type: 'object',
|
|
1883
|
+
properties: {
|
|
1884
|
+
fileId: { type: 'string', description: '文件 ID' },
|
|
1885
|
+
localPath: { type: 'string', description: '本地保存路径,要含文件名' },
|
|
1886
|
+
},
|
|
1887
|
+
required: ['fileId', 'localPath'],
|
|
1888
|
+
additionalProperties: false,
|
|
1889
|
+
},
|
|
1890
|
+
async execute(args) {
|
|
1891
|
+
const fileId = String(args?.fileId ?? '').trim()
|
|
1892
|
+
const localPath = String(args?.localPath ?? '').trim()
|
|
1893
|
+
if (!fileId || !localPath) return textResult('fileId 和 localPath 都不能为空。')
|
|
1894
|
+
const job = client.startJob('download', basename(localPath), (active) =>
|
|
1895
|
+
client.downloadFile(fileId, localPath, active),
|
|
1896
|
+
)
|
|
1897
|
+
const result = await job.promise
|
|
1898
|
+
return textResult(`已下载到 ${result.localPath}(${humanSize(result.bytes)})。`)
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
{
|
|
1902
|
+
name: TOOL_PREFIX + 'download_folder',
|
|
1903
|
+
timeoutMs: 6 * 60 * 60 * 1000,
|
|
1904
|
+
description:
|
|
1905
|
+
'递归下载整个文件夹到本地。移动云盘没有打包下载接口,这里是逐个文件下载、在本地还原目录结构,所以文件多会比较慢。',
|
|
1906
|
+
parameters: {
|
|
1907
|
+
type: 'object',
|
|
1908
|
+
properties: {
|
|
1909
|
+
folderId: { type: 'string', description: '文件夹 ID' },
|
|
1910
|
+
localPath: { type: 'string', description: '本地保存目录' },
|
|
1911
|
+
},
|
|
1912
|
+
required: ['folderId', 'localPath'],
|
|
1913
|
+
additionalProperties: false,
|
|
1914
|
+
},
|
|
1915
|
+
async execute(args) {
|
|
1916
|
+
const folderId = String(args?.folderId ?? '').trim()
|
|
1917
|
+
const localPath = String(args?.localPath ?? '').trim()
|
|
1918
|
+
if (!folderId || !localPath) return textResult('folderId 和 localPath 都不能为空。')
|
|
1919
|
+
const job = client.startJob('download-folder', basename(localPath), (active) =>
|
|
1920
|
+
client.downloadFolder(folderId, localPath, active),
|
|
1921
|
+
)
|
|
1922
|
+
const result = await job.promise
|
|
1923
|
+
return textResult(
|
|
1924
|
+
`已把整个文件夹下载到 ${result.localDir}(${result.files} 个文件,${humanSize(result.bytes)})。`,
|
|
1925
|
+
)
|
|
1926
|
+
},
|
|
1927
|
+
},
|
|
1928
|
+
]
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// ------------------------------------------------------------------ 路由
|
|
1932
|
+
|
|
1933
|
+
function writeJson(res, status, payload) {
|
|
1934
|
+
const body = JSON.stringify(payload)
|
|
1935
|
+
res.writeHead(status, {
|
|
1936
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1937
|
+
'cache-control': 'no-store',
|
|
1938
|
+
'content-length': Buffer.byteLength(body),
|
|
1939
|
+
})
|
|
1940
|
+
res.end(body)
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function readJsonBody(req, limit = 64 * 1024) {
|
|
1944
|
+
return new Promise((resolveBody) => {
|
|
1945
|
+
let size = 0
|
|
1946
|
+
const chunks = []
|
|
1947
|
+
req.on('data', (chunk) => {
|
|
1948
|
+
size += chunk.length
|
|
1949
|
+
if (size > limit) {
|
|
1950
|
+
resolveBody({ invalid: true })
|
|
1951
|
+
req.destroy()
|
|
1952
|
+
return
|
|
1953
|
+
}
|
|
1954
|
+
chunks.push(chunk)
|
|
1955
|
+
})
|
|
1956
|
+
req.on('end', () => {
|
|
1957
|
+
const text = chunks.length ? Buffer.concat(chunks).toString('utf8') : ''
|
|
1958
|
+
if (!text.trim()) return resolveBody({ value: {} })
|
|
1959
|
+
try {
|
|
1960
|
+
resolveBody({ value: JSON.parse(text) })
|
|
1961
|
+
} catch {
|
|
1962
|
+
resolveBody({ invalid: true })
|
|
1963
|
+
}
|
|
1964
|
+
})
|
|
1965
|
+
req.on('error', () => resolveBody({ invalid: true }))
|
|
1966
|
+
})
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function createRoutes(client, options = {}) {
|
|
1970
|
+
const base = '/api/dsh-mcloud'
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* 所有路由共用的一道闸:环回 + Host + Origin。
|
|
1974
|
+
*
|
|
1975
|
+
* `x-dsh-mcloud: 1` 只是「这条请求确实想调插件」的标记,**不是安全边界** ——
|
|
1976
|
+
* 任何网页都能自己加上。真正的边界在 requestRejection 里。
|
|
1977
|
+
*/
|
|
1978
|
+
function guard(req, res) {
|
|
1979
|
+
const reason = requestRejection(req, options)
|
|
1980
|
+
if (reason !== null) {
|
|
1981
|
+
writeJson(res, 403, { ok: false, error: reason })
|
|
1982
|
+
return false
|
|
1983
|
+
}
|
|
1984
|
+
return true
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function guardWrite(req, res) {
|
|
1988
|
+
if (!guard(req, res)) return false
|
|
1989
|
+
if (String(req.method ?? 'GET').toUpperCase() !== 'POST') {
|
|
1990
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1991
|
+
return false
|
|
1992
|
+
}
|
|
1993
|
+
if (req.headers[WRITE_HEADER] !== '1') {
|
|
1994
|
+
writeJson(res, 403, { ok: false, error: 'missing plugin header' })
|
|
1995
|
+
return false
|
|
1996
|
+
}
|
|
1997
|
+
return true
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
async function post(req, res, run) {
|
|
2001
|
+
const body = await readJsonBody(req)
|
|
2002
|
+
if (body.invalid) return writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
2003
|
+
try {
|
|
2004
|
+
const result = await run(body.value ?? {})
|
|
2005
|
+
writeJson(res, result?.ok === false ? 400 : 200, { ok: true, ...(result ?? {}) })
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
writeJson(res, 400, { ok: false, error: errorMessage(error) })
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
return [
|
|
2012
|
+
{
|
|
2013
|
+
kind: 'exact',
|
|
2014
|
+
path: `${base}/status`,
|
|
2015
|
+
handler: async (req, res) => {
|
|
2016
|
+
if (!guard(req, res)) return
|
|
2017
|
+
try {
|
|
2018
|
+
writeJson(res, 200, await client.status())
|
|
2019
|
+
} catch (error) {
|
|
2020
|
+
writeJson(res, 500, { ok: false, error: errorMessage(error) })
|
|
2021
|
+
}
|
|
2022
|
+
},
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
kind: 'exact',
|
|
2026
|
+
path: `${base}/scan/start`,
|
|
2027
|
+
handler: async (req, res) => {
|
|
2028
|
+
if (!guardWrite(req, res)) return
|
|
2029
|
+
await post(req, res, async () => client.startScan())
|
|
2030
|
+
},
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
kind: 'exact',
|
|
2034
|
+
path: `${base}/scan/status`,
|
|
2035
|
+
handler: async (req, res) => {
|
|
2036
|
+
if (!guard(req, res)) return
|
|
2037
|
+
try {
|
|
2038
|
+
writeJson(res, 200, { ok: true, ...client.snapshotScan() })
|
|
2039
|
+
} catch (error) {
|
|
2040
|
+
writeJson(res, 500, { ok: false, error: errorMessage(error) })
|
|
2041
|
+
}
|
|
2042
|
+
},
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
kind: 'exact',
|
|
2046
|
+
path: `${base}/scan/cancel`,
|
|
2047
|
+
handler: async (req, res) => {
|
|
2048
|
+
if (!guardWrite(req, res)) return
|
|
2049
|
+
await post(req, res, async () => client.cancelScan())
|
|
2050
|
+
},
|
|
2051
|
+
},
|
|
2052
|
+
{
|
|
2053
|
+
kind: 'exact',
|
|
2054
|
+
path: `${base}/login`,
|
|
2055
|
+
handler: async (req, res) => {
|
|
2056
|
+
if (!guardWrite(req, res)) return
|
|
2057
|
+
await post(req, res, async (value) => {
|
|
2058
|
+
if (typeof value.account !== 'string' || typeof value.password !== 'string') {
|
|
2059
|
+
throw new Error('account 和 password 都必须是字符串')
|
|
2060
|
+
}
|
|
2061
|
+
const result = await client.login(value.account, value.password, value.rememberPassword === true)
|
|
2062
|
+
return result
|
|
2063
|
+
})
|
|
2064
|
+
},
|
|
2065
|
+
},
|
|
2066
|
+
{
|
|
2067
|
+
kind: 'exact',
|
|
2068
|
+
path: `${base}/login/saved`,
|
|
2069
|
+
handler: async (req, res) => {
|
|
2070
|
+
if (!guard(req, res)) return
|
|
2071
|
+
try {
|
|
2072
|
+
writeJson(res, 200, { ok: true, ...client.savedLogin() })
|
|
2073
|
+
} catch (error) {
|
|
2074
|
+
writeJson(res, 500, { ok: false, error: errorMessage(error) })
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
kind: 'exact',
|
|
2080
|
+
path: `${base}/auth`,
|
|
2081
|
+
handler: async (req, res) => {
|
|
2082
|
+
if (!guardWrite(req, res)) return
|
|
2083
|
+
await post(req, res, async (value) => {
|
|
2084
|
+
if (typeof value.authorization !== 'string') {
|
|
2085
|
+
throw new Error('authorization 必须是字符串')
|
|
2086
|
+
}
|
|
2087
|
+
const result = await client.authorize(value.authorization, value.userDomainId ?? '')
|
|
2088
|
+
return { accountMasked: result.accountMasked, quota: result.quota, quotaError: result.quotaError }
|
|
2089
|
+
})
|
|
2090
|
+
},
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
kind: 'exact',
|
|
2094
|
+
path: `${base}/auth/clear`,
|
|
2095
|
+
handler: async (req, res) => {
|
|
2096
|
+
if (!guardWrite(req, res)) return
|
|
2097
|
+
await post(req, res, async () => {
|
|
2098
|
+
client.forget()
|
|
2099
|
+
return {}
|
|
2100
|
+
})
|
|
2101
|
+
},
|
|
2102
|
+
},
|
|
2103
|
+
{
|
|
2104
|
+
kind: 'exact',
|
|
2105
|
+
path: `${base}/list`,
|
|
2106
|
+
handler: async (req, res) => {
|
|
2107
|
+
if (!guard(req, res)) return
|
|
2108
|
+
try {
|
|
2109
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
2110
|
+
const parentFileId = url.searchParams.get('parentFileId') || ROOT_ID
|
|
2111
|
+
const cursor = url.searchParams.get('cursor') || ''
|
|
2112
|
+
const pageSize = url.searchParams.get('pageSize')
|
|
2113
|
+
// refresh=1 强制绕过缓存(「刷新」按钮和写操作之后用)
|
|
2114
|
+
const force = url.searchParams.get('refresh') === '1'
|
|
2115
|
+
const page = await client.listFiles(parentFileId, cursor, pageSize ?? LIST_PAGE_SIZE, { force })
|
|
2116
|
+
writeJson(res, 200, { ok: true, parentFileId, ...page })
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
writeJson(res, 400, { ok: false, error: errorMessage(error) })
|
|
2119
|
+
}
|
|
2120
|
+
},
|
|
2121
|
+
},
|
|
2122
|
+
{
|
|
2123
|
+
kind: 'exact',
|
|
2124
|
+
path: `${base}/search`,
|
|
2125
|
+
handler: async (req, res) => {
|
|
2126
|
+
if (!guard(req, res)) return
|
|
2127
|
+
try {
|
|
2128
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
2129
|
+
const keyword = url.searchParams.get('keyword') || ''
|
|
2130
|
+
const parentFileId = url.searchParams.get('parentFileId') || ROOT_ID
|
|
2131
|
+
const depth = url.searchParams.get('depth')
|
|
2132
|
+
const result = await client.searchFiles(keyword, parentFileId, {
|
|
2133
|
+
depth: depth === null ? undefined : Number(depth),
|
|
2134
|
+
})
|
|
2135
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
writeJson(res, 500, { ok: false, error: errorMessage(error) })
|
|
2138
|
+
}
|
|
2139
|
+
},
|
|
2140
|
+
},
|
|
2141
|
+
{
|
|
2142
|
+
kind: 'exact',
|
|
2143
|
+
path: `${base}/pick-dir`,
|
|
2144
|
+
handler: async (req, res) => {
|
|
2145
|
+
if (!guardWrite(req, res)) return
|
|
2146
|
+
await post(req, res, async () => {
|
|
2147
|
+
const paths = await pickLocalPaths('download-dir')
|
|
2148
|
+
return { path: paths[0] || '', cancelled: paths.length === 0 }
|
|
2149
|
+
})
|
|
2150
|
+
},
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
kind: 'exact',
|
|
2154
|
+
path: `${base}/link`,
|
|
2155
|
+
handler: async (req, res) => {
|
|
2156
|
+
if (!guardWrite(req, res)) return
|
|
2157
|
+
await post(req, res, async (value) => {
|
|
2158
|
+
const url = await client.getDownloadUrl(String(value.fileId ?? ''))
|
|
2159
|
+
return { url }
|
|
2160
|
+
})
|
|
2161
|
+
},
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
kind: 'exact',
|
|
2165
|
+
path: `${base}/mkdir`,
|
|
2166
|
+
handler: async (req, res) => {
|
|
2167
|
+
if (!guardWrite(req, res)) return
|
|
2168
|
+
await post(req, res, async (value) => {
|
|
2169
|
+
const dirName = String(value.name ?? '').trim()
|
|
2170
|
+
if (!dirName) throw new Error('文件夹名不能为空')
|
|
2171
|
+
await client.makeDir(String(value.parentFileId || ROOT_ID), dirName)
|
|
2172
|
+
return {}
|
|
2173
|
+
})
|
|
2174
|
+
},
|
|
2175
|
+
},
|
|
2176
|
+
{
|
|
2177
|
+
kind: 'exact',
|
|
2178
|
+
path: `${base}/rename`,
|
|
2179
|
+
handler: async (req, res) => {
|
|
2180
|
+
if (!guardWrite(req, res)) return
|
|
2181
|
+
await post(req, res, async (value) => {
|
|
2182
|
+
const nextName = String(value.name ?? '').trim()
|
|
2183
|
+
if (!nextName) throw new Error('新名字不能为空')
|
|
2184
|
+
await client.renameNode(String(value.fileId ?? ''), nextName)
|
|
2185
|
+
return {}
|
|
2186
|
+
})
|
|
2187
|
+
},
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
kind: 'exact',
|
|
2191
|
+
path: `${base}/move`,
|
|
2192
|
+
handler: async (req, res) => {
|
|
2193
|
+
if (!guardWrite(req, res)) return
|
|
2194
|
+
await post(req, res, async (value) => {
|
|
2195
|
+
const ids = Array.isArray(value.fileIds) ? value.fileIds.map(String) : []
|
|
2196
|
+
if (!ids.length) throw new Error('fileIds 不能为空')
|
|
2197
|
+
await client.moveNodes(ids, String(value.toParentFileId || ROOT_ID))
|
|
2198
|
+
return {}
|
|
2199
|
+
})
|
|
2200
|
+
},
|
|
2201
|
+
},
|
|
2202
|
+
{
|
|
2203
|
+
kind: 'exact',
|
|
2204
|
+
path: `${base}/remove`,
|
|
2205
|
+
handler: async (req, res) => {
|
|
2206
|
+
if (!guardWrite(req, res)) return
|
|
2207
|
+
await post(req, res, async (value) => {
|
|
2208
|
+
const ids = Array.isArray(value.fileIds) ? value.fileIds.map(String) : []
|
|
2209
|
+
if (!ids.length) throw new Error('fileIds 不能为空')
|
|
2210
|
+
await client.removeNodes(ids)
|
|
2211
|
+
return {}
|
|
2212
|
+
})
|
|
2213
|
+
},
|
|
2214
|
+
},
|
|
2215
|
+
{
|
|
2216
|
+
kind: 'exact',
|
|
2217
|
+
path: `${base}/transfers/clear`,
|
|
2218
|
+
handler: async (req, res) => {
|
|
2219
|
+
if (!guardWrite(req, res)) return
|
|
2220
|
+
await post(req, res, async () => {
|
|
2221
|
+
// 只清记录,不碰网盘文件,也不需要用户确认
|
|
2222
|
+
const removed = client.clearFinishedJobs()
|
|
2223
|
+
return { removed }
|
|
2224
|
+
})
|
|
2225
|
+
},
|
|
2226
|
+
},
|
|
2227
|
+
{
|
|
2228
|
+
kind: 'exact',
|
|
2229
|
+
path: `${base}/transfers`,
|
|
2230
|
+
handler: async (req, res) => {
|
|
2231
|
+
if (!guard(req, res)) return
|
|
2232
|
+
try {
|
|
2233
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
2234
|
+
const jobId = url.searchParams.get('jobId')
|
|
2235
|
+
if (jobId) {
|
|
2236
|
+
const job = client.getJob(jobId)
|
|
2237
|
+
if (!job) return writeJson(res, 404, { ok: false, error: 'job not found' })
|
|
2238
|
+
return writeJson(res, 200, { ok: true, job })
|
|
2239
|
+
}
|
|
2240
|
+
writeJson(res, 200, { ok: true, jobs: client.listJobs() })
|
|
2241
|
+
} catch (error) {
|
|
2242
|
+
writeJson(res, 500, { ok: false, error: errorMessage(error) })
|
|
2243
|
+
}
|
|
2244
|
+
},
|
|
2245
|
+
},
|
|
2246
|
+
{
|
|
2247
|
+
kind: 'exact',
|
|
2248
|
+
path: `${base}/upload`,
|
|
2249
|
+
handler: async (req, res) => {
|
|
2250
|
+
if (!guardWrite(req, res)) return
|
|
2251
|
+
await post(req, res, async (value) => {
|
|
2252
|
+
const localPath = String(value.localPath ?? '').trim()
|
|
2253
|
+
if (!localPath) throw new Error('localPath 不能为空')
|
|
2254
|
+
// 先确认文件真在、能读,别等任务跑起来才报错
|
|
2255
|
+
const stats = statSync(localPath)
|
|
2256
|
+
if (!stats.isFile()) throw new Error(`不是一个文件:${localPath}`)
|
|
2257
|
+
const job = client.startJob('upload', String(value.name || basename(localPath)), (active) =>
|
|
2258
|
+
client.uploadFile(localPath, value.parentFileId, value.name, active),
|
|
2259
|
+
)
|
|
2260
|
+
job.total = stats.size
|
|
2261
|
+
return { jobId: job.id }
|
|
2262
|
+
})
|
|
2263
|
+
},
|
|
2264
|
+
},
|
|
2265
|
+
{
|
|
2266
|
+
kind: 'exact',
|
|
2267
|
+
path: `${base}/pick`,
|
|
2268
|
+
handler: async (req, res) => {
|
|
2269
|
+
if (!guardWrite(req, res)) return
|
|
2270
|
+
await post(req, res, async (value) => {
|
|
2271
|
+
const mode = value.mode === 'folder' ? 'folder' : 'files'
|
|
2272
|
+
const paths = await pickLocalPaths(mode)
|
|
2273
|
+
return { paths, cancelled: paths.length === 0 }
|
|
2274
|
+
})
|
|
2275
|
+
},
|
|
2276
|
+
},
|
|
2277
|
+
{
|
|
2278
|
+
kind: 'exact',
|
|
2279
|
+
path: `${base}/upload-folder`,
|
|
2280
|
+
handler: async (req, res) => {
|
|
2281
|
+
if (!guardWrite(req, res)) return
|
|
2282
|
+
await post(req, res, async (value) => {
|
|
2283
|
+
const localPath = String(value.localPath ?? '').trim()
|
|
2284
|
+
if (!localPath) throw new Error('localPath 不能为空')
|
|
2285
|
+
const stats = statSync(localPath)
|
|
2286
|
+
if (!stats.isDirectory()) throw new Error(`不是一个文件夹:${localPath}`)
|
|
2287
|
+
const job = client.startJob('upload-folder', basename(localPath), (active) =>
|
|
2288
|
+
client.uploadFolder(localPath, value.parentFileId, active),
|
|
2289
|
+
)
|
|
2290
|
+
return { jobId: job.id }
|
|
2291
|
+
})
|
|
2292
|
+
},
|
|
2293
|
+
},
|
|
2294
|
+
{
|
|
2295
|
+
// 拖拽上传:body 是文件原始字节,不是 JSON,所以不能走 post() 助手
|
|
2296
|
+
kind: 'exact',
|
|
2297
|
+
path: `${base}/upload-body`,
|
|
2298
|
+
handler: async (req, res) => {
|
|
2299
|
+
if (!guard(req, res)) return
|
|
2300
|
+
if (String(req.method ?? 'GET').toUpperCase() !== 'POST') {
|
|
2301
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2302
|
+
return
|
|
2303
|
+
}
|
|
2304
|
+
if (req.headers[WRITE_HEADER] !== '1') {
|
|
2305
|
+
writeJson(res, 403, { ok: false, error: 'missing plugin header' })
|
|
2306
|
+
return
|
|
2307
|
+
}
|
|
2308
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
2309
|
+
const rawName = url.searchParams.get('name') || 'upload.bin'
|
|
2310
|
+
const name = basename(rawName).replace(/[\\/:*?"<>|]/g, '_') || 'upload.bin'
|
|
2311
|
+
const parentFileId = url.searchParams.get('parentFileId') || ROOT_ID
|
|
2312
|
+
const tempPath = join(tmpdir(), `dsh-mcloud-${randomUUID()}-${name}`)
|
|
2313
|
+
try {
|
|
2314
|
+
await pipeline(req, createWriteStream(tempPath))
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
rmSync(tempPath, { force: true })
|
|
2317
|
+
writeJson(res, 500, { ok: false, error: `接收上传数据失败:${errorMessage(error)}` })
|
|
2318
|
+
return
|
|
2319
|
+
}
|
|
2320
|
+
const job = client.startJob('upload', name, (active) =>
|
|
2321
|
+
client.uploadFromTempPath(tempPath, parentFileId, name, active),
|
|
2322
|
+
)
|
|
2323
|
+
writeJson(res, 200, { ok: true, jobId: job.id, name })
|
|
2324
|
+
},
|
|
2325
|
+
},
|
|
2326
|
+
{
|
|
2327
|
+
kind: 'exact',
|
|
2328
|
+
path: `${base}/download`,
|
|
2329
|
+
handler: async (req, res) => {
|
|
2330
|
+
if (!guardWrite(req, res)) return
|
|
2331
|
+
await post(req, res, async (value) => {
|
|
2332
|
+
const fileId = String(value.fileId ?? '').trim()
|
|
2333
|
+
if (!fileId) throw new Error('fileId 不能为空')
|
|
2334
|
+
const localPath =
|
|
2335
|
+
String(value.localPath ?? '').trim() || join(DEFAULT_DOWNLOAD_DIR, fileId)
|
|
2336
|
+
const job = client.startJob('download', basename(localPath), (active) =>
|
|
2337
|
+
client.downloadFile(fileId, localPath, active),
|
|
2338
|
+
)
|
|
2339
|
+
return { jobId: job.id, localPath }
|
|
2340
|
+
})
|
|
2341
|
+
},
|
|
2342
|
+
},
|
|
2343
|
+
{
|
|
2344
|
+
kind: 'exact',
|
|
2345
|
+
path: `${base}/download-folder`,
|
|
2346
|
+
handler: async (req, res) => {
|
|
2347
|
+
if (!guardWrite(req, res)) return
|
|
2348
|
+
await post(req, res, async (value) => {
|
|
2349
|
+
const folderId = String(value.folderId ?? '').trim()
|
|
2350
|
+
if (!folderId) throw new Error('folderId 不能为空')
|
|
2351
|
+
const localDir =
|
|
2352
|
+
String(value.localPath ?? '').trim() || join(DEFAULT_DOWNLOAD_DIR, folderId)
|
|
2353
|
+
const job = client.startJob('download-folder', basename(localDir), (active) =>
|
|
2354
|
+
client.downloadFolder(folderId, localDir, active),
|
|
2355
|
+
)
|
|
2356
|
+
return { jobId: job.id, localDir }
|
|
2357
|
+
})
|
|
2358
|
+
},
|
|
2359
|
+
},
|
|
2360
|
+
{
|
|
2361
|
+
// 「上传文件夹」走浏览器的 webkitdirectory 时,浏览器只给相对路径,
|
|
2362
|
+
// 所以要按相对路径逐层把远端目录建出来,再把文件塞进对应目录。
|
|
2363
|
+
kind: 'exact',
|
|
2364
|
+
path: `${base}/mkdir-path`,
|
|
2365
|
+
handler: async (req, res) => {
|
|
2366
|
+
if (!guardWrite(req, res)) return
|
|
2367
|
+
await post(req, res, async (value) => {
|
|
2368
|
+
const parentFileId = String(value.parentFileId || ROOT_ID)
|
|
2369
|
+
const parts = String(value.path ?? '')
|
|
2370
|
+
.split(/[\\/]+/)
|
|
2371
|
+
.map((part) => part.trim())
|
|
2372
|
+
.filter((part) => part && part !== '.' && part !== '..')
|
|
2373
|
+
if (!parts.length) throw new Error('path 不能为空')
|
|
2374
|
+
let current = parentFileId
|
|
2375
|
+
for (const part of parts) {
|
|
2376
|
+
current = await client.ensureFolder(current, part)
|
|
2377
|
+
}
|
|
2378
|
+
return { fileId: current }
|
|
2379
|
+
})
|
|
2380
|
+
},
|
|
2381
|
+
},
|
|
2382
|
+
{
|
|
2383
|
+
kind: 'exact',
|
|
2384
|
+
path: `${base}/transfer/cancel`,
|
|
2385
|
+
handler: async (req, res) => {
|
|
2386
|
+
if (!guardWrite(req, res)) return
|
|
2387
|
+
await post(req, res, async (value) => {
|
|
2388
|
+
const cancelled = client.cancelJob(String(value.jobId ?? ''))
|
|
2389
|
+
return { cancelled }
|
|
2390
|
+
})
|
|
2391
|
+
},
|
|
2392
|
+
},
|
|
2393
|
+
]
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// ------------------------------------------------------------------ apply
|
|
2397
|
+
|
|
2398
|
+
export function apply(ctx, rawConfig) {
|
|
2399
|
+
const config = unwrapConfig(rawConfig)
|
|
2400
|
+
const credentialDir = typeof config.credentialDir === 'string' && config.credentialDir
|
|
2401
|
+
? resolve(config.credentialDir)
|
|
2402
|
+
: DEFAULT_CREDENTIAL_DIR
|
|
2403
|
+
|
|
2404
|
+
// 用户自己指定了目录就不用回落;否则允许读旧版本留下的凭据。
|
|
2405
|
+
const legacyDirs = typeof config.credentialDir === 'string' && config.credentialDir
|
|
2406
|
+
? []
|
|
2407
|
+
: LEGACY_CREDENTIAL_DIRS
|
|
2408
|
+
|
|
2409
|
+
let store
|
|
2410
|
+
try {
|
|
2411
|
+
store = createCredentialStore(credentialDir, legacyDirs)
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
ctx.logger?.warn?.(`mcloud: 凭据目录不可用(${errorMessage(error)})`)
|
|
2414
|
+
store = createCredentialStore(DEFAULT_CREDENTIAL_DIR, legacyDirs)
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
const client = createClient(store, ctx.logger, config)
|
|
2418
|
+
|
|
2419
|
+
/**
|
|
2420
|
+
* 额外的 Host 白名单,只在「前面挂了反代 / 用自定义域名访问 DSH」时才需要。
|
|
2421
|
+
* 支持字符串或数组,形如 `dsh.example.com` 或 `dsh.example.com:8443`。
|
|
2422
|
+
*/
|
|
2423
|
+
const allowedAuthorities = (Array.isArray(config.allowedAuthorities)
|
|
2424
|
+
? config.allowedAuthorities
|
|
2425
|
+
: typeof config.allowedAuthorities === 'string'
|
|
2426
|
+
? [config.allowedAuthorities]
|
|
2427
|
+
: []
|
|
2428
|
+
)
|
|
2429
|
+
.filter((entry) => typeof entry === 'string' && entry.trim())
|
|
2430
|
+
.map((entry) => entry.trim())
|
|
2431
|
+
|
|
2432
|
+
// ---- 工具
|
|
2433
|
+
try {
|
|
2434
|
+
const disposers = []
|
|
2435
|
+
for (const definition of toolDefinitions(client)) {
|
|
2436
|
+
try {
|
|
2437
|
+
const dispose = ctx.tools.register({
|
|
2438
|
+
name: definition.name,
|
|
2439
|
+
description: definition.description,
|
|
2440
|
+
parameters: definition.parameters,
|
|
2441
|
+
output: outputSpec,
|
|
2442
|
+
timeoutMs: definition.timeoutMs ?? 60_000,
|
|
2443
|
+
execute: (args, execution) => definition.execute(args ?? {}, execution),
|
|
2444
|
+
})
|
|
2445
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
ctx.logger?.warn?.(`mobile-cloud: 工具 ${definition.name} 注册失败(${errorMessage(error)})`)
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
ctx.effect(
|
|
2451
|
+
() => () => {
|
|
2452
|
+
for (const dispose of disposers.reverse()) {
|
|
2453
|
+
try {
|
|
2454
|
+
dispose()
|
|
2455
|
+
} catch {
|
|
2456
|
+
/* 已经卸过了 */
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
},
|
|
2460
|
+
'dsh-mcloud: tools',
|
|
2461
|
+
)
|
|
2462
|
+
|
|
2463
|
+
// 写入类工具一律走审批(跟百度网盘插件同一套闸)
|
|
2464
|
+
if (config.enableWrites !== false) {
|
|
2465
|
+
ctx.on('tools/pre-execute', async (execution, next) => {
|
|
2466
|
+
const decision = await next()
|
|
2467
|
+
if (decision?.kind === 'allow' && WRITE_TOOL_NAMES.has(execution?.name)) {
|
|
2468
|
+
return { kind: 'ask', reason: '这是移动云盘上会改动内容的操作,确认一下再执行。' }
|
|
2469
|
+
}
|
|
2470
|
+
return decision
|
|
2471
|
+
})
|
|
2472
|
+
}
|
|
2473
|
+
} catch (error) {
|
|
2474
|
+
ctx.logger?.warn?.(`mobile-cloud: 工具注册整体失败(${errorMessage(error)})`)
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// ---- HTTP 路由
|
|
2478
|
+
try {
|
|
2479
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
2480
|
+
const server = webCtx.webServer
|
|
2481
|
+
const routes = createRoutes(client, {
|
|
2482
|
+
// 每次都现读,端口是 listen 之后才定的,不能注册时快照
|
|
2483
|
+
get webServer() {
|
|
2484
|
+
return server
|
|
2485
|
+
},
|
|
2486
|
+
allowedAuthorities,
|
|
2487
|
+
})
|
|
2488
|
+
const disposers = routes.map((route) => server.register(route))
|
|
2489
|
+
webCtx.effect(
|
|
2490
|
+
() => () => {
|
|
2491
|
+
for (const dispose of disposers) {
|
|
2492
|
+
try {
|
|
2493
|
+
dispose()
|
|
2494
|
+
} catch {
|
|
2495
|
+
/* 已经卸过了 */
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
},
|
|
2499
|
+
'dsh-mcloud: routes',
|
|
2500
|
+
)
|
|
2501
|
+
})
|
|
2502
|
+
} catch (error) {
|
|
2503
|
+
ctx.logger?.warn?.(`mobile-cloud: 路由注册失败(${errorMessage(error)})`)
|
|
2504
|
+
}
|
|
2505
|
+
}
|