@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/routes/users.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// 用户管理路由(仅 admin)。从 server.js 提取,行为保持不变。
|
|
2
|
+
// 挂载点:/api/users
|
|
3
|
+
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const bcrypt = require('bcryptjs');
|
|
6
|
+
const { dbAll, dbGet, dbRun } = require('../lib/db');
|
|
7
|
+
const { requireAuth, requireAdmin } = require('../lib/middleware/auth');
|
|
8
|
+
const { clientIp } = require('../lib/util');
|
|
9
|
+
const logger = require('../logger');
|
|
10
|
+
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
router.get('/', requireAuth, requireAdmin, async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
const users = await dbAll('SELECT id, username, email, email_verified, role, created_at FROM users ORDER BY id ASC');
|
|
16
|
+
res.json({ users: users.map(u => ({ ...u, emailVerified: !!u.email_verified })) });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
res.status(500).json({ error: '获取用户列表失败' });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
router.post('/', requireAuth, requireAdmin, async (req, res) => {
|
|
23
|
+
const { username, password, role, email } = req.body || {};
|
|
24
|
+
if (!username || !password) return res.status(400).json({ error: '用户名和密码不能为空' });
|
|
25
|
+
if (username.length > 30 || username.length < 2 || !/^[a-zA-Z0-9_]+$/.test(username)) {
|
|
26
|
+
return res.status(400).json({ error: '用户名 2-30 位,只能包含字母、数字和下划线' });
|
|
27
|
+
}
|
|
28
|
+
if (password.length < 8) return res.status(400).json({ error: '密码至少 8 位' });
|
|
29
|
+
if (!['admin', 'user'].includes(role || 'user')) return res.status(400).json({ error: '无效角色' });
|
|
30
|
+
if (email) {
|
|
31
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
32
|
+
if (!emailRegex.test(email)) return res.status(400).json({ error: '邮箱格式不正确' });
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
// 唯一性检查
|
|
36
|
+
if (email) {
|
|
37
|
+
const emailConflict = await dbGet('SELECT id FROM users WHERE email = ? OR username = ?', [email, email]);
|
|
38
|
+
if (emailConflict) return res.status(409).json({ error: '该邮箱已被使用' });
|
|
39
|
+
}
|
|
40
|
+
const nameConflict = await dbGet('SELECT id FROM users WHERE username = ?', [username]);
|
|
41
|
+
if (nameConflict) return res.status(409).json({ error: '用户名已存在' });
|
|
42
|
+
|
|
43
|
+
const hash = await bcrypt.hash(password, 10);
|
|
44
|
+
const result = await dbRun(
|
|
45
|
+
'INSERT INTO users (username, email, email_verified, password_hash, role) VALUES (?, ?, ?, ?, ?)',
|
|
46
|
+
[username, email || null, email ? 1 : 0, hash, role || 'user']
|
|
47
|
+
);
|
|
48
|
+
logger.audit('user.create', { userId: result.lastID, username, email, role: role || 'user', createdBy: req.userId, ip: clientIp(req) });
|
|
49
|
+
res.json({ id: result.lastID, username, email: email || null, role: role || 'user' });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.message && e.message.includes('UNIQUE')) return res.status(400).json({ error: '用户名或邮箱已存在' });
|
|
52
|
+
res.status(500).json({ error: '创建用户失败' });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
router.put('/:id', requireAuth, requireAdmin, async (req, res) => {
|
|
57
|
+
const targetId = parseInt(req.params.id);
|
|
58
|
+
if (isNaN(targetId)) return res.status(400).json({ error: '无效用户 ID' });
|
|
59
|
+
const { role, password, username, email } = req.body || {};
|
|
60
|
+
if (!role && !password && !username && email === undefined) return res.status(400).json({ error: '无更新字段' });
|
|
61
|
+
try {
|
|
62
|
+
const user = await dbGet('SELECT * FROM users WHERE id = ?', [targetId]);
|
|
63
|
+
if (!user) return res.status(404).json({ error: '用户不存在' });
|
|
64
|
+
if (username) {
|
|
65
|
+
if (username.length > 30 || username.length < 2 || !/^[a-zA-Z0-9_]+$/.test(username)) {
|
|
66
|
+
return res.status(400).json({ error: '用户名 2-30 位,只能包含字母、数字和下划线' });
|
|
67
|
+
}
|
|
68
|
+
const conflict = await dbGet('SELECT id FROM users WHERE username = ? AND id != ?', [username, targetId]);
|
|
69
|
+
if (conflict) return res.status(409).json({ error: '用户名已存在' });
|
|
70
|
+
await dbRun('UPDATE users SET username = ? WHERE id = ?', [username, targetId]);
|
|
71
|
+
}
|
|
72
|
+
if (email !== undefined) {
|
|
73
|
+
if (email) {
|
|
74
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
75
|
+
if (!emailRegex.test(email)) return res.status(400).json({ error: '邮箱格式不正确' });
|
|
76
|
+
const conflict = await dbGet('SELECT id FROM users WHERE (email = ? OR username = ?) AND id != ?', [email, email, targetId]);
|
|
77
|
+
if (conflict) return res.status(409).json({ error: '该邮箱已被使用' });
|
|
78
|
+
await dbRun('UPDATE users SET email = ?, email_verified = ? WHERE id = ?', [email, 1, targetId]);
|
|
79
|
+
} else {
|
|
80
|
+
await dbRun('UPDATE users SET email = NULL, email_verified = 0 WHERE id = ?', [targetId]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (role) {
|
|
84
|
+
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: '无效角色' });
|
|
85
|
+
await dbRun('UPDATE users SET role = ? WHERE id = ?', [role, targetId]);
|
|
86
|
+
}
|
|
87
|
+
if (password) {
|
|
88
|
+
if (password.length < 8) return res.status(400).json({ error: '密码至少 8 位' });
|
|
89
|
+
const hash = await bcrypt.hash(password, 10);
|
|
90
|
+
await dbRun('UPDATE users SET password_hash = ? WHERE id = ?', [hash, targetId]);
|
|
91
|
+
}
|
|
92
|
+
logger.audit('user.update', { targetUserId: targetId, changes: { role, username, email, password: !!password }, updatedBy: req.userId, ip: clientIp(req) });
|
|
93
|
+
res.json({ success: true });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
res.status(500).json({ error: '更新用户失败' });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
router.delete('/:id', requireAuth, requireAdmin, async (req, res) => {
|
|
100
|
+
const targetId = parseInt(req.params.id);
|
|
101
|
+
if (isNaN(targetId)) return res.status(400).json({ error: '无效用户 ID' });
|
|
102
|
+
if (targetId === req.userId) return res.status(400).json({ error: '不能删除自己' });
|
|
103
|
+
try {
|
|
104
|
+
const user = await dbGet('SELECT * FROM users WHERE id = ?', [targetId]);
|
|
105
|
+
if (!user) return res.status(404).json({ error: '用户不存在' });
|
|
106
|
+
// 将该用户的文件转交给第一个 admin
|
|
107
|
+
const admin = await dbGet("SELECT id FROM users WHERE role = 'admin' AND id != ? ORDER BY id ASC LIMIT 1", [targetId]);
|
|
108
|
+
if (admin) {
|
|
109
|
+
await dbRun('UPDATE files SET uploaded_by = ? WHERE uploaded_by = ?', [admin.id, targetId]);
|
|
110
|
+
}
|
|
111
|
+
// 删除用户(ON DELETE CASCADE 会清理 tokens)
|
|
112
|
+
await dbRun('DELETE FROM users WHERE id = ?', [targetId]);
|
|
113
|
+
logger.audit('user.delete', { targetUserId: targetId, username: user.username, deletedBy: req.userId, ip: clientIp(req) });
|
|
114
|
+
res.json({ success: true });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
res.status(500).json({ error: '删除用户失败' });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
module.exports = router;
|
package/server.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// 即页 jpage 服务端入口。
|
|
2
|
+
// 仅负责:app 创建、中间件装配、路由挂载、MCP/静态/catch-all、全局错误处理、启动编排与关闭钩子。
|
|
3
|
+
// 业务逻辑分布在 lib/(共享层)与 routes/(按域拆分的 Router)。
|
|
4
|
+
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const multer = require('multer');
|
|
10
|
+
const session = require('express-session');
|
|
11
|
+
const SQLiteStore = require('connect-sqlite3')(session);
|
|
12
|
+
const bcrypt = require('bcryptjs');
|
|
13
|
+
const helmet = require('helmet');
|
|
14
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
15
|
+
const morgan = require('morgan');
|
|
16
|
+
const cron = require('node-cron');
|
|
17
|
+
|
|
18
|
+
const { runMigrations } = require('./migrations');
|
|
19
|
+
const { setDb, dbGet, dbRun, configureDatabase } = require('./lib/db');
|
|
20
|
+
const { DATA_DIR, UPLOAD_DIR } = require('./lib/paths');
|
|
21
|
+
const { generateReadablePassword, currentUserId } = require('./lib/util');
|
|
22
|
+
const { loadTemplates, loadTemplateNameMap } = require('./lib/templates');
|
|
23
|
+
const { reloadCategoryNameCache } = require('./lib/categories');
|
|
24
|
+
const { backfillFtsIndex } = require('./lib/fts');
|
|
25
|
+
const { scheduleViewCountFlush, flushViewCounts, recordVisit } = require('./lib/view-counts');
|
|
26
|
+
const { setAdminUserId } = require('./lib/auth-state');
|
|
27
|
+
const { renderFile } = require('./lib/render');
|
|
28
|
+
const { initMailer } = require('./mailer');
|
|
29
|
+
const { mountMcpServer, closeMcpTransports } = require('./mcp-server');
|
|
30
|
+
const logger = require('./logger');
|
|
31
|
+
|
|
32
|
+
// 路由域
|
|
33
|
+
const authRouter = require('./routes/auth');
|
|
34
|
+
const usersRouter = require('./routes/users');
|
|
35
|
+
const tokensRouter = require('./routes/tokens');
|
|
36
|
+
const filesRouter = require('./routes/files');
|
|
37
|
+
const tagsRouter = require('./routes/tags');
|
|
38
|
+
const categoriesRouter = require('./routes/categories');
|
|
39
|
+
const contentTemplatesRouter = require('./routes/content-templates');
|
|
40
|
+
const adminRouter = require('./routes/admin');
|
|
41
|
+
const skillsRouter = require('./routes/skills');
|
|
42
|
+
|
|
43
|
+
const PORT = process.env.PORT || 8858;
|
|
44
|
+
const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
45
|
+
const ALLOW_REGISTRATION = process.env.ALLOW_REGISTRATION === 'true';
|
|
46
|
+
|
|
47
|
+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
48
|
+
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
49
|
+
|
|
50
|
+
// 创建并注入数据库实例(lib/* 通过 require('./lib/db') 取同一实例)
|
|
51
|
+
const db = new sqlite3.Database(path.join(DATA_DIR, 'database.sqlite'));
|
|
52
|
+
setDb(db);
|
|
53
|
+
|
|
54
|
+
// --- 会话密钥 ---
|
|
55
|
+
let sessionSecret = process.env.SESSION_SECRET;
|
|
56
|
+
let sessionSecretWarning = false;
|
|
57
|
+
if (!sessionSecret) {
|
|
58
|
+
if (NODE_ENV === 'production') {
|
|
59
|
+
logger.error({ type: 'app', message: '生产模式下必须设置 SESSION_SECRET' });
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
sessionSecret = crypto.randomBytes(32).toString('hex');
|
|
63
|
+
sessionSecretWarning = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const app = express();
|
|
67
|
+
app.set('trust proxy', 1);
|
|
68
|
+
|
|
69
|
+
// helmet:关闭内置 CSP(由下方手写中间件 + render.js 分级下发),
|
|
70
|
+
// 显式 frameguard=deny 与 CSP frame-ancestors 'none' 对齐,消除头冲突。
|
|
71
|
+
// crossOriginEmbedderPolicy 关闭:渲染端点会加载用户内容(可能含未带 CORP 的子资源)。
|
|
72
|
+
app.use(helmet({
|
|
73
|
+
contentSecurityPolicy: false,
|
|
74
|
+
crossOriginEmbedderPolicy: false,
|
|
75
|
+
frameguard: { action: 'deny' },
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// CSP 中间件:管理界面下发严格 APP_CSP;渲染端点(render/asset/短链)跳过,
|
|
79
|
+
// 由 lib/render.js 的 renderFile 按内容类型分级下发 Markdown/HTML 策略。
|
|
80
|
+
const { APP_CSP, isRenderPath } = require('./lib/csp');
|
|
81
|
+
app.use((req, res, next) => {
|
|
82
|
+
if (isRenderPath(req.path)) return next();
|
|
83
|
+
res.setHeader('Content-Security-Policy', APP_CSP);
|
|
84
|
+
next();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 全局 JSON 解析限制为 1MB;大 body(upload-json / upload-zip-base64)由端点级中间件放宽。
|
|
88
|
+
app.use(express.json({ limit: '1mb' }));
|
|
89
|
+
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
|
90
|
+
|
|
91
|
+
app.use((req, res, next) => {
|
|
92
|
+
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
|
|
93
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
94
|
+
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
|
|
95
|
+
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
|
96
|
+
next();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
app.use(session({
|
|
100
|
+
store: new SQLiteStore({ db: 'sessions.sqlite', dir: DATA_DIR }),
|
|
101
|
+
secret: sessionSecret,
|
|
102
|
+
resave: false,
|
|
103
|
+
saveUninitialized: false,
|
|
104
|
+
name: 'jpage.sid',
|
|
105
|
+
cookie: {
|
|
106
|
+
httpOnly: true,
|
|
107
|
+
sameSite: 'lax',
|
|
108
|
+
// HTTPS 部署应开启 secure,避免会话 cookie 被中间人嗅探。
|
|
109
|
+
// 显式通过 COOKIE_SECURE=true 开启;未设时保持 false 以兼容 HTTP 部署。
|
|
110
|
+
secure: process.env.COOKIE_SECURE === 'true',
|
|
111
|
+
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
112
|
+
}
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
// --- 结构化 HTTP 请求日志 ---
|
|
116
|
+
morgan.token('user-id', (req) => req.userId || req.session?.userId || '-');
|
|
117
|
+
morgan.token('ts', () => new Date().toISOString());
|
|
118
|
+
app.use(morgan((tokens, req, res) => {
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
level: 'info',
|
|
121
|
+
type: 'http',
|
|
122
|
+
method: tokens.method(req, res),
|
|
123
|
+
url: tokens.url(req, res),
|
|
124
|
+
status: parseInt(tokens.status(req, res), 10),
|
|
125
|
+
contentLength: tokens.res(req, res, 'content-length') || 0,
|
|
126
|
+
responseTime: tokens['response-time'](req, res) + ' ms',
|
|
127
|
+
remoteAddr: tokens['remote-addr'](req, res),
|
|
128
|
+
userAgent: tokens['user-agent'](req, res),
|
|
129
|
+
referrer: tokens.referrer(req, res) || '',
|
|
130
|
+
userId: tokens['user-id'](req, res),
|
|
131
|
+
timestamp: tokens.ts(req, res),
|
|
132
|
+
});
|
|
133
|
+
}, {
|
|
134
|
+
skip: (req) => req.path.startsWith('/vendor/') || /\.(css|js|map|png|ico|woff2?)$/i.test(req.path),
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
const { version: PACKAGE_VERSION } = require('./package.json');
|
|
138
|
+
|
|
139
|
+
// --- 健康检查 ---
|
|
140
|
+
app.get('/health', async (req, res) => {
|
|
141
|
+
let dbOk = false;
|
|
142
|
+
let diskOk = false;
|
|
143
|
+
try { await dbGet('SELECT 1'); dbOk = true; } catch {}
|
|
144
|
+
try { diskOk = fs.existsSync(UPLOAD_DIR); } catch {}
|
|
145
|
+
const ok = dbOk && diskOk;
|
|
146
|
+
res.status(ok ? 200 : 503).json({
|
|
147
|
+
status: ok ? 'ok' : 'degraded',
|
|
148
|
+
db: dbOk,
|
|
149
|
+
disk: diskOk,
|
|
150
|
+
uptime: process.uptime(),
|
|
151
|
+
version: PACKAGE_VERSION
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// --- 路由挂载 ---
|
|
156
|
+
app.use('/api/auth', authRouter);
|
|
157
|
+
app.use('/api/users', usersRouter);
|
|
158
|
+
app.use('/api/tokens', tokensRouter);
|
|
159
|
+
app.use('/api/files', filesRouter);
|
|
160
|
+
app.use('/api/tags', tagsRouter);
|
|
161
|
+
app.use('/api', categoriesRouter); // /api/categories、/api/templates
|
|
162
|
+
app.use('/api/content-templates', contentTemplatesRouter);
|
|
163
|
+
app.use('/api/admin', adminRouter);
|
|
164
|
+
app.use('/api', skillsRouter); // /api/skills、/api/mcp/config
|
|
165
|
+
|
|
166
|
+
// --- 短链(根路径,公开热点)---
|
|
167
|
+
app.get('/s/:key', async (req, res) => {
|
|
168
|
+
try {
|
|
169
|
+
const file = await dbGet('SELECT * FROM files WHERE share_key = ?', [req.params.key]);
|
|
170
|
+
if (!file) return res.status(404).send('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:sans-serif;text-align:center;padding:4em"><h1>404</h1><p>页面不存在</p><a href="/">返回首页</a></body></html>');
|
|
171
|
+
if (!file.is_public && !currentUserId(req)) return res.redirect('/');
|
|
172
|
+
recordVisit(file, req).catch(() => {});
|
|
173
|
+
await renderFile(res, file);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
res.status(500).json({ error: '渲染失败' });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// --- MCP 端点 ---
|
|
180
|
+
async function authenticateMcpToken(tokenValue) {
|
|
181
|
+
// 旧 MCP_TOKEN
|
|
182
|
+
if (process.env.MCP_TOKEN && tokenValue === process.env.MCP_TOKEN) return true;
|
|
183
|
+
// 用户级 Token
|
|
184
|
+
const hash = crypto.createHash('sha256').update(tokenValue).digest('hex');
|
|
185
|
+
const row = await dbGet('SELECT id FROM tokens WHERE token_hash = ?', [hash]);
|
|
186
|
+
return !!row;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
mountMcpServer(app, {
|
|
190
|
+
port: PORT,
|
|
191
|
+
mcpToken: process.env.MCP_TOKEN,
|
|
192
|
+
mcpIp: process.env.MCP_IP || 'localhost',
|
|
193
|
+
protocol: process.env.MCP_PROTOCOL || 'http',
|
|
194
|
+
authenticateRequest: authenticateMcpToken,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// --- 静态资源 ---
|
|
198
|
+
const NODE_MODULES = path.join(__dirname, 'node_modules');
|
|
199
|
+
// 静态资源长缓存:版本化路径(vendor 内容随包固定,public 资源带 ?v= 查询参数)
|
|
200
|
+
// 30 天 + immutable,命中后浏览器零往返;首次加载仍走 ETag。
|
|
201
|
+
const STATIC_OPTS = { maxAge: '30d', immutable: true, etag: true, lastModified: true };
|
|
202
|
+
app.use('/vendor/katex', express.static(path.join(NODE_MODULES, 'katex', 'dist'), STATIC_OPTS));
|
|
203
|
+
app.use('/vendor/highlight.js', express.static(path.join(NODE_MODULES, 'highlight.js'), STATIC_OPTS));
|
|
204
|
+
app.use('/vendor/mermaid', express.static(path.join(NODE_MODULES, 'mermaid', 'dist'), STATIC_OPTS));
|
|
205
|
+
|
|
206
|
+
// index:false —— 不让 static 自动把 / 映射到 index.html(由下方 catch-all 注入哈希资源路径后返回)
|
|
207
|
+
app.use(express.static(path.join(__dirname, 'public'), { ...STATIC_OPTS, index: false }));
|
|
208
|
+
|
|
209
|
+
// --- SPA 兜底:返回 index.html,注入打包后的带哈希资源路径(若已 build)---
|
|
210
|
+
const INDEX_HTML_PATH = path.join(__dirname, 'public', 'index.html');
|
|
211
|
+
const DIST_MANIFEST_PATH = path.join(__dirname, 'public', 'dist', 'manifest.json');
|
|
212
|
+
let _indexHtmlCache = { html: null, manifestMtime: 0, manifest: null };
|
|
213
|
+
function getIndexHtml() {
|
|
214
|
+
let manifest = null, manifestMtime = 0;
|
|
215
|
+
try {
|
|
216
|
+
const st = fs.statSync(DIST_MANIFEST_PATH);
|
|
217
|
+
manifestMtime = st.mtimeMs;
|
|
218
|
+
if (_indexHtmlCache.html && _indexHtmlCache.manifestMtime === manifestMtime) {
|
|
219
|
+
return _indexHtmlCache.html; // manifest 未变,用缓存
|
|
220
|
+
}
|
|
221
|
+
manifest = JSON.parse(fs.readFileSync(DIST_MANIFEST_PATH, 'utf8'));
|
|
222
|
+
} catch {
|
|
223
|
+
// 无构建产物 → 返回源 index.html(引用源文件 /css、/js)
|
|
224
|
+
if (_indexHtmlCache.html && !_indexHtmlCache.manifest) return _indexHtmlCache.html;
|
|
225
|
+
const html = fs.readFileSync(INDEX_HTML_PATH, 'utf8');
|
|
226
|
+
_indexHtmlCache = { html, manifestMtime: 0, manifest: null };
|
|
227
|
+
return html;
|
|
228
|
+
}
|
|
229
|
+
// 注入哈希路径
|
|
230
|
+
let html = fs.readFileSync(INDEX_HTML_PATH, 'utf8');
|
|
231
|
+
if (manifest['style.css']) {
|
|
232
|
+
html = html.replace(/\/css\/style\.css\?v=[\d.]+/g, '/dist/' + manifest['style.css']);
|
|
233
|
+
}
|
|
234
|
+
if (manifest['app.js']) {
|
|
235
|
+
html = html.replace(/\/js\/app\.js\?v=[\d.]+/g, '/dist/' + manifest['app.js']);
|
|
236
|
+
}
|
|
237
|
+
_indexHtmlCache = { html, manifestMtime, manifest };
|
|
238
|
+
return html;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
app.get('*', (req, res) => {
|
|
242
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
243
|
+
res.send(getIndexHtml());
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// --- 全局错误处理 ---
|
|
247
|
+
app.use((err, req, res, next) => {
|
|
248
|
+
logger.error({ type: 'app', message: err.message, stack: err.stack });
|
|
249
|
+
if (err instanceof multer.MulterError) {
|
|
250
|
+
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件大小超过50MB限制' });
|
|
251
|
+
return res.status(400).json({ error: err.message });
|
|
252
|
+
}
|
|
253
|
+
res.status(500).json({ error: err.message || '服务器内部错误' });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// --- 初始管理员引导 ---
|
|
257
|
+
async function bootstrapAdmin() {
|
|
258
|
+
let adminUser = process.env.ADMIN_USER;
|
|
259
|
+
const explicitPass = process.env.ADMIN_PASSWORD;
|
|
260
|
+
try {
|
|
261
|
+
const row = await dbGet('SELECT COUNT(*) AS c FROM users');
|
|
262
|
+
if (row.c > 0) return;
|
|
263
|
+
|
|
264
|
+
if (!adminUser) adminUser = 'admin';
|
|
265
|
+
|
|
266
|
+
let adminPass;
|
|
267
|
+
if (explicitPass) {
|
|
268
|
+
if (explicitPass.length < 8) {
|
|
269
|
+
logger.warn({ type: 'app', message: 'ADMIN_PASSWORD 长度不足 8 位,跳过自动创建' });
|
|
270
|
+
logger.warn({ type: 'app', message: '解决方式:设置为 ≥8 位的强密码,或留空以自动生成' });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
adminPass = explicitPass;
|
|
274
|
+
} else {
|
|
275
|
+
adminPass = generateReadablePassword(16);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const hash = await bcrypt.hash(adminPass, 10);
|
|
279
|
+
await dbRun('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [adminUser, hash, 'admin']);
|
|
280
|
+
logger.info({ type: 'app', message: '已创建初始管理员', username: adminUser });
|
|
281
|
+
if (!explicitPass) {
|
|
282
|
+
logger.info({ type: 'app', message: '初始密码', password: adminPass, sensitive: true });
|
|
283
|
+
logger.info({ type: 'app', message: '首次登录后请立即修改密码' });
|
|
284
|
+
}
|
|
285
|
+
} catch (e) {
|
|
286
|
+
logger.error({ type: 'app', message: '初始化管理员失败', error: e.message });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- 启动 ---
|
|
291
|
+
// 初始化数据库与缓存(启动和测试都需要);listen 仅在直接运行时执行。
|
|
292
|
+
async function initApp() {
|
|
293
|
+
await configureDatabase();
|
|
294
|
+
await runMigrations(db);
|
|
295
|
+
initMailer();
|
|
296
|
+
loadTemplates();
|
|
297
|
+
await loadTemplateNameMap();
|
|
298
|
+
await reloadCategoryNameCache();
|
|
299
|
+
await backfillFtsIndex();
|
|
300
|
+
await bootstrapAdmin();
|
|
301
|
+
let adminUserId = null;
|
|
302
|
+
try {
|
|
303
|
+
const row = await dbGet('SELECT id FROM users ORDER BY id ASC LIMIT 1');
|
|
304
|
+
if (row) adminUserId = row.id;
|
|
305
|
+
} catch (e) {
|
|
306
|
+
logger.error({ type: 'app', message: '解析 admin user id 失败', error: e.message });
|
|
307
|
+
}
|
|
308
|
+
setAdminUserId(adminUserId);
|
|
309
|
+
scheduleViewCountFlush();
|
|
310
|
+
return adminUserId;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (require.main === module) {
|
|
314
|
+
app.listen(PORT, async () => {
|
|
315
|
+
const mcpIp = process.env.MCP_IP || 'localhost';
|
|
316
|
+
const adminUserId = await initApp();
|
|
317
|
+
logger.info({ type: 'app', message: '服务已启动', url: `http://${mcpIp}:${PORT}`, registration: ALLOW_REGISTRATION ? 'open' : 'closed' });
|
|
318
|
+
if (sessionSecretWarning) logger.warn({ type: 'app', message: 'SESSION_SECRET 未设置,已生成临时密钥(重启后会话会失效)' });
|
|
319
|
+
|
|
320
|
+
// 自动定时备份
|
|
321
|
+
const backupCron = process.env.BACKUP_CRON;
|
|
322
|
+
if (backupCron) {
|
|
323
|
+
const { createBackupArchive } = adminRouter;
|
|
324
|
+
const backupDir = process.env.BACKUP_DIR || path.join(DATA_DIR, 'backups');
|
|
325
|
+
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
|
326
|
+
if (cron.validate(backupCron)) {
|
|
327
|
+
cron.schedule(backupCron, () => {
|
|
328
|
+
try {
|
|
329
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
330
|
+
const fname = `jpage-backup-${ts}.zip`;
|
|
331
|
+
const fpath = path.join(backupDir, fname);
|
|
332
|
+
const output = fs.createWriteStream(fpath);
|
|
333
|
+
const archive = createBackupArchive();
|
|
334
|
+
output.on('close', () => {
|
|
335
|
+
logger.info({ type: 'app', message: '自动备份完成', file: fpath });
|
|
336
|
+
const backups = fs.readdirSync(backupDir)
|
|
337
|
+
.filter(f => f.startsWith('jpage-backup-') && f.endsWith('.zip'))
|
|
338
|
+
.sort();
|
|
339
|
+
while (backups.length > 7) {
|
|
340
|
+
fs.unlinkSync(path.join(backupDir, backups.shift()));
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
archive.pipe(output);
|
|
344
|
+
archive.finalize();
|
|
345
|
+
} catch (e) {
|
|
346
|
+
logger.error({ type: 'app', message: '自动备份失败', error: e.message });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
logger.info({ type: 'app', message: '自动备份已启用', cron: backupCron, dir: backupDir });
|
|
350
|
+
} else {
|
|
351
|
+
logger.warn({ type: 'app', message: 'BACKUP_CRON 格式无效', cron: backupCron });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (process.env.MCP_TOKEN && !adminUserId) {
|
|
355
|
+
logger.warn({ type: 'app', message: 'MCP_TOKEN 已设置但 users 表为空,MCP 端点将禁用' });
|
|
356
|
+
} else if (process.env.MCP_TOKEN && adminUserId) {
|
|
357
|
+
logger.info({ type: 'app', message: 'MCP 端点已启用', url: `http://${mcpIp}:${PORT}/mcp` });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
362
|
+
process.on(sig, async () => {
|
|
363
|
+
logger.info({ type: 'app', message: `收到 ${sig},正在关闭 MCP transport` });
|
|
364
|
+
await flushViewCounts(); // 关闭前回写缓冲的 view_count,避免丢失
|
|
365
|
+
await closeMcpTransports();
|
|
366
|
+
process.exit(0);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 导出供集成测试使用(require 时不 listen)
|
|
372
|
+
module.exports = { app, db, initApp };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jpage-content-template
|
|
3
|
+
description: 当用户要生成 HTML/Markdown 内容时,先从模板市场查找风格样例,参照样例的风格生成新内容。适用于用户要求生成页面、报告、仪表板等,且希望有特定风格参考的场景。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 核心规则
|
|
7
|
+
|
|
8
|
+
当用户要求生成 HTML 或 Markdown 内容时,先判断是否需要风格参考。如果用户指定了风格或类型(如「仪表板」「报告」「深色风格」),先查询模板市场获取样例。
|
|
9
|
+
|
|
10
|
+
# 触发场景
|
|
11
|
+
|
|
12
|
+
- 用户说「参照模板生成…」「用模板风格生成…」
|
|
13
|
+
- 用户要求生成特定类型的内容(仪表板、报告、简历等)
|
|
14
|
+
- 用户提到具体风格关键词(深色、极简、科技感等)
|
|
15
|
+
- 用户说「从模板市场找一个…」
|
|
16
|
+
- 用户要求生成美观的页面但未指定具体样式
|
|
17
|
+
|
|
18
|
+
# 工作流
|
|
19
|
+
|
|
20
|
+
## 场景一:用户指定模板或风格
|
|
21
|
+
|
|
22
|
+
用户明确要求参照某个模板或某种风格。
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
1. 调 list_content_templates 查询模板市场
|
|
26
|
+
- 如果用户指定了场景类型(如「仪表板」),设置 scene 参数
|
|
27
|
+
- 如果用户指定了风格关键词,设置 keyword 参数
|
|
28
|
+
2. 向用户展示匹配的模板列表(标题、场景、描述)
|
|
29
|
+
3. 用户选择一个模板后,调 get_content_template(id=选择的模板id) 获取完整样例
|
|
30
|
+
4. 学习样例的以下特征:
|
|
31
|
+
- 整体布局结构(header/main/footer/sidebar 等区域划分)
|
|
32
|
+
- 色彩方案(主色、辅色、背景色、文字色)
|
|
33
|
+
- 排版风格(字体大小、间距、对齐方式)
|
|
34
|
+
- 交互元素(按钮样式、卡片样式、表格样式)
|
|
35
|
+
- 使用的 CSS 技术(Grid/Flexbox/absolute 等)
|
|
36
|
+
- 特殊效果(渐变、阴影、动画等)
|
|
37
|
+
5. 根据用户的具体内容需求,生成风格一致但内容全新的 HTML/Markdown
|
|
38
|
+
6. 调 upload_file 上传到即页,返回预览链接
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 场景二:自动推荐模板
|
|
42
|
+
|
|
43
|
+
用户没有指定模板,但要求生成特定类型的内容。
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
1. 根据用户需求判断可能的场景类型
|
|
47
|
+
2. 调 list_content_templates(scene=场景类型, sort="use_count", limit=3)
|
|
48
|
+
3. 如果有匹配模板,向用户推荐:
|
|
49
|
+
「我找到了几个相关模板,是否参照某个模板的风格?还是直接生成?」
|
|
50
|
+
4. 用户选择后,按场景一的流程继续
|
|
51
|
+
5. 如果用户选择直接生成,则不使用模板,正常生成
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 场景三:上传样例到模板市场
|
|
55
|
+
|
|
56
|
+
用户有一段好看的 HTML/Markdown,想保存为模板供以后参考。
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
1. 调 POST /api/content-templates 上传模板
|
|
60
|
+
- title: 模板名称
|
|
61
|
+
- content: 完整的 HTML/Markdown 内容
|
|
62
|
+
- scene: 使用场景(dashboard/report/resume/landing/note/other)
|
|
63
|
+
- description: 风格描述
|
|
64
|
+
- styleTags: 风格标签(逗号分隔)
|
|
65
|
+
- fileType: html 或 markdown
|
|
66
|
+
2. 告知用户模板已上传到市场
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
# 场景与关键词对照
|
|
70
|
+
|
|
71
|
+
| 用户可能说的 | scene 参数 |
|
|
72
|
+
|---|---|
|
|
73
|
+
| 仪表板、数据看板、Dashboard、监控面板 | dashboard |
|
|
74
|
+
| 报告、周报、月报、分析报告 | report |
|
|
75
|
+
| 简历、CV、名片、个人主页 | resume |
|
|
76
|
+
| 落地页、Landing Page、产品页、活动页 | landing |
|
|
77
|
+
| 笔记、文档、会议纪要、技术文档 | note |
|
|
78
|
+
| 卡片、海报、Banner、封面 | card |
|
|
79
|
+
| 演示、PPT、幻灯片 | presentation |
|
|
80
|
+
| 邮件、Email、Newsletter | email |
|
|
81
|
+
| 其他未分类 | other |
|
|
82
|
+
|
|
83
|
+
# 风格学习原则
|
|
84
|
+
|
|
85
|
+
AI 拿到样例后应学习的维度(按优先级):
|
|
86
|
+
|
|
87
|
+
1. **布局结构** — 区域划分、内容组织方式
|
|
88
|
+
2. **色彩方案** — 主色调、配色关系
|
|
89
|
+
3. **字体排版** — 字号层级、行间距、字重
|
|
90
|
+
4. **组件样式** — 卡片、按钮、表格、图表容器
|
|
91
|
+
5. **视觉装饰** — 圆角、阴影、渐变、边框
|
|
92
|
+
|
|
93
|
+
**重要**:学习风格,不复制内容。生成的必须是全新的原创内容,仅保持视觉风格一致。
|
|
94
|
+
|
|
95
|
+
# MCP 工具
|
|
96
|
+
|
|
97
|
+
- `list_content_templates` — 查询模板列表(支持 scene/keyword/fileType 筛选)
|
|
98
|
+
- `get_content_template` — 获取模板完整内容(自动记录使用次数)
|