@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,452 @@
1
+ <?php
2
+ /**
3
+ * opm share server — PHP 版本 (PHP 7+)
4
+ *
5
+ * 单文件部署,与 Node.js 版 (server.js) 功能完全一致。
6
+ *
7
+ * ──────────────────────────────────────────────────────────────
8
+ * 部署方式
9
+ * ──────────────────────────────────────────────────────────────
10
+ *
11
+ * 方式一:PHP 内置服务器(开发 / VPS)
12
+ * php -S 0.0.0.0:8787 server.php
13
+ *
14
+ * 方式二:云虚拟主机(Apache + PHP)
15
+ * 1. 上传 server.php 和 .htaccess 到网站根目录(如 htdocs/)
16
+ * 2. 确保 data/ 目录可写(脚本自动创建)
17
+ * 3. opm 客户端配置 share.server.active 指向你的域名
18
+ *
19
+ * 方式三:Nginx + PHP-FPM
20
+ * location / { try_files $uri /server.php?$query_string; }
21
+ *
22
+ * ──────────────────────────────────────────────────────────────
23
+ * 可配置常量(集中在此,按需修改)
24
+ * ──────────────────────────────────────────────────────────────
25
+ */
26
+
27
+ // 默认密码:上传时未指定密码则用此值;此值视为"无密码",不校验
28
+ $DEFAULT_PASSWORD = '0000';
29
+
30
+ // 请求 body 最大字节数(默认 10MB)
31
+ $MAX_BODY_BYTES = 10 * 1024 * 1024;
32
+
33
+ // 数据目录:存放分享 JSON 文件的目录
34
+ // 留空则自动使用脚本同级 data/ 目录;也可填绝对路径如 /var/lib/opm/share
35
+ $DATA_DIR = '';
36
+
37
+ // 监听地址(仅 php -S 有效,Apache/Nginx 忽略)
38
+ $LISTEN_HOST = '0.0.0.0';
39
+
40
+ // 监听端口(仅 php -S 有效,Apache/Nginx 忽略)
41
+ $LISTEN_PORT = 8787;
42
+
43
+ // ──────────────────────────────────────────────────────────────
44
+ // 以下为服务实现,通常无需修改
45
+ // ──────────────────────────────────────────────────────────────
46
+
47
+ // 环境变量优先于常量(兼容 opm share serve 后台启动注入的场景)
48
+
49
+ function resolveDataDir() {
50
+ global $DATA_DIR;
51
+ $dir = getenv('OPM_SHARE_DATA_DIR');
52
+ if (!$dir) {
53
+ $dir = $DATA_DIR ?: __DIR__ . '/data';
54
+ }
55
+ if (!is_dir($dir)) {
56
+ @mkdir($dir, 0755, true);
57
+ }
58
+ return $dir;
59
+ }
60
+
61
+ function resolveBaseUrl() {
62
+ global $LISTEN_HOST, $LISTEN_PORT;
63
+ $httpHost = getenv('HTTP_HOST');
64
+ if ($httpHost) {
65
+ return 'http://' . $httpHost;
66
+ }
67
+ $host = getenv('OPM_SHARE_HOST') ?: $LISTEN_HOST;
68
+ $port = getenv('OPM_SHARE_PORT') ?: $LISTEN_PORT;
69
+ return 'http://' . $host . ':' . $port;
70
+ }
71
+
72
+ function getDefaultPassword() {
73
+ global $DEFAULT_PASSWORD;
74
+ return $DEFAULT_PASSWORD;
75
+ }
76
+
77
+ function getMaxBodyBytes() {
78
+ global $MAX_BODY_BYTES;
79
+ return $MAX_BODY_BYTES;
80
+ }
81
+
82
+ // ── 工具函数 ──
83
+
84
+ function genId() {
85
+ return bin2hex(random_bytes(4));
86
+ }
87
+
88
+ function defaultTitle($content) {
89
+ $folded = preg_replace('/\s+/', ' ', trim($content));
90
+ if (strlen($folded) <= 30) return $folded;
91
+ return substr($folded, 0, 30) . '...';
92
+ }
93
+
94
+ function wantsJson() {
95
+ $accept = isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : '';
96
+ return strpos($accept, 'application/json') !== false;
97
+ }
98
+
99
+ function readBody() {
100
+ $body = file_get_contents('php://input');
101
+ if (strlen($body) > getMaxBodyBytes()) {
102
+ http_response_code(413);
103
+ echo json_encode(array('error' => 'Body too large'));
104
+ exit;
105
+ }
106
+ return $body;
107
+ }
108
+
109
+ // ── 响应辅助 ──
110
+
111
+ function sendJson($status, $data) {
112
+ http_response_code($status);
113
+ header('Content-Type: application/json; charset=utf-8');
114
+ header('Access-Control-Allow-Origin: *');
115
+ echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
116
+ exit;
117
+ }
118
+
119
+ function sendHtml($status, $html) {
120
+ http_response_code($status);
121
+ header('Content-Type: text/html; charset=utf-8');
122
+ header('Access-Control-Allow-Origin: *');
123
+ echo $html;
124
+ exit;
125
+ }
126
+
127
+ function sendByAccept($status, $json, $html) {
128
+ if (wantsJson()) {
129
+ sendJson($status, $json);
130
+ } else {
131
+ sendHtml($status, $html);
132
+ }
133
+ }
134
+
135
+ // ── HTML 渲染 ──
136
+
137
+ function renderHtml($item) {
138
+ $escaped = htmlspecialchars($item['content'] ?: '', ENT_QUOTES, 'UTF-8');
139
+ $title = $item['title'] ?: 'Shared Text';
140
+ $id = htmlspecialchars($item['id'], ENT_QUOTES, 'UTF-8');
141
+ $created = htmlspecialchars($item['created'], ENT_QUOTES, 'UTF-8');
142
+ return <<<HTML
143
+ <!DOCTYPE html>
144
+ <html lang="en">
145
+ <head>
146
+ <meta charset="utf-8">
147
+ <meta name="viewport" content="width=device-width, initial-scale=1">
148
+ <title>{$title}</title>
149
+ <style>
150
+ body { font-family: monospace; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
151
+ h1 { font-size: 1.2rem; color: #333; }
152
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; }
153
+ .meta { color: #999; font-size: 0.85rem; margin-bottom: 1rem; }
154
+ </style>
155
+ </head>
156
+ <body>
157
+ <h1>{$title}</h1>
158
+ <div class="meta">ID: {$id} | Created: {$created}</div>
159
+ <pre>{$escaped}</pre>
160
+ </body>
161
+ </html>
162
+ HTML;
163
+ }
164
+
165
+ function notFoundHtml($id) {
166
+ $id = htmlspecialchars($id, ENT_QUOTES, 'UTF-8');
167
+ 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>";
168
+ }
169
+
170
+ function forbiddenHtml() {
171
+ return '<h1>403 Forbidden</h1><p>Password required or incorrect.</p>';
172
+ }
173
+
174
+ // ── 数据访问层 ──
175
+
176
+ function readShare($dataDir, $id) {
177
+ $filePath = $dataDir . '/' . $id . '.json';
178
+ if (!file_exists($filePath)) return null;
179
+ $raw = file_get_contents($filePath);
180
+ return json_decode($raw, true);
181
+ }
182
+
183
+ function writeShare($dataDir, $item) {
184
+ $filePath = $dataDir . '/' . $item['id'] . '.json';
185
+ file_put_contents($filePath, json_encode($item, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
186
+ }
187
+
188
+ function removeShare($dataDir, $id) {
189
+ $filePath = $dataDir . '/' . $id . '.json';
190
+ if (!file_exists($filePath)) return false;
191
+ unlink($filePath);
192
+ return true;
193
+ }
194
+
195
+ function listAllShares($dataDir, $limit = 0) {
196
+ $items = array();
197
+ $files = glob($dataDir . '/*.json');
198
+ if ($files === false) $files = array();
199
+
200
+ foreach ($files as $file) {
201
+ $raw = file_get_contents($file);
202
+ $item = json_decode($raw, true);
203
+ if ($item) {
204
+ $items[] = array(
205
+ 'id' => $item['id'],
206
+ 'title' => $item['title'],
207
+ 'created' => $item['created'],
208
+ );
209
+ }
210
+ }
211
+
212
+ usort($items, function($a, $b) {
213
+ return strcmp($b['created'] ?: '', $a['created'] ?: '');
214
+ });
215
+
216
+ if ($limit > 0) {
217
+ $items = array_slice($items, 0, $limit);
218
+ }
219
+ return $items;
220
+ }
221
+
222
+ function checkPassword($item, $pw) {
223
+ if (!$item['password'] || $item['password'] === getDefaultPassword()) return true;
224
+ return $item['password'] === $pw;
225
+ }
226
+
227
+ // ── 路由处理器 ──
228
+
229
+ function handleUpload($dataDir, $baseUrl) {
230
+ $body = readBody();
231
+ $parsed = json_decode($body, true);
232
+ if ($parsed === null) {
233
+ sendJson(400, array('error' => 'Invalid JSON body'));
234
+ }
235
+
236
+ $content = isset($parsed['content']) ? $parsed['content'] : '';
237
+ if (!$content) {
238
+ sendJson(400, array('error' => 'content is required'));
239
+ }
240
+
241
+ $id = isset($parsed['id']) && $parsed['id'] ? $parsed['id'] : genId();
242
+ $item = array(
243
+ 'id' => $id,
244
+ 'title' => isset($parsed['title']) && $parsed['title'] ? $parsed['title'] : defaultTitle($content),
245
+ 'content' => $content,
246
+ 'password' => isset($parsed['password']) && $parsed['password'] ? $parsed['password'] : getDefaultPassword(),
247
+ 'type' => isset($parsed['type']) && $parsed['type'] ? $parsed['type'] : 'text',
248
+ 'filename' => isset($parsed['filename']) && $parsed['filename'] ? $parsed['filename'] : '',
249
+ 'created' => gmdate('Y-m-d\TH:i:s.v\Z'),
250
+ );
251
+
252
+ writeShare($dataDir, $item);
253
+ sendJson(200, array(
254
+ 'id' => $id,
255
+ 'url' => $baseUrl . '/share/' . $id,
256
+ 'htmlUrl' => $baseUrl . '/share/html/' . $id,
257
+ ));
258
+ }
259
+
260
+ function handleList($dataDir) {
261
+ $n = isset($_GET['n']) ? intval($_GET['n']) : 10;
262
+ if (!$n) $n = 10;
263
+ $items = listAllShares($dataDir, $n);
264
+ sendJson(200, array('count' => count($items), 'items' => $items));
265
+ }
266
+
267
+ function handleHtml($dataDir, $id, $pw) {
268
+ $item = readShare($dataDir, $id);
269
+ if (!$item) {
270
+ sendHtml(404, notFoundHtml($id));
271
+ }
272
+ if (!checkPassword($item, $pw)) {
273
+ sendHtml(403, forbiddenHtml());
274
+ }
275
+ sendHtml(200, renderHtml($item));
276
+ }
277
+
278
+ function handleLatest($dataDir, $pw) {
279
+ $items = listAllShares($dataDir, 1);
280
+ if (count($items) === 0) {
281
+ sendByAccept(404, array('error' => 'No shares found'), '<h1>404 Not Found</h1><p>No shares available.</p>');
282
+ }
283
+ $item = readShare($dataDir, $items[0]['id']);
284
+ if (!$item) {
285
+ sendByAccept(404, array('error' => 'Not found'), notFoundHtml($items[0]['id']));
286
+ }
287
+ if (!checkPassword($item, $pw)) {
288
+ sendByAccept(403, array('error' => 'Password required or incorrect'), forbiddenHtml());
289
+ }
290
+ sendByAccept(200, array(
291
+ 'id' => $item['id'],
292
+ 'title' => $item['title'],
293
+ 'content' => $item['content'],
294
+ 'type' => isset($item['type']) ? $item['type'] : 'text',
295
+ 'filename' => isset($item['filename']) ? $item['filename'] : '',
296
+ 'created' => $item['created'],
297
+ ), renderHtml($item));
298
+ }
299
+
300
+ function handleGet($dataDir, $id, $pw) {
301
+ $item = readShare($dataDir, $id);
302
+ if (!$item) {
303
+ sendByAccept(404, array('error' => 'Not found', 'id' => $id, 'message' => "Share {$id} does not exist or has been deleted"), notFoundHtml($id));
304
+ }
305
+ if (!checkPassword($item, $pw)) {
306
+ sendByAccept(403, array('error' => 'Password required or incorrect'), forbiddenHtml());
307
+ }
308
+ sendByAccept(200, array(
309
+ 'id' => $item['id'],
310
+ 'title' => $item['title'],
311
+ 'content' => $item['content'],
312
+ 'type' => isset($item['type']) ? $item['type'] : 'text',
313
+ 'filename' => isset($item['filename']) ? $item['filename'] : '',
314
+ 'created' => $item['created'],
315
+ ), renderHtml($item));
316
+ }
317
+
318
+ function handleDelete($dataDir, $id) {
319
+ if (!readShare($dataDir, $id)) {
320
+ sendJson(404, array('error' => 'Not found', 'id' => $id, 'message' => "Share {$id} does not exist"));
321
+ }
322
+ removeShare($dataDir, $id);
323
+ sendJson(200, array('deleted' => true, 'id' => $id));
324
+ }
325
+
326
+ function handleClear($dataDir) {
327
+ $days = isset($_GET['days']) ? intval($_GET['days']) : 0;
328
+ $keep = isset($_GET['keep']) ? intval($_GET['keep']) : 0;
329
+
330
+ $files = glob($dataDir . '/*.json');
331
+ if ($files === false) $files = array();
332
+
333
+ $items = array();
334
+ foreach ($files as $file) {
335
+ $raw = file_get_contents($file);
336
+ $item = json_decode($raw, true);
337
+ if ($item) {
338
+ $items[] = array(
339
+ 'id' => $item['id'],
340
+ 'file' => basename($file),
341
+ 'created' => isset($item['created']) ? $item['created'] : '',
342
+ );
343
+ }
344
+ }
345
+
346
+ $deleted = array();
347
+ $now = time();
348
+
349
+ if ($days > 0) {
350
+ $cutoff = $now - $days * 24 * 60 * 60;
351
+ foreach ($items as $f) {
352
+ $createdTs = strtotime($f['created']);
353
+ if ($createdTs !== false && $createdTs < $cutoff) {
354
+ unlink($dataDir . '/' . $f['file']);
355
+ $deleted[] = $f['id'];
356
+ }
357
+ }
358
+ $items = array_filter($items, function($f) use ($deleted) {
359
+ return !in_array($f['id'], $deleted);
360
+ });
361
+ $items = array_values($items);
362
+ }
363
+
364
+ if ($keep > 0 && count($items) > $keep) {
365
+ usort($items, function($a, $b) {
366
+ return strcmp($b['created'] ?: '', $a['created'] ?: '');
367
+ });
368
+ $toDelete = array_slice($items, $keep);
369
+ foreach ($toDelete as $f) {
370
+ unlink($dataDir . '/' . $f['file']);
371
+ $deleted[] = $f['id'];
372
+ }
373
+ }
374
+
375
+ sendJson(200, array('deleted' => count($deleted), 'ids' => $deleted));
376
+ }
377
+
378
+ // ── 路由分发 ──
379
+
380
+ function route() {
381
+ $method = $_SERVER['REQUEST_METHOD'];
382
+ $dataDir = resolveDataDir();
383
+ $baseUrl = resolveBaseUrl();
384
+
385
+ // 解析路径(兼容 php -S 和 Apache/Nginx)
386
+ $uri = $_SERVER['REQUEST_URI'];
387
+ $path = parse_url($uri, PHP_URL_PATH);
388
+
389
+ // 去除可能的 index.php / server.php 前缀
390
+ $scriptName = isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '';
391
+ if ($scriptName && strpos($path, $scriptName) === 0 && $path !== $scriptName) {
392
+ $path = substr($path, strlen($scriptName));
393
+ }
394
+ // 确保以 / 开头
395
+ if ($path === '' || $path[0] !== '/') {
396
+ $path = '/' . $path;
397
+ }
398
+
399
+ // CORS preflight
400
+ if ($method === 'OPTIONS') {
401
+ http_response_code(204);
402
+ header('Access-Control-Allow-Origin: *');
403
+ header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
404
+ header('Access-Control-Allow-Headers: Content-Type');
405
+ exit;
406
+ }
407
+
408
+ try {
409
+ // POST /share — 上传
410
+ if ($method === 'POST' && $path === '/share') {
411
+ handleUpload($dataDir, $baseUrl);
412
+ }
413
+
414
+ // GET /share/list — 列表(必须在 /share/:id 之前匹配)
415
+ if ($method === 'GET' && $path === '/share/list') {
416
+ handleList($dataDir);
417
+ }
418
+
419
+ // POST /share/delete/<id> — 删除
420
+ if ($method === 'POST' && preg_match('#^/share/delete/([^/]+)$#', $path, $m)) {
421
+ handleDelete($dataDir, $m[1]);
422
+ }
423
+
424
+ // POST /share/clear — 清理
425
+ if ($method === 'POST' && $path === '/share/clear') {
426
+ handleClear($dataDir);
427
+ }
428
+
429
+ // GET /share/html/<id>[/<pw>] — HTML 页面
430
+ if ($method === 'GET' && preg_match('#^/share/html/([^/]+)(?:/([^/]+))?$#', $path, $m)) {
431
+ handleHtml($dataDir, $m[1], isset($m[2]) ? $m[2] : '');
432
+ }
433
+
434
+ // GET /share/latest[/<pw>] — 最近一条(必须在 /share/:id 之前匹配)
435
+ if ($method === 'GET' && preg_match('#^/share/latest(?:/([^/]+))?$#', $path, $m)) {
436
+ handleLatest($dataDir, isset($m[1]) ? $m[1] : '');
437
+ }
438
+
439
+ // GET /share/<id>[/<pw>] — JSON 或 HTML
440
+ if ($method === 'GET' && preg_match('#^/share/([^/]+)(?:/([^/]+))?$#', $path, $m)) {
441
+ handleGet($dataDir, $m[1], isset($m[2]) ? $m[2] : '');
442
+ }
443
+
444
+ sendJson(404, array('error' => 'Not found'));
445
+ } catch (Exception $e) {
446
+ sendJson(500, array('error' => $e->getMessage()));
447
+ }
448
+ }
449
+
450
+ // ── 启动 ──
451
+
452
+ route();