@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.
- package/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- 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
|
+
});
|