most-box 0.0.1 → 0.0.2
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/README.md +156 -73
- package/cli.js +2 -2
- package/package.json +9 -5
- package/public/app.css +1519 -0
- package/public/app.jsx +607 -399
- package/public/bundle.css +1 -0
- package/public/bundle.js +10 -14
- package/public/error-boundary.jsx +50 -0
- package/public/index.html +2 -1
- package/public/index.jsx +16 -1
- package/server.js +280 -197
- package/src/config.js +24 -7
- package/src/core/cid.js +23 -18
- package/src/index.js +400 -272
- package/src/utils/security.js +27 -24
- package/public/bundle.js.map +0 -7
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/mask-icon.svg +0 -3
- package/public/icons/most.png +0 -0
- package/public/icons/pwa-192x192.png +0 -0
- package/public/icons/pwa-512x512.png +0 -0
package/src/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MostBoxEngine -
|
|
3
|
-
*
|
|
2
|
+
* MostBoxEngine - 核心 P2P 引擎
|
|
3
|
+
* 基于 Hyperswarm/Hyperdrive 的跨平台 P2P 文件共享引擎
|
|
4
|
+
*
|
|
5
|
+
* 架构设计:
|
|
6
|
+
* - Hyperdrive: 只负责存储文件内容,key 使用 CID(解耦存储与目录结构)
|
|
7
|
+
* - published-files.json: 维护文件元数据和显示路径(用户看到的文件夹结构)
|
|
8
|
+
* - 移动/重命名只需更新 JSON,零成本,不修改 Hyperdrive
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import EventEmitter from 'eventemitter3'
|
|
@@ -15,7 +20,7 @@ import path from 'node:path'
|
|
|
15
20
|
import { calculateCid, parseMostLink } from './core/cid.js'
|
|
16
21
|
import { sanitizeFilename, validateAndSanitizePath, validateFileSize, checkDirectoryWritable, formatFileSize } from './utils/security.js'
|
|
17
22
|
import { ValidationError, PathSecurityError, FileSizeError, PeerNotFoundError, IntegrityError, PermissionError, EngineNotInitializedError } from './utils/errors.js'
|
|
18
|
-
import { GLOBAL_SHARED_SEED_STRING, MAX_FILE_SIZE, CONNECTION_TIMEOUT, DOWNLOAD_TIMEOUT, SWARM_BOOTSTRAP } from './config.js'
|
|
23
|
+
import { GLOBAL_SHARED_SEED_STRING, MAX_FILE_SIZE, CONNECTION_TIMEOUT, DOWNLOAD_TIMEOUT, SWARM_BOOTSTRAP, MAX_PEERS, SWARM_KEEP_ALIVE_INTERVAL, SWARM_RANDOM_PUNCH_INTERVAL, DRIVE_ENTRY_TIMEOUT, DRIVE_SYNC_TIMEOUT, STREAM_READ_TIMEOUT, DOWNLOAD_POLL_INTERVAL, PROGRESS_THROTTLE, DEFAULT_READ_LIMIT } from './config.js'
|
|
19
24
|
|
|
20
25
|
export class MostBoxEngine extends EventEmitter {
|
|
21
26
|
#store = null
|
|
@@ -25,22 +30,23 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
25
30
|
#trashFiles = []
|
|
26
31
|
#initialized = false
|
|
27
32
|
#options = null
|
|
28
|
-
#activeDownloads = new Map()
|
|
33
|
+
#activeDownloads = new Map()
|
|
34
|
+
#drivePromises = new Map()
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
|
-
*
|
|
32
|
-
* @param {object} options -
|
|
33
|
-
* @param {string} options.dataPath -
|
|
34
|
-
* @param {string} [options.downloadPath] -
|
|
35
|
-
* @param {number} [options.maxFileSize] -
|
|
37
|
+
* 创建新的 MostBoxEngine 实例
|
|
38
|
+
* @param {object} options - 配置选项
|
|
39
|
+
* @param {string} options.dataPath - 存储 P2P 数据的路径(必填)
|
|
40
|
+
* @param {string} [options.downloadPath] - 默认下载路径(可选,默认为 dataPath/downloads)
|
|
41
|
+
* @param {number} [options.maxFileSize] - 最大文件大小(字节)(默认:100GB)
|
|
36
42
|
*/
|
|
37
43
|
constructor(options) {
|
|
38
44
|
super()
|
|
39
|
-
|
|
45
|
+
|
|
40
46
|
if (!options || !options.dataPath) {
|
|
41
47
|
throw new Error('dataPath is required')
|
|
42
48
|
}
|
|
43
|
-
|
|
49
|
+
|
|
44
50
|
this.#options = {
|
|
45
51
|
dataPath: options.dataPath,
|
|
46
52
|
downloadPath: options.downloadPath || path.join(options.dataPath, 'downloads'),
|
|
@@ -49,7 +55,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
/**
|
|
52
|
-
*
|
|
58
|
+
* 初始化引擎 — 必须在调用其他方法之前调用
|
|
53
59
|
*/
|
|
54
60
|
async start() {
|
|
55
61
|
if (this.#initialized) {
|
|
@@ -57,19 +63,17 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
const { dataPath } = this.#options
|
|
60
|
-
|
|
66
|
+
|
|
61
67
|
console.log(`[MostBox] Initializing engine...`)
|
|
62
68
|
console.log(`[MostBox] Storage path: ${dataPath}`)
|
|
63
|
-
|
|
64
|
-
// Create storage directory if not exists
|
|
69
|
+
|
|
65
70
|
if (!fs.existsSync(dataPath)) {
|
|
66
71
|
fs.mkdirSync(dataPath, { recursive: true })
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
// Initialize Corestore with global shared seed
|
|
70
74
|
const GLOBAL_SHARED_SEED = b4a.alloc(32).fill(GLOBAL_SHARED_SEED_STRING)
|
|
71
75
|
this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
|
|
72
|
-
|
|
76
|
+
|
|
73
77
|
try {
|
|
74
78
|
await this.#store.ready()
|
|
75
79
|
console.log(`[MostBox] Corestore ready`)
|
|
@@ -90,27 +94,17 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
// Initialize Hyperswarm with NAT traversal enabled
|
|
94
97
|
console.log(`[MostBox] Initializing Hyperswarm...`)
|
|
95
98
|
this.#swarm = new Hyperswarm({
|
|
96
|
-
|
|
97
|
-
maxPeers: 64,
|
|
98
|
-
// DHT bootstrap nodes (same as Keet.io/HyperDHT)
|
|
99
|
+
maxPeers: MAX_PEERS,
|
|
99
100
|
bootstrap: SWARM_BOOTSTRAP,
|
|
100
|
-
// Enable NAT traversal (hole punching)
|
|
101
|
-
// firewall function: allow all connections (default behavior)
|
|
102
101
|
firewall: () => false,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Random punch interval for NAT traversal (20 seconds)
|
|
106
|
-
randomPunchInterval: 20000,
|
|
107
|
-
// Increase timeouts for unstable networks
|
|
102
|
+
connectionKeepAlive: SWARM_KEEP_ALIVE_INTERVAL,
|
|
103
|
+
randomPunchInterval: SWARM_RANDOM_PUNCH_INTERVAL,
|
|
108
104
|
handshakeTimeout: CONNECTION_TIMEOUT
|
|
109
105
|
})
|
|
110
106
|
|
|
111
|
-
// Handle swarm-level errors
|
|
112
107
|
this.#swarm.on('error', (err) => {
|
|
113
|
-
// Silently handle SSL/network errors - they're non-critical for DHT discovery
|
|
114
108
|
if (err.code === 'SSL_ERROR' || err.message?.includes('handshake') || err.message?.includes('ECONNRESET')) {
|
|
115
109
|
console.warn('[MostBox] Network warning (non-critical):', err.message)
|
|
116
110
|
return
|
|
@@ -119,10 +113,8 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
119
113
|
this.emit('error', err)
|
|
120
114
|
})
|
|
121
115
|
|
|
122
|
-
// Replicate store on new connections
|
|
123
116
|
this.#swarm.on('connection', (conn, info) => {
|
|
124
117
|
console.log(`[MostBox] New peer connection established`)
|
|
125
|
-
// Handle connection errors gracefully
|
|
126
118
|
conn.on('error', (err) => {
|
|
127
119
|
if (err.code === 'SSL_ERROR' || err.message?.includes('handshake')) {
|
|
128
120
|
console.warn('[MostBox] Connection warning:', err.message)
|
|
@@ -135,42 +127,42 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
135
127
|
this.emit('connection', conn)
|
|
136
128
|
})
|
|
137
129
|
|
|
138
|
-
// Load published files metadata
|
|
139
130
|
this.#publishedFiles = this.#loadPublishedMetadata()
|
|
140
131
|
console.log(`[MostBox] Loaded ${this.#publishedFiles.length} published files`)
|
|
141
|
-
|
|
142
|
-
// Load trash files metadata
|
|
132
|
+
|
|
143
133
|
this.#trashFiles = this.#loadTrashMetadata()
|
|
144
134
|
console.log(`[MostBox] Loaded ${this.#trashFiles.length} trash files`)
|
|
145
|
-
|
|
135
|
+
|
|
146
136
|
this.#initialized = true
|
|
147
137
|
console.log(`[MostBox] Engine initialized successfully`)
|
|
148
138
|
this.emit('ready')
|
|
149
|
-
|
|
139
|
+
|
|
150
140
|
return this
|
|
151
141
|
}
|
|
152
142
|
|
|
153
143
|
/**
|
|
154
|
-
*
|
|
144
|
+
* 停止引擎并清理资源
|
|
155
145
|
*/
|
|
156
146
|
async stop() {
|
|
157
147
|
if (!this.#initialized) {
|
|
158
148
|
return
|
|
159
149
|
}
|
|
160
150
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
151
|
+
for (const task of this.#activeDownloads.values()) {
|
|
152
|
+
task.aborted = true
|
|
153
|
+
if (task.readStream) task.readStream.destroy()
|
|
154
|
+
if (task.writeStream) task.writeStream.destroy()
|
|
164
155
|
}
|
|
156
|
+
this.#activeDownloads.clear()
|
|
157
|
+
|
|
158
|
+
await Promise.allSettled([...this.#drives.values()].map(d => d.close()))
|
|
165
159
|
this.#drives.clear()
|
|
166
160
|
|
|
167
|
-
// Destroy swarm
|
|
168
161
|
if (this.#swarm) {
|
|
169
162
|
await this.#swarm.destroy()
|
|
170
163
|
this.#swarm = null
|
|
171
164
|
}
|
|
172
165
|
|
|
173
|
-
// Close store
|
|
174
166
|
if (this.#store) {
|
|
175
167
|
await this.#store.close()
|
|
176
168
|
this.#store = null
|
|
@@ -181,8 +173,8 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
181
173
|
}
|
|
182
174
|
|
|
183
175
|
/**
|
|
184
|
-
*
|
|
185
|
-
* @returns {string}
|
|
176
|
+
* 获取节点的公钥
|
|
177
|
+
* @returns {string} 节点 ID(十六进制字符串)
|
|
186
178
|
*/
|
|
187
179
|
getNodeId() {
|
|
188
180
|
this.#ensureInitialized()
|
|
@@ -190,7 +182,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
190
182
|
}
|
|
191
183
|
|
|
192
184
|
/**
|
|
193
|
-
*
|
|
185
|
+
* 获取当前网络状态
|
|
194
186
|
* @returns {{ peers: number, status: string }}
|
|
195
187
|
*/
|
|
196
188
|
getNetworkStatus() {
|
|
@@ -203,9 +195,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
203
195
|
}
|
|
204
196
|
|
|
205
197
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* @param {string}
|
|
198
|
+
* 将内容发布到 P2P 网络
|
|
199
|
+
* Hyperdrive 中存储 key 为 '/' + cid,metadata 中存储 displayName(用户看到的路径)
|
|
200
|
+
* @param {string|Buffer} content - 文件路径(字符串)或内容(Buffer)
|
|
201
|
+
* @param {string} [fileName] - 文件名(Buffer 输入时必填)
|
|
209
202
|
* @returns {Promise<{ cid: string, link: string, fileName: string }>}
|
|
210
203
|
*/
|
|
211
204
|
async publishFile(content, fileName) {
|
|
@@ -246,47 +239,61 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
246
239
|
this.emit('publish:progress', { stage: 'calculating-cid', file: safeFileName })
|
|
247
240
|
|
|
248
241
|
const { cid: rootCid } = await calculateCid(content)
|
|
249
|
-
const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
|
|
250
242
|
const cidString = rootCid.toString()
|
|
251
243
|
|
|
244
|
+
// 检查相同内容是否已存在
|
|
245
|
+
const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
|
|
246
|
+
if (existingIndex !== -1) {
|
|
247
|
+
const existing = this.#publishedFiles[existingIndex]
|
|
248
|
+
return {
|
|
249
|
+
cid: cidString,
|
|
250
|
+
link: `most://${cidString}`,
|
|
251
|
+
fileName: existing.fileName,
|
|
252
|
+
alreadyExists: true
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 获取或创建该 CID 对应的 drive
|
|
257
|
+
const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
|
|
252
258
|
const name = `drive-${hashHex}`
|
|
253
259
|
let drive = this.#drives.get(name)
|
|
254
|
-
|
|
260
|
+
|
|
255
261
|
if (!drive) {
|
|
256
|
-
drive =
|
|
257
|
-
await drive.ready()
|
|
258
|
-
this.#drives.set(name, drive)
|
|
259
|
-
|
|
262
|
+
drive = await this.#getOrCreateDrive(name, { server: true, client: false })
|
|
260
263
|
const discovery = this.#swarm.join(drive.discoveryKey, { server: true, client: false })
|
|
261
264
|
await discovery.flushed()
|
|
262
265
|
}
|
|
263
266
|
|
|
264
267
|
this.emit('publish:progress', { stage: 'uploading', file: safeFileName })
|
|
265
268
|
|
|
266
|
-
|
|
269
|
+
// Hyperdrive 中用 CID 作为 key 存储(解耦目录结构)
|
|
270
|
+
const driveKey = '/' + cidString
|
|
271
|
+
|
|
272
|
+
const ws = drive.createWriteStream(driveKey)
|
|
267
273
|
|
|
268
274
|
if (Buffer.isBuffer(content)) {
|
|
269
|
-
|
|
270
|
-
const CHUNK_SIZE = 64 * 1024 // 64KB chunks
|
|
275
|
+
const CHUNK_SIZE = 64 * 1024
|
|
271
276
|
let offset = 0
|
|
272
|
-
|
|
273
277
|
const waitForDrain = () => new Promise(resolve => ws.once('drain', resolve))
|
|
274
278
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
try {
|
|
280
|
+
while (offset < content.length) {
|
|
281
|
+
const chunk = content.slice(offset, offset + CHUNK_SIZE)
|
|
282
|
+
const canContinue = ws.write(chunk)
|
|
283
|
+
offset += chunk.length
|
|
284
|
+
if (!canContinue && offset < content.length) {
|
|
285
|
+
await waitForDrain()
|
|
286
|
+
}
|
|
282
287
|
}
|
|
288
|
+
ws.end()
|
|
289
|
+
await new Promise((resolve, reject) => {
|
|
290
|
+
ws.on('finish', resolve)
|
|
291
|
+
ws.on('error', reject)
|
|
292
|
+
})
|
|
293
|
+
} catch (err) {
|
|
294
|
+
ws.destroy()
|
|
295
|
+
throw err
|
|
283
296
|
}
|
|
284
|
-
|
|
285
|
-
ws.end()
|
|
286
|
-
await new Promise((resolve, reject) => {
|
|
287
|
-
ws.on('finish', resolve)
|
|
288
|
-
ws.on('error', reject)
|
|
289
|
-
})
|
|
290
297
|
} else {
|
|
291
298
|
const rs = fs.createReadStream(cleanPath)
|
|
292
299
|
await new Promise((resolve, reject) => {
|
|
@@ -297,24 +304,14 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
297
304
|
})
|
|
298
305
|
}
|
|
299
306
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
alreadyExists: true
|
|
309
|
-
}
|
|
310
|
-
} else {
|
|
311
|
-
this.#publishedFiles.push({
|
|
312
|
-
fileName: safeFileName,
|
|
313
|
-
cid: cidString,
|
|
314
|
-
publishedAt: new Date().toISOString(),
|
|
315
|
-
starred: false
|
|
316
|
-
})
|
|
317
|
-
}
|
|
307
|
+
// 存储 displayName(用户看到的文件夹路径),不存储 drivePath
|
|
308
|
+
this.#publishedFiles.push({
|
|
309
|
+
fileName: safeFileName,
|
|
310
|
+
cid: cidString,
|
|
311
|
+
driveName: name,
|
|
312
|
+
publishedAt: new Date().toISOString(),
|
|
313
|
+
starred: false
|
|
314
|
+
})
|
|
318
315
|
this.#savePublishedMetadata()
|
|
319
316
|
|
|
320
317
|
const result = {
|
|
@@ -328,25 +325,22 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
328
325
|
}
|
|
329
326
|
|
|
330
327
|
/**
|
|
331
|
-
*
|
|
332
|
-
* @param {string} link - most://
|
|
333
|
-
* @param {string} [taskId] -
|
|
328
|
+
* 从 P2P 网络下载文件
|
|
329
|
+
* @param {string} link - most:// 链接
|
|
330
|
+
* @param {string} [taskId] - 用于取消的任务 ID
|
|
334
331
|
* @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
|
|
335
332
|
*/
|
|
336
333
|
async downloadFile(link, taskId = null) {
|
|
337
334
|
this.#ensureInitialized()
|
|
338
335
|
|
|
339
|
-
// Generate taskId if not provided
|
|
340
336
|
taskId = taskId || `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
341
337
|
|
|
342
338
|
console.log(`[MostBox] Starting download for link: ${link} (taskId: ${taskId})`)
|
|
343
339
|
|
|
344
|
-
// Register in active downloads
|
|
345
340
|
const taskState = { aborted: false, readStream: null, writeStream: null }
|
|
346
341
|
this.#activeDownloads.set(taskId, taskState)
|
|
347
342
|
|
|
348
343
|
try {
|
|
349
|
-
// Parse link
|
|
350
344
|
const parsed = parseMostLink(link)
|
|
351
345
|
if (parsed.error) {
|
|
352
346
|
throw new ValidationError(parsed.error)
|
|
@@ -354,7 +348,6 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
354
348
|
const cidString = parsed.cid
|
|
355
349
|
console.log(`[MostBox] Parsed CID: ${cidString}`)
|
|
356
350
|
|
|
357
|
-
// Check if file already exists in published files
|
|
358
351
|
const existingFile = this.#publishedFiles.find(f => f.cid === cidString)
|
|
359
352
|
if (existingFile) {
|
|
360
353
|
console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
|
|
@@ -365,50 +358,40 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
365
358
|
}
|
|
366
359
|
}
|
|
367
360
|
|
|
368
|
-
// Parse CID
|
|
369
361
|
const parsedCid = CID.parse(cidString)
|
|
370
|
-
const
|
|
371
|
-
const hashHex = b4a.toString(hashBytes, 'hex')
|
|
362
|
+
const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
372
363
|
|
|
373
|
-
// Check cancellation
|
|
374
364
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
375
365
|
|
|
376
|
-
// Get/Create drive
|
|
377
366
|
const name = `drive-${hashHex}`
|
|
378
367
|
let drive = this.#drives.get(name)
|
|
379
|
-
|
|
368
|
+
|
|
380
369
|
if (!drive) {
|
|
381
370
|
console.log(`[MostBox] Creating new drive: ${name}`)
|
|
382
|
-
drive =
|
|
383
|
-
|
|
384
|
-
this.#drives.set(name, drive)
|
|
385
|
-
|
|
371
|
+
drive = await this.#getOrCreateDrive(name, { server: true, client: true })
|
|
372
|
+
|
|
386
373
|
this.emit('download:status', { taskId, status: 'connecting' })
|
|
387
|
-
|
|
374
|
+
|
|
388
375
|
console.log(`[MostBox] Joining swarm for drive discovery...`)
|
|
389
|
-
// Join as both server and client to allow self-downloads
|
|
390
376
|
await this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed()
|
|
391
377
|
console.log(`[MostBox] Swarm join flushed`)
|
|
392
378
|
} else {
|
|
393
379
|
console.log(`[MostBox] Using existing drive: ${name}`)
|
|
394
380
|
}
|
|
395
381
|
|
|
396
|
-
// Check cancellation
|
|
397
382
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
398
383
|
|
|
399
384
|
this.emit('download:status', { taskId, status: 'finding-peers' })
|
|
400
385
|
|
|
401
|
-
|
|
402
|
-
console.log(`[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT/1000}s)...`)
|
|
386
|
+
console.log(`[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT / 1000}s)...`)
|
|
403
387
|
const entries = await this.#waitForDriveContent(drive, DOWNLOAD_TIMEOUT, taskId, taskState)
|
|
404
388
|
|
|
405
389
|
if (entries.length === 0) {
|
|
406
390
|
console.log(`[MostBox] No entries found after timeout`)
|
|
407
|
-
|
|
408
|
-
// 提供更详细的错误信息
|
|
391
|
+
|
|
409
392
|
const peerCount = this.#swarm.connections.size
|
|
410
393
|
let errorMessage = 'No files found in drive. '
|
|
411
|
-
|
|
394
|
+
|
|
412
395
|
if (peerCount === 0) {
|
|
413
396
|
errorMessage += 'Could not connect to any peers. This may be due to:\n'
|
|
414
397
|
errorMessage += '1. Network firewall blocking P2P connections\n'
|
|
@@ -421,29 +404,27 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
421
404
|
errorMessage += '2. File may have been removed by publisher\n'
|
|
422
405
|
errorMessage += '3. File link may be invalid or corrupted'
|
|
423
406
|
}
|
|
424
|
-
|
|
407
|
+
|
|
425
408
|
throw new PeerNotFoundError(errorMessage)
|
|
426
409
|
}
|
|
427
410
|
|
|
428
|
-
// Check cancellation
|
|
429
411
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
430
412
|
|
|
431
413
|
console.log(`[MostBox] Found ${entries.length} entries, starting download...`)
|
|
432
414
|
|
|
433
|
-
// Save to storage directory (not Downloads folder)
|
|
434
415
|
const targetDir = this.#options.dataPath
|
|
435
416
|
|
|
436
|
-
// Check storage directory
|
|
437
417
|
const writableCheck = await checkDirectoryWritable(targetDir)
|
|
438
418
|
if (!writableCheck.writable) {
|
|
439
419
|
throw new PermissionError(writableCheck.error)
|
|
440
420
|
}
|
|
441
421
|
|
|
442
|
-
//
|
|
422
|
+
// 下载文件
|
|
443
423
|
for (const entry of entries) {
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
424
|
+
const cleanKey = entry.key.replace(/^[\/\\]/, '')
|
|
425
|
+
// 用原始文件名作为 displayName
|
|
426
|
+
const sanitizedFileName = sanitizeFilename(cleanKey)
|
|
427
|
+
|
|
447
428
|
let totalBytes = 0
|
|
448
429
|
try {
|
|
449
430
|
const stat = await drive.entry(entry.key)
|
|
@@ -451,56 +432,53 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
451
432
|
totalBytes = stat.value.blob.byteLength || 0
|
|
452
433
|
}
|
|
453
434
|
} catch {
|
|
454
|
-
//
|
|
435
|
+
// 忽略
|
|
455
436
|
}
|
|
456
437
|
|
|
457
438
|
const savePath = path.join(targetDir, sanitizedFileName)
|
|
458
|
-
|
|
459
|
-
this.emit('download:status', {
|
|
439
|
+
|
|
440
|
+
this.emit('download:status', {
|
|
460
441
|
taskId,
|
|
461
|
-
status: 'downloading',
|
|
462
|
-
file: sanitizedFileName,
|
|
463
|
-
size: totalBytes ? formatFileSize(totalBytes) : null
|
|
442
|
+
status: 'downloading',
|
|
443
|
+
file: sanitizedFileName,
|
|
444
|
+
size: totalBytes ? formatFileSize(totalBytes) : null
|
|
464
445
|
})
|
|
465
446
|
|
|
466
|
-
// Download with progress
|
|
467
447
|
const rs = drive.createReadStream(entry.key)
|
|
468
448
|
const ws = fs.createWriteStream(savePath)
|
|
469
|
-
|
|
449
|
+
|
|
470
450
|
taskState.readStream = rs
|
|
471
451
|
taskState.writeStream = ws
|
|
472
452
|
|
|
473
453
|
let loadedBytes = 0
|
|
474
454
|
let lastProgressUpdate = 0
|
|
475
|
-
|
|
455
|
+
|
|
476
456
|
await new Promise((resolve, reject) => {
|
|
477
457
|
rs.on('data', (chunk) => {
|
|
478
|
-
// Check cancellation
|
|
479
458
|
if (taskState.aborted) {
|
|
480
459
|
rs.destroy()
|
|
481
460
|
ws.destroy()
|
|
461
|
+
fs.unlink(savePath, () => { })
|
|
482
462
|
reject(new Error('Download cancelled'))
|
|
483
463
|
return
|
|
484
464
|
}
|
|
485
465
|
loadedBytes += chunk.length
|
|
486
466
|
const now = Date.now()
|
|
487
|
-
if (totalBytes > 0 && now - lastProgressUpdate >
|
|
467
|
+
if (totalBytes > 0 && now - lastProgressUpdate > PROGRESS_THROTTLE) {
|
|
488
468
|
lastProgressUpdate = now
|
|
489
469
|
const percent = Math.round((loadedBytes / totalBytes) * 100)
|
|
490
470
|
this.emit('download:progress', { taskId, loaded: loadedBytes, total: totalBytes, percent })
|
|
491
471
|
}
|
|
492
472
|
})
|
|
493
|
-
|
|
473
|
+
|
|
494
474
|
rs.pipe(ws)
|
|
495
475
|
ws.on('finish', resolve)
|
|
496
476
|
ws.on('error', reject)
|
|
497
477
|
rs.on('error', reject)
|
|
498
478
|
})
|
|
499
479
|
|
|
500
|
-
// Check cancellation before verification
|
|
501
480
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
502
481
|
|
|
503
|
-
// Verify integrity
|
|
504
482
|
this.emit('download:status', { taskId, status: 'verifying' })
|
|
505
483
|
|
|
506
484
|
const { cid: downloadedCid } = await calculateCid(savePath)
|
|
@@ -512,13 +490,24 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
512
490
|
throw new IntegrityError(`File content CID mismatch. File may be corrupted or tampered.`)
|
|
513
491
|
}
|
|
514
492
|
|
|
493
|
+
// Write file content to Hyperdrive so it can be served for preview
|
|
494
|
+
const driveKey = '/' + cidString
|
|
495
|
+
const readStream = fs.createReadStream(savePath)
|
|
496
|
+
const writeStream = drive.createWriteStream(driveKey)
|
|
497
|
+
await new Promise((resolve, reject) => {
|
|
498
|
+
readStream.pipe(writeStream)
|
|
499
|
+
writeStream.on('finish', resolve)
|
|
500
|
+
writeStream.on('error', reject)
|
|
501
|
+
readStream.on('error', reject)
|
|
502
|
+
})
|
|
503
|
+
|
|
515
504
|
const result = {
|
|
516
505
|
taskId,
|
|
517
506
|
fileName: sanitizedFileName,
|
|
518
507
|
savedPath: savePath
|
|
519
508
|
}
|
|
520
509
|
|
|
521
|
-
//
|
|
510
|
+
// 将下载的文件添加到已发布文件列表(displayName 用原始文件名)
|
|
522
511
|
const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
|
|
523
512
|
if (existingIndex !== -1) {
|
|
524
513
|
const existing = this.#publishedFiles[existingIndex]
|
|
@@ -530,6 +519,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
530
519
|
this.#publishedFiles.push({
|
|
531
520
|
fileName: sanitizedFileName,
|
|
532
521
|
cid: cidString,
|
|
522
|
+
driveName: name,
|
|
533
523
|
publishedAt: new Date().toISOString(),
|
|
534
524
|
starred: false
|
|
535
525
|
})
|
|
@@ -545,19 +535,19 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
545
535
|
}
|
|
546
536
|
|
|
547
537
|
/**
|
|
548
|
-
*
|
|
549
|
-
* @param {object} [options] -
|
|
550
|
-
* @param {boolean} [options.starred] -
|
|
538
|
+
* 列出所有已发布文件
|
|
539
|
+
* @param {object} [options] - 筛选选项
|
|
540
|
+
* @param {boolean} [options.starred] - 按收藏状态筛选
|
|
551
541
|
* @returns {Array<{ fileName: string, cid: string, link: string, publishedAt: string, starred: boolean }>}
|
|
552
542
|
*/
|
|
553
543
|
listPublishedFiles(options = {}) {
|
|
554
544
|
this.#ensureInitialized()
|
|
555
545
|
let files = this.#publishedFiles
|
|
556
|
-
|
|
546
|
+
|
|
557
547
|
if (options.starred === true) {
|
|
558
548
|
files = files.filter(f => f.starred === true)
|
|
559
549
|
}
|
|
560
|
-
|
|
550
|
+
|
|
561
551
|
return files.map(f => ({
|
|
562
552
|
fileName: f.fileName,
|
|
563
553
|
cid: f.cid,
|
|
@@ -566,11 +556,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
566
556
|
starred: f.starred || false
|
|
567
557
|
}))
|
|
568
558
|
}
|
|
569
|
-
|
|
559
|
+
|
|
570
560
|
/**
|
|
571
|
-
*
|
|
572
|
-
* @param {string} cid - CID
|
|
573
|
-
* @returns {object}
|
|
561
|
+
* 切换文件的收藏状态
|
|
562
|
+
* @param {string} cid - 文件的 CID
|
|
563
|
+
* @returns {object} 更新后的文件信息
|
|
574
564
|
*/
|
|
575
565
|
toggleStarred(cid) {
|
|
576
566
|
this.#ensureInitialized()
|
|
@@ -587,36 +577,35 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
587
577
|
}
|
|
588
578
|
|
|
589
579
|
/**
|
|
590
|
-
*
|
|
591
|
-
* @param {string} cid - CID
|
|
592
|
-
* @returns {Promise<Array>}
|
|
580
|
+
* 删除已发布文件 — 移至回收站而非永久删除
|
|
581
|
+
* @param {string} cid - 要删除文件的 CID
|
|
582
|
+
* @returns {Promise<Array>} 更新后的已发布文件列表
|
|
593
583
|
*/
|
|
594
584
|
async deletePublishedFile(cid) {
|
|
595
585
|
this.#ensureInitialized()
|
|
596
586
|
const index = this.#publishedFiles.findIndex(f => f.cid === cid)
|
|
597
587
|
if (index !== -1) {
|
|
598
588
|
const fileRecord = this.#publishedFiles[index]
|
|
599
|
-
|
|
600
|
-
// Move to trash instead of permanent deletion
|
|
589
|
+
|
|
601
590
|
this.#trashFiles.push({
|
|
602
591
|
fileName: fileRecord.fileName,
|
|
603
592
|
cid: fileRecord.cid,
|
|
593
|
+
driveName: fileRecord.driveName,
|
|
604
594
|
publishedAt: fileRecord.publishedAt,
|
|
605
595
|
starred: fileRecord.starred || false,
|
|
606
596
|
deletedAt: new Date().toISOString()
|
|
607
597
|
})
|
|
608
598
|
this.#saveTrashMetadata()
|
|
609
|
-
|
|
610
|
-
// Remove from published files
|
|
599
|
+
|
|
611
600
|
this.#publishedFiles.splice(index, 1)
|
|
612
601
|
this.#savePublishedMetadata()
|
|
613
602
|
}
|
|
614
603
|
return this.listPublishedFiles()
|
|
615
604
|
}
|
|
616
|
-
|
|
605
|
+
|
|
617
606
|
/**
|
|
618
|
-
*
|
|
619
|
-
* @returns {Array}
|
|
607
|
+
* 列出回收站中的所有文件
|
|
608
|
+
* @returns {Array} 回收站文件
|
|
620
609
|
*/
|
|
621
610
|
listTrashFiles() {
|
|
622
611
|
this.#ensureInitialized()
|
|
@@ -629,11 +618,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
629
618
|
deletedAt: f.deletedAt
|
|
630
619
|
}))
|
|
631
620
|
}
|
|
632
|
-
|
|
621
|
+
|
|
633
622
|
/**
|
|
634
|
-
*
|
|
635
|
-
* @param {string} cid - CID
|
|
636
|
-
* @returns {Array}
|
|
623
|
+
* 从回收站恢复文件
|
|
624
|
+
* @param {string} cid - 要恢复文件的 CID
|
|
625
|
+
* @returns {Array} 更新后的已发布文件列表
|
|
637
626
|
*/
|
|
638
627
|
restoreTrashFile(cid) {
|
|
639
628
|
this.#ensureInitialized()
|
|
@@ -641,120 +630,105 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
641
630
|
if (index === -1) {
|
|
642
631
|
throw new Error('File not found in trash')
|
|
643
632
|
}
|
|
644
|
-
|
|
633
|
+
|
|
645
634
|
const fileRecord = this.#trashFiles[index]
|
|
646
|
-
|
|
647
|
-
|
|
635
|
+
|
|
636
|
+
const parsedCid = CID.parse(fileRecord.cid)
|
|
637
|
+
const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
638
|
+
const driveName = `drive-${hashHex}`
|
|
639
|
+
|
|
648
640
|
this.#publishedFiles.push({
|
|
649
641
|
fileName: fileRecord.fileName,
|
|
650
642
|
cid: fileRecord.cid,
|
|
643
|
+
driveName,
|
|
651
644
|
publishedAt: fileRecord.publishedAt,
|
|
652
645
|
starred: fileRecord.starred || false
|
|
653
646
|
})
|
|
654
647
|
this.#savePublishedMetadata()
|
|
655
|
-
|
|
656
|
-
// Remove from trash
|
|
648
|
+
|
|
657
649
|
this.#trashFiles.splice(index, 1)
|
|
658
650
|
this.#saveTrashMetadata()
|
|
659
|
-
|
|
651
|
+
|
|
660
652
|
return this.listPublishedFiles()
|
|
661
653
|
}
|
|
662
|
-
|
|
654
|
+
|
|
663
655
|
/**
|
|
664
|
-
*
|
|
665
|
-
* @param {string} cid - CID
|
|
666
|
-
* @returns {Promise<Array>}
|
|
656
|
+
* 永久删除回收站中的文件
|
|
657
|
+
* @param {string} cid - 要永久删除文件的 CID
|
|
658
|
+
* @returns {Promise<Array>} 更新后的回收站列表
|
|
667
659
|
*/
|
|
668
660
|
async permanentDeleteTrashFile(cid) {
|
|
669
661
|
this.#ensureInitialized()
|
|
670
662
|
const index = this.#trashFiles.findIndex(f => f.cid === cid)
|
|
671
663
|
if (index !== -1) {
|
|
672
664
|
const fileRecord = this.#trashFiles[index]
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const parsedCid = CID.parse(cid)
|
|
676
|
-
const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
677
|
-
const driveName = `drive-${hashHex}`
|
|
678
|
-
|
|
679
|
-
// Delete file from Hyperdrive and cleanup drive
|
|
665
|
+
const driveName = fileRecord.driveName
|
|
666
|
+
|
|
680
667
|
const drive = this.#drives.get(driveName)
|
|
681
668
|
if (drive) {
|
|
682
669
|
try {
|
|
683
|
-
await drive.del(fileRecord.
|
|
670
|
+
await drive.del('/' + fileRecord.cid)
|
|
684
671
|
} catch (err) {
|
|
685
|
-
//
|
|
672
|
+
// 文件可能不存在于驱动器中
|
|
686
673
|
}
|
|
687
|
-
|
|
688
|
-
// Leave swarm for this drive
|
|
674
|
+
|
|
689
675
|
await this.#swarm.leave(drive.discoveryKey)
|
|
690
|
-
|
|
691
|
-
// Close and remove drive
|
|
692
676
|
await drive.close()
|
|
693
677
|
this.#drives.delete(driveName)
|
|
694
678
|
}
|
|
695
|
-
|
|
696
|
-
// Remove from trash
|
|
679
|
+
|
|
697
680
|
this.#trashFiles.splice(index, 1)
|
|
698
681
|
this.#saveTrashMetadata()
|
|
699
682
|
}
|
|
700
683
|
return this.listTrashFiles()
|
|
701
684
|
}
|
|
702
|
-
|
|
685
|
+
|
|
703
686
|
/**
|
|
704
|
-
*
|
|
705
|
-
* @returns {Promise<Array>}
|
|
687
|
+
* 清空回收站 — 永久删除所有回收站文件
|
|
688
|
+
* @returns {Promise<Array>} 清空后的回收站列表
|
|
706
689
|
*/
|
|
707
690
|
async emptyTrash() {
|
|
708
691
|
this.#ensureInitialized()
|
|
709
|
-
|
|
692
|
+
|
|
710
693
|
for (const fileRecord of this.#trashFiles) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
714
|
-
const driveName = `drive-${hashHex}`
|
|
715
|
-
|
|
716
|
-
// Delete file from Hyperdrive and cleanup drive
|
|
694
|
+
const driveName = fileRecord.driveName
|
|
695
|
+
|
|
717
696
|
const drive = this.#drives.get(driveName)
|
|
718
697
|
if (drive) {
|
|
719
698
|
try {
|
|
720
|
-
await drive.del(fileRecord.
|
|
699
|
+
await drive.del('/' + fileRecord.cid)
|
|
721
700
|
} catch (err) {
|
|
722
|
-
//
|
|
701
|
+
// 文件可能不存在
|
|
723
702
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
this.#swarm.leave(drive.discoveryKey)
|
|
727
|
-
|
|
728
|
-
// Close and remove drive
|
|
703
|
+
|
|
704
|
+
await this.#swarm.leave(drive.discoveryKey)
|
|
729
705
|
await drive.close()
|
|
730
706
|
this.#drives.delete(driveName)
|
|
731
707
|
}
|
|
732
708
|
}
|
|
733
|
-
|
|
734
|
-
// Clear trash
|
|
709
|
+
|
|
735
710
|
this.#trashFiles = []
|
|
736
711
|
this.#saveTrashMetadata()
|
|
737
|
-
|
|
712
|
+
|
|
738
713
|
return []
|
|
739
714
|
}
|
|
740
|
-
|
|
715
|
+
|
|
741
716
|
/**
|
|
742
|
-
*
|
|
717
|
+
* 获取存储统计信息
|
|
743
718
|
* @returns {Promise<{ total: number, used: number, free: number, fileCount: number, trashCount: number }>}
|
|
744
719
|
*/
|
|
745
720
|
async getStorageStats() {
|
|
746
721
|
this.#ensureInitialized()
|
|
747
|
-
|
|
722
|
+
|
|
748
723
|
let totalSize = 0
|
|
749
724
|
let freeSize = 0
|
|
750
725
|
const { dataPath } = this.#options
|
|
751
|
-
|
|
726
|
+
|
|
752
727
|
try {
|
|
753
728
|
const stats = fs.statfsSync(dataPath)
|
|
754
729
|
totalSize = stats.bsize * stats.blocks
|
|
755
730
|
freeSize = stats.bsize * stats.bfree
|
|
756
731
|
} catch (err) {
|
|
757
|
-
// Fallback if statfs is not available
|
|
758
732
|
try {
|
|
759
733
|
const stats = fs.statSync(dataPath)
|
|
760
734
|
totalSize = 0
|
|
@@ -764,8 +738,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
764
738
|
freeSize = 0
|
|
765
739
|
}
|
|
766
740
|
}
|
|
767
|
-
|
|
768
|
-
// Calculate used space by files
|
|
741
|
+
|
|
769
742
|
let usedSize = 0
|
|
770
743
|
const calculateDirSize = (dirPath) => {
|
|
771
744
|
try {
|
|
@@ -781,17 +754,17 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
781
754
|
const stat = fs.statSync(fullPath)
|
|
782
755
|
usedSize += stat.size
|
|
783
756
|
} catch {
|
|
784
|
-
//
|
|
757
|
+
// 跳过无法访问的文件
|
|
785
758
|
}
|
|
786
759
|
}
|
|
787
760
|
}
|
|
788
761
|
} catch {
|
|
789
|
-
//
|
|
762
|
+
// 跳过无法访问的目录
|
|
790
763
|
}
|
|
791
764
|
}
|
|
792
|
-
|
|
765
|
+
|
|
793
766
|
calculateDirSize(dataPath)
|
|
794
|
-
|
|
767
|
+
|
|
795
768
|
return {
|
|
796
769
|
total: totalSize,
|
|
797
770
|
used: usedSize,
|
|
@@ -802,10 +775,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
802
775
|
}
|
|
803
776
|
|
|
804
777
|
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
* @param {string}
|
|
808
|
-
* @
|
|
778
|
+
* 移动/重命名已发布文件
|
|
779
|
+
* 只更新 metadata 中的 displayName,不修改 Hyperdrive
|
|
780
|
+
* @param {string} cid - 要移动文件的 CID
|
|
781
|
+
* @param {string} newFileName - 新文件路径
|
|
782
|
+
* @returns {object} 更新后的文件信息
|
|
809
783
|
*/
|
|
810
784
|
moveFile(cid, newFileName) {
|
|
811
785
|
this.#ensureInitialized()
|
|
@@ -825,20 +799,22 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
825
799
|
}
|
|
826
800
|
|
|
827
801
|
/**
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
* @param {string}
|
|
831
|
-
* @
|
|
802
|
+
* 重命名文件夹(重命名文件夹内的所有文件 displayName)
|
|
803
|
+
* 只更新 metadata,不修改 Hyperdrive
|
|
804
|
+
* @param {string} oldPath - 当前文件夹路径
|
|
805
|
+
* @param {string} newPath - 新文件夹路径
|
|
806
|
+
* @returns {object} 更新后的文件信息
|
|
832
807
|
*/
|
|
833
808
|
renameFolder(oldPath, newPath) {
|
|
834
809
|
this.#ensureInitialized()
|
|
835
810
|
const prefix = oldPath + '/'
|
|
836
811
|
const updatedFiles = []
|
|
837
|
-
|
|
812
|
+
|
|
838
813
|
for (const file of this.#publishedFiles) {
|
|
839
814
|
if (file.fileName.startsWith(prefix)) {
|
|
840
|
-
const
|
|
841
|
-
|
|
815
|
+
const remainder = file.fileName.substring(prefix.length)
|
|
816
|
+
const newFileName = sanitizeFilename(remainder ? newPath + '/' + remainder : newPath)
|
|
817
|
+
file.fileName = newFileName
|
|
842
818
|
file.publishedAt = new Date().toISOString()
|
|
843
819
|
updatedFiles.push({
|
|
844
820
|
cid: file.cid,
|
|
@@ -847,19 +823,19 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
847
823
|
})
|
|
848
824
|
}
|
|
849
825
|
}
|
|
850
|
-
|
|
826
|
+
|
|
851
827
|
if (updatedFiles.length > 0) {
|
|
852
828
|
this.#savePublishedMetadata()
|
|
853
829
|
}
|
|
854
|
-
|
|
830
|
+
|
|
855
831
|
return { files: updatedFiles }
|
|
856
832
|
}
|
|
857
833
|
|
|
858
834
|
/**
|
|
859
|
-
*
|
|
860
|
-
* @param {string} taskId -
|
|
835
|
+
* 取消正在进行的下载
|
|
836
|
+
* @param {string} taskId - 要取消下载的任务 ID
|
|
861
837
|
*/
|
|
862
|
-
|
|
838
|
+
cancelDownload(taskId) {
|
|
863
839
|
const task = this.#activeDownloads.get(taskId)
|
|
864
840
|
if (task) {
|
|
865
841
|
task.aborted = true
|
|
@@ -872,7 +848,130 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
872
848
|
return this.#publishedFiles
|
|
873
849
|
}
|
|
874
850
|
|
|
875
|
-
|
|
851
|
+
/**
|
|
852
|
+
* 读取已发布文件的内容(用于预览)
|
|
853
|
+
* Hyperdrive 中用 CID 作为 key 存储
|
|
854
|
+
* @param {string} cid - 文件的 CID
|
|
855
|
+
* @param {number} [offset=0] - 读取起始位置
|
|
856
|
+
* @param {number} [limit=10000] - 最大读取字节数
|
|
857
|
+
*/
|
|
858
|
+
async readFileContent(cid, offset = 0, limit = DEFAULT_READ_LIMIT) {
|
|
859
|
+
this.#ensureInitialized()
|
|
860
|
+
|
|
861
|
+
const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
|
|
862
|
+
if (!fileRecord) {
|
|
863
|
+
throw new Error('File not found')
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const drive = await this.#getDriveForFile(fileRecord)
|
|
867
|
+
|
|
868
|
+
// Hyperdrive 中 key 为 '/' + cid
|
|
869
|
+
const driveKey = '/' + cid
|
|
870
|
+
const entry = await drive.entry(driveKey, { wait: true, timeout: DRIVE_ENTRY_TIMEOUT })
|
|
871
|
+
if (!entry || !entry.value) {
|
|
872
|
+
throw new Error('File content not available')
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const chunks = []
|
|
876
|
+
const stream = drive.createReadStream(driveKey, { start: offset, end: offset + limit - 1 })
|
|
877
|
+
|
|
878
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
879
|
+
setTimeout(() => reject(new Error('Stream read timeout')), STREAM_READ_TIMEOUT)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
const readPromise = (async () => {
|
|
883
|
+
for await (const chunk of stream) {
|
|
884
|
+
chunks.push(chunk)
|
|
885
|
+
}
|
|
886
|
+
})()
|
|
887
|
+
|
|
888
|
+
await Promise.race([readPromise, timeoutPromise])
|
|
889
|
+
|
|
890
|
+
const content = Buffer.concat(chunks).toString('utf8')
|
|
891
|
+
const hasMore = chunks.length > 0 && chunks[chunks.length - 1].length === limit
|
|
892
|
+
|
|
893
|
+
return { content, hasMore }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* 读取已发布文件的原始内容(用于预览/下载)
|
|
898
|
+
* Hyperdrive 中用 CID 作为 key 存储
|
|
899
|
+
* @param {string} cid - 文件的 CID
|
|
900
|
+
* @param {object} [options] - 选项
|
|
901
|
+
* @param {number} [options.offset=0] - 读取起始位置
|
|
902
|
+
* @param {number} [options.limit] - 最大读取字节数,不指定则读取到末尾
|
|
903
|
+
* @param {number} [options.timeout=10000] - 流读取超时(毫秒)
|
|
904
|
+
* @returns {Promise<{buffer: Buffer, fileName: string, totalSize: number}>}
|
|
905
|
+
*/
|
|
906
|
+
async readFileRaw(cid, options = {}) {
|
|
907
|
+
this.#ensureInitialized()
|
|
908
|
+
|
|
909
|
+
const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
|
|
910
|
+
if (!fileRecord) {
|
|
911
|
+
throw new Error('File not found')
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const drive = await this.#getDriveForFile(fileRecord)
|
|
915
|
+
|
|
916
|
+
const driveKey = '/' + cid
|
|
917
|
+
const entry = await drive.entry(driveKey, { wait: true, timeout: DRIVE_ENTRY_TIMEOUT })
|
|
918
|
+
if (!entry || !entry.value || !entry.value.blob) {
|
|
919
|
+
throw new Error('File content not available')
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const totalSize = entry.value.blob.byteLength || 0
|
|
923
|
+
|
|
924
|
+
const { offset = 0, limit, timeout = STREAM_READ_TIMEOUT } = options
|
|
925
|
+
const effectiveLimit = (limit === undefined || limit === null)
|
|
926
|
+
? totalSize - offset
|
|
927
|
+
: Math.min(limit, totalSize - offset)
|
|
928
|
+
|
|
929
|
+
if (effectiveLimit <= 0) {
|
|
930
|
+
return { buffer: Buffer.alloc(0), fileName: fileRecord.fileName, totalSize }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const chunks = []
|
|
934
|
+
const stream = drive.createReadStream(driveKey, {
|
|
935
|
+
start: offset,
|
|
936
|
+
end: offset + effectiveLimit - 1
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
940
|
+
setTimeout(() => reject(new Error('Stream read timeout')), timeout)
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
const readPromise = (async () => {
|
|
944
|
+
try {
|
|
945
|
+
for await (const chunk of stream) {
|
|
946
|
+
chunks.push(chunk)
|
|
947
|
+
}
|
|
948
|
+
} catch (err) {
|
|
949
|
+
if (err.message !== 'Stream read timeout') {
|
|
950
|
+
throw err
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
})()
|
|
954
|
+
|
|
955
|
+
await Promise.race([readPromise, timeoutPromise])
|
|
956
|
+
await readPromise.catch(() => { })
|
|
957
|
+
|
|
958
|
+
const buffer = Buffer.concat(chunks)
|
|
959
|
+
return { buffer, fileName: fileRecord.fileName, totalSize }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* 获取文件对应的 drive,如果不存在则创建并同步
|
|
964
|
+
*/
|
|
965
|
+
async #getDriveForFile(fileRecord) {
|
|
966
|
+
let drive = this.#drives.get(fileRecord.driveName)
|
|
967
|
+
if (!drive) {
|
|
968
|
+
drive = await this.#getOrCreateDrive(fileRecord.driveName, { server: true, client: true })
|
|
969
|
+
}
|
|
970
|
+
await this.#syncDrive(drive)
|
|
971
|
+
return drive
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// --- 私有方法 ---
|
|
876
975
|
|
|
877
976
|
#ensureInitialized() {
|
|
878
977
|
if (!this.#initialized) {
|
|
@@ -880,21 +979,61 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
880
979
|
}
|
|
881
980
|
}
|
|
882
981
|
|
|
982
|
+
async #getOrCreateDrive(name, options = { server: true, client: false }) {
|
|
983
|
+
if (this.#drives.has(name)) return this.#drives.get(name)
|
|
984
|
+
if (this.#drivePromises.has(name)) return this.#drivePromises.get(name)
|
|
985
|
+
|
|
986
|
+
const promise = (async () => {
|
|
987
|
+
const drive = new Hyperdrive(this.#store.namespace(name))
|
|
988
|
+
await drive.ready()
|
|
989
|
+
this.#drives.set(name, drive)
|
|
990
|
+
return drive
|
|
991
|
+
})()
|
|
992
|
+
|
|
993
|
+
this.#drivePromises.set(name, promise)
|
|
994
|
+
|
|
995
|
+
try {
|
|
996
|
+
const drive = await promise
|
|
997
|
+
return drive
|
|
998
|
+
} finally {
|
|
999
|
+
this.#drivePromises.delete(name)
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async #syncDrive(drive, timeout = DRIVE_SYNC_TIMEOUT) {
|
|
1004
|
+
const done = drive.findingPeers()
|
|
1005
|
+
this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed().then(done, done)
|
|
1006
|
+
try {
|
|
1007
|
+
const updated = await Promise.race([
|
|
1008
|
+
drive.update(),
|
|
1009
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Sync timeout')), timeout))
|
|
1010
|
+
])
|
|
1011
|
+
return updated
|
|
1012
|
+
} catch {
|
|
1013
|
+
return false
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
883
1017
|
#getMetadataPath() {
|
|
884
1018
|
return path.join(this.#options.dataPath, 'published-files.json')
|
|
885
1019
|
}
|
|
886
|
-
|
|
1020
|
+
|
|
887
1021
|
#getTrashMetadataPath() {
|
|
888
1022
|
return path.join(this.#options.dataPath, 'trash-files.json')
|
|
889
1023
|
}
|
|
890
1024
|
|
|
1025
|
+
#atomicWrite(filePath, data) {
|
|
1026
|
+
const tmpPath = filePath + '.tmp'
|
|
1027
|
+
fs.writeFileSync(tmpPath, data, 'utf-8')
|
|
1028
|
+
fs.renameSync(tmpPath, filePath)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
891
1031
|
#loadPublishedMetadata() {
|
|
892
1032
|
try {
|
|
893
1033
|
const metadataPath = this.#getMetadataPath()
|
|
894
1034
|
if (fs.existsSync(metadataPath)) {
|
|
895
1035
|
const data = fs.readFileSync(metadataPath, 'utf-8')
|
|
896
1036
|
const parsed = JSON.parse(data)
|
|
897
|
-
// Ensure starred field exists for older data
|
|
898
1037
|
return parsed.map(f => ({ ...f, starred: f.starred || false }))
|
|
899
1038
|
}
|
|
900
1039
|
} catch (err) {
|
|
@@ -906,12 +1045,12 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
906
1045
|
#savePublishedMetadata() {
|
|
907
1046
|
try {
|
|
908
1047
|
const metadataPath = this.#getMetadataPath()
|
|
909
|
-
|
|
1048
|
+
this.#atomicWrite(metadataPath, JSON.stringify(this.#publishedFiles, null, 2))
|
|
910
1049
|
} catch (err) {
|
|
911
1050
|
console.error('Failed to save published metadata:', err.message)
|
|
912
1051
|
}
|
|
913
1052
|
}
|
|
914
|
-
|
|
1053
|
+
|
|
915
1054
|
#loadTrashMetadata() {
|
|
916
1055
|
try {
|
|
917
1056
|
const metadataPath = this.#getTrashMetadataPath()
|
|
@@ -924,32 +1063,31 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
924
1063
|
}
|
|
925
1064
|
return []
|
|
926
1065
|
}
|
|
927
|
-
|
|
1066
|
+
|
|
928
1067
|
#saveTrashMetadata() {
|
|
929
1068
|
try {
|
|
930
1069
|
const metadataPath = this.#getTrashMetadataPath()
|
|
931
|
-
|
|
1070
|
+
this.#atomicWrite(metadataPath, JSON.stringify(this.#trashFiles, null, 2))
|
|
932
1071
|
} catch (err) {
|
|
933
1072
|
console.error('Failed to save trash metadata:', err.message)
|
|
934
1073
|
}
|
|
935
1074
|
}
|
|
936
1075
|
|
|
937
1076
|
/**
|
|
938
|
-
*
|
|
939
|
-
* @param {Hyperdrive} drive -
|
|
940
|
-
* @param {number} timeout -
|
|
941
|
-
* @param {string} [taskId] -
|
|
942
|
-
* @param {object} [taskState] -
|
|
943
|
-
* @returns {Promise<Array>} -
|
|
1077
|
+
* 等待驱动器内容从对等节点或本地可用
|
|
1078
|
+
* @param {Hyperdrive} drive - 要检查的驱动器
|
|
1079
|
+
* @param {number} timeout - 最大等待时间(毫秒)
|
|
1080
|
+
* @param {string} [taskId] - 用于取消的任务 ID
|
|
1081
|
+
* @param {object} [taskState] - 任务状态对象
|
|
1082
|
+
* @returns {Promise<Array>} - 条目列表
|
|
944
1083
|
*/
|
|
945
1084
|
async #waitForDriveContent(drive, timeout, taskId = null, taskState = null) {
|
|
946
1085
|
const startTime = Date.now()
|
|
947
|
-
const checkInterval =
|
|
1086
|
+
const checkInterval = DOWNLOAD_POLL_INTERVAL
|
|
948
1087
|
let lastPeerCount = 0
|
|
949
1088
|
let lastStatus = ''
|
|
950
1089
|
let bootstrapNodesChecked = false
|
|
951
1090
|
|
|
952
|
-
// First, check if content is already available locally (for self-published files)
|
|
953
1091
|
const localEntries = []
|
|
954
1092
|
try {
|
|
955
1093
|
for await (const entry of drive.list()) {
|
|
@@ -961,36 +1099,32 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
961
1099
|
return localEntries
|
|
962
1100
|
}
|
|
963
1101
|
} catch (err) {
|
|
964
|
-
//
|
|
1102
|
+
// 继续进行节点发现
|
|
965
1103
|
}
|
|
966
1104
|
|
|
967
1105
|
while (Date.now() - startTime < timeout) {
|
|
968
|
-
// Check cancellation
|
|
969
1106
|
if (taskState && taskState.aborted) {
|
|
970
1107
|
throw new Error('Download cancelled')
|
|
971
1108
|
}
|
|
972
1109
|
|
|
973
1110
|
const currentTime = Date.now()
|
|
974
1111
|
const elapsed = Math.round((currentTime - startTime) / 1000)
|
|
975
|
-
|
|
976
|
-
// Check if we have peers
|
|
1112
|
+
|
|
977
1113
|
const currentPeerCount = this.#swarm.connections.size
|
|
978
1114
|
const hasPeers = currentPeerCount > 0
|
|
979
1115
|
|
|
980
|
-
// Log peer count changes
|
|
981
1116
|
if (currentPeerCount !== lastPeerCount) {
|
|
982
1117
|
console.log(`[MostBox] Peer count changed: ${lastPeerCount} -> ${currentPeerCount} (elapsed: ${elapsed}s)`)
|
|
983
1118
|
lastPeerCount = currentPeerCount
|
|
984
1119
|
}
|
|
985
1120
|
|
|
986
|
-
// Try to list entries (works for both local and synced data)
|
|
987
1121
|
const entries = []
|
|
988
1122
|
try {
|
|
989
1123
|
for await (const entry of drive.list()) {
|
|
990
1124
|
entries.push(entry)
|
|
991
1125
|
}
|
|
992
1126
|
} catch (err) {
|
|
993
|
-
//
|
|
1127
|
+
// 驱动器可能尚未就绪
|
|
994
1128
|
}
|
|
995
1129
|
|
|
996
1130
|
if (entries.length > 0) {
|
|
@@ -999,7 +1133,6 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
999
1133
|
return entries
|
|
1000
1134
|
}
|
|
1001
1135
|
|
|
1002
|
-
// Update status based on peer connection
|
|
1003
1136
|
if (hasPeers) {
|
|
1004
1137
|
const newStatus = 'syncing'
|
|
1005
1138
|
if (lastStatus !== newStatus) {
|
|
@@ -1012,12 +1145,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1012
1145
|
this.emit('download:status', { taskId, status: newStatus })
|
|
1013
1146
|
lastStatus = newStatus
|
|
1014
1147
|
}
|
|
1015
|
-
|
|
1016
|
-
// Log progress every 30 seconds
|
|
1148
|
+
|
|
1017
1149
|
if (elapsed % 30 === 0 && elapsed > 0) {
|
|
1018
|
-
console.log(`[MostBox] Still waiting for peers... (${elapsed}s elapsed, timeout: ${timeout/1000}s)`)
|
|
1019
|
-
|
|
1020
|
-
// Check if bootstrap nodes are reachable (only once)
|
|
1150
|
+
console.log(`[MostBox] Still waiting for peers... (${elapsed}s elapsed, timeout: ${timeout / 1000}s)`)
|
|
1151
|
+
|
|
1021
1152
|
if (!bootstrapNodesChecked && elapsed >= 60) {
|
|
1022
1153
|
bootstrapNodesChecked = true
|
|
1023
1154
|
console.log(`[MostBox] No peers found after 60s. This may indicate:`)
|
|
@@ -1029,13 +1160,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1029
1160
|
}
|
|
1030
1161
|
}
|
|
1031
1162
|
|
|
1032
|
-
// Wait before next check
|
|
1033
1163
|
await new Promise(resolve => setTimeout(resolve, checkInterval))
|
|
1034
1164
|
}
|
|
1035
1165
|
|
|
1036
|
-
console.log(`[MostBox] Timeout reached after ${timeout/1000}s, making final attempt...`)
|
|
1166
|
+
console.log(`[MostBox] Timeout reached after ${timeout / 1000}s, making final attempt...`)
|
|
1037
1167
|
|
|
1038
|
-
// Final attempt - return whatever we have (might be empty)
|
|
1039
1168
|
const entries = []
|
|
1040
1169
|
try {
|
|
1041
1170
|
for await (const entry of drive.list()) {
|
|
@@ -1044,30 +1173,29 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1044
1173
|
} catch (err) {
|
|
1045
1174
|
console.log(`[MostBox] Final attempt failed: ${err.message}`)
|
|
1046
1175
|
}
|
|
1047
|
-
|
|
1176
|
+
|
|
1048
1177
|
console.log(`[MostBox] Final entry count: ${entries.length}`)
|
|
1049
|
-
|
|
1050
|
-
// Provide detailed error information
|
|
1178
|
+
|
|
1051
1179
|
if (entries.length === 0) {
|
|
1052
1180
|
const peerCount = this.#swarm.connections.size
|
|
1053
1181
|
console.log(`[MostBox] Diagnostic information:`)
|
|
1054
1182
|
console.log(`[MostBox] - Peer count: ${peerCount}`)
|
|
1055
1183
|
console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
|
|
1056
|
-
console.log(`[MostBox] - Timeout: ${timeout/1000}s`)
|
|
1057
|
-
|
|
1184
|
+
console.log(`[MostBox] - Timeout: ${timeout / 1000}s`)
|
|
1185
|
+
|
|
1058
1186
|
if (peerCount === 0) {
|
|
1059
1187
|
console.log(`[MostBox] Suggestion: Check network connectivity and firewall settings`)
|
|
1060
1188
|
} else {
|
|
1061
1189
|
console.log(`[MostBox] Suggestion: Publisher may be offline or file may have been removed`)
|
|
1062
1190
|
}
|
|
1063
1191
|
}
|
|
1064
|
-
|
|
1192
|
+
|
|
1065
1193
|
return entries
|
|
1066
1194
|
}
|
|
1067
1195
|
}
|
|
1068
1196
|
|
|
1069
|
-
//
|
|
1197
|
+
// 重新导出工具函数
|
|
1070
1198
|
export * from './config.js'
|
|
1071
1199
|
export * from './core/cid.js'
|
|
1072
1200
|
export * from './utils/errors.js'
|
|
1073
|
-
export * from './utils/security.js'
|
|
1201
|
+
export * from './utils/security.js'
|