most-box 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +21 -14
  2. package/electron/main.js +72 -8
  3. package/electron/preload.js +6 -0
  4. package/out/404/index.html +2 -2
  5. package/out/404.html +2 -2
  6. package/out/__next.__PAGE__.txt +6 -6
  7. package/out/__next._full.txt +16 -16
  8. package/out/__next._head.txt +3 -3
  9. package/out/__next._index.txt +8 -8
  10. package/out/__next._tree.txt +5 -5
  11. package/out/_next/static/chunks/0.e2avjgna_b2.js +1 -0
  12. package/out/_next/static/chunks/{0ho~log~~-jwp.css → 03h~nhgj0hv3p.css} +1 -1
  13. package/out/_next/static/chunks/07td.jq7xff84.css +1 -0
  14. package/out/_next/static/chunks/{0qou.u2e2dy48.css → 0adx~d-j05c9d.css} +2 -2
  15. package/out/_next/static/chunks/0aq.rc9woa2nz.js +1 -0
  16. package/out/_next/static/chunks/0etes81d_cihn.js +1 -0
  17. package/out/_next/static/chunks/0g_a~e050bgzg.css +1 -0
  18. package/out/_next/static/chunks/{0o6lrkxy4jwag.js → 0gcsdf57gcm6h.js} +1 -1
  19. package/out/_next/static/chunks/0gwian.hp3-92.js +1 -0
  20. package/out/_next/static/chunks/0hpev4am9jpmu.css +1 -0
  21. package/out/_next/static/chunks/0l5_.uqb-uqb8.js +1 -0
  22. package/out/_next/static/chunks/0mex8svsiv-2l.js +1 -0
  23. package/out/_next/static/chunks/0myq9gs8szydh.js +1 -0
  24. package/out/_next/static/chunks/{0usvo~vu7r8np.js → 0o9ce4cyf76by.js} +1 -1
  25. package/out/_next/static/chunks/0p0sv~fuddvgr.js +1 -0
  26. package/out/_next/static/chunks/{0o98f1yq..o.8.js → 0pt.5cg1t09qs.js} +1 -1
  27. package/out/_next/static/chunks/0q0ksgxg98xgd.js +1 -0
  28. package/out/_next/static/chunks/0qgx9t4jx16ua.css +1 -0
  29. package/out/_next/static/chunks/0ukyg~tkm~h2m.css +1 -0
  30. package/out/_next/static/chunks/0wtf0xsiicxx6.js +1 -0
  31. package/out/_next/static/chunks/0xdwau5k2augv.css +4 -0
  32. package/out/_next/static/chunks/12nr19.nnn6s3.js +5 -0
  33. package/out/_next/static/chunks/{0qub_r0x_r-e9.css → 12pep-2t-qg4n.css} +1 -1
  34. package/out/_next/static/chunks/14_inksek_rth.js +2 -0
  35. package/out/_next/static/chunks/153-sz7s.qml2.js +1 -0
  36. package/out/_next/static/chunks/16xls5tt_68lx.js +1 -0
  37. package/out/_next/static/chunks/{turbopack-0xs6mybc~5t_3.js → turbopack-0xta0kqwzkf28.js} +1 -1
  38. package/out/_not-found/__next._full.txt +13 -13
  39. package/out/_not-found/__next._head.txt +3 -3
  40. package/out/_not-found/__next._index.txt +8 -8
  41. package/out/_not-found/__next._not-found.__PAGE__.txt +4 -4
  42. package/out/_not-found/__next._not-found.txt +3 -3
  43. package/out/_not-found/__next._tree.txt +3 -3
  44. package/out/_not-found/index.html +2 -2
  45. package/out/_not-found/index.txt +13 -13
  46. package/out/admin/__next._full.txt +15 -15
  47. package/out/admin/__next._head.txt +3 -3
  48. package/out/admin/__next._index.txt +8 -8
  49. package/out/admin/__next._tree.txt +4 -4
  50. package/out/admin/__next.admin.__PAGE__.txt +4 -4
  51. package/out/admin/__next.admin.txt +4 -4
  52. package/out/admin/index.html +2 -2
  53. package/out/admin/index.txt +15 -15
  54. package/out/app/__next._full.txt +14 -14
  55. package/out/app/__next._head.txt +3 -3
  56. package/out/app/__next._index.txt +8 -8
  57. package/out/app/__next._tree.txt +3 -3
  58. package/out/app/__next.app.__PAGE__.txt +4 -4
  59. package/out/app/__next.app.txt +3 -3
  60. package/out/app/index.html +2 -2
  61. package/out/app/index.txt +14 -14
  62. package/out/chat/__next._full.txt +15 -15
  63. package/out/chat/__next._head.txt +3 -3
  64. package/out/chat/__next._index.txt +8 -8
  65. package/out/chat/__next._tree.txt +4 -4
  66. package/out/chat/__next.chat.__PAGE__.txt +4 -4
  67. package/out/chat/__next.chat.txt +4 -4
  68. package/out/chat/index.html +2 -2
  69. package/out/chat/index.txt +15 -15
  70. package/out/chat/join/__next._full.txt +25 -0
  71. package/out/chat/join/__next._head.txt +5 -0
  72. package/out/{changelog → chat/join}/__next._index.txt +8 -8
  73. package/out/chat/join/__next._tree.txt +5 -0
  74. package/out/chat/join/__next.chat.join.__PAGE__.txt +9 -0
  75. package/out/chat/join/__next.chat.join.txt +5 -0
  76. package/out/chat/join/__next.chat.txt +5 -0
  77. package/out/chat/join/index.html +15 -0
  78. package/out/chat/join/index.txt +25 -0
  79. package/out/download/__next._full.txt +21 -21
  80. package/out/download/__next._head.txt +3 -3
  81. package/out/download/__next._index.txt +8 -8
  82. package/out/download/__next._tree.txt +5 -5
  83. package/out/download/__next.download.__PAGE__.txt +9 -9
  84. package/out/download/__next.download.txt +3 -3
  85. package/out/download/index.html +2 -2
  86. package/out/download/index.txt +21 -21
  87. package/out/index.html +2 -2
  88. package/out/index.txt +16 -16
  89. package/out/note/__next._full.txt +14 -14
  90. package/out/note/__next._head.txt +3 -3
  91. package/out/note/__next._index.txt +8 -8
  92. package/out/note/__next._tree.txt +3 -3
  93. package/out/note/__next.note.__PAGE__.txt +4 -4
  94. package/out/note/__next.note.txt +3 -3
  95. package/out/note/index.html +2 -2
  96. package/out/note/index.txt +14 -14
  97. package/out/ping/__next._full.txt +16 -16
  98. package/out/ping/__next._head.txt +3 -3
  99. package/out/ping/__next._index.txt +8 -8
  100. package/out/ping/__next._tree.txt +5 -5
  101. package/out/ping/__next.ping.__PAGE__.txt +5 -5
  102. package/out/ping/__next.ping.txt +4 -4
  103. package/out/ping/index.html +2 -2
  104. package/out/ping/index.txt +16 -16
  105. package/out/web3/__next._full.txt +15 -15
  106. package/out/web3/__next._head.txt +3 -3
  107. package/out/web3/__next._index.txt +8 -8
  108. package/out/web3/__next._tree.txt +4 -4
  109. package/out/web3/__next.web3.__PAGE__.txt +4 -4
  110. package/out/web3/__next.web3.txt +4 -4
  111. package/out/web3/ed25519/__next._full.txt +13 -13
  112. package/out/web3/ed25519/__next._head.txt +3 -3
  113. package/out/web3/ed25519/__next._index.txt +8 -8
  114. package/out/web3/ed25519/__next._tree.txt +4 -4
  115. package/out/web3/ed25519/__next.web3.ed25519.__PAGE__.txt +2 -2
  116. package/out/web3/ed25519/__next.web3.ed25519.txt +3 -3
  117. package/out/web3/ed25519/__next.web3.txt +4 -4
  118. package/out/web3/ed25519/index.html +1 -1
  119. package/out/web3/ed25519/index.txt +13 -13
  120. package/out/web3/index.html +2 -2
  121. package/out/web3/index.txt +15 -15
  122. package/out/web3/tools/__next._full.txt +13 -13
  123. package/out/web3/tools/__next._head.txt +3 -3
  124. package/out/web3/tools/__next._index.txt +8 -8
  125. package/out/web3/tools/__next._tree.txt +4 -4
  126. package/out/web3/tools/__next.web3.tools.__PAGE__.txt +2 -2
  127. package/out/web3/tools/__next.web3.tools.txt +3 -3
  128. package/out/web3/tools/__next.web3.txt +4 -4
  129. package/out/web3/tools/index.html +1 -1
  130. package/out/web3/tools/index.txt +13 -13
  131. package/package.json +19 -9
  132. package/server/index.js +133 -1303
  133. package/server/src/config.js +1 -1
  134. package/server/src/core/channelAttachment.js +68 -0
  135. package/server/src/core/cid.js +2 -88
  136. package/server/src/core/cidTopic.js +29 -0
  137. package/server/src/core/mostLink.js +88 -0
  138. package/server/src/http/access.js +123 -0
  139. package/server/src/http/app.js +1095 -0
  140. package/server/src/http/errors.js +35 -0
  141. package/server/src/http/nodeLogs.js +53 -0
  142. package/server/src/http/nodeStatus.js +146 -0
  143. package/server/src/http/staticFiles.js +84 -0
  144. package/server/src/http/uploads.js +114 -0
  145. package/server/src/index.js +799 -211
  146. package/server/src/node/config.js +38 -6
  147. package/server/src/utils/api.js +287 -14
  148. package/server/src/utils/auth.js +63 -0
  149. package/server/src/utils/dateTime.js +30 -0
  150. package/server/src/utils/downloadMessages.js +89 -0
  151. package/server/src/utils/errors.js +7 -0
  152. package/server/src/utils/mostWallet.js +151 -0
  153. package/server/src/utils/mp.js +2 -26
  154. package/server/src/utils/noteBackup.js +2 -5
  155. package/server/src/utils/noteUtils.js +11 -3
  156. package/server/src/utils/userIdentity.js +0 -1
  157. package/out/_next/static/chunks/00-u5nq76f0.j.js +0 -1
  158. package/out/_next/static/chunks/00fm8lijienf1.js +0 -1
  159. package/out/_next/static/chunks/00o9ht.f2qm00.css +0 -4
  160. package/out/_next/static/chunks/00zi-erhjrny2.js +0 -2
  161. package/out/_next/static/chunks/084xf0edl9sfo.js +0 -1
  162. package/out/_next/static/chunks/09f1gfke9m5wg.css +0 -1
  163. package/out/_next/static/chunks/09xyi6fpro_d-.css +0 -1
  164. package/out/_next/static/chunks/0_npg_pcoywti.js +0 -5
  165. package/out/_next/static/chunks/0_r_mk1~6bosc.js +0 -1
  166. package/out/_next/static/chunks/0arm0a6adt7cc.css +0 -1
  167. package/out/_next/static/chunks/0c9j3eq_14vv2.css +0 -1
  168. package/out/_next/static/chunks/0d4bueddmcnca.js +0 -1
  169. package/out/_next/static/chunks/0gtwvy1z9ksa7.css +0 -1
  170. package/out/_next/static/chunks/0j27tcmtt4ly7.js +0 -1
  171. package/out/_next/static/chunks/0j3v4mq67wtnh.js +0 -1
  172. package/out/_next/static/chunks/0lkmf5ry.s_7w.js +0 -1
  173. package/out/_next/static/chunks/0p486m03-zfoi.js +0 -1
  174. package/out/_next/static/chunks/0r1~k82nji8sf.js +0 -1
  175. package/out/_next/static/chunks/0v7qp4hv-_._r.js +0 -1
  176. package/out/_next/static/chunks/0wuwlgcn6gxqt.js +0 -1
  177. package/out/_next/static/chunks/0xl5_avhu._i8.js +0 -1
  178. package/out/_next/static/chunks/10kvl8vj_plm-.js +0 -1
  179. package/out/_next/static/chunks/16m27azcs4k6w.js +0 -1
  180. package/out/changelog/__next._full.txt +0 -25
  181. package/out/changelog/__next._head.txt +0 -5
  182. package/out/changelog/__next._tree.txt +0 -5
  183. package/out/changelog/__next.changelog.__PAGE__.txt +0 -10
  184. package/out/changelog/__next.changelog.txt +0 -5
  185. package/out/changelog/index.html +0 -15
  186. package/out/changelog/index.txt +0 -25
  187. package/out/docs/__next._full.txt +0 -25
  188. package/out/docs/__next._head.txt +0 -5
  189. package/out/docs/__next._index.txt +0 -9
  190. package/out/docs/__next._tree.txt +0 -5
  191. package/out/docs/__next.docs.__PAGE__.txt +0 -10
  192. package/out/docs/__next.docs.txt +0 -5
  193. package/out/docs/getting-started/__next._full.txt +0 -25
  194. package/out/docs/getting-started/__next._head.txt +0 -5
  195. package/out/docs/getting-started/__next._index.txt +0 -9
  196. package/out/docs/getting-started/__next._tree.txt +0 -5
  197. package/out/docs/getting-started/__next.docs.getting-started.__PAGE__.txt +0 -10
  198. package/out/docs/getting-started/__next.docs.getting-started.txt +0 -5
  199. package/out/docs/getting-started/__next.docs.txt +0 -5
  200. package/out/docs/getting-started/index.html +0 -15
  201. package/out/docs/getting-started/index.txt +0 -25
  202. package/out/docs/index.html +0 -15
  203. package/out/docs/index.txt +0 -25
  204. package/out/note/edit/__next._full.txt +0 -24
  205. package/out/note/edit/__next._head.txt +0 -5
  206. package/out/note/edit/__next._index.txt +0 -9
  207. package/out/note/edit/__next._tree.txt +0 -4
  208. package/out/note/edit/__next.note.edit.__PAGE__.txt +0 -9
  209. package/out/note/edit/__next.note.edit.txt +0 -5
  210. package/out/note/edit/__next.note.txt +0 -5
  211. package/out/note/edit/index.html +0 -15
  212. package/out/note/edit/index.txt +0 -24
  213. /package/out/_next/static/{sIuUKxnnGU7K9Tu9UDKE8 → t7ZIeQpVvjz4a7-5Tt-VK}/_buildManifest.js +0 -0
  214. /package/out/_next/static/{sIuUKxnnGU7K9Tu9UDKE8 → t7ZIeQpVvjz4a7-5Tt-VK}/_clientMiddlewareManifest.js +0 -0
  215. /package/out/_next/static/{sIuUKxnnGU7K9Tu9UDKE8 → t7ZIeQpVvjz4a7-5Tt-VK}/_ssgManifest.js +0 -0
@@ -0,0 +1,1095 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { Hono } from 'hono'
4
+ import { cors } from 'hono/cors'
5
+ import { parseMostLink, validateCidString } from '../core/cid.js'
6
+ import { sanitizeFilename } from '../utils/security.js'
7
+ import { normalizeAddress, verifyAuthHeader } from '../utils/auth.js'
8
+ import {
9
+ DEFAULT_NODE_HOST,
10
+ DEFAULT_NODE_PORT,
11
+ createNodeConfigStore,
12
+ evaluateStorageLimits,
13
+ normalizeRemoteInvites,
14
+ } from '../node/config.js'
15
+ import { createNodeLogger } from '../node/logs.js'
16
+ import {
17
+ getAllowedOrigins,
18
+ getInvalidInviteResponse,
19
+ getRequestPath,
20
+ hasValidInvite,
21
+ isLocalRequest,
22
+ isLocalUpgradeRequest,
23
+ isLoopbackRemoteAddress,
24
+ isPublicListenHost,
25
+ isRemoteAccessRequest,
26
+ remoteInviteConfigured,
27
+ } from './access.js'
28
+ import { badRequestOrAppError, errorJson } from './errors.js'
29
+ import { listFilteredNodeLogs } from './nodeLogs.js'
30
+ import {
31
+ buildNodeStatus,
32
+ buildOpenApiSpec,
33
+ getNetworkAddresses,
34
+ getPackageVersion,
35
+ } from './nodeStatus.js'
36
+ import { parseMultipartBusboy } from './uploads.js'
37
+ import { getMimeType, registerStaticRoutes } from './staticFiles.js'
38
+
39
+ const RATE_LIMIT_WINDOW = 60 * 1000
40
+ const RATE_LIMIT_MAX_REQUESTS = 120
41
+ export { UPLOAD_TMP_DIR } from './uploads.js'
42
+
43
+ // --- 配置 ---
44
+ const defaultConfigStore = createNodeConfigStore()
45
+ const CONFIG_DIR = defaultConfigStore.configDir
46
+ const PORT = DEFAULT_NODE_PORT
47
+ const HOST = DEFAULT_NODE_HOST
48
+
49
+
50
+
51
+ export function getDataPath(configStore = defaultConfigStore) {
52
+ return configStore.getDataPath()
53
+ }
54
+
55
+ function resolveDataPathForSave(inputPath) {
56
+ let dataPath = String(inputPath || '').trim()
57
+ let basePath = dataPath
58
+
59
+ if (!dataPath) {
60
+ return { dataPath: '' }
61
+ }
62
+
63
+ if (dataPath.match(/^[A-Za-z]:\\$/)) {
64
+ basePath = dataPath
65
+ dataPath = path.join(dataPath, 'most-data')
66
+ }
67
+
68
+ if (!fs.existsSync(basePath)) {
69
+ return { error: '目录不存在' }
70
+ }
71
+
72
+ if (!fs.existsSync(dataPath)) {
73
+ fs.mkdirSync(dataPath, { recursive: true })
74
+ }
75
+
76
+ return { dataPath }
77
+ }
78
+
79
+
80
+
81
+ // --- Hono 应用工厂 ---
82
+ export function createApp(engine, options = {}) {
83
+ const appPort = options.port || PORT
84
+ const appHost = options.host || HOST
85
+ const configStore = options.configStore || defaultConfigStore
86
+ const nodeLogger =
87
+ options.nodeLogger || createNodeLogger(configStore.configDir || CONFIG_DIR)
88
+ const wssRef = options.wssRef || { current: null }
89
+ const serverInstanceRef = options.serverInstanceRef || { current: null }
90
+ function getRemoteInviteSet() {
91
+ const invites =
92
+ options.remoteInvites === undefined
93
+ ? configStore.getNodeConfig().remoteInvites
94
+ : normalizeRemoteInvites(options.remoteInvites)
95
+ return new Set(invites)
96
+ }
97
+
98
+ // 速率限制(每个 app 实例独立)
99
+ const rateLimitMap = new Map()
100
+ function checkRateLimit(clientIp) {
101
+ const now = Date.now()
102
+ if (!rateLimitMap.has(clientIp)) {
103
+ rateLimitMap.set(clientIp, [])
104
+ }
105
+ const requests = rateLimitMap.get(clientIp)
106
+ while (requests.length > 0 && requests[0] < now - RATE_LIMIT_WINDOW) {
107
+ requests.shift()
108
+ }
109
+ if (requests.length === 0) {
110
+ rateLimitMap.delete(clientIp)
111
+ }
112
+ if (requests.length >= RATE_LIMIT_MAX_REQUESTS) {
113
+ return false
114
+ }
115
+ requests.push(now)
116
+ return true
117
+ }
118
+
119
+ function rateLimitMiddleware() {
120
+ return async (c, next) => {
121
+ const clientIp =
122
+ c.req.header('x-forwarded-for') ||
123
+ c.env?.incoming?.socket?.remoteAddress ||
124
+ 'unknown'
125
+ if (!checkRateLimit(clientIp)) {
126
+ return c.json({ error: 'Too many requests' }, 429)
127
+ }
128
+ await next()
129
+ }
130
+ }
131
+
132
+ function isValidInvite(c) {
133
+ const invite = String(c.req.header('x-mostbox-invite') || '').trim()
134
+ return hasValidInvite(getRemoteInviteSet(), invite)
135
+ }
136
+
137
+ function isRemoteRequest(c) {
138
+ return isRemoteAccessRequest({
139
+ invite: c.req.header('x-mostbox-invite'),
140
+ origin: c.req.header('origin'),
141
+ listenHost: appHost,
142
+ local: isLocalRequest(c),
143
+ })
144
+ }
145
+
146
+ function isPublicFileDownloadPath(path) {
147
+ return /^\/api\/files\/[^/]+\/download$/.test(path)
148
+ }
149
+
150
+ function requiresUserAuth(path) {
151
+ if (isPublicFileDownloadPath(path)) {
152
+ return false
153
+ }
154
+
155
+ return (
156
+ path === '/api/files' ||
157
+ path === '/api/publish' ||
158
+ path === '/api/download/check' ||
159
+ path === '/api/download' ||
160
+ path === '/api/download/cancel' ||
161
+ path === '/api/trash' ||
162
+ path === '/api/move' ||
163
+ path === '/api/folder/rename' ||
164
+ path.startsWith('/api/files/') ||
165
+ path.startsWith('/api/trash/') ||
166
+ path.startsWith('/api/channels')
167
+ )
168
+ }
169
+
170
+ function isAdminApi(path) {
171
+ return (
172
+ path.startsWith('/api/admin/') ||
173
+ path === '/api/node/config' ||
174
+ path === '/api/node/policy' ||
175
+ path === '/api/node/logs' ||
176
+ path === '/api/shutdown'
177
+ )
178
+ }
179
+
180
+ function authMiddleware() {
181
+ return async (c, next) => {
182
+ const path = getRequestPath(c)
183
+
184
+ if (isRemoteRequest(c) && !isValidInvite(c)) {
185
+ return getInvalidInviteResponse(c)
186
+ }
187
+
188
+ if (isRemoteRequest(c) && isAdminApi(path)) {
189
+ return c.json(
190
+ {
191
+ error: 'Remote users cannot access node administration',
192
+ code: 'REMOTE_ADMIN_FORBIDDEN',
193
+ },
194
+ 403
195
+ )
196
+ }
197
+
198
+ const authHeader = c.req.header('authorization')
199
+ if (authHeader) {
200
+ if (isPublicFileDownloadPath(path)) {
201
+ await next()
202
+ return
203
+ }
204
+
205
+ const auth = verifyAuthHeader(authHeader, c.req.method, path)
206
+ if (!auth.ok) {
207
+ return c.json({ error: auth.error, code: 'UNAUTHORIZED' }, 401)
208
+ }
209
+ c.set('userAddress', auth.address)
210
+ }
211
+
212
+ if (requiresUserAuth(path) && !c.get('userAddress')) {
213
+ return c.json({ error: 'Login required', code: 'LOGIN_REQUIRED' }, 401)
214
+ }
215
+
216
+ await next()
217
+ }
218
+ }
219
+
220
+ // WebSocket 广播
221
+ const channelSubscriptions = new Map()
222
+
223
+ function wsBroadcast(event, data) {
224
+ const payload = JSON.stringify({ event, data })
225
+ const wss = wssRef.current
226
+ if (wss) {
227
+ wss.clients.forEach(client => {
228
+ if (client.readyState === 1) {
229
+ try {
230
+ client.send(payload)
231
+ } catch (err) {
232
+ console.warn('[WS] Failed to send to client:', err.message)
233
+ }
234
+ }
235
+ })
236
+ }
237
+ }
238
+
239
+ async function broadcastNodeStatus() {
240
+ try {
241
+ const status = await buildNodeStatus(engine, configStore, appPort)
242
+ wsBroadcast('node:status', status)
243
+ return status
244
+ } catch (err) {
245
+ const entry = nodeLogger.append({
246
+ level: 'error',
247
+ event: 'node:status:error',
248
+ message: err.message,
249
+ })
250
+ wsBroadcast('node:log', entry)
251
+ return null
252
+ }
253
+ }
254
+
255
+ function appendNodeLog(input) {
256
+ const entry = nodeLogger.append(input)
257
+ wsBroadcast('node:log', entry)
258
+ return entry
259
+ }
260
+
261
+ function wsSendToChannel(channelName, event, data) {
262
+ const payload = JSON.stringify({ event, data })
263
+ const subscribers = channelSubscriptions.get(channelName)
264
+ if (subscribers) {
265
+ subscribers.forEach(ws => {
266
+ if (ws.readyState === 1) {
267
+ try {
268
+ ws.send(payload)
269
+ } catch (err) {
270
+ console.warn(
271
+ '[WS] Failed to send to channel subscriber:',
272
+ err.message
273
+ )
274
+ }
275
+ }
276
+ })
277
+ }
278
+ }
279
+
280
+ function subscribeToChannel(ws, channelName) {
281
+ if (!channelSubscriptions.has(channelName)) {
282
+ channelSubscriptions.set(channelName, new Set())
283
+ }
284
+ channelSubscriptions.get(channelName).add(ws)
285
+ }
286
+
287
+ function unsubscribeFromChannel(ws, channelName) {
288
+ const subscribers = channelSubscriptions.get(channelName)
289
+ if (subscribers) {
290
+ subscribers.delete(ws)
291
+ if (subscribers.size === 0) {
292
+ channelSubscriptions.delete(channelName)
293
+ }
294
+ }
295
+ }
296
+
297
+ function cleanupWsSubscriptions(ws) {
298
+ for (const [channel, subscribers] of channelSubscriptions) {
299
+ subscribers.delete(ws)
300
+ if (subscribers.size === 0) {
301
+ channelSubscriptions.delete(channel)
302
+ }
303
+ }
304
+ }
305
+
306
+ function validateWebSocketRequest(req) {
307
+ const url = new URL(req.url, `http://localhost:${appPort}`)
308
+ const invite = String(url.searchParams.get('invite') || '').trim()
309
+ const remote = isRemoteAccessRequest({
310
+ invite,
311
+ origin: req.headers.origin,
312
+ listenHost: appHost,
313
+ local: isLocalUpgradeRequest(req),
314
+ })
315
+ if (!remote) return true
316
+
317
+ const wsInviteSet = new Set(configStore.getNodeConfig().remoteInvites)
318
+ if (!hasValidInvite(wsInviteSet, invite)) {
319
+ return false
320
+ }
321
+
322
+ const address = url.searchParams.get('address') || ''
323
+ const timestamp = url.searchParams.get('timestamp') || ''
324
+ const signature = url.searchParams.get('signature') || ''
325
+ const auth = verifyAuthHeader(
326
+ `${address},${timestamp},${signature}`,
327
+ 'GET',
328
+ '/ws'
329
+ )
330
+ return auth.ok
331
+ }
332
+
333
+ // 将广播函数挂载到 engine 上供外部测试使用
334
+ engine.wsBroadcast = wsBroadcast
335
+ engine.wsSendToChannel = wsSendToChannel
336
+
337
+ const app = new Hono()
338
+
339
+ // CORS 中间件
340
+ app.use(
341
+ '/api/*',
342
+ cors({
343
+ origin: getAllowedOrigins(appPort),
344
+ credentials: true,
345
+ })
346
+ )
347
+
348
+ // 速率限制中间件
349
+ app.use('/api/*', rateLimitMiddleware())
350
+ app.use('/api/*', authMiddleware())
351
+
352
+ // 全局错误处理
353
+ app.onError((err, c) => {
354
+ console.error('[API Error]', err)
355
+ try {
356
+ const errorLogDir = configStore.configDir || CONFIG_DIR
357
+ const errorLogPath = path.join(errorLogDir, 'server-error.log')
358
+ if (!fs.existsSync(errorLogDir)) {
359
+ fs.mkdirSync(errorLogDir, { recursive: true })
360
+ }
361
+ fs.appendFileSync(
362
+ errorLogPath,
363
+ `[${new Date().toISOString()}] ${err.stack}\n`
364
+ )
365
+ } catch {}
366
+ return c.json({ error: err.message, code: err.code }, 500)
367
+ })
368
+
369
+ // --- 配置路由 ---
370
+ app.get('/api/node-id', c => {
371
+ return c.json({ id: engine.getNodeId() })
372
+ })
373
+
374
+ app.get('/api/remote/capabilities', c => {
375
+ const remoteInviteSet = getRemoteInviteSet()
376
+ return c.json({
377
+ remoteAccess:
378
+ isPublicListenHost(appHost) && remoteInviteConfigured(remoteInviteSet),
379
+ inviteRequired: true,
380
+ inviteConfigured: remoteInviteConfigured(remoteInviteSet),
381
+ authenticated: Boolean(c.get('userAddress')),
382
+ userAddress: c.get('userAddress') || null,
383
+ adminAvailable: !isRemoteRequest(c),
384
+ listenHost: appHost,
385
+ })
386
+ })
387
+
388
+ app.get('/api/config', c => {
389
+ const config = configStore.loadRawConfig()
390
+ return c.json({ dataPath: config.dataPath || '' })
391
+ })
392
+
393
+ app.post('/api/config', async c => {
394
+ const body = await c.req.json()
395
+ const patch = {}
396
+
397
+ if (body.resetStorage) {
398
+ patch.dataPath = ''
399
+ } else if (body.dataPath !== undefined) {
400
+ const resolved = resolveDataPathForSave(body.dataPath)
401
+ if (resolved.error) return c.json({ error: resolved.error }, 400)
402
+ patch.dataPath = resolved.dataPath
403
+ }
404
+
405
+ const { success } = configStore.saveNodeConfigPatch(patch)
406
+ appendNodeLog({
407
+ event: 'node:config:updated',
408
+ message: 'Node config updated',
409
+ data: { dataPath: getDataPath(configStore) },
410
+ })
411
+ await broadcastNodeStatus()
412
+ return c.json({ success, dataPath: getDataPath(configStore) })
413
+ })
414
+
415
+ app.get('/api/config/data-path', c => {
416
+ const config = configStore.getNodeConfig()
417
+ const isDefault = !config.dataPath
418
+ const dataPath = getDataPath(configStore)
419
+ return c.json({ dataPath, isDefault })
420
+ })
421
+
422
+ app.get('/api/node/status', async c => {
423
+ try {
424
+ return c.json(await buildNodeStatus(engine, configStore, appPort))
425
+ } catch (err) {
426
+ return errorJson(c, err)
427
+ }
428
+ })
429
+
430
+ app.get('/api/node/config', c => {
431
+ const config = configStore.getNodeConfig()
432
+ return c.json({
433
+ ...config,
434
+ dataPath: getDataPath(configStore),
435
+ configuredDataPath: config.dataPath,
436
+ isDefaultDataPath: !config.dataPath,
437
+ currentHost: appHost,
438
+ currentPort: appPort,
439
+ remoteInvites: config.remoteInvites,
440
+ })
441
+ })
442
+
443
+ app.post('/api/node/config', async c => {
444
+ const body = await c.req.json()
445
+ const patch = { ...body }
446
+
447
+ if (body.resetStorage) {
448
+ patch.dataPath = ''
449
+ } else if (body.dataPath !== undefined) {
450
+ const resolved = resolveDataPathForSave(body.dataPath)
451
+ if (resolved.error) return c.json({ error: resolved.error }, 400)
452
+ patch.dataPath = resolved.dataPath
453
+ }
454
+
455
+ const { success, config } = configStore.saveNodeConfigPatch(patch)
456
+ engine.setMaxFileSize(config.maxFileSizeBytes)
457
+ appendNodeLog({
458
+ event: 'node:config:updated',
459
+ message: 'Node daemon config updated',
460
+ data: {
461
+ dataPath: getDataPath(configStore),
462
+ port: config.port,
463
+ capacityBytes: config.capacityBytes,
464
+ remoteInviteCount: config.remoteInvites.length,
465
+ },
466
+ })
467
+ await broadcastNodeStatus()
468
+ return c.json({ success, ...config, dataPath: getDataPath(configStore) })
469
+ })
470
+
471
+ app.get('/api/node/policy', c => {
472
+ const config = configStore.getNodeConfig()
473
+ return c.json({
474
+ maxFileSizeBytes: config.maxFileSizeBytes,
475
+ })
476
+ })
477
+
478
+ app.post('/api/node/policy', async c => {
479
+ const body = await c.req.json()
480
+ const { success, config } = configStore.saveNodeConfigPatch({
481
+ maxFileSizeBytes: body.maxFileSizeBytes,
482
+ })
483
+ engine.setMaxFileSize(config.maxFileSizeBytes)
484
+ const policy = {
485
+ maxFileSizeBytes: config.maxFileSizeBytes,
486
+ }
487
+ appendNodeLog({
488
+ event: 'node:policy:updated',
489
+ message: 'Node storage limits updated',
490
+ data: policy,
491
+ })
492
+ await broadcastNodeStatus()
493
+ return c.json({ success, ...policy })
494
+ })
495
+
496
+ app.post('/api/node/policy/evaluate', async c => {
497
+ const body = await c.req.json()
498
+ const decision = evaluateStorageLimits(configStore.getNodeConfig(), body)
499
+ return c.json(decision)
500
+ })
501
+
502
+ app.get('/api/node/logs', c => {
503
+ const limit = Number(c.req.query('limit') || 100)
504
+ const filter = c.req.query('filter') || 'all'
505
+ const query = c.req.query('q') || ''
506
+ const result = listFilteredNodeLogs(nodeLogger, { limit, filter, query })
507
+ return c.json({
508
+ logFile: nodeLogger.logFile,
509
+ filter: result.filter,
510
+ query: result.query,
511
+ logs: result.logs,
512
+ })
513
+ })
514
+
515
+ app.delete('/api/node/logs', c => {
516
+ const success = nodeLogger.clear()
517
+ const clearedAt = new Date().toISOString()
518
+ wsBroadcast('node:logs:cleared', { clearedAt })
519
+ return c.json({ success, clearedAt })
520
+ })
521
+
522
+ app.get('/api/node/diagnostics', async c => {
523
+ try {
524
+ const status = await buildNodeStatus(engine, configStore, appPort)
525
+ return c.json({
526
+ generatedAt: new Date().toISOString(),
527
+ packageVersion: getPackageVersion(),
528
+ platform: process.platform,
529
+ nodeVersion: process.version,
530
+ status,
531
+ logFile: nodeLogger.logFile,
532
+ logs: nodeLogger.list(200),
533
+ })
534
+ } catch (err) {
535
+ return errorJson(c, err)
536
+ }
537
+ })
538
+
539
+ app.get('/api/admin/users', c => {
540
+ return c.json({ users: engine.listUsers() })
541
+ })
542
+
543
+ app.delete('/api/admin/users/:address/data', async c => {
544
+ const address = normalizeAddress(c.req.param('address'))
545
+ if (!address) {
546
+ return c.json({ error: 'valid address is required' }, 400)
547
+ }
548
+ try {
549
+ const result = await engine.clearUserData(address)
550
+ appendNodeLog({
551
+ event: 'node:user-data:cleared',
552
+ message: 'User data cleared',
553
+ data: result,
554
+ })
555
+ await broadcastNodeStatus()
556
+ return c.json({ success: true, ...result })
557
+ } catch (err) {
558
+ return errorJson(c, err)
559
+ }
560
+ })
561
+
562
+ app.get('/api/openapi.json', c => {
563
+ return c.json(buildOpenApiSpec(appPort))
564
+ })
565
+
566
+ // --- 网络路由 ---
567
+ app.get('/api/network-status', c => {
568
+ return c.json(engine.getNetworkStatus())
569
+ })
570
+
571
+ app.get('/api/network', c => {
572
+ return c.json(getNetworkAddresses(appPort))
573
+ })
574
+
575
+ // --- 节点保种路由 ---
576
+ app.get('/api/node/holdings', c => {
577
+ try {
578
+ return c.json(engine.listHoldings())
579
+ } catch (err) {
580
+ return errorJson(c, err)
581
+ }
582
+ })
583
+
584
+ app.post('/api/node/holdings', async c => {
585
+ try {
586
+ const body = await c.req.json()
587
+ const holding = await engine.addHolding(body)
588
+ appendNodeLog({
589
+ event: 'node:holding:added',
590
+ message: 'Node holding added',
591
+ data: { cid: holding.cid, size: holding.size },
592
+ })
593
+ await broadcastNodeStatus()
594
+ return c.json({ success: true, holding })
595
+ } catch (err) {
596
+ return errorJson(c, err)
597
+ }
598
+ })
599
+
600
+ app.post('/api/p2p/pull', async c => {
601
+ try {
602
+ const body = await c.req.json()
603
+ const timeout =
604
+ body.timeout === undefined ? undefined : Number(body.timeout)
605
+ const result = await engine.pullByCid({
606
+ ...body,
607
+ timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
608
+ })
609
+ appendNodeLog({
610
+ event: 'node:pull:success',
611
+ message: 'P2P pull completed',
612
+ data: { cid: result.cid, taskId: result.taskId },
613
+ })
614
+ await broadcastNodeStatus()
615
+ return c.json({ success: true, ...result })
616
+ } catch (err) {
617
+ appendNodeLog({
618
+ level: 'error',
619
+ event: 'node:pull:error',
620
+ message: err.message,
621
+ data: { code: err.code || 'UNKNOWN' },
622
+ })
623
+ return errorJson(c, err)
624
+ }
625
+ })
626
+
627
+ // --- 文件路由 ---
628
+ app.get('/api/files', c => {
629
+ return c.json(
630
+ engine.listPublishedFiles({ ownerAddress: c.get('userAddress') })
631
+ )
632
+ })
633
+
634
+ app.post('/api/publish', async c => {
635
+ const req = c.env.incoming
636
+ const result = await parseMultipartBusboy(
637
+ req,
638
+ configStore.getNodeConfig().maxFileSizeBytes
639
+ )
640
+
641
+ if (!result || !result.filename) {
642
+ return c.json({ error: 'No file provided' }, 400)
643
+ }
644
+
645
+ try {
646
+ const publishResult = await engine.publishFile(
647
+ result.filePath,
648
+ result.filename,
649
+ { localPath: null, ownerAddress: c.get('userAddress') }
650
+ )
651
+ return c.json({ success: true, ...publishResult })
652
+ } finally {
653
+ fs.unlink(result.filePath, () => {})
654
+ }
655
+ })
656
+
657
+ app.post('/api/download/check', async c => {
658
+ const body = await c.req.json()
659
+ if (!body.link) {
660
+ return c.json({ error: 'link is required' }, 400)
661
+ }
662
+
663
+ const parsed = parseMostLink(body.link)
664
+ if (parsed.error) {
665
+ return c.json({ error: parsed.error }, 400)
666
+ }
667
+
668
+ const existingFile = engine
669
+ .getPublishedFiles({ ownerAddress: c.get('userAddress') })
670
+ .find(f => f.cid === parsed.cid)
671
+ if (existingFile) {
672
+ return c.json({
673
+ success: true,
674
+ available: true,
675
+ cid: parsed.cid,
676
+ fileName: existingFile.fileName,
677
+ size: Number(existingFile.size) || null,
678
+ alreadyExists: true,
679
+ })
680
+ }
681
+
682
+ if (engine.hasDownloadNameConflict(parsed.fileName)) {
683
+ return c.json(
684
+ {
685
+ error: `已有同名文件: ${parsed.fileName}`,
686
+ code: 'CONFLICT',
687
+ },
688
+ 409
689
+ )
690
+ }
691
+
692
+ try {
693
+ const result = await engine.checkDownloadAvailability(body.link, {
694
+ ownerAddress: c.get('userAddress'),
695
+ })
696
+ return c.json({ success: true, ...result })
697
+ } catch (err) {
698
+ return errorJson(c, err)
699
+ }
700
+ })
701
+
702
+ app.post('/api/download', async c => {
703
+ const body = await c.req.json()
704
+ if (!body.link) {
705
+ return c.json({ error: 'link is required' }, 400)
706
+ }
707
+
708
+ const taskId = `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
709
+
710
+ const parsed = parseMostLink(body.link)
711
+ if (parsed.error) {
712
+ return c.json({ error: parsed.error }, 400)
713
+ }
714
+
715
+ const existingFile = engine
716
+ .getPublishedFiles({ ownerAddress: c.get('userAddress') })
717
+ .find(f => f.cid === parsed.cid)
718
+ if (existingFile) {
719
+ console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
720
+ try {
721
+ const result = await engine.downloadFile(body.link, taskId, {
722
+ ownerAddress: c.get('userAddress'),
723
+ })
724
+ return c.json({ success: true, ...result })
725
+ } catch (err) {
726
+ return errorJson(c, err)
727
+ }
728
+ }
729
+
730
+ if (engine.hasDownloadNameConflict(parsed.fileName)) {
731
+ return c.json(
732
+ {
733
+ error: `已有同名文件: ${parsed.fileName}`,
734
+ code: 'CONFLICT',
735
+ },
736
+ 409
737
+ )
738
+ }
739
+
740
+ engine
741
+ .downloadFile(body.link, taskId, { ownerAddress: c.get('userAddress') })
742
+ .catch(err => {
743
+ if (err.message === 'Download cancelled') {
744
+ wsBroadcast('download:cancelled', { taskId })
745
+ } else {
746
+ wsBroadcast('download:error', { taskId, error: err.message })
747
+ }
748
+ })
749
+
750
+ return c.json({ success: true, taskId })
751
+ })
752
+
753
+ app.post('/api/download/cancel', async c => {
754
+ const body = await c.req.json()
755
+ if (!body.taskId) {
756
+ return c.json({ error: 'taskId is required' }, 400)
757
+ }
758
+ engine.cancelDownload(body.taskId)
759
+ return c.json({ success: true })
760
+ })
761
+
762
+ app.delete('/api/files/:cid', async c => {
763
+ const cid = c.req.param('cid')
764
+ const cidValidation = validateCidString(cid)
765
+ if (!cidValidation.valid) {
766
+ return c.json({ error: cidValidation.error }, 400)
767
+ }
768
+ const result = await engine.deletePublishedFile(cid, {
769
+ ownerAddress: c.get('userAddress'),
770
+ })
771
+ return c.json(result)
772
+ })
773
+
774
+ app.post('/api/move', async c => {
775
+ const body = await c.req.json()
776
+ if (!body.cid || !body.newFileName) {
777
+ return c.json({ error: 'cid and newFileName are required' }, 400)
778
+ }
779
+ const cidValidation = validateCidString(body.cid)
780
+ if (!cidValidation.valid) {
781
+ return c.json({ error: cidValidation.error }, 400)
782
+ }
783
+ const cleanFileName = sanitizeFilename(body.newFileName)
784
+ if (
785
+ !cleanFileName ||
786
+ cleanFileName === 'unnamed' ||
787
+ body.newFileName.length > 255
788
+ ) {
789
+ return c.json({ error: 'Invalid filename' }, 400)
790
+ }
791
+ try {
792
+ const result = engine.moveFile(body.cid, cleanFileName, {
793
+ ownerAddress: c.get('userAddress'),
794
+ })
795
+ return c.json({ success: true, ...result })
796
+ } catch (err) {
797
+ return c.json({ error: err.message }, 400)
798
+ }
799
+ })
800
+
801
+ app.get('/api/files/:cid/download', async c => {
802
+ const cid = c.req.param('cid')
803
+ const cidValidation = validateCidString(cid)
804
+ if (!cidValidation.valid) {
805
+ return c.json({ error: cidValidation.error }, 400)
806
+ }
807
+
808
+ const rangeHeader = c.req.header('range')
809
+
810
+ try {
811
+ if (rangeHeader) {
812
+ const rangeMatch = rangeHeader.match(/bytes=(\d+)-(\d*)/)
813
+ if (rangeMatch) {
814
+ const start = parseInt(rangeMatch[1], 10)
815
+ const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : undefined
816
+ const offset = start
817
+ const limit = end !== undefined ? end - start + 1 : undefined
818
+
819
+ const result = await engine.readFileRaw(cid, {
820
+ offset,
821
+ limit,
822
+ public: true,
823
+ })
824
+ const contentType = getMimeType(result.fileName)
825
+
826
+ c.header('Content-Type', contentType)
827
+ c.header('Content-Length', String(result.buffer.length))
828
+ c.header(
829
+ 'Content-Range',
830
+ `bytes ${offset}-${offset + result.buffer.length - 1}/${result.totalSize}`
831
+ )
832
+ c.header('Accept-Ranges', 'bytes')
833
+ c.status(206)
834
+ return c.body(result.buffer)
835
+ }
836
+ }
837
+
838
+ const result = await engine.readFileRaw(cid, {
839
+ public: true,
840
+ })
841
+ const contentType = getMimeType(result.fileName)
842
+ c.header('Content-Type', contentType)
843
+ c.header('Content-Length', String(result.totalSize))
844
+ c.header('Accept-Ranges', 'bytes')
845
+ c.header(
846
+ 'Content-Disposition',
847
+ `inline; filename="${encodeURIComponent(result.fileName)}"`
848
+ )
849
+ return c.body(result.buffer)
850
+ } catch (err) {
851
+ if (err.message === 'File not found') {
852
+ return c.json({ error: err.message }, 404)
853
+ }
854
+ return c.json({ error: err.message }, 400)
855
+ }
856
+ })
857
+
858
+ // --- 回收站路由 ---
859
+ app.get('/api/trash', c => {
860
+ return c.json(engine.listTrashFiles({ ownerAddress: c.get('userAddress') }))
861
+ })
862
+
863
+ app.post('/api/trash/:cid/restore', async c => {
864
+ const cid = c.req.param('cid')
865
+ const cidValidation = validateCidString(cid)
866
+ if (!cidValidation.valid) {
867
+ return c.json({ error: cidValidation.error }, 400)
868
+ }
869
+ try {
870
+ const result = await engine.restoreTrashFile(cid, {
871
+ ownerAddress: c.get('userAddress'),
872
+ })
873
+ return c.json({ success: true, files: result })
874
+ } catch (err) {
875
+ return c.json({ error: err.message }, 400)
876
+ }
877
+ })
878
+
879
+ app.delete('/api/trash/:cid', async c => {
880
+ const cid = c.req.param('cid')
881
+ const cidValidation = validateCidString(cid)
882
+ if (!cidValidation.valid) {
883
+ return c.json({ error: cidValidation.error }, 400)
884
+ }
885
+ const result = await engine.permanentDeleteTrashFile(cid, {
886
+ ownerAddress: c.get('userAddress'),
887
+ })
888
+ return c.json({ success: true, trashFiles: result })
889
+ })
890
+
891
+ app.delete('/api/trash', async c => {
892
+ const result = await engine.emptyTrash({
893
+ ownerAddress: c.get('userAddress'),
894
+ })
895
+ return c.json({ success: true, trashFiles: result })
896
+ })
897
+
898
+ // --- 收藏路由 ---
899
+ app.post('/api/files/:cid/star', async c => {
900
+ const cid = c.req.param('cid')
901
+ const cidValidation = validateCidString(cid)
902
+ if (!cidValidation.valid) {
903
+ return c.json({ error: cidValidation.error }, 400)
904
+ }
905
+ try {
906
+ const result = engine.toggleStarred(cid, {
907
+ ownerAddress: c.get('userAddress'),
908
+ })
909
+ return c.json({ success: true, ...result })
910
+ } catch (err) {
911
+ return c.json({ error: err.message }, 400)
912
+ }
913
+ })
914
+
915
+ // --- 显示名路由 ---
916
+ app.get('/api/display-name', c => {
917
+ return c.json({ displayName: engine.getDisplayName() })
918
+ })
919
+
920
+ app.post('/api/display-name', async c => {
921
+ const body = await c.req.json()
922
+ if (!body.name || !body.name.trim()) {
923
+ return c.json({ error: 'name is required' }, 400)
924
+ }
925
+ const trimmed = body.name.trim()
926
+ if (trimmed.length > 100) {
927
+ return c.json({ error: 'Name too long (max 100 chars)' }, 400)
928
+ }
929
+ if (/[<>]/.test(trimmed)) {
930
+ return c.json({ error: 'Name contains invalid characters' }, 400)
931
+ }
932
+ const success = engine.setDisplayName(trimmed)
933
+ return c.json({ success, displayName: engine.getDisplayName() })
934
+ })
935
+
936
+ // --- 频道路由 ---
937
+ app.post('/api/channels', async c => {
938
+ const body = await c.req.json()
939
+ if (!body.name || !body.name.trim()) {
940
+ return c.json({ error: 'name is required' }, 400)
941
+ }
942
+ try {
943
+ const result = await engine.createChannel(
944
+ body.name.trim(),
945
+ body.type || 'personal',
946
+ { ownerAddress: c.get('userAddress') }
947
+ )
948
+ return c.json({ success: true, ...result })
949
+ } catch (err) {
950
+ return c.json({ error: err.message }, 400)
951
+ }
952
+ })
953
+
954
+ app.get('/api/channels', c => {
955
+ return c.json(engine.listChannels({ ownerAddress: c.get('userAddress') }))
956
+ })
957
+
958
+ app.delete('/api/channels/:name', async c => {
959
+ const name = c.req.param('name')
960
+ try {
961
+ const result = await engine.leaveChannel(name, {
962
+ ownerAddress: c.get('userAddress'),
963
+ })
964
+ return c.json({ success: true, channels: result })
965
+ } catch (err) {
966
+ return c.json({ error: err.message }, 400)
967
+ }
968
+ })
969
+
970
+ app.get('/api/channels/:name/messages', async c => {
971
+ const name = c.req.param('name')
972
+ const limit = parseInt(c.req.query('limit') || '100', 10)
973
+ const offset = parseInt(c.req.query('offset') || '0', 10)
974
+ try {
975
+ const messages = await engine.getChannelMessages(name, {
976
+ limit,
977
+ offset,
978
+ ownerAddress: c.get('userAddress'),
979
+ })
980
+ return c.json(messages)
981
+ } catch (err) {
982
+ return badRequestOrAppError(c, err)
983
+ }
984
+ })
985
+
986
+ app.post('/api/channels/:name/messages', async c => {
987
+ const name = c.req.param('name')
988
+ const body = await c.req.json()
989
+ if (!body.content || !body.content.trim()) {
990
+ return c.json({ error: 'content is required' }, 400)
991
+ }
992
+ if (!body.author || !body.authorName) {
993
+ return c.json({ error: 'author and authorName are required' }, 400)
994
+ }
995
+ if (!/^0x[a-fA-F0-9]{40}$/.test(body.author)) {
996
+ return c.json({ error: 'Invalid author format' }, 400)
997
+ }
998
+ if (normalizeAddress(body.author) !== c.get('userAddress')) {
999
+ return c.json({ error: 'message author must match logged-in user' }, 403)
1000
+ }
1001
+ if (body.authorName.length > 50) {
1002
+ return c.json({ error: 'authorName too long' }, 400)
1003
+ }
1004
+ try {
1005
+ const message = await engine.sendMessage(
1006
+ name,
1007
+ body.content,
1008
+ body.author,
1009
+ body.authorName,
1010
+ { ownerAddress: c.get('userAddress'), attachment: body.attachment }
1011
+ )
1012
+ return c.json({ success: true, message })
1013
+ } catch (err) {
1014
+ return badRequestOrAppError(c, err)
1015
+ }
1016
+ })
1017
+
1018
+ app.get('/api/channels/:name/peers', c => {
1019
+ try {
1020
+ return c.json(
1021
+ engine.getChannelPeers(c.req.param('name'), {
1022
+ ownerAddress: c.get('userAddress'),
1023
+ })
1024
+ )
1025
+ } catch (err) {
1026
+ return badRequestOrAppError(c, err)
1027
+ }
1028
+ })
1029
+
1030
+ app.put('/api/channels/:name/remark', async c => {
1031
+ const name = c.req.param('name')
1032
+ const body = await c.req.json()
1033
+ try {
1034
+ const remark = engine.setChannelRemark(name, body.remark, {
1035
+ ownerAddress: c.get('userAddress'),
1036
+ })
1037
+ return c.json({ success: true, remark })
1038
+ } catch (err) {
1039
+ return c.json({ error: err.message }, 400)
1040
+ }
1041
+ })
1042
+
1043
+ // --- 文件夹重命名 ---
1044
+ app.post('/api/folder/rename', async c => {
1045
+ const body = await c.req.json()
1046
+ if (!body.oldPath || !body.newPath) {
1047
+ return c.json({ error: 'oldPath and newPath are required' }, 400)
1048
+ }
1049
+ if (body.oldPath.length > 500 || body.newPath.length > 500) {
1050
+ return c.json({ error: 'Path too long' }, 400)
1051
+ }
1052
+ if (body.oldPath.includes('..') || body.newPath.includes('..')) {
1053
+ return c.json({ error: 'Path traversal not allowed' }, 400)
1054
+ }
1055
+ try {
1056
+ const result = engine.renameFolder(body.oldPath, body.newPath, {
1057
+ ownerAddress: c.get('userAddress'),
1058
+ })
1059
+ return c.json({ success: true, ...result })
1060
+ } catch (err) {
1061
+ return c.json({ error: err.message }, 400)
1062
+ }
1063
+ })
1064
+
1065
+ // --- 关机路由 ---
1066
+ app.post('/api/shutdown', c => {
1067
+ const clientIp = c.env.incoming?.socket?.remoteAddress || 'unknown'
1068
+ if (!isLoopbackRemoteAddress(clientIp)) {
1069
+ return c.json({ error: 'Forbidden' }, 403)
1070
+ }
1071
+ c.json({ success: true })
1072
+ console.log('[MostBox] Shutdown requested via API...')
1073
+ setTimeout(async () => {
1074
+ await engine.stop()
1075
+ if (serverInstanceRef.current) serverInstanceRef.current.close()
1076
+ console.log('[MostBox] Server stopped.')
1077
+ process.exit(0)
1078
+ }, 100)
1079
+ return c.body(null)
1080
+ })
1081
+
1082
+ registerStaticRoutes(app)
1083
+
1084
+ return {
1085
+ app,
1086
+ wsBroadcast,
1087
+ wsSendToChannel,
1088
+ broadcastNodeStatus,
1089
+ appendNodeLog,
1090
+ subscribeToChannel,
1091
+ unsubscribeFromChannel,
1092
+ cleanupWsSubscriptions,
1093
+ validateWebSocketRequest,
1094
+ }
1095
+ }