@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
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Token 明文可逆加密:AES-256-GCM。
|
|
2
|
+
//
|
|
3
|
+
// 用途:API Token 创建时除存 SHA-256 哈希(用于鉴权比对,不可逆)外,
|
|
4
|
+
// 另存一份 AES-256-GCM 密文,使 token 明文可在用户登录后再次查看/复制。
|
|
5
|
+
// 鉴权链路(lib/middleware/auth.js)完全不依赖本模块,仍走哈希比对。
|
|
6
|
+
//
|
|
7
|
+
// 密钥来源(优先级):
|
|
8
|
+
// 1. 环境变量 TOKEN_ENCRYPTION_KEY(hex;若长度不足 32 字节则用 sha256 派生到 32 字节)
|
|
9
|
+
// 2. 数据目录下的 token-key.key 文件(hex 文本,重启不变)
|
|
10
|
+
// 3. 上述文件不存在 → 生成 32 随机字节写入该文件后再读取
|
|
11
|
+
// 不设置环境变量时也能开箱即用,不破坏现有部署。
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { DATA_DIR } = require('./paths');
|
|
17
|
+
|
|
18
|
+
const KEY_FILE = path.join(DATA_DIR, 'token-key.key');
|
|
19
|
+
const ALGO = 'aes-256-gcm';
|
|
20
|
+
const IV_LEN = 12; // GCM 推荐 96-bit IV
|
|
21
|
+
|
|
22
|
+
// 解析为 32 字节密钥:合法 hex(64) 直接用,否则 sha256 派生。
|
|
23
|
+
function deriveKey(raw) {
|
|
24
|
+
if (!raw) return null;
|
|
25
|
+
const trimmed = String(raw).trim();
|
|
26
|
+
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
|
27
|
+
return Buffer.from(trimmed, 'hex');
|
|
28
|
+
}
|
|
29
|
+
return crypto.createHash('sha256').update(trimmed).digest();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 读取或生成持久化密钥文件(hex 文本)。
|
|
33
|
+
function loadKeyFile() {
|
|
34
|
+
try {
|
|
35
|
+
if (fs.existsSync(KEY_FILE)) {
|
|
36
|
+
const hex = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
|
37
|
+
if (/^[0-9a-fA-F]{64}$/.test(hex)) return Buffer.from(hex, 'hex');
|
|
38
|
+
}
|
|
39
|
+
} catch (_) { /* 读取失败则走生成路径 */ }
|
|
40
|
+
|
|
41
|
+
// 生成并写入(0600,仅属主可读写)
|
|
42
|
+
const key = crypto.randomBytes(32);
|
|
43
|
+
try {
|
|
44
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
45
|
+
fs.writeFileSync(KEY_FILE, key.toString('hex'), { mode: 0o600 });
|
|
46
|
+
} catch (_) { /* 写入失败时退回内存密钥(重启后旧密文不可解密) */ }
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let _key = process.env.TOKEN_ENCRYPTION_KEY ? deriveKey(process.env.TOKEN_ENCRYPTION_KEY) : loadKeyFile();
|
|
51
|
+
|
|
52
|
+
// 供测试重置密钥(按环境变量/文件重新解析)。
|
|
53
|
+
function reloadKey() {
|
|
54
|
+
_key = process.env.TOKEN_ENCRYPTION_KEY ? deriveKey(process.env.TOKEN_ENCRYPTION_KEY) : loadKeyFile();
|
|
55
|
+
return _key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 密文格式:base64(iv) : base64(ciphertext) : base64(authTag)
|
|
59
|
+
function encryptToken(plain) {
|
|
60
|
+
const iv = crypto.randomBytes(IV_LEN);
|
|
61
|
+
const cipher = crypto.createCipheriv(ALGO, _key, iv);
|
|
62
|
+
const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
63
|
+
const tag = cipher.getAuthTag();
|
|
64
|
+
return [iv.toString('base64'), enc.toString('base64'), tag.toString('base64')].join(':');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 解密失败抛错(由调用方捕获并转友好提示)。
|
|
68
|
+
function decryptToken(encStr) {
|
|
69
|
+
const parts = String(encStr || '').split(':');
|
|
70
|
+
if (parts.length !== 3) throw new Error('invalid ciphertext format');
|
|
71
|
+
const [ivB, dataB, tagB] = parts;
|
|
72
|
+
const iv = Buffer.from(ivB, 'base64');
|
|
73
|
+
const data = Buffer.from(dataB, 'base64');
|
|
74
|
+
const tag = Buffer.from(tagB, 'base64');
|
|
75
|
+
const decipher = crypto.createDecipheriv(ALGO, _key, iv);
|
|
76
|
+
decipher.setAuthTag(tag);
|
|
77
|
+
const dec = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
78
|
+
return dec.toString('utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
encryptToken,
|
|
83
|
+
decryptToken,
|
|
84
|
+
reloadKey,
|
|
85
|
+
};
|
package/lib/csp.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// 分级 CSP(内容安全策略)策略。
|
|
2
|
+
//
|
|
3
|
+
// 设计:管理界面与用户内容渲染页用不同策略,平衡安全与功能:
|
|
4
|
+
// - 管理界面(app.js / index.html):严格策略,只放行同源 + 内联 style(前端大量用 inline style)。
|
|
5
|
+
// - Markdown 渲染页:较严格,内联脚本靠 nonce 放行(mermaid 初始化)。
|
|
6
|
+
// - HTML 渲染页(用户原始 HTML,常含合法 script/外链):宽松,依赖 iframe sandbox 兜底。
|
|
7
|
+
//
|
|
8
|
+
// nonce 方案:每次渲染 Markdown 生成随机 nonce,注入到模板内联 <script> 与响应头,
|
|
9
|
+
// 不依赖具体脚本内容(模板内容变动不会让 CSP 失效)。
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
// 管理界面:无内联 script(仅一个外链 module + 外链 css),style 需要 unsafe-inline(前端海量 inline style)。
|
|
14
|
+
const APP_CSP = [
|
|
15
|
+
"default-src 'self'",
|
|
16
|
+
"script-src 'self'",
|
|
17
|
+
"style-src 'self' 'unsafe-inline'",
|
|
18
|
+
"img-src 'self' data: blob:",
|
|
19
|
+
"font-src 'self'",
|
|
20
|
+
"connect-src 'self'",
|
|
21
|
+
"frame-src 'self'",
|
|
22
|
+
"frame-ancestors 'none'",
|
|
23
|
+
].join('; ');
|
|
24
|
+
|
|
25
|
+
// Markdown 渲染页:内联 mermaid 初始化脚本靠 nonce 放行,vendor 资源同源。
|
|
26
|
+
function markdownCsp(nonce) {
|
|
27
|
+
return [
|
|
28
|
+
"default-src 'self'",
|
|
29
|
+
`script-src 'self' 'nonce-${nonce}'`,
|
|
30
|
+
"style-src 'self' 'unsafe-inline'",
|
|
31
|
+
"img-src 'self' data: blob:",
|
|
32
|
+
"font-src 'self'",
|
|
33
|
+
"connect-src 'self'",
|
|
34
|
+
"frame-ancestors 'none'",
|
|
35
|
+
].join('; ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// HTML 渲染页:用户 HTML 常含合法 script/外链资源,宽松策略 + iframe sandbox 兜底。
|
|
39
|
+
// 放开 https: 让用户的图表库/CDN/图片能加载;sandbox 去掉 allow-same-origin 阻断对父窗口的访问。
|
|
40
|
+
const HTML_CSP = [
|
|
41
|
+
"default-src 'self'",
|
|
42
|
+
"script-src 'self' 'unsafe-inline' https:",
|
|
43
|
+
"style-src 'self' 'unsafe-inline' https:",
|
|
44
|
+
"img-src 'self' data: blob: https:",
|
|
45
|
+
"font-src 'self' https: data:",
|
|
46
|
+
"connect-src 'self' https:",
|
|
47
|
+
"frame-ancestors 'none'",
|
|
48
|
+
].join('; ');
|
|
49
|
+
|
|
50
|
+
// 判断请求路径是否为用户内容渲染端点(这些端点由路由内自行 setHeader,中间件跳过)。
|
|
51
|
+
function isRenderPath(reqPath) {
|
|
52
|
+
return /^\/api\/files\/\d+\/(render|versions\/\d+\/render|asset\/)/.test(reqPath) || /^\/s\//.test(reqPath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 生成 nonce(base64,128bit)
|
|
56
|
+
function generateNonce() {
|
|
57
|
+
return crypto.randomBytes(16).toString('base64');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
APP_CSP,
|
|
62
|
+
HTML_CSP,
|
|
63
|
+
markdownCsp,
|
|
64
|
+
isRenderPath,
|
|
65
|
+
generateNonce,
|
|
66
|
+
};
|
package/lib/db.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SQLite 数据库访问层。
|
|
2
|
+
// db 实例由 server.js 创建并通过 setDb() 注入;之后 dbRun/dbGet/dbAll 即可使用。
|
|
3
|
+
// 从 server.js 提取,行为保持不变。
|
|
4
|
+
|
|
5
|
+
const { dbRun: _dbRun, dbGet: _dbGet, dbAll: _dbAll } = require('../migrations');
|
|
6
|
+
|
|
7
|
+
let db = null;
|
|
8
|
+
|
|
9
|
+
function setDb(instance) {
|
|
10
|
+
db = instance;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getDb() {
|
|
14
|
+
return db;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dbRun(sql, params = []) {
|
|
18
|
+
return _dbRun(db, sql, params);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dbGet(sql, params = []) {
|
|
22
|
+
return _dbGet(db, sql, params);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function dbAll(sql, params = []) {
|
|
26
|
+
return _dbAll(db, sql, params);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- SQLite 性能 PRAGMA ---
|
|
30
|
+
// 在任何查询前应用:WAL 让读写不互斥(并发提升),synchronous=NORMAL 减少每次提交的 fsync,
|
|
31
|
+
// busy_timeout 在写冲突时自动重试,cache_size/temp_store/mmap_size 提升读吞吐。
|
|
32
|
+
function configureDatabase() {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
db.exec(
|
|
35
|
+
`PRAGMA journal_mode=WAL;
|
|
36
|
+
PRAGMA synchronous=NORMAL;
|
|
37
|
+
PRAGMA busy_timeout=5000;
|
|
38
|
+
PRAGMA cache_size=-20000;
|
|
39
|
+
PRAGMA temp_store=MEMORY;
|
|
40
|
+
PRAGMA mmap_size=268435452;`,
|
|
41
|
+
(err) => (err ? reject(err) : resolve())
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
setDb,
|
|
48
|
+
getDb,
|
|
49
|
+
dbRun,
|
|
50
|
+
dbGet,
|
|
51
|
+
dbAll,
|
|
52
|
+
configureDatabase,
|
|
53
|
+
};
|
package/lib/dispatch.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// 进程内请求分发:让 MCP tool 不再走 fetch('http://127.0.0.1:port/...') 自调用,
|
|
2
|
+
// 而是直接调用同一个 Express app 的路由栈,绕过 TCP 序列化 + 二次鉴权 DB 查询。
|
|
3
|
+
//
|
|
4
|
+
// 接口与 buildApiClient 完全一致:{ get, post, put, del },path 以 /api/... 开头,
|
|
5
|
+
// 返回 Promise<解析后的 JSON>,失败抛 Error(与 fetch 路径相同:`REST <method> <path> -> <status> <msg>`)。
|
|
6
|
+
//
|
|
7
|
+
// 实现要点:
|
|
8
|
+
// - 用 net.Socket 作为 req 的 connection/socket,保证流的销毁生命周期正常;
|
|
9
|
+
// - 合成 IncomingMessage(method/url/headers/body),模拟一次 HTTP 请求;
|
|
10
|
+
// - 合成 ServerResponse,缓存 write/end 的字节,结束后解析 JSON;
|
|
11
|
+
// - 通过 app.handle(req, res) 走完整中间件链(含 requireAuth、限流、审计),保证行为与 HTTP 一致;
|
|
12
|
+
// - 认证靠 Authorization: Bearer <token> 头,复用现有 requireAuth 逻辑。
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const net = require('net');
|
|
15
|
+
|
|
16
|
+
function makeSocket() {
|
|
17
|
+
// 一个未连接的真实 net.Socket:满足 Node 流销毁对 connection 类型的要求。
|
|
18
|
+
const socket = new net.Socket({ handle: undefined });
|
|
19
|
+
socket.remoteAddress = '127.0.0.1';
|
|
20
|
+
socket.destroy = () => {};
|
|
21
|
+
return socket;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createDispatcher(app, { token }) {
|
|
25
|
+
function call(method, path, body) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const headers = { host: '127.0.0.1' };
|
|
28
|
+
if (token) headers.authorization = 'Bearer ' + token;
|
|
29
|
+
let payloadBuf = null;
|
|
30
|
+
if (body !== undefined) {
|
|
31
|
+
payloadBuf = Buffer.from(JSON.stringify(body), 'utf8');
|
|
32
|
+
headers['content-type'] = 'application/json';
|
|
33
|
+
headers['content-length'] = String(payloadBuf.length);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 合成请求(基于真实 socket)
|
|
37
|
+
const socket = makeSocket();
|
|
38
|
+
const req = new http.IncomingMessage(socket);
|
|
39
|
+
req.method = method.toUpperCase();
|
|
40
|
+
req.url = path;
|
|
41
|
+
req.headers = headers;
|
|
42
|
+
req.httpVersion = '1.1';
|
|
43
|
+
req.httpVersionMajor = 1;
|
|
44
|
+
req.httpVersionMinor = 1;
|
|
45
|
+
if (payloadBuf) req.push(payloadBuf);
|
|
46
|
+
req.push(null);
|
|
47
|
+
|
|
48
|
+
// 合成响应
|
|
49
|
+
const res = new http.ServerResponse(req);
|
|
50
|
+
const chunks = [];
|
|
51
|
+
res.write = function (chunk) { if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); return true; };
|
|
52
|
+
const _headers = {};
|
|
53
|
+
res.writeHead = function (status, h) { res.statusCode = status; if (h) Object.assign(_headers, h); return this; };
|
|
54
|
+
res.setHeader = function (k, v) { _headers[String(k).toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v); return this; };
|
|
55
|
+
res.getHeader = function (k) { return _headers[String(k).toLowerCase()]; };
|
|
56
|
+
res.removeHeader = function (k) { delete _headers[String(k).toLowerCase()]; return this; };
|
|
57
|
+
const finished = () => {
|
|
58
|
+
const buf = Buffer.concat(chunks);
|
|
59
|
+
const text = buf.toString('utf8');
|
|
60
|
+
let data = null;
|
|
61
|
+
if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
|
|
62
|
+
const status = res.statusCode || 200;
|
|
63
|
+
if (status < 200 || status >= 300) {
|
|
64
|
+
const msg = (data && data.error) || ('HTTP ' + status) || 'unknown error';
|
|
65
|
+
const err = new Error('REST ' + method.toUpperCase() + ' ' + path + ' -> ' + status + ' ' + msg);
|
|
66
|
+
err.status = status;
|
|
67
|
+
return reject(err);
|
|
68
|
+
}
|
|
69
|
+
resolve(data);
|
|
70
|
+
};
|
|
71
|
+
let ended = false;
|
|
72
|
+
res.end = function (chunk) {
|
|
73
|
+
if (ended) return this;
|
|
74
|
+
ended = true;
|
|
75
|
+
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
76
|
+
finished();
|
|
77
|
+
return this;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// 走完整 Express 中间件链
|
|
81
|
+
try {
|
|
82
|
+
app.handle(req, res, () => {
|
|
83
|
+
// 未匹配任何路由 → 404
|
|
84
|
+
if (!ended) {
|
|
85
|
+
res.statusCode = 404;
|
|
86
|
+
chunks.push(Buffer.from(JSON.stringify({ error: 'Not Found' })));
|
|
87
|
+
finished();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (!ended) reject(e);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
get: (path) => call('GET', path),
|
|
97
|
+
post: (path, body) => call('POST', path, body),
|
|
98
|
+
put: (path, body) => call('PUT', path, body),
|
|
99
|
+
del: (path) => call('DELETE', path),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { createDispatcher };
|
package/lib/fts.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// FTS5 全文搜索:索引写入 / 删除 / 回填 / 查询转义。
|
|
2
|
+
// 从 server.js 提取,行为保持不变。
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const logger = require('../logger');
|
|
7
|
+
const { dbRun, dbGet, dbAll } = require('./db');
|
|
8
|
+
const { UPLOAD_DIR } = require('./paths');
|
|
9
|
+
|
|
10
|
+
const FTS_INDEXABLE_EXTS = new Set(['.html', '.htm', '.md', '.markdown', '.txt']);
|
|
11
|
+
const FTS_MAX_CONTENT_SIZE = 100 * 1024; // 100KB
|
|
12
|
+
|
|
13
|
+
function isFtsIndexable(fileType, storedName) {
|
|
14
|
+
if (fileType === 'bundle') return false;
|
|
15
|
+
const ext = path.extname(storedName || '').toLowerCase();
|
|
16
|
+
return FTS_INDEXABLE_EXTS.has(ext);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function indexFileContent(fileId, storedName) {
|
|
20
|
+
try {
|
|
21
|
+
const filePath = path.join(UPLOAD_DIR, storedName);
|
|
22
|
+
if (!fs.existsSync(filePath)) return;
|
|
23
|
+
let content = await fs.promises.readFile(filePath, 'utf-8');
|
|
24
|
+
if (content.length > FTS_MAX_CONTENT_SIZE) content = content.slice(0, FTS_MAX_CONTENT_SIZE);
|
|
25
|
+
// 去除 HTML 标签,只保留纯文本用于索引
|
|
26
|
+
content = content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
27
|
+
// 对 CJK 字符逐字加空格,使 unicode61 tokenizer 能按字符分词
|
|
28
|
+
content = content.replace(/([一-鿿])/g, ' $1 ');
|
|
29
|
+
content = content.replace(/\s+/g, ' ').trim();
|
|
30
|
+
await dbRun('DELETE FROM file_contents_fts WHERE file_id = ?', [fileId]);
|
|
31
|
+
await dbRun('INSERT INTO file_contents_fts(rowid, file_id, content) VALUES (?, ?, ?)', [fileId, fileId, content]);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
logger.error({ type: 'app', message: 'FTS 索引失败', fileId, error: e.message });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function deleteFileIndex(fileId) {
|
|
38
|
+
try {
|
|
39
|
+
await dbRun('DELETE FROM file_contents_fts WHERE file_id = ?', [fileId]);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
logger.error({ type: 'app', message: 'FTS 删除索引失败', fileId, error: e.message });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function backfillFtsIndex() {
|
|
46
|
+
try {
|
|
47
|
+
const count = await dbGet('SELECT COUNT(*) AS cnt FROM file_contents_fts');
|
|
48
|
+
if (count.cnt > 0) return;
|
|
49
|
+
const files = await dbAll('SELECT id, stored_name, file_type, is_bundle FROM files');
|
|
50
|
+
let indexed = 0;
|
|
51
|
+
for (const f of files) {
|
|
52
|
+
if (f.is_bundle) continue;
|
|
53
|
+
if (!isFtsIndexable(f.file_type, f.stored_name)) continue;
|
|
54
|
+
await indexFileContent(f.id, f.stored_name);
|
|
55
|
+
indexed++;
|
|
56
|
+
}
|
|
57
|
+
if (indexed > 0) logger.info({ type: 'app', message: 'FTS 索引回填完成', count: indexed });
|
|
58
|
+
} catch (e) {
|
|
59
|
+
logger.error({ type: 'app', message: 'FTS 索引回填失败', error: e.message });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function escapeFtsQuery(q) {
|
|
64
|
+
// 移除 FTS5 特殊字符
|
|
65
|
+
let cleaned = q.replace(/["'*:(){}[\]\\^+\-&|!~]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
66
|
+
if (!cleaned) return '';
|
|
67
|
+
// 对 CJK 字符逐字加空格,与索引时一致
|
|
68
|
+
cleaned = cleaned.replace(/([一-鿿])/g, ' $1 ').replace(/\s+/g, ' ').trim();
|
|
69
|
+
// 对每个 token 加引号,避免 FTS5 语法错误
|
|
70
|
+
return cleaned.split(/\s+/).map(w => `"${w}"`).join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
FTS_INDEXABLE_EXTS,
|
|
75
|
+
FTS_MAX_CONTENT_SIZE,
|
|
76
|
+
isFtsIndexable,
|
|
77
|
+
indexFileContent,
|
|
78
|
+
deleteFileIndex,
|
|
79
|
+
backfillFtsIndex,
|
|
80
|
+
escapeFtsQuery,
|
|
81
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// 认证与授权中间件:requireAuth(Session / Bearer Token / MCP_TOKEN)+ requireAdmin。
|
|
2
|
+
// 从 server.js 提取,行为保持不变。
|
|
3
|
+
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { dbGet, dbRun } = require('../db');
|
|
6
|
+
const { now } = require('../util');
|
|
7
|
+
const { getAdminUserId } = require('../auth-state');
|
|
8
|
+
|
|
9
|
+
// 软认证:尽力解析 Session/Bearer Token 并填充 req.userId / req.userRole,
|
|
10
|
+
// 但不拒绝匿名请求。用于「允许匿名访问公开内容、同时让登录用户/admin 访问受限内容」
|
|
11
|
+
// 的端点(详情 / 渲染 / 下载 / 资源 / 短链)。
|
|
12
|
+
// 与 requireAuth 共用解析逻辑,仅在未通过认证时不返回 401 而是放行。
|
|
13
|
+
async function loadSession(req, res, next) {
|
|
14
|
+
// Session 路径
|
|
15
|
+
if (req.session && req.session.userId) {
|
|
16
|
+
req.userId = req.session.userId;
|
|
17
|
+
if (req.session.userRole) {
|
|
18
|
+
req.userRole = req.session.userRole;
|
|
19
|
+
} else {
|
|
20
|
+
const user = await dbGet('SELECT role FROM users WHERE id = ?', [req.session.userId]);
|
|
21
|
+
if (user) {
|
|
22
|
+
req.session.userRole = user.role;
|
|
23
|
+
req.userRole = user.role;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return next();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Bearer Token 路径(与 requireAuth 一致,成功才填充)
|
|
30
|
+
const auth = req.headers.authorization;
|
|
31
|
+
if (auth && auth.startsWith('Bearer ')) {
|
|
32
|
+
const tokenValue = auth.slice(7);
|
|
33
|
+
const adminUserId = getAdminUserId();
|
|
34
|
+
if (process.env.MCP_TOKEN && tokenValue === process.env.MCP_TOKEN && adminUserId) {
|
|
35
|
+
req.userId = adminUserId;
|
|
36
|
+
const admin = await dbGet('SELECT role FROM users WHERE id = ?', [adminUserId]);
|
|
37
|
+
req.userRole = admin ? admin.role : 'admin';
|
|
38
|
+
return next();
|
|
39
|
+
}
|
|
40
|
+
const hash = crypto.createHash('sha256').update(tokenValue).digest('hex');
|
|
41
|
+
const tokenRow = await dbGet(
|
|
42
|
+
'SELECT t.user_id, u.role FROM tokens t JOIN users u ON t.user_id = u.id WHERE t.token_hash = ?',
|
|
43
|
+
[hash]
|
|
44
|
+
);
|
|
45
|
+
if (tokenRow) {
|
|
46
|
+
dbRun('UPDATE tokens SET last_used_at = ? WHERE token_hash = ?', [now(), hash]).catch(() => {});
|
|
47
|
+
req.userId = tokenRow.user_id;
|
|
48
|
+
req.userRole = tokenRow.role;
|
|
49
|
+
return next();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 未通过认证:放行(由下游中间件如 loadFileWithPrivacy 决定是否拒绝)
|
|
54
|
+
return next();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function requireAuth(req, res, next) {
|
|
58
|
+
// Session 路径
|
|
59
|
+
if (req.session && req.session.userId) {
|
|
60
|
+
req.userId = req.session.userId;
|
|
61
|
+
// 从 session 读取 role,若旧 session 无 role 则从 DB 回填
|
|
62
|
+
if (req.session.userRole) {
|
|
63
|
+
req.userRole = req.session.userRole;
|
|
64
|
+
} else {
|
|
65
|
+
const user = await dbGet('SELECT role FROM users WHERE id = ?', [req.session.userId]);
|
|
66
|
+
if (!user) {
|
|
67
|
+
req.session.destroy(() => {});
|
|
68
|
+
return res.status(401).json({ error: '未登录' });
|
|
69
|
+
}
|
|
70
|
+
req.session.userRole = user.role;
|
|
71
|
+
req.userRole = user.role;
|
|
72
|
+
}
|
|
73
|
+
return next();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Bearer Token 路径
|
|
77
|
+
const auth = req.headers.authorization;
|
|
78
|
+
if (auth && auth.startsWith('Bearer ')) {
|
|
79
|
+
const tokenValue = auth.slice(7);
|
|
80
|
+
|
|
81
|
+
// 1. 旧 MCP_TOKEN 向后兼容
|
|
82
|
+
const adminUserId = getAdminUserId();
|
|
83
|
+
if (process.env.MCP_TOKEN && tokenValue === process.env.MCP_TOKEN && adminUserId) {
|
|
84
|
+
req.mcpUserId = adminUserId;
|
|
85
|
+
req.userId = adminUserId;
|
|
86
|
+
const admin = await dbGet('SELECT role FROM users WHERE id = ?', [adminUserId]);
|
|
87
|
+
req.userRole = admin ? admin.role : 'admin';
|
|
88
|
+
return next();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. 用户级 Token 查询
|
|
92
|
+
const hash = crypto.createHash('sha256').update(tokenValue).digest('hex');
|
|
93
|
+
const tokenRow = await dbGet(
|
|
94
|
+
'SELECT t.user_id, u.role FROM tokens t JOIN users u ON t.user_id = u.id WHERE t.token_hash = ?',
|
|
95
|
+
[hash]
|
|
96
|
+
);
|
|
97
|
+
if (tokenRow) {
|
|
98
|
+
dbRun('UPDATE tokens SET last_used_at = ? WHERE token_hash = ?', [now(), hash]).catch(() => {});
|
|
99
|
+
req.tokenUserId = tokenRow.user_id;
|
|
100
|
+
req.userId = tokenRow.user_id;
|
|
101
|
+
req.userRole = tokenRow.role;
|
|
102
|
+
return next();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return res.status(401).json({ error: '未登录' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requireAdmin(req, res, next) {
|
|
110
|
+
if (req.userRole !== 'admin') return res.status(403).json({ error: '需要管理员权限' });
|
|
111
|
+
next();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { loadSession, requireAuth, requireAdmin };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// 文件加载与归属校验中间件。
|
|
2
|
+
// loadFileWithPrivacy:按权限(admin/所有者/公开)加载文件到 req.fileRecord。
|
|
3
|
+
// checkFileOwnership:判断当前用户是否拥有该文件(admin 拥有一切)。
|
|
4
|
+
// 从 server.js 提取,行为保持不变。
|
|
5
|
+
|
|
6
|
+
const { dbGet } = require('../db');
|
|
7
|
+
|
|
8
|
+
function loadFileWithPrivacy(req, res, next) {
|
|
9
|
+
dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]).then(file => {
|
|
10
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
11
|
+
const userId = req.userId;
|
|
12
|
+
const role = req.userRole;
|
|
13
|
+
|
|
14
|
+
// admin 可访问一切
|
|
15
|
+
if (role === 'admin') {
|
|
16
|
+
req.fileRecord = file;
|
|
17
|
+
return next();
|
|
18
|
+
}
|
|
19
|
+
// 普通用户:公开文件 或 自己的文件
|
|
20
|
+
if (userId && (file.is_public || file.uploaded_by === userId)) {
|
|
21
|
+
req.fileRecord = file;
|
|
22
|
+
return next();
|
|
23
|
+
}
|
|
24
|
+
// 未登录:仅公开文件
|
|
25
|
+
if (!userId && file.is_public) {
|
|
26
|
+
req.fileRecord = file;
|
|
27
|
+
return next();
|
|
28
|
+
}
|
|
29
|
+
if (!userId) return res.status(401).json({ error: '未登录' });
|
|
30
|
+
return res.status(403).json({ error: '无权访问此文件' });
|
|
31
|
+
}).catch(() => {
|
|
32
|
+
res.status(500).json({ error: '读取失败' });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function checkFileOwnership(req, file) {
|
|
37
|
+
if (req.userRole === 'admin') return true;
|
|
38
|
+
if (file.uploaded_by === req.userId) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { loadFileWithPrivacy, checkFileOwnership };
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// 路径常量集中管理。被多个 lib 模块和 routes 共享。
|
|
2
|
+
// 从 server.js 提取,行为保持不变。
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DATA_DIR = process.env.JPAGE_DATA_DIR || path.join(__dirname, '..', 'data');
|
|
7
|
+
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
|
8
|
+
|
|
9
|
+
module.exports = { DATA_DIR, UPLOAD_DIR };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// 渲染缓存:Markdown 渲染(marked + highlight.js + KaTeX)是 CPU 热点,
|
|
2
|
+
// 公开短链 /render 热门文档被反复渲染。缓存以 (fileId, updated_at) 为失效键:
|
|
3
|
+
// 覆盖上传会更新 updated_at,旧缓存自动失效。HTML/Bundle 不缓存(主要瓶颈是磁盘读,
|
|
4
|
+
// 且注入逻辑依赖文件内容,缓存收益低、失效复杂)。
|
|
5
|
+
// 从 server.js 提取,行为保持不变。
|
|
6
|
+
|
|
7
|
+
const RENDER_CACHE = new Map(); // key -> { html, ts }
|
|
8
|
+
const RENDER_CACHE_MAX = 256;
|
|
9
|
+
|
|
10
|
+
function renderCacheKey(file) {
|
|
11
|
+
// stored_name 必须进 key:历史版本渲染会用 { ...file, stored_name: ver.stored_name },
|
|
12
|
+
// 若不加会错误命中当前版本的缓存。
|
|
13
|
+
return `${file.id}:${file.stored_name || ''}:${file.updated_at || ''}:${file.is_bundle ? 1 : 0}:${file.entry_path || ''}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getRenderedHtml(file) {
|
|
17
|
+
const key = renderCacheKey(file);
|
|
18
|
+
return RENDER_CACHE.has(key) ? RENDER_CACHE.get(key).html : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setRenderedHtml(file, html) {
|
|
22
|
+
const key = renderCacheKey(file);
|
|
23
|
+
if (RENDER_CACHE.size >= RENDER_CACHE_MAX && !RENDER_CACHE.has(key)) {
|
|
24
|
+
// 简单 LRU 淘汰:删最早的 key(Map 保持插入顺序)
|
|
25
|
+
const firstKey = RENDER_CACHE.keys().next().value;
|
|
26
|
+
RENDER_CACHE.delete(firstKey);
|
|
27
|
+
}
|
|
28
|
+
RENDER_CACHE.set(key, { html });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function invalidateRenderCache(fileId) {
|
|
32
|
+
for (const k of RENDER_CACHE.keys()) {
|
|
33
|
+
if (k.startsWith(`${fileId}:`)) RENDER_CACHE.delete(k);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 数据导入替换数据库连接后,清空全部渲染缓存
|
|
38
|
+
function clearRenderCache() {
|
|
39
|
+
RENDER_CACHE.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
renderCacheKey,
|
|
44
|
+
getRenderedHtml,
|
|
45
|
+
setRenderedHtml,
|
|
46
|
+
invalidateRenderCache,
|
|
47
|
+
clearRenderCache,
|
|
48
|
+
};
|