@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,183 @@
1
+ // MCP transport 层:会话生命周期 + Express 路由挂载 + 关闭钩子。
2
+ // 从 mcp-server.js 提取,行为保持不变。
3
+ //
4
+ // 持有模块级状态(transports / sessionActivity / sweep timer),
5
+ // 每次 session initialize 时通过 getServer(callerToken) 重建 server + dispatcher,
6
+ // 把调用者 token 绑进后续所有进程内 API 调用。
7
+
8
+ const { randomUUID } = require('node:crypto');
9
+ const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
10
+ const { isInitializeRequest } = require('@modelcontextprotocol/sdk/types.js');
11
+ const logger = require('../logger');
12
+ const { createDispatcher } = require('../lib/dispatch');
13
+ const { createMcpServer } = require('./server');
14
+
15
+ // 历史:早期 MCP tool 用 fetch('http://127.0.0.1:port/...') 自调用 REST(buildApiClient)。
16
+ // 现已改为进程内 dispatcher(lib/dispatch.js),绕过 TCP + 二次鉴权 DB 查询,
17
+ // 单次调用延迟降 ~80%。原 buildApiClient 已无人引用,本次拆分时移除。
18
+
19
+ // --- 模块级 session 状态 ---
20
+ const transports = {};
21
+ const sessionActivity = {};
22
+ const SESSION_TTL_MS = 60 * 60 * 1000;
23
+ const SESSION_SWEEP_MS = 10 * 60 * 1000;
24
+ let sessionSweepTimer = null;
25
+
26
+ function touchSession(sid) {
27
+ if (sid) sessionActivity[sid] = Date.now();
28
+ }
29
+
30
+ function sweepSessions() {
31
+ const now = Date.now();
32
+ for (const sid of Object.keys(transports)) {
33
+ const last = sessionActivity[sid] || 0;
34
+ if (now - last > SESSION_TTL_MS) {
35
+ logger.info({ type: 'app', message: 'MCP session 超时清理', sessionId: sid, idleMs: now - last });
36
+ try { transports[sid].close(); } catch (e) {
37
+ logger.error({ type: 'app', message: '关闭超时 session 失败', sessionId: sid, error: e.message });
38
+ }
39
+ delete transports[sid];
40
+ delete sessionActivity[sid];
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 挂载 MCP Streamable HTTP 端点(POST/GET/DELETE /mcp)。
47
+ * @param {object} app - Express app
48
+ * @param {object} opts
49
+ * @param {number} opts.port
50
+ * @param {string} opts.mcpToken - 全局 MCP_TOKEN(可为空,此时必须有用户级 Token)
51
+ * @param {string} opts.mcpIp
52
+ * @param {string} opts.protocol
53
+ * @param {function} opts.authenticateRequest - async (tokenValue) => boolean,验证 Bearer token
54
+ */
55
+ function mountMcpServer(app, { port, mcpToken, mcpIp, protocol, authenticateRequest }) {
56
+ if (!mcpToken && !authenticateRequest) {
57
+ logger.info({ type: 'app', message: 'MCP_TOKEN 未设置且无 Token 验权,MCP 端点 /mcp 已禁用' });
58
+ return;
59
+ }
60
+
61
+ if (!sessionSweepTimer) {
62
+ sessionSweepTimer = setInterval(sweepSessions, SESSION_SWEEP_MS);
63
+ if (typeof sessionSweepTimer.unref === 'function') sessionSweepTimer.unref();
64
+ }
65
+
66
+ function getServer(callerToken) {
67
+ // 进程内直调:绕过 fetch('http://127.0.0.1:port/...') 自调用,
68
+ // 消除 TCP 序列化 + 二次鉴权 DB 查询(MCP 端到端延迟降 50-70%)。
69
+ const api = createDispatcher(app, { token: callerToken || mcpToken });
70
+ return createMcpServer({ port, api, mcpIp, protocol });
71
+ }
72
+
73
+ const bearerAuth = async (req, res, next) => {
74
+ const auth = req.headers.authorization;
75
+ if (!auth || !auth.startsWith('Bearer ')) {
76
+ return res.status(401).json({ error: 'MCP 鉴权失败' });
77
+ }
78
+ const tokenValue = auth.slice(7);
79
+
80
+ // 旧 MCP_TOKEN 向后兼容
81
+ if (mcpToken && tokenValue === mcpToken) {
82
+ return next();
83
+ }
84
+
85
+ // 用户级 Token 验证
86
+ if (authenticateRequest) {
87
+ const valid = await authenticateRequest(tokenValue).catch(() => false);
88
+ if (valid) return next();
89
+ }
90
+
91
+ return res.status(401).json({ error: 'MCP 鉴权失败' });
92
+ };
93
+
94
+ const mcpPostHandler = async (req, res) => {
95
+ const sessionId = req.headers['mcp-session-id'];
96
+ try {
97
+ let transport;
98
+ if (sessionId && transports[sessionId]) {
99
+ transport = transports[sessionId];
100
+ touchSession(sessionId);
101
+ } else if (!sessionId && isInitializeRequest(req.body)) {
102
+ transport = new StreamableHTTPServerTransport({
103
+ sessionIdGenerator: () => randomUUID(),
104
+ onsessioninitialized: (sid) => {
105
+ transports[sid] = transport;
106
+ touchSession(sid);
107
+ },
108
+ });
109
+ transport.onclose = () => {
110
+ const sid = transport.sessionId;
111
+ if (sid && transports[sid]) delete transports[sid];
112
+ };
113
+ const callerToken = req.headers.authorization?.startsWith('Bearer ')
114
+ ? req.headers.authorization.slice(7)
115
+ : mcpToken;
116
+ const server = getServer(callerToken);
117
+ await server.connect(transport);
118
+ await transport.handleRequest(req, res, req.body);
119
+ return;
120
+ } else {
121
+ return res.status(404).json({
122
+ jsonrpc: '2.0',
123
+ error: { code: -32000, message: 'Bad Request: 缺少有效 mcp-session-id 或 initialize 请求' },
124
+ id: null,
125
+ });
126
+ }
127
+ await transport.handleRequest(req, res, req.body);
128
+ } catch (e) {
129
+ logger.error({ type: 'app', message: 'MCP POST 错误', error: e.message });
130
+ if (!res.headersSent) {
131
+ res.status(500).json({
132
+ jsonrpc: '2.0',
133
+ error: { code: -32603, message: e.message || 'Internal server error' },
134
+ id: null,
135
+ });
136
+ }
137
+ }
138
+ };
139
+
140
+ const mcpGetHandler = async (req, res) => {
141
+ const sessionId = req.headers['mcp-session-id'];
142
+ if (!sessionId || !transports[sessionId]) {
143
+ return res.status(404).send('Invalid or missing mcp-session-id');
144
+ }
145
+ const transport = transports[sessionId];
146
+ await transport.handleRequest(req, res);
147
+ };
148
+
149
+ const mcpDeleteHandler = async (req, res) => {
150
+ const sessionId = req.headers['mcp-session-id'];
151
+ if (!sessionId || !transports[sessionId]) {
152
+ return res.status(404).send('Invalid or missing mcp-session-id');
153
+ }
154
+ try {
155
+ const transport = transports[sessionId];
156
+ await transport.handleRequest(req, res);
157
+ } catch (e) {
158
+ logger.error({ type: 'app', message: 'MCP DELETE 错误', error: e.message });
159
+ if (!res.headersSent) res.status(500).send('Error processing session termination');
160
+ }
161
+ };
162
+
163
+ app.post('/mcp', bearerAuth, mcpPostHandler);
164
+ app.get('/mcp', bearerAuth, mcpGetHandler);
165
+ app.delete('/mcp', bearerAuth, mcpDeleteHandler);
166
+
167
+ logger.info({ type: 'app', message: 'MCP 端点已挂载', url: `${protocol}://${mcpIp}:${port}/mcp` });
168
+ }
169
+
170
+ async function closeMcpTransports() {
171
+ if (sessionSweepTimer) { clearInterval(sessionSweepTimer); sessionSweepTimer = null; }
172
+ for (const sid of Object.keys(transports)) {
173
+ try {
174
+ await transports[sid].close();
175
+ } catch (e) {
176
+ logger.error({ type: 'app', message: '关闭 MCP transport 失败', sessionId: sid, error: e.message });
177
+ }
178
+ delete transports[sid];
179
+ delete sessionActivity[sid];
180
+ }
181
+ }
182
+
183
+ module.exports = { mountMcpServer, closeMcpTransports };
package/mcp/util.js ADDED
@@ -0,0 +1,63 @@
1
+ // MCP 工具的纯函数 + API 辅助。从 mcp-server.js 提取,行为保持不变。
2
+ // 被 tools-files / tools-tags / tools-versions 等模块共享。
3
+
4
+ // 把任意 payload 包成 MCP tool 结果(text content)。opts.isError 标记工具级错误。
5
+ function textResult(payload, opts = {}) {
6
+ const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
7
+ return {
8
+ content: [{ type: 'text', text }],
9
+ ...(opts.isError ? { isError: true } : {}),
10
+ };
11
+ }
12
+
13
+ // 人类可读的字节大小。
14
+ function formatSize(bytes) {
15
+ if (bytes < 1024) return bytes + ' B';
16
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
17
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
18
+ }
19
+
20
+ // ISO → YYYY-MM-DD HH:mm(falsy 返回「未知时间」)。
21
+ function formatTime(iso) {
22
+ if (!iso) return '未知时间';
23
+ const d = new Date(iso);
24
+ const pad = (n) => String(n).padStart(2, '0');
25
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
26
+ }
27
+
28
+ // 标签名 → id 列表。不存在的标签自动创建(POST /api/tags,重复名返回现有)。
29
+ async function resolveTagIds(api, tags) {
30
+ if (!tags || tags.length === 0) return [];
31
+ const all = await api.get('/api/tags');
32
+ const existing = new Map(all.tags.map(t => [t.name, t.id]));
33
+ const tagIds = [];
34
+ for (const name of tags) {
35
+ if (existing.has(name)) {
36
+ tagIds.push(existing.get(name));
37
+ } else {
38
+ const created = await api.post('/api/tags', { name });
39
+ tagIds.push(created.id);
40
+ existing.set(name, created.id);
41
+ }
42
+ }
43
+ return tagIds;
44
+ }
45
+
46
+ // 上传后为文件设置标签 + 分类(upload_file / batch 用)。
47
+ async function applyTagsAndCategory(api, fileId, tags, categoryId) {
48
+ if (tags && tags.length > 0) {
49
+ const tagIds = await resolveTagIds(api, tags);
50
+ await api.put(`/api/files/${fileId}/tags`, { tagIds });
51
+ }
52
+ if (categoryId) {
53
+ await api.put(`/api/files/${fileId}/category`, { categoryId });
54
+ }
55
+ }
56
+
57
+ module.exports = {
58
+ textResult,
59
+ formatSize,
60
+ formatTime,
61
+ resolveTagIds,
62
+ applyTagsAndCategory,
63
+ };
package/mcp-server.js ADDED
@@ -0,0 +1,20 @@
1
+ // MCP 服务入口(re-export transport 层)。
2
+ //
3
+ // 历史上 mcp-server.js 是一个 717 行的单体文件,承载 MCP 工具注册 + transport 生命周期。
4
+ // 已按职责拆分到 mcp/ 目录:
5
+ // constants.js 共享常量(RESOURCE_MAX_BYTES / ALLOWED_EXTS / MAX_FILE_SIZE)
6
+ // util.js 纯函数 + API 辅助(textResult / formatSize / formatTime /
7
+ // resolveTagIds / applyTagsAndCategory)
8
+ // tools-files.js list_files / upload_file / get_file_content / delete_file /
9
+ // rename_file / get_file_url / star_file / unstar_file
10
+ // tools-versions.js list_file_versions / restore_file_version
11
+ // tools-tags.js list_tags / add_tags_to_file
12
+ // tools-categories.js list_categories / create_category / set_file_category
13
+ // tools-content-templates.js list_content_templates / get_content_template
14
+ // resources.js jpage://files / jpage://file/{id}
15
+ // server.js createMcpServer 工厂(装配 17 tools + 2 resources)
16
+ // transport.js mountMcpServer / closeMcpTransports(会话生命周期)
17
+ //
18
+ // 此文件保留为外部入口,re-export transport 层,使 server.js 的 require('./mcp-server')
19
+ // 零变化。行为与拆分前完全一致(共 17 tools + 2 resources)。
20
+ module.exports = require('./mcp/transport');
@@ -0,0 +1,25 @@
1
+ module.exports = {
2
+ name: 'init_schema',
3
+
4
+ async up(db, { dbRun }) {
5
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS files (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ original_name TEXT NOT NULL,
8
+ stored_name TEXT NOT NULL,
9
+ file_type TEXT NOT NULL,
10
+ size INTEGER NOT NULL,
11
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
12
+ is_public INTEGER NOT NULL DEFAULT 1,
13
+ uploaded_by INTEGER
14
+ )`);
15
+
16
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS users (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ username TEXT UNIQUE NOT NULL,
19
+ password_hash TEXT NOT NULL,
20
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
21
+ )`);
22
+
23
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(created_at DESC)');
24
+ }
25
+ };
@@ -0,0 +1,33 @@
1
+ const crypto = require('crypto');
2
+
3
+ function generateShareKey() {
4
+ return crypto.randomBytes(6).toString('base64url').slice(0, 8);
5
+ }
6
+
7
+ module.exports = {
8
+ name: 'add_share_key',
9
+
10
+ async up(db, { dbRun, dbAll, dbGet }) {
11
+ // 检查 share_key 列是否已存在
12
+ const cols = await dbAll(db, 'PRAGMA table_info(files)');
13
+ const names = new Set(cols.map(c => c.name));
14
+
15
+ if (!names.has('share_key')) {
16
+ await dbRun(db, 'ALTER TABLE files ADD COLUMN share_key TEXT');
17
+ }
18
+
19
+ await dbRun(db, 'CREATE UNIQUE INDEX IF NOT EXISTS idx_files_share_key ON files(share_key)');
20
+
21
+ // 回填已有行
22
+ const rows = await dbAll(db, 'SELECT id FROM files WHERE share_key IS NULL');
23
+ for (const row of rows) {
24
+ const key = generateShareKey();
25
+ try {
26
+ await dbRun(db, 'UPDATE files SET share_key = ? WHERE id = ?', [key, row.id]);
27
+ } catch (_) {
28
+ const retryKey = generateShareKey();
29
+ await dbRun(db, 'UPDATE files SET share_key = ? WHERE id = ?', [retryKey, row.id]);
30
+ }
31
+ }
32
+ }
33
+ };
@@ -0,0 +1,28 @@
1
+ module.exports = {
2
+ name: 'add_roles_and_tokens',
3
+
4
+ async up(db, { dbRun, dbAll }) {
5
+ // 检查 role 列是否已存在
6
+ const cols = await dbAll(db, 'PRAGMA table_info(users)');
7
+ const names = new Set(cols.map(c => c.name));
8
+
9
+ if (!names.has('role')) {
10
+ await dbRun(db, "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
11
+ }
12
+
13
+ // 创建 tokens 表
14
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS tokens (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ user_id INTEGER NOT NULL,
17
+ name TEXT NOT NULL,
18
+ token_hash TEXT NOT NULL,
19
+ token_prefix TEXT NOT NULL,
20
+ last_used_at DATETIME,
21
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
22
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
23
+ )`);
24
+
25
+ await dbRun(db, 'CREATE UNIQUE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash)');
26
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id)');
27
+ }
28
+ };
@@ -0,0 +1,32 @@
1
+ module.exports = {
2
+ name: 'add_version_history',
3
+
4
+ async up(db, { dbRun, dbAll }) {
5
+ // 1. 检查并添加 updated_at 列
6
+ const cols = await dbAll(db, 'PRAGMA table_info(files)');
7
+ const colNames = new Set(cols.map(c => c.name));
8
+
9
+ if (!colNames.has('updated_at')) {
10
+ await dbRun(db, 'ALTER TABLE files ADD COLUMN updated_at DATETIME');
11
+ }
12
+
13
+ // 2. 回填 updated_at
14
+ await dbRun(db, 'UPDATE files SET updated_at = created_at WHERE updated_at IS NULL');
15
+
16
+ // 3. 创建 file_versions 表
17
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS file_versions (
18
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ file_id INTEGER NOT NULL,
20
+ version INTEGER NOT NULL,
21
+ stored_name TEXT NOT NULL,
22
+ size INTEGER NOT NULL,
23
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
24
+ uploaded_by INTEGER,
25
+ UNIQUE(file_id, version),
26
+ FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
27
+ )`);
28
+
29
+ // 4. 创建索引
30
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_fv_file_ver ON file_versions(file_id, version DESC)');
31
+ }
32
+ };
@@ -0,0 +1,49 @@
1
+ module.exports = {
2
+ name: 'tags_starred_categories',
3
+
4
+ async up(db, { dbRun, dbGet, dbAll }) {
5
+ // 创建标签词典表
6
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS tags (
7
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ name TEXT UNIQUE NOT NULL,
9
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
10
+ )`);
11
+
12
+ // 创建文件-标签关联表(多对多)
13
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS file_tags (
14
+ file_id INTEGER NOT NULL,
15
+ tag_id INTEGER NOT NULL,
16
+ PRIMARY KEY (file_id, tag_id)
17
+ )`);
18
+
19
+ // 创建收藏表
20
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS starred_files (
21
+ user_id INTEGER NOT NULL,
22
+ file_id INTEGER NOT NULL,
23
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
24
+ PRIMARY KEY (user_id, file_id)
25
+ )`);
26
+
27
+ // 创建分类表(用户自建,互斥)
28
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS categories (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ name TEXT NOT NULL,
31
+ user_id INTEGER NOT NULL,
32
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
33
+ UNIQUE(name, user_id)
34
+ )`);
35
+
36
+ // files 表加 category_id 列(幂等检测)
37
+ const cols = await dbAll(db, 'PRAGMA table_info(files)');
38
+ const colNames = new Set(cols.map(c => c.name));
39
+ if (!colNames.has('category_id')) {
40
+ await dbRun(db, 'ALTER TABLE files ADD COLUMN category_id INTEGER');
41
+ }
42
+
43
+ // 索引
44
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_file_tags_tag ON file_tags(tag_id)');
45
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_starred_user_file ON starred_files(user_id, file_id)');
46
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_files_category ON files(category_id)');
47
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)');
48
+ }
49
+ };
@@ -0,0 +1,17 @@
1
+ module.exports = {
2
+ name: 'zip_bundle_support',
3
+
4
+ async up(db, { dbRun, dbGet, dbAll }) {
5
+ const cols = await dbAll(db, 'PRAGMA table_info(files)');
6
+ const colNames = new Set(cols.map(c => c.name));
7
+
8
+ if (!colNames.has('is_bundle')) {
9
+ await dbRun(db, 'ALTER TABLE files ADD COLUMN is_bundle INTEGER NOT NULL DEFAULT 0');
10
+ }
11
+ if (!colNames.has('entry_path')) {
12
+ await dbRun(db, 'ALTER TABLE files ADD COLUMN entry_path TEXT DEFAULT NULL');
13
+ }
14
+
15
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_files_is_bundle ON files(is_bundle)');
16
+ }
17
+ };
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ name: 'add_file_type_and_uploaded_by_indexes',
3
+ async up(db, { dbRun }) {
4
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_files_file_type ON files(file_type)');
5
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by)');
6
+ }
7
+ };
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ name: 'add_fts5_full_text_search',
3
+ async up(db, { dbRun }) {
4
+ await dbRun(db, `CREATE VIRTUAL TABLE IF NOT EXISTS file_contents_fts USING fts5(content, file_id UNINDEXED, tokenize='porter unicode61')`);
5
+ }
6
+ };
@@ -0,0 +1,20 @@
1
+ module.exports = {
2
+ name: 'add_link_visits_and_view_count',
3
+ async up(db, { dbRun, dbGet, dbAll }) {
4
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS link_visits (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ file_id INTEGER NOT NULL REFERENCES files(id),
7
+ share_key TEXT,
8
+ ip_hash TEXT,
9
+ user_agent TEXT,
10
+ visited_at DATETIME DEFAULT CURRENT_TIMESTAMP
11
+ )`);
12
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_link_visits_file ON link_visits(file_id)');
13
+ await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_link_visits_ip_file ON link_visits(ip_hash, file_id)');
14
+
15
+ const cols = await dbAll(db, 'PRAGMA table_info(files)');
16
+ if (!cols.some(c => c.name === 'view_count')) {
17
+ await dbRun(db, 'ALTER TABLE files ADD COLUMN view_count INTEGER DEFAULT 0');
18
+ }
19
+ }
20
+ };
@@ -0,0 +1,34 @@
1
+ module.exports = {
2
+ name: 'add_templates_system',
3
+ async up(db, { dbRun, dbGet, dbAll }) {
4
+ // 创建 templates 表
5
+ await dbRun(db, `CREATE TABLE IF NOT EXISTS templates (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ name TEXT NOT NULL UNIQUE,
8
+ description TEXT,
9
+ file_path TEXT NOT NULL,
10
+ is_builtin INTEGER NOT NULL DEFAULT 0,
11
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
12
+ )`);
13
+
14
+ // 注册内置模板
15
+ const builtins = [
16
+ ['default', '默认模板', 'templates/default.html'],
17
+ ['github', 'GitHub 风格', 'templates/github.html'],
18
+ ['academic', '学术风格', 'templates/academic.html'],
19
+ ['dark-pro', '深色专业', 'templates/dark-pro.html'],
20
+ ];
21
+ for (const [name, desc, filePath] of builtins) {
22
+ await dbRun(db,
23
+ `INSERT OR IGNORE INTO templates (name, description, file_path, is_builtin) VALUES (?, ?, ?, 1)`,
24
+ [name, desc, filePath]
25
+ );
26
+ }
27
+
28
+ // files 表增加 template_id 列(幂等)
29
+ const cols = await dbAll(db, `PRAGMA table_info(files)`);
30
+ if (!cols.some(c => c.name === 'template_id')) {
31
+ await dbRun(db, `ALTER TABLE files ADD COLUMN template_id INTEGER REFERENCES templates(id)`);
32
+ }
33
+ }
34
+ };