most-box 0.0.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/LICENSE +21 -0
- package/README.md +73 -0
- package/build.mjs +40 -0
- package/cli.js +2 -0
- package/package.json +44 -0
- package/public/app.jsx +1335 -0
- package/public/bundle.js +111 -0
- package/public/bundle.js.map +7 -0
- package/public/favicon.ico +0 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/mask-icon.svg +3 -0
- package/public/icons/most.png +0 -0
- package/public/icons/pwa-192x192.png +0 -0
- package/public/icons/pwa-512x512.png +0 -0
- package/public/index.html +15 -0
- package/public/index.jsx +5 -0
- package/server.js +615 -0
- package/src/config.js +22 -0
- package/src/core/cid.js +141 -0
- package/src/index.js +1073 -0
- package/src/utils/errors.js +66 -0
- package/src/utils/security.js +166 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MostBoxEngine - Core P2P Engine
|
|
3
|
+
* Platform-agnostic engine for P2P file sharing using Hyperswarm/Hyperdrive
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import EventEmitter from 'eventemitter3'
|
|
7
|
+
import Hyperswarm from 'hyperswarm'
|
|
8
|
+
import Corestore from 'corestore'
|
|
9
|
+
import Hyperdrive from 'hyperdrive'
|
|
10
|
+
import b4a from 'b4a'
|
|
11
|
+
import { CID } from 'multiformats/cid'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { calculateCid, parseMostLink } from './core/cid.js'
|
|
16
|
+
import { sanitizeFilename, validateAndSanitizePath, validateFileSize, checkDirectoryWritable, formatFileSize } from './utils/security.js'
|
|
17
|
+
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'
|
|
19
|
+
|
|
20
|
+
export class MostBoxEngine extends EventEmitter {
|
|
21
|
+
#store = null
|
|
22
|
+
#swarm = null
|
|
23
|
+
#drives = new Map()
|
|
24
|
+
#publishedFiles = []
|
|
25
|
+
#trashFiles = []
|
|
26
|
+
#initialized = false
|
|
27
|
+
#options = null
|
|
28
|
+
#activeDownloads = new Map() // taskId -> { aborted, readStream, writeStream }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a new MostBoxEngine instance
|
|
32
|
+
* @param {object} options - Configuration options
|
|
33
|
+
* @param {string} options.dataPath - Path to store P2P data (required)
|
|
34
|
+
* @param {string} [options.downloadPath] - Default download path (optional, defaults to dataPath/downloads)
|
|
35
|
+
* @param {number} [options.maxFileSize] - Maximum file size in bytes (default: 100GB)
|
|
36
|
+
*/
|
|
37
|
+
constructor(options) {
|
|
38
|
+
super()
|
|
39
|
+
|
|
40
|
+
if (!options || !options.dataPath) {
|
|
41
|
+
throw new Error('dataPath is required')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.#options = {
|
|
45
|
+
dataPath: options.dataPath,
|
|
46
|
+
downloadPath: options.downloadPath || path.join(options.dataPath, 'downloads'),
|
|
47
|
+
maxFileSize: options.maxFileSize || MAX_FILE_SIZE
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Initialize the engine - must be called before other methods
|
|
53
|
+
*/
|
|
54
|
+
async start() {
|
|
55
|
+
if (this.#initialized) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { dataPath } = this.#options
|
|
60
|
+
|
|
61
|
+
console.log(`[MostBox] Initializing engine...`)
|
|
62
|
+
console.log(`[MostBox] Storage path: ${dataPath}`)
|
|
63
|
+
|
|
64
|
+
// Create storage directory if not exists
|
|
65
|
+
if (!fs.existsSync(dataPath)) {
|
|
66
|
+
fs.mkdirSync(dataPath, { recursive: true })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Initialize Corestore with global shared seed
|
|
70
|
+
const GLOBAL_SHARED_SEED = b4a.alloc(32).fill(GLOBAL_SHARED_SEED_STRING)
|
|
71
|
+
this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await this.#store.ready()
|
|
75
|
+
console.log(`[MostBox] Corestore ready`)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.message && err.message.includes('Another corestore is stored here')) {
|
|
78
|
+
console.log(`[MostBox] Resetting corrupt storage...`)
|
|
79
|
+
fs.rmSync(dataPath, { recursive: true, force: true })
|
|
80
|
+
fs.mkdirSync(dataPath, { recursive: true })
|
|
81
|
+
this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
|
|
82
|
+
await this.#store.ready()
|
|
83
|
+
console.log(`[MostBox] Corestore reset and ready`)
|
|
84
|
+
} else if (err.message && err.message.includes('Invalid device file')) {
|
|
85
|
+
throw new Error(`存储文件损坏,请关闭其他访问 ${dataPath} 的程序后重试`)
|
|
86
|
+
} else if (err.message && err.message.includes('File descriptor could not be locked')) {
|
|
87
|
+
throw new Error(`存储文件被锁定,请关闭其他访问 ${dataPath} 的程序后重试`)
|
|
88
|
+
} else {
|
|
89
|
+
throw err
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Initialize Hyperswarm with NAT traversal enabled
|
|
94
|
+
console.log(`[MostBox] Initializing Hyperswarm...`)
|
|
95
|
+
this.#swarm = new Hyperswarm({
|
|
96
|
+
// Connection settings for better stability
|
|
97
|
+
maxPeers: 64,
|
|
98
|
+
// DHT bootstrap nodes (same as Keet.io/HyperDHT)
|
|
99
|
+
bootstrap: SWARM_BOOTSTRAP,
|
|
100
|
+
// Enable NAT traversal (hole punching)
|
|
101
|
+
// firewall function: allow all connections (default behavior)
|
|
102
|
+
firewall: () => false,
|
|
103
|
+
// Connection keep-alive timeout (5 seconds)
|
|
104
|
+
connectionKeepAlive: 5000,
|
|
105
|
+
// Random punch interval for NAT traversal (20 seconds)
|
|
106
|
+
randomPunchInterval: 20000,
|
|
107
|
+
// Increase timeouts for unstable networks
|
|
108
|
+
handshakeTimeout: CONNECTION_TIMEOUT
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Handle swarm-level errors
|
|
112
|
+
this.#swarm.on('error', (err) => {
|
|
113
|
+
// Silently handle SSL/network errors - they're non-critical for DHT discovery
|
|
114
|
+
if (err.code === 'SSL_ERROR' || err.message?.includes('handshake') || err.message?.includes('ECONNRESET')) {
|
|
115
|
+
console.warn('[MostBox] Network warning (non-critical):', err.message)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
console.error('[MostBox] Swarm error:', err.message)
|
|
119
|
+
this.emit('error', err)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Replicate store on new connections
|
|
123
|
+
this.#swarm.on('connection', (conn, info) => {
|
|
124
|
+
console.log(`[MostBox] New peer connection established`)
|
|
125
|
+
// Handle connection errors gracefully
|
|
126
|
+
conn.on('error', (err) => {
|
|
127
|
+
if (err.code === 'SSL_ERROR' || err.message?.includes('handshake')) {
|
|
128
|
+
console.warn('[MostBox] Connection warning:', err.message)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
console.error('[MostBox] Connection error:', err.message)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
this.#store.replicate(conn)
|
|
135
|
+
this.emit('connection', conn)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Load published files metadata
|
|
139
|
+
this.#publishedFiles = this.#loadPublishedMetadata()
|
|
140
|
+
console.log(`[MostBox] Loaded ${this.#publishedFiles.length} published files`)
|
|
141
|
+
|
|
142
|
+
// Load trash files metadata
|
|
143
|
+
this.#trashFiles = this.#loadTrashMetadata()
|
|
144
|
+
console.log(`[MostBox] Loaded ${this.#trashFiles.length} trash files`)
|
|
145
|
+
|
|
146
|
+
this.#initialized = true
|
|
147
|
+
console.log(`[MostBox] Engine initialized successfully`)
|
|
148
|
+
this.emit('ready')
|
|
149
|
+
|
|
150
|
+
return this
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Stop the engine and cleanup resources
|
|
155
|
+
*/
|
|
156
|
+
async stop() {
|
|
157
|
+
if (!this.#initialized) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Close all drives
|
|
162
|
+
for (const drive of this.#drives.values()) {
|
|
163
|
+
await drive.close()
|
|
164
|
+
}
|
|
165
|
+
this.#drives.clear()
|
|
166
|
+
|
|
167
|
+
// Destroy swarm
|
|
168
|
+
if (this.#swarm) {
|
|
169
|
+
await this.#swarm.destroy()
|
|
170
|
+
this.#swarm = null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Close store
|
|
174
|
+
if (this.#store) {
|
|
175
|
+
await this.#store.close()
|
|
176
|
+
this.#store = null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.#initialized = false
|
|
180
|
+
this.emit('stopped')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get the node's public key
|
|
185
|
+
* @returns {string} Node ID as hex string
|
|
186
|
+
*/
|
|
187
|
+
getNodeId() {
|
|
188
|
+
this.#ensureInitialized()
|
|
189
|
+
return b4a.toString(this.#swarm.keyPair.publicKey, 'hex')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get current network status
|
|
194
|
+
* @returns {{ peers: number, status: string }}
|
|
195
|
+
*/
|
|
196
|
+
getNetworkStatus() {
|
|
197
|
+
this.#ensureInitialized()
|
|
198
|
+
const connections = this.#swarm.connections.size
|
|
199
|
+
return {
|
|
200
|
+
peers: connections,
|
|
201
|
+
status: connections > 0 ? 'connected' : 'waiting'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Publish content to the P2P network
|
|
207
|
+
* @param {string|Buffer} content - File path (string) or content (Buffer)
|
|
208
|
+
* @param {string} [fileName] - Name for the file (required for Buffer input)
|
|
209
|
+
* @returns {Promise<{ cid: string, link: string, fileName: string }>}
|
|
210
|
+
*/
|
|
211
|
+
async publishFile(content, fileName) {
|
|
212
|
+
this.#ensureInitialized()
|
|
213
|
+
|
|
214
|
+
let cleanPath = null
|
|
215
|
+
let safeFileName
|
|
216
|
+
let fileSize
|
|
217
|
+
|
|
218
|
+
if (Buffer.isBuffer(content)) {
|
|
219
|
+
if (!fileName) {
|
|
220
|
+
throw new Error('fileName is required when publishing Buffer content')
|
|
221
|
+
}
|
|
222
|
+
safeFileName = sanitizeFilename(fileName)
|
|
223
|
+
fileSize = content.length
|
|
224
|
+
} else {
|
|
225
|
+
cleanPath = content
|
|
226
|
+
const pathValidation = validateAndSanitizePath(cleanPath)
|
|
227
|
+
if (pathValidation.error) {
|
|
228
|
+
throw new PathSecurityError(pathValidation.error)
|
|
229
|
+
}
|
|
230
|
+
cleanPath = pathValidation.cleanPath
|
|
231
|
+
|
|
232
|
+
const sizeValidation = await validateFileSize(cleanPath, this.#options.maxFileSize)
|
|
233
|
+
if (!sizeValidation.valid) {
|
|
234
|
+
throw new FileSizeError(sizeValidation.error, sizeValidation.size)
|
|
235
|
+
}
|
|
236
|
+
fileSize = sizeValidation.size
|
|
237
|
+
|
|
238
|
+
safeFileName = sanitizeFilename(fileName || path.basename(cleanPath))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (fileSize > this.#options.maxFileSize) {
|
|
242
|
+
const maxGB = Math.round(this.#options.maxFileSize / (1024 * 1024 * 1024))
|
|
243
|
+
throw new FileSizeError(`File size exceeds limit of ${maxGB} GB`, fileSize)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.emit('publish:progress', { stage: 'calculating-cid', file: safeFileName })
|
|
247
|
+
|
|
248
|
+
const { cid: rootCid } = await calculateCid(content)
|
|
249
|
+
const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
|
|
250
|
+
const cidString = rootCid.toString()
|
|
251
|
+
|
|
252
|
+
const name = `drive-${hashHex}`
|
|
253
|
+
let drive = this.#drives.get(name)
|
|
254
|
+
|
|
255
|
+
if (!drive) {
|
|
256
|
+
drive = new Hyperdrive(this.#store.namespace(name))
|
|
257
|
+
await drive.ready()
|
|
258
|
+
this.#drives.set(name, drive)
|
|
259
|
+
|
|
260
|
+
const discovery = this.#swarm.join(drive.discoveryKey, { server: true, client: false })
|
|
261
|
+
await discovery.flushed()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.emit('publish:progress', { stage: 'uploading', file: safeFileName })
|
|
265
|
+
|
|
266
|
+
const ws = drive.createWriteStream(safeFileName)
|
|
267
|
+
|
|
268
|
+
if (Buffer.isBuffer(content)) {
|
|
269
|
+
// Stream buffer in chunks to avoid exceeding Hyperdrive block size limit
|
|
270
|
+
const CHUNK_SIZE = 64 * 1024 // 64KB chunks
|
|
271
|
+
let offset = 0
|
|
272
|
+
|
|
273
|
+
const waitForDrain = () => new Promise(resolve => ws.once('drain', resolve))
|
|
274
|
+
|
|
275
|
+
while (offset < content.length) {
|
|
276
|
+
const chunk = content.slice(offset, offset + CHUNK_SIZE)
|
|
277
|
+
const canContinue = ws.write(chunk)
|
|
278
|
+
offset += chunk.length
|
|
279
|
+
|
|
280
|
+
if (!canContinue && offset < content.length) {
|
|
281
|
+
await waitForDrain()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
ws.end()
|
|
286
|
+
await new Promise((resolve, reject) => {
|
|
287
|
+
ws.on('finish', resolve)
|
|
288
|
+
ws.on('error', reject)
|
|
289
|
+
})
|
|
290
|
+
} else {
|
|
291
|
+
const rs = fs.createReadStream(cleanPath)
|
|
292
|
+
await new Promise((resolve, reject) => {
|
|
293
|
+
rs.pipe(ws)
|
|
294
|
+
ws.on('finish', resolve)
|
|
295
|
+
ws.on('error', reject)
|
|
296
|
+
rs.on('error', reject)
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
|
|
301
|
+
if (existingIndex !== -1) {
|
|
302
|
+
const existing = this.#publishedFiles[existingIndex]
|
|
303
|
+
// Same content already exists - return "already exists" regardless of filename
|
|
304
|
+
return {
|
|
305
|
+
cid: cidString,
|
|
306
|
+
link: `most://${cidString}`,
|
|
307
|
+
fileName: existing.fileName,
|
|
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
|
+
}
|
|
318
|
+
this.#savePublishedMetadata()
|
|
319
|
+
|
|
320
|
+
const result = {
|
|
321
|
+
cid: cidString,
|
|
322
|
+
link: `most://${cidString}`,
|
|
323
|
+
fileName: safeFileName
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.emit('publish:success', result)
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Download a file from the P2P network
|
|
332
|
+
* @param {string} link - most:// link
|
|
333
|
+
* @param {string} [taskId] - Task ID for cancellation
|
|
334
|
+
* @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
|
|
335
|
+
*/
|
|
336
|
+
async downloadFile(link, taskId = null) {
|
|
337
|
+
this.#ensureInitialized()
|
|
338
|
+
|
|
339
|
+
// Generate taskId if not provided
|
|
340
|
+
taskId = taskId || `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
341
|
+
|
|
342
|
+
console.log(`[MostBox] Starting download for link: ${link} (taskId: ${taskId})`)
|
|
343
|
+
|
|
344
|
+
// Register in active downloads
|
|
345
|
+
const taskState = { aborted: false, readStream: null, writeStream: null }
|
|
346
|
+
this.#activeDownloads.set(taskId, taskState)
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
// Parse link
|
|
350
|
+
const parsed = parseMostLink(link)
|
|
351
|
+
if (parsed.error) {
|
|
352
|
+
throw new ValidationError(parsed.error)
|
|
353
|
+
}
|
|
354
|
+
const cidString = parsed.cid
|
|
355
|
+
console.log(`[MostBox] Parsed CID: ${cidString}`)
|
|
356
|
+
|
|
357
|
+
// Check if file already exists in published files
|
|
358
|
+
const existingFile = this.#publishedFiles.find(f => f.cid === cidString)
|
|
359
|
+
if (existingFile) {
|
|
360
|
+
console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
|
|
361
|
+
return {
|
|
362
|
+
taskId,
|
|
363
|
+
fileName: existingFile.fileName,
|
|
364
|
+
alreadyExists: true
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Parse CID
|
|
369
|
+
const parsedCid = CID.parse(cidString)
|
|
370
|
+
const hashBytes = parsedCid.multihash.digest
|
|
371
|
+
const hashHex = b4a.toString(hashBytes, 'hex')
|
|
372
|
+
|
|
373
|
+
// Check cancellation
|
|
374
|
+
if (taskState.aborted) throw new Error('Download cancelled')
|
|
375
|
+
|
|
376
|
+
// Get/Create drive
|
|
377
|
+
const name = `drive-${hashHex}`
|
|
378
|
+
let drive = this.#drives.get(name)
|
|
379
|
+
|
|
380
|
+
if (!drive) {
|
|
381
|
+
console.log(`[MostBox] Creating new drive: ${name}`)
|
|
382
|
+
drive = new Hyperdrive(this.#store.namespace(name))
|
|
383
|
+
await drive.ready()
|
|
384
|
+
this.#drives.set(name, drive)
|
|
385
|
+
|
|
386
|
+
this.emit('download:status', { taskId, status: 'connecting' })
|
|
387
|
+
|
|
388
|
+
console.log(`[MostBox] Joining swarm for drive discovery...`)
|
|
389
|
+
// Join as both server and client to allow self-downloads
|
|
390
|
+
await this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed()
|
|
391
|
+
console.log(`[MostBox] Swarm join flushed`)
|
|
392
|
+
} else {
|
|
393
|
+
console.log(`[MostBox] Using existing drive: ${name}`)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check cancellation
|
|
397
|
+
if (taskState.aborted) throw new Error('Download cancelled')
|
|
398
|
+
|
|
399
|
+
this.emit('download:status', { taskId, status: 'finding-peers' })
|
|
400
|
+
|
|
401
|
+
// Wait for peers and data to sync
|
|
402
|
+
console.log(`[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT/1000}s)...`)
|
|
403
|
+
const entries = await this.#waitForDriveContent(drive, DOWNLOAD_TIMEOUT, taskId, taskState)
|
|
404
|
+
|
|
405
|
+
if (entries.length === 0) {
|
|
406
|
+
console.log(`[MostBox] No entries found after timeout`)
|
|
407
|
+
|
|
408
|
+
// 提供更详细的错误信息
|
|
409
|
+
const peerCount = this.#swarm.connections.size
|
|
410
|
+
let errorMessage = 'No files found in drive. '
|
|
411
|
+
|
|
412
|
+
if (peerCount === 0) {
|
|
413
|
+
errorMessage += 'Could not connect to any peers. This may be due to:\n'
|
|
414
|
+
errorMessage += '1. Network firewall blocking P2P connections\n'
|
|
415
|
+
errorMessage += '2. DHT bootstrap nodes unreachable\n'
|
|
416
|
+
errorMessage += '3. NAT traversal failed (try port forwarding)\n'
|
|
417
|
+
errorMessage += '4. No peers are currently sharing this file'
|
|
418
|
+
} else {
|
|
419
|
+
errorMessage += `Connected to ${peerCount} peers but no file data was found. This may be due to:\n`
|
|
420
|
+
errorMessage += '1. Publisher node offline\n'
|
|
421
|
+
errorMessage += '2. File may have been removed by publisher\n'
|
|
422
|
+
errorMessage += '3. File link may be invalid or corrupted'
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
throw new PeerNotFoundError(errorMessage)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Check cancellation
|
|
429
|
+
if (taskState.aborted) throw new Error('Download cancelled')
|
|
430
|
+
|
|
431
|
+
console.log(`[MostBox] Found ${entries.length} entries, starting download...`)
|
|
432
|
+
|
|
433
|
+
// Save to storage directory (not Downloads folder)
|
|
434
|
+
const targetDir = this.#options.dataPath
|
|
435
|
+
|
|
436
|
+
// Check storage directory
|
|
437
|
+
const writableCheck = await checkDirectoryWritable(targetDir)
|
|
438
|
+
if (!writableCheck.writable) {
|
|
439
|
+
throw new PermissionError(writableCheck.error)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Download files
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
const sanitizedFileName = sanitizeFilename(entry.key.replace(/^[\/\\]/, ''))
|
|
445
|
+
|
|
446
|
+
// Get file size
|
|
447
|
+
let totalBytes = 0
|
|
448
|
+
try {
|
|
449
|
+
const stat = await drive.entry(entry.key)
|
|
450
|
+
if (stat && stat.value && stat.value.blob) {
|
|
451
|
+
totalBytes = stat.value.blob.byteLength || 0
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// Ignore
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const savePath = path.join(targetDir, sanitizedFileName)
|
|
458
|
+
|
|
459
|
+
this.emit('download:status', {
|
|
460
|
+
taskId,
|
|
461
|
+
status: 'downloading',
|
|
462
|
+
file: sanitizedFileName,
|
|
463
|
+
size: totalBytes ? formatFileSize(totalBytes) : null
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Download with progress
|
|
467
|
+
const rs = drive.createReadStream(entry.key)
|
|
468
|
+
const ws = fs.createWriteStream(savePath)
|
|
469
|
+
|
|
470
|
+
taskState.readStream = rs
|
|
471
|
+
taskState.writeStream = ws
|
|
472
|
+
|
|
473
|
+
let loadedBytes = 0
|
|
474
|
+
let lastProgressUpdate = 0
|
|
475
|
+
|
|
476
|
+
await new Promise((resolve, reject) => {
|
|
477
|
+
rs.on('data', (chunk) => {
|
|
478
|
+
// Check cancellation
|
|
479
|
+
if (taskState.aborted) {
|
|
480
|
+
rs.destroy()
|
|
481
|
+
ws.destroy()
|
|
482
|
+
reject(new Error('Download cancelled'))
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
loadedBytes += chunk.length
|
|
486
|
+
const now = Date.now()
|
|
487
|
+
if (totalBytes > 0 && now - lastProgressUpdate > 500) {
|
|
488
|
+
lastProgressUpdate = now
|
|
489
|
+
const percent = Math.round((loadedBytes / totalBytes) * 100)
|
|
490
|
+
this.emit('download:progress', { taskId, loaded: loadedBytes, total: totalBytes, percent })
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
rs.pipe(ws)
|
|
495
|
+
ws.on('finish', resolve)
|
|
496
|
+
ws.on('error', reject)
|
|
497
|
+
rs.on('error', reject)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
// Check cancellation before verification
|
|
501
|
+
if (taskState.aborted) throw new Error('Download cancelled')
|
|
502
|
+
|
|
503
|
+
// Verify integrity
|
|
504
|
+
this.emit('download:status', { taskId, status: 'verifying' })
|
|
505
|
+
|
|
506
|
+
const { cid: downloadedCid } = await calculateCid(savePath)
|
|
507
|
+
const expectedHash = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
508
|
+
const actualHash = b4a.toString(downloadedCid.multihash.digest, 'hex')
|
|
509
|
+
|
|
510
|
+
if (expectedHash !== actualHash) {
|
|
511
|
+
fs.unlinkSync(savePath)
|
|
512
|
+
throw new IntegrityError(`File content CID mismatch. File may be corrupted or tampered.`)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const result = {
|
|
516
|
+
taskId,
|
|
517
|
+
fileName: sanitizedFileName,
|
|
518
|
+
savedPath: savePath
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 将下载的文件添加到已发布文件列表
|
|
522
|
+
const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
|
|
523
|
+
if (existingIndex !== -1) {
|
|
524
|
+
const existing = this.#publishedFiles[existingIndex]
|
|
525
|
+
if (existing.fileName !== sanitizedFileName) {
|
|
526
|
+
throw new Error(`文件已存在: ${existing.fileName}`)
|
|
527
|
+
}
|
|
528
|
+
existing.publishedAt = new Date().toISOString()
|
|
529
|
+
} else {
|
|
530
|
+
this.#publishedFiles.push({
|
|
531
|
+
fileName: sanitizedFileName,
|
|
532
|
+
cid: cidString,
|
|
533
|
+
publishedAt: new Date().toISOString(),
|
|
534
|
+
starred: false
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
this.#savePublishedMetadata()
|
|
538
|
+
|
|
539
|
+
this.emit('download:success', result)
|
|
540
|
+
return result
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
this.#activeDownloads.delete(taskId)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* List all published files
|
|
549
|
+
* @param {object} [options] - Filter options
|
|
550
|
+
* @param {boolean} [options.starred] - Filter by starred status
|
|
551
|
+
* @returns {Array<{ fileName: string, cid: string, link: string, publishedAt: string, starred: boolean }>}
|
|
552
|
+
*/
|
|
553
|
+
listPublishedFiles(options = {}) {
|
|
554
|
+
this.#ensureInitialized()
|
|
555
|
+
let files = this.#publishedFiles
|
|
556
|
+
|
|
557
|
+
if (options.starred === true) {
|
|
558
|
+
files = files.filter(f => f.starred === true)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return files.map(f => ({
|
|
562
|
+
fileName: f.fileName,
|
|
563
|
+
cid: f.cid,
|
|
564
|
+
link: `most://${f.cid}`,
|
|
565
|
+
publishedAt: f.publishedAt,
|
|
566
|
+
starred: f.starred || false
|
|
567
|
+
}))
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Toggle starred status of a file
|
|
572
|
+
* @param {string} cid - CID of the file
|
|
573
|
+
* @returns {object} Updated file info
|
|
574
|
+
*/
|
|
575
|
+
toggleStarred(cid) {
|
|
576
|
+
this.#ensureInitialized()
|
|
577
|
+
const index = this.#publishedFiles.findIndex(f => f.cid === cid)
|
|
578
|
+
if (index === -1) {
|
|
579
|
+
throw new Error('File not found')
|
|
580
|
+
}
|
|
581
|
+
this.#publishedFiles[index].starred = !this.#publishedFiles[index].starred
|
|
582
|
+
this.#savePublishedMetadata()
|
|
583
|
+
return {
|
|
584
|
+
cid,
|
|
585
|
+
starred: this.#publishedFiles[index].starred
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Delete a published file - moves to trash instead of permanent deletion
|
|
591
|
+
* @param {string} cid - CID of the file to delete
|
|
592
|
+
* @returns {Promise<Array>} Updated list of published files
|
|
593
|
+
*/
|
|
594
|
+
async deletePublishedFile(cid) {
|
|
595
|
+
this.#ensureInitialized()
|
|
596
|
+
const index = this.#publishedFiles.findIndex(f => f.cid === cid)
|
|
597
|
+
if (index !== -1) {
|
|
598
|
+
const fileRecord = this.#publishedFiles[index]
|
|
599
|
+
|
|
600
|
+
// Move to trash instead of permanent deletion
|
|
601
|
+
this.#trashFiles.push({
|
|
602
|
+
fileName: fileRecord.fileName,
|
|
603
|
+
cid: fileRecord.cid,
|
|
604
|
+
publishedAt: fileRecord.publishedAt,
|
|
605
|
+
starred: fileRecord.starred || false,
|
|
606
|
+
deletedAt: new Date().toISOString()
|
|
607
|
+
})
|
|
608
|
+
this.#saveTrashMetadata()
|
|
609
|
+
|
|
610
|
+
// Remove from published files
|
|
611
|
+
this.#publishedFiles.splice(index, 1)
|
|
612
|
+
this.#savePublishedMetadata()
|
|
613
|
+
}
|
|
614
|
+
return this.listPublishedFiles()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* List all files in trash
|
|
619
|
+
* @returns {Array} Trash files
|
|
620
|
+
*/
|
|
621
|
+
listTrashFiles() {
|
|
622
|
+
this.#ensureInitialized()
|
|
623
|
+
return this.#trashFiles.map(f => ({
|
|
624
|
+
fileName: f.fileName,
|
|
625
|
+
cid: f.cid,
|
|
626
|
+
link: `most://${f.cid}`,
|
|
627
|
+
publishedAt: f.publishedAt,
|
|
628
|
+
starred: f.starred || false,
|
|
629
|
+
deletedAt: f.deletedAt
|
|
630
|
+
}))
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Restore a file from trash
|
|
635
|
+
* @param {string} cid - CID of the file to restore
|
|
636
|
+
* @returns {Array} Updated list of published files
|
|
637
|
+
*/
|
|
638
|
+
restoreTrashFile(cid) {
|
|
639
|
+
this.#ensureInitialized()
|
|
640
|
+
const index = this.#trashFiles.findIndex(f => f.cid === cid)
|
|
641
|
+
if (index === -1) {
|
|
642
|
+
throw new Error('File not found in trash')
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const fileRecord = this.#trashFiles[index]
|
|
646
|
+
|
|
647
|
+
// Restore to published files
|
|
648
|
+
this.#publishedFiles.push({
|
|
649
|
+
fileName: fileRecord.fileName,
|
|
650
|
+
cid: fileRecord.cid,
|
|
651
|
+
publishedAt: fileRecord.publishedAt,
|
|
652
|
+
starred: fileRecord.starred || false
|
|
653
|
+
})
|
|
654
|
+
this.#savePublishedMetadata()
|
|
655
|
+
|
|
656
|
+
// Remove from trash
|
|
657
|
+
this.#trashFiles.splice(index, 1)
|
|
658
|
+
this.#saveTrashMetadata()
|
|
659
|
+
|
|
660
|
+
return this.listPublishedFiles()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Permanently delete a file from trash
|
|
665
|
+
* @param {string} cid - CID of the file to permanently delete
|
|
666
|
+
* @returns {Promise<Array>} Updated trash list
|
|
667
|
+
*/
|
|
668
|
+
async permanentDeleteTrashFile(cid) {
|
|
669
|
+
this.#ensureInitialized()
|
|
670
|
+
const index = this.#trashFiles.findIndex(f => f.cid === cid)
|
|
671
|
+
if (index !== -1) {
|
|
672
|
+
const fileRecord = this.#trashFiles[index]
|
|
673
|
+
|
|
674
|
+
// Reconstruct drive name from CID
|
|
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
|
|
680
|
+
const drive = this.#drives.get(driveName)
|
|
681
|
+
if (drive) {
|
|
682
|
+
try {
|
|
683
|
+
await drive.del(fileRecord.fileName)
|
|
684
|
+
} catch (err) {
|
|
685
|
+
// File may not exist in drive, continue with cleanup
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Leave swarm for this drive
|
|
689
|
+
await this.#swarm.leave(drive.discoveryKey)
|
|
690
|
+
|
|
691
|
+
// Close and remove drive
|
|
692
|
+
await drive.close()
|
|
693
|
+
this.#drives.delete(driveName)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Remove from trash
|
|
697
|
+
this.#trashFiles.splice(index, 1)
|
|
698
|
+
this.#saveTrashMetadata()
|
|
699
|
+
}
|
|
700
|
+
return this.listTrashFiles()
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Empty the trash - permanently delete all trash files
|
|
705
|
+
* @returns {Promise<Array>} Empty trash list
|
|
706
|
+
*/
|
|
707
|
+
async emptyTrash() {
|
|
708
|
+
this.#ensureInitialized()
|
|
709
|
+
|
|
710
|
+
for (const fileRecord of this.#trashFiles) {
|
|
711
|
+
// Reconstruct drive name from CID
|
|
712
|
+
const parsedCid = CID.parse(fileRecord.cid)
|
|
713
|
+
const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
714
|
+
const driveName = `drive-${hashHex}`
|
|
715
|
+
|
|
716
|
+
// Delete file from Hyperdrive and cleanup drive
|
|
717
|
+
const drive = this.#drives.get(driveName)
|
|
718
|
+
if (drive) {
|
|
719
|
+
try {
|
|
720
|
+
await drive.del(fileRecord.fileName)
|
|
721
|
+
} catch (err) {
|
|
722
|
+
// File may not exist in drive, continue with cleanup
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Leave swarm for this drive
|
|
726
|
+
this.#swarm.leave(drive.discoveryKey)
|
|
727
|
+
|
|
728
|
+
// Close and remove drive
|
|
729
|
+
await drive.close()
|
|
730
|
+
this.#drives.delete(driveName)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Clear trash
|
|
735
|
+
this.#trashFiles = []
|
|
736
|
+
this.#saveTrashMetadata()
|
|
737
|
+
|
|
738
|
+
return []
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Get storage statistics
|
|
743
|
+
* @returns {Promise<{ total: number, used: number, free: number, fileCount: number, trashCount: number }>}
|
|
744
|
+
*/
|
|
745
|
+
async getStorageStats() {
|
|
746
|
+
this.#ensureInitialized()
|
|
747
|
+
|
|
748
|
+
let totalSize = 0
|
|
749
|
+
let freeSize = 0
|
|
750
|
+
const { dataPath } = this.#options
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
const stats = fs.statfsSync(dataPath)
|
|
754
|
+
totalSize = stats.bsize * stats.blocks
|
|
755
|
+
freeSize = stats.bsize * stats.bfree
|
|
756
|
+
} catch (err) {
|
|
757
|
+
// Fallback if statfs is not available
|
|
758
|
+
try {
|
|
759
|
+
const stats = fs.statSync(dataPath)
|
|
760
|
+
totalSize = 0
|
|
761
|
+
freeSize = 0
|
|
762
|
+
} catch {
|
|
763
|
+
totalSize = 0
|
|
764
|
+
freeSize = 0
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Calculate used space by files
|
|
769
|
+
let usedSize = 0
|
|
770
|
+
const calculateDirSize = (dirPath) => {
|
|
771
|
+
try {
|
|
772
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
773
|
+
for (const entry of entries) {
|
|
774
|
+
const fullPath = path.join(dirPath, entry.name)
|
|
775
|
+
if (entry.isDirectory()) {
|
|
776
|
+
if (entry.name !== 'db') {
|
|
777
|
+
calculateDirSize(fullPath)
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
try {
|
|
781
|
+
const stat = fs.statSync(fullPath)
|
|
782
|
+
usedSize += stat.size
|
|
783
|
+
} catch {
|
|
784
|
+
// Skip files we can't access
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
} catch {
|
|
789
|
+
// Skip directories we can't access
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
calculateDirSize(dataPath)
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
total: totalSize,
|
|
797
|
+
used: usedSize,
|
|
798
|
+
free: freeSize,
|
|
799
|
+
fileCount: this.#publishedFiles.length,
|
|
800
|
+
trashCount: this.#trashFiles.length
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Move/rename a published file (changes path without re-uploading)
|
|
806
|
+
* @param {string} cid - CID of the file to move
|
|
807
|
+
* @param {string} newFileName - New file path
|
|
808
|
+
* @returns {object} Updated file info
|
|
809
|
+
*/
|
|
810
|
+
moveFile(cid, newFileName) {
|
|
811
|
+
this.#ensureInitialized()
|
|
812
|
+
const index = this.#publishedFiles.findIndex(f => f.cid === cid)
|
|
813
|
+
if (index === -1) {
|
|
814
|
+
throw new Error('File not found')
|
|
815
|
+
}
|
|
816
|
+
const safeFileName = sanitizeFilename(newFileName)
|
|
817
|
+
this.#publishedFiles[index].fileName = safeFileName
|
|
818
|
+
this.#publishedFiles[index].publishedAt = new Date().toISOString()
|
|
819
|
+
this.#savePublishedMetadata()
|
|
820
|
+
return {
|
|
821
|
+
cid,
|
|
822
|
+
fileName: safeFileName,
|
|
823
|
+
link: `most://${cid}`
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Rename a folder (renames all files within the folder)
|
|
829
|
+
* @param {string} oldPath - Current folder path
|
|
830
|
+
* @param {string} newPath - New folder path
|
|
831
|
+
* @returns {object} Updated files info
|
|
832
|
+
*/
|
|
833
|
+
renameFolder(oldPath, newPath) {
|
|
834
|
+
this.#ensureInitialized()
|
|
835
|
+
const prefix = oldPath + '/'
|
|
836
|
+
const updatedFiles = []
|
|
837
|
+
|
|
838
|
+
for (const file of this.#publishedFiles) {
|
|
839
|
+
if (file.fileName.startsWith(prefix)) {
|
|
840
|
+
const newFileName = newPath + file.fileName.substring(prefix.length)
|
|
841
|
+
file.fileName = sanitizeFilename(newFileName)
|
|
842
|
+
file.publishedAt = new Date().toISOString()
|
|
843
|
+
updatedFiles.push({
|
|
844
|
+
cid: file.cid,
|
|
845
|
+
fileName: file.fileName,
|
|
846
|
+
link: `most://${file.cid}`
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (updatedFiles.length > 0) {
|
|
852
|
+
this.#savePublishedMetadata()
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return { files: updatedFiles }
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Cancel an active download
|
|
860
|
+
* @param {string} taskId - The task ID of the download to cancel
|
|
861
|
+
*/
|
|
862
|
+
cancelDownload(taskId) {
|
|
863
|
+
const task = this.#activeDownloads.get(taskId)
|
|
864
|
+
if (task) {
|
|
865
|
+
task.aborted = true
|
|
866
|
+
if (task.readStream) task.readStream.destroy()
|
|
867
|
+
if (task.writeStream) task.writeStream.destroy()
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
getPublishedFiles() {
|
|
872
|
+
return this.#publishedFiles
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// --- Private methods ---
|
|
876
|
+
|
|
877
|
+
#ensureInitialized() {
|
|
878
|
+
if (!this.#initialized) {
|
|
879
|
+
throw new EngineNotInitializedError()
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
#getMetadataPath() {
|
|
884
|
+
return path.join(this.#options.dataPath, 'published-files.json')
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
#getTrashMetadataPath() {
|
|
888
|
+
return path.join(this.#options.dataPath, 'trash-files.json')
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
#loadPublishedMetadata() {
|
|
892
|
+
try {
|
|
893
|
+
const metadataPath = this.#getMetadataPath()
|
|
894
|
+
if (fs.existsSync(metadataPath)) {
|
|
895
|
+
const data = fs.readFileSync(metadataPath, 'utf-8')
|
|
896
|
+
const parsed = JSON.parse(data)
|
|
897
|
+
// Ensure starred field exists for older data
|
|
898
|
+
return parsed.map(f => ({ ...f, starred: f.starred || false }))
|
|
899
|
+
}
|
|
900
|
+
} catch (err) {
|
|
901
|
+
console.warn('Failed to load published metadata, using empty list:', err.message)
|
|
902
|
+
}
|
|
903
|
+
return []
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
#savePublishedMetadata() {
|
|
907
|
+
try {
|
|
908
|
+
const metadataPath = this.#getMetadataPath()
|
|
909
|
+
fs.writeFileSync(metadataPath, JSON.stringify(this.#publishedFiles, null, 2), 'utf-8')
|
|
910
|
+
} catch (err) {
|
|
911
|
+
console.error('Failed to save published metadata:', err.message)
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
#loadTrashMetadata() {
|
|
916
|
+
try {
|
|
917
|
+
const metadataPath = this.#getTrashMetadataPath()
|
|
918
|
+
if (fs.existsSync(metadataPath)) {
|
|
919
|
+
const data = fs.readFileSync(metadataPath, 'utf-8')
|
|
920
|
+
return JSON.parse(data)
|
|
921
|
+
}
|
|
922
|
+
} catch (err) {
|
|
923
|
+
console.warn('Failed to load trash metadata, using empty list:', err.message)
|
|
924
|
+
}
|
|
925
|
+
return []
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
#saveTrashMetadata() {
|
|
929
|
+
try {
|
|
930
|
+
const metadataPath = this.#getTrashMetadataPath()
|
|
931
|
+
fs.writeFileSync(metadataPath, JSON.stringify(this.#trashFiles, null, 2), 'utf-8')
|
|
932
|
+
} catch (err) {
|
|
933
|
+
console.error('Failed to save trash metadata:', err.message)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Wait for drive content to be available from peers or local
|
|
939
|
+
* @param {Hyperdrive} drive - The drive to check
|
|
940
|
+
* @param {number} timeout - Maximum wait time in ms
|
|
941
|
+
* @param {string} [taskId] - Task ID for cancellation
|
|
942
|
+
* @param {object} [taskState] - Task state object
|
|
943
|
+
* @returns {Promise<Array>} - List of entries
|
|
944
|
+
*/
|
|
945
|
+
async #waitForDriveContent(drive, timeout, taskId = null, taskState = null) {
|
|
946
|
+
const startTime = Date.now()
|
|
947
|
+
const checkInterval = 1000 // Check every second
|
|
948
|
+
let lastPeerCount = 0
|
|
949
|
+
let lastStatus = ''
|
|
950
|
+
let bootstrapNodesChecked = false
|
|
951
|
+
|
|
952
|
+
// First, check if content is already available locally (for self-published files)
|
|
953
|
+
const localEntries = []
|
|
954
|
+
try {
|
|
955
|
+
for await (const entry of drive.list()) {
|
|
956
|
+
localEntries.push(entry)
|
|
957
|
+
}
|
|
958
|
+
if (localEntries.length > 0) {
|
|
959
|
+
console.log(`[MostBox] Found ${localEntries.length} entries locally`)
|
|
960
|
+
this.emit('download:status', { taskId, status: 'syncing' })
|
|
961
|
+
return localEntries
|
|
962
|
+
}
|
|
963
|
+
} catch (err) {
|
|
964
|
+
// Continue to peer discovery
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
while (Date.now() - startTime < timeout) {
|
|
968
|
+
// Check cancellation
|
|
969
|
+
if (taskState && taskState.aborted) {
|
|
970
|
+
throw new Error('Download cancelled')
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const currentTime = Date.now()
|
|
974
|
+
const elapsed = Math.round((currentTime - startTime) / 1000)
|
|
975
|
+
|
|
976
|
+
// Check if we have peers
|
|
977
|
+
const currentPeerCount = this.#swarm.connections.size
|
|
978
|
+
const hasPeers = currentPeerCount > 0
|
|
979
|
+
|
|
980
|
+
// Log peer count changes
|
|
981
|
+
if (currentPeerCount !== lastPeerCount) {
|
|
982
|
+
console.log(`[MostBox] Peer count changed: ${lastPeerCount} -> ${currentPeerCount} (elapsed: ${elapsed}s)`)
|
|
983
|
+
lastPeerCount = currentPeerCount
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Try to list entries (works for both local and synced data)
|
|
987
|
+
const entries = []
|
|
988
|
+
try {
|
|
989
|
+
for await (const entry of drive.list()) {
|
|
990
|
+
entries.push(entry)
|
|
991
|
+
}
|
|
992
|
+
} catch (err) {
|
|
993
|
+
// Drive might not be ready yet
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (entries.length > 0) {
|
|
997
|
+
console.log(`[MostBox] Found ${entries.length} entries after ${elapsed}s`)
|
|
998
|
+
this.emit('download:status', { taskId, status: 'syncing' })
|
|
999
|
+
return entries
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Update status based on peer connection
|
|
1003
|
+
if (hasPeers) {
|
|
1004
|
+
const newStatus = 'syncing'
|
|
1005
|
+
if (lastStatus !== newStatus) {
|
|
1006
|
+
this.emit('download:status', { taskId, status: newStatus })
|
|
1007
|
+
lastStatus = newStatus
|
|
1008
|
+
}
|
|
1009
|
+
} else {
|
|
1010
|
+
const newStatus = 'finding-peers'
|
|
1011
|
+
if (lastStatus !== newStatus) {
|
|
1012
|
+
this.emit('download:status', { taskId, status: newStatus })
|
|
1013
|
+
lastStatus = newStatus
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Log progress every 30 seconds
|
|
1017
|
+
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)
|
|
1021
|
+
if (!bootstrapNodesChecked && elapsed >= 60) {
|
|
1022
|
+
bootstrapNodesChecked = true
|
|
1023
|
+
console.log(`[MostBox] No peers found after 60s. This may indicate:`)
|
|
1024
|
+
console.log(`[MostBox] 1. Network/firewall blocking P2P connections`)
|
|
1025
|
+
console.log(`[MostBox] 2. DHT bootstrap nodes unreachable`)
|
|
1026
|
+
console.log(`[MostBox] 3. Publisher node offline`)
|
|
1027
|
+
console.log(`[MostBox] 4. NAT traversal failed`)
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Wait before next check
|
|
1033
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval))
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
console.log(`[MostBox] Timeout reached after ${timeout/1000}s, making final attempt...`)
|
|
1037
|
+
|
|
1038
|
+
// Final attempt - return whatever we have (might be empty)
|
|
1039
|
+
const entries = []
|
|
1040
|
+
try {
|
|
1041
|
+
for await (const entry of drive.list()) {
|
|
1042
|
+
entries.push(entry)
|
|
1043
|
+
}
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
console.log(`[MostBox] Final attempt failed: ${err.message}`)
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
console.log(`[MostBox] Final entry count: ${entries.length}`)
|
|
1049
|
+
|
|
1050
|
+
// Provide detailed error information
|
|
1051
|
+
if (entries.length === 0) {
|
|
1052
|
+
const peerCount = this.#swarm.connections.size
|
|
1053
|
+
console.log(`[MostBox] Diagnostic information:`)
|
|
1054
|
+
console.log(`[MostBox] - Peer count: ${peerCount}`)
|
|
1055
|
+
console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
|
|
1056
|
+
console.log(`[MostBox] - Timeout: ${timeout/1000}s`)
|
|
1057
|
+
|
|
1058
|
+
if (peerCount === 0) {
|
|
1059
|
+
console.log(`[MostBox] Suggestion: Check network connectivity and firewall settings`)
|
|
1060
|
+
} else {
|
|
1061
|
+
console.log(`[MostBox] Suggestion: Publisher may be offline or file may have been removed`)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return entries
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Re-export utilities
|
|
1070
|
+
export * from './config.js'
|
|
1071
|
+
export * from './core/cid.js'
|
|
1072
|
+
export * from './utils/errors.js'
|
|
1073
|
+
export * from './utils/security.js'
|