feat: add admin web updates and verified username changes

Add isolated update jobs with database backups, progress logs and application rollback. Remove default account hints and prevent default admin recreation after renaming.

Enforce customer and order search scopes, secure session cookies, fix quotation rounding and amount wording, and update backend dependencies. Add API and deployment regression coverage.
This commit is contained in:
Drsakura
2026-10-22 17:24:19 +08:00
parent dc607e4ea3
commit 1ffcc1ea07
20 changed files with 905 additions and 585 deletions
+31 -4
View File
@@ -121,6 +121,11 @@ router.patch('/me', (req, res) => {
values.push(value || null);
}
if (req.body?.role !== undefined || req.body?.org_id !== undefined) {
if (!auth.permissionsOfRole(req.user.role).includes('user.manage')) {
return res.status(403).json({ error: '角色与组织请联系管理员修改' });
}
}
if (req.body?.role !== undefined) {
const role = String(req.body.role || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内');
@@ -150,6 +155,31 @@ router.patch('/me', (req, res) => {
}
});
/** PATCH /api/me/username —— 账号绑定用户 id,改名不改变业务数据归属。 */
router.patch('/me/username', auth.requireAuth, (req, res) => {
try {
const username = String(req.body?.username || '').trim();
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.@-]{2,49}$/.test(username)) {
throw badRequest('用户名需为 3–50 位字母、数字或 _ . @ -,以字母、数字或下划线开头');
}
auth.verifyCurrentPassword(req, req.body?.current_password);
if (username === req.user.username) throw badRequest('新用户名与当前用户名相同');
const change = db.transaction(() => {
if (db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?').get(username, req.user.id)) {
const error = badRequest('该用户名已被使用');
error.statusCode = 409;
throw error;
}
db.prepare('UPDATE users SET username = ?, updated_at = ? WHERE id = ?')
.run(username, new Date().toISOString(), req.user.id);
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid);
});
change();
auth.audit({ username, action: 'username_change', detail: `用户名由 ${req.user.username} 修改为 ${username}`, req });
res.json({ user: auth.publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id)), message: '用户名已修改,其它设备需重新登录' });
} catch (error) { fail(res, error); }
});
/** PATCH /api/me/password —— 改密码:三段校验全过才落库 */
router.patch('/me/password', (req, res) => {
try {
@@ -163,10 +193,7 @@ router.patch('/me/password', (req, res) => {
if (next.length < 6) throw badRequest('新密码至少 6 位');
if (next !== confirm) throw badRequest('两次输入的新密码不一致');
if (next === current) throw badRequest('新密码不能与当前密码相同');
if (!auth.verifyPassword(current, req.user.password_hash)) {
auth.audit({ username: req.user.username, action: 'password_change_failed', detail: '当前密码不正确', req, ok: 0 });
throw badRequest('当前密码不正确');
}
auth.verifyCurrentPassword(req, current);
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
.run(auth.hashPassword(next), new Date().toISOString(), req.user.id);
+4 -1
View File
@@ -23,7 +23,7 @@ const router = express.Router();
* 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。
* ------------------------------------------------------------------------- */
const CATEGORIES = [
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_)/ },
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_|username_)/ },
{ key: 'user', label: '用户', match: /^user_/ },
{ key: 'permission', label: '权限', match: /^permission_group_/ },
{ key: 'org', label: '组织', match: /^(organization_|branding_)/ },
@@ -43,6 +43,9 @@ const ACTION_LABELS = {
profile_update: '更新个人资料',
password_change: '修改密码',
password_change_failed: '修改密码失败',
password_verify_failed: '身份密码验证失败',
username_change: '修改用户名',
system_update: '启动系统更新',
user_create: '新建用户',
user_update: '更新用户',
permission_group_create: '新建权限组',
+39 -3
View File
@@ -5,8 +5,44 @@
const express = require('express');
const updater = require('../lib/updater');
const branding = require('../lib/branding');
const auth = require('../lib/auth');
const webUpdate = require('../lib/webUpdate');
const fs = require('fs');
const path = require('path');
const router = express.Router();
function adminOnly(req, res, next) {
if (!req.user) return res.status(401).json({ error: '请先登录' });
if (req.user.role !== 'admin') return res.status(403).json({ error: '仅系统管理员可执行系统更新' });
next();
}
router.get('/update/status', adminOnly, async (req, res) => {
const status = await webUpdate.readStatus();
let log = '';
if (/^[a-f0-9]{16}$/.test(status.id || '')) {
const data = path.resolve(process.env.DATA_DIR || path.join(__dirname, '../data'));
try {
const file = path.join(data, 'system-update', status.id, 'update.log');
const fd = fs.openSync(file, 'r');
try {
const size = fs.fstatSync(fd).size;
const buffer = Buffer.alloc(Math.min(size, 12000));
fs.readSync(fd, buffer, 0, buffer.length, Math.max(0, size - buffer.length));
log = buffer.toString('utf8');
} finally { fs.closeSync(fd); }
} catch { /* 任务尚未开始写日志 */ }
}
res.setHeader('Cache-Control', 'no-store');
res.json({ ...status, log, capability: webUpdate.capability() });
});
router.post('/update', adminOnly, async (req, res) => {
try {
auth.verifyCurrentPassword(req, req.body?.current_password);
const status = await webUpdate.start(req);
auth.audit({ username: req.user.username, action: 'system_update', detail: `网页更新至 ${status.version}`, req });
res.status(202).json(status);
} catch (error) { res.status(error.statusCode || 500).json({ error: error.message }); }
});
// 简易缓存:GitHub 未认证请求每小时只有 60 次配额,连续点「检查更新」不该把额度刷完。
const CACHE_TTL_MS = 30 * 1000;
@@ -16,7 +52,7 @@ let cache = { key: '', at: 0, payload: null };
* GET /api/system/info
* 当前版本与运行环境。轻量、不发外部请求,设置页打开时就能调。
*/
router.get('/info', (req, res) => {
router.get('/info', auth.requireAuth, (req, res) => {
try {
res.json(updater.systemInfo());
} catch (error) {
@@ -28,9 +64,9 @@ router.get('/info', (req, res) => {
* GET /api/system/check-update?repo=owner/repo&force=1
* 对比远端仓库版本与本机版本。repo 省略时用默认仓库(Drsakura/happy-export)。
*/
router.get('/check-update', async (req, res) => {
router.get('/check-update', auth.requireAuth, async (req, res) => {
try {
const repo = updater.normalizeRepo(req.query.repo) || updater.DEFAULT_REPO;
const repo = updater.normalizeRepo(process.env.HAPPY_UPDATE_REPO) || updater.DEFAULT_REPO;
const force = String(req.query.force || '') === '1';
if (!force && cache.payload && cache.key === repo && Date.now() - cache.at < CACHE_TTL_MS) {