most-box 0.0.1 → 0.0.4
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 +182 -73
- package/out/404/index.html +15 -0
- package/out/404.html +15 -0
- package/out/__next.__PAGE__.txt +9 -0
- package/out/__next._full.txt +18 -0
- package/out/__next._head.txt +5 -0
- package/out/__next._index.txt +6 -0
- package/out/__next._tree.txt +2 -0
- package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_buildManifest.js +11 -0
- package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_clientMiddlewareManifest.js +1 -0
- package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_ssgManifest.js +1 -0
- package/out/_next/static/chunks/00l-yd3t8dvwz.js +5 -0
- package/out/_next/static/chunks/03k8t3tgym~8~.js +1 -0
- package/out/_next/static/chunks/03~yq9q893hmn.js +1 -0
- package/out/_next/static/chunks/09vfh8lfuacc0.css +1 -0
- package/out/_next/static/chunks/0bogtdbh.dcu1.js +1 -0
- package/out/_next/static/chunks/0dbhjjzl8qfwv.js +1 -0
- package/out/_next/static/chunks/0f73psqhr8dre.css +1 -0
- package/out/_next/static/chunks/0fbi7z4_.4j1j.js +1 -0
- package/out/_next/static/chunks/0ht900cau6_ur.js +31 -0
- package/out/_next/static/chunks/0ohm.ia-4ec60.js +1 -0
- package/out/_next/static/chunks/0u5ydb-f0.vxl.js +1 -0
- package/out/_next/static/chunks/14t2m1on-s5v~.js +1 -0
- package/out/_next/static/chunks/turbopack-076ce9exut_h3.js +1 -0
- package/out/_not-found/__next._full.txt +16 -0
- package/out/_not-found/__next._head.txt +5 -0
- package/out/_not-found/__next._index.txt +6 -0
- package/out/_not-found/__next._not-found/__PAGE__.txt +5 -0
- package/out/_not-found/__next._not-found.txt +5 -0
- package/out/_not-found/__next._tree.txt +2 -0
- package/out/_not-found/index.html +15 -0
- package/out/_not-found/index.txt +16 -0
- package/out/app.css +1535 -0
- package/out/bundle.js +107 -0
- package/out/bundle.js.map +7 -0
- package/out/chat/__next._full.txt +19 -0
- package/out/chat/__next._head.txt +5 -0
- package/out/chat/__next._index.txt +6 -0
- package/out/chat/__next._tree.txt +3 -0
- package/out/chat/__next.chat/__PAGE__.txt +9 -0
- package/out/chat/__next.chat.txt +5 -0
- package/out/chat/index.html +15 -0
- package/out/chat/index.txt +19 -0
- package/out/chat-page.js +112 -0
- package/out/chat.css +378 -0
- package/out/favicon.ico +0 -0
- package/out/index.html +15 -0
- package/out/index.js +148 -0
- package/out/index.txt +18 -0
- package/package.json +16 -7
- package/public/app.css +1535 -0
- package/public/bundle.js +10 -14
- package/public/bundle.js.map +4 -4
- package/public/chat-page.js +112 -0
- package/public/chat.css +378 -0
- package/public/index.js +148 -0
- package/server.js +464 -199
- package/src/config.js +36 -8
- package/src/core/cid.js +28 -19
- package/src/index.js +872 -276
- package/src/utils/api.js +6 -0
- package/src/utils/security.js +27 -24
- package/build.mjs +0 -40
- package/public/app.jsx +0 -1335
- 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/public/index.html +0 -15
- package/public/index.jsx +0 -5
package/src/index.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
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'
|
|
7
12
|
import Hyperswarm from 'hyperswarm'
|
|
8
13
|
import Corestore from 'corestore'
|
|
9
14
|
import Hyperdrive from 'hyperdrive'
|
|
15
|
+
import Hypercore from 'hypercore'
|
|
10
16
|
import b4a from 'b4a'
|
|
17
|
+
import crypto from 'node:crypto'
|
|
11
18
|
import { CID } from 'multiformats/cid'
|
|
12
19
|
import fs from 'node:fs'
|
|
13
20
|
import path from 'node:path'
|
|
@@ -15,7 +22,7 @@ import path from 'node:path'
|
|
|
15
22
|
import { calculateCid, parseMostLink } from './core/cid.js'
|
|
16
23
|
import { sanitizeFilename, validateAndSanitizePath, validateFileSize, checkDirectoryWritable, formatFileSize } from './utils/security.js'
|
|
17
24
|
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'
|
|
25
|
+
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, CHANNEL_NAME_MIN_LENGTH, CHANNEL_NAME_MAX_LENGTH, CHANNEL_NAME_REGEX, CHANNEL_NAME_PREFIX, CHANNEL_TOPIC_STRING, CHANNEL_MESSAGE_LIMIT, MAX_MESSAGE_LENGTH } from './config.js'
|
|
19
26
|
|
|
20
27
|
export class MostBoxEngine extends EventEmitter {
|
|
21
28
|
#store = null
|
|
@@ -25,22 +32,28 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
25
32
|
#trashFiles = []
|
|
26
33
|
#initialized = false
|
|
27
34
|
#options = null
|
|
28
|
-
#activeDownloads = new Map()
|
|
35
|
+
#activeDownloads = new Map()
|
|
36
|
+
#drivePromises = new Map()
|
|
37
|
+
|
|
38
|
+
#channels = []
|
|
39
|
+
#channelCores = new Map()
|
|
40
|
+
#channelDiscoveries = new Map()
|
|
41
|
+
#channelPeers = new Map()
|
|
29
42
|
|
|
30
43
|
/**
|
|
31
|
-
*
|
|
32
|
-
* @param {object} options -
|
|
33
|
-
* @param {string} options.dataPath -
|
|
34
|
-
* @param {string} [options.downloadPath] -
|
|
35
|
-
* @param {number} [options.maxFileSize] -
|
|
44
|
+
* 创建新的 MostBoxEngine 实例
|
|
45
|
+
* @param {object} options - 配置选项
|
|
46
|
+
* @param {string} options.dataPath - 存储 P2P 数据的路径(必填)
|
|
47
|
+
* @param {string} [options.downloadPath] - 默认下载路径(可选,默认为 dataPath/downloads)
|
|
48
|
+
* @param {number} [options.maxFileSize] - 最大文件大小(字节)(默认:100GB)
|
|
36
49
|
*/
|
|
37
50
|
constructor(options) {
|
|
38
51
|
super()
|
|
39
|
-
|
|
52
|
+
|
|
40
53
|
if (!options || !options.dataPath) {
|
|
41
54
|
throw new Error('dataPath is required')
|
|
42
55
|
}
|
|
43
|
-
|
|
56
|
+
|
|
44
57
|
this.#options = {
|
|
45
58
|
dataPath: options.dataPath,
|
|
46
59
|
downloadPath: options.downloadPath || path.join(options.dataPath, 'downloads'),
|
|
@@ -49,7 +62,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/**
|
|
52
|
-
*
|
|
65
|
+
* 初始化引擎 — 必须在调用其他方法之前调用
|
|
53
66
|
*/
|
|
54
67
|
async start() {
|
|
55
68
|
if (this.#initialized) {
|
|
@@ -57,19 +70,17 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
const { dataPath } = this.#options
|
|
60
|
-
|
|
73
|
+
|
|
61
74
|
console.log(`[MostBox] Initializing engine...`)
|
|
62
75
|
console.log(`[MostBox] Storage path: ${dataPath}`)
|
|
63
|
-
|
|
64
|
-
// Create storage directory if not exists
|
|
76
|
+
|
|
65
77
|
if (!fs.existsSync(dataPath)) {
|
|
66
78
|
fs.mkdirSync(dataPath, { recursive: true })
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
// Initialize Corestore with global shared seed
|
|
70
81
|
const GLOBAL_SHARED_SEED = b4a.alloc(32).fill(GLOBAL_SHARED_SEED_STRING)
|
|
71
82
|
this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
|
|
72
|
-
|
|
83
|
+
|
|
73
84
|
try {
|
|
74
85
|
await this.#store.ready()
|
|
75
86
|
console.log(`[MostBox] Corestore ready`)
|
|
@@ -90,27 +101,17 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
// Initialize Hyperswarm with NAT traversal enabled
|
|
94
104
|
console.log(`[MostBox] Initializing Hyperswarm...`)
|
|
95
105
|
this.#swarm = new Hyperswarm({
|
|
96
|
-
|
|
97
|
-
maxPeers: 64,
|
|
98
|
-
// DHT bootstrap nodes (same as Keet.io/HyperDHT)
|
|
106
|
+
maxPeers: MAX_PEERS,
|
|
99
107
|
bootstrap: SWARM_BOOTSTRAP,
|
|
100
|
-
// Enable NAT traversal (hole punching)
|
|
101
|
-
// firewall function: allow all connections (default behavior)
|
|
102
108
|
firewall: () => false,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Random punch interval for NAT traversal (20 seconds)
|
|
106
|
-
randomPunchInterval: 20000,
|
|
107
|
-
// Increase timeouts for unstable networks
|
|
109
|
+
connectionKeepAlive: SWARM_KEEP_ALIVE_INTERVAL,
|
|
110
|
+
randomPunchInterval: SWARM_RANDOM_PUNCH_INTERVAL,
|
|
108
111
|
handshakeTimeout: CONNECTION_TIMEOUT
|
|
109
112
|
})
|
|
110
113
|
|
|
111
|
-
// Handle swarm-level errors
|
|
112
114
|
this.#swarm.on('error', (err) => {
|
|
113
|
-
// Silently handle SSL/network errors - they're non-critical for DHT discovery
|
|
114
115
|
if (err.code === 'SSL_ERROR' || err.message?.includes('handshake') || err.message?.includes('ECONNRESET')) {
|
|
115
116
|
console.warn('[MostBox] Network warning (non-critical):', err.message)
|
|
116
117
|
return
|
|
@@ -119,58 +120,97 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
119
120
|
this.emit('error', err)
|
|
120
121
|
})
|
|
121
122
|
|
|
122
|
-
// Replicate store on new connections
|
|
123
123
|
this.#swarm.on('connection', (conn, info) => {
|
|
124
|
-
console.log(`[MostBox] New peer connection established`)
|
|
125
|
-
// Handle connection errors gracefully
|
|
126
124
|
conn.on('error', (err) => {
|
|
127
125
|
if (err.code === 'SSL_ERROR' || err.message?.includes('handshake')) {
|
|
128
|
-
console.warn('[MostBox] Connection warning:', err.message)
|
|
129
126
|
return
|
|
130
127
|
}
|
|
131
|
-
console.error('[MostBox] Connection error:', err.message)
|
|
132
128
|
})
|
|
133
129
|
|
|
134
130
|
this.#store.replicate(conn)
|
|
131
|
+
this.#handleChannelConnection(conn).catch(() => {})
|
|
135
132
|
this.emit('connection', conn)
|
|
136
133
|
})
|
|
137
134
|
|
|
138
|
-
// Load published files metadata
|
|
139
135
|
this.#publishedFiles = this.#loadPublishedMetadata()
|
|
140
136
|
console.log(`[MostBox] Loaded ${this.#publishedFiles.length} published files`)
|
|
141
|
-
|
|
142
|
-
// Load trash files metadata
|
|
137
|
+
|
|
143
138
|
this.#trashFiles = this.#loadTrashMetadata()
|
|
144
139
|
console.log(`[MostBox] Loaded ${this.#trashFiles.length} trash files`)
|
|
145
|
-
|
|
140
|
+
|
|
141
|
+
this.#channels = this.#loadChannelsMetadata()
|
|
142
|
+
console.log(`[MostBox] Loaded ${this.#channels.length} channels`)
|
|
143
|
+
|
|
144
|
+
for (const channel of this.#channels) {
|
|
145
|
+
try {
|
|
146
|
+
const ns = this.#store.namespace(`channel-${channel.name}`)
|
|
147
|
+
const core = ns.get({ key: b4a.from(channel.coreKey, 'hex'), valueEncoding: 'json' })
|
|
148
|
+
await core.ready()
|
|
149
|
+
this.#channelCores.set(channel.name, core)
|
|
150
|
+
this.#channelPeers.set(channel.name, new Map())
|
|
151
|
+
|
|
152
|
+
let lastCoreLength = core.length
|
|
153
|
+
core.on('append', async () => {
|
|
154
|
+
if (core.length > lastCoreLength) {
|
|
155
|
+
for (let i = lastCoreLength; i < core.length; i++) {
|
|
156
|
+
try {
|
|
157
|
+
const entry = await core.get(i)
|
|
158
|
+
if (entry && entry.type === 'message') {
|
|
159
|
+
this.emit('channel:message', { channel: channel.name, message: entry })
|
|
160
|
+
}
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
lastCoreLength = core.length
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const discoveryKey = b4a.from(channel.discoveryKey, 'hex')
|
|
168
|
+
const discovery = this.#swarm.join(discoveryKey, { server: true, client: true })
|
|
169
|
+
this.#channelDiscoveries.set(channel.name, discovery)
|
|
170
|
+
console.log(`[MostBox] Rejoined channel: ${channel.name}`)
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.warn(`[MostBox] Failed to rejoin channel ${channel.name}:`, err.message)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
146
176
|
this.#initialized = true
|
|
147
177
|
console.log(`[MostBox] Engine initialized successfully`)
|
|
148
178
|
this.emit('ready')
|
|
149
|
-
|
|
179
|
+
|
|
150
180
|
return this
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
/**
|
|
154
|
-
*
|
|
184
|
+
* 停止引擎并清理资源
|
|
155
185
|
*/
|
|
156
186
|
async stop() {
|
|
157
187
|
if (!this.#initialized) {
|
|
158
188
|
return
|
|
159
189
|
}
|
|
160
190
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
191
|
+
for (const task of this.#activeDownloads.values()) {
|
|
192
|
+
task.aborted = true
|
|
193
|
+
if (task.readStream) task.readStream.destroy()
|
|
194
|
+
if (task.writeStream) task.writeStream.destroy()
|
|
164
195
|
}
|
|
196
|
+
this.#activeDownloads.clear()
|
|
197
|
+
|
|
198
|
+
await Promise.allSettled([...this.#drives.values()].map(d => d.close()))
|
|
165
199
|
this.#drives.clear()
|
|
166
200
|
|
|
167
|
-
|
|
201
|
+
for (const core of this.#channelCores.values()) {
|
|
202
|
+
try { await core.close() } catch {}
|
|
203
|
+
}
|
|
204
|
+
this.#channelCores.clear()
|
|
205
|
+
this.#channelDiscoveries.clear()
|
|
206
|
+
this.#channelPeers.clear()
|
|
207
|
+
this.#channels = []
|
|
208
|
+
|
|
168
209
|
if (this.#swarm) {
|
|
169
210
|
await this.#swarm.destroy()
|
|
170
211
|
this.#swarm = null
|
|
171
212
|
}
|
|
172
213
|
|
|
173
|
-
// Close store
|
|
174
214
|
if (this.#store) {
|
|
175
215
|
await this.#store.close()
|
|
176
216
|
this.#store = null
|
|
@@ -181,8 +221,8 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
181
221
|
}
|
|
182
222
|
|
|
183
223
|
/**
|
|
184
|
-
*
|
|
185
|
-
* @returns {string}
|
|
224
|
+
* 获取节点的公钥
|
|
225
|
+
* @returns {string} 节点 ID(十六进制字符串)
|
|
186
226
|
*/
|
|
187
227
|
getNodeId() {
|
|
188
228
|
this.#ensureInitialized()
|
|
@@ -190,7 +230,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
190
230
|
}
|
|
191
231
|
|
|
192
232
|
/**
|
|
193
|
-
*
|
|
233
|
+
* 获取当前网络状态
|
|
194
234
|
* @returns {{ peers: number, status: string }}
|
|
195
235
|
*/
|
|
196
236
|
getNetworkStatus() {
|
|
@@ -203,9 +243,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
203
243
|
}
|
|
204
244
|
|
|
205
245
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* @param {string}
|
|
246
|
+
* 将内容发布到 P2P 网络
|
|
247
|
+
* Hyperdrive 中存储 key 为 '/' + cid,metadata 中存储 displayName(用户看到的路径)
|
|
248
|
+
* @param {string|Buffer} content - 文件路径(字符串)或内容(Buffer)
|
|
249
|
+
* @param {string} [fileName] - 文件名(Buffer 输入时必填)
|
|
209
250
|
* @returns {Promise<{ cid: string, link: string, fileName: string }>}
|
|
210
251
|
*/
|
|
211
252
|
async publishFile(content, fileName) {
|
|
@@ -246,47 +287,61 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
246
287
|
this.emit('publish:progress', { stage: 'calculating-cid', file: safeFileName })
|
|
247
288
|
|
|
248
289
|
const { cid: rootCid } = await calculateCid(content)
|
|
249
|
-
const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
|
|
250
290
|
const cidString = rootCid.toString()
|
|
251
291
|
|
|
292
|
+
// 检查相同内容是否已存在
|
|
293
|
+
const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
|
|
294
|
+
if (existingIndex !== -1) {
|
|
295
|
+
const existing = this.#publishedFiles[existingIndex]
|
|
296
|
+
return {
|
|
297
|
+
cid: cidString,
|
|
298
|
+
link: `most://${cidString}`,
|
|
299
|
+
fileName: existing.fileName,
|
|
300
|
+
alreadyExists: true
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 获取或创建该 CID 对应的 drive
|
|
305
|
+
const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
|
|
252
306
|
const name = `drive-${hashHex}`
|
|
253
307
|
let drive = this.#drives.get(name)
|
|
254
|
-
|
|
308
|
+
|
|
255
309
|
if (!drive) {
|
|
256
|
-
drive =
|
|
257
|
-
await drive.ready()
|
|
258
|
-
this.#drives.set(name, drive)
|
|
259
|
-
|
|
310
|
+
drive = await this.#getOrCreateDrive(name, { server: true, client: false })
|
|
260
311
|
const discovery = this.#swarm.join(drive.discoveryKey, { server: true, client: false })
|
|
261
312
|
await discovery.flushed()
|
|
262
313
|
}
|
|
263
314
|
|
|
264
315
|
this.emit('publish:progress', { stage: 'uploading', file: safeFileName })
|
|
265
316
|
|
|
266
|
-
|
|
317
|
+
// Hyperdrive 中用 CID 作为 key 存储(解耦目录结构)
|
|
318
|
+
const driveKey = '/' + cidString
|
|
319
|
+
|
|
320
|
+
const ws = drive.createWriteStream(driveKey)
|
|
267
321
|
|
|
268
322
|
if (Buffer.isBuffer(content)) {
|
|
269
|
-
|
|
270
|
-
const CHUNK_SIZE = 64 * 1024 // 64KB chunks
|
|
323
|
+
const CHUNK_SIZE = 64 * 1024
|
|
271
324
|
let offset = 0
|
|
272
|
-
|
|
273
325
|
const waitForDrain = () => new Promise(resolve => ws.once('drain', resolve))
|
|
274
326
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
327
|
+
try {
|
|
328
|
+
while (offset < content.length) {
|
|
329
|
+
const chunk = content.slice(offset, offset + CHUNK_SIZE)
|
|
330
|
+
const canContinue = ws.write(chunk)
|
|
331
|
+
offset += chunk.length
|
|
332
|
+
if (!canContinue && offset < content.length) {
|
|
333
|
+
await waitForDrain()
|
|
334
|
+
}
|
|
282
335
|
}
|
|
336
|
+
ws.end()
|
|
337
|
+
await new Promise((resolve, reject) => {
|
|
338
|
+
ws.on('finish', resolve)
|
|
339
|
+
ws.on('error', reject)
|
|
340
|
+
})
|
|
341
|
+
} catch (err) {
|
|
342
|
+
ws.destroy()
|
|
343
|
+
throw err
|
|
283
344
|
}
|
|
284
|
-
|
|
285
|
-
ws.end()
|
|
286
|
-
await new Promise((resolve, reject) => {
|
|
287
|
-
ws.on('finish', resolve)
|
|
288
|
-
ws.on('error', reject)
|
|
289
|
-
})
|
|
290
345
|
} else {
|
|
291
346
|
const rs = fs.createReadStream(cleanPath)
|
|
292
347
|
await new Promise((resolve, reject) => {
|
|
@@ -297,29 +352,19 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
297
352
|
})
|
|
298
353
|
}
|
|
299
354
|
|
|
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
|
-
}
|
|
355
|
+
// 存储 displayName(用户看到的文件夹路径),不存储 drivePath
|
|
356
|
+
this.#publishedFiles.push({
|
|
357
|
+
fileName: safeFileName,
|
|
358
|
+
cid: cidString,
|
|
359
|
+
driveName: name,
|
|
360
|
+
publishedAt: new Date().toISOString(),
|
|
361
|
+
starred: false
|
|
362
|
+
})
|
|
318
363
|
this.#savePublishedMetadata()
|
|
319
364
|
|
|
320
365
|
const result = {
|
|
321
366
|
cid: cidString,
|
|
322
|
-
link: `most://${cidString}`,
|
|
367
|
+
link: `most://${cidString}?filename=${encodeURIComponent(safeFileName)}`,
|
|
323
368
|
fileName: safeFileName
|
|
324
369
|
}
|
|
325
370
|
|
|
@@ -328,25 +373,22 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
328
373
|
}
|
|
329
374
|
|
|
330
375
|
/**
|
|
331
|
-
*
|
|
332
|
-
* @param {string} link - most://
|
|
333
|
-
* @param {string} [taskId] -
|
|
376
|
+
* 从 P2P 网络下载文件
|
|
377
|
+
* @param {string} link - most:// 链接
|
|
378
|
+
* @param {string} [taskId] - 用于取消的任务 ID
|
|
334
379
|
* @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
|
|
335
380
|
*/
|
|
336
381
|
async downloadFile(link, taskId = null) {
|
|
337
382
|
this.#ensureInitialized()
|
|
338
383
|
|
|
339
|
-
// Generate taskId if not provided
|
|
340
384
|
taskId = taskId || `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
341
385
|
|
|
342
386
|
console.log(`[MostBox] Starting download for link: ${link} (taskId: ${taskId})`)
|
|
343
387
|
|
|
344
|
-
// Register in active downloads
|
|
345
388
|
const taskState = { aborted: false, readStream: null, writeStream: null }
|
|
346
389
|
this.#activeDownloads.set(taskId, taskState)
|
|
347
390
|
|
|
348
391
|
try {
|
|
349
|
-
// Parse link
|
|
350
392
|
const parsed = parseMostLink(link)
|
|
351
393
|
if (parsed.error) {
|
|
352
394
|
throw new ValidationError(parsed.error)
|
|
@@ -354,7 +396,6 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
354
396
|
const cidString = parsed.cid
|
|
355
397
|
console.log(`[MostBox] Parsed CID: ${cidString}`)
|
|
356
398
|
|
|
357
|
-
// Check if file already exists in published files
|
|
358
399
|
const existingFile = this.#publishedFiles.find(f => f.cid === cidString)
|
|
359
400
|
if (existingFile) {
|
|
360
401
|
console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
|
|
@@ -365,50 +406,42 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
365
406
|
}
|
|
366
407
|
}
|
|
367
408
|
|
|
368
|
-
|
|
409
|
+
const linkFileName = parsed.fileName
|
|
410
|
+
|
|
369
411
|
const parsedCid = CID.parse(cidString)
|
|
370
|
-
const
|
|
371
|
-
const hashHex = b4a.toString(hashBytes, 'hex')
|
|
412
|
+
const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
372
413
|
|
|
373
|
-
// Check cancellation
|
|
374
414
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
375
415
|
|
|
376
|
-
// Get/Create drive
|
|
377
416
|
const name = `drive-${hashHex}`
|
|
378
417
|
let drive = this.#drives.get(name)
|
|
379
|
-
|
|
418
|
+
|
|
380
419
|
if (!drive) {
|
|
381
420
|
console.log(`[MostBox] Creating new drive: ${name}`)
|
|
382
|
-
drive =
|
|
383
|
-
|
|
384
|
-
this.#drives.set(name, drive)
|
|
385
|
-
|
|
421
|
+
drive = await this.#getOrCreateDrive(name, { server: true, client: true })
|
|
422
|
+
|
|
386
423
|
this.emit('download:status', { taskId, status: 'connecting' })
|
|
387
|
-
|
|
424
|
+
|
|
388
425
|
console.log(`[MostBox] Joining swarm for drive discovery...`)
|
|
389
|
-
// Join as both server and client to allow self-downloads
|
|
390
426
|
await this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed()
|
|
391
427
|
console.log(`[MostBox] Swarm join flushed`)
|
|
392
428
|
} else {
|
|
393
429
|
console.log(`[MostBox] Using existing drive: ${name}`)
|
|
394
430
|
}
|
|
395
431
|
|
|
396
|
-
// Check cancellation
|
|
397
432
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
398
433
|
|
|
399
434
|
this.emit('download:status', { taskId, status: 'finding-peers' })
|
|
400
435
|
|
|
401
|
-
|
|
402
|
-
console.log(`[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT/1000}s)...`)
|
|
436
|
+
console.log(`[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT / 1000}s)...`)
|
|
403
437
|
const entries = await this.#waitForDriveContent(drive, DOWNLOAD_TIMEOUT, taskId, taskState)
|
|
404
438
|
|
|
405
439
|
if (entries.length === 0) {
|
|
406
440
|
console.log(`[MostBox] No entries found after timeout`)
|
|
407
|
-
|
|
408
|
-
// 提供更详细的错误信息
|
|
441
|
+
|
|
409
442
|
const peerCount = this.#swarm.connections.size
|
|
410
443
|
let errorMessage = 'No files found in drive. '
|
|
411
|
-
|
|
444
|
+
|
|
412
445
|
if (peerCount === 0) {
|
|
413
446
|
errorMessage += 'Could not connect to any peers. This may be due to:\n'
|
|
414
447
|
errorMessage += '1. Network firewall blocking P2P connections\n'
|
|
@@ -421,29 +454,28 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
421
454
|
errorMessage += '2. File may have been removed by publisher\n'
|
|
422
455
|
errorMessage += '3. File link may be invalid or corrupted'
|
|
423
456
|
}
|
|
424
|
-
|
|
457
|
+
|
|
425
458
|
throw new PeerNotFoundError(errorMessage)
|
|
426
459
|
}
|
|
427
460
|
|
|
428
|
-
// Check cancellation
|
|
429
461
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
430
462
|
|
|
431
463
|
console.log(`[MostBox] Found ${entries.length} entries, starting download...`)
|
|
432
464
|
|
|
433
|
-
// Save to storage directory (not Downloads folder)
|
|
434
465
|
const targetDir = this.#options.dataPath
|
|
435
466
|
|
|
436
|
-
// Check storage directory
|
|
437
467
|
const writableCheck = await checkDirectoryWritable(targetDir)
|
|
438
468
|
if (!writableCheck.writable) {
|
|
439
469
|
throw new PermissionError(writableCheck.error)
|
|
440
470
|
}
|
|
441
471
|
|
|
442
|
-
//
|
|
472
|
+
// 下载文件
|
|
443
473
|
for (const entry of entries) {
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
474
|
+
const cleanKey = entry.key.replace(/^[\/\\]/, '')
|
|
475
|
+
const sanitizedFileName = linkFileName
|
|
476
|
+
? sanitizeFilename(linkFileName)
|
|
477
|
+
: sanitizeFilename(cleanKey)
|
|
478
|
+
|
|
447
479
|
let totalBytes = 0
|
|
448
480
|
try {
|
|
449
481
|
const stat = await drive.entry(entry.key)
|
|
@@ -451,56 +483,53 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
451
483
|
totalBytes = stat.value.blob.byteLength || 0
|
|
452
484
|
}
|
|
453
485
|
} catch {
|
|
454
|
-
//
|
|
486
|
+
// 忽略
|
|
455
487
|
}
|
|
456
488
|
|
|
457
489
|
const savePath = path.join(targetDir, sanitizedFileName)
|
|
458
|
-
|
|
459
|
-
this.emit('download:status', {
|
|
490
|
+
|
|
491
|
+
this.emit('download:status', {
|
|
460
492
|
taskId,
|
|
461
|
-
status: 'downloading',
|
|
462
|
-
file: sanitizedFileName,
|
|
463
|
-
size: totalBytes ? formatFileSize(totalBytes) : null
|
|
493
|
+
status: 'downloading',
|
|
494
|
+
file: sanitizedFileName,
|
|
495
|
+
size: totalBytes ? formatFileSize(totalBytes) : null
|
|
464
496
|
})
|
|
465
497
|
|
|
466
|
-
// Download with progress
|
|
467
498
|
const rs = drive.createReadStream(entry.key)
|
|
468
499
|
const ws = fs.createWriteStream(savePath)
|
|
469
|
-
|
|
500
|
+
|
|
470
501
|
taskState.readStream = rs
|
|
471
502
|
taskState.writeStream = ws
|
|
472
503
|
|
|
473
504
|
let loadedBytes = 0
|
|
474
505
|
let lastProgressUpdate = 0
|
|
475
|
-
|
|
506
|
+
|
|
476
507
|
await new Promise((resolve, reject) => {
|
|
477
508
|
rs.on('data', (chunk) => {
|
|
478
|
-
// Check cancellation
|
|
479
509
|
if (taskState.aborted) {
|
|
480
510
|
rs.destroy()
|
|
481
511
|
ws.destroy()
|
|
512
|
+
fs.unlink(savePath, () => { })
|
|
482
513
|
reject(new Error('Download cancelled'))
|
|
483
514
|
return
|
|
484
515
|
}
|
|
485
516
|
loadedBytes += chunk.length
|
|
486
517
|
const now = Date.now()
|
|
487
|
-
if (totalBytes > 0 && now - lastProgressUpdate >
|
|
518
|
+
if (totalBytes > 0 && now - lastProgressUpdate > PROGRESS_THROTTLE) {
|
|
488
519
|
lastProgressUpdate = now
|
|
489
520
|
const percent = Math.round((loadedBytes / totalBytes) * 100)
|
|
490
521
|
this.emit('download:progress', { taskId, loaded: loadedBytes, total: totalBytes, percent })
|
|
491
522
|
}
|
|
492
523
|
})
|
|
493
|
-
|
|
524
|
+
|
|
494
525
|
rs.pipe(ws)
|
|
495
526
|
ws.on('finish', resolve)
|
|
496
527
|
ws.on('error', reject)
|
|
497
528
|
rs.on('error', reject)
|
|
498
529
|
})
|
|
499
530
|
|
|
500
|
-
// Check cancellation before verification
|
|
501
531
|
if (taskState.aborted) throw new Error('Download cancelled')
|
|
502
532
|
|
|
503
|
-
// Verify integrity
|
|
504
533
|
this.emit('download:status', { taskId, status: 'verifying' })
|
|
505
534
|
|
|
506
535
|
const { cid: downloadedCid } = await calculateCid(savePath)
|
|
@@ -512,13 +541,24 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
512
541
|
throw new IntegrityError(`File content CID mismatch. File may be corrupted or tampered.`)
|
|
513
542
|
}
|
|
514
543
|
|
|
544
|
+
// Write file content to Hyperdrive so it can be served for preview
|
|
545
|
+
const driveKey = '/' + cidString
|
|
546
|
+
const readStream = fs.createReadStream(savePath)
|
|
547
|
+
const writeStream = drive.createWriteStream(driveKey)
|
|
548
|
+
await new Promise((resolve, reject) => {
|
|
549
|
+
readStream.pipe(writeStream)
|
|
550
|
+
writeStream.on('finish', resolve)
|
|
551
|
+
writeStream.on('error', reject)
|
|
552
|
+
readStream.on('error', reject)
|
|
553
|
+
})
|
|
554
|
+
|
|
515
555
|
const result = {
|
|
516
556
|
taskId,
|
|
517
557
|
fileName: sanitizedFileName,
|
|
518
558
|
savedPath: savePath
|
|
519
559
|
}
|
|
520
560
|
|
|
521
|
-
//
|
|
561
|
+
// 将下载的文件添加到已发布文件列表(displayName 用原始文件名)
|
|
522
562
|
const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
|
|
523
563
|
if (existingIndex !== -1) {
|
|
524
564
|
const existing = this.#publishedFiles[existingIndex]
|
|
@@ -530,6 +570,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
530
570
|
this.#publishedFiles.push({
|
|
531
571
|
fileName: sanitizedFileName,
|
|
532
572
|
cid: cidString,
|
|
573
|
+
driveName: name,
|
|
533
574
|
publishedAt: new Date().toISOString(),
|
|
534
575
|
starred: false
|
|
535
576
|
})
|
|
@@ -545,19 +586,19 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
545
586
|
}
|
|
546
587
|
|
|
547
588
|
/**
|
|
548
|
-
*
|
|
549
|
-
* @param {object} [options] -
|
|
550
|
-
* @param {boolean} [options.starred] -
|
|
589
|
+
* 列出所有已发布文件
|
|
590
|
+
* @param {object} [options] - 筛选选项
|
|
591
|
+
* @param {boolean} [options.starred] - 按收藏状态筛选
|
|
551
592
|
* @returns {Array<{ fileName: string, cid: string, link: string, publishedAt: string, starred: boolean }>}
|
|
552
593
|
*/
|
|
553
594
|
listPublishedFiles(options = {}) {
|
|
554
595
|
this.#ensureInitialized()
|
|
555
596
|
let files = this.#publishedFiles
|
|
556
|
-
|
|
597
|
+
|
|
557
598
|
if (options.starred === true) {
|
|
558
599
|
files = files.filter(f => f.starred === true)
|
|
559
600
|
}
|
|
560
|
-
|
|
601
|
+
|
|
561
602
|
return files.map(f => ({
|
|
562
603
|
fileName: f.fileName,
|
|
563
604
|
cid: f.cid,
|
|
@@ -566,11 +607,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
566
607
|
starred: f.starred || false
|
|
567
608
|
}))
|
|
568
609
|
}
|
|
569
|
-
|
|
610
|
+
|
|
570
611
|
/**
|
|
571
|
-
*
|
|
572
|
-
* @param {string} cid - CID
|
|
573
|
-
* @returns {object}
|
|
612
|
+
* 切换文件的收藏状态
|
|
613
|
+
* @param {string} cid - 文件的 CID
|
|
614
|
+
* @returns {object} 更新后的文件信息
|
|
574
615
|
*/
|
|
575
616
|
toggleStarred(cid) {
|
|
576
617
|
this.#ensureInitialized()
|
|
@@ -587,36 +628,35 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
587
628
|
}
|
|
588
629
|
|
|
589
630
|
/**
|
|
590
|
-
*
|
|
591
|
-
* @param {string} cid - CID
|
|
592
|
-
* @returns {Promise<Array>}
|
|
631
|
+
* 删除已发布文件 — 移至回收站而非永久删除
|
|
632
|
+
* @param {string} cid - 要删除文件的 CID
|
|
633
|
+
* @returns {Promise<Array>} 更新后的已发布文件列表
|
|
593
634
|
*/
|
|
594
635
|
async deletePublishedFile(cid) {
|
|
595
636
|
this.#ensureInitialized()
|
|
596
637
|
const index = this.#publishedFiles.findIndex(f => f.cid === cid)
|
|
597
638
|
if (index !== -1) {
|
|
598
639
|
const fileRecord = this.#publishedFiles[index]
|
|
599
|
-
|
|
600
|
-
// Move to trash instead of permanent deletion
|
|
640
|
+
|
|
601
641
|
this.#trashFiles.push({
|
|
602
642
|
fileName: fileRecord.fileName,
|
|
603
643
|
cid: fileRecord.cid,
|
|
644
|
+
driveName: fileRecord.driveName,
|
|
604
645
|
publishedAt: fileRecord.publishedAt,
|
|
605
646
|
starred: fileRecord.starred || false,
|
|
606
647
|
deletedAt: new Date().toISOString()
|
|
607
648
|
})
|
|
608
649
|
this.#saveTrashMetadata()
|
|
609
|
-
|
|
610
|
-
// Remove from published files
|
|
650
|
+
|
|
611
651
|
this.#publishedFiles.splice(index, 1)
|
|
612
652
|
this.#savePublishedMetadata()
|
|
613
653
|
}
|
|
614
654
|
return this.listPublishedFiles()
|
|
615
655
|
}
|
|
616
|
-
|
|
656
|
+
|
|
617
657
|
/**
|
|
618
|
-
*
|
|
619
|
-
* @returns {Array}
|
|
658
|
+
* 列出回收站中的所有文件
|
|
659
|
+
* @returns {Array} 回收站文件
|
|
620
660
|
*/
|
|
621
661
|
listTrashFiles() {
|
|
622
662
|
this.#ensureInitialized()
|
|
@@ -629,11 +669,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
629
669
|
deletedAt: f.deletedAt
|
|
630
670
|
}))
|
|
631
671
|
}
|
|
632
|
-
|
|
672
|
+
|
|
633
673
|
/**
|
|
634
|
-
*
|
|
635
|
-
* @param {string} cid - CID
|
|
636
|
-
* @returns {Array}
|
|
674
|
+
* 从回收站恢复文件
|
|
675
|
+
* @param {string} cid - 要恢复文件的 CID
|
|
676
|
+
* @returns {Array} 更新后的已发布文件列表
|
|
637
677
|
*/
|
|
638
678
|
restoreTrashFile(cid) {
|
|
639
679
|
this.#ensureInitialized()
|
|
@@ -641,120 +681,105 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
641
681
|
if (index === -1) {
|
|
642
682
|
throw new Error('File not found in trash')
|
|
643
683
|
}
|
|
644
|
-
|
|
684
|
+
|
|
645
685
|
const fileRecord = this.#trashFiles[index]
|
|
646
|
-
|
|
647
|
-
|
|
686
|
+
|
|
687
|
+
const parsedCid = CID.parse(fileRecord.cid)
|
|
688
|
+
const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
|
|
689
|
+
const driveName = `drive-${hashHex}`
|
|
690
|
+
|
|
648
691
|
this.#publishedFiles.push({
|
|
649
692
|
fileName: fileRecord.fileName,
|
|
650
693
|
cid: fileRecord.cid,
|
|
694
|
+
driveName,
|
|
651
695
|
publishedAt: fileRecord.publishedAt,
|
|
652
696
|
starred: fileRecord.starred || false
|
|
653
697
|
})
|
|
654
698
|
this.#savePublishedMetadata()
|
|
655
|
-
|
|
656
|
-
// Remove from trash
|
|
699
|
+
|
|
657
700
|
this.#trashFiles.splice(index, 1)
|
|
658
701
|
this.#saveTrashMetadata()
|
|
659
|
-
|
|
702
|
+
|
|
660
703
|
return this.listPublishedFiles()
|
|
661
704
|
}
|
|
662
|
-
|
|
705
|
+
|
|
663
706
|
/**
|
|
664
|
-
*
|
|
665
|
-
* @param {string} cid - CID
|
|
666
|
-
* @returns {Promise<Array>}
|
|
707
|
+
* 永久删除回收站中的文件
|
|
708
|
+
* @param {string} cid - 要永久删除文件的 CID
|
|
709
|
+
* @returns {Promise<Array>} 更新后的回收站列表
|
|
667
710
|
*/
|
|
668
711
|
async permanentDeleteTrashFile(cid) {
|
|
669
712
|
this.#ensureInitialized()
|
|
670
713
|
const index = this.#trashFiles.findIndex(f => f.cid === cid)
|
|
671
714
|
if (index !== -1) {
|
|
672
715
|
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
|
|
716
|
+
const driveName = fileRecord.driveName
|
|
717
|
+
|
|
680
718
|
const drive = this.#drives.get(driveName)
|
|
681
719
|
if (drive) {
|
|
682
720
|
try {
|
|
683
|
-
await drive.del(fileRecord.
|
|
721
|
+
await drive.del('/' + fileRecord.cid)
|
|
684
722
|
} catch (err) {
|
|
685
|
-
//
|
|
723
|
+
// 文件可能不存在于驱动器中
|
|
686
724
|
}
|
|
687
|
-
|
|
688
|
-
// Leave swarm for this drive
|
|
725
|
+
|
|
689
726
|
await this.#swarm.leave(drive.discoveryKey)
|
|
690
|
-
|
|
691
|
-
// Close and remove drive
|
|
692
727
|
await drive.close()
|
|
693
728
|
this.#drives.delete(driveName)
|
|
694
729
|
}
|
|
695
|
-
|
|
696
|
-
// Remove from trash
|
|
730
|
+
|
|
697
731
|
this.#trashFiles.splice(index, 1)
|
|
698
732
|
this.#saveTrashMetadata()
|
|
699
733
|
}
|
|
700
734
|
return this.listTrashFiles()
|
|
701
735
|
}
|
|
702
|
-
|
|
736
|
+
|
|
703
737
|
/**
|
|
704
|
-
*
|
|
705
|
-
* @returns {Promise<Array>}
|
|
738
|
+
* 清空回收站 — 永久删除所有回收站文件
|
|
739
|
+
* @returns {Promise<Array>} 清空后的回收站列表
|
|
706
740
|
*/
|
|
707
741
|
async emptyTrash() {
|
|
708
742
|
this.#ensureInitialized()
|
|
709
|
-
|
|
743
|
+
|
|
710
744
|
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
|
|
745
|
+
const driveName = fileRecord.driveName
|
|
746
|
+
|
|
717
747
|
const drive = this.#drives.get(driveName)
|
|
718
748
|
if (drive) {
|
|
719
749
|
try {
|
|
720
|
-
await drive.del(fileRecord.
|
|
750
|
+
await drive.del('/' + fileRecord.cid)
|
|
721
751
|
} catch (err) {
|
|
722
|
-
//
|
|
752
|
+
// 文件可能不存在
|
|
723
753
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
this.#swarm.leave(drive.discoveryKey)
|
|
727
|
-
|
|
728
|
-
// Close and remove drive
|
|
754
|
+
|
|
755
|
+
await this.#swarm.leave(drive.discoveryKey)
|
|
729
756
|
await drive.close()
|
|
730
757
|
this.#drives.delete(driveName)
|
|
731
758
|
}
|
|
732
759
|
}
|
|
733
|
-
|
|
734
|
-
// Clear trash
|
|
760
|
+
|
|
735
761
|
this.#trashFiles = []
|
|
736
762
|
this.#saveTrashMetadata()
|
|
737
|
-
|
|
763
|
+
|
|
738
764
|
return []
|
|
739
765
|
}
|
|
740
|
-
|
|
766
|
+
|
|
741
767
|
/**
|
|
742
|
-
*
|
|
768
|
+
* 获取存储统计信息
|
|
743
769
|
* @returns {Promise<{ total: number, used: number, free: number, fileCount: number, trashCount: number }>}
|
|
744
770
|
*/
|
|
745
771
|
async getStorageStats() {
|
|
746
772
|
this.#ensureInitialized()
|
|
747
|
-
|
|
773
|
+
|
|
748
774
|
let totalSize = 0
|
|
749
775
|
let freeSize = 0
|
|
750
776
|
const { dataPath } = this.#options
|
|
751
|
-
|
|
777
|
+
|
|
752
778
|
try {
|
|
753
779
|
const stats = fs.statfsSync(dataPath)
|
|
754
780
|
totalSize = stats.bsize * stats.blocks
|
|
755
781
|
freeSize = stats.bsize * stats.bfree
|
|
756
782
|
} catch (err) {
|
|
757
|
-
// Fallback if statfs is not available
|
|
758
783
|
try {
|
|
759
784
|
const stats = fs.statSync(dataPath)
|
|
760
785
|
totalSize = 0
|
|
@@ -764,8 +789,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
764
789
|
freeSize = 0
|
|
765
790
|
}
|
|
766
791
|
}
|
|
767
|
-
|
|
768
|
-
// Calculate used space by files
|
|
792
|
+
|
|
769
793
|
let usedSize = 0
|
|
770
794
|
const calculateDirSize = (dirPath) => {
|
|
771
795
|
try {
|
|
@@ -781,17 +805,17 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
781
805
|
const stat = fs.statSync(fullPath)
|
|
782
806
|
usedSize += stat.size
|
|
783
807
|
} catch {
|
|
784
|
-
//
|
|
808
|
+
// 跳过无法访问的文件
|
|
785
809
|
}
|
|
786
810
|
}
|
|
787
811
|
}
|
|
788
812
|
} catch {
|
|
789
|
-
//
|
|
813
|
+
// 跳过无法访问的目录
|
|
790
814
|
}
|
|
791
815
|
}
|
|
792
|
-
|
|
816
|
+
|
|
793
817
|
calculateDirSize(dataPath)
|
|
794
|
-
|
|
818
|
+
|
|
795
819
|
return {
|
|
796
820
|
total: totalSize,
|
|
797
821
|
used: usedSize,
|
|
@@ -802,10 +826,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
802
826
|
}
|
|
803
827
|
|
|
804
828
|
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
* @param {string}
|
|
808
|
-
* @
|
|
829
|
+
* 移动/重命名已发布文件
|
|
830
|
+
* 只更新 metadata 中的 displayName,不修改 Hyperdrive
|
|
831
|
+
* @param {string} cid - 要移动文件的 CID
|
|
832
|
+
* @param {string} newFileName - 新文件路径
|
|
833
|
+
* @returns {object} 更新后的文件信息
|
|
809
834
|
*/
|
|
810
835
|
moveFile(cid, newFileName) {
|
|
811
836
|
this.#ensureInitialized()
|
|
@@ -825,20 +850,22 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
825
850
|
}
|
|
826
851
|
|
|
827
852
|
/**
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
* @param {string}
|
|
831
|
-
* @
|
|
853
|
+
* 重命名文件夹(重命名文件夹内的所有文件 displayName)
|
|
854
|
+
* 只更新 metadata,不修改 Hyperdrive
|
|
855
|
+
* @param {string} oldPath - 当前文件夹路径
|
|
856
|
+
* @param {string} newPath - 新文件夹路径
|
|
857
|
+
* @returns {object} 更新后的文件信息
|
|
832
858
|
*/
|
|
833
859
|
renameFolder(oldPath, newPath) {
|
|
834
860
|
this.#ensureInitialized()
|
|
835
861
|
const prefix = oldPath + '/'
|
|
836
862
|
const updatedFiles = []
|
|
837
|
-
|
|
863
|
+
|
|
838
864
|
for (const file of this.#publishedFiles) {
|
|
839
865
|
if (file.fileName.startsWith(prefix)) {
|
|
840
|
-
const
|
|
841
|
-
|
|
866
|
+
const remainder = file.fileName.substring(prefix.length)
|
|
867
|
+
const newFileName = sanitizeFilename(remainder ? newPath + '/' + remainder : newPath)
|
|
868
|
+
file.fileName = newFileName
|
|
842
869
|
file.publishedAt = new Date().toISOString()
|
|
843
870
|
updatedFiles.push({
|
|
844
871
|
cid: file.cid,
|
|
@@ -847,19 +874,19 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
847
874
|
})
|
|
848
875
|
}
|
|
849
876
|
}
|
|
850
|
-
|
|
877
|
+
|
|
851
878
|
if (updatedFiles.length > 0) {
|
|
852
879
|
this.#savePublishedMetadata()
|
|
853
880
|
}
|
|
854
|
-
|
|
881
|
+
|
|
855
882
|
return { files: updatedFiles }
|
|
856
883
|
}
|
|
857
884
|
|
|
858
885
|
/**
|
|
859
|
-
*
|
|
860
|
-
* @param {string} taskId -
|
|
886
|
+
* 取消正在进行的下载
|
|
887
|
+
* @param {string} taskId - 要取消下载的任务 ID
|
|
861
888
|
*/
|
|
862
|
-
|
|
889
|
+
cancelDownload(taskId) {
|
|
863
890
|
const task = this.#activeDownloads.get(taskId)
|
|
864
891
|
if (task) {
|
|
865
892
|
task.aborted = true
|
|
@@ -872,7 +899,452 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
872
899
|
return this.#publishedFiles
|
|
873
900
|
}
|
|
874
901
|
|
|
875
|
-
|
|
902
|
+
/**
|
|
903
|
+
* 读取已发布文件的内容(用于预览)
|
|
904
|
+
* Hyperdrive 中用 CID 作为 key 存储
|
|
905
|
+
* @param {string} cid - 文件的 CID
|
|
906
|
+
* @param {number} [offset=0] - 读取起始位置
|
|
907
|
+
* @param {number} [limit=10000] - 最大读取字节数
|
|
908
|
+
*/
|
|
909
|
+
async readFileContent(cid, offset = 0, limit = DEFAULT_READ_LIMIT) {
|
|
910
|
+
this.#ensureInitialized()
|
|
911
|
+
|
|
912
|
+
const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
|
|
913
|
+
if (!fileRecord) {
|
|
914
|
+
throw new Error('File not found')
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const drive = await this.#getDriveForFile(fileRecord)
|
|
918
|
+
|
|
919
|
+
// Hyperdrive 中 key 为 '/' + cid
|
|
920
|
+
const driveKey = '/' + cid
|
|
921
|
+
const entry = await drive.entry(driveKey, { wait: true, timeout: DRIVE_ENTRY_TIMEOUT })
|
|
922
|
+
if (!entry || !entry.value) {
|
|
923
|
+
throw new Error('File content not available')
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const chunks = []
|
|
927
|
+
const stream = drive.createReadStream(driveKey, { start: offset, end: offset + limit - 1 })
|
|
928
|
+
|
|
929
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
930
|
+
setTimeout(() => reject(new Error('Stream read timeout')), STREAM_READ_TIMEOUT)
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
const readPromise = (async () => {
|
|
934
|
+
for await (const chunk of stream) {
|
|
935
|
+
chunks.push(chunk)
|
|
936
|
+
}
|
|
937
|
+
})()
|
|
938
|
+
|
|
939
|
+
await Promise.race([readPromise, timeoutPromise])
|
|
940
|
+
|
|
941
|
+
const content = Buffer.concat(chunks).toString('utf8')
|
|
942
|
+
const hasMore = chunks.length > 0 && chunks[chunks.length - 1].length === limit
|
|
943
|
+
|
|
944
|
+
return { content, hasMore }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* 读取已发布文件的原始内容(用于预览/下载)
|
|
949
|
+
* Hyperdrive 中用 CID 作为 key 存储
|
|
950
|
+
* @param {string} cid - 文件的 CID
|
|
951
|
+
* @param {object} [options] - 选项
|
|
952
|
+
* @param {number} [options.offset=0] - 读取起始位置
|
|
953
|
+
* @param {number} [options.limit] - 最大读取字节数,不指定则读取到末尾
|
|
954
|
+
* @param {number} [options.timeout=10000] - 流读取超时(毫秒)
|
|
955
|
+
* @returns {Promise<{buffer: Buffer, fileName: string, totalSize: number}>}
|
|
956
|
+
*/
|
|
957
|
+
async readFileRaw(cid, options = {}) {
|
|
958
|
+
this.#ensureInitialized()
|
|
959
|
+
|
|
960
|
+
const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
|
|
961
|
+
if (!fileRecord) {
|
|
962
|
+
throw new Error('File not found')
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const drive = await this.#getDriveForFile(fileRecord)
|
|
966
|
+
|
|
967
|
+
const driveKey = '/' + cid
|
|
968
|
+
const entry = await drive.entry(driveKey, { wait: true, timeout: DRIVE_ENTRY_TIMEOUT })
|
|
969
|
+
if (!entry || !entry.value || !entry.value.blob) {
|
|
970
|
+
throw new Error('File content not available')
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const totalSize = entry.value.blob.byteLength || 0
|
|
974
|
+
|
|
975
|
+
const { offset = 0, limit, timeout = STREAM_READ_TIMEOUT } = options
|
|
976
|
+
const effectiveLimit = (limit === undefined || limit === null)
|
|
977
|
+
? totalSize - offset
|
|
978
|
+
: Math.min(limit, totalSize - offset)
|
|
979
|
+
|
|
980
|
+
if (effectiveLimit <= 0) {
|
|
981
|
+
return { buffer: Buffer.alloc(0), fileName: fileRecord.fileName, totalSize }
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const chunks = []
|
|
985
|
+
const stream = drive.createReadStream(driveKey, {
|
|
986
|
+
start: offset,
|
|
987
|
+
end: offset + effectiveLimit - 1
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
991
|
+
setTimeout(() => reject(new Error('Stream read timeout')), timeout)
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
const readPromise = (async () => {
|
|
995
|
+
try {
|
|
996
|
+
for await (const chunk of stream) {
|
|
997
|
+
chunks.push(chunk)
|
|
998
|
+
}
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
if (err.message !== 'Stream read timeout') {
|
|
1001
|
+
throw err
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
})()
|
|
1005
|
+
|
|
1006
|
+
await Promise.race([readPromise, timeoutPromise])
|
|
1007
|
+
await readPromise.catch(() => { })
|
|
1008
|
+
|
|
1009
|
+
const buffer = Buffer.concat(chunks)
|
|
1010
|
+
return { buffer, fileName: fileRecord.fileName, totalSize }
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* 获取文件对应的 drive,如果不存在则创建并同步
|
|
1015
|
+
*/
|
|
1016
|
+
async #getDriveForFile(fileRecord) {
|
|
1017
|
+
let drive = this.#drives.get(fileRecord.driveName)
|
|
1018
|
+
if (!drive) {
|
|
1019
|
+
drive = await this.#getOrCreateDrive(fileRecord.driveName, { server: true, client: true })
|
|
1020
|
+
}
|
|
1021
|
+
await this.#syncDrive(drive)
|
|
1022
|
+
return drive
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// --- 频道管理 ---
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* 创建或加入频道
|
|
1029
|
+
* @param {string} name - 频道名
|
|
1030
|
+
* @param {string} [type='personal'] - 频道类型
|
|
1031
|
+
* @returns {Promise<{ name: string, key: string }>}
|
|
1032
|
+
*/
|
|
1033
|
+
async createChannel(name, type = 'personal') {
|
|
1034
|
+
this.#ensureInitialized()
|
|
1035
|
+
|
|
1036
|
+
if (!CHANNEL_NAME_REGEX.test(name)) {
|
|
1037
|
+
throw new Error('频道名只能包含字母、数字、下划线和连字符')
|
|
1038
|
+
}
|
|
1039
|
+
if (name.length < CHANNEL_NAME_MIN_LENGTH) {
|
|
1040
|
+
throw new Error(`频道名至少 ${CHANNEL_NAME_MIN_LENGTH} 个字符`)
|
|
1041
|
+
}
|
|
1042
|
+
if (name.length > CHANNEL_NAME_MAX_LENGTH) {
|
|
1043
|
+
throw new Error(`频道名最多 ${CHANNEL_NAME_MAX_LENGTH} 个字符`)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const existing = this.#channels.find(c => c.name === name)
|
|
1047
|
+
if (existing) {
|
|
1048
|
+
return { name: existing.name, key: existing.coreKey }
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const ns = this.#store.namespace(`channel-${name}`)
|
|
1052
|
+
const core = ns.get({ name: 'messages', valueEncoding: 'json' })
|
|
1053
|
+
await core.ready()
|
|
1054
|
+
|
|
1055
|
+
const discoveryKey = this.#generateChannelDiscoveryKey(name)
|
|
1056
|
+
const discovery = this.#swarm.join(discoveryKey, { server: true, client: true })
|
|
1057
|
+
await discovery.flushed()
|
|
1058
|
+
|
|
1059
|
+
let lastCoreLength = core.length
|
|
1060
|
+
core.on('append', async () => {
|
|
1061
|
+
if (core.length > lastCoreLength) {
|
|
1062
|
+
|
|
1063
|
+
for (let i = lastCoreLength; i < core.length; i++) {
|
|
1064
|
+
try {
|
|
1065
|
+
const entry = await core.get(i)
|
|
1066
|
+
if (entry && entry.type === 'message') {
|
|
1067
|
+
|
|
1068
|
+
this.emit('channel:message', { channel: name, message: entry })
|
|
1069
|
+
}
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
lastCoreLength = core.length
|
|
1075
|
+
}
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
const channelInfo = {
|
|
1079
|
+
name,
|
|
1080
|
+
discoveryKey: b4a.toString(discoveryKey, 'hex'),
|
|
1081
|
+
coreKey: b4a.toString(core.key, 'hex'),
|
|
1082
|
+
createdAt: new Date().toISOString(),
|
|
1083
|
+
type
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
this.#channels.push(channelInfo)
|
|
1087
|
+
this.#channelCores.set(name, core)
|
|
1088
|
+
this.#channelPeers.set(name, new Map())
|
|
1089
|
+
this.#saveChannelsMetadata()
|
|
1090
|
+
|
|
1091
|
+
console.log(`[MostBox] Channel created: ${name}`)
|
|
1092
|
+
this.emit('channel:joined', { name, key: channelInfo.coreKey })
|
|
1093
|
+
|
|
1094
|
+
return { name, key: channelInfo.coreKey }
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* 加入已有频道(通过频道名和 coreKey)
|
|
1099
|
+
* @param {string} name - 频道名
|
|
1100
|
+
* @param {string} [coreKey] - 频道的 coreKey(加入他人创建的频道时必填)
|
|
1101
|
+
* @returns {Promise<{ name: string, key: string }>}
|
|
1102
|
+
*/
|
|
1103
|
+
async joinChannel(name, coreKey = null) {
|
|
1104
|
+
this.#ensureInitialized()
|
|
1105
|
+
|
|
1106
|
+
const existing = this.#channels.find(c => c.name === name)
|
|
1107
|
+
if (existing) {
|
|
1108
|
+
return { name: existing.name, key: existing.coreKey }
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (!coreKey) {
|
|
1112
|
+
throw new Error('加入已有频道需要提供 coreKey')
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const ns = this.#store.namespace(`channel-${name}`)
|
|
1116
|
+
const core = ns.get({ key: b4a.from(coreKey, 'hex'), valueEncoding: 'json' })
|
|
1117
|
+
await core.ready()
|
|
1118
|
+
|
|
1119
|
+
const discoveryKey = this.#generateChannelDiscoveryKey(name)
|
|
1120
|
+
const discovery = this.#swarm.join(discoveryKey, { server: true, client: true })
|
|
1121
|
+
await discovery.flushed()
|
|
1122
|
+
|
|
1123
|
+
let lastCoreLength = core.length
|
|
1124
|
+
core.on('append', async () => {
|
|
1125
|
+
if (core.length > lastCoreLength) {
|
|
1126
|
+
|
|
1127
|
+
for (let i = lastCoreLength; i < core.length; i++) {
|
|
1128
|
+
try {
|
|
1129
|
+
const entry = await core.get(i)
|
|
1130
|
+
if (entry && entry.type === 'message') {
|
|
1131
|
+
|
|
1132
|
+
this.emit('channel:message', { channel: name, message: entry })
|
|
1133
|
+
}
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
lastCoreLength = core.length
|
|
1139
|
+
}
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
const channelInfo = {
|
|
1143
|
+
name,
|
|
1144
|
+
discoveryKey: b4a.toString(discoveryKey, 'hex'),
|
|
1145
|
+
coreKey,
|
|
1146
|
+
createdAt: new Date().toISOString(),
|
|
1147
|
+
type: 'group'
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
this.#channels.push(channelInfo)
|
|
1151
|
+
this.#channelCores.set(name, core)
|
|
1152
|
+
this.#channelPeers.set(name, new Map())
|
|
1153
|
+
this.#saveChannelsMetadata()
|
|
1154
|
+
|
|
1155
|
+
console.log(`[MostBox] Joined channel: ${name}`)
|
|
1156
|
+
this.emit('channel:joined', { name, key: coreKey })
|
|
1157
|
+
|
|
1158
|
+
return { name, key: coreKey }
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* 离开频道
|
|
1163
|
+
* @param {string} name - 频道名
|
|
1164
|
+
* @returns {Promise<string[]>} 剩余频道列表
|
|
1165
|
+
*/
|
|
1166
|
+
async leaveChannel(name) {
|
|
1167
|
+
this.#ensureInitialized()
|
|
1168
|
+
|
|
1169
|
+
const index = this.#channels.findIndex(c => c.name === name)
|
|
1170
|
+
if (index === -1) {
|
|
1171
|
+
throw new Error('频道不存在')
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const channel = this.#channels[index]
|
|
1175
|
+
|
|
1176
|
+
const discovery = this.#channelDiscoveries.get(name)
|
|
1177
|
+
if (discovery) {
|
|
1178
|
+
try {
|
|
1179
|
+
await this.#swarm.leave(b4a.from(channel.discoveryKey, 'hex'))
|
|
1180
|
+
} catch {}
|
|
1181
|
+
this.#channelDiscoveries.delete(name)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const core = this.#channelCores.get(name)
|
|
1185
|
+
if (core) {
|
|
1186
|
+
try {
|
|
1187
|
+
await core.close()
|
|
1188
|
+
} catch {}
|
|
1189
|
+
this.#channelCores.delete(name)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
this.#channelPeers.delete(name)
|
|
1193
|
+
this.#channels.splice(index, 1)
|
|
1194
|
+
this.#saveChannelsMetadata()
|
|
1195
|
+
|
|
1196
|
+
console.log(`[MostBox] Left channel: ${name}`)
|
|
1197
|
+
this.emit('channel:left', { name })
|
|
1198
|
+
|
|
1199
|
+
return this.listChannels()
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* 列出所有频道
|
|
1204
|
+
* @returns {Array<{ name: string, coreKey: string, createdAt: string, type: string, peerCount: number }>}
|
|
1205
|
+
*/
|
|
1206
|
+
listChannels() {
|
|
1207
|
+
this.#ensureInitialized()
|
|
1208
|
+
|
|
1209
|
+
return this.#channels.map(c => ({
|
|
1210
|
+
name: c.name,
|
|
1211
|
+
coreKey: c.coreKey,
|
|
1212
|
+
createdAt: c.createdAt,
|
|
1213
|
+
type: c.type,
|
|
1214
|
+
peerCount: (this.#channelPeers.get(c.name) || new Map()).size
|
|
1215
|
+
}))
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* 获取频道消息
|
|
1220
|
+
* @param {string} name - 频道名
|
|
1221
|
+
* @param {object} [options] - 选项
|
|
1222
|
+
* @param {number} [options.limit=100] - 消息数量
|
|
1223
|
+
* @param {number} [options.offset=0] - 偏移量
|
|
1224
|
+
* @returns {Promise<Array>}
|
|
1225
|
+
*/
|
|
1226
|
+
async getChannelMessages(name, options = {}) {
|
|
1227
|
+
this.#ensureInitialized()
|
|
1228
|
+
|
|
1229
|
+
const { limit = CHANNEL_MESSAGE_LIMIT, offset = 0 } = options
|
|
1230
|
+
|
|
1231
|
+
const core = this.#channelCores.get(name)
|
|
1232
|
+
if (!core) {
|
|
1233
|
+
throw new Error('频道未初始化')
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const messages = []
|
|
1237
|
+
const total = core.length
|
|
1238
|
+
const start = Math.max(0, total - offset - limit)
|
|
1239
|
+
const end = total - offset
|
|
1240
|
+
|
|
1241
|
+
for (let i = start; i < end; i++) {
|
|
1242
|
+
try {
|
|
1243
|
+
const entry = await core.get(i)
|
|
1244
|
+
messages.push(entry)
|
|
1245
|
+
} catch {
|
|
1246
|
+
break
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return messages
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* 发送消息到频道
|
|
1255
|
+
* @param {string} name - 频道名
|
|
1256
|
+
* @param {string} content - 消息内容
|
|
1257
|
+
* @param {string} [authorName] - 作者名(可选,默认使用 displayName)
|
|
1258
|
+
* @returns {Promise<object>}
|
|
1259
|
+
*/
|
|
1260
|
+
async sendMessage(name, content, authorName = null) {
|
|
1261
|
+
this.#ensureInitialized()
|
|
1262
|
+
|
|
1263
|
+
const core = this.#channelCores.get(name)
|
|
1264
|
+
if (!core) {
|
|
1265
|
+
throw new Error('频道未初始化')
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (!content || !content.trim()) {
|
|
1269
|
+
throw new Error('消息内容不能为空')
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const trimmed = content.trim()
|
|
1273
|
+
if (trimmed.length > MAX_MESSAGE_LENGTH) {
|
|
1274
|
+
throw new Error(`消息内容不能超过 ${MAX_MESSAGE_LENGTH} 字符`)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const displayName = this.getNodeId().slice(0, 4)
|
|
1278
|
+
|
|
1279
|
+
const message = {
|
|
1280
|
+
type: 'message',
|
|
1281
|
+
author: this.getNodeId(),
|
|
1282
|
+
authorName: displayName,
|
|
1283
|
+
content: trimmed,
|
|
1284
|
+
timestamp: Date.now()
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
await core.append(message)
|
|
1288
|
+
|
|
1289
|
+
this.emit('channel:message', { channel: name, message })
|
|
1290
|
+
|
|
1291
|
+
return message
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* 获取频道内在线用户
|
|
1296
|
+
* @param {string} name - 频道名
|
|
1297
|
+
* @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
|
|
1298
|
+
*/
|
|
1299
|
+
getChannelPeers(name) {
|
|
1300
|
+
this.#ensureInitialized()
|
|
1301
|
+
|
|
1302
|
+
const peers = this.#channelPeers.get(name)
|
|
1303
|
+
if (!peers) {
|
|
1304
|
+
throw new Error('频道未初始化')
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return [...peers.values()].map(p => ({
|
|
1308
|
+
peerId: p.peerId,
|
|
1309
|
+
authorName: p.authorName,
|
|
1310
|
+
lastSeen: p.lastSeen
|
|
1311
|
+
}))
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* 获取显示名
|
|
1316
|
+
* @returns {string|null}
|
|
1317
|
+
*/
|
|
1318
|
+
getDisplayName() {
|
|
1319
|
+
try {
|
|
1320
|
+
const configPath = this.#getConfigPath()
|
|
1321
|
+
if (fs.existsSync(configPath)) {
|
|
1322
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
1323
|
+
return config.displayName || null
|
|
1324
|
+
}
|
|
1325
|
+
} catch {}
|
|
1326
|
+
return null
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* 设置显示名
|
|
1331
|
+
* @param {string} name - 显示名
|
|
1332
|
+
*/
|
|
1333
|
+
setDisplayName(name) {
|
|
1334
|
+
try {
|
|
1335
|
+
const configPath = this.#getConfigPath()
|
|
1336
|
+
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {}
|
|
1337
|
+
config.displayName = name.trim()
|
|
1338
|
+
const tmpPath = configPath + '.tmp'
|
|
1339
|
+
fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8')
|
|
1340
|
+
fs.renameSync(tmpPath, configPath)
|
|
1341
|
+
return true
|
|
1342
|
+
} catch {
|
|
1343
|
+
return false
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// --- 私有方法 ---
|
|
876
1348
|
|
|
877
1349
|
#ensureInitialized() {
|
|
878
1350
|
if (!this.#initialized) {
|
|
@@ -880,21 +1352,61 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
880
1352
|
}
|
|
881
1353
|
}
|
|
882
1354
|
|
|
1355
|
+
async #getOrCreateDrive(name, options = { server: true, client: false }) {
|
|
1356
|
+
if (this.#drives.has(name)) return this.#drives.get(name)
|
|
1357
|
+
if (this.#drivePromises.has(name)) return this.#drivePromises.get(name)
|
|
1358
|
+
|
|
1359
|
+
const promise = (async () => {
|
|
1360
|
+
const drive = new Hyperdrive(this.#store.namespace(name))
|
|
1361
|
+
await drive.ready()
|
|
1362
|
+
this.#drives.set(name, drive)
|
|
1363
|
+
return drive
|
|
1364
|
+
})()
|
|
1365
|
+
|
|
1366
|
+
this.#drivePromises.set(name, promise)
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
const drive = await promise
|
|
1370
|
+
return drive
|
|
1371
|
+
} finally {
|
|
1372
|
+
this.#drivePromises.delete(name)
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async #syncDrive(drive, timeout = DRIVE_SYNC_TIMEOUT) {
|
|
1377
|
+
const done = drive.findingPeers()
|
|
1378
|
+
this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed().then(done, done)
|
|
1379
|
+
try {
|
|
1380
|
+
const updated = await Promise.race([
|
|
1381
|
+
drive.update(),
|
|
1382
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Sync timeout')), timeout))
|
|
1383
|
+
])
|
|
1384
|
+
return updated
|
|
1385
|
+
} catch {
|
|
1386
|
+
return false
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
883
1390
|
#getMetadataPath() {
|
|
884
1391
|
return path.join(this.#options.dataPath, 'published-files.json')
|
|
885
1392
|
}
|
|
886
|
-
|
|
1393
|
+
|
|
887
1394
|
#getTrashMetadataPath() {
|
|
888
1395
|
return path.join(this.#options.dataPath, 'trash-files.json')
|
|
889
1396
|
}
|
|
890
1397
|
|
|
1398
|
+
#atomicWrite(filePath, data) {
|
|
1399
|
+
const tmpPath = filePath + '.tmp'
|
|
1400
|
+
fs.writeFileSync(tmpPath, data, 'utf-8')
|
|
1401
|
+
fs.renameSync(tmpPath, filePath)
|
|
1402
|
+
}
|
|
1403
|
+
|
|
891
1404
|
#loadPublishedMetadata() {
|
|
892
1405
|
try {
|
|
893
1406
|
const metadataPath = this.#getMetadataPath()
|
|
894
1407
|
if (fs.existsSync(metadataPath)) {
|
|
895
1408
|
const data = fs.readFileSync(metadataPath, 'utf-8')
|
|
896
1409
|
const parsed = JSON.parse(data)
|
|
897
|
-
// Ensure starred field exists for older data
|
|
898
1410
|
return parsed.map(f => ({ ...f, starred: f.starred || false }))
|
|
899
1411
|
}
|
|
900
1412
|
} catch (err) {
|
|
@@ -906,12 +1418,12 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
906
1418
|
#savePublishedMetadata() {
|
|
907
1419
|
try {
|
|
908
1420
|
const metadataPath = this.#getMetadataPath()
|
|
909
|
-
|
|
1421
|
+
this.#atomicWrite(metadataPath, JSON.stringify(this.#publishedFiles, null, 2))
|
|
910
1422
|
} catch (err) {
|
|
911
1423
|
console.error('Failed to save published metadata:', err.message)
|
|
912
1424
|
}
|
|
913
1425
|
}
|
|
914
|
-
|
|
1426
|
+
|
|
915
1427
|
#loadTrashMetadata() {
|
|
916
1428
|
try {
|
|
917
1429
|
const metadataPath = this.#getTrashMetadataPath()
|
|
@@ -924,32 +1436,126 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
924
1436
|
}
|
|
925
1437
|
return []
|
|
926
1438
|
}
|
|
927
|
-
|
|
1439
|
+
|
|
928
1440
|
#saveTrashMetadata() {
|
|
929
1441
|
try {
|
|
930
1442
|
const metadataPath = this.#getTrashMetadataPath()
|
|
931
|
-
|
|
1443
|
+
this.#atomicWrite(metadataPath, JSON.stringify(this.#trashFiles, null, 2))
|
|
932
1444
|
} catch (err) {
|
|
933
1445
|
console.error('Failed to save trash metadata:', err.message)
|
|
934
1446
|
}
|
|
935
1447
|
}
|
|
936
1448
|
|
|
1449
|
+
#getChannelsMetadataPath() {
|
|
1450
|
+
return path.join(this.#options.dataPath, 'channels.json')
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
#getConfigPath() {
|
|
1454
|
+
return path.join(this.#options.dataPath, 'channel-config.json')
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
#loadChannelsMetadata() {
|
|
1458
|
+
try {
|
|
1459
|
+
const metadataPath = this.#getChannelsMetadataPath()
|
|
1460
|
+
if (fs.existsSync(metadataPath)) {
|
|
1461
|
+
const data = fs.readFileSync(metadataPath, 'utf-8')
|
|
1462
|
+
return JSON.parse(data)
|
|
1463
|
+
}
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
console.warn('Failed to load channels metadata, using empty list:', err.message)
|
|
1466
|
+
}
|
|
1467
|
+
return []
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
#saveChannelsMetadata() {
|
|
1471
|
+
try {
|
|
1472
|
+
const metadataPath = this.#getChannelsMetadataPath()
|
|
1473
|
+
this.#atomicWrite(metadataPath, JSON.stringify(this.#channels, null, 2))
|
|
1474
|
+
} catch (err) {
|
|
1475
|
+
console.error('Failed to save channels metadata:', err.message)
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
#generateChannelDiscoveryKey(name) {
|
|
1480
|
+
const hash = crypto.createHash('sha256')
|
|
1481
|
+
.update(`${CHANNEL_NAME_PREFIX}${name}`)
|
|
1482
|
+
.digest()
|
|
1483
|
+
return hash
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
async #handleChannelConnection(conn) {
|
|
1487
|
+
const stream = conn
|
|
1488
|
+
let connectedPeerId = null
|
|
1489
|
+
let connectedAuthorName = null
|
|
1490
|
+
|
|
1491
|
+
const helloMessage = JSON.stringify({
|
|
1492
|
+
type: 'channel-hello',
|
|
1493
|
+
peerId: this.getNodeId(),
|
|
1494
|
+
authorName: this.getNodeId().slice(0, 4),
|
|
1495
|
+
channels: this.#channels.map(c => c.name)
|
|
1496
|
+
})
|
|
1497
|
+
|
|
1498
|
+
try {
|
|
1499
|
+
stream.write(helloMessage)
|
|
1500
|
+
} catch {
|
|
1501
|
+
return
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
stream.on('data', async (data) => {
|
|
1505
|
+
try {
|
|
1506
|
+
const msg = JSON.parse(data.toString())
|
|
1507
|
+
if (msg.type === 'channel-hello') {
|
|
1508
|
+
connectedPeerId = msg.peerId
|
|
1509
|
+
connectedAuthorName = msg.authorName
|
|
1510
|
+
|
|
1511
|
+
const theirChannels = new Set(msg.channels || [])
|
|
1512
|
+
for (const [name, peers] of this.#channelPeers) {
|
|
1513
|
+
if (theirChannels.has(name)) {
|
|
1514
|
+
peers.set(msg.peerId, {
|
|
1515
|
+
peerId: msg.peerId,
|
|
1516
|
+
authorName: msg.authorName,
|
|
1517
|
+
lastSeen: Date.now()
|
|
1518
|
+
})
|
|
1519
|
+
const core = this.#channelCores.get(name)
|
|
1520
|
+
if (core) {
|
|
1521
|
+
const ns = this.#store.namespace(`channel-${name}`)
|
|
1522
|
+
ns.replicate(conn).catch(() => {})
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
this.emit('channel:peer:online', { peerId: msg.peerId, authorName: msg.authorName })
|
|
1527
|
+
}
|
|
1528
|
+
} catch {}
|
|
1529
|
+
})
|
|
1530
|
+
|
|
1531
|
+
stream.on('close', () => {
|
|
1532
|
+
if (connectedPeerId) {
|
|
1533
|
+
for (const [name, peers] of this.#channelPeers) {
|
|
1534
|
+
if (peers.has(connectedPeerId)) {
|
|
1535
|
+
const peer = peers.get(connectedPeerId)
|
|
1536
|
+
peers.delete(connectedPeerId)
|
|
1537
|
+
this.emit('channel:peer:offline', { peerId: connectedPeerId, authorName: peer?.authorName })
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
})
|
|
1542
|
+
}
|
|
1543
|
+
|
|
937
1544
|
/**
|
|
938
|
-
*
|
|
939
|
-
* @param {Hyperdrive} drive -
|
|
940
|
-
* @param {number} timeout -
|
|
941
|
-
* @param {string} [taskId] -
|
|
942
|
-
* @param {object} [taskState] -
|
|
943
|
-
* @returns {Promise<Array>} -
|
|
1545
|
+
* 等待驱动器内容从对等节点或本地可用
|
|
1546
|
+
* @param {Hyperdrive} drive - 要检查的驱动器
|
|
1547
|
+
* @param {number} timeout - 最大等待时间(毫秒)
|
|
1548
|
+
* @param {string} [taskId] - 用于取消的任务 ID
|
|
1549
|
+
* @param {object} [taskState] - 任务状态对象
|
|
1550
|
+
* @returns {Promise<Array>} - 条目列表
|
|
944
1551
|
*/
|
|
945
1552
|
async #waitForDriveContent(drive, timeout, taskId = null, taskState = null) {
|
|
946
1553
|
const startTime = Date.now()
|
|
947
|
-
const checkInterval =
|
|
1554
|
+
const checkInterval = DOWNLOAD_POLL_INTERVAL
|
|
948
1555
|
let lastPeerCount = 0
|
|
949
1556
|
let lastStatus = ''
|
|
950
1557
|
let bootstrapNodesChecked = false
|
|
951
1558
|
|
|
952
|
-
// First, check if content is already available locally (for self-published files)
|
|
953
1559
|
const localEntries = []
|
|
954
1560
|
try {
|
|
955
1561
|
for await (const entry of drive.list()) {
|
|
@@ -961,36 +1567,32 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
961
1567
|
return localEntries
|
|
962
1568
|
}
|
|
963
1569
|
} catch (err) {
|
|
964
|
-
//
|
|
1570
|
+
// 继续进行节点发现
|
|
965
1571
|
}
|
|
966
1572
|
|
|
967
1573
|
while (Date.now() - startTime < timeout) {
|
|
968
|
-
// Check cancellation
|
|
969
1574
|
if (taskState && taskState.aborted) {
|
|
970
1575
|
throw new Error('Download cancelled')
|
|
971
1576
|
}
|
|
972
1577
|
|
|
973
1578
|
const currentTime = Date.now()
|
|
974
1579
|
const elapsed = Math.round((currentTime - startTime) / 1000)
|
|
975
|
-
|
|
976
|
-
// Check if we have peers
|
|
1580
|
+
|
|
977
1581
|
const currentPeerCount = this.#swarm.connections.size
|
|
978
1582
|
const hasPeers = currentPeerCount > 0
|
|
979
1583
|
|
|
980
|
-
// Log peer count changes
|
|
981
1584
|
if (currentPeerCount !== lastPeerCount) {
|
|
982
1585
|
console.log(`[MostBox] Peer count changed: ${lastPeerCount} -> ${currentPeerCount} (elapsed: ${elapsed}s)`)
|
|
983
1586
|
lastPeerCount = currentPeerCount
|
|
984
1587
|
}
|
|
985
1588
|
|
|
986
|
-
// Try to list entries (works for both local and synced data)
|
|
987
1589
|
const entries = []
|
|
988
1590
|
try {
|
|
989
1591
|
for await (const entry of drive.list()) {
|
|
990
1592
|
entries.push(entry)
|
|
991
1593
|
}
|
|
992
1594
|
} catch (err) {
|
|
993
|
-
//
|
|
1595
|
+
// 驱动器可能尚未就绪
|
|
994
1596
|
}
|
|
995
1597
|
|
|
996
1598
|
if (entries.length > 0) {
|
|
@@ -999,7 +1601,6 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
999
1601
|
return entries
|
|
1000
1602
|
}
|
|
1001
1603
|
|
|
1002
|
-
// Update status based on peer connection
|
|
1003
1604
|
if (hasPeers) {
|
|
1004
1605
|
const newStatus = 'syncing'
|
|
1005
1606
|
if (lastStatus !== newStatus) {
|
|
@@ -1012,12 +1613,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1012
1613
|
this.emit('download:status', { taskId, status: newStatus })
|
|
1013
1614
|
lastStatus = newStatus
|
|
1014
1615
|
}
|
|
1015
|
-
|
|
1016
|
-
// Log progress every 30 seconds
|
|
1616
|
+
|
|
1017
1617
|
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)
|
|
1618
|
+
console.log(`[MostBox] Still waiting for peers... (${elapsed}s elapsed, timeout: ${timeout / 1000}s)`)
|
|
1619
|
+
|
|
1021
1620
|
if (!bootstrapNodesChecked && elapsed >= 60) {
|
|
1022
1621
|
bootstrapNodesChecked = true
|
|
1023
1622
|
console.log(`[MostBox] No peers found after 60s. This may indicate:`)
|
|
@@ -1029,13 +1628,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1029
1628
|
}
|
|
1030
1629
|
}
|
|
1031
1630
|
|
|
1032
|
-
// Wait before next check
|
|
1033
1631
|
await new Promise(resolve => setTimeout(resolve, checkInterval))
|
|
1034
1632
|
}
|
|
1035
1633
|
|
|
1036
|
-
console.log(`[MostBox] Timeout reached after ${timeout/1000}s, making final attempt...`)
|
|
1634
|
+
console.log(`[MostBox] Timeout reached after ${timeout / 1000}s, making final attempt...`)
|
|
1037
1635
|
|
|
1038
|
-
// Final attempt - return whatever we have (might be empty)
|
|
1039
1636
|
const entries = []
|
|
1040
1637
|
try {
|
|
1041
1638
|
for await (const entry of drive.list()) {
|
|
@@ -1044,30 +1641,29 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1044
1641
|
} catch (err) {
|
|
1045
1642
|
console.log(`[MostBox] Final attempt failed: ${err.message}`)
|
|
1046
1643
|
}
|
|
1047
|
-
|
|
1644
|
+
|
|
1048
1645
|
console.log(`[MostBox] Final entry count: ${entries.length}`)
|
|
1049
|
-
|
|
1050
|
-
// Provide detailed error information
|
|
1646
|
+
|
|
1051
1647
|
if (entries.length === 0) {
|
|
1052
1648
|
const peerCount = this.#swarm.connections.size
|
|
1053
1649
|
console.log(`[MostBox] Diagnostic information:`)
|
|
1054
1650
|
console.log(`[MostBox] - Peer count: ${peerCount}`)
|
|
1055
1651
|
console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
|
|
1056
|
-
console.log(`[MostBox] - Timeout: ${timeout/1000}s`)
|
|
1057
|
-
|
|
1652
|
+
console.log(`[MostBox] - Timeout: ${timeout / 1000}s`)
|
|
1653
|
+
|
|
1058
1654
|
if (peerCount === 0) {
|
|
1059
1655
|
console.log(`[MostBox] Suggestion: Check network connectivity and firewall settings`)
|
|
1060
1656
|
} else {
|
|
1061
1657
|
console.log(`[MostBox] Suggestion: Publisher may be offline or file may have been removed`)
|
|
1062
1658
|
}
|
|
1063
1659
|
}
|
|
1064
|
-
|
|
1660
|
+
|
|
1065
1661
|
return entries
|
|
1066
1662
|
}
|
|
1067
1663
|
}
|
|
1068
1664
|
|
|
1069
|
-
//
|
|
1665
|
+
// 重新导出工具函数
|
|
1070
1666
|
export * from './config.js'
|
|
1071
1667
|
export * from './core/cid.js'
|
|
1072
1668
|
export * from './utils/errors.js'
|
|
1073
|
-
export * from './utils/security.js'
|
|
1669
|
+
export * from './utils/security.js'
|