@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
package/lib/render.js ADDED
@@ -0,0 +1,157 @@
1
+ // 文件渲染:listBundleEntries(bundle 目录清单)+ renderFile(Markdown/HTML/bundle → HTML 文档)。
2
+ // Markdown 渲染页下发严格 CSP(内联 mermaid 脚本靠每次生成的 nonce 放行),
3
+ // HTML 渲染页下发宽松 CSP(用户 HTML 常含合法 script,依赖 iframe sandbox 兜底)。
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { marked, applyTemplate, templateCache, getTemplateForFile, BUILTIN_TEMPLATE_THEMES } = require('./templates');
8
+ const { getRenderedHtml, setRenderedHtml } = require('./render-cache');
9
+ const { UPLOAD_DIR } = require('./paths');
10
+ const { ZIP_MAX_FILE_COUNT } = require('./zip');
11
+ const { generateNonce, markdownCsp, HTML_CSP } = require('./csp');
12
+
13
+ // 枚举 bundle 目录组成,供 /content 返回清单。
14
+ // 安全与体量约束:条目数上限与上传校验一致(ZIP_MAX_FILE_COUNT),
15
+ // 另设深度上限避免畸形嵌套;只收录 bundle 目录内的相对路径,防穿越。
16
+ const BUNDLE_LIST_MAX_DEPTH = 8;
17
+ const BUNDLE_LIST_MAX_ENTRIES = ZIP_MAX_FILE_COUNT;
18
+
19
+ async function listBundleEntries(bundleDir) {
20
+ const root = path.resolve(bundleDir);
21
+ const out = [];
22
+ let truncated = false;
23
+
24
+ async function walk(dir, depth) {
25
+ if (out.length >= BUNDLE_LIST_MAX_ENTRIES || depth > BUNDLE_LIST_MAX_DEPTH) {
26
+ truncated = true;
27
+ return;
28
+ }
29
+ let names;
30
+ try { names = await fs.promises.readdir(dir); }
31
+ catch { return; } // 子目录不可读则跳过,不致整个清单失败
32
+ for (const name of names) {
33
+ const full = path.join(dir, name);
34
+ const resolved = path.resolve(full);
35
+ const rel = path.relative(root, resolved);
36
+ // 仅收录 root 内的条目,跳过越界/穿越项
37
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) continue;
38
+ let st;
39
+ try { st = await fs.promises.stat(full); }
40
+ catch { continue; }
41
+ const relPosix = rel.split(path.sep).join('/');
42
+ if (st.isDirectory()) {
43
+ await walk(full, depth + 1);
44
+ } else {
45
+ if (out.length >= BUNDLE_LIST_MAX_ENTRIES) { truncated = true; return; }
46
+ out.push({ path: relPosix, size: st.size });
47
+ }
48
+ }
49
+ }
50
+
51
+ await walk(bundleDir, 0);
52
+ return { entries: out, truncated };
53
+ }
54
+
55
+ // 发送 Markdown 渲染结果:注入 nonce 到内联 <script>(无 src),下发严格 CSP。
56
+ // 缓存的是「无 nonce 的模板 HTML」,每次发送时动态注入 nonce,保证 nonce 唯一。
57
+ function sendMarkdownHtml(res, html) {
58
+ const nonce = generateNonce();
59
+ // 只给内联 <script>(无 src 属性)加 nonce;外链 <script src> 走 'self' 无需 nonce
60
+ const withNonce = html.replace(/<script(?![^>]*\bsrc=)([^>]*)>/gi, '<script nonce="' + nonce + '"$1>');
61
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
62
+ res.setHeader('Content-Security-Policy', markdownCsp(nonce));
63
+ res.send(withNonce);
64
+ }
65
+
66
+ // 发送 HTML 渲染结果:下发宽松 CSP(依赖 iframe sandbox 兜底)
67
+ function sendHtmlDoc(res, html) {
68
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
69
+ res.setHeader('Content-Security-Policy', HTML_CSP);
70
+ res.send(html);
71
+ }
72
+
73
+ // 向 HTML 文档注入 charset meta(若缺失)
74
+ function ensureCharset(html) {
75
+ if (/<meta[^>]+charset=/i.test(html)) return html;
76
+ const meta = '<meta charset="UTF-8">';
77
+ if (/<head>/i.test(html)) return html.replace(/<head>/i, '<head>\n' + meta);
78
+ if (/<html/i.test(html)) return html.replace(/<html[^>]*>/i, '$&\n<head>' + meta + '</head>');
79
+ return meta + '\n' + html;
80
+ }
81
+
82
+ async function renderFile(res, file) {
83
+ // Bundle 渲染
84
+ if (file.is_bundle) {
85
+ const bundleDir = path.join(UPLOAD_DIR, file.stored_name);
86
+ const entryPath = path.join(bundleDir, file.entry_path || 'index.html');
87
+ const resolved = path.resolve(entryPath);
88
+ const resolvedDir = path.resolve(bundleDir) + path.sep;
89
+ if (!resolved.startsWith(resolvedDir)) return res.status(403).json({ error: '非法路径' });
90
+ try {
91
+ let content = await fs.promises.readFile(entryPath, 'utf-8');
92
+ const entryExt = path.extname(file.entry_path || 'index.html').toLowerCase();
93
+ const baseTag = '<base href="/api/files/' + file.id + '/asset/">';
94
+
95
+ if (entryExt === '.md' || entryExt === '.markdown') {
96
+ // Markdown 入口:marked 渲染 + 模板(命中缓存则跳过昂贵的渲染)
97
+ const cached = getRenderedHtml(file);
98
+ if (cached) return sendMarkdownHtml(res, cached);
99
+ const mdHtml = marked.parse(content, { gfm: true, breaks: false, async: false })
100
+ .replace(/<pre><code class="hljs language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
101
+ (_, code) => `<pre class="mermaid">${code}</pre>`);
102
+ const tplName = await getTemplateForFile(file);
103
+ const tpl = templateCache[tplName] || templateCache['default'];
104
+ const hljsTheme = BUILTIN_TEMPLATE_THEMES[tplName] || 'github';
105
+ let fullHtml = applyTemplate(tpl, file.original_name, mdHtml, hljsTheme);
106
+ if (/<head>/i.test(fullHtml)) {
107
+ fullHtml = fullHtml.replace(/<head>/i, '<head>\n' + baseTag);
108
+ }
109
+ setRenderedHtml(file, fullHtml);
110
+ return sendMarkdownHtml(res, fullHtml);
111
+ }
112
+
113
+ // HTML 入口:注入 <base> 和 charset,宽松 CSP
114
+ if (/<head>/i.test(content)) {
115
+ content = content.replace(/<head>/i, '<head>\n' + baseTag);
116
+ } else if (/<html/i.test(content)) {
117
+ content = content.replace(/<html[^>]*>/i, '$&\n<head>' + baseTag + '</head>');
118
+ }
119
+ return sendHtmlDoc(res, ensureCharset(content));
120
+ } catch (e) {
121
+ if (e && e.code === 'ENOENT') return res.status(404).json({ error: '入口文件已丢失' });
122
+ return res.status(500).json({ error: '渲染失败' });
123
+ }
124
+ }
125
+
126
+ const filePath = path.join(UPLOAD_DIR, file.stored_name);
127
+ try {
128
+ const content = await fs.promises.readFile(filePath, 'utf-8');
129
+
130
+ if (file.file_type === 'markdown') {
131
+ // 命中渲染缓存则直接返回(updated_at 失效,覆盖上传会刷新 key)
132
+ const cached = getRenderedHtml(file);
133
+ if (cached) return sendMarkdownHtml(res, cached);
134
+ const html = marked.parse(content, { gfm: true, breaks: false, async: false })
135
+ .replace(/<pre><code class="hljs language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
136
+ (_, code) => `<pre class="mermaid">${code}</pre>`);
137
+ const tplName = await getTemplateForFile(file);
138
+ const tpl = templateCache[tplName] || templateCache['default'];
139
+ const hljsTheme = BUILTIN_TEMPLATE_THEMES[tplName] || 'github';
140
+ const fullHtml = applyTemplate(tpl, file.original_name, html, hljsTheme);
141
+ setRenderedHtml(file, fullHtml);
142
+ return sendMarkdownHtml(res, fullHtml);
143
+ }
144
+
145
+ return sendHtmlDoc(res, ensureCharset(content));
146
+ } catch (e) {
147
+ if (e && e.code === 'ENOENT') return res.status(404).json({ error: '文件已丢失' });
148
+ res.status(500).json({ error: '渲染失败' });
149
+ }
150
+ }
151
+
152
+ module.exports = {
153
+ BUNDLE_LIST_MAX_DEPTH,
154
+ BUNDLE_LIST_MAX_ENTRIES,
155
+ listBundleEntries,
156
+ renderFile,
157
+ };
@@ -0,0 +1,149 @@
1
+ // 模板系统 + Markdown 渲染管线(marked + highlight.js + KaTeX)。
2
+ // 从 server.js 提取,行为保持不变。
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { marked } = require('marked');
7
+ const { markedHighlight } = require('marked-highlight');
8
+ const hljs = require('highlight.js');
9
+ const katex = require('katex');
10
+ const logger = require('../logger');
11
+ const { dbAll, dbGet } = require('./db');
12
+
13
+ const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
14
+ const templateCache = {};
15
+
16
+ const TEMPLATE_PLACEHOLDERS = {
17
+ katex_css_url: '/vendor/katex/katex.min.css',
18
+ hljs_css_url: '/vendor/highlight.js/styles',
19
+ mermaid_js_url: '/vendor/mermaid/mermaid.min.js',
20
+ hljs_js_url: '/vendor/highlight.js/highlight.min.js',
21
+ katex_js_url: '/vendor/katex/katex.min.js',
22
+ marked_js_url: '/vendor/marked/marked.min.js',
23
+ };
24
+
25
+ function loadTemplates() {
26
+ if (!fs.existsSync(TEMPLATES_DIR)) return;
27
+ const files = fs.readdirSync(TEMPLATES_DIR).filter(f => f.endsWith('.html'));
28
+ for (const f of files) {
29
+ const name = path.basename(f, '.html');
30
+ const raw = fs.readFileSync(path.join(TEMPLATES_DIR, f), 'utf-8');
31
+ templateCache[name] = compileTemplate(raw);
32
+ }
33
+ logger.info({ type: 'app', msg: 'templates loaded', count: Object.keys(templateCache).length });
34
+ }
35
+
36
+ // 预编译模板:静态占位符(vendor URL)在加载时一次替换;运行时只剩 title/content/hljs_theme 三个动态替换,
37
+ // 避免每次渲染都跑 ~8 次正则(含 new RegExp 构造)。返回函数 (title, content, hljsTheme) => html。
38
+ function compileTemplate(raw) {
39
+ let src = raw;
40
+ for (const [key, value] of Object.entries(TEMPLATE_PLACEHOLDERS)) {
41
+ src = src.split('{{' + key + '}}').join(value);
42
+ }
43
+ return function applyCompiled(title, content, hljsTheme) {
44
+ return src
45
+ .split('{{title}}').join(title)
46
+ .split('{{content}}').join(content)
47
+ .split('{{hljs_theme}}').join(hljsTheme || 'github');
48
+ };
49
+ }
50
+
51
+ function applyTemplate(tplFn, title, content, hljsTheme) {
52
+ return tplFn(title, content, hljsTheme);
53
+ }
54
+
55
+ const BUILTIN_TEMPLATE_THEMES = {
56
+ default: 'github',
57
+ github: 'github',
58
+ academic: 'github',
59
+ 'dark-pro': 'github-dark-dimmed',
60
+ };
61
+
62
+ let templateNameToId = {};
63
+
64
+ async function loadTemplateNameMap() {
65
+ const rows = await dbAll('SELECT id, name FROM templates');
66
+ templateNameToId = {};
67
+ for (const r of rows) templateNameToId[r.name] = r.id;
68
+ }
69
+
70
+ async function getTemplateForFile(file) {
71
+ const tplId = file.template_id;
72
+ if (tplId) {
73
+ const row = await dbGet('SELECT name FROM templates WHERE id = ?', [tplId]);
74
+ if (row && templateCache[row.name]) return row.name;
75
+ }
76
+ return 'default';
77
+ }
78
+
79
+ function renderKatex(tex, displayMode) {
80
+ try {
81
+ return katex.renderToString(tex, {
82
+ displayMode,
83
+ throwOnError: false,
84
+ output: 'html',
85
+ strict: false
86
+ });
87
+ } catch (e) {
88
+ return `<code class="katex-error">${tex}</code>`;
89
+ }
90
+ }
91
+
92
+ // --- marked 渲染管线配置(highlight.js + KaTeX 扩展)---
93
+ marked.use(markedHighlight({
94
+ langPrefix: 'hljs language-',
95
+ highlight(code, lang) {
96
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
97
+ return hljs.highlight(code, { language, ignoreIllegals: true }).value;
98
+ }
99
+ }));
100
+
101
+ marked.use({
102
+ extensions: [
103
+ {
104
+ name: 'katexInline',
105
+ level: 'inline',
106
+ start(src) { return src.indexOf('$'); },
107
+ tokenizer(src) {
108
+ const match = /^\$([^\$\n]+?)\$/.exec(src);
109
+ if (!match) return;
110
+ return {
111
+ type: 'katexInline',
112
+ raw: match[0],
113
+ text: match[1]
114
+ };
115
+ },
116
+ renderer(token) {
117
+ return renderKatex(token.text, false);
118
+ }
119
+ },
120
+ {
121
+ name: 'katexBlock',
122
+ level: 'block',
123
+ start(src) { return src.indexOf('$$'); },
124
+ tokenizer(src) {
125
+ const match = /^\$\$([\s\S]+?)\$\$(?:\n|$)/.exec(src);
126
+ if (!match) return;
127
+ return {
128
+ type: 'katexBlock',
129
+ raw: match[0],
130
+ text: match[1]
131
+ };
132
+ },
133
+ renderer(token) {
134
+ return `<div class="katex-display">${renderKatex(token.text, true)}</div>\n`;
135
+ }
136
+ }
137
+ ]
138
+ });
139
+
140
+ module.exports = {
141
+ templateCache,
142
+ BUILTIN_TEMPLATE_THEMES,
143
+ loadTemplates,
144
+ applyTemplate,
145
+ loadTemplateNameMap,
146
+ getTemplateForFile,
147
+ renderKatex,
148
+ marked,
149
+ };
package/lib/util.js ADDED
@@ -0,0 +1,66 @@
1
+ // 通用工具函数:纯逻辑或仅依赖标准库/Express 请求对象,无 DB/全局状态耦合。
2
+ // 从 server.js 提取,行为保持不变。
3
+
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+
7
+ // --- UTC 时间工具 ---
8
+ // 统一存 UTC:与 SQLite 的 CURRENT_TIMESTAMP / datetime('now') 一致,
9
+ // 避免跨时区部署时的时间偏差。展示层(前端)负责转本地时区。
10
+ // 比原 toLocaleString(Asia/Shanghai) 快约一个数量级。
11
+ function now() {
12
+ return new Date().toISOString().slice(0, 19).replace('T', ' ');
13
+ }
14
+
15
+ // --- 异步文件清理 ---
16
+ function unlinkQuiet(p) { return fs.promises.unlink(p).catch(() => {}); }
17
+
18
+ // 8 位短链 key(base64url,去 padding)
19
+ function generateShareKey() {
20
+ return crypto.randomBytes(6).toString('base64url').slice(0, 8);
21
+ }
22
+
23
+ // 取客户端 IP(优先信任反代的 X-Forwarded-For,配合 app.set('trust proxy', 1))
24
+ function clientIp(req) {
25
+ return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
26
+ req.socket?.remoteAddress || '-';
27
+ }
28
+
29
+ // 当前用户 id:优先中间件写入的 req.userId,回退到 session
30
+ function currentUserId(req) {
31
+ return req.userId || (req.session && req.session.userId) || null;
32
+ }
33
+
34
+ // multer 上传的中文文件名解码:部分客户端用 latin1 传输 UTF-8 文件名,
35
+ // 这里还原成原始 UTF-8。已包含非 latin1 字符则视为已正确解码。
36
+ function decodeFilename(name) {
37
+ if (!name) return name;
38
+ for (let i = 0; i < name.length; i++) {
39
+ if (name.charCodeAt(i) > 255) return name;
40
+ }
41
+ const buf = Buffer.from(name, 'latin1');
42
+ const decoded = buf.toString('utf8');
43
+ if (Buffer.from(decoded).equals(buf)) return decoded;
44
+ return name;
45
+ }
46
+
47
+ // 生成可读随机密码(去掉易混字符 0/O/1/l/I)
48
+ function generateReadablePassword(length) {
49
+ const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
50
+ const bytes = crypto.randomBytes(length);
51
+ let pwd = '';
52
+ for (let i = 0; i < length; i++) {
53
+ pwd += chars[bytes[i] % chars.length];
54
+ }
55
+ return pwd;
56
+ }
57
+
58
+ module.exports = {
59
+ now,
60
+ unlinkQuiet,
61
+ generateShareKey,
62
+ clientIp,
63
+ currentUserId,
64
+ decodeFilename,
65
+ generateReadablePassword,
66
+ };
@@ -0,0 +1,59 @@
1
+ // view_count 内存累加 + 批量 flush。
2
+ // 短链 /s/:key 是热点写路径。把 UPDATE files SET view_count 累积到内存,
3
+ // 定时(30s)或进程退出时批量回写,减少每访问一次就一次写。
4
+ // 从 server.js 提取,行为保持不变。
5
+
6
+ const crypto = require('crypto');
7
+ const { dbRun, dbGet } = require('./db');
8
+ const { clientIp } = require('./util');
9
+
10
+ const VIEW_COUNT_BUFFER = new Map(); // fileId -> pending increments
11
+ const VIEW_COUNT_FLUSH_MS = 30000;
12
+ let viewCountFlushTimer = null;
13
+
14
+ async function flushViewCounts() {
15
+ if (!VIEW_COUNT_BUFFER.size) return;
16
+ const entries = [...VIEW_COUNT_BUFFER.entries()];
17
+ VIEW_COUNT_BUFFER.clear();
18
+ for (const [fileId, n] of entries) {
19
+ await dbRun('UPDATE files SET view_count = view_count + ? WHERE id = ?', [n, fileId]).catch(() => {});
20
+ }
21
+ }
22
+
23
+ function getPendingViewCount(fileId) {
24
+ return VIEW_COUNT_BUFFER.get(fileId) || 0;
25
+ }
26
+
27
+ function bufferViewCount(fileId) {
28
+ VIEW_COUNT_BUFFER.set(fileId, (VIEW_COUNT_BUFFER.get(fileId) || 0) + 1);
29
+ }
30
+
31
+ function scheduleViewCountFlush() {
32
+ if (viewCountFlushTimer) return;
33
+ viewCountFlushTimer = setInterval(flushViewCounts, VIEW_COUNT_FLUSH_MS);
34
+ if (typeof viewCountFlushTimer.unref === 'function') viewCountFlushTimer.unref();
35
+ }
36
+
37
+ async function recordVisit(file, req) {
38
+ const ip = clientIp(req);
39
+ const ipHash = crypto.createHash('sha256').update(ip + process.env.SESSION_SECRET).digest('hex').slice(0, 16);
40
+ const ua = (req.headers['user-agent'] || '').slice(0, 200);
41
+ const recent = await dbGet(
42
+ "SELECT id FROM link_visits WHERE file_id = ? AND ip_hash = ? AND visited_at > datetime('now','-5 minutes') LIMIT 1",
43
+ [file.id, ipHash]
44
+ );
45
+ if (recent) return;
46
+ await dbRun(
47
+ 'INSERT INTO link_visits (file_id, share_key, ip_hash, user_agent) VALUES (?, ?, ?, ?)',
48
+ [file.id, file.share_key, ipHash, ua]
49
+ );
50
+ bufferViewCount(file.id);
51
+ }
52
+
53
+ module.exports = {
54
+ flushViewCounts,
55
+ getPendingViewCount,
56
+ bufferViewCount,
57
+ scheduleViewCountFlush,
58
+ recordVisit,
59
+ };
package/lib/zip.js ADDED
@@ -0,0 +1,192 @@
1
+ // ZIP 上传处理:安全校验、解压、bundle/batch 分类与入库。
2
+ // 从 server.js 提取,行为保持不变。
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const JSZip = require('jszip');
7
+ const logger = require('../logger');
8
+ const { dbRun, dbGet } = require('./db');
9
+ const { UPLOAD_DIR } = require('./paths');
10
+ const { now, generateShareKey, currentUserId, clientIp } = require('./util');
11
+ const { isFtsIndexable, indexFileContent } = require('./fts');
12
+
13
+ // --- ZIP 安全常量 ---
14
+ const ZIP_MAX_FILE_COUNT = 1000;
15
+ const ZIP_MAX_EXTRACTED_SIZE = 200 * 1024 * 1024;
16
+ const ZIP_MAX_SINGLE_FILE_SIZE = 50 * 1024 * 1024;
17
+
18
+ // --- ZIP 工具函数 ---
19
+
20
+ async function validateZipEntries(zip) {
21
+ const entries = [];
22
+ let fileCount = 0;
23
+ return new Promise((resolve, reject) => {
24
+ zip.forEach((normalizedPath, zipEntry) => {
25
+ if (normalizedPath.includes('..')) {
26
+ return reject(new Error('ZIP 条目路径包含目录穿越: ' + (zipEntry.unsafeOriginalName || normalizedPath)));
27
+ }
28
+ if (zipEntry.unixPermissions != null &&
29
+ (zipEntry.unixPermissions & 0o170000) === 0o120000) {
30
+ return reject(new Error('ZIP 包含符号链接: ' + (zipEntry.unsafeOriginalName || normalizedPath)));
31
+ }
32
+ if (!normalizedPath.trim() || zipEntry.dir) return;
33
+ fileCount++;
34
+ entries.push({ name: normalizedPath, originalName: zipEntry.unsafeOriginalName || normalizedPath });
35
+ });
36
+ if (fileCount === 0) return reject(new Error('ZIP 包中无文件'));
37
+ if (fileCount > ZIP_MAX_FILE_COUNT) return reject(new Error('ZIP 包含 ' + fileCount + ' 个文件,超过上限 ' + ZIP_MAX_FILE_COUNT));
38
+ resolve(entries);
39
+ });
40
+ }
41
+
42
+ async function extractEntries(zip, entries, targetDir) {
43
+ let totalSize = 0;
44
+ const results = [];
45
+ const resolvedTarget = path.resolve(targetDir) + path.sep;
46
+ for (const entry of entries) {
47
+ const zipFile = zip.file(entry.name);
48
+ if (!zipFile) continue;
49
+ const buf = await zipFile.async('nodebuffer');
50
+ if (buf.length > ZIP_MAX_SINGLE_FILE_SIZE) throw new Error('文件 ' + entry.name + ' 解压后超过单文件限制');
51
+ totalSize += buf.length;
52
+ if (totalSize > ZIP_MAX_EXTRACTED_SIZE) throw new Error('解压总大小超过 ' + Math.round(ZIP_MAX_EXTRACTED_SIZE / 1024 / 1024) + 'MB 限制');
53
+ const filePath = path.join(targetDir, entry.name);
54
+ if (!path.resolve(filePath).startsWith(resolvedTarget)) throw new Error('路径穿越: ' + entry.name);
55
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
56
+ await fs.promises.writeFile(filePath, buf);
57
+ results.push({ name: entry.name, size: buf.length });
58
+ }
59
+ return { entries: results, totalSize };
60
+ }
61
+
62
+ function findEntryHtml(entries) {
63
+ const htmlExts = ['.html', '.htm'];
64
+ for (const name of ['index.html', 'index.htm']) {
65
+ const found = entries.find(e => e.name.toLowerCase() === name);
66
+ if (found) return found.name;
67
+ }
68
+ const rootHtmls = entries.filter(e =>
69
+ htmlExts.some(ext => e.name.toLowerCase().endsWith(ext)) && !e.name.includes('/')
70
+ ).sort((a, b) => a.name.localeCompare(b.name));
71
+ if (rootHtmls.length > 0) return rootHtmls[0].name;
72
+ for (const name of ['index.html', 'index.htm']) {
73
+ const found = entries.find(e => e.name.split('/').pop().toLowerCase() === name);
74
+ if (found) return found.name;
75
+ }
76
+ const anyHtml = entries.find(e => htmlExts.some(ext => e.name.toLowerCase().endsWith(ext)));
77
+ return anyHtml ? anyHtml.name : null;
78
+ }
79
+
80
+ function classifyZip(entries) {
81
+ const htmlExts = ['.html', '.htm'];
82
+ const assetExts = ['.css', '.js', '.json', '.png', '.jpg', '.jpeg', '.gif',
83
+ '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf', '.eot',
84
+ '.mp3', '.mp4', '.webm', '.ogg', '.wav', '.pdf',
85
+ '.map', '.webmanifest', '.xml', '.txt'];
86
+ const htmlFiles = entries.filter(e => htmlExts.some(ext => e.name.toLowerCase().endsWith(ext)));
87
+ const mdFiles = entries.filter(e => e.name.toLowerCase().endsWith('.md') || e.name.toLowerCase().endsWith('.markdown'));
88
+ const assetFiles = entries.filter(e => assetExts.some(ext => e.name.toLowerCase().endsWith(ext)));
89
+ const hasSubDirs = entries.some(e => e.name.includes('/'));
90
+ if (htmlFiles.length === 0 && mdFiles.length === 0) return { type: 'reject', reason: 'ZIP 中无 HTML 或 Markdown 文件' };
91
+ // 纯 Markdown + 资源文件:作为 bundle 处理,第一个 MD 文件作为入口
92
+ if (htmlFiles.length === 0 && mdFiles.length >= 1 && assetFiles.length > 0) {
93
+ return { type: 'bundle', entryFile: mdFiles[0].name };
94
+ }
95
+ const hasRootIndex = entries.some(e => e.name.toLowerCase() === 'index.html' || e.name.toLowerCase() === 'index.htm');
96
+ if (htmlFiles.length >= 1 && hasRootIndex && (hasSubDirs || assetFiles.length > 0)) return { type: 'bundle', entryFile: findEntryHtml(entries) };
97
+ if (htmlFiles.length >= 1 && (hasSubDirs || assetFiles.length > 0) && mdFiles.length === 0) {
98
+ const entry = findEntryHtml(entries);
99
+ if (entry) return { type: 'bundle', entryFile: entry };
100
+ }
101
+ if (!hasSubDirs && assetFiles.length === 0) return { type: 'batch', files: [...htmlFiles, ...mdFiles] };
102
+ if (htmlFiles.length === 1) return { type: 'bundle', entryFile: findEntryHtml(entries) };
103
+ return { type: 'batch', files: [...htmlFiles, ...mdFiles] };
104
+ }
105
+
106
+ async function handleZipUpload(req, res, zipBuffer) {
107
+ try {
108
+ const zip = await JSZip.loadAsync(zipBuffer);
109
+ const entries = await validateZipEntries(zip);
110
+ const classification = classifyZip(entries);
111
+
112
+ if (classification.type === 'reject') {
113
+ return res.status(400).json({ error: classification.reason });
114
+ }
115
+
116
+ const isPublic = req.body.isPublic === 'true' || req.body.isPublic === true;
117
+ const userId = currentUserId(req);
118
+
119
+ if (classification.type === 'bundle') {
120
+ const dirName = Date.now() + '-' + Math.round(Math.random() * 1e9);
121
+ const bundleDir = path.join(UPLOAD_DIR, dirName);
122
+ await extractEntries(zip, entries, bundleDir);
123
+
124
+ const totalSize = await fs.promises.readdir(bundleDir).then(files =>
125
+ Promise.all(files.map(f => fs.promises.stat(path.join(bundleDir, f)).then(s => s.size)))
126
+ ).then(sizes => sizes.reduce((a, b) => a + b, 0));
127
+
128
+ const originalName = req.file ? req.file.originalname : (req.body && req.body.name) || 'upload.zip';
129
+ const entryExt = path.extname(classification.entryFile).toLowerCase();
130
+ const fileType = (entryExt === '.md' || entryExt === '.markdown') ? 'markdown' : 'html';
131
+
132
+ const result = await dbRun(
133
+ 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, updated_at, is_bundle, entry_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
134
+ [originalName, dirName, fileType, totalSize, isPublic ? 1 : 0, userId, generateShareKey(), now(), classification.entryFile]
135
+ );
136
+
137
+ const shareKey = await dbGet('SELECT share_key FROM files WHERE id = ?', [result.lastID]).then(r => r?.share_key);
138
+ logger.audit('file.upload', { fileId: result.lastID, fileName: originalName, fileType: 'bundle', size: totalSize, ip: clientIp(req) });
139
+ return res.json({
140
+ id: result.lastID,
141
+ original_name: originalName,
142
+ file_type: fileType,
143
+ size: totalSize,
144
+ is_public: isPublic ? 1 : 0,
145
+ is_bundle: 1,
146
+ entry_path: classification.entryFile,
147
+ share_key: shareKey
148
+ });
149
+ }
150
+
151
+ // batch 模式
152
+ const results = [];
153
+ for (const entry of classification.files) {
154
+ const zipFile = zip.file(entry.name);
155
+ if (!zipFile) continue;
156
+ const buf = await zipFile.async('nodebuffer');
157
+ const ext = path.extname(entry.name).toLowerCase();
158
+ const fileType = (ext === '.md' || ext === '.markdown') ? 'markdown' : 'html';
159
+ const unique = Date.now() + '-' + Math.round(Math.random() * 1e9);
160
+ const storedName = unique + ext;
161
+ const filePath = path.join(UPLOAD_DIR, storedName);
162
+ await fs.promises.writeFile(filePath, buf);
163
+
164
+ const baseName = path.basename(entry.name);
165
+ const dbResult = await dbRun(
166
+ 'INSERT INTO files (original_name, stored_name, file_type, size, is_public, uploaded_by, share_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
167
+ [baseName, storedName, fileType, buf.length, isPublic ? 1 : 0, userId, generateShareKey(), now()]
168
+ );
169
+ if (isFtsIndexable(fileType, storedName)) {
170
+ indexFileContent(dbResult.lastID, storedName);
171
+ }
172
+ results.push({ id: dbResult.lastID, original_name: baseName, file_type: fileType, size: buf.length });
173
+ }
174
+
175
+ logger.audit('file.upload', { fileType: 'batch', count: results.length, ip: clientIp(req) });
176
+ return res.json({ type: 'batch', count: results.length, files: results });
177
+ } catch (e) {
178
+ logger.error({ type: 'app', action: 'zip.upload', error: e.message });
179
+ return res.status(500).json({ error: 'ZIP 处理失败: ' + e.message });
180
+ }
181
+ }
182
+
183
+ module.exports = {
184
+ ZIP_MAX_FILE_COUNT,
185
+ ZIP_MAX_EXTRACTED_SIZE,
186
+ ZIP_MAX_SINGLE_FILE_SIZE,
187
+ validateZipEntries,
188
+ extractEntries,
189
+ findEntryHtml,
190
+ classifyZip,
191
+ handleZipUpload,
192
+ };
package/logger.js ADDED
@@ -0,0 +1,16 @@
1
+ const JSONLogger = {
2
+ _output(level, obj) {
3
+ const entry = { level, timestamp: new Date().toISOString(), ...obj };
4
+ const line = JSON.stringify(entry);
5
+ if (level === 'error') process.stderr.write(line + '\n');
6
+ else process.stdout.write(line + '\n');
7
+ },
8
+ info(obj) { this._output('info', obj); },
9
+ warn(obj) { this._output('warn', obj); },
10
+ error(obj) { this._output('error', obj); },
11
+ audit(action, details = {}) {
12
+ this._output('info', { type: 'audit', action, ...details });
13
+ },
14
+ };
15
+
16
+ module.exports = JSONLogger;
package/mailer.js ADDED
@@ -0,0 +1,34 @@
1
+ const nodemailer = require('nodemailer');
2
+ const logger = require('./logger');
3
+
4
+ let transporter = null;
5
+ let fromAddress = '';
6
+ let appUrl = '';
7
+
8
+ function initMailer() {
9
+ const { SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, SMTP_FROM, APP_URL } = process.env;
10
+ if (!SMTP_HOST) return false;
11
+
12
+ transporter = nodemailer.createTransport({
13
+ host: SMTP_HOST,
14
+ port: parseInt(SMTP_PORT) || 465,
15
+ secure: SMTP_SECURE !== 'false',
16
+ auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
17
+ });
18
+ fromAddress = SMTP_FROM || SMTP_USER || 'noreply@jpage.local';
19
+ appUrl = APP_URL || 'http://localhost:8858';
20
+ logger.info({ type: 'app', message: 'SMTP 已配置', host: SMTP_HOST, port: SMTP_PORT || 465 });
21
+ return true;
22
+ }
23
+
24
+ async function sendMail(to, subject, html) {
25
+ if (!transporter) throw new Error('SMTP 未配置');
26
+ const info = await transporter.sendMail({ from: fromAddress, to, subject, html });
27
+ logger.info({ type: 'app', message: '邮件已发送', to, subject, messageId: info.messageId });
28
+ return info;
29
+ }
30
+
31
+ function getAppUrl() { return appUrl || process.env.APP_URL || 'http://localhost:8858'; }
32
+ function isMailerConfigured() { return !!transporter; }
33
+
34
+ module.exports = { initMailer, sendMail, getAppUrl, isMailerConfigured };