@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,90 @@
|
|
|
1
|
+
// 分类 + 模板元数据路由。从 server.js 提取,行为保持不变。
|
|
2
|
+
// 挂载点:/api(内部路径 /categories、/categories/:id、/templates)
|
|
3
|
+
// 注:/api/files/:id/category 归 routes/files.js(同为 /api/files 前缀)。
|
|
4
|
+
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const { dbAll, dbGet, dbRun } = require('../lib/db');
|
|
7
|
+
const { requireAuth, requireAdmin } = require('../lib/middleware/auth');
|
|
8
|
+
const { clientIp } = require('../lib/util');
|
|
9
|
+
const { reloadCategoryNameCache } = require('../lib/categories');
|
|
10
|
+
const logger = require('../logger');
|
|
11
|
+
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
|
|
14
|
+
// --- 模板列表(渲染样式模板,区别于内容模板市场 content-templates)---
|
|
15
|
+
router.get('/templates', requireAuth, async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const templates = await dbAll('SELECT * FROM templates ORDER BY is_builtin DESC, name ASC');
|
|
18
|
+
res.json({ templates });
|
|
19
|
+
} catch (e) {
|
|
20
|
+
res.status(500).json({ error: '获取模板列表失败' });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// --- 分类管理 ---
|
|
25
|
+
|
|
26
|
+
router.get('/categories', requireAuth, async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const role = req.userRole;
|
|
29
|
+
const userId = req.userId;
|
|
30
|
+
let categories;
|
|
31
|
+
if (role === 'admin') {
|
|
32
|
+
categories = await dbAll(`
|
|
33
|
+
SELECT c.id, c.name, c.created_at, COUNT(f.id) AS file_count
|
|
34
|
+
FROM categories c LEFT JOIN files f ON f.category_id = c.id
|
|
35
|
+
GROUP BY c.id ORDER BY c.created_at ASC
|
|
36
|
+
`);
|
|
37
|
+
} else {
|
|
38
|
+
categories = await dbAll(`
|
|
39
|
+
SELECT c.id, c.name, c.created_at, COUNT(f.id) AS file_count
|
|
40
|
+
FROM categories c LEFT JOIN files f ON f.category_id = c.id AND f.uploaded_by = ?
|
|
41
|
+
GROUP BY c.id ORDER BY c.created_at ASC
|
|
42
|
+
`, [userId]);
|
|
43
|
+
}
|
|
44
|
+
res.json({ categories });
|
|
45
|
+
} catch (e) {
|
|
46
|
+
res.status(500).json({ error: '获取分类失败' });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
router.post('/categories', requireAuth, async (req, res) => {
|
|
51
|
+
const { name } = req.body || {};
|
|
52
|
+
if (!name || !name.trim()) return res.status(400).json({ error: '分类名不能为空' });
|
|
53
|
+
try {
|
|
54
|
+
const existing = await dbGet('SELECT id, name, created_at FROM categories WHERE name = ?', [name.trim()]);
|
|
55
|
+
if (existing) return res.json(existing);
|
|
56
|
+
const result = await dbRun('INSERT INTO categories (name, user_id) VALUES (?, ?)', [name.trim(), req.userId]);
|
|
57
|
+
await reloadCategoryNameCache();
|
|
58
|
+
logger.audit('category.create', { categoryId: result.lastID, name: name.trim(), ip: clientIp(req) });
|
|
59
|
+
res.json({ id: result.lastID, name: name.trim() });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
res.status(500).json({ error: '创建分类失败' });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
router.put('/categories/:id', requireAuth, requireAdmin, async (req, res) => {
|
|
66
|
+
const { name } = req.body || {};
|
|
67
|
+
if (!name || !name.trim()) return res.status(400).json({ error: '分类名不能为空' });
|
|
68
|
+
try {
|
|
69
|
+
await dbRun('UPDATE categories SET name = ? WHERE id = ?', [name.trim(), req.params.id]);
|
|
70
|
+
await reloadCategoryNameCache();
|
|
71
|
+
logger.audit('category.rename', { categoryId: req.params.id, name: name.trim(), ip: clientIp(req) });
|
|
72
|
+
res.json({ success: true });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
res.status(500).json({ error: '重命名分类失败' });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
router.delete('/categories/:id', requireAuth, requireAdmin, async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
await dbRun('UPDATE files SET category_id = NULL WHERE category_id = ?', [req.params.id]);
|
|
81
|
+
await dbRun('DELETE FROM categories WHERE id = ?', [req.params.id]);
|
|
82
|
+
await reloadCategoryNameCache();
|
|
83
|
+
logger.audit('category.delete', { categoryId: req.params.id, ip: clientIp(req) });
|
|
84
|
+
res.json({ success: true });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
res.status(500).json({ error: '删除分类失败' });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
module.exports = router;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// 内容模板市场路由。从 server.js 提取,行为保持不变。
|
|
2
|
+
// 挂载点:/api/content-templates
|
|
3
|
+
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const { dbGet, dbRun, dbAll } = require('../lib/db');
|
|
6
|
+
const { requireAuth } = require('../lib/middleware/auth');
|
|
7
|
+
const { clientIp } = require('../lib/util');
|
|
8
|
+
const logger = require('../logger');
|
|
9
|
+
|
|
10
|
+
const router = express.Router();
|
|
11
|
+
|
|
12
|
+
const CONTENT_TEMPLATE_MAX_SIZE = 512000; // 500KB
|
|
13
|
+
const CONTENT_TEMPLATE_SCENES = ['dashboard', 'report', 'resume', 'landing', 'note', 'presentation', 'card', 'email', 'other'];
|
|
14
|
+
|
|
15
|
+
// 公开模板列表(无需登录)
|
|
16
|
+
router.get('/public', async (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
19
|
+
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit) || 8));
|
|
20
|
+
const offset = (page - 1) * limit;
|
|
21
|
+
const { scene } = req.query;
|
|
22
|
+
|
|
23
|
+
const conditions = ['ct.is_public = 1'];
|
|
24
|
+
const params = [];
|
|
25
|
+
if (scene) { conditions.push('ct.scene = ?'); params.push(scene); }
|
|
26
|
+
const where = 'WHERE ' + conditions.join(' AND ');
|
|
27
|
+
|
|
28
|
+
const total = await dbGet(`SELECT COUNT(*) as count FROM content_templates ct ${where}`, params);
|
|
29
|
+
const templates = await dbAll(
|
|
30
|
+
`SELECT ct.id, ct.title, ct.description, ct.file_type, ct.scene, ct.style_tags, ct.use_count
|
|
31
|
+
FROM content_templates ct
|
|
32
|
+
${where} ORDER BY ct.use_count DESC LIMIT ? OFFSET ?`,
|
|
33
|
+
[...params, limit, offset]
|
|
34
|
+
);
|
|
35
|
+
res.json({
|
|
36
|
+
templates,
|
|
37
|
+
pagination: { page, limit, total: total.count, totalPages: Math.ceil(total.count / limit) }
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
logger.error({ type: 'app', msg: '获取公开模板列表失败', error: e.message });
|
|
41
|
+
res.status(500).json({ error: '获取模板列表失败' });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 公开模板预览(无需登录)
|
|
46
|
+
router.get('/public/:id/preview', async (req, res) => {
|
|
47
|
+
try {
|
|
48
|
+
const t = await dbGet(
|
|
49
|
+
'SELECT id, title, file_type, content FROM content_templates WHERE id = ? AND is_public = 1',
|
|
50
|
+
[req.params.id]
|
|
51
|
+
);
|
|
52
|
+
if (!t) return res.status(404).json({ error: '模板不存在' });
|
|
53
|
+
res.json({ id: t.id, title: t.title, file_type: t.file_type, content: t.content });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
res.status(500).json({ error: '获取模板预览失败' });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
router.get('/', requireAuth, async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
62
|
+
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit) || 10));
|
|
63
|
+
const offset = (page - 1) * limit;
|
|
64
|
+
const { scene, keyword, fileType, sort } = req.query;
|
|
65
|
+
|
|
66
|
+
const conditions = [];
|
|
67
|
+
const params = [];
|
|
68
|
+
if (req.userRole !== 'admin') {
|
|
69
|
+
conditions.push('(ct.is_public = 1 OR ct.uploaded_by = ?)');
|
|
70
|
+
params.push(req.userId);
|
|
71
|
+
}
|
|
72
|
+
if (scene) { conditions.push('ct.scene = ?'); params.push(scene); }
|
|
73
|
+
if (fileType) { conditions.push('ct.file_type = ?'); params.push(fileType); }
|
|
74
|
+
if (keyword) {
|
|
75
|
+
conditions.push('(ct.title LIKE ? OR ct.description LIKE ?)');
|
|
76
|
+
params.push(`%${keyword}%`, `%${keyword}%`);
|
|
77
|
+
}
|
|
78
|
+
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
|
|
79
|
+
const orderBy = sort === 'created_at' ? 'ct.created_at DESC' : 'ct.use_count DESC';
|
|
80
|
+
|
|
81
|
+
const total = await dbGet(`SELECT COUNT(*) as count FROM content_templates ct ${where}`, params);
|
|
82
|
+
const templates = await dbAll(
|
|
83
|
+
`SELECT ct.id, ct.title, ct.description, ct.file_type, ct.scene, ct.style_tags,
|
|
84
|
+
ct.uploaded_by, ct.use_count, ct.is_public, ct.created_at, ct.updated_at,
|
|
85
|
+
u.username as uploader_name
|
|
86
|
+
FROM content_templates ct LEFT JOIN users u ON ct.uploaded_by = u.id
|
|
87
|
+
${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
|
88
|
+
[...params, limit, offset]
|
|
89
|
+
);
|
|
90
|
+
res.json({
|
|
91
|
+
templates,
|
|
92
|
+
pagination: { page, limit, total: total.count, totalPages: Math.ceil(total.count / limit) }
|
|
93
|
+
});
|
|
94
|
+
} catch (e) {
|
|
95
|
+
logger.error({ type: 'app', msg: '获取内容模板列表失败', error: e.message });
|
|
96
|
+
res.status(500).json({ error: '获取内容模板列表失败' });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
router.get('/scenes', requireAuth, async (req, res) => {
|
|
101
|
+
res.json({ scenes: CONTENT_TEMPLATE_SCENES });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
router.get('/:id', requireAuth, async (req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
const t = await dbGet(
|
|
107
|
+
`SELECT ct.id, ct.title, ct.description, ct.file_type, ct.scene, ct.style_tags,
|
|
108
|
+
ct.uploaded_by, ct.use_count, ct.is_public, ct.created_at, ct.updated_at,
|
|
109
|
+
u.username as uploader_name
|
|
110
|
+
FROM content_templates ct LEFT JOIN users u ON ct.uploaded_by = u.id
|
|
111
|
+
WHERE ct.id = ?`, [req.params.id]
|
|
112
|
+
);
|
|
113
|
+
if (!t) return res.status(404).json({ error: '模板不存在' });
|
|
114
|
+
if (!t.is_public && req.userRole !== 'admin' && t.uploaded_by !== req.userId) {
|
|
115
|
+
return res.status(403).json({ error: '无权访问' });
|
|
116
|
+
}
|
|
117
|
+
res.json(t);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
res.status(500).json({ error: '获取模板详情失败' });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
router.get('/:id/content', requireAuth, async (req, res) => {
|
|
124
|
+
try {
|
|
125
|
+
const t = await dbGet('SELECT id, title, file_type, content, is_public, uploaded_by FROM content_templates WHERE id = ?', [req.params.id]);
|
|
126
|
+
if (!t) return res.status(404).json({ error: '模板不存在' });
|
|
127
|
+
if (!t.is_public && req.userRole !== 'admin' && t.uploaded_by !== req.userId) {
|
|
128
|
+
return res.status(403).json({ error: '无权访问' });
|
|
129
|
+
}
|
|
130
|
+
res.json({ id: t.id, title: t.title, file_type: t.file_type, content: t.content });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
res.status(500).json({ error: '获取模板内容失败' });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
router.post('/', requireAuth, async (req, res) => {
|
|
137
|
+
const { title, description, fileType, scene, styleTags, content, isPublic } = req.body || {};
|
|
138
|
+
if (!title || !title.trim()) return res.status(400).json({ error: '模板标题不能为空' });
|
|
139
|
+
if (!content) return res.status(400).json({ error: '样例内容不能为空' });
|
|
140
|
+
if (Buffer.byteLength(content, 'utf-8') > CONTENT_TEMPLATE_MAX_SIZE) {
|
|
141
|
+
return res.status(400).json({ error: '样例内容不能超过 500KB' });
|
|
142
|
+
}
|
|
143
|
+
const ft = fileType || 'html';
|
|
144
|
+
if (ft !== 'html' && ft !== 'markdown') return res.status(400).json({ error: '文件类型仅支持 html 或 markdown' });
|
|
145
|
+
try {
|
|
146
|
+
const result = await dbRun(
|
|
147
|
+
`INSERT INTO content_templates (title, description, file_type, scene, style_tags, content, uploaded_by, is_public) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
148
|
+
[title.trim(), description || null, ft, scene || null, styleTags || null, content, req.userId, isPublic !== false ? 1 : 0]
|
|
149
|
+
);
|
|
150
|
+
logger.audit('content_template.create', { templateId: result.lastID, title: title.trim(), scene, ip: clientIp(req) });
|
|
151
|
+
res.json({ id: result.lastID, title: title.trim() });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
logger.error({ type: 'app', msg: '创建内容模板失败', error: e.message });
|
|
154
|
+
res.status(500).json({ error: '创建内容模板失败' });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
router.put('/:id', requireAuth, async (req, res) => {
|
|
159
|
+
try {
|
|
160
|
+
const t = await dbGet('SELECT id, uploaded_by FROM content_templates WHERE id = ?', [req.params.id]);
|
|
161
|
+
if (!t) return res.status(404).json({ error: '模板不存在' });
|
|
162
|
+
if (req.userRole !== 'admin' && t.uploaded_by !== req.userId) return res.status(403).json({ error: '无权操作' });
|
|
163
|
+
|
|
164
|
+
const { title, description, scene, styleTags, content, isPublic } = req.body || {};
|
|
165
|
+
const sets = [];
|
|
166
|
+
const params = [];
|
|
167
|
+
if (title !== undefined) { sets.push('title = ?'); params.push(title.trim()); }
|
|
168
|
+
if (description !== undefined) { sets.push('description = ?'); params.push(description); }
|
|
169
|
+
if (scene !== undefined) { sets.push('scene = ?'); params.push(scene); }
|
|
170
|
+
if (styleTags !== undefined) { sets.push('style_tags = ?'); params.push(styleTags); }
|
|
171
|
+
if (content !== undefined) {
|
|
172
|
+
if (Buffer.byteLength(content, 'utf-8') > CONTENT_TEMPLATE_MAX_SIZE) {
|
|
173
|
+
return res.status(400).json({ error: '样例内容不能超过 500KB' });
|
|
174
|
+
}
|
|
175
|
+
sets.push('content = ?'); params.push(content);
|
|
176
|
+
}
|
|
177
|
+
if (isPublic !== undefined) { sets.push('is_public = ?'); params.push(isPublic ? 1 : 0); }
|
|
178
|
+
if (sets.length === 0) return res.json({ success: true });
|
|
179
|
+
|
|
180
|
+
sets.push("updated_at = datetime('now')");
|
|
181
|
+
params.push(req.params.id);
|
|
182
|
+
await dbRun(`UPDATE content_templates SET ${sets.join(', ')} WHERE id = ?`, params);
|
|
183
|
+
logger.audit('content_template.update', { templateId: parseInt(req.params.id), ip: clientIp(req) });
|
|
184
|
+
res.json({ success: true });
|
|
185
|
+
} catch (e) {
|
|
186
|
+
logger.error({ type: 'app', msg: '更新内容模板失败', error: e.message });
|
|
187
|
+
res.status(500).json({ error: '更新内容模板失败' });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
router.delete('/:id', requireAuth, async (req, res) => {
|
|
192
|
+
try {
|
|
193
|
+
const t = await dbGet('SELECT id, uploaded_by FROM content_templates WHERE id = ?', [req.params.id]);
|
|
194
|
+
if (!t) return res.status(404).json({ error: '模板不存在' });
|
|
195
|
+
if (req.userRole !== 'admin' && t.uploaded_by !== req.userId) return res.status(403).json({ error: '无权操作' });
|
|
196
|
+
await dbRun('DELETE FROM content_templates WHERE id = ?', [req.params.id]);
|
|
197
|
+
logger.audit('content_template.delete', { templateId: parseInt(req.params.id), ip: clientIp(req) });
|
|
198
|
+
res.json({ success: true });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
logger.error({ type: 'app', msg: '删除内容模板失败', error: e.message });
|
|
201
|
+
res.status(500).json({ error: '删除内容模板失败' });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
router.post('/:id/use', requireAuth, async (req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
await dbRun('UPDATE content_templates SET use_count = use_count + 1 WHERE id = ?', [req.params.id]);
|
|
208
|
+
const t = await dbGet('SELECT use_count FROM content_templates WHERE id = ?', [req.params.id]);
|
|
209
|
+
res.json({ success: true, use_count: t ? t.use_count : 0 });
|
|
210
|
+
} catch (e) {
|
|
211
|
+
res.status(500).json({ error: '记录使用失败' });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
module.exports = router;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 文件路由的共享层:上传配置(multer/限流)+ 版本备份序列 + 下载头 + 路径守卫。
|
|
2
|
+
// 由 routes/files/ 下各子模块按需 require,避免重复定义。
|
|
3
|
+
//
|
|
4
|
+
// 设计原则:只放「真正安全、零行为差异」的提取项。权限校验、行级 enrichment 等
|
|
5
|
+
// 涉及行为语义的逻辑仍留在各路由文件内,不在本轮重构中改动。
|
|
6
|
+
|
|
7
|
+
const express = require('express');
|
|
8
|
+
const multer = require('multer');
|
|
9
|
+
const rateLimit = require('express-rate-limit');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { dbGet, dbRun } = require('../../lib/db');
|
|
12
|
+
const { now } = require('../../lib/util');
|
|
13
|
+
const { decodeFilename } = require('../../lib/util');
|
|
14
|
+
const { UPLOAD_DIR } = require('../../lib/paths');
|
|
15
|
+
|
|
16
|
+
// --- 常量 ---
|
|
17
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
18
|
+
const ALLOWED_UPLOAD_EXTS = ['.html', '.htm', '.md', '.markdown', '.zip']; // 上传(含 ZIP)
|
|
19
|
+
const ALLOWED_TEXT_EXTS = ['.html', '.htm', '.md', '.markdown']; // JSON 上传(无 ZIP)
|
|
20
|
+
|
|
21
|
+
// --- 上传限流 ---
|
|
22
|
+
const uploadLimiter = rateLimit({
|
|
23
|
+
windowMs: 15 * 60 * 1000,
|
|
24
|
+
max: 50,
|
|
25
|
+
message: { error: '上传请求过于频繁,请稍后再试' }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// --- multer 配置(multipart 上传用) ---
|
|
29
|
+
const storage = multer.diskStorage({
|
|
30
|
+
destination: (req, file, cb) => {
|
|
31
|
+
cb(null, UPLOAD_DIR);
|
|
32
|
+
},
|
|
33
|
+
filename: (req, file, cb) => {
|
|
34
|
+
const decoded = decodeFilename(file.originalname);
|
|
35
|
+
const ext = path.extname(decoded);
|
|
36
|
+
cb(null, generateStoredName(ext));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const upload = multer({
|
|
41
|
+
storage,
|
|
42
|
+
limits: { fileSize: MAX_FILE_SIZE },
|
|
43
|
+
fileFilter: (req, file, cb) => {
|
|
44
|
+
const decoded = decodeFilename(file.originalname);
|
|
45
|
+
const ext = path.extname(decoded).toLowerCase();
|
|
46
|
+
if (ALLOWED_UPLOAD_EXTS.includes(ext)) return cb(null, true);
|
|
47
|
+
cb(new Error('仅支持 HTML、Markdown 和 ZIP 文件'));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// JSON body 解析器:上传类端点需要放宽到 50MB(全局默认 1MB)
|
|
52
|
+
const largeJson = express.json({ limit: '50mb' });
|
|
53
|
+
|
|
54
|
+
// --- 文件名生成:替换原先散落在 5 处的 Date.now()+'-'+Math.round(...)+ext ---
|
|
55
|
+
function generateStoredName(ext) {
|
|
56
|
+
return Date.now() + '-' + Math.round(Math.random() * 1e9) + ext;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- 版本备份 + 主记录更新序列 ---
|
|
60
|
+
// 原先在 upload/upload-json/overwrite/overwrite-json/restore 共 5 处重复。
|
|
61
|
+
// 行为零差异:读 nextVer → INSERT 旧版本到 file_versions → UPDATE files 主记录。
|
|
62
|
+
//
|
|
63
|
+
// @param {object} file - 旧 files 行(含 stored_name/size/id)
|
|
64
|
+
// @param {object} next - 新版本数据 { storedName, size }
|
|
65
|
+
// @param {number} recordedBy - 写入 file_versions.uploaded_by 的用户 id
|
|
66
|
+
// (upload/overwrite 各处用 file.uploaded_by;restore 用 currentUserId)
|
|
67
|
+
// @returns {Promise<{ version: number }>} 返回新版本号(nextVer + 1,对齐审计日志语义)
|
|
68
|
+
async function backupAndApplyVersion(file, next, recordedBy) {
|
|
69
|
+
const verRow = await dbGet(
|
|
70
|
+
'SELECT COALESCE(MAX(version), 0) + 1 AS nextVer FROM file_versions WHERE file_id = ?',
|
|
71
|
+
[file.id]
|
|
72
|
+
);
|
|
73
|
+
const nextVer = verRow.nextVer;
|
|
74
|
+
await dbRun(
|
|
75
|
+
'INSERT INTO file_versions (file_id, version, stored_name, size, uploaded_by) VALUES (?, ?, ?, ?, ?)',
|
|
76
|
+
[file.id, nextVer, file.stored_name, file.size, recordedBy]
|
|
77
|
+
);
|
|
78
|
+
await dbRun(
|
|
79
|
+
'UPDATE files SET stored_name = ?, size = ?, updated_at = ? WHERE id = ?',
|
|
80
|
+
[next.storedName, next.size, now(), file.id]
|
|
81
|
+
);
|
|
82
|
+
return { version: nextVer + 1 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- 下载 Content-Disposition 头(UTF-8 文件名) ---
|
|
86
|
+
// 替换 download 路由两处重复的 encoded + filename*=UTF-8'' 拼接。
|
|
87
|
+
function setDownloadHeaders(res, name) {
|
|
88
|
+
const encoded = encodeURIComponent(name);
|
|
89
|
+
res.setHeader('Content-Disposition', "attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- bundle 路径穿越守卫 ---
|
|
93
|
+
// 替换 content/asset 路由的 path.resolve + startsWith 重复校验。
|
|
94
|
+
// 返回 true 表示安全(路径落在 bundleDir 内)。
|
|
95
|
+
function isWithinBundle(absPath, bundleDir) {
|
|
96
|
+
const resolvedDir = path.resolve(bundleDir) + path.sep;
|
|
97
|
+
const resolved = path.resolve(absPath);
|
|
98
|
+
return resolved.startsWith(resolvedDir) || resolved === path.resolve(bundleDir);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
MAX_FILE_SIZE,
|
|
103
|
+
ALLOWED_UPLOAD_EXTS,
|
|
104
|
+
ALLOWED_TEXT_EXTS,
|
|
105
|
+
uploadLimiter,
|
|
106
|
+
upload,
|
|
107
|
+
largeJson,
|
|
108
|
+
generateStoredName,
|
|
109
|
+
backupAndApplyVersion,
|
|
110
|
+
setDownloadHeaders,
|
|
111
|
+
isWithinBundle,
|
|
112
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// 标签关联 / 收藏 / 分类设置 / 访问统计 路由。
|
|
2
|
+
// 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。
|
|
3
|
+
|
|
4
|
+
const { dbGet, dbRun, dbAll } = require('../../lib/db');
|
|
5
|
+
const { requireAuth } = require('../../lib/middleware/auth');
|
|
6
|
+
const { checkFileOwnership } = require('../../lib/middleware/files');
|
|
7
|
+
const { clientIp } = require('../../lib/util');
|
|
8
|
+
const { getPendingViewCount } = require('../../lib/view-counts');
|
|
9
|
+
const logger = require('../../logger');
|
|
10
|
+
|
|
11
|
+
function registerAssociations(router) {
|
|
12
|
+
// --- 标签关联(替换文件的全部标签) ---
|
|
13
|
+
router.put('/:id/tags', requireAuth, async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
const file = await dbGet('SELECT id FROM files WHERE id = ?', [req.params.id]);
|
|
16
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
17
|
+
if (!checkFileOwnership(req, file)) return res.status(403).json({ error: '无权操作此文件' });
|
|
18
|
+
const { tagIds } = req.body || {};
|
|
19
|
+
if (!Array.isArray(tagIds)) return res.status(400).json({ error: 'tagIds 必须是数组' });
|
|
20
|
+
await dbRun('DELETE FROM file_tags WHERE file_id = ?', [req.params.id]);
|
|
21
|
+
for (const tid of tagIds) {
|
|
22
|
+
await dbRun('INSERT OR IGNORE INTO file_tags (file_id, tag_id) VALUES (?, ?)', [req.params.id, tid]);
|
|
23
|
+
}
|
|
24
|
+
res.json({ success: true });
|
|
25
|
+
logger.audit('file.updateTags', { fileId: req.params.id, tagIds, ip: clientIp(req) });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
res.status(500).json({ error: '更新标签失败' });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// --- 收藏 ---
|
|
32
|
+
router.post('/:id/star', requireAuth, async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const file = await dbGet('SELECT id FROM files WHERE id = ?', [req.params.id]);
|
|
35
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
36
|
+
await dbRun('INSERT OR IGNORE INTO starred_files (user_id, file_id) VALUES (?, ?)', [req.userId, req.params.id]);
|
|
37
|
+
res.json({ success: true });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
res.status(500).json({ error: '收藏失败' });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
router.delete('/:id/star', requireAuth, async (req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
await dbRun('DELETE FROM starred_files WHERE user_id = ? AND file_id = ?', [req.userId, req.params.id]);
|
|
46
|
+
res.json({ success: true });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
res.status(500).json({ error: '取消收藏失败' });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- 分类设置 ---
|
|
53
|
+
router.put('/:id/category', requireAuth, async (req, res) => {
|
|
54
|
+
const { categoryId } = req.body || {};
|
|
55
|
+
try {
|
|
56
|
+
const file = await dbGet('SELECT id, uploaded_by FROM files WHERE id = ?', [req.params.id]);
|
|
57
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
58
|
+
if (req.userRole !== 'admin' && file.uploaded_by !== req.userId) {
|
|
59
|
+
return res.status(403).json({ error: '无权操作' });
|
|
60
|
+
}
|
|
61
|
+
await dbRun('UPDATE files SET category_id = ? WHERE id = ?', [categoryId || null, req.params.id]);
|
|
62
|
+
logger.audit('file.setCategory', { fileId: parseInt(req.params.id), categoryId: categoryId || null, ip: clientIp(req) });
|
|
63
|
+
res.json({ success: true });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
res.status(500).json({ error: '设置分类失败' });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// --- 访问统计 ---
|
|
70
|
+
router.get('/:id/stats', requireAuth, async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const file = await dbGet('SELECT id, uploaded_by, view_count FROM files WHERE id = ?', [req.params.id]);
|
|
73
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
74
|
+
if (req.userRole !== 'admin' && file.uploaded_by !== req.userId) {
|
|
75
|
+
return res.status(403).json({ error: '无权访问' });
|
|
76
|
+
}
|
|
77
|
+
const [daily7, daily30] = await Promise.all([
|
|
78
|
+
dbAll(
|
|
79
|
+
"SELECT date(visited_at) as date, COUNT(*) as count FROM link_visits WHERE file_id = ? AND visited_at > datetime('now','-7 days') GROUP BY date(visited_at) ORDER BY date",
|
|
80
|
+
[file.id]
|
|
81
|
+
),
|
|
82
|
+
dbAll(
|
|
83
|
+
"SELECT date(visited_at) as date, COUNT(*) as count FROM link_visits WHERE file_id = ? AND visited_at > datetime('now','-30 days') GROUP BY date(visited_at) ORDER BY date",
|
|
84
|
+
[file.id]
|
|
85
|
+
)
|
|
86
|
+
]);
|
|
87
|
+
res.json({ viewCount: (file.view_count || 0) + getPendingViewCount(file.id), daily7, daily30 });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
res.status(500).json({ error: '获取统计失败' });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { registerAssociations };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// 更新 / 删除 / 批量操作路由。
|
|
2
|
+
// 从 routes/files.js 提取,行为保持不变。挂在共享 router 上。
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { dbGet, dbRun, dbAll } = require('../../lib/db');
|
|
7
|
+
const { requireAuth } = require('../../lib/middleware/auth');
|
|
8
|
+
const { unlinkQuiet, clientIp } = require('../../lib/util');
|
|
9
|
+
const { UPLOAD_DIR } = require('../../lib/paths');
|
|
10
|
+
const { deleteFileIndex } = require('../../lib/fts');
|
|
11
|
+
const { invalidateRenderCache } = require('../../lib/render-cache');
|
|
12
|
+
const { checkFileOwnership } = require('../../lib/middleware/files');
|
|
13
|
+
const logger = require('../../logger');
|
|
14
|
+
|
|
15
|
+
function registerCrud(router) {
|
|
16
|
+
// --- 更新 ---
|
|
17
|
+
router.put('/:id', requireAuth, async (req, res) => {
|
|
18
|
+
const { name, isPublic, templateId } = req.body || {};
|
|
19
|
+
if (name === undefined && isPublic === undefined && templateId === undefined) {
|
|
20
|
+
return res.status(400).json({ error: '无更新字段' });
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]);
|
|
24
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
25
|
+
if (!checkFileOwnership(req, file)) return res.status(403).json({ error: '无权操作此文件' });
|
|
26
|
+
if (name !== undefined) {
|
|
27
|
+
if (typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: '文件名不能为空' });
|
|
28
|
+
await dbRun('UPDATE files SET original_name = ? WHERE id = ?', [name.trim(), req.params.id]);
|
|
29
|
+
}
|
|
30
|
+
if (isPublic !== undefined) {
|
|
31
|
+
await dbRun('UPDATE files SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, req.params.id]);
|
|
32
|
+
}
|
|
33
|
+
if (templateId !== undefined) {
|
|
34
|
+
const tid = templateId ? parseInt(templateId) : null;
|
|
35
|
+
if (tid) {
|
|
36
|
+
const tpl = await dbGet('SELECT id FROM templates WHERE id = ?', [tid]);
|
|
37
|
+
if (!tpl) return res.status(400).json({ error: '模板不存在' });
|
|
38
|
+
}
|
|
39
|
+
await dbRun('UPDATE files SET template_id = ? WHERE id = ?', [tid, req.params.id]);
|
|
40
|
+
}
|
|
41
|
+
logger.audit('file.update', { fileId: req.params.id, changes: { name, isPublic, templateId }, ip: clientIp(req) });
|
|
42
|
+
res.json({ success: true });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
res.status(500).json({ error: '更新失败' });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- 删除 ---
|
|
49
|
+
router.delete('/:id', requireAuth, async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
const file = await dbGet('SELECT * FROM files WHERE id = ?', [req.params.id]);
|
|
52
|
+
if (!file) return res.status(404).json({ error: '文件不存在' });
|
|
53
|
+
if (!checkFileOwnership(req, file)) return res.status(403).json({ error: '无权操作此文件' });
|
|
54
|
+
|
|
55
|
+
// 清理关联数据
|
|
56
|
+
await dbRun('DELETE FROM file_tags WHERE file_id = ?', [req.params.id]);
|
|
57
|
+
await dbRun('DELETE FROM starred_files WHERE file_id = ?', [req.params.id]);
|
|
58
|
+
await deleteFileIndex(req.params.id);
|
|
59
|
+
|
|
60
|
+
// 清理版本记录及对应磁盘文件
|
|
61
|
+
const versions = await dbAll('SELECT stored_name FROM file_versions WHERE file_id = ?', [req.params.id]);
|
|
62
|
+
for (const v of versions) {
|
|
63
|
+
const p = path.join(UPLOAD_DIR, v.stored_name);
|
|
64
|
+
if (fs.existsSync(p)) await unlinkQuiet(p);
|
|
65
|
+
}
|
|
66
|
+
await dbRun('DELETE FROM file_versions WHERE file_id = ?', [req.params.id]);
|
|
67
|
+
|
|
68
|
+
// 删除主文件
|
|
69
|
+
const filePath = path.join(UPLOAD_DIR, file.stored_name);
|
|
70
|
+
if (fs.existsSync(filePath)) await unlinkQuiet(filePath);
|
|
71
|
+
await dbRun('DELETE FROM files WHERE id = ?', [req.params.id]);
|
|
72
|
+
invalidateRenderCache(req.params.id);
|
|
73
|
+
logger.audit('file.delete', { fileId: req.params.id, fileName: file.original_name, ip: clientIp(req) });
|
|
74
|
+
res.json({ success: true });
|
|
75
|
+
} catch (e) {
|
|
76
|
+
res.status(500).json({ error: '删除失败' });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// --- 批量操作 ---
|
|
81
|
+
router.post('/batch', requireAuth, async (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const { action, ids, data } = req.body;
|
|
84
|
+
if (!action || !Array.isArray(ids) || !ids.length) {
|
|
85
|
+
return res.status(400).json({ error: '缺少 action 或 ids 参数' });
|
|
86
|
+
}
|
|
87
|
+
if (ids.length > 200) return res.status(400).json({ error: '单次最多操作 200 个文件' });
|
|
88
|
+
const validActions = ['delete', 'setPublic', 'setPrivate', 'setCategory'];
|
|
89
|
+
if (!validActions.includes(action)) return res.status(400).json({ error: '不支持的操作: ' + action });
|
|
90
|
+
|
|
91
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
92
|
+
const files = await dbAll(`SELECT * FROM files WHERE id IN (${placeholders})`, ids);
|
|
93
|
+
if (!files.length) return res.json({ success: true, affected: 0 });
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
if (!checkFileOwnership(req, f)) return res.status(403).json({ error: '无权操作部分文件' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fileIds = files.map(f => f.id);
|
|
99
|
+
const idPlaceholders = fileIds.map(() => '?').join(',');
|
|
100
|
+
|
|
101
|
+
if (action === 'delete') {
|
|
102
|
+
await dbRun('BEGIN');
|
|
103
|
+
try {
|
|
104
|
+
await dbRun(`DELETE FROM file_tags WHERE file_id IN (${idPlaceholders})`, fileIds);
|
|
105
|
+
await dbRun(`DELETE FROM starred_files WHERE file_id IN (${idPlaceholders})`, fileIds);
|
|
106
|
+
const versions = await dbAll(`SELECT stored_name FROM file_versions WHERE file_id IN (${idPlaceholders})`, fileIds);
|
|
107
|
+
for (const v of versions) {
|
|
108
|
+
await unlinkQuiet(path.join(UPLOAD_DIR, v.stored_name));
|
|
109
|
+
}
|
|
110
|
+
await dbRun(`DELETE FROM file_versions WHERE file_id IN (${idPlaceholders})`, fileIds);
|
|
111
|
+
for (const f of files) {
|
|
112
|
+
await unlinkQuiet(path.join(UPLOAD_DIR, f.stored_name));
|
|
113
|
+
}
|
|
114
|
+
await dbRun(`DELETE FROM files WHERE id IN (${idPlaceholders})`, fileIds);
|
|
115
|
+
await dbRun('COMMIT');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
await dbRun('ROLLBACK');
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
logger.audit('file.batchDelete', { count: fileIds.length, ip: clientIp(req) });
|
|
121
|
+
} else if (action === 'setPublic' || action === 'setPrivate') {
|
|
122
|
+
const isPublic = action === 'setPublic' ? 1 : 0;
|
|
123
|
+
await dbRun(`UPDATE files SET is_public = ? WHERE id IN (${idPlaceholders})`, [isPublic, ...fileIds]);
|
|
124
|
+
logger.audit('file.batchSetPrivacy', { action, count: fileIds.length, ip: clientIp(req) });
|
|
125
|
+
} else if (action === 'setCategory') {
|
|
126
|
+
const categoryId = data && data.categoryId ? data.categoryId : null;
|
|
127
|
+
await dbRun(`UPDATE files SET category_id = ? WHERE id IN (${idPlaceholders})`, [categoryId, ...fileIds]);
|
|
128
|
+
logger.audit('file.batchSetCategory', { categoryId, count: fileIds.length, ip: clientIp(req) });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
res.json({ success: true, affected: fileIds.length });
|
|
132
|
+
} catch (e) {
|
|
133
|
+
logger.error({ type: 'app', action: 'file.batch', error: e.message });
|
|
134
|
+
res.status(500).json({ error: '批量操作失败' });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { registerCrud };
|