@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,44 @@
|
|
|
1
|
+
const STORAGE_KEY = 'jpage-theme';
|
|
2
|
+
|
|
3
|
+
function getSystemPreference() {
|
|
4
|
+
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function getStoredTheme() {
|
|
8
|
+
try {
|
|
9
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getCurrentTheme() {
|
|
16
|
+
return getStoredTheme() || getSystemPreference();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function applyTheme(theme) {
|
|
20
|
+
document.documentElement.classList.toggle('light', theme === 'light');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toggleTheme() {
|
|
24
|
+
const current = getCurrentTheme();
|
|
25
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
26
|
+
applyTheme(next);
|
|
27
|
+
try { localStorage.setItem(STORAGE_KEY, next); } catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function initTheme() {
|
|
31
|
+
applyTheme(getCurrentTheme());
|
|
32
|
+
|
|
33
|
+
document.querySelectorAll('.theme-toggle').forEach(btn => {
|
|
34
|
+
btn.addEventListener('click', toggleTheme);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setupThemeToggle(container) {
|
|
39
|
+
container.querySelectorAll('.theme-toggle').forEach(btn => {
|
|
40
|
+
btn.addEventListener('click', toggleTheme);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { initTheme, setupThemeToggle, toggleTheme };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// 工具函数:HTML 转义、文件大小格式化、相对时间、骨架屏等
|
|
2
|
+
|
|
3
|
+
function escapeHtml(text) {
|
|
4
|
+
return String(text)
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatSize(bytes) {
|
|
13
|
+
if (bytes < 1024) return bytes + ' B';
|
|
14
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
15
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function relativeTime(dateStr) {
|
|
19
|
+
if (!dateStr) return '';
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const then = new Date(dateStr).getTime();
|
|
22
|
+
if (isNaN(then)) return '';
|
|
23
|
+
const diff = now - then;
|
|
24
|
+
const seconds = Math.floor(diff / 1000);
|
|
25
|
+
if (seconds < 60) return '刚刚';
|
|
26
|
+
const minutes = Math.floor(seconds / 60);
|
|
27
|
+
if (minutes < 60) return `${minutes} 分钟前`;
|
|
28
|
+
const hours = Math.floor(minutes / 60);
|
|
29
|
+
if (hours < 24) return `${hours} 小时前`;
|
|
30
|
+
const days = Math.floor(hours / 24);
|
|
31
|
+
if (days === 1) return '昨天';
|
|
32
|
+
if (days < 30) return `${days} 天前`;
|
|
33
|
+
return new Date(dateStr).toLocaleDateString('zh-CN');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatDate(iso) {
|
|
37
|
+
if (!iso) return '-';
|
|
38
|
+
const d = new Date(iso);
|
|
39
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
|
43
|
+
|
|
44
|
+
function buildSkeletonCards(n) {
|
|
45
|
+
let html = '';
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
html += '<div class="skeleton-item" aria-hidden="true">'
|
|
48
|
+
+ '<div class="skeleton-icon"></div>'
|
|
49
|
+
+ '<div class="skeleton-lines">'
|
|
50
|
+
+ '<div class="skeleton-line skeleton-w60"></div>'
|
|
51
|
+
+ '<div class="skeleton-line skeleton-w40"></div>'
|
|
52
|
+
+ '</div></div>';
|
|
53
|
+
}
|
|
54
|
+
return html;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function openModal(el) {
|
|
58
|
+
el.hidden = false;
|
|
59
|
+
el.setAttribute('aria-hidden', 'false');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function closeModal(el) {
|
|
63
|
+
el.hidden = true;
|
|
64
|
+
el.setAttribute('aria-hidden', 'true');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { escapeHtml, formatSize, relativeTime, formatDate, esc, buildSkeletonCards, openModal, closeModal };
|
package/routes/admin.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// 管理员路由:数据备份导出 / 导入恢复 / 存储统计。从 server.js 提取,行为保持不变。
|
|
2
|
+
// 挂载点:/api/admin
|
|
3
|
+
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const multer = require('multer');
|
|
8
|
+
const archiver = require('archiver');
|
|
9
|
+
const JSZip = require('jszip');
|
|
10
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
11
|
+
const { getDb, dbGet, configureDatabase } = require('../lib/db');
|
|
12
|
+
const { DATA_DIR, UPLOAD_DIR } = require('../lib/paths');
|
|
13
|
+
const { requireAuth, requireAdmin } = require('../lib/middleware/auth');
|
|
14
|
+
const { clientIp } = require('../lib/util');
|
|
15
|
+
const { loadTemplateNameMap } = require('../lib/templates');
|
|
16
|
+
const { reloadCategoryNameCache } = require('../lib/categories');
|
|
17
|
+
const { clearRenderCache } = require('../lib/render-cache');
|
|
18
|
+
const logger = require('../logger');
|
|
19
|
+
|
|
20
|
+
const router = express.Router();
|
|
21
|
+
|
|
22
|
+
// 用于 import 的独立 multer 实例(不限文件类型)
|
|
23
|
+
const adminUpload = multer({
|
|
24
|
+
storage: multer.diskStorage({
|
|
25
|
+
destination: (req, file, cb) => cb(null, DATA_DIR),
|
|
26
|
+
filename: (req, file, cb) => cb(null, 'import-' + Date.now() + '.zip')
|
|
27
|
+
}),
|
|
28
|
+
limits: { fileSize: 500 * 1024 * 1024 }
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function createBackupArchive() {
|
|
32
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
33
|
+
const db = getDb();
|
|
34
|
+
db.run('PRAGMA wal_checkpoint(FULL)', (err) => {
|
|
35
|
+
if (err) logger.warn({ type: 'app', message: 'WAL checkpoint 失败', error: err.message });
|
|
36
|
+
});
|
|
37
|
+
archive.file(path.join(DATA_DIR, 'database.sqlite'), { name: 'database.sqlite' });
|
|
38
|
+
const sessionFile = path.join(DATA_DIR, 'sessions.sqlite');
|
|
39
|
+
if (fs.existsSync(sessionFile)) archive.file(sessionFile, { name: 'sessions.sqlite' });
|
|
40
|
+
if (fs.existsSync(UPLOAD_DIR)) archive.directory(UPLOAD_DIR, 'uploads');
|
|
41
|
+
return archive;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
router.get('/export', requireAuth, requireAdmin, (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
47
|
+
const fname = `jpage-backup-${date}.zip`;
|
|
48
|
+
const encoded = encodeURIComponent(fname);
|
|
49
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
50
|
+
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`);
|
|
51
|
+
const archive = createBackupArchive();
|
|
52
|
+
archive.on('end', () => res.end());
|
|
53
|
+
archive.pipe(res);
|
|
54
|
+
archive.finalize().catch(e => {
|
|
55
|
+
logger.error({ type: 'app', message: '备份导出失败', error: e.message });
|
|
56
|
+
if (!res.headersSent) res.status(500).json({ error: '导出失败' });
|
|
57
|
+
});
|
|
58
|
+
logger.audit('backup.export', { ip: clientIp(req) });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
res.status(500).json({ error: '导出失败' });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
router.post('/import', requireAuth, requireAdmin, adminUpload.single('file'), async (req, res) => {
|
|
65
|
+
if (!req.file) return res.status(400).json({ error: '请上传 ZIP 文件' });
|
|
66
|
+
const zipPath = req.file.path;
|
|
67
|
+
try {
|
|
68
|
+
const zipBuf = fs.readFileSync(zipPath);
|
|
69
|
+
const zip = await JSZip.loadAsync(zipBuf);
|
|
70
|
+
if (!zip.file('database.sqlite')) {
|
|
71
|
+
fs.unlinkSync(zipPath);
|
|
72
|
+
return res.status(400).json({ error: '无效的备份文件:缺少 database.sqlite' });
|
|
73
|
+
}
|
|
74
|
+
const backupDate = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
75
|
+
const backupDir = path.join(path.dirname(DATA_DIR), `data-backup-${backupDate}`);
|
|
76
|
+
fs.cpSync(DATA_DIR, backupDir, { recursive: true });
|
|
77
|
+
logger.info({ type: 'app', message: '导入前备份已创建', backupDir });
|
|
78
|
+
for (const entry of fs.readdirSync(DATA_DIR)) {
|
|
79
|
+
fs.rmSync(path.join(DATA_DIR, entry), { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
for (const [relPath, entry] of Object.entries(zip.files)) {
|
|
82
|
+
if (entry.dir) {
|
|
83
|
+
fs.mkdirSync(path.join(DATA_DIR, relPath), { recursive: true });
|
|
84
|
+
} else {
|
|
85
|
+
const buf = await entry.async('nodebuffer');
|
|
86
|
+
const filePath = path.join(DATA_DIR, relPath);
|
|
87
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
88
|
+
fs.writeFileSync(filePath, buf);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
92
|
+
// 导入替换数据库连接:在同一个 db 实例上替换方法,使所有持有该引用的模块继续生效
|
|
93
|
+
const db = getDb();
|
|
94
|
+
db.close();
|
|
95
|
+
const newDb = new sqlite3.Database(path.join(DATA_DIR, 'database.sqlite'));
|
|
96
|
+
db.run = newDb.run.bind(newDb);
|
|
97
|
+
db.get = newDb.get.bind(newDb);
|
|
98
|
+
db.all = newDb.all.bind(newDb);
|
|
99
|
+
db.close = newDb.close.bind(newDb);
|
|
100
|
+
db.exec = newDb.exec.bind(newDb);
|
|
101
|
+
// 导入替换了连接:重新应用性能 PRAGMA 与刷新分类缓存
|
|
102
|
+
await configureDatabase();
|
|
103
|
+
await loadTemplateNameMap();
|
|
104
|
+
await reloadCategoryNameCache();
|
|
105
|
+
clearRenderCache();
|
|
106
|
+
logger.audit('backup.import', { ip: clientIp(req), backupDir });
|
|
107
|
+
res.json({ success: true, message: '数据已恢复,建议刷新页面重新加载' });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
logger.error({ type: 'app', message: '数据导入失败', error: e.message });
|
|
110
|
+
res.status(500).json({ error: '导入失败: ' + e.message });
|
|
111
|
+
} finally {
|
|
112
|
+
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
router.get('/stats', requireAuth, requireAdmin, async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const fileCount = await dbGet('SELECT COUNT(*) AS c FROM files');
|
|
119
|
+
let dbSize = 0;
|
|
120
|
+
const dbPath = path.join(DATA_DIR, 'database.sqlite');
|
|
121
|
+
if (fs.existsSync(dbPath)) dbSize = fs.statSync(dbPath).size;
|
|
122
|
+
let uploadsSize = 0;
|
|
123
|
+
if (fs.existsSync(UPLOAD_DIR)) {
|
|
124
|
+
for (const f of fs.readdirSync(UPLOAD_DIR)) {
|
|
125
|
+
const s = fs.statSync(path.join(UPLOAD_DIR, f));
|
|
126
|
+
if (s.isFile()) uploadsSize += s.size;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
res.json({ fileCount: fileCount.c, dbSize, uploadsSize, totalSize: dbSize + uploadsSize });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
res.status(500).json({ error: '获取统计失败' });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
module.exports = router;
|
|
136
|
+
module.exports.createBackupArchive = createBackupArchive;
|
package/routes/auth.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// 认证路由:me / login / register / logout / change-password / profile /
|
|
2
|
+
// verify-email / resend-verification / send-register-code / smtp-status / registration-status。
|
|
3
|
+
// 从 server.js 提取,行为保持不变。挂载点:/api/auth
|
|
4
|
+
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const bcrypt = require('bcryptjs');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const rateLimit = require('express-rate-limit');
|
|
9
|
+
const { dbGet, dbRun } = require('../lib/db');
|
|
10
|
+
const { requireAuth } = require('../lib/middleware/auth');
|
|
11
|
+
const { clientIp } = require('../lib/util');
|
|
12
|
+
const { sendMail, getAppUrl, isMailerConfigured } = require('../mailer');
|
|
13
|
+
const logger = require('../logger');
|
|
14
|
+
|
|
15
|
+
const router = express.Router();
|
|
16
|
+
|
|
17
|
+
const ALLOW_REGISTRATION = process.env.ALLOW_REGISTRATION === 'true';
|
|
18
|
+
|
|
19
|
+
const loginLimiter = rateLimit({
|
|
20
|
+
windowMs: 15 * 60 * 1000,
|
|
21
|
+
max: 10,
|
|
22
|
+
message: { error: '登录尝试过于频繁,请稍后再试' },
|
|
23
|
+
standardHeaders: true,
|
|
24
|
+
legacyHeaders: false
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const registerLimiter = rateLimit({
|
|
28
|
+
windowMs: 15 * 60 * 1000,
|
|
29
|
+
max: 5,
|
|
30
|
+
message: { error: '注册请求过于频繁,请稍后再试' },
|
|
31
|
+
standardHeaders: true,
|
|
32
|
+
legacyHeaders: false
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const sendCodeLimiter = rateLimit({
|
|
36
|
+
windowMs: 60 * 1000,
|
|
37
|
+
max: 1,
|
|
38
|
+
message: { error: '发送过于频繁,请 1 分钟后再试' },
|
|
39
|
+
standardHeaders: true,
|
|
40
|
+
legacyHeaders: false
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const resendLimiter = rateLimit({
|
|
44
|
+
windowMs: 60 * 60 * 1000,
|
|
45
|
+
max: 5,
|
|
46
|
+
standardHeaders: true,
|
|
47
|
+
legacyHeaders: false,
|
|
48
|
+
message: { error: '请求过于频繁,请稍后再试' }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- 邮箱验证 ---
|
|
52
|
+
|
|
53
|
+
function generateVerifyToken() {
|
|
54
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
55
|
+
const bytes = crypto.randomBytes(32);
|
|
56
|
+
let token = 'jv_';
|
|
57
|
+
for (let i = 0; i < 32; i++) token += chars[bytes[i] % chars.length];
|
|
58
|
+
return token;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function sendVerificationEmail(userId, email, type, newEmail) {
|
|
62
|
+
const token = generateVerifyToken();
|
|
63
|
+
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
64
|
+
const prefix = token.slice(0, 8);
|
|
65
|
+
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
66
|
+
|
|
67
|
+
await dbRun("DELETE FROM email_verifications WHERE user_id = ? AND type = ?", [userId, type]);
|
|
68
|
+
await dbRun(
|
|
69
|
+
'INSERT INTO email_verifications (user_id, token_hash, token_prefix, type, new_email, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
70
|
+
[userId, hash, prefix, type, newEmail || null, expiresAt]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!isMailerConfigured()) return { sent: false };
|
|
74
|
+
|
|
75
|
+
const targetEmail = newEmail || email;
|
|
76
|
+
const appUrl = getAppUrl();
|
|
77
|
+
const link = `${appUrl}/api/auth/verify-email?token=${token}`;
|
|
78
|
+
try {
|
|
79
|
+
await sendMail(targetEmail, '验证你的邮箱 — 即页',
|
|
80
|
+
`<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;padding:24px">
|
|
81
|
+
<h2 style="color:#1a1a1a">验证你的邮箱</h2>
|
|
82
|
+
<p style="color:#555;font-size:15px">请点击以下按钮验证你的邮箱地址:</p>
|
|
83
|
+
<p style="margin:24px 0"><a href="${link}" style="display:inline-block;padding:12px 28px;background:#4f46e5;color:#fff;border-radius:6px;text-decoration:none;font-size:15px">验证邮箱</a></p>
|
|
84
|
+
<p style="color:#888;font-size:13px">或复制链接到浏览器:<br><a href="${link}" style="word-break:break-all">${link}</a></p>
|
|
85
|
+
<p style="color:#888;font-size:13px">链接 24 小时内有效。</p>
|
|
86
|
+
</div>`
|
|
87
|
+
);
|
|
88
|
+
return { sent: true };
|
|
89
|
+
} catch (e) {
|
|
90
|
+
logger.error({ type: 'app', message: '发送验证邮件失败', error: e.message, userId });
|
|
91
|
+
return { sent: false, error: e.message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 从邮箱前缀生成唯一用户名
|
|
96
|
+
async function generateUsernameFromEmail(email) {
|
|
97
|
+
let base = email.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '').slice(0, 24);
|
|
98
|
+
if (!base) base = 'user';
|
|
99
|
+
let username = base;
|
|
100
|
+
let suffix = 1;
|
|
101
|
+
while (await dbGet('SELECT id FROM users WHERE username = ?', [username])) {
|
|
102
|
+
username = base + suffix;
|
|
103
|
+
suffix++;
|
|
104
|
+
if (username.length > 30) username = base.slice(0, 24) + suffix;
|
|
105
|
+
}
|
|
106
|
+
return username;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- 路由 ---
|
|
110
|
+
|
|
111
|
+
router.get('/me', async (req, res) => {
|
|
112
|
+
if (!req.session || !req.session.userId) return res.status(401).json({ error: '未登录' });
|
|
113
|
+
try {
|
|
114
|
+
const user = await dbGet('SELECT id, username, email, email_verified, role FROM users WHERE id = ?', [req.session.userId]);
|
|
115
|
+
if (!user) {
|
|
116
|
+
req.session.destroy(() => {});
|
|
117
|
+
return res.status(401).json({ error: '未登录' });
|
|
118
|
+
}
|
|
119
|
+
res.json({ id: user.id, username: user.username, email: user.email || null, emailVerified: !!user.email_verified, role: user.role });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
res.status(500).json({ error: '查询失败' });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
router.post('/login', loginLimiter, async (req, res) => {
|
|
126
|
+
const { username, account, password } = req.body || {};
|
|
127
|
+
const input = account || username;
|
|
128
|
+
if (!input || !password) return res.status(400).json({ error: '用户名和密码不能为空' });
|
|
129
|
+
try {
|
|
130
|
+
// 统一入口:自动识别用户名或邮箱
|
|
131
|
+
const isEmail = input.includes('@');
|
|
132
|
+
const user = isEmail
|
|
133
|
+
? await dbGet('SELECT * FROM users WHERE email = ?', [input])
|
|
134
|
+
: await dbGet('SELECT * FROM users WHERE username = ?', [input]);
|
|
135
|
+
if (!user) return res.status(401).json({ error: '登录失败' });
|
|
136
|
+
const ok = await bcrypt.compare(password, user.password_hash);
|
|
137
|
+
if (!ok) {
|
|
138
|
+
logger.audit('login', { username: input, ip: clientIp(req), success: false });
|
|
139
|
+
return res.status(401).json({ error: '登录失败' });
|
|
140
|
+
}
|
|
141
|
+
req.session.userId = user.id;
|
|
142
|
+
req.session.username = user.username;
|
|
143
|
+
req.session.userRole = user.role;
|
|
144
|
+
logger.audit('login', { username: user.username, ip: clientIp(req), success: true });
|
|
145
|
+
res.json({ id: user.id, username: user.username, email: user.email || null, emailVerified: !!user.email_verified, role: user.role });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
res.status(500).json({ error: '登录失败' });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
router.post('/register', registerLimiter, async (req, res) => {
|
|
152
|
+
if (!ALLOW_REGISTRATION) return res.status(403).json({ error: '注册功能未开放' });
|
|
153
|
+
const { email, username, password, confirmPassword, code } = req.body || {};
|
|
154
|
+
if (!email) return res.status(400).json({ error: '请填写邮箱' });
|
|
155
|
+
if (!code) return res.status(400).json({ error: '请填写验证码' });
|
|
156
|
+
if (!password || !confirmPassword) return res.status(400).json({ error: '请填写密码' });
|
|
157
|
+
if (password.length < 8) return res.status(400).json({ error: '密码至少 8 位' });
|
|
158
|
+
if (password !== confirmPassword) return res.status(400).json({ error: '两次密码不一致' });
|
|
159
|
+
|
|
160
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
161
|
+
if (!emailRegex.test(email)) return res.status(400).json({ error: '邮箱格式不正确' });
|
|
162
|
+
|
|
163
|
+
// 验证码校验(仅验证,不删除)
|
|
164
|
+
const codeHash = crypto.createHash('sha256').update(code + email).digest('hex');
|
|
165
|
+
const codeRow = await dbGet(
|
|
166
|
+
"SELECT * FROM email_verifications WHERE type = 'register_code' AND new_email = ? AND token_hash = ? AND expires_at > datetime('now')",
|
|
167
|
+
[email, codeHash]
|
|
168
|
+
);
|
|
169
|
+
if (!codeRow) return res.status(400).json({ error: '验证码无效或已过期' });
|
|
170
|
+
|
|
171
|
+
let finalUsername = username;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// 邮箱唯一性检查
|
|
175
|
+
const emailConflict = await dbGet('SELECT id FROM users WHERE email = ? OR username = ?', [email, email]);
|
|
176
|
+
if (emailConflict) return res.status(409).json({ error: '该邮箱已被使用' });
|
|
177
|
+
|
|
178
|
+
// 如果没提供 username,从邮箱自动生成
|
|
179
|
+
if (!finalUsername) {
|
|
180
|
+
finalUsername = await generateUsernameFromEmail(email);
|
|
181
|
+
} else {
|
|
182
|
+
if (finalUsername.length < 2 || finalUsername.length > 30 || !/^[a-zA-Z0-9_]+$/.test(finalUsername)) {
|
|
183
|
+
return res.status(400).json({ error: '用户名只能包含字母、数字和下划线,2-30 位' });
|
|
184
|
+
}
|
|
185
|
+
const nameConflict = await dbGet('SELECT id FROM users WHERE username = ? OR email = ?', [finalUsername, finalUsername]);
|
|
186
|
+
if (nameConflict) return res.status(409).json({ error: '该用户名已被使用' });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 所有校验通过,消费验证码
|
|
190
|
+
await dbRun('DELETE FROM email_verifications WHERE id = ?', [codeRow.id]);
|
|
191
|
+
|
|
192
|
+
const hash = await bcrypt.hash(password, 10);
|
|
193
|
+
const result = await dbRun(
|
|
194
|
+
'INSERT INTO users (username, email, email_verified, password_hash, role) VALUES (?, ?, 1, ?, ?)',
|
|
195
|
+
[finalUsername, email, hash, 'user']
|
|
196
|
+
);
|
|
197
|
+
req.session.userId = result.lastID;
|
|
198
|
+
req.session.username = finalUsername;
|
|
199
|
+
req.session.userRole = 'user';
|
|
200
|
+
logger.audit('register', { username: finalUsername, email, userId: result.lastID, ip: clientIp(req) });
|
|
201
|
+
res.status(201).json({ id: result.lastID, username: finalUsername, email, emailVerified: true, role: 'user' });
|
|
202
|
+
} catch (e) {
|
|
203
|
+
logger.error({ type: 'app', msg: 'register error', error: e.message });
|
|
204
|
+
if (e.message && e.message.includes('UNIQUE')) return res.status(409).json({ error: '用户名或邮箱已存在' });
|
|
205
|
+
res.status(500).json({ error: '注册失败,请稍后重试' });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
router.post('/logout', (req, res) => {
|
|
210
|
+
const userId = req.session?.userId;
|
|
211
|
+
req.session.destroy(() => {
|
|
212
|
+
res.clearCookie('jpage.sid');
|
|
213
|
+
logger.audit('logout', { userId, ip: clientIp(req) });
|
|
214
|
+
res.json({ success: true });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
router.post('/change-password', requireAuth, async (req, res) => {
|
|
219
|
+
const { currentPassword, newPassword } = req.body || {};
|
|
220
|
+
if (!currentPassword || !newPassword) return res.status(400).json({ error: '当前密码和新密码不能为空' });
|
|
221
|
+
if (newPassword.length < 8) return res.status(400).json({ error: '新密码至少 8 位' });
|
|
222
|
+
try {
|
|
223
|
+
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.userId]);
|
|
224
|
+
if (!user) return res.status(401).json({ error: '未登录' });
|
|
225
|
+
const ok = await bcrypt.compare(currentPassword, user.password_hash);
|
|
226
|
+
if (!ok) return res.status(400).json({ error: '当前密码错误' });
|
|
227
|
+
const hash = await bcrypt.hash(newPassword, 10);
|
|
228
|
+
await dbRun('UPDATE users SET password_hash = ? WHERE id = ?', [hash, req.userId]);
|
|
229
|
+
logger.audit('password.change', { userId: req.userId, ip: clientIp(req) });
|
|
230
|
+
res.json({ success: true });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
res.status(500).json({ error: '修改密码失败' });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
router.post('/profile', requireAuth, async (req, res) => {
|
|
237
|
+
const { username, email } = req.body || {};
|
|
238
|
+
if (!username && email === undefined) return res.status(400).json({ error: '无更新字段' });
|
|
239
|
+
try {
|
|
240
|
+
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.userId]);
|
|
241
|
+
if (!user) return res.status(401).json({ error: '未登录' });
|
|
242
|
+
const changes = {};
|
|
243
|
+
if (username && username !== user.username) {
|
|
244
|
+
if (username.length > 30 || username.length < 2 || !/^[a-zA-Z0-9_]+$/.test(username)) {
|
|
245
|
+
return res.status(400).json({ error: '用户名 2-30 位,只能包含字母、数字和下划线' });
|
|
246
|
+
}
|
|
247
|
+
const conflict = await dbGet('SELECT id FROM users WHERE username = ? AND id != ?', [username, req.userId]);
|
|
248
|
+
if (conflict) return res.status(409).json({ error: '该用户名已被使用' });
|
|
249
|
+
await dbRun('UPDATE users SET username = ? WHERE id = ?', [username, req.userId]);
|
|
250
|
+
req.session.username = username;
|
|
251
|
+
changes.username = username;
|
|
252
|
+
}
|
|
253
|
+
if (email !== undefined && email !== user.email) {
|
|
254
|
+
if (email) {
|
|
255
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
256
|
+
if (!emailRegex.test(email)) return res.status(400).json({ error: '邮箱格式不正确' });
|
|
257
|
+
const conflict = await dbGet('SELECT id FROM users WHERE (email = ? OR username = ?) AND id != ?', [email, email, req.userId]);
|
|
258
|
+
if (conflict) return res.status(409).json({ error: '该邮箱已被使用' });
|
|
259
|
+
await dbRun('UPDATE users SET email = ?, email_verified = 0 WHERE id = ?', [email, req.userId]);
|
|
260
|
+
changes.email = email;
|
|
261
|
+
changes.emailVerified = false;
|
|
262
|
+
await sendVerificationEmail(req.userId, email, 'verify_email');
|
|
263
|
+
} else {
|
|
264
|
+
await dbRun('UPDATE users SET email = NULL, email_verified = 0 WHERE id = ?', [req.userId]);
|
|
265
|
+
changes.email = null;
|
|
266
|
+
changes.emailVerified = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
logger.audit('profile.update', { userId: req.userId, changes, ip: clientIp(req) });
|
|
270
|
+
const updated = await dbGet('SELECT username, email, email_verified FROM users WHERE id = ?', [req.userId]);
|
|
271
|
+
res.json({ username: updated.username, email: updated.email || null, emailVerified: !!updated.email_verified });
|
|
272
|
+
} catch (e) {
|
|
273
|
+
res.status(500).json({ error: '更新失败' });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// GET /api/auth/verify-email?token=...
|
|
278
|
+
router.get('/verify-email', async (req, res) => {
|
|
279
|
+
const { token } = req.query;
|
|
280
|
+
if (!token) return res.redirect('/#/email-verify-failed');
|
|
281
|
+
try {
|
|
282
|
+
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
283
|
+
const row = await dbGet('SELECT * FROM email_verifications WHERE token_hash = ?', [hash]);
|
|
284
|
+
if (!row) return res.redirect('/#/email-verify-failed');
|
|
285
|
+
if (new Date(row.expires_at) < new Date()) {
|
|
286
|
+
await dbRun('DELETE FROM email_verifications WHERE id = ?', [row.id]);
|
|
287
|
+
return res.redirect('/#/email-verify-expired');
|
|
288
|
+
}
|
|
289
|
+
if (row.type === 'verify_email') {
|
|
290
|
+
await dbRun('UPDATE users SET email_verified = 1 WHERE id = ?', [row.user_id]);
|
|
291
|
+
} else if (row.type === 'change_email' && row.new_email) {
|
|
292
|
+
await dbRun('UPDATE users SET email = ?, email_verified = 1 WHERE id = ?', [row.new_email, row.user_id]);
|
|
293
|
+
}
|
|
294
|
+
await dbRun('DELETE FROM email_verifications WHERE id = ?', [row.id]);
|
|
295
|
+
logger.audit('email.verify', { userId: row.user_id, type: row.type });
|
|
296
|
+
res.redirect('/#/email-verified');
|
|
297
|
+
} catch (e) {
|
|
298
|
+
logger.error({ type: 'app', message: '邮箱验证失败', error: e.message });
|
|
299
|
+
res.redirect('/#/email-verify-failed');
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
router.post('/resend-verification', requireAuth, resendLimiter, async (req, res) => {
|
|
304
|
+
try {
|
|
305
|
+
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.userId]);
|
|
306
|
+
if (!user) return res.status(401).json({ error: '未登录' });
|
|
307
|
+
if (!user.email) return res.status(400).json({ error: '未设置邮箱' });
|
|
308
|
+
if (user.email_verified) return res.status(400).json({ error: '邮箱已验证' });
|
|
309
|
+
const result = await sendVerificationEmail(user.id, user.email, 'verify_email');
|
|
310
|
+
res.json({ success: true, sent: result.sent });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
res.status(500).json({ error: '发送失败' });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
router.post('/send-register-code', sendCodeLimiter, async (req, res) => {
|
|
317
|
+
if (!ALLOW_REGISTRATION) return res.status(403).json({ error: '注册功能未开放' });
|
|
318
|
+
const { email } = req.body || {};
|
|
319
|
+
if (!email) return res.status(400).json({ error: '请填写邮箱' });
|
|
320
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
321
|
+
if (!emailRegex.test(email)) return res.status(400).json({ error: '邮箱格式不正确' });
|
|
322
|
+
|
|
323
|
+
// 邮箱唯一性检查
|
|
324
|
+
const conflict = await dbGet('SELECT id FROM users WHERE email = ? OR username = ?', [email, email]);
|
|
325
|
+
if (conflict) return res.status(409).json({ error: '该邮箱已被使用' });
|
|
326
|
+
|
|
327
|
+
if (!isMailerConfigured()) return res.status(503).json({ error: '邮件服务未配置,无法注册' });
|
|
328
|
+
|
|
329
|
+
// 生成 6 位数字验证码
|
|
330
|
+
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
331
|
+
const hash = crypto.createHash('sha256').update(code + email).digest('hex');
|
|
332
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
333
|
+
|
|
334
|
+
// 删除该邮箱之前的注册验证码
|
|
335
|
+
await dbRun("DELETE FROM email_verifications WHERE type = 'register_code' AND new_email = ?", [email]);
|
|
336
|
+
await dbRun(
|
|
337
|
+
'INSERT INTO email_verifications (user_id, token_hash, token_prefix, type, new_email, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
338
|
+
[0, hash, code.slice(0, 3) + '***', 'register_code', email, expiresAt]
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
await sendMail(email, '注册验证码 — 即页',
|
|
343
|
+
`<div style="max-width:480px;margin:0 auto;padding:32px 24px;font-family:system-ui,-apple-system,sans-serif;color:#333">
|
|
344
|
+
<h2 style="margin:0 0 24px;font-size:20px;color:#111">注册验证码</h2>
|
|
345
|
+
<p style="margin:0 0 16px;font-size:15px">你的注册验证码是:</p>
|
|
346
|
+
<p style="margin:0 0 24px;font-size:32px;font-weight:700;letter-spacing:6px;color:#4f46e5">${code}</p>
|
|
347
|
+
<p style="margin:0;font-size:13px;color:#888">验证码 10 分钟内有效。如非本人操作请忽略。</p>
|
|
348
|
+
</div>`
|
|
349
|
+
);
|
|
350
|
+
res.json({ sent: true });
|
|
351
|
+
} catch (e) {
|
|
352
|
+
logger.error({ type: 'app', message: '发送注册验证码失败', error: e.message });
|
|
353
|
+
res.status(500).json({ error: '验证码发送失败,请稍后重试' });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
router.get('/smtp-status', (req, res) => {
|
|
358
|
+
res.json({ configured: isMailerConfigured() });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
router.get('/registration-status', (req, res) => {
|
|
362
|
+
res.json({ enabled: ALLOW_REGISTRATION });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
module.exports = router;
|