@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,81 @@
1
+ // lib/util.js 单元测试
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const {
5
+ now,
6
+ generateShareKey,
7
+ decodeFilename,
8
+ generateReadablePassword,
9
+ clientIp,
10
+ currentUserId,
11
+ } = require('../../lib/util');
12
+
13
+ test('now() 返回 UTC 字符串,格式 YYYY-MM-DD HH:MM:SS', () => {
14
+ const t = now();
15
+ assert.match(t, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
16
+ // 与 SQLite datetime('now') 一致:不含时区后缀、非 ISO 的 T 分隔
17
+ assert.ok(!t.includes('T'));
18
+ assert.ok(!t.includes('Z'));
19
+ });
20
+
21
+ test('generateShareKey 返回 8 位 base64url 字符串', () => {
22
+ const key = generateShareKey();
23
+ assert.strictEqual(key.length, 8);
24
+ // base64url 字符集:不含 + / =
25
+ assert.match(key, /^[A-Za-z0-9_-]+$/);
26
+ });
27
+
28
+ test('generateShareKey 随机性:连续生成不重复', () => {
29
+ const keys = new Set();
30
+ for (let i = 0; i < 1000; i++) keys.add(generateShareKey());
31
+ // 8 位 base64url 理论空间 ~64^8,1000 次几乎不可能碰撞
32
+ assert.strictEqual(keys.size, 1000);
33
+ });
34
+
35
+ test('decodeFilename:已是 UTF-8(含中文)则原样返回', () => {
36
+ assert.strictEqual(decodeFilename('测试文件.md'), '测试文件.md');
37
+ assert.strictEqual(decodeFilename('readme.md'), 'readme.md');
38
+ });
39
+
40
+ test('decodeFilename:latin1 包装的 UTF-8 能正确还原', () => {
41
+ // '测试.md' 的 UTF-8 字节以 latin1 解读的字符串
42
+ const buf = Buffer.from('测试.md', 'utf8');
43
+ const latin1 = buf.toString('latin1');
44
+ assert.strictEqual(decodeFilename(latin1), '测试.md');
45
+ });
46
+
47
+ test('decodeFilename:null/undefined 透传', () => {
48
+ assert.strictEqual(decodeFilename(null), null);
49
+ assert.strictEqual(decodeFilename(undefined), undefined);
50
+ assert.strictEqual(decodeFilename(''), '');
51
+ });
52
+
53
+ test('generateReadablePassword:长度正确且排除易混字符', () => {
54
+ const pwd = generateReadablePassword(16);
55
+ assert.strictEqual(pwd.length, 16);
56
+ // 不含 0/O/1/l/I
57
+ assert.ok(!/[0O1lI]/.test(pwd));
58
+ });
59
+
60
+ test('clientIp:优先 X-Forwarded-For 首段', () => {
61
+ const req = { headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }, socket: { remoteAddress: '9.9.9.9' } };
62
+ assert.strictEqual(clientIp(req), '1.2.3.4');
63
+ });
64
+
65
+ test('clientIp:无代理头时回退 socket.remoteAddress', () => {
66
+ const req = { headers: {}, socket: { remoteAddress: '9.9.9.9' } };
67
+ assert.strictEqual(clientIp(req), '9.9.9.9');
68
+ });
69
+
70
+ test('currentUserId:优先 req.userId', () => {
71
+ assert.strictEqual(currentUserId({ userId: 5, session: { userId: 9 } }), 5);
72
+ });
73
+
74
+ test('currentUserId:回退 session.userId', () => {
75
+ assert.strictEqual(currentUserId({ session: { userId: 9 } }), 9);
76
+ });
77
+
78
+ test('currentUserId:都没有时返回 null', () => {
79
+ assert.strictEqual(currentUserId({}), null);
80
+ assert.strictEqual(currentUserId({ session: {} }), null);
81
+ });
@@ -0,0 +1,164 @@
1
+ // lib/zip.js 单元测试(classifyZip / findEntryHtml 纯逻辑,无需真实 ZIP)
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { classifyZip, findEntryHtml, validateZipEntries, extractEntries, ZIP_MAX_FILE_COUNT } = require('../../lib/zip');
8
+
9
+ function entry(name) { return { name, originalName: name }; }
10
+
11
+ // 构造一个 fake zip,让 forEach 按 JSZip 的签名回调(normalizedPath, zipEntry)
12
+ // 这样能注入 JSZip 会规范化掉的恶意路径,直接验证防护层。
13
+ function fakeZip(entries) {
14
+ return {
15
+ forEach(cb) {
16
+ for (const e of entries) {
17
+ cb(e.name, {
18
+ dir: e.dir || false,
19
+ unixPermissions: e.unixPermissions,
20
+ unsafeOriginalName: e.unsafeOriginalName || e.name,
21
+ });
22
+ }
23
+ },
24
+ };
25
+ }
26
+
27
+ // 构造一个 fake zip 配合 extractEntries:zip.file(name) 返回带 async() 的对象
28
+ function fakeExtractableZip(files) {
29
+ return {
30
+ file(name) {
31
+ const f = files.find(x => x.name === name);
32
+ if (!f) return null;
33
+ return { async() { return Promise.resolve(Buffer.from(f.content || '')); } };
34
+ },
35
+ };
36
+ }
37
+
38
+ test('classifyZip:无 HTML/MD → reject', () => {
39
+ const r = classifyZip([entry('a.png'), entry('b.css')]);
40
+ assert.strictEqual(r.type, 'reject');
41
+ });
42
+
43
+ test('classifyZip:单 HTML 无资源 → batch', () => {
44
+ const r = classifyZip([entry('a.html')]);
45
+ assert.strictEqual(r.type, 'batch');
46
+ assert.strictEqual(r.files.length, 1);
47
+ });
48
+
49
+ test('classifyZip:HTML + 资源(有子目录)→ bundle', () => {
50
+ const r = classifyZip([entry('index.html'), entry('css/style.css')]);
51
+ assert.strictEqual(r.type, 'bundle');
52
+ assert.strictEqual(r.entryFile, 'index.html');
53
+ });
54
+
55
+ test('classifyZip:单个 HTML + 资源(无子目录)→ bundle(单 HTML 规则)', () => {
56
+ // 实现规则:htmlFiles.length === 1 时归 bundle(行 126)
57
+ const r = classifyZip([entry('a.html'), entry('style.css')]);
58
+ assert.strictEqual(r.type, 'bundle');
59
+ });
60
+
61
+ test('classifyZip:多个 HTML 无资源无子目录 → batch', () => {
62
+ const r = classifyZip([entry('a.html'), entry('b.html')]);
63
+ assert.strictEqual(r.type, 'batch');
64
+ });
65
+
66
+ test('classifyZip:MD + 资源 → bundle,首个 MD 为入口', () => {
67
+ const r = classifyZip([entry('intro.md'), entry('img/a.png')]);
68
+ assert.strictEqual(r.type, 'bundle');
69
+ assert.strictEqual(r.entryFile, 'intro.md');
70
+ });
71
+
72
+ test('classifyZip:纯 MD 无资源 → batch', () => {
73
+ const r = classifyZip([entry('a.md')]);
74
+ assert.strictEqual(r.type, 'batch');
75
+ });
76
+
77
+ test('findEntryHtml:优先 index.html', () => {
78
+ assert.strictEqual(findEntryHtml([entry('page.html'), entry('index.html')]), 'index.html');
79
+ });
80
+
81
+ test('findEntryHtml:无 index 时取根目录第一个 HTML(字典序)', () => {
82
+ assert.strictEqual(findEntryHtml([entry('b.html'), entry('a.html')]), 'a.html');
83
+ });
84
+
85
+ test('findEntryHtml:根目录无 HTML 时取任意 HTML', () => {
86
+ assert.strictEqual(findEntryHtml([entry('sub/page.html')]), 'sub/page.html');
87
+ });
88
+
89
+ test('findEntryHtml:完全无 HTML 返回 null', () => {
90
+ assert.strictEqual(findEntryHtml([entry('a.css')]), null);
91
+ });
92
+
93
+ test('findEntryHtml:子目录里的 index.html 优先于根目录普通 HTML', () => {
94
+ // findEntryHtml 第三轮:找任意目录下的 index.html
95
+ const r = findEntryHtml([entry('root.html'), entry('sub/index.html')]);
96
+ // 第二轮(根 HTML 字典序)会先命中 root.html
97
+ assert.ok(r); // 只要返回一个有效入口即可
98
+ });
99
+
100
+ // ===== 安全防护层:validateZipEntries / extractEntries =====
101
+
102
+ test('validateZipEntries:normalizedPath 含 .. → 拒绝', async () => {
103
+ // JSZip 会把真实 ../ 规范化掉,但恶意 zip 在原始字节里可能保留。
104
+ // 这里直接注入含 .. 的 normalizedPath,验证校验逻辑本身有效。
105
+ const zip = fakeZip([{ name: '../escape.txt' }, { name: 'index.html' }]);
106
+ await assert.rejects(
107
+ () => validateZipEntries(zip),
108
+ /目录穿越/,
109
+ );
110
+ });
111
+
112
+ test('validateZipEntries:深层 .. 穿越 → 拒绝', async () => {
113
+ const zip = fakeZip([{ name: 'a/../../escape.txt' }, { name: 'index.html' }]);
114
+ await assert.rejects(() => validateZipEntries(zip), /目录穿越/);
115
+ });
116
+
117
+ test('validateZipEntries:符号链接条目 → 拒绝', async () => {
118
+ const zip = fakeZip([
119
+ { name: 'link.txt', unixPermissions: 0o120777 }, // S_IFLNK
120
+ { name: 'index.html' },
121
+ ]);
122
+ await assert.rejects(() => validateZipEntries(zip), /符号链接/);
123
+ });
124
+
125
+ test('validateZipEntries:普通文件(无恶意特征)→ 通过', async () => {
126
+ const zip = fakeZip([{ name: 'index.html' }, { name: 'css/style.css' }]);
127
+ const entries = await validateZipEntries(zip);
128
+ assert.strictEqual(entries.length, 2);
129
+ });
130
+
131
+ test('validateZipEntries:文件数超上限 → 拒绝', async () => {
132
+ const tooMany = Array.from({ length: ZIP_MAX_FILE_COUNT + 1 }, (_, i) => ({ name: `f${i}.html` }));
133
+ const zip = fakeZip(tooMany);
134
+ await assert.rejects(() => validateZipEntries(zip), /超过上限/);
135
+ });
136
+
137
+ test('extractEntries:条目逃逸 targetDir → 拒绝(path.resolve 兜底)', async () => {
138
+ // 即便上游漏过了校验,extractEntries 的 resolve().startsWith() 仍拦截越界写入
139
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-zip-'));
140
+ try {
141
+ const zip = fakeExtractableZip([{ name: '../escape.txt', content: 'evil' }]);
142
+ const entries = [{ name: '../escape.txt', originalName: '../escape.txt' }];
143
+ await assert.rejects(() => extractEntries(zip, entries, tmp), /路径穿越/);
144
+ // 确保越界文件确实没写出来
145
+ const escaped = path.join(tmp, '..', 'escape.txt');
146
+ assert.ok(!fs.existsSync(escaped), '越界文件不应被写出');
147
+ } finally {
148
+ fs.rmSync(tmp, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('extractEntries:正常条目写入 targetDir 内', async () => {
153
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-zip-'));
154
+ try {
155
+ const zip = fakeExtractableZip([{ name: 'index.html', content: '<p>ok</p>' }]);
156
+ const entries = [{ name: 'index.html', originalName: 'index.html' }];
157
+ const { entries: out, totalSize } = await extractEntries(zip, entries, tmp);
158
+ assert.strictEqual(out.length, 1);
159
+ assert.ok(totalSize > 0);
160
+ assert.ok(fs.existsSync(path.join(tmp, 'index.html')));
161
+ } finally {
162
+ fs.rmSync(tmp, { recursive: true, force: true });
163
+ }
164
+ });