@code2rich/jpage 1.5.0

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 (143) hide show
  1. package/.claude/settings.local.json +68 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +56 -0
  4. package/.github/workflows/ci.yml +43 -0
  5. package/CLAUDE.md +280 -0
  6. package/Dockerfile +44 -0
  7. package/LICENSE +21 -0
  8. package/README.md +433 -0
  9. package/README_EN.md +399 -0
  10. package/bin/args.js +64 -0
  11. package/bin/client.js +93 -0
  12. package/bin/commands/_shared.js +54 -0
  13. package/bin/commands/cat.js +23 -0
  14. package/bin/commands/ls.js +44 -0
  15. package/bin/commands/mv.js +20 -0
  16. package/bin/commands/rm.js +22 -0
  17. package/bin/commands/skills.js +70 -0
  18. package/bin/commands/star.js +23 -0
  19. package/bin/commands/tags.js +97 -0
  20. package/bin/commands/upload.js +84 -0
  21. package/bin/commands/url.js +25 -0
  22. package/bin/commands/whoami.js +29 -0
  23. package/bin/config.js +85 -0
  24. package/bin/jpage.js +168 -0
  25. package/build.js +112 -0
  26. package/docker-compose.yml +26 -0
  27. package/docs/api.md +438 -0
  28. package/docs/design/005-custom-modal.md +296 -0
  29. package/docs/design/013-file-version-history.md +324 -0
  30. package/docs/design/billing-system.md +600 -0
  31. package/docs/design/db-index-and-healthcheck.md +176 -0
  32. package/docs/design/loading-states.md +209 -0
  33. package/docs/virtual-hosting-feasibility.md +453 -0
  34. package/eslint.config.mjs +172 -0
  35. package/lib/auth-state.js +15 -0
  36. package/lib/categories.js +20 -0
  37. package/lib/crypto.js +85 -0
  38. package/lib/csp.js +66 -0
  39. package/lib/db.js +53 -0
  40. package/lib/dispatch.js +103 -0
  41. package/lib/fts.js +81 -0
  42. package/lib/middleware/auth.js +114 -0
  43. package/lib/middleware/files.js +42 -0
  44. package/lib/paths.js +9 -0
  45. package/lib/render-cache.js +48 -0
  46. package/lib/render.js +157 -0
  47. package/lib/templates.js +149 -0
  48. package/lib/util.js +66 -0
  49. package/lib/view-counts.js +59 -0
  50. package/lib/zip.js +192 -0
  51. package/logger.js +16 -0
  52. package/mailer.js +34 -0
  53. package/mcp/constants.js +16 -0
  54. package/mcp/resources.js +74 -0
  55. package/mcp/server.js +43 -0
  56. package/mcp/tools-categories.js +56 -0
  57. package/mcp/tools-content-templates.js +59 -0
  58. package/mcp/tools-files.js +245 -0
  59. package/mcp/tools-tags.js +41 -0
  60. package/mcp/tools-versions.js +57 -0
  61. package/mcp/transport.js +183 -0
  62. package/mcp/util.js +63 -0
  63. package/mcp-server.js +20 -0
  64. package/migrations/001_init_schema.js +25 -0
  65. package/migrations/002_add_share_key.js +33 -0
  66. package/migrations/003_add_roles_and_tokens.js +28 -0
  67. package/migrations/004_add_version_history.js +32 -0
  68. package/migrations/005_tags_starred_categories.js +49 -0
  69. package/migrations/006_zip_bundle.js +17 -0
  70. package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
  71. package/migrations/008_add_fts5.js +6 -0
  72. package/migrations/009_add_link_visits.js +20 -0
  73. package/migrations/010_add_templates_system.js +34 -0
  74. package/migrations/011_content_templates.js +233 -0
  75. package/migrations/012_add_email_and_verification.js +35 -0
  76. package/migrations/013_add_token_encrypted.js +14 -0
  77. package/migrations.js +65 -0
  78. package/package.json +63 -0
  79. package/public/css/style.css +2915 -0
  80. package/public/index.html +855 -0
  81. package/public/js/api.js +22 -0
  82. package/public/js/app.js +94 -0
  83. package/public/js/components/dialog.js +106 -0
  84. package/public/js/components/toast.js +13 -0
  85. package/public/js/pages/content-templates.js +330 -0
  86. package/public/js/pages/home.js +1903 -0
  87. package/public/js/pages/landing.js +158 -0
  88. package/public/js/pages/login.js +175 -0
  89. package/public/js/pages/preview.js +713 -0
  90. package/public/js/theme.js +44 -0
  91. package/public/js/utils.js +67 -0
  92. package/routes/admin.js +136 -0
  93. package/routes/auth.js +365 -0
  94. package/routes/categories.js +90 -0
  95. package/routes/content-templates.js +215 -0
  96. package/routes/files/_shared.js +112 -0
  97. package/routes/files/associations.js +94 -0
  98. package/routes/files/crud.js +139 -0
  99. package/routes/files/detail-serve.js +178 -0
  100. package/routes/files/index.js +38 -0
  101. package/routes/files/list.js +200 -0
  102. package/routes/files/overwrite.js +114 -0
  103. package/routes/files/upload.js +204 -0
  104. package/routes/files/versions.js +166 -0
  105. package/routes/files.js +16 -0
  106. package/routes/skills.js +93 -0
  107. package/routes/tags.js +65 -0
  108. package/routes/tokens.js +110 -0
  109. package/routes/users.js +120 -0
  110. package/server.js +372 -0
  111. package/skills/jpage-content-template/SKILL.md +98 -0
  112. package/skills/jpage-upload/SKILL.md +247 -0
  113. package/skills-registry.js +135 -0
  114. package/templates/academic.html +41 -0
  115. package/templates/dark-pro.html +41 -0
  116. package/templates/default.html +56 -0
  117. package/templates/github.html +67 -0
  118. package/test/browser-harness.js +125 -0
  119. package/test/dispatch-bench.js +74 -0
  120. package/test/helpers/setup.js +45 -0
  121. package/test/integration/admin.test.js +108 -0
  122. package/test/integration/auth.test.js +93 -0
  123. package/test/integration/categories.test.js +103 -0
  124. package/test/integration/cli.test.js +310 -0
  125. package/test/integration/content-templates.test.js +147 -0
  126. package/test/integration/files-security.test.js +248 -0
  127. package/test/integration/files.test.js +139 -0
  128. package/test/integration/share.test.js +79 -0
  129. package/test/integration/skills.test.js +104 -0
  130. package/test/integration/tags.test.js +84 -0
  131. package/test/integration/tokens.test.js +89 -0
  132. package/test/integration/users.test.js +138 -0
  133. package/test/mcp-harness.js +152 -0
  134. package/test/perf-bench.js +108 -0
  135. package/test/perf-harness.js +198 -0
  136. package/test/run-server.sh +15 -0
  137. package/test/unit/cli-args.test.js +88 -0
  138. package/test/unit/cli-config.test.js +89 -0
  139. package/test/unit/crypto.test.js +100 -0
  140. package/test/unit/fts.test.js +52 -0
  141. package/test/unit/render-cache.test.js +76 -0
  142. package/test/unit/util.test.js +81 -0
  143. package/test/unit/zip.test.js +164 -0
