@wwkit/opm 1.0.3 → 1.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.
@@ -0,0 +1,430 @@
1
+ /**
2
+ * opm share server — 纯 Node.js HTTP server
3
+ *
4
+ * 单文件部署,与 PHP 版 (server.php) 功能完全一致。
5
+ *
6
+ * ──────────────────────────────────────────────────────────────
7
+ * 部署方式
8
+ * ──────────────────────────────────────────────────────────────
9
+ *
10
+ * 方式一:直接运行(开发 / VPS)
11
+ * node server.js
12
+ *
13
+ * 方式二:后台运行 + 进程管理
14
+ * pm2 start server.js --name opm-share
15
+ * pm2 save && pm2 startup
16
+ *
17
+ * 方式三:opm 内置管理
18
+ * opm share serve start # 后台启动(读取 config.json5 中 share.serve 配置)
19
+ * opm share serve stop
20
+ * opm share serve status
21
+ *
22
+ * ──────────────────────────────────────────────────────────────
23
+ * 可配置常量(集中在此,按需修改)
24
+ * ──────────────────────────────────────────────────────────────
25
+ */
26
+
27
+ // 默认密码:上传时未指定密码则用此值;此值视为"无密码",不校验
28
+ const DEFAULT_PASSWORD = '0000'
29
+
30
+ // 请求 body 最大字节数(默认 10MB)
31
+ const MAX_BODY_BYTES = 10 * 1024 * 1024
32
+
33
+ // 数据目录:存放分享 JSON 文件的目录
34
+ // 留空则自动使用 ~/.config/opm/share/data;也可填绝对路径如 /var/lib/opm/share
35
+ const DATA_DIR = ''
36
+
37
+ // 监听地址
38
+ const LISTEN_HOST = '0.0.0.0'
39
+
40
+ // 监听端口
41
+ const LISTEN_PORT = 8787
42
+
43
+ // ──────────────────────────────────────────────────────────────
44
+ // 以下为服务实现,通常无需修改
45
+ // ──────────────────────────────────────────────────────────────
46
+
47
+ import http from 'node:http'
48
+ import fs from 'node:fs'
49
+ import path from 'node:path'
50
+ import crypto from 'node:crypto'
51
+ import os from 'node:os'
52
+
53
+ // ── 配置解析(环境变量优先于常量) ──
54
+
55
+ function resolveDataDir() {
56
+ const env = process.env.OPM_SHARE_DATA_DIR
57
+ if (env) return env
58
+ if (DATA_DIR) return DATA_DIR
59
+ return path.join(os.homedir() || '/tmp', '.config', 'opm', 'share', 'data')
60
+ }
61
+
62
+ function resolveHost() {
63
+ return process.env.OPM_SHARE_HOST || LISTEN_HOST
64
+ }
65
+
66
+ function resolvePort() {
67
+ return parseInt(process.env.OPM_SHARE_PORT || String(LISTEN_PORT), 10)
68
+ }
69
+
70
+ // ── 工具函数 ──
71
+
72
+ function genId() {
73
+ return crypto.randomBytes(4).toString('hex')
74
+ }
75
+
76
+ function defaultTitle(content) {
77
+ const folded = content.replace(/\s+/g, ' ').trim()
78
+ if (folded.length <= 30) return folded
79
+ return folded.slice(0, 30) + '...'
80
+ }
81
+
82
+ function wantsJson(req) {
83
+ return (req.headers.accept || '').includes('application/json')
84
+ }
85
+
86
+ function readBody(req) {
87
+ return new Promise((resolve, reject) => {
88
+ let size = 0
89
+ const chunks = []
90
+ req.on('data', (chunk) => {
91
+ size += chunk.length
92
+ if (size > MAX_BODY_BYTES) {
93
+ reject(new Error('Body too large'))
94
+ req.destroy()
95
+ return
96
+ }
97
+ chunks.push(chunk)
98
+ })
99
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
100
+ req.on('error', reject)
101
+ })
102
+ }
103
+
104
+ // ── 响应辅助 ──
105
+
106
+ function sendJson(res, status, data) {
107
+ const body = JSON.stringify(data)
108
+ res.writeHead(status, {
109
+ 'Content-Type': 'application/json; charset=utf-8',
110
+ 'Access-Control-Allow-Origin': '*',
111
+ })
112
+ res.end(body)
113
+ }
114
+
115
+ function sendHtml(res, status, html) {
116
+ res.writeHead(status, {
117
+ 'Content-Type': 'text/html; charset=utf-8',
118
+ 'Access-Control-Allow-Origin': '*',
119
+ })
120
+ res.end(html)
121
+ }
122
+
123
+ function sendByAccept(req, res, status, json, html) {
124
+ if (wantsJson(req)) {
125
+ sendJson(res, status, json)
126
+ } else {
127
+ sendHtml(res, status, html)
128
+ }
129
+ }
130
+
131
+ // ── HTML 渲染 ──
132
+
133
+ function renderHtml(item) {
134
+ const escaped = (item.content || '')
135
+ .replace(/&/g, '&amp;')
136
+ .replace(/</g, '&lt;')
137
+ .replace(/>/g, '&gt;')
138
+ const title = item.title || 'Shared Text'
139
+ return `<!DOCTYPE html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="utf-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <title>${title}</title>
145
+ <style>
146
+ body { font-family: monospace; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
147
+ h1 { font-size: 1.2rem; color: #333; }
148
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; }
149
+ .meta { color: #999; font-size: 0.85rem; margin-bottom: 1rem; }
150
+ </style>
151
+ </head>
152
+ <body>
153
+ <h1>${title}</h1>
154
+ <div class="meta">ID: ${item.id} | Created: ${item.created}</div>
155
+ <pre>${escaped}</pre>
156
+ </body>
157
+ </html>`
158
+ }
159
+
160
+ function notFoundHtml(id) {
161
+ return `<h1>404 Not Found</h1><p>Share <code>${id}</code> does not exist or has been deleted.</p><p>Run <code>opm share list</code> to see available shares.</p>`
162
+ }
163
+
164
+ function forbiddenHtml() {
165
+ return '<h1>403 Forbidden</h1><p>Password required or incorrect.</p>'
166
+ }
167
+
168
+ // ── 数据访问层 ──
169
+
170
+ function readShare(dataDir, id) {
171
+ const filePath = path.join(dataDir, `${id}.json`)
172
+ if (!fs.existsSync(filePath)) return null
173
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
174
+ }
175
+
176
+ function writeShare(dataDir, item) {
177
+ fs.writeFileSync(path.join(dataDir, `${item.id}.json`), JSON.stringify(item, null, 2), 'utf8')
178
+ }
179
+
180
+ function removeShare(dataDir, id) {
181
+ const filePath = path.join(dataDir, `${id}.json`)
182
+ if (!fs.existsSync(filePath)) return false
183
+ fs.unlinkSync(filePath)
184
+ return true
185
+ }
186
+
187
+ function listAllShares(dataDir, limit) {
188
+ const files = fs.readdirSync(dataDir)
189
+ .filter((f) => f.endsWith('.json'))
190
+ .map((f) => {
191
+ const item = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'))
192
+ return { id: item.id, title: item.title, created: item.created }
193
+ })
194
+ .sort((a, b) => (b.created || '').localeCompare(a.created || ''))
195
+ return limit ? files.slice(0, limit) : files
196
+ }
197
+
198
+ function checkPassword(item, pw) {
199
+ if (!item.password || item.password === DEFAULT_PASSWORD) return true
200
+ return item.password === pw
201
+ }
202
+
203
+ // ── 路由处理器 ──
204
+
205
+ async function handleUpload(req, res, dataDir, baseUrl) {
206
+ const body = await readBody(req)
207
+ let parsed
208
+ try {
209
+ parsed = JSON.parse(body)
210
+ } catch {
211
+ sendJson(res, 400, { error: 'Invalid JSON body' })
212
+ return
213
+ }
214
+
215
+ const content = parsed.content || ''
216
+ if (!content) {
217
+ sendJson(res, 400, { error: 'content is required' })
218
+ return
219
+ }
220
+
221
+ const id = parsed.id || genId()
222
+ const item = {
223
+ id,
224
+ title: parsed.title || defaultTitle(content),
225
+ content,
226
+ password: parsed.password || DEFAULT_PASSWORD,
227
+ type: parsed.type || 'text',
228
+ filename: parsed.filename || '',
229
+ created: new Date().toISOString(),
230
+ }
231
+
232
+ writeShare(dataDir, item)
233
+ sendJson(res, 200, { id, url: `${baseUrl}/share/${id}`, htmlUrl: `${baseUrl}/share/html/${id}` })
234
+ }
235
+
236
+ function handleList(req, res, dataDir, url) {
237
+ const n = parseInt(url.searchParams.get('n') || '10', 10) || 10
238
+ const items = listAllShares(dataDir, n)
239
+ sendJson(res, 200, { count: items.length, items })
240
+ }
241
+
242
+ function handleHtml(req, res, dataDir, id, pw) {
243
+ const item = readShare(dataDir, id)
244
+ if (!item) {
245
+ sendHtml(res, 404, notFoundHtml(id))
246
+ return
247
+ }
248
+ if (!checkPassword(item, pw)) {
249
+ sendHtml(res, 403, forbiddenHtml())
250
+ return
251
+ }
252
+ sendHtml(res, 200, renderHtml(item))
253
+ }
254
+
255
+ function handleLatest(req, res, dataDir, pw) {
256
+ const items = listAllShares(dataDir, 1)
257
+ if (items.length === 0) {
258
+ sendByAccept(req, res, 404, { error: 'No shares found' }, '<h1>404 Not Found</h1><p>No shares available.</p>')
259
+ return
260
+ }
261
+ const item = readShare(dataDir, items[0].id)
262
+ if (!item) {
263
+ sendByAccept(req, res, 404, { error: 'Not found' }, notFoundHtml(items[0].id))
264
+ return
265
+ }
266
+ if (!checkPassword(item, pw)) {
267
+ sendByAccept(req, res, 403, { error: 'Password required or incorrect' }, forbiddenHtml())
268
+ return
269
+ }
270
+ sendByAccept(
271
+ req, res, 200,
272
+ { id: item.id, title: item.title, content: item.content, type: item.type || 'text', filename: item.filename || '', created: item.created },
273
+ renderHtml(item),
274
+ )
275
+ }
276
+
277
+ function handleGet(req, res, dataDir, id, pw) {
278
+ const item = readShare(dataDir, id)
279
+ if (!item) {
280
+ sendByAccept(req, res, 404, { error: 'Not found', id, message: `Share ${id} does not exist or has been deleted` }, notFoundHtml(id))
281
+ return
282
+ }
283
+ if (!checkPassword(item, pw)) {
284
+ sendByAccept(req, res, 403, { error: 'Password required or incorrect' }, forbiddenHtml())
285
+ return
286
+ }
287
+ sendByAccept(
288
+ req, res, 200,
289
+ { id: item.id, title: item.title, content: item.content, type: item.type || 'text', filename: item.filename || '', created: item.created },
290
+ renderHtml(item),
291
+ )
292
+ }
293
+
294
+ function handleDelete(req, res, dataDir, id) {
295
+ if (!readShare(dataDir, id)) {
296
+ sendJson(res, 404, { error: 'Not found', id, message: `Share ${id} does not exist` })
297
+ return
298
+ }
299
+ removeShare(dataDir, id)
300
+ sendJson(res, 200, { deleted: true, id })
301
+ }
302
+
303
+ function handleClear(req, res, dataDir, url) {
304
+ const days = parseInt(url.searchParams.get('days') || '0', 10) || 0
305
+ const keep = parseInt(url.searchParams.get('keep') || '0', 10) || 0
306
+
307
+ let files = fs.readdirSync(dataDir)
308
+ .filter((f) => f.endsWith('.json'))
309
+ .map((f) => {
310
+ const item = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'))
311
+ return { id: item.id, file: f, created: item.created }
312
+ })
313
+
314
+ const deleted = []
315
+ const now = Date.now()
316
+
317
+ if (days > 0) {
318
+ const cutoff = now - days * 24 * 60 * 60 * 1000
319
+ for (const f of files) {
320
+ const createdMs = new Date(f.created).getTime()
321
+ if (createdMs < cutoff) {
322
+ fs.unlinkSync(path.join(dataDir, f.file))
323
+ deleted.push(f.id)
324
+ }
325
+ }
326
+ files = files.filter((f) => !deleted.includes(f.id))
327
+ }
328
+
329
+ if (keep > 0 && files.length > keep) {
330
+ files.sort((a, b) => (b.created || '').localeCompare(a.created || ''))
331
+ const toDelete = files.slice(keep)
332
+ for (const f of toDelete) {
333
+ fs.unlinkSync(path.join(dataDir, f.file))
334
+ deleted.push(f.id)
335
+ }
336
+ }
337
+
338
+ sendJson(res, 200, { deleted: deleted.length, ids: deleted })
339
+ }
340
+
341
+ // ── 路由分发 ──
342
+
343
+ function createRouter(dataDir, baseUrl) {
344
+ return async (req, res) => {
345
+ if (req.method === 'OPTIONS') {
346
+ res.writeHead(204, {
347
+ 'Access-Control-Allow-Origin': '*',
348
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
349
+ 'Access-Control-Allow-Headers': 'Content-Type',
350
+ })
351
+ res.end()
352
+ return
353
+ }
354
+
355
+ const url = new URL(req.url, baseUrl)
356
+ const pathname = url.pathname
357
+
358
+ try {
359
+ if (req.method === 'POST' && pathname === '/share') {
360
+ await handleUpload(req, res, dataDir, baseUrl)
361
+ return
362
+ }
363
+
364
+ if (req.method === 'GET' && pathname === '/share/list') {
365
+ handleList(req, res, dataDir, url)
366
+ return
367
+ }
368
+
369
+ const deleteMatch = pathname.match(/^\/share\/delete\/([^/]+)$/)
370
+ if (req.method === 'POST' && deleteMatch) {
371
+ handleDelete(req, res, dataDir, deleteMatch[1])
372
+ return
373
+ }
374
+
375
+ if (req.method === 'POST' && pathname === '/share/clear') {
376
+ handleClear(req, res, dataDir, url)
377
+ return
378
+ }
379
+
380
+ const htmlMatch = pathname.match(/^\/share\/html\/([^/]+)(?:\/([^/]+))?$/)
381
+ if (req.method === 'GET' && htmlMatch) {
382
+ handleHtml(req, res, dataDir, htmlMatch[1], htmlMatch[2] || '')
383
+ return
384
+ }
385
+
386
+ const latestMatch = pathname.match(/^\/share\/latest(?:\/([^/]+))?$/)
387
+ if (req.method === 'GET' && latestMatch) {
388
+ handleLatest(req, res, dataDir, latestMatch[1] || '')
389
+ return
390
+ }
391
+
392
+ const shareMatch = pathname.match(/^\/share\/([^/]+)(?:\/([^/]+))?$/)
393
+ if (req.method === 'GET' && shareMatch) {
394
+ handleGet(req, res, dataDir, shareMatch[1], shareMatch[2] || '')
395
+ return
396
+ }
397
+
398
+ sendJson(res, 404, { error: 'Not found' })
399
+ } catch (err) {
400
+ sendJson(res, 500, { error: err.message })
401
+ }
402
+ }
403
+ }
404
+
405
+ // ── 启动入口 ──
406
+
407
+ export function startServer() {
408
+ const host = resolveHost()
409
+ const port = resolvePort()
410
+ const dataDir = resolveDataDir()
411
+
412
+ fs.mkdirSync(dataDir, { recursive: true })
413
+ const baseUrl = `http://${host}:${port}`
414
+
415
+ const server = http.createServer(createRouter(dataDir, baseUrl))
416
+
417
+ return new Promise((resolve, reject) => {
418
+ server.on('error', reject)
419
+ server.listen(port, host, () => {
420
+ console.log(`[opm share] Server running at ${baseUrl}`)
421
+ console.log(`[opm share] Data dir: ${dataDir}`)
422
+ console.log('[opm share] Press Ctrl+C to stop')
423
+ resolve(server)
424
+ })
425
+ })
426
+ }
427
+
428
+ if (process.argv[1] && process.argv[1].endsWith('server.js')) {
429
+ startServer()
430
+ }