@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.
- package/package.json +2 -2
- package/src/cli/index.js +4 -0
- package/src/config.json5 +18 -0
- package/src/index.js +2 -0
- package/src/tools/share/.htaccess +20 -0
- package/src/tools/share/client.js +172 -0
- package/src/tools/share/index.js +735 -0
- package/src/tools/share/server.js +430 -0
- package/src/tools/share/server.php +452 -0
- package/src/tools/webwork/index.js +558 -0
|
@@ -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, '&')
|
|
136
|
+
.replace(/</g, '<')
|
|
137
|
+
.replace(/>/g, '>')
|
|
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
|
+
}
|