@@ -0,0 +1,204 @@
1
+ // 上传路由:multipart / JSON / ZIP(base64)。同名文件自动覆盖并保留版本历史。
2
+ // 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。
3
+ // 注册顺序:必须在 `/:id` 系列之前注册。
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { dbGet, dbRun } = require('../../lib/db');
8
+ const { requireAuth } = require('../../lib/middleware/auth');
9
+ const { now, unlinkQuiet, generateShareKey, currentUserId, clientIp, decodeFilename } = require('../../lib/util');
10
+ const { UPLOAD_DIR } = require('../../lib/paths');
11
+ const { isFtsIndexable, indexFileContent } = require('../../lib/fts');
12
+ const { handleZipUpload } = require('../../lib/zip');
13
+ const { uploadLimiter, upload, largeJson, MAX_FILE_SIZE, ALLOWED_TEXT_EXTS, backupAndApplyVersion } = require('./_shared');
14
+ const logger = require('../../logger');
15
+
16
+ function registerUpload(router) {
17
+ // --- multipart 上传 ---
18
+ router.post('/upload', requireAuth, uploadLimiter, upload.single('file'), async (req, res) => {
19
+ if (!req.file) return res.status(400).json({ error: '未上传文件' });
20
+ req.file.originalname = decodeFilename(req.file.originalname);
21
+ const ext = path.extname(req.file.originalname).toLowerCase();
22
+ // ZIP 处理
23
+ if (ext === '.zip') {
24
+ return handleZipUpload(req, res, await fs.promises.readFile(req.file.path));
25
+ }
26
+ let fileType = 'html';
27
+ if (ext === '.md' || ext === '.markdown') fileType = 'markdown';
28
+ const isPublic = req.body.isPublic === 'true' || req.body.isPublic === true;
29
+ try {
30
+ // 检查同名文件
31
+ const existing = await dbGet(
32
+ 'SELECT id, stored_name, size, uploaded_by, file_type, is_public FROM files WHERE original_name = ?',
33
+ [req.file.originalname]
34
+ );
35
+
36
+ if (existing) {
37
+ // 同名文件:校验文件类型
38
+ if (existing.file_type !== fileType) {
39
+ // 类型不匹配,清理已上传的文件,拒绝覆盖
40
+ await unlinkQuiet(path.join(UPLOAD_DIR, req.file.filename));
41
+ return res.status(400).json({ error: '文件类型不匹配' });
42
+ }
43
+
44
+ // 备份当前版本并更新主记录(新文件已由 multer 写入磁盘)
45
+ const { version } = await backupAndApplyVersion(
46
+ existing,
47
+ { storedName: req.file.filename, size: req.file.size },
48
+ existing.uploaded_by
49
+ );
50
+
51
+ // FTS 索引同步
52
+ if (isFtsIndexable(fileType, req.file.filename)) {
53
+ indexFileContent(existing.id, req.file.filename);
54
+ }
55
+
56
+ const shareKey = await dbGet('SELECT share_key FROM files WHERE id = ?', [existing.id]).then(r => r?.share_key);
57
+ logger.audit('file.overwrite', { fileId: existing.id, fileName: req.file.originalname, version, fileType, size: req.file.size, ip: clientIp(req) });
58
+ return res.json({
59
+ id: existing.id,
60
+ overwritten: true,
61
+ version,
62
+ original_name: req.file.originalname,
63
+ file_type: fileType,
64
+ size: req.file.size,
65
+ is_public: existing.is_public,
66
+ share_key: shareKey
67
+ });
68
+ }
69
+
70
+ // 不存在同名文件:新建
71
+ const result = await dbRun(
72
+ 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
73
+ [req.file.originalname, req.file.filename, fileType, req.file.size, isPublic ? 1 : 0, currentUserId(req), generateShareKey(), now()]
74
+ );
75
+ // FTS 索引同步
76
+ if (isFtsIndexable(fileType, req.file.filename)) {
77
+ indexFileContent(result.lastID, req.file.filename);
78
+ }
79
+ const shareKey = await dbGet('SELECT share_key FROM files WHERE id = ?', [result.lastID]).then(r => r?.share_key);
80
+ logger.audit('file.upload', { fileId: result.lastID, fileName: req.file.originalname, fileType, size: req.file.size, ip: clientIp(req) });
81
+ res.json({
82
+ id: result.lastID,
83
+ original_name: req.file.originalname,
84
+ file_type: fileType,
85
+ size: req.file.size,
86
+ is_public: isPublic ? 1 : 0,
87
+ share_key: shareKey
88
+ });
89
+ } catch (e) {
90
+ res.status(500).json({ error: '保存文件记录失败' });
91
+ }
92
+ });
93
+
94
+ // --- JSON 上传 ---
95
+ router.post('/upload-json', requireAuth, uploadLimiter, largeJson, async (req, res) => {
96
+ const { name, content, isPublic } = req.body || {};
97
+ if (typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: '文件名不能为空' });
98
+ if (typeof content !== 'string') return res.status(400).json({ error: 'content 必须是字符串' });
99
+ const decoded = decodeFilename(name.trim());
100
+ const ext = path.extname(decoded).toLowerCase();
101
+ if (!ALLOWED_TEXT_EXTS.includes(ext)) return res.status(400).json({ error: '仅支持 HTML 和 Markdown 文件' });
102
+ const size = Buffer.byteLength(content, 'utf-8');
103
+ if (size > MAX_FILE_SIZE) return res.status(400).json({ error: '文件大小超过50MB限制' });
104
+ const fileType = (ext === '.md' || ext === '.markdown') ? 'markdown' : 'html';
105
+ const { generateStoredName } = require('./_shared');
106
+ const storedName = generateStoredName(ext);
107
+ const filePath = path.join(UPLOAD_DIR, storedName);
108
+ try {
109
+ await fs.promises.writeFile(filePath, content, 'utf-8');
110
+ } catch (e) {
111
+ logger.error({ type: 'app', message: '写入文件失败', error: e.message });
112
+ return res.status(500).json({ error: '写入文件失败' });
113
+ }
114
+
115
+ // 检查同名文件
116
+ const existing = await dbGet(
117
+ 'SELECT id, stored_name, size, uploaded_by, file_type, is_public, share_key FROM files WHERE original_name = ?',
118
+ [decoded]
119
+ ).catch(() => null);
120
+
121
+ if (existing) {
122
+ // 同名文件:校验文件类型
123
+ if (existing.file_type !== fileType) {
124
+ await unlinkQuiet(filePath);
125
+ return res.status(400).json({ error: '文件类型不匹配' });
126
+ }
127
+
128
+ try {
129
+ const { version } = await backupAndApplyVersion(
130
+ existing,
131
+ { storedName, size },
132
+ existing.uploaded_by
133
+ );
134
+
135
+ // FTS 索引同步
136
+ if (isFtsIndexable(fileType, storedName)) {
137
+ indexFileContent(existing.id, storedName);
138
+ }
139
+
140
+ logger.audit('file.overwrite', { fileId: existing.id, fileName: decoded, version, fileType, size, ip: clientIp(req) });
141
+ return res.json({
142
+ id: existing.id,
143
+ overwritten: true,
144
+ version,
145
+ original_name: decoded,
146
+ file_type: fileType,
147
+ size,
148
+ is_public: existing.is_public,
149
+ share_key: existing.share_key
150
+ });
151
+ } catch (e) {
152
+ await unlinkQuiet(filePath);
153
+ return res.status(500).json({ error: '覆盖上传失败' });
154
+ }
155
+ }
156
+
157
+ // 不存在同名文件:新建
158
+ const isPublicFlag = isPublic === false ? 0 : 1;
159
+ try {
160
+ const result = await dbRun(
161
+ 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
162
+ [decoded, storedName, fileType, size, isPublicFlag, currentUserId(req), generateShareKey(), now()]
163
+ );
164
+ // FTS 索引同步
165
+ if (isFtsIndexable(fileType, storedName)) {
166
+ indexFileContent(result.lastID, storedName);
167
+ }
168
+ const shareKey = await dbGet('SELECT share_key FROM files WHERE id = ?', [result.lastID]).then(r => r?.share_key);
169
+ logger.audit('file.upload', { fileId: result.lastID, fileName: decoded, fileType, size, ip: clientIp(req) });
170
+ res.json({
171
+ id: result.lastID,
172
+ original_name: decoded,
173
+ file_type: fileType,
174
+ size,
175
+ is_public: isPublicFlag,
176
+ share_key: shareKey
177
+ });
178
+ } catch (e) {
179
+ await unlinkQuiet(filePath);
180
+ res.status(500).json({ error: '保存文件记录失败' });
181
+ }
182
+ });
183
+
184
+ // --- ZIP(base64) 上传 ---
185
+ router.post('/upload-zip-base64', requireAuth, uploadLimiter, largeJson, async (req, res) => {
186
+ const { name, content, isPublic } = req.body || {};
187
+ if (typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: '文件名不能为空' });
188
+ if (typeof content !== 'string') return res.status(400).json({ error: 'content 必须是字符串' });
189
+ const ext = path.extname(name).toLowerCase();
190
+ if (ext !== '.zip') return res.status(400).json({ error: '仅支持 ZIP 文件' });
191
+ try {
192
+ const zipBuffer = Buffer.from(content, 'base64');
193
+ if (zipBuffer.length > MAX_FILE_SIZE) return res.status(400).json({ error: 'ZIP 文件超过50MB限制' });
194
+ req.file = { originalname: decodeFilename(name) };
195
+ req.body.isPublic = isPublic;
196
+ return await handleZipUpload(req, res, zipBuffer);
197
+ } catch (e) {
198
+ logger.error({ type: 'app', action: 'zip.base64', error: e.message });
199
+ return res.status(500).json({ error: 'ZIP 处理失败: ' + e.message });
200
+ }
201
+ });
202
+ }
203
+
204
+ module.exports = { registerUpload };
@@ -0,0 +1,166 @@
1
+ // 版本历史路由:列表 / 内容 / 渲染 / 恢复 / 删除单版本。
2
+ // 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { dbGet, dbAll, dbRun } = require('../../lib/db');
7
+ const { requireAuth } = require('../../lib/middleware/auth');
8
+ const { unlinkQuiet, currentUserId, clientIp } = require('../../lib/util');
9
+ const { UPLOAD_DIR } = require('../../lib/paths');
10
+ const { renderFile } = require('../../lib/render');
11
+ const { generateStoredName, backupAndApplyVersion } = require('./_shared');
12
+ const logger = require('../../logger');
13
+
14
+ function registerVersions(router) {
15
+ // --- 版本列表 ---
16
+ router.get('/:id/versions', requireAuth, async (req, res) => {
17
+ try {
18
+ const file = await dbGet('SELECT id, size, updated_at FROM files WHERE id = ?', [req.params.id]);
19
+ if (!file) return res.status(404).json({ error: '文件不存在' });
20
+
21
+ const versions = await dbAll(
22
+ 'SELECT id, version, size, created_at FROM file_versions WHERE file_id = ? ORDER BY version DESC',
23
+ [req.params.id]
24
+ );
25
+
26
+ res.json({
27
+ file_id: file.id,
28
+ current: { size: file.size, updated_at: file.updated_at },
29
+ versions
30
+ });
31
+ } catch (e) {
32
+ res.status(500).json({ error: '获取版本列表失败' });
33
+ }
34
+ });
35
+
36
+ // --- 版本内容(原文) ---
37
+ router.get('/:id/versions/:ver/content', requireAuth, async (req, res) => {
38
+ try {
39
+ const ver = await dbGet(
40
+ 'SELECT * FROM file_versions WHERE file_id = ? AND version = ?',
41
+ [req.params.id, req.params.ver]
42
+ );
43
+ if (!ver) return res.status(404).json({ error: '版本不存在' });
44
+
45
+ const filePath = path.join(UPLOAD_DIR, ver.stored_name);
46
+
47
+ const file = await dbGet('SELECT original_name, file_type, uploaded_by FROM files WHERE id = ?', [req.params.id]);
48
+ if (req.userRole !== 'admin' && file?.uploaded_by !== req.userId) {
49
+ return res.status(403).json({ error: '无权读取此文件原文' });
50
+ }
51
+ let content;
52
+ try {
53
+ content = await fs.promises.readFile(filePath, 'utf-8');
54
+ } catch (e) {
55
+ if (e && e.code === 'ENOENT') return res.status(404).json({ error: '版本文件已丢失' });
56
+ throw e;
57
+ }
58
+ res.json({
59
+ id: parseInt(req.params.id),
60
+ version: ver.version,
61
+ original_name: file?.original_name,
62
+ file_type: file?.file_type,
63
+ content
64
+ });
65
+ } catch (e) {
66
+ res.status(500).json({ error: '读取版本内容失败' });
67
+ }
68
+ });
69
+
70
+ // --- 版本渲染 ---
71
+ router.get('/:id/versions/:ver/render', requireAuth, async (req, res) => {
72
+ try {
73
+ const ver = await dbGet(
74
+ 'SELECT * FROM file_versions WHERE file_id = ? AND version = ?',
75
+ [req.params.id, req.params.ver]
76
+ );
77
+ if (!ver) return res.status(404).json({ error: '版本不存在' });
78
+
79
+ const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]);
80
+ if (!file) return res.status(404).json({ error: '文件不存在' });
81
+
82
+ // 构造一个 file-like 对象,使用历史版本的 stored_name
83
+ const versionFile = { ...file, stored_name: ver.stored_name };
84
+ await renderFile(res, versionFile);
85
+ } catch (e) {
86
+ res.status(500).json({ error: '渲染版本失败' });
87
+ }
88
+ });
89
+
90
+ // --- 恢复历史版本 ---
91
+ router.post('/:id/versions/:ver/restore', requireAuth, async (req, res) => {
92
+ let newStoredName;
93
+ try {
94
+ const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]);
95
+ if (!file) return res.status(404).json({ error: '文件不存在' });
96
+
97
+ const targetVer = await dbGet(
98
+ 'SELECT * FROM file_versions WHERE file_id = ? AND version = ?',
99
+ [req.params.id, req.params.ver]
100
+ );
101
+ if (!targetVer) return res.status(404).json({ error: '版本不存在' });
102
+
103
+ // 读取目标版本文件内容
104
+ const targetPath = path.join(UPLOAD_DIR, targetVer.stored_name);
105
+ let targetContent;
106
+ try {
107
+ targetContent = await fs.promises.readFile(targetPath, 'utf-8');
108
+ } catch (e) {
109
+ if (e && e.code === 'ENOENT') return res.status(404).json({ error: '版本文件已丢失' });
110
+ throw e;
111
+ }
112
+
113
+ // 复制到新磁盘文件
114
+ const ext = file.file_type === 'markdown' ? '.md' : '.html';
115
+ newStoredName = generateStoredName(ext);
116
+ await fs.promises.writeFile(path.join(UPLOAD_DIR, newStoredName), targetContent, 'utf-8');
117
+
118
+ // 当前版本备份并更新主记录。
119
+ // 注意:restore 记录的是执行恢复的用户(currentUserId),与 upload/overwrite
120
+ // 记录 file.uploaded_by(原始上传者)的语义不同,此处刻意保留原行为。
121
+ const newSize = Buffer.byteLength(targetContent, 'utf-8');
122
+ const { version } = await backupAndApplyVersion(
123
+ file,
124
+ { storedName: newStoredName, size: newSize },
125
+ currentUserId(req)
126
+ );
127
+
128
+ logger.audit('file.restore', { fileId: file.id, fileName: file.original_name, restoredVersion: parseInt(req.params.ver), newVersion: version, ip: clientIp(req) });
129
+ res.json({
130
+ success: true,
131
+ id: file.id,
132
+ version,
133
+ restored_from: parseInt(req.params.ver),
134
+ size: newSize
135
+ });
136
+ } catch (e) {
137
+ if (newStoredName) { await unlinkQuiet(path.join(UPLOAD_DIR, newStoredName)); }
138
+ res.status(500).json({ error: '恢复版本失败' });
139
+ }
140
+ });
141
+
142
+ // --- 删除单版本 ---
143
+ router.delete('/:id/versions/:ver', requireAuth, async (req, res) => {
144
+ try {
145
+ const ver = await dbGet(
146
+ 'SELECT * FROM file_versions WHERE file_id = ? AND version = ?',
147
+ [req.params.id, req.params.ver]
148
+ );
149
+ if (!ver) return res.status(404).json({ error: '版本不存在' });
150
+
151
+ // 删除磁盘文件
152
+ const filePath = path.join(UPLOAD_DIR, ver.stored_name);
153
+ if (fs.existsSync(filePath)) await unlinkQuiet(filePath);
154
+
155
+ // 删除版本记录
156
+ await dbRun('DELETE FROM file_versions WHERE id = ?', [ver.id]);
157
+
158
+ logger.audit('file.version.delete', { fileId: parseInt(req.params.id), version: parseInt(req.params.ver), ip: clientIp(req) });
159
+ res.json({ success: true });
160
+ } catch (e) {
161
+ res.status(500).json({ error: '删除版本失败' });
162
+ }
163
+ });
164
+ }
165
+
166
+ module.exports = { registerVersions };
@@ -0,0 +1,16 @@
1
+ // 文件路由入口(re-export 聚合器)。
2
+ //
3
+ // 历史上 routes/files.js 是一个 1097 行的单体文件。已按子域拆分到 routes/files/ 目录:
4
+ // _shared.js 上传配置 + 版本备份序列 + 下载头 + 路径守卫(共享层)
5
+ // list.js GET /, GET /search
6
+ // upload.js POST /upload, /upload-json, /upload-zip-base64
7
+ // crud.js PUT /:id, DELETE /:id, POST /batch
8
+ // detail-serve.js GET /:id, /:id/content, /:id/asset/*, /:id/render, /:id/download
9
+ // overwrite.js POST /:id/overwrite, /:id/overwrite-json
10
+ // versions.js GET /:id/versions, content/render/restore, DELETE version
11
+ // associations.js PUT /:id/tags, star/unstar, /:id/category, GET /:id/stats
12
+ // index.js 聚合器(按原始顺序注册到单一 router)
13
+ //
14
+ // 此文件保留为外部入口,re-export 聚合器,使 server.js 的 require('./routes/files')
15
+ // 与挂载点 /api/files 零变化。行为与拆分前完全一致。
16
+ module.exports = require('./files/index');
@@ -0,0 +1,93 @@
1
+ // Skills 与 MCP 配置路由。从 server.js 提取,行为保持不变。
2
+ // 挂载点:/api(内部路径 /skills、/skills/:name、/skills/:name/download、/mcp/config)
3
+
4
+ const express = require('express');
5
+ const { dbAll } = require('../lib/db');
6
+ const { requireAuth } = require('../lib/middleware/auth');
7
+ const { marked } = require('../lib/templates');
8
+ const { listSkills, getSkill, createZipStream } = require('../skills-registry');
9
+ const logger = require('../logger');
10
+
11
+ const router = express.Router();
12
+
13
+ router.get('/skills', requireAuth, async (req, res) => {
14
+ try {
15
+ res.json({ skills: listSkills() });
16
+ } catch (e) {
17
+ logger.error({ type: 'app', message: '列出 skills 失败', error: e.message });
18
+ res.status(500).json({ error: '列出 skills 失败' });
19
+ }
20
+ });
21
+
22
+ router.get('/skills/:name', requireAuth, async (req, res) => {
23
+ const skill = getSkill(req.params.name);
24
+ if (!skill) return res.status(404).json({ error: 'Skill 不存在' });
25
+ if (skill.installBody) {
26
+ skill.installHtml = marked.parse(skill.installBody, { gfm: true, breaks: false, async: false });
27
+ }
28
+ res.json(skill);
29
+ });
30
+
31
+ // 生成标准 mcpServers 配置块(所有客户端共用同一格式,仅目标文件/说明不同)
32
+ function buildServerConfig(url, token) {
33
+ return {
34
+ mcpServers: {
35
+ jpage: {
36
+ type: 'http',
37
+ url,
38
+ headers: { Authorization: `Bearer ${token || '<YOUR_TOKEN>'}` }
39
+ }
40
+ }
41
+ };
42
+ }
43
+
44
+ router.get('/mcp/config', requireAuth, async (req, res) => {
45
+ const enabled = !!process.env.MCP_TOKEN || true; // 现在总是可以用用户级 Token
46
+ const host = req.headers.host || `localhost:${process.env.PORT || 8858}`;
47
+ const protocol = req.protocol || 'http';
48
+ const url = `${protocol}://${host}/mcp`;
49
+
50
+ // 获取当前用户的 Token 列表
51
+ const tokens = await dbAll(
52
+ 'SELECT id, name, token_prefix, created_at FROM tokens WHERE user_id = ? ORDER BY created_at DESC',
53
+ [req.userId]
54
+ );
55
+
56
+ const globalToken = process.env.MCP_TOKEN && req.userRole === 'admin' ? process.env.MCP_TOKEN : null;
57
+
58
+ // 所有客户端共用同一配置对象;差异仅在目标文件路径/说明文字
59
+ const config = buildServerConfig(url, globalToken);
60
+ const configs = [
61
+ { id: 'claude-code', label: 'Claude Code', path: '.mcp.json(项目根)或 ~/.claude.json', config },
62
+ { id: 'claude-desktop', label: 'Claude Desktop', path: 'claude_desktop_config.json', config },
63
+ { id: 'cursor', label: 'Cursor', path: '~/.cursor/mcp.json', config },
64
+ { id: 'zcode', label: 'ZCode', path: 'ZCode 设置 → MCP 服务器', config },
65
+ { id: 'generic', label: '通用/标准 JSON', path: '任意支持 mcpServers 的客户端', config }
66
+ ];
67
+
68
+ res.json({
69
+ enabled,
70
+ globalToken,
71
+ url,
72
+ tokens,
73
+ config,
74
+ configs
75
+ });
76
+ });
77
+
78
+ router.get('/skills/:name/download', requireAuth, (req, res) => {
79
+ const archive = createZipStream(req.params.name);
80
+ if (!archive) return res.status(404).json({ error: 'Skill 不存在' });
81
+ const fname = `${req.params.name}.zip`;
82
+ const encoded = encodeURIComponent(fname);
83
+ res.setHeader('Content-Type', 'application/zip');
84
+ res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`);
85
+ archive.on('end', () => res.end());
86
+ archive.pipe(res);
87
+ archive.finalize().catch(e => {
88
+ logger.error({ type: 'app', message: 'archiver finalize 失败', error: e.message });
89
+ if (!res.headersSent) res.status(500).json({ error: '打包失败' });
90
+ });
91
+ });
92
+
93
+ module.exports = router;
package/routes/tags.js ADDED
@@ -0,0 +1,65 @@
1
+ // 标签 CRUD 路由。从 server.js 提取,行为保持不变。
2
+ // 挂载点:/api/tags
3
+ // 注:文件的标签关联 /api/files/:id/tags 归 routes/files.js(同为 /api/files 前缀)。
4
+
5
+ const express = require('express');
6
+ const { dbAll, dbGet, dbRun } = require('../lib/db');
7
+ const { requireAuth } = require('../lib/middleware/auth');
8
+ const { clientIp } = require('../lib/util');
9
+ const logger = require('../logger');
10
+
11
+ const router = express.Router();
12
+
13
+ router.get('/', requireAuth, async (req, res) => {
14
+ try {
15
+ const role = req.userRole;
16
+ const userId = req.userId;
17
+ let tags;
18
+ if (role === 'admin') {
19
+ tags = await dbAll(`
20
+ SELECT t.id, t.name, t.created_at, COUNT(ft.file_id) AS file_count
21
+ FROM tags t LEFT JOIN file_tags ft ON t.id = ft.tag_id
22
+ GROUP BY t.id ORDER BY t.name ASC
23
+ `);
24
+ } else {
25
+ tags = await dbAll(`
26
+ SELECT t.id, t.name, t.created_at, COUNT(ft.file_id) AS file_count
27
+ FROM tags t LEFT JOIN file_tags ft ON t.id = ft.tag_id
28
+ LEFT JOIN files f ON ft.file_id = f.id AND f.uploaded_by = ?
29
+ GROUP BY t.id ORDER BY t.name ASC
30
+ `, [userId]);
31
+ }
32
+ res.json({ tags });
33
+ } catch (e) {
34
+ res.status(500).json({ error: '获取标签失败' });
35
+ }
36
+ });
37
+
38
+ router.post('/', requireAuth, async (req, res) => {
39
+ const { name } = req.body || {};
40
+ if (!name || !name.trim()) return res.status(400).json({ error: '标签名不能为空' });
41
+ try {
42
+ const existing = await dbGet('SELECT id, name, created_at FROM tags WHERE name = ?', [name.trim()]);
43
+ if (existing) return res.json(existing);
44
+ const result = await dbRun('INSERT INTO tags (name) VALUES (?)', [name.trim()]);
45
+ res.json({ id: result.lastID, name: name.trim() });
46
+ logger.audit('tag.create', { tagId: result.lastID, tagName: name.trim(), ip: clientIp(req) });
47
+ } catch (e) {
48
+ res.status(500).json({ error: '创建标签失败' });
49
+ }
50
+ });
51
+
52
+ router.delete('/:id', requireAuth, async (req, res) => {
53
+ try {
54
+ const tag = await dbGet('SELECT id FROM tags WHERE id = ?', [req.params.id]);
55
+ if (!tag) return res.status(404).json({ error: '标签不存在' });
56
+ await dbRun('DELETE FROM file_tags WHERE tag_id = ?', [req.params.id]);
57
+ await dbRun('DELETE FROM tags WHERE id = ?', [req.params.id]);
58
+ res.json({ success: true });
59
+ logger.audit('tag.delete', { tagId: req.params.id, ip: clientIp(req) });
60
+ } catch (e) {
61
+ res.status(500).json({ error: '删除标签失败' });
62
+ }
63
+ });
64
+
65
+ module.exports = router;
@@ -0,0 +1,110 @@
1
+ // API Token 管理路由:创建 / 列表 / 查看明文 / 删除。
2
+ // 鉴权用 SHA-256 哈希比对(lib/middleware/auth.js),与可逆加密存储(lib/crypto.js)相互独立:
3
+ // - token_hash:不可逆哈希,用于 Bearer 鉴权。
4
+ // - token_enc:AES-256-GCM 密文,使已登录用户可在 UI 上再次查看/复制明文。
5
+
6
+ const express = require('express');
7
+ const crypto = require('crypto');
8
+ const { dbGet, dbRun, dbAll } = require('../lib/db');
9
+ const { requireAuth } = require('../lib/middleware/auth');
10
+ const { clientIp } = require('../lib/util');
11
+ const { encryptToken, decryptToken } = require('../lib/crypto');
12
+ const logger = require('../logger');
13
+
14
+ const router = express.Router();
15
+
16
+ function generateApiToken() {
17
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
18
+ const bytes = crypto.randomBytes(32);
19
+ let token = 'jp_';
20
+ for (let i = 0; i < 32; i++) {
21
+ token += chars[bytes[i] % chars.length];
22
+ }
23
+ return token;
24
+ }
25
+
26
+ router.get('/', requireAuth, async (req, res) => {
27
+ try {
28
+ const tokens = await dbAll(
29
+ 'SELECT id, name, token_prefix, last_used_at, created_at, token_enc IS NOT NULL AS viewable FROM tokens WHERE user_id = ? ORDER BY created_at DESC',
30
+ [req.userId]
31
+ );
32
+ res.json({ tokens });
33
+ } catch (e) {
34
+ res.status(500).json({ error: '获取令牌列表失败' });
35
+ }
36
+ });
37
+
38
+ router.post('/', requireAuth, async (req, res) => {
39
+ const { name } = req.body || {};
40
+ if (!name || !name.trim()) return res.status(400).json({ error: '令牌名称不能为空' });
41
+ try {
42
+ // 每用户最多 10 个 Token
43
+ const count = await dbGet('SELECT COUNT(*) AS c FROM tokens WHERE user_id = ?', [req.userId]);
44
+ if (count.c >= 10) return res.status(400).json({ error: '最多创建 10 个令牌' });
45
+
46
+ const tokenValue = generateApiToken();
47
+ const tokenHash = crypto.createHash('sha256').update(tokenValue).digest('hex');
48
+ const tokenPrefix = tokenValue.slice(0, 8);
49
+ const tokenEnc = encryptToken(tokenValue); // 可逆加密存储,用于后续查看明文
50
+
51
+ const result = await dbRun(
52
+ 'INSERT INTO tokens (user_id, name, token_hash, token_prefix, token_enc) VALUES (?, ?, ?, ?, ?)',
53
+ [req.userId, name.trim(), tokenHash, tokenPrefix, tokenEnc]
54
+ );
55
+ logger.audit('token.create', { tokenId: result.lastID, name: name.trim(), userId: req.userId, ip: clientIp(req) });
56
+ res.json({
57
+ id: result.lastID,
58
+ name: name.trim(),
59
+ token: tokenValue,
60
+ token_prefix: tokenPrefix,
61
+ });
62
+ } catch (e) {
63
+ res.status(500).json({ error: '创建令牌失败' });
64
+ }
65
+ });
66
+
67
+ // 查看令牌明文:旧令牌(token_enc 为 NULL)不可查看,仅返回友好提示。
68
+ router.post('/:id/reveal', requireAuth, async (req, res) => {
69
+ const tokenId = parseInt(req.params.id);
70
+ if (isNaN(tokenId)) return res.status(400).json({ error: '无效令牌 ID' });
71
+ try {
72
+ const token = await dbGet('SELECT * FROM tokens WHERE id = ?', [tokenId]);
73
+ if (!token) return res.status(404).json({ error: '令牌不存在' });
74
+ if (token.user_id !== req.userId && req.userRole !== 'admin') {
75
+ return res.status(403).json({ error: '无权查看此令牌' });
76
+ }
77
+ if (!token.token_enc) {
78
+ return res.status(409).json({ error: '此令牌创建于功能启用前,无法查看明文,请删除后重建' });
79
+ }
80
+ try {
81
+ const plain = decryptToken(token.token_enc);
82
+ logger.audit('token.reveal', { tokenId, userId: req.userId, ip: clientIp(req) });
83
+ res.json({ token: plain });
84
+ } catch (e) {
85
+ // 密钥变更或密文损坏,无法解密
86
+ res.status(409).json({ error: '此令牌无法解密,请删除后重建' });
87
+ }
88
+ } catch (e) {
89
+ res.status(500).json({ error: '查看令牌失败' });
90
+ }
91
+ });
92
+
93
+ router.delete('/:id', requireAuth, async (req, res) => {
94
+ const tokenId = parseInt(req.params.id);
95
+ if (isNaN(tokenId)) return res.status(400).json({ error: '无效令牌 ID' });
96
+ try {
97
+ const token = await dbGet('SELECT * FROM tokens WHERE id = ?', [tokenId]);
98
+ if (!token) return res.status(404).json({ error: '令牌不存在' });
99
+ if (token.user_id !== req.userId && req.userRole !== 'admin') {
100
+ return res.status(403).json({ error: '无权删除此令牌' });
101
+ }
102
+ await dbRun('DELETE FROM tokens WHERE id = ?', [tokenId]);
103
+ logger.audit('token.delete', { tokenId, userId: req.userId, ip: clientIp(req) });
104
+ res.json({ success: true });
105
+ } catch (e) {
106
+ res.status(500).json({ error: '删除令牌失败' });
107
+ }
108
+ });
109
+
110
+ module.exports = router;