@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,178 @@
|
|
|
1
|
+
// 详情 / 原文 / 资源 / 渲染 / 下载 路由。
|
|
2
|
+
// 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。
|
|
3
|
+
// 注册顺序:在静态路径(/、/search、/upload*)之后,其他 /:id/* 之前。
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const archiver = require('archiver');
|
|
8
|
+
const { dbAll, dbGet } = require('../../lib/db');
|
|
9
|
+
const { requireAuth, loadSession } = require('../../lib/middleware/auth');
|
|
10
|
+
const { loadFileWithPrivacy } = require('../../lib/middleware/files');
|
|
11
|
+
const { UPLOAD_DIR } = require('../../lib/paths');
|
|
12
|
+
const { listBundleEntries, renderFile } = require('../../lib/render');
|
|
13
|
+
const { setDownloadHeaders, isWithinBundle } = require('./_shared');
|
|
14
|
+
const logger = require('../../logger');
|
|
15
|
+
|
|
16
|
+
function registerDetailServe(router) {
|
|
17
|
+
// --- 详情 ---
|
|
18
|
+
router.get('/:id', loadSession, loadFileWithPrivacy, async (req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const f = req.fileRecord;
|
|
21
|
+
// 并行查询:tags / starred / category / version_count(WAL 下读不互斥)
|
|
22
|
+
const [tags, starredRow, cat, versionRow] = await Promise.all([
|
|
23
|
+
dbAll('SELECT t.id, t.name FROM tags t JOIN file_tags ft ON ft.tag_id = t.id WHERE ft.file_id = ?', [f.id]),
|
|
24
|
+
req.userId ? dbGet('SELECT 1 AS hit FROM starred_files WHERE user_id = ? AND file_id = ?', [req.userId, f.id]) : Promise.resolve(null),
|
|
25
|
+
f.category_id ? dbGet('SELECT name FROM categories WHERE id = ?', [f.category_id]) : Promise.resolve(null),
|
|
26
|
+
dbGet('SELECT COUNT(*) AS c FROM file_versions WHERE file_id = ?', [f.id]),
|
|
27
|
+
]);
|
|
28
|
+
const starred = !!(starredRow && starredRow.hit);
|
|
29
|
+
const category_name = cat ? cat.name : null;
|
|
30
|
+
res.json({
|
|
31
|
+
id: f.id,
|
|
32
|
+
original_name: f.original_name,
|
|
33
|
+
file_type: f.file_type,
|
|
34
|
+
size: f.size,
|
|
35
|
+
is_public: f.is_public,
|
|
36
|
+
created_at: f.created_at,
|
|
37
|
+
updated_at: f.updated_at,
|
|
38
|
+
share_key: f.share_key,
|
|
39
|
+
category_id: f.category_id,
|
|
40
|
+
uploaded_by: f.uploaded_by,
|
|
41
|
+
is_bundle: f.is_bundle,
|
|
42
|
+
entry_path: f.entry_path,
|
|
43
|
+
view_count: f.view_count,
|
|
44
|
+
template_id: f.template_id,
|
|
45
|
+
version_count: versionRow ? versionRow.c : 0,
|
|
46
|
+
tags,
|
|
47
|
+
starred,
|
|
48
|
+
category_name,
|
|
49
|
+
});
|
|
50
|
+
} catch (e) {
|
|
51
|
+
logger.error({ type: 'app', error: e.message });
|
|
52
|
+
res.status(500).json({ error: '获取文件失败' });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// --- 原文 ---
|
|
57
|
+
router.get('/:id/content', requireAuth, loadFileWithPrivacy, async (req, res) => {
|
|
58
|
+
const file = req.fileRecord;
|
|
59
|
+
if (req.userRole !== 'admin' && file.uploaded_by !== req.userId) {
|
|
60
|
+
return res.status(403).json({ error: '无权读取此文件原文' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Bundle 没有单一原文:降级返回入口文件内容(源码视图)+ 目录清单 + 元信息。
|
|
64
|
+
// 这样前端预览页能正常进入,源码视图展示入口文件,完整包仍走 /download。
|
|
65
|
+
if (file.is_bundle) {
|
|
66
|
+
const bundleDir = path.join(UPLOAD_DIR, file.stored_name);
|
|
67
|
+
const entryPath = path.join(bundleDir, file.entry_path || 'index.html');
|
|
68
|
+
// 入口路径必须落在 bundle 目录内,防穿越(与 renderFile 同款校验)
|
|
69
|
+
if (!isWithinBundle(entryPath, bundleDir)) {
|
|
70
|
+
return res.status(403).json({ error: '非法路径' });
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
// 入口读不到时降级为空串而非报错:预览页仍可用 iframe /render 与下载
|
|
74
|
+
const entryContent = await fs.promises.readFile(entryPath, 'utf-8').catch(() => '');
|
|
75
|
+
const { entries, truncated } = await listBundleEntries(bundleDir);
|
|
76
|
+
return res.json({
|
|
77
|
+
id: file.id,
|
|
78
|
+
original_name: file.original_name,
|
|
79
|
+
file_type: file.file_type,
|
|
80
|
+
is_public: file.is_public,
|
|
81
|
+
uploaded_by: file.uploaded_by,
|
|
82
|
+
is_bundle: file.is_bundle,
|
|
83
|
+
entry_path: file.entry_path,
|
|
84
|
+
template_id: file.template_id,
|
|
85
|
+
content: entryContent,
|
|
86
|
+
entries,
|
|
87
|
+
entries_truncated: truncated,
|
|
88
|
+
});
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (e && e.code === 'ENOENT') return res.status(404).json({ error: '文件已丢失' });
|
|
91
|
+
logger.error({ type: 'app', error: e.message });
|
|
92
|
+
return res.status(500).json({ error: '读取文件失败' });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const filePath = path.join(UPLOAD_DIR, file.stored_name);
|
|
97
|
+
try {
|
|
98
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
99
|
+
res.json({
|
|
100
|
+
id: file.id,
|
|
101
|
+
original_name: file.original_name,
|
|
102
|
+
file_type: file.file_type,
|
|
103
|
+
is_public: file.is_public,
|
|
104
|
+
uploaded_by: file.uploaded_by,
|
|
105
|
+
is_bundle: file.is_bundle,
|
|
106
|
+
template_id: file.template_id,
|
|
107
|
+
content
|
|
108
|
+
});
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e && e.code === 'ENOENT') return res.status(404).json({ error: '文件已丢失' });
|
|
111
|
+
res.status(500).json({ error: '读取文件失败' });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- 资源(bundle 内静态文件) ---
|
|
116
|
+
router.get('/:id/asset/*', loadSession, loadFileWithPrivacy, async (req, res) => {
|
|
117
|
+
const file = req.fileRecord;
|
|
118
|
+
if (!file.is_bundle) return res.status(400).json({ error: '非网站包' });
|
|
119
|
+
const bundleDir = path.resolve(path.join(UPLOAD_DIR, file.stored_name));
|
|
120
|
+
const assetRelative = req.params[0];
|
|
121
|
+
const assetPath = path.resolve(path.join(bundleDir, assetRelative));
|
|
122
|
+
if (!isWithinBundle(assetPath, bundleDir)) {
|
|
123
|
+
return res.status(403).json({ error: '非法路径' });
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const stat = await fs.promises.stat(assetPath);
|
|
127
|
+
if (stat.isDirectory()) return res.status(404).json({ error: '资源不存在' });
|
|
128
|
+
} catch {
|
|
129
|
+
return res.status(404).json({ error: '资源不存在' });
|
|
130
|
+
}
|
|
131
|
+
res.sendFile(assetPath, (err) => {
|
|
132
|
+
if (err && !res.headersSent && err.code === 'ENOENT') {
|
|
133
|
+
res.status(404).json({ error: '资源不存在' });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// --- 渲染 ---
|
|
139
|
+
router.get('/:id/render', loadSession, loadFileWithPrivacy, async (req, res) => {
|
|
140
|
+
await renderFile(res, req.fileRecord);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// --- 下载 ---
|
|
144
|
+
router.get('/:id/download', loadSession, loadFileWithPrivacy, async (req, res) => {
|
|
145
|
+
const file = req.fileRecord;
|
|
146
|
+
if (file.is_bundle) {
|
|
147
|
+
const bundleDir = path.join(UPLOAD_DIR, file.stored_name);
|
|
148
|
+
try {
|
|
149
|
+
const st = await fs.promises.stat(bundleDir);
|
|
150
|
+
if (!st.isDirectory()) return res.status(404).json({ error: '文件已丢失' });
|
|
151
|
+
} catch {
|
|
152
|
+
return res.status(404).json({ error: '文件已丢失' });
|
|
153
|
+
}
|
|
154
|
+
setDownloadHeaders(res, file.original_name);
|
|
155
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
156
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
157
|
+
archive.directory(bundleDir, false);
|
|
158
|
+
archive.on('end', () => res.end());
|
|
159
|
+
archive.pipe(res);
|
|
160
|
+
return archive.finalize().catch(e => {
|
|
161
|
+
logger.error({ type: 'app', message: 'bundle 打包失败', error: e.message });
|
|
162
|
+
if (!res.headersSent) res.status(500).json({ error: '打包失败' });
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const filePath = path.join(UPLOAD_DIR, file.stored_name);
|
|
166
|
+
setDownloadHeaders(res, file.original_name);
|
|
167
|
+
// 去掉同步 existsSync 预检:sendFile 找不到文件时走 errback 返回 404
|
|
168
|
+
res.sendFile(filePath, (err) => {
|
|
169
|
+
if (err && !res.headersSent) {
|
|
170
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: '文件已丢失' });
|
|
171
|
+
logger.error({ type: 'app', message: '下载失败', error: err.message });
|
|
172
|
+
return res.status(500).json({ error: '下载失败' });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { registerDetailServe };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 文件路由聚合器:创建单个 router,按**原始注册顺序**调用各子模块的 register。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要保持顺序:Express 路由按声明顺序匹配。静态路径(/、/search、/upload、
|
|
4
|
+
// /upload-json、/upload-zip-base64、/batch)必须先于 /:id 注册,否则会被 /:id 吞掉。
|
|
5
|
+
// 这里用「共享 router」模式(而非子 router.use)正是为了把所有路由挂到同一个 router
|
|
6
|
+
// 上、由聚合器统一管控顺序,避免拆分后路由匹配语义漂移。
|
|
7
|
+
//
|
|
8
|
+
// 外部入口仍是 routes/files.js(re-export 本文件),server.js 的 require 路径不变。
|
|
9
|
+
|
|
10
|
+
const express = require('express');
|
|
11
|
+
const { registerList } = require('./list');
|
|
12
|
+
const { registerUpload } = require('./upload');
|
|
13
|
+
const { registerCrud } = require('./crud');
|
|
14
|
+
const { registerDetailServe } = require('./detail-serve');
|
|
15
|
+
const { registerOverwrite } = require('./overwrite');
|
|
16
|
+
const { registerVersions } = require('./versions');
|
|
17
|
+
const { registerAssociations } = require('./associations');
|
|
18
|
+
|
|
19
|
+
const router = express.Router();
|
|
20
|
+
|
|
21
|
+
// 注册顺序 = routes/files.js 原始顺序,行为零差异:
|
|
22
|
+
// 1. list : GET /, GET /search (静态路径,最先)
|
|
23
|
+
// 2. upload : POST /upload, /upload-json, /upload-zip-base64, POST /batch
|
|
24
|
+
// 注意:batch 在 crud 里,紧跟 upload 之后(原文件即如此)
|
|
25
|
+
// 3. crud : PUT /:id, DELETE /:id, POST /batch
|
|
26
|
+
// 4. detail-serve : GET /:id, /:id/content, /:id/asset/*, /:id/render, /:id/download
|
|
27
|
+
// 5. overwrite : POST /:id/overwrite, /:id/overwrite-json
|
|
28
|
+
// 6. versions : GET /:id/versions, ...content/render/restore, DELETE version
|
|
29
|
+
// 7. associations : PUT /:id/tags, star/unstar, /:id/category, GET /:id/stats
|
|
30
|
+
registerList(router);
|
|
31
|
+
registerUpload(router);
|
|
32
|
+
registerCrud(router); // 含 POST /batch(在 PUT/DELETE /:id 之间,与原文件一致)
|
|
33
|
+
registerDetailServe(router);
|
|
34
|
+
registerOverwrite(router);
|
|
35
|
+
registerVersions(router);
|
|
36
|
+
registerAssociations(router);
|
|
37
|
+
|
|
38
|
+
module.exports = router;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// 文件列表 + 全文搜索路由。
|
|
2
|
+
// 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。
|
|
3
|
+
// 注意:路由注册顺序敏感 —— `/` 和 `/search` 必须在 `/:id` 之前注册,
|
|
4
|
+
// 否则 `/:id` 会吞掉这些静态路径。聚合器 routes/files/index.js 按序调用。
|
|
5
|
+
|
|
6
|
+
const { dbGet, dbAll } = require('../../lib/db');
|
|
7
|
+
const { requireAuth } = require('../../lib/middleware/auth');
|
|
8
|
+
const { getCategoryName } = require('../../lib/categories');
|
|
9
|
+
const { escapeFtsQuery } = require('../../lib/fts');
|
|
10
|
+
const logger = require('../../logger');
|
|
11
|
+
|
|
12
|
+
function registerList(router) {
|
|
13
|
+
// --- 列表 ---
|
|
14
|
+
router.get('/', requireAuth, async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const userId = req.userId;
|
|
17
|
+
const role = req.userRole;
|
|
18
|
+
|
|
19
|
+
// 分页参数
|
|
20
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
21
|
+
const maxLimit = 100;
|
|
22
|
+
const limit = Math.min(maxLimit, Math.max(1, parseInt(req.query.limit) || 20));
|
|
23
|
+
const offset = (page - 1) * limit;
|
|
24
|
+
|
|
25
|
+
// 排序参数(白名单校验,防 SQL 注入)
|
|
26
|
+
const allowedSorts = ['updated_at', 'created_at', 'original_name', 'size'];
|
|
27
|
+
const sort = allowedSorts.includes(req.query.sort) ? req.query.sort : 'updated_at';
|
|
28
|
+
const order = req.query.order === 'asc' ? 'ASC' : 'DESC';
|
|
29
|
+
|
|
30
|
+
// 筛选参数
|
|
31
|
+
const keyword = (req.query.keyword || '').trim();
|
|
32
|
+
const categoryId = req.query.category || null;
|
|
33
|
+
const tagId = req.query.tag || null;
|
|
34
|
+
|
|
35
|
+
// 构建 WHERE 条件
|
|
36
|
+
const conditions = [];
|
|
37
|
+
const params = [];
|
|
38
|
+
|
|
39
|
+
if (role !== 'admin') {
|
|
40
|
+
conditions.push(`f.uploaded_by = ?`);
|
|
41
|
+
params.push(userId);
|
|
42
|
+
}
|
|
43
|
+
if (keyword) {
|
|
44
|
+
conditions.push(`f.original_name LIKE ?`);
|
|
45
|
+
params.push(`%${keyword}%`);
|
|
46
|
+
}
|
|
47
|
+
if (categoryId === 'uncategorized') {
|
|
48
|
+
conditions.push(`f.category_id IS NULL`);
|
|
49
|
+
} else if (categoryId) {
|
|
50
|
+
conditions.push(`f.category_id = ?`);
|
|
51
|
+
params.push(parseInt(categoryId));
|
|
52
|
+
}
|
|
53
|
+
if (tagId) {
|
|
54
|
+
conditions.push(`EXISTS (SELECT 1 FROM file_tags ft WHERE ft.file_id = f.id AND ft.tag_id = ?)`);
|
|
55
|
+
params.push(parseInt(tagId));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
59
|
+
|
|
60
|
+
// 总数查询
|
|
61
|
+
const countRow = await dbGet(`SELECT COUNT(*) AS total FROM files f ${whereClause}`, params);
|
|
62
|
+
const total = countRow.total;
|
|
63
|
+
const totalPages = Math.ceil(total / limit) || 1;
|
|
64
|
+
|
|
65
|
+
// 数据查询
|
|
66
|
+
const sql = `SELECT f.id, f.original_name, f.file_type, f.size, f.is_public, f.created_at, f.updated_at, f.share_key, f.category_id, f.uploaded_by, f.is_bundle, f.entry_path, f.view_count, f.template_id,
|
|
67
|
+
(SELECT COUNT(*) FROM file_versions WHERE file_id = f.id) AS version_count
|
|
68
|
+
FROM files f ${whereClause} ORDER BY f.${sort} ${order} LIMIT ? OFFSET ?`;
|
|
69
|
+
const files = await dbAll(sql, [...params, limit, offset]);
|
|
70
|
+
|
|
71
|
+
const fileIdStr = files.length ? files.map(f => f.id).join(',') : '0';
|
|
72
|
+
|
|
73
|
+
// 批量获取标签
|
|
74
|
+
const tagRows = await dbAll(
|
|
75
|
+
`SELECT ft.file_id, t.id AS tag_id, t.name AS tag_name FROM file_tags ft JOIN tags t ON ft.tag_id = t.id WHERE ft.file_id IN (${fileIdStr})`
|
|
76
|
+
);
|
|
77
|
+
const tagsMap = {};
|
|
78
|
+
tagRows.forEach(r => {
|
|
79
|
+
if (!tagsMap[r.file_id]) tagsMap[r.file_id] = [];
|
|
80
|
+
tagsMap[r.file_id].push({ id: r.tag_id, name: r.tag_name });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// 批量获取收藏状态
|
|
84
|
+
let starredSet = new Set();
|
|
85
|
+
if (userId) {
|
|
86
|
+
const starRows = await dbAll(
|
|
87
|
+
`SELECT file_id FROM starred_files WHERE user_id = ? AND file_id IN (${fileIdStr})`, [userId]
|
|
88
|
+
);
|
|
89
|
+
starredSet = new Set(starRows.map(r => r.file_id));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 分类名称走内存缓存(避免每次列表全表扫 categories)
|
|
93
|
+
const result = files.map(f => ({
|
|
94
|
+
...f,
|
|
95
|
+
tags: tagsMap[f.id] || [],
|
|
96
|
+
starred: starredSet.has(f.id),
|
|
97
|
+
category_name: f.category_id ? getCategoryName(f.category_id) : null,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
res.json({
|
|
101
|
+
files: result,
|
|
102
|
+
pagination: { page, limit, total, totalPages }
|
|
103
|
+
});
|
|
104
|
+
} catch (e) {
|
|
105
|
+
res.status(500).json({ error: '获取文件列表失败' });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- 全文搜索 ---
|
|
110
|
+
// FTS5 的 MATCH 不能与普通列在 LEFT JOIN + OR 中混用(SQLite 报 "unable to use function MATCH")。
|
|
111
|
+
// 因此用 UNION 合并两类命中:FTS 全文命中(带 snippet)+ 文件名 LIKE 命中(snippet 为 NULL)。
|
|
112
|
+
// UNION 自动按整行去重;外层 JOIN files 取详情,COUNT 与 LIMIT 同源,分页准确、无重复。
|
|
113
|
+
// 一次往返替代原来的两次全量查询 + 内存去重。
|
|
114
|
+
router.get('/search', requireAuth, async (req, res) => {
|
|
115
|
+
const q = (req.query.q || '').trim();
|
|
116
|
+
if (!q) return res.status(400).json({ error: '搜索关键词不能为空' });
|
|
117
|
+
|
|
118
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
119
|
+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
|
|
120
|
+
const offset = (page - 1) * limit;
|
|
121
|
+
const userId = req.userId;
|
|
122
|
+
const role = req.userRole;
|
|
123
|
+
|
|
124
|
+
const ftsQuery = escapeFtsQuery(q);
|
|
125
|
+
const likeQ = '%' + q + '%';
|
|
126
|
+
const useFts = !!ftsQuery;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// 权限子句作用于外层 files 行
|
|
130
|
+
let permClause = '';
|
|
131
|
+
const permParams = [];
|
|
132
|
+
if (role !== 'admin') {
|
|
133
|
+
permClause = 'AND f.uploaded_by = ?';
|
|
134
|
+
permParams.push(userId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 匹配 id 集合(含 snippet):FTS 命中 UNION 文件名命中
|
|
138
|
+
const matchedIdsSql = useFts
|
|
139
|
+
? "(SELECT fts.file_id AS id, snippet(file_contents_fts, 0, '<mark>', '</mark>', '...', 32) AS snippet " +
|
|
140
|
+
'FROM file_contents_fts fts WHERE fts.content MATCH ? ' +
|
|
141
|
+
'UNION ' +
|
|
142
|
+
'SELECT f2.id AS id, NULL AS snippet FROM files f2 WHERE f2.original_name LIKE ?)'
|
|
143
|
+
: '(SELECT f2.id AS id, NULL AS snippet FROM files f2 WHERE f2.original_name LIKE ?)';
|
|
144
|
+
const matchedParams = useFts ? [ftsQuery, likeQ] : [likeQ];
|
|
145
|
+
|
|
146
|
+
const countRow = await dbGet(
|
|
147
|
+
'SELECT COUNT(*) AS total FROM files f JOIN ' + matchedIdsSql + ' m ON m.id = f.id WHERE 1=1 ' + permClause,
|
|
148
|
+
[...matchedParams, ...permParams]
|
|
149
|
+
);
|
|
150
|
+
const total = countRow.total;
|
|
151
|
+
const totalPages = Math.ceil(total / limit) || 1;
|
|
152
|
+
|
|
153
|
+
const files = await dbAll(
|
|
154
|
+
'SELECT f.id, f.original_name, f.file_type, f.size, f.is_public, f.created_at, f.updated_at, f.share_key, f.category_id, f.uploaded_by, f.is_bundle, f.entry_path, f.view_count, ' +
|
|
155
|
+
'(SELECT COUNT(*) FROM file_versions WHERE file_id = f.id) AS version_count, m.snippet ' +
|
|
156
|
+
'FROM files f JOIN ' + matchedIdsSql + ' m ON m.id = f.id WHERE 1=1 ' + permClause + ' ' +
|
|
157
|
+
'ORDER BY f.updated_at DESC LIMIT ? OFFSET ?',
|
|
158
|
+
[...matchedParams, ...permParams, limit, offset]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const fileIdStr = files.length ? files.map(f => f.id).join(',') : '0';
|
|
162
|
+
|
|
163
|
+
const tagRows = await dbAll(
|
|
164
|
+
'SELECT ft.file_id, t.id AS tag_id, t.name AS tag_name FROM file_tags ft JOIN tags t ON ft.tag_id = t.id WHERE ft.file_id IN (' + fileIdStr + ')'
|
|
165
|
+
);
|
|
166
|
+
const tagsMap = {};
|
|
167
|
+
tagRows.forEach(r => {
|
|
168
|
+
if (!tagsMap[r.file_id]) tagsMap[r.file_id] = [];
|
|
169
|
+
tagsMap[r.file_id].push({ id: r.tag_id, name: r.tag_name });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
let starredSet = new Set();
|
|
173
|
+
if (userId) {
|
|
174
|
+
const starRows = await dbAll(
|
|
175
|
+
'SELECT file_id FROM starred_files WHERE user_id = ? AND file_id IN (' + fileIdStr + ')', [userId]
|
|
176
|
+
);
|
|
177
|
+
starredSet = new Set(starRows.map(r => r.file_id));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 分类名称走内存缓存
|
|
181
|
+
const result = files.map(f => ({
|
|
182
|
+
...f,
|
|
183
|
+
tags: tagsMap[f.id] || [],
|
|
184
|
+
starred: starredSet.has(f.id),
|
|
185
|
+
category_name: f.category_id ? getCategoryName(f.category_id) : null,
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
res.json({
|
|
189
|
+
files: result,
|
|
190
|
+
query: q,
|
|
191
|
+
pagination: { page, limit, total, totalPages }
|
|
192
|
+
});
|
|
193
|
+
} catch (e) {
|
|
194
|
+
logger.error({ type: 'app', message: '搜索失败', error: e.message });
|
|
195
|
+
res.status(500).json({ error: '搜索失败' });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { registerList };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// 按 ID 覆盖上传(预览页专用):multipart / JSON。自动保留版本历史。
|
|
2
|
+
// 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { dbGet } = require('../../lib/db');
|
|
7
|
+
const { requireAuth } = require('../../lib/middleware/auth');
|
|
8
|
+
const { unlinkQuiet, clientIp, decodeFilename } = require('../../lib/util');
|
|
9
|
+
const { UPLOAD_DIR } = require('../../lib/paths');
|
|
10
|
+
const { isFtsIndexable, indexFileContent } = require('../../lib/fts');
|
|
11
|
+
const { uploadLimiter, upload, largeJson, MAX_FILE_SIZE, backupAndApplyVersion } = require('./_shared');
|
|
12
|
+
const logger = require('../../logger');
|
|
13
|
+
|
|
14
|
+
function registerOverwrite(router) {
|
|
15
|
+
// --- multipart 覆盖 ---
|
|
16
|
+
router.post('/:id/overwrite', requireAuth, uploadLimiter, upload.single('file'), async (req, res) => {
|
|
17
|
+
if (!req.file) return res.status(400).json({ error: '未上传文件' });
|
|
18
|
+
req.file.originalname = decodeFilename(req.file.originalname);
|
|
19
|
+
const ext = path.extname(req.file.originalname).toLowerCase();
|
|
20
|
+
let fileType = 'html';
|
|
21
|
+
if (ext === '.md' || ext === '.markdown') fileType = 'markdown';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]);
|
|
25
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
26
|
+
|
|
27
|
+
// 校验文件类型
|
|
28
|
+
if (file.file_type !== fileType) {
|
|
29
|
+
await unlinkQuiet(path.join(UPLOAD_DIR, req.file.filename));
|
|
30
|
+
return res.status(400).json({ error: '文件类型不匹配' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { version } = await backupAndApplyVersion(
|
|
34
|
+
file,
|
|
35
|
+
{ storedName: req.file.filename, size: req.file.size },
|
|
36
|
+
file.uploaded_by
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// FTS 索引同步
|
|
40
|
+
if (isFtsIndexable(fileType, req.file.filename)) {
|
|
41
|
+
indexFileContent(file.id, req.file.filename);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
logger.audit('file.overwrite', { fileId: file.id, fileName: file.original_name, version, fileType, size: req.file.size, ip: clientIp(req) });
|
|
45
|
+
res.json({
|
|
46
|
+
id: file.id,
|
|
47
|
+
overwritten: true,
|
|
48
|
+
version,
|
|
49
|
+
original_name: file.original_name,
|
|
50
|
+
file_type: fileType,
|
|
51
|
+
size: req.file.size,
|
|
52
|
+
is_public: file.is_public,
|
|
53
|
+
share_key: file.share_key
|
|
54
|
+
});
|
|
55
|
+
} catch (e) {
|
|
56
|
+
res.status(500).json({ error: '覆盖上传失败' });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- JSON 覆盖 ---
|
|
61
|
+
router.post('/:id/overwrite-json', requireAuth, uploadLimiter, largeJson, async (req, res) => {
|
|
62
|
+
const { content } = req.body || {};
|
|
63
|
+
if (typeof content !== 'string') return res.status(400).json({ error: 'content 必须是字符串' });
|
|
64
|
+
|
|
65
|
+
let storedName;
|
|
66
|
+
try {
|
|
67
|
+
const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]);
|
|
68
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
69
|
+
|
|
70
|
+
const size = Buffer.byteLength(content, 'utf-8');
|
|
71
|
+
if (size > MAX_FILE_SIZE) return res.status(400).json({ error: '文件大小超过50MB限制' });
|
|
72
|
+
|
|
73
|
+
const ext = file.file_type === 'markdown' ? '.md' : '.html';
|
|
74
|
+
const { generateStoredName } = require('./_shared');
|
|
75
|
+
storedName = generateStoredName(ext);
|
|
76
|
+
const filePath = path.join(UPLOAD_DIR, storedName);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await fs.promises.writeFile(filePath, content, 'utf-8');
|
|
80
|
+
} catch (e) {
|
|
81
|
+
logger.error({ type: 'app', message: '写入文件失败', error: e.message });
|
|
82
|
+
return res.status(500).json({ error: '写入文件失败' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { version } = await backupAndApplyVersion(
|
|
86
|
+
file,
|
|
87
|
+
{ storedName, size },
|
|
88
|
+
file.uploaded_by
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// FTS 索引同步
|
|
92
|
+
if (isFtsIndexable(file.file_type, storedName)) {
|
|
93
|
+
indexFileContent(file.id, storedName);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
logger.audit('file.overwrite', { fileId: file.id, fileName: file.original_name, version, fileType: file.file_type, size, ip: clientIp(req) });
|
|
97
|
+
res.json({
|
|
98
|
+
id: file.id,
|
|
99
|
+
overwritten: true,
|
|
100
|
+
version,
|
|
101
|
+
original_name: file.original_name,
|
|
102
|
+
file_type: file.file_type,
|
|
103
|
+
size,
|
|
104
|
+
is_public: file.is_public,
|
|
105
|
+
share_key: file.share_key
|
|
106
|
+
});
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (storedName) { await unlinkQuiet(path.join(UPLOAD_DIR, storedName)); }
|
|
109
|
+
res.status(500).json({ error: '覆盖上传失败' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { registerOverwrite };
|