From 1ffcc1ea0736810965c9ef90afe20a9077433d8f Mon Sep 17 00:00:00 2001 From: Drsakura Date: Thu, 22 Oct 2026 17:24:19 +0800 Subject: [PATCH] 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. --- .gitignore | 4 + README.md | 14 +- backend/db/db.js | 6 +- backend/lib/auth.js | 23 +- backend/lib/updateWorker.js | 149 +++++++ backend/lib/updater.js | 7 +- backend/lib/webUpdate.js | 83 ++++ backend/package-lock.json | 563 ++------------------------- backend/package.json | 8 +- backend/routes/auth.js | 35 +- backend/routes/logs.js | 5 +- backend/routes/system.js | 42 +- backend/server.js | 48 ++- backend/test/regressions.test.js | 224 +++++++++++ backend/test/web-update.test.js | 76 ++++ docs/web-updates.md | 62 +++ frontend/src/amountInWords.js | 14 +- frontend/src/components/Settings.jsx | 73 +++- frontend/src/pages/LoginPage.jsx | 1 - frontend/src/pages/ProfilePage.jsx | 53 ++- 20 files changed, 905 insertions(+), 585 deletions(-) create mode 100644 backend/lib/updateWorker.js create mode 100644 backend/lib/webUpdate.js create mode 100644 backend/test/regressions.test.js create mode 100644 backend/test/web-update.test.js create mode 100644 docs/web-updates.md diff --git a/.gitignore b/.gitignore index 67173cf..e782dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # ---------- 依赖 ---------- node_modules/ +backend/node_modules.update-new/ +backend/node_modules.update-old/ +frontend/dist.update-new/ +frontend/dist.update-old/ .pnpm-store/ # ---------- 构建产物 ---------- diff --git a/README.md b/README.md index ae18eeb..96502ec 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ ### 安装依赖 +需要 Node.js 22 或更高版本。 + ```bash # 后端 cd backend -npm install +npm install --ignore-scripts # 前端 cd frontend @@ -129,12 +131,20 @@ SQLite 单文件存储: | --- | --- | --- | | `DATA_DIR` | `backend/data` | 数据库与汇率缓存所在目录 | | `PORT` | `4300` | 后端监听端口 | +| `SECURE_COOKIE` | `0` | HTTPS 部署设为 `1`,为会话 Cookie 添加 `Secure`;本地 HTTP 开发保持默认 | | `CONTRACT_RETENTION_DAYS` | `7` | 合同导入原文件的保留天数 | -| `HAPPY_UPDATE_REPO` | `Drsakura/happy-export` | 在线更新检查所用的 GitHub 仓库 | +| `HAPPY_UPDATE_REPO` | `Drsakura/happy-export` | 网页更新使用的 GitHub 公共仓库 | +| `HAPPY_UPDATE_SERVICE` | `erp` | 网页更新重启的 systemd 服务名,不含 `.service` 后缀 | > 注意:后端**没有引入 dotenv**,不会自动读取 `.env` 文件。 > 请通过系统环境变量、启动脚本或容器编排来设置上述变量。 +## 网页更新与账号 + +管理员可在「设置 → 系统与更新」输入当前密码安装新版本,查看更新进度和日志。支持 Linux systemd 部署,包含构建、数据库备份、服务重启及失败后的应用恢复;首次需要部署包含此功能的版本。详见 [网页更新配置与恢复说明](docs/web-updates.md)。 + +所有用户可在「个人资料 → 修改用户名」验证当前密码后修改自己的登录名;业务数据归属不变,其它设备需重新登录。 + ## 开发计划 Phase 1 至 Phase 3 已完成;Phase 4、Phase 5 部分完成,未完成项保留为待办。 diff --git a/backend/db/db.js b/backend/db/db.js index f9ef39c..ed09455 100644 --- a/backend/db/db.js +++ b/backend/db/db.js @@ -276,12 +276,12 @@ function ensureColumns() { ensureColumns(); -// 创建默认管理员账号(如果不存在) +// 只为空库创建初始管理员;改名后重启不能重新生成默认账号。 function createDefaultAdmin() { const crypto = require('crypto'); - const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get('admin'); + const existingUser = db.prepare('SELECT id FROM users LIMIT 1').get(); - if (!adminExists) { + if (!existingUser) { // 生成默认密码哈希:admin123 const salt = crypto.randomBytes(16).toString('hex'); const hash = crypto.scryptSync('admin123', salt, 64).toString('hex'); diff --git a/backend/lib/auth.js b/backend/lib/auth.js index de7c952..df534dc 100644 --- a/backend/lib/auth.js +++ b/backend/lib/auth.js @@ -41,6 +41,22 @@ function verifyPassword(plain, stored) { return crypto.timingSafeEqual(a, b); } +/* 密码复核与登录共享失败次数和锁定,避免已登录会话无限猜密码。 */ +function verifyCurrentPassword(req, password) { + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + const reject = (message, statusCode) => { throw Object.assign(new Error(message), { statusCode }); }; + if (user.locked_until && Date.parse(user.locked_until) > Date.now()) reject('账号已锁定,请稍后再试', 429); + if (!password) reject('请输入当前密码', 400); + if (!verifyPassword(String(password), user.password_hash)) { + const failed = Number(user.failed_count || 0) + 1; + db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?') + .run(failed, failed >= MAX_FAILED ? new Date(Date.now() + LOCK_MS).toISOString() : null, user.id); + audit({ username: user.username, action: 'password_verify_failed', detail: '敏感操作密码验证失败', req, ok: 0 }); + reject(failed >= MAX_FAILED ? '密码连续错误,账号已锁定 15 分钟' : '当前密码不正确', failed >= MAX_FAILED ? 429 : 400); + } + db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL WHERE id = ?').run(user.id); +} + /* ---------------------------------------------------------------- cookie */ function parseCookies(header) { @@ -62,12 +78,13 @@ function parseCookies(header) { function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) { res.append('Set-Cookie', [ - `${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}` + `${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`, + ...(process.env.SECURE_COOKIE === '1' ? ['Secure'] : []) ].join('; ')); } function clearSessionCookie(res) { - res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); + setSessionCookie(res, '', 0); } function clientIp(req) { @@ -110,6 +127,7 @@ function sessionUser(req) { return null; } if (!row.is_active) return null; + if (row.locked_until && Date.parse(row.locked_until) > Date.now()) return null; return row; } @@ -210,6 +228,7 @@ module.exports = { LOCK_MS, hashPassword, verifyPassword, + verifyCurrentPassword, parseCookies, setSessionCookie, clearSessionCookie, diff --git a/backend/lib/updateWorker.js b/backend/lib/updateWorker.js new file mode 100644 index 0000000..5cf1efe --- /dev/null +++ b/backend/lib/updateWorker.js @@ -0,0 +1,149 @@ +// 被复制到数据目录后独立运行;仅用内置模块,ERP 重启或替换依赖均不影响任务。 +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +async function executeUpdate(config, hooks = {}) { +const { root, data, dir, jobDir, service, repo, tag, version, node, port } = config; +const statusFile = path.join(dir, 'status.json'); +const logFile = path.join(jobDir, 'update.log'); +const stage = path.join(jobDir, 'source'); +const backendModules = path.join(root, 'backend/node_modules'); +const frontendDist = path.join(root, 'frontend/dist'); +const newModules = `${backendModules}.update-new`; +const oldModules = `${backendModules}.update-old`; +const newDist = `${frontendDist}.update-new`; +const oldDist = `${frontendDist}.update-old`; +let switched = false; +let oldCommit; +let status = JSON.parse(fs.readFileSync(statusFile, 'utf8')); +function progress(state, message, extra = {}) { + status = { ...status, state, message, updatedAt: new Date().toISOString(), ...extra }; + const temporary = `${statusFile}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(status), { mode: 0o600 }); + fs.renameSync(temporary, statusFile); +} +function run(command, args, cwd = root, timeout = 10 * 60 * 1000) { + if (hooks.run) return hooks.run(command, args, cwd); + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env: { ...process.env, PATH: `${path.dirname(node)}:${process.env.PATH || ''}` }, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + const consume = chunk => { + output = (output + chunk.toString()).slice(-12000); + fs.appendFileSync(logFile, chunk); + }; + child.stdout.on('data', consume); + child.stderr.on('data', consume); + const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeout); + child.on('error', error => { clearTimeout(timer); reject(error); }); + child.on('close', code => { + clearTimeout(timer); + if (code === 0) resolve(output.trim()); + else reject(new Error(`${command} 执行失败(${code}),请查看更新日志`)); + }); + }); +} +async function healthy(expected) { + if (hooks.healthy) return hooks.healthy(expected); + for (let attempt = 0; attempt < 45; attempt++) { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) }); + const info = await response.json(); + if (response.ok && info.status === 'ok' && (!expected || info.version === expected)) return; + } catch { /* 重启期间暂不可用 */ } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + throw new Error('重启后健康检查未通过'); +} +async function main() { + try { + progress('preparing', '正在下载新版本'); + oldCommit = await run('git', ['rev-parse', 'HEAD']); + await run('git', ['fetch', '--no-tags', `https://github.com/${repo}.git`, `refs/tags/${tag}`]); + const target = await run('git', ['rev-parse', 'FETCH_HEAD^{commit}']); + await run('git', ['worktree', 'add', '--detach', stage, target]); + const manifest = JSON.parse(fs.readFileSync(path.join(stage, 'package.json'), 'utf8')); + if (manifest.version !== version) throw new Error('版本标签与应用版本不一致,已取消更新'); + progress('installing', '正在安装新版本依赖'); + await run('npm', ['ci', '--omit=dev', '--ignore-scripts'], path.join(stage, 'backend')); + await run('npm', ['ci', '--include=dev'], path.join(stage, 'frontend')); + progress('building', '正在构建新版本页面'); + await run('npm', ['run', 'build'], path.join(stage, 'frontend')); + await run(node, ['--check', path.join(stage, 'backend/server.js')]); + // 在目标文件系统中准备目录,再通过 rename 切换,避免跨盘移动失败。 + for (const entry of [newModules, newDist, oldModules, oldDist]) { + if (fs.existsSync(entry)) throw new Error('发现上次更新残留目录,请管理员检查后清理'); + } + fs.cpSync(path.join(stage, 'backend/node_modules'), newModules, { recursive: true, verbatimSymlinks: true }); + fs.cpSync(path.join(stage, 'frontend/dist'), newDist, { recursive: true }); + progress('backing_up', '正在备份数据库'); + const dbPath = path.join(data, 'trade.db'); + const backup = path.join(jobDir, 'trade-before-update.db'); + if (!fs.existsSync(dbPath)) throw new Error('未找到业务数据库,已取消更新'); + if (hooks.backup) await hooks.backup(backup); + else { + const Database = require(path.join(backendModules, 'better-sqlite3-multiple-ciphers')); + const db = new Database(dbPath, { readonly: true }); + try { await db.backup(backup); } finally { db.close(); } + } + fs.chmodSync(backup, 0o600); + // 下载安装期间如果部署代码被人工改动,则保留这些修改并取消更新。 + if ((await run('git', ['status', '--porcelain', '--untracked-files=normal'])).trim() + || await run('git', ['rev-parse', 'HEAD']) !== oldCommit) throw new Error('部署代码在更新过程中发生变化,已取消更新'); + progress('deploying', '正在切换应用版本', { previousCommit: oldCommit, targetCommit: target }); + switched = true; + fs.renameSync(backendModules, oldModules); + fs.renameSync(newModules, backendModules); + if (fs.existsSync(frontendDist)) fs.renameSync(frontendDist, oldDist); + fs.renameSync(newDist, frontendDist); + await run('git', ['reset', '--hard', target]); + progress('restarting', '正在重启服务,请稍候'); + await run('systemctl', ['restart', `${service}.service`], root, 60000); + await healthy(version); + switched = false; + try { + fs.rmSync(oldModules, { recursive: true, force: true }); + fs.rmSync(oldDist, { recursive: true, force: true }); + } catch (cleanupError) { + // 服务已验证成功,清理旧目录失败不能触发半套依赖的回退。 + fs.appendFileSync(logFile, `\n旧版本目录清理失败:${cleanupError.message}\n`); + } + progress('completed', '更新完成,可刷新页面使用新版本', { finishedAt: new Date().toISOString() }); + } catch (error) { + fs.appendFileSync(logFile, `\n${error.stack}\n`); + if (switched) { + try { + progress('rolling_back', '更新失败,正在恢复上一版应用'); + await run('systemctl', ['stop', `${service}.service`], root, 60000); + await run('git', ['reset', '--hard', oldCommit]); + if (fs.existsSync(oldModules)) { + fs.rmSync(backendModules, { recursive: true, force: true }); + fs.renameSync(oldModules, backendModules); + } + if (fs.existsSync(oldDist)) { + fs.rmSync(frontendDist, { recursive: true, force: true }); + fs.renameSync(oldDist, frontendDist); + } + await run('systemctl', ['start', `${service}.service`], root, 60000); + await healthy(); + progress('failed', `${error.message};已恢复上一版应用,数据库备份已保留`, { finishedAt: new Date().toISOString() }); + } catch (rollbackError) { + fs.appendFileSync(logFile, `\n${rollbackError.stack}\n`); + progress('failed', `${error.message};自动恢复失败,请管理员查看日志和数据库备份`, { finishedAt: new Date().toISOString() }); + } + } else progress('failed', error.message, { finishedAt: new Date().toISOString() }); + } finally { + // 数据库备份与日志长期保留;不自动覆盖线上库,防止回滚丢失期间的写入。 + try { await run('git', ['worktree', 'remove', '--force', stage]); } catch { /* 保留现场 */ } + for (const entry of [newModules, newDist]) { + try { fs.rmSync(entry, { recursive: true, force: true }); } catch { /* 保留现场 */ } + } + fs.rmSync(path.join(dir, 'lock'), { force: true }); + } +} +await main(); +} +module.exports = { executeUpdate }; +if (require.main === module) { + const config = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + executeUpdate(config).catch(error => { console.error(error); process.exitCode = 1; }); +} diff --git a/backend/lib/updater.js b/backend/lib/updater.js index fe949ec..ffad44c 100644 --- a/backend/lib/updater.js +++ b/backend/lib/updater.js @@ -1,11 +1,8 @@ /** * happy 出口通 —— 系统版本检查 * - * 与 sku-manager 的 updater 不同,这里**只做检查,不做自动替换**: - * happy 是整体应用(前端 dist + 后端服务 + 数据库),更新应当走 - * `git pull` + 重新构建 + 重启服务,而不是像 sku-manager 那样下载 zip - * 换版本目录。所以本模块只负责回答一个问题: - * 远端仓库的版本号 vs 本机版本号,谁新? + * 本模块检查 GitHub Release/tag;网页安装入口见 webUpdate.js, + * 独立更新任务见 updateWorker.js(构建、备份、切换与重启)。 * * 版本来源,按优先级: * 1. GitHub Releases 的 latest.tag_name —— 推荐用法(打 tag 并发 Release) diff --git a/backend/lib/webUpdate.js b/backend/lib/webUpdate.js new file mode 100644 index 0000000..9c85f35 --- /dev/null +++ b/backend/lib/webUpdate.js @@ -0,0 +1,83 @@ +/** 管理员网页更新入口。安装由独立 systemd 单元执行,重启 ERP 不会杀掉更新任务。 */ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const exec = promisify(execFile); +const updater = require('./updater'); +const ROOT = path.resolve(__dirname, '../..'); +const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'backend/data')); +const DIR = path.join(DATA, 'system-update'); +const STATUS = path.join(DIR, 'status.json'); +const LOCK = path.join(DIR, 'lock'); +const SERVICE = process.env.HAPPY_UPDATE_SERVICE || 'erp'; + +let lastInspection = 0; +async function readStatus() { + let status; + try { status = JSON.parse(fs.readFileSync(STATUS, 'utf8')); } + catch { return { state: 'idle', message: '尚未执行更新' }; } + if (process.platform === 'linux' && /^[a-f0-9]{16}$/.test(status.id || '') + && !['idle', 'completed', 'failed'].includes(status.state) + && Date.now() - Date.parse(status.startedAt) > 60000 && Date.now() - lastInspection > 30000) { + lastInspection = Date.now(); + try { + const { stdout } = await exec('systemctl', ['show', `happy-update-${status.id}.service`, '--property=ActiveState', '--value'], { timeout: 5000 }); + if (['inactive', 'failed'].includes(stdout.trim())) { + // 再读一次,避免 worker 刚完成时被旧状态覆盖。 + const latest = JSON.parse(fs.readFileSync(STATUS, 'utf8')); + if (latest.id === status.id && !['completed', 'failed'].includes(latest.state)) { + status = { ...latest, state: 'failed', message: '更新任务意外中断,请查看日志;数据库备份与恢复目录已保留', finishedAt: new Date().toISOString() }; + fs.writeFileSync(STATUS, JSON.stringify(status), { mode: 0o600 }); + fs.rmSync(LOCK, { force: true }); + } else status = latest; + } + } catch { /* systemd 暂不可达时保留状态,不能认定任务已终止 */ } + } + return status; +} +function capability() { + return { supported: process.platform === 'linux', service: SERVICE, + reason: process.platform === 'linux' ? '' : '网页安装支持 Linux systemd 部署;当前环境可检查版本。' }; +} +async function start(req) { + if (!capability().supported) throw Object.assign(new Error(capability().reason), { statusCode: 400 }); + if (!/^[a-zA-Z0-9_.@-]+$/.test(SERVICE)) throw new Error('更新服务名配置无效'); + await exec('systemctl', ['is-active', `${SERVICE}.service`], { timeout: 10000 }); + const dirty = await exec('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: ROOT, timeout: 10000 }); + if (dirty.stdout.trim()) throw Object.assign(new Error('部署目录有未提交或未跟踪的文件,请先处理后再更新'), { statusCode: 409 }); + const repo = updater.normalizeRepo(process.env.HAPPY_UPDATE_REPO || updater.DEFAULT_REPO); + if (!repo || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) throw new Error('服务器更新源配置无效'); + fs.mkdirSync(DIR, { recursive: true, mode: 0o700 }); + try { fs.writeFileSync(LOCK, String(Date.now()), { flag: 'wx', mode: 0o600 }); } + catch (error) { + if (error.code === 'EEXIST') throw Object.assign(new Error('已有更新任务正在执行'), { statusCode: 409 }); + throw error; + } + try { + const result = await updater.checkUpdate(repo); + if (!result.ok) throw new Error(result.error); + if (!result.hasUpdate) throw Object.assign(new Error('当前已是最新版本'), { statusCode: 409 }); + if (!/^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(result.tag)) throw new Error('版本标签无效'); + const id = crypto.randomBytes(8).toString('hex'); + const jobDir = path.join(DIR, id); + fs.mkdirSync(jobDir, { mode: 0o700 }); + const worker = path.join(jobDir, 'worker.js'); + fs.copyFileSync(path.join(__dirname, 'updateWorker.js'), worker); + const config = path.join(jobDir, 'config.json'); + fs.writeFileSync(config, JSON.stringify({ root: ROOT, data: DATA, dir: DIR, jobDir, service: SERVICE, + repo, tag: result.tag, version: result.latest, node: process.execPath, port: process.env.PORT || '4300' }), { mode: 0o600 }); + const status = { id, state: 'queued', message: '更新任务已创建', version: result.latest, + startedAt: new Date().toISOString(), username: req.user.username }; + fs.writeFileSync(STATUS, JSON.stringify(status), { mode: 0o600 }); + await exec('systemd-run', ['--unit', `happy-update-${id}`, '--collect', '--property=Type=exec', + process.execPath, worker, config], { timeout: 15000 }); + return status; + } catch (error) { + fs.rmSync(LOCK, { force: true }); + fs.writeFileSync(STATUS, JSON.stringify({ state: 'failed', message: error.message, finishedAt: new Date().toISOString() }), { mode: 0o600 }); + throw error; + } +} +module.exports = { start, readStatus, capability }; diff --git a/backend/package-lock.json b/backend/package-lock.json index acbcd52..f0a542b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,22 +1,19 @@ { "name": "trade-management-backend", - "version": "1.0.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trade-management-backend", - "version": "1.0.0", + "version": "1.1.1", "license": "ISC", "dependencies": { "better-sqlite3-multiple-ciphers": "^13.0.3", "cors": "^2.8.5", "express": "^4.18.2", - "multer": "^1.4.5-lts.1", + "multer": "^2.4.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" - }, - "devDependencies": { - "nodemon": "^3.0.1" } }, "node_modules/accepts": { @@ -32,20 +29,6 @@ "node": ">= 0.6" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -58,16 +41,6 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/better-sqlite3-multiple-ciphers": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-13.0.3.tgz", @@ -80,19 +53,6 @@ "node": ">=22" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -117,54 +77,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -214,46 +126,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -290,12 +162,6 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -416,9 +282,9 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -440,9 +306,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -461,19 +327,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -510,21 +363,6 @@ "node": ">= 0.6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -571,19 +409,6 @@ "node": ">= 0.4" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -596,16 +421,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -662,13 +477,6 @@ "node": ">=0.10.0" } }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -684,58 +492,6 @@ "node": ">= 0.10" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -805,43 +561,6 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -849,22 +568,21 @@ "license": "MIT" }, "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "busboy": "^1.6.0", + "type-is": "^1.6.18" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/negotiator": { @@ -885,70 +603,6 @@ "node": "^18 || ^20 || >= 21" } }, - "node_modules/nodemon": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", - "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/nodemon/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -997,29 +651,10 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -1027,19 +662,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -1076,40 +708,6 @@ "node": ">= 0.8" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1136,19 +734,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -1272,19 +857,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1302,47 +874,6 @@ "node": ">=10.0.0" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1352,16 +883,6 @@ "node": ">=0.6" } }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1375,19 +896,6 @@ "node": ">= 0.6" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1397,12 +905,6 @@ "node": ">= 0.8" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1432,15 +934,6 @@ "engines": { "node": ">=0.8" } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } } } } diff --git a/backend/package.json b/backend/package.json index ad47502..2c0e7c8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,8 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "nodemon server.js" + "dev": "node --watch server.js", + "test": "node --test" }, "keywords": [ "trade", @@ -18,10 +19,7 @@ "better-sqlite3-multiple-ciphers": "^13.0.3", "cors": "^2.8.5", "express": "^4.18.2", - "multer": "^1.4.5-lts.1", + "multer": "^2.4.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" - }, - "devDependencies": { - "nodemon": "^3.0.1" } } diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 36e8551..c5ab536 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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); diff --git a/backend/routes/logs.js b/backend/routes/logs.js index 30853e0..f2125f9 100644 --- a/backend/routes/logs.js +++ b/backend/routes/logs.js @@ -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: '新建权限组', diff --git a/backend/routes/system.js b/backend/routes/system.js index 9365e13..ba9c922 100644 --- a/backend/routes/system.js +++ b/backend/routes/system.js @@ -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) { diff --git a/backend/server.js b/backend/server.js index cc47b88..0a43e0f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -766,8 +766,11 @@ app.put('/api/products/:sku/packaging', auth.requirePermission('product.edit'), }); // 订单路由 -app.get('/api/orders', (req, res) => { +app.get('/api/orders', auth.requirePermission('order.view'), (req, res) => { try { + const canViewAllOrders = auth.permissionsOfRole(req.user.role).includes('order.edit'); + const orderFilter = canViewAllOrders ? '' : 'WHERE o.owner_id IS NULL OR o.owner_id = ?'; + const orderParams = canViewAllOrders ? [] : [req.user.id]; /* 带上杂费合计:订单列表上只看 total_amount 看不出赚没赚 —— 快递费吊卡费加起来几百块,小单子能把毛利吃掉一截。 逐行取两位再 SUM,跟支出明细页的合计口径一致。 */ @@ -782,8 +785,9 @@ app.get('/api/orders', (req, res) => { (SELECT COUNT(*) FROM order_expenses e WHERE e.order_id = o.id) AS expense_count FROM orders o LEFT JOIN customers c ON o.customer_id = c.id + ${orderFilter} ORDER BY o.order_date DESC - `).all(); + `).all(...orderParams); res.json(orders); } catch (error) { res.status(500).json({ error: error.message }); @@ -925,6 +929,7 @@ app.get('/api/orders/:id/expenses', auth.requirePermission('order.view'), (req, try { const order = getOrder(req.params.id); if (!order) return res.status(404).json({ error: '订单不存在' }); + if (!canTouchOrderExpense(req.user, order)) return res.status(403).json({ error: '这张订单不在你名下' }); const expenses = db.prepare(` SELECT e.*, s.name AS supplier_name, u.display_name AS created_by_name FROM order_expenses e @@ -1226,7 +1231,7 @@ app.post('/api/assistant/command', async (req, res) => { -// 全局搜索:仅索引业务表的非敏感字段,查询参数使用绑定变量。 +// 全局搜索:查询参数使用绑定变量;客户及其关联记录必须遵守客户归属范围。 const SEARCH_SOURCES = [ { table: 'suppliers', type: 'supplier', typeLabel: '供应商', title: ['name', 'short_name'], detail: ['contact_person', 'phone', 'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes'], path: '/suppliers' }, { table: 'product_groups', type: 'product', typeLabel: '产品分组', title: ['name', 'group_key'], detail: ['brand', 'category', 'notes'], path: '/products' }, @@ -1258,9 +1263,32 @@ app.get('/api/search', (req, res) => { const columns = existingColumns(source.table); const searchable = [...source.title, ...source.detail].filter(column => columns.has(column)); if (!searchable.length) continue; - const where = searchable.map(column => `CAST(${column} AS TEXT) LIKE ?`).join(' OR '); - const selected = ['rowid AS _search_id', ...searchable].join(', '); - const rows = db.prepare(`SELECT ${selected} FROM ${source.table} WHERE ${where} LIMIT 8`).all(...searchable.map(() => pattern)); + const where = searchable.map(column => `CAST(s.${column} AS TEXT) LIKE ?`).join(' OR '); + const selected = ['s.rowid AS _search_id', ...searchable.map(column => `s.${column}`)].join(', '); + const conditions = [`(${where})`]; + const params = searchable.map(() => pattern); + let from = `${source.table} s`; + if (['customers', 'contacts', 'activities', 'deals', 'todos'].includes(source.table)) { + const link = source.table === 'customers' ? 's.id' : 's.customer_id'; + from += ` LEFT JOIN customers c ON c.id = ${link}`; + const scope = customerAccess.scopeFilter(req.user, 'all'); + if (scope.conditions.length) { + const owned = scope.conditions.join(' AND '); + // 没有关联客户的工作台记录仍可检索;关联客户的记录不能绕过归属。 + conditions.push(['activities', 'todos'].includes(source.table) + ? `(s.customer_id IS NULL OR (${owned}))` + : `(${owned})`); + } + params.push(...scope.params); + } + if (['orders', 'order_items'].includes(source.table) + && !auth.permissionsOfRole(req.user.role).includes('order.edit')) { + const orderLink = source.table === 'orders' ? 's.id' : 's.order_id'; + from += ` JOIN orders o ON o.id = ${orderLink}`; + conditions.push('(o.owner_id IS NULL OR o.owner_id = ?)'); + params.push(req.user.id); + } + const rows = db.prepare(`SELECT ${selected} FROM ${from} WHERE ${conditions.join(' AND ')} LIMIT 8`).all(...params); rows.forEach(row => { const title = source.title.map(column => row[column]).filter(Boolean).join(' · ') || source.typeLabel; const detail = source.detail.map(column => row[column]).filter(value => value !== undefined && value !== null && value !== '').join(' · '); @@ -1694,18 +1722,20 @@ app.post('/api/quotes', auth.requirePermission('quote.edit'), (req, res) => { const provided = pick('unit_price'); // 前端为了即时预览会自己算一次并传上来;没传就按同一口径在后端补算,兜住 - const unitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate); - if (unitPrice === null || unitPrice < 0) { + const rawUnitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate); + if (rawUnitPrice === null || rawUnitPrice < 0) { throw badRequest(`${label}的单价无法确定:请填单价,或同时填齐采购价 / 汇率 / 加价率`); } + // 行金额必须由单据上实际存储、显示的四位单价计算。 + const unitPrice = roundTo(rawUnitPrice, 4); return { sku, productName: String(raw?.product_name || sku).trim(), productNameEn: raw?.product_name_en ? String(raw.product_name_en).trim() : null, spec: raw?.spec ? String(raw.spec).trim() : null, qty, - unitPrice: roundTo(unitPrice, 4), + unitPrice, totalPrice: roundTo(unitPrice * qty, 2), currency, purchasePrice, exchangeRate, markupRate, diff --git a/backend/test/regressions.test.js b/backend/test/regressions.test.js new file mode 100644 index 0000000..0d3a7b9 --- /dev/null +++ b/backend/test/regressions.test.js @@ -0,0 +1,224 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const net = require('node:net'); +const { pathToFileURL } = require('node:url'); + +// 所有写入都在独立临时库,不能加载本地业务库。 +test('security and quotation regressions', { timeout: 30000 }, async t => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'happy-regression-')); + process.env.DATA_DIR = dataDir; + const db = require('../db/db'); + const auth = require('../lib/auth'); + let server; + t.after(async () => { + if (server && server.exitCode === null) { + const exited = once(server, 'exit'); + server.kill(); + await exited; + } + db.close(); + rmSync(dataDir, { recursive: true, force: true }); + }); + const now = new Date().toISOString(); + const admin = db.prepare("SELECT * FROM users WHERE role = 'admin'").get(); + const repId = db.prepare('INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)') + .run('regression_rep', auth.hashPassword('test-password'), 'sales_rep', now).lastInsertRowid; + const insertCustomer = db.prepare('INSERT INTO customers (company, owner_id, pool_status, created_at) VALUES (?, ?, ?, ?)'); + const ownId = insertCustomer.run('scope-own', repId, 'owned', now).lastInsertRowid; + const otherId = insertCustomer.run('scope-other', admin.id, 'owned', now).lastInsertRowid; + const poolId = insertCustomer.run('scope-pool', repId, 'public', now).lastInsertRowid; + for (const [id, marker] of [[ownId, 'scope-own'], [otherId, 'scope-other'], [poolId, 'scope-pool']]) { + db.prepare('INSERT INTO contacts (customer_id, name, email, created_at) VALUES (?, ?, ?, ?)') + .run(id, marker, `${marker}@example.test`, now); + db.prepare('INSERT INTO activities (customer_id, activity_type, content, created_at) VALUES (?, ?, ?, ?)') + .run(id, 'call', marker, now); + db.prepare('INSERT INTO deals (customer_id, title, stage, created_at) VALUES (?, ?, ?, ?)') + .run(id, marker, 'new', now); + db.prepare('INSERT INTO todos (customer_id, title, created_at) VALUES (?, ?, ?)').run(id, marker, now); + } + db.prepare('INSERT INTO activities (activity_type, content, created_at) VALUES (?, ?, ?)').run('todo', 'unlinked-activity', now); + db.prepare('INSERT INTO todos (title, created_at) VALUES (?, ?)').run('unlinked-todo', now); + const insertOrder = db.prepare('INSERT INTO orders (order_no, customer_id, order_date, owner_id, created_at) VALUES (?, ?, ?, ?, ?)'); + const ownOrderId = insertOrder.run('REP-OWN', ownId, now.slice(0, 10), repId, now).lastInsertRowid; + const otherOrderId = insertOrder.run('ADMIN-OTHER', otherId, now.slice(0, 10), admin.id, now).lastInsertRowid; + insertOrder.run('LEGACY-UNOWNED', otherId, now.slice(0, 10), null, now); + const adminCookie = `happy_sid=${auth.createSession(admin.id)}`; + const repCookie = `happy_sid=${auth.createSession(repId)}`; + + // 由系统分配空闲端口,避免干扰已在运行的开发服务。 + const probe = net.createServer(); + probe.listen(0, '127.0.0.1'); + await once(probe, 'listening'); + const port = probe.address().port; + await new Promise(resolve => probe.close(resolve)); + server = spawn(process.execPath, [path.join(__dirname, '../server.js')], { + env: { ...process.env, PORT: String(port), SECURE_COOKIE: '1' }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let logs = ''; + server.stdout.on('data', chunk => { logs += chunk; }); + server.stderr.on('data', chunk => { logs += chunk; }); + const base = `http://127.0.0.1:${port}`; + for (let attempt = 0; ; attempt++) { + try { if ((await fetch(`${base}/api/health`)).ok) break; } catch { /* 等待启动 */ } + if (attempt >= 100 || server.exitCode !== null) throw new Error(`Server startup failed: ${logs}`); + await new Promise(resolve => setTimeout(resolve, 50)); + } + const search = async (term, cookie) => { + const res = await fetch(`${base}/api/search?q=${encodeURIComponent(term)}`, { headers: { cookie } }); + assert.equal(res.status, 200); + return (await res.json()).results; + }; + + await t.test('search isolates customers, contacts, activities, deals and todos', async () => { + assert.equal((await fetch(`${base}/api/search?q=scope`)).status, 401); + for (const term of ['scope-other', 'scope-pool', 'scope-other@example.test']) { + assert.deepEqual(await search(term, repCookie), []); + assert.ok((await search(term, adminCookie)).length > 0); + } + const own = await search('scope-own', repCookie); + assert.equal(own.length, 5); + assert.deepEqual(new Set(own.map(r => r.typeLabel)), new Set(['客户', '客户联系人', '活动', '商机', '待办'])); + assert.ok((await search('unlinked-activity', repCookie)).length > 0); + assert.ok((await search('unlinked-todo', repCookie)).length > 0); + // 通配符不能令 OR 条件跳过归属检查。 + const broad = JSON.stringify(await search('%', repCookie)); + assert.ok(!broad.includes('scope-other') && !broad.includes('scope-pool')); + }); + + await t.test('order lists, global search and expense details respect ownership', async () => { + const listRes = await fetch(`${base}/api/orders`, { headers: { cookie: repCookie } }); + assert.equal(listRes.status, 200); + const visibleOrders = await listRes.json(); + assert.ok(visibleOrders.some(order => order.id === ownOrderId)); + assert.ok(visibleOrders.some(order => order.order_no === 'LEGACY-UNOWNED')); + assert.ok(!visibleOrders.some(order => order.id === otherOrderId)); + assert.deepEqual(await search('ADMIN-OTHER', repCookie), []); + assert.ok((await search('ADMIN-OTHER', adminCookie)).length > 0); + assert.equal((await fetch(`${base}/api/orders/${otherOrderId}/expenses`, { headers: { cookie: repCookie } })).status, 403); + }); + + await t.test('session cookies support HTTP development and secure HTTPS login/logout', () => { + for (const secure of ['0', '1']) { + process.env.SECURE_COOKIE = secure; + const cookies = []; + const res = { append: (key, value) => cookies.push(value) }; + auth.setSessionCookie(res, 'test-session'); + auth.clearSessionCookie(res); + for (const cookie of cookies) { + assert.equal(cookie.includes('; Secure'), secure === '1'); + assert.ok(cookie.includes('HttpOnly') && cookie.includes('SameSite=Lax')); + } + assert.ok(cookies[1].includes('Max-Age=0')); + } + }); + + await t.test('stored quotation amounts use the displayed rounded price', async () => { + const res = await fetch(`${base}/api/quotes`, { + method: 'POST', headers: { cookie: adminCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ customer_id: ownId, items: [ + { sku: 'rounding-a', unit_price: 2.99995, qty: 1000 }, + { sku: 'rounding-b', unit_price: 1.00005, qty: 10000 } + ] }) + }); + assert.equal(res.status, 201, await res.clone().text()); + const { items } = await res.json(); + assert.deepEqual(items.map(i => [i.unit_price, i.total_price]), [[3, 3000], [1.0001, 10001]]); + }); + + await t.test('multer 2 accepts contract multipart uploads', async () => { + const form = new FormData(); + form.append('files', new Blob(['SKU,Name,Price\nTEST-01,Test product,12.50']), 'regression.csv'); + const res = await fetch(`${base}/api/contracts/import`, { method: 'POST', headers: { cookie: adminCookie }, body: form }); + assert.equal(res.status, 201, await res.clone().text()); + }); + + await t.test('only admins can start updates or inspect update logs', async () => { + assert.equal((await fetch(`${base}/api/system/update/status`)).status, 401); + assert.equal((await fetch(`${base}/api/system/update/status`, { headers: { cookie: repCookie } })).status, 403); + assert.equal((await fetch(`${base}/api/system/update/status`, { headers: { cookie: adminCookie } })).status, 200); + const post = async (cookie, password) => fetch(`${base}/api/system/update`, { + method: 'POST', headers: { ...(cookie ? { cookie } : {}), 'Content-Type': 'application/json' }, + body: JSON.stringify({ current_password: password }) + }); + assert.equal((await post('', 'admin123')).status, 401); + assert.equal((await post(repCookie, 'test-password')).status, 403); + const wrong = await post(adminCookie, 'wrong-password'); + assert.equal(wrong.status, 400); + assert.match((await wrong.json()).error, /密码不正确/); + }); + + await t.test('profile cannot promote a sales rep to admin', async () => { + const res = await fetch(`${base}/api/me`, { method: 'PATCH', headers: { cookie: repCookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ role: 'admin' }) }); + assert.equal(res.status, 403); + assert.equal(db.prepare('SELECT role FROM users WHERE id = ?').get(repId).role, 'sales_rep'); + }); + + await t.test('username changes verify password, preserve ownership and expire other sessions', async () => { + const patch = async body => fetch(`${base}/api/me/username`, { + method: 'PATCH', headers: { cookie: adminCookie, 'Content-Type': 'application/json' }, body: JSON.stringify(body) + }); + assert.equal((await patch({ username: 'renamed_admin' })).status, 400); + assert.equal((await patch({ username: 'renamed_admin', current_password: 'wrong-password' })).status, 400); + assert.equal((await patch({ username: 'REGRESSION_REP', current_password: 'admin123' })).status, 409); + assert.equal((await patch({ username: 'bad name', current_password: 'admin123' })).status, 400); + const otherSession = auth.createSession(admin.id); + const res = await patch({ username: 'renamed_admin', current_password: 'admin123' }); + assert.equal(res.status, 200, await res.clone().text()); + const renamed = (await res.json()).user; + assert.equal(renamed.id, admin.id); + assert.equal(renamed.username, 'renamed_admin'); + assert.equal(db.prepare('SELECT owner_id FROM customers WHERE id = ?').get(otherId).owner_id, admin.id); + assert.equal(db.prepare('SELECT id FROM sessions WHERE id = ?').get(otherSession), undefined); + assert.equal((await fetch(`${base}/api/auth/me`, { headers: { cookie: adminCookie } })).status, 200); + const login = async username => fetch(`${base}/api/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: 'admin123' }) + }); + assert.equal((await login('admin')).status, 400); + assert.equal((await login('renamed_admin')).status, 200); + // 在第二个进程重新初始化数据库,改名后不能重建带默认密码的 admin。 + const child = spawn(process.execPath, ['-e', "require('./db/db').close()"], { + cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR: dataDir }, stdio: 'ignore' + }); + const [code] = await once(child, 'exit'); + assert.equal(code, 0); + assert.equal(db.prepare("SELECT id FROM users WHERE username = 'admin'").get(), undefined); + }); + + await t.test('password verification locks repeated guesses before sensitive operations', async () => { + for (let attempt = 1; attempt <= 5; attempt++) { + const res = await fetch(`${base}/api/me/username`, { + method: 'PATCH', headers: { cookie: repCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'another_name', current_password: 'wrong-password' }) + }); + assert.equal(res.status, attempt === 5 ? 429 : 400); + } + assert.equal((await fetch(`${base}/api/auth/me`, { headers: { cookie: repCookie } })).status, 401); + const rep = db.prepare('SELECT username, locked_until FROM users WHERE id = ?').get(repId); + assert.equal(rep.username, 'regression_rep'); + assert.ok(Date.parse(rep.locked_until) > Date.now()); + }); + + await t.test('amount words separate minor units and preserve group conjunctions', async () => { + const { amountToWords: words } = await import(pathToFileURL(path.join(__dirname, '../../frontend/src/amountInWords.js'))); + const cases = [ + [100.01, 'SAY US DOLLARS ONE HUNDRED AND CENTS ONE ONLY'], + [1005, 'SAY US DOLLARS ONE THOUSAND AND FIVE ONLY'], + [1015, 'SAY US DOLLARS ONE THOUSAND AND FIFTEEN ONLY'], + [2001, 'SAY US DOLLARS TWO THOUSAND AND ONE ONLY'], + [1200, 'SAY US DOLLARS ONE THOUSAND TWO HUNDRED ONLY'], + [1000005, 'SAY US DOLLARS ONE MILLION AND FIVE ONLY'], + [0, 'SAY US DOLLARS ZERO ONLY'], + [0.5, 'SAY US DOLLARS ZERO AND CENTS FIFTY ONLY'] + ]; + for (const [amount, expected] of cases) assert.equal(words(amount), expected); + assert.equal(words(1e15, 'JPY'), 'SAY JAPANESE YEN ONE QUADRILLION ONLY'); + assert.equal(words(1e20), ''); + assert.equal(words('invalid'), ''); + }); +}); diff --git a/backend/test/web-update.test.js b/backend/test/web-update.test.js new file mode 100644 index 0000000..28fc074 --- /dev/null +++ b/backend/test/web-update.test.js @@ -0,0 +1,76 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const Database = require('better-sqlite3-multiple-ciphers'); +const { executeUpdate } = require('../lib/updateWorker'); + +for (const mode of ['success', 'build-failure', 'restart-failure', 'health-failure', 'rollback-failure']) { + test(`web update deployment: ${mode}`, async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-update-')); + let live; + t.after(() => { + if (live?.open) live.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + const data = path.join(root, 'backend/data'); + const dir = path.join(data, 'system-update'); + const jobDir = path.join(dir, 'test-job'); + const stage = path.join(jobDir, 'source'); + const modules = path.join(root, 'backend/node_modules'); + const dist = path.join(root, 'frontend/dist'); + for (const folder of [jobDir, modules, dist]) fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync(path.join(modules, 'marker'), 'old modules'); + fs.writeFileSync(path.join(dist, 'index.html'), 'old page'); + fs.writeFileSync(path.join(root, 'server-code'), 'old'); + fs.writeFileSync(path.join(dir, 'status.json'), JSON.stringify({ state: 'queued' })); + fs.writeFileSync(path.join(dir, 'lock'), 'locked'); + live = new Database(path.join(data, 'trade.db')); + live.pragma('journal_mode = WAL'); + live.exec('CREATE TABLE assets (value TEXT); INSERT INTO assets VALUES (\'real business data\')'); + const commands = []; + const hooks = { + run: async (command, args, cwd) => { + commands.push([command, ...args]); + if (command === 'git') { + if (args[0] === 'rev-parse') return args[1] === 'HEAD' ? 'old-commit' : 'new-commit'; + if (args[0] === 'status') return ''; + if (args[0] === 'worktree' && args[1] === 'add') { + for (const folder of ['backend/node_modules', 'frontend/dist']) fs.mkdirSync(path.join(stage, folder), { recursive: true }); + fs.writeFileSync(path.join(stage, 'package.json'), JSON.stringify({ version: '2.0.0' })); + fs.writeFileSync(path.join(stage, 'backend/node_modules/marker'), 'new modules'); + fs.writeFileSync(path.join(stage, 'frontend/dist/index.html'), 'new page'); + } + if (args[0] === 'reset') fs.writeFileSync(path.join(root, 'server-code'), args[2] === 'old-commit' ? 'old' : 'new'); + if (args[0] === 'worktree' && args[1] === 'remove') fs.rmSync(stage, { recursive: true, force: true }); + } + if (command === 'npm' && args[0] === 'run' && mode === 'build-failure') throw new Error('build failed'); + if (command === 'systemctl' && args[0] === 'restart' && ['restart-failure', 'rollback-failure'].includes(mode)) throw new Error('restart failed'); + if (command === 'systemctl' && args[0] === 'start' && mode === 'rollback-failure') throw new Error('rollback failed'); + return ''; + }, + backup: destination => live.backup(destination), + healthy: async expected => { if (expected && mode === 'health-failure') throw new Error('health check failed'); } + }; + await executeUpdate({ root, data, dir, jobDir, service: 'erp', repo: 'owner/repo', tag: 'v2.0.0', + version: '2.0.0', node: process.execPath, port: '4300' }, hooks); + const status = JSON.parse(fs.readFileSync(path.join(dir, 'status.json'), 'utf8')); + const success = mode === 'success'; + assert.equal(status.state, success ? 'completed' : 'failed'); + assert.equal(fs.readFileSync(path.join(modules, 'marker'), 'utf8'), success ? 'new modules' : 'old modules'); + assert.equal(fs.readFileSync(path.join(dist, 'index.html'), 'utf8'), success ? 'new page' : 'old page'); + assert.equal(fs.readFileSync(path.join(root, 'server-code'), 'utf8'), success ? 'new' : 'old'); + assert.equal(fs.existsSync(path.join(dir, 'lock')), false); + assert.equal(live.prepare('SELECT value FROM assets').get().value, 'real business data'); + if (mode === 'build-failure') { + assert.ok(!commands.some(args => args[0] === 'systemctl' || args[1] === 'reset')); + } else { + const backup = new Database(path.join(jobDir, 'trade-before-update.db'), { readonly: true }); + assert.equal(backup.prepare('SELECT value FROM assets').get().value, 'real business data'); + backup.close(); + } + if (['restart-failure', 'health-failure'].includes(mode)) assert.match(status.message, /已恢复上一版/); + if (mode === 'rollback-failure') assert.match(status.message, /自动恢复失败/); + }); +} diff --git a/docs/web-updates.md b/docs/web-updates.md new file mode 100644 index 0000000..f120e54 --- /dev/null +++ b/docs/web-updates.md @@ -0,0 +1,62 @@ +# 管理员网页更新 + +管理员进入「设置 → 系统与更新」,检查新版本后输入当前密码,点击「安装」。任务进度和日志会在网页显示;重启过程中连接短暂中断,页面会自动重试,完成后点击「刷新使用新版本」。普通账号只能检查版本,不能安装或读取更新日志。 + +## 首次启用 + +这项功能需要先把包含网页更新功能的版本部署到服务器一次。之后通过 GitHub 发布更高版本的 Release 或 tag 即可在网页安装。发布标签与仓库顶层 `package.json` 的版本必须一致;版本号必须高于当前版本。更新不自动跟随 main 的每一次提交。 + +运行条件: + +- Linux + systemd、Node.js 22 或更高版本、Git、npm。 +- 应用目录为 Git 工作副本,没有未提交的修改或未跟踪文件(已忽略的数据、依赖和构建目录不受影响)。 +- ERP 服务账号可以创建 systemd 临时单元、启停 ERP 服务,并读写应用目录和数据目录。现有 root 运行的 `erp.service` 满足此权限条件。 +- 服务器能够访问 GitHub、npm registry 和项目依赖来源。 +- 磁盘可容纳临时源码、两套后端依赖、前端构建和一份数据库备份。 + +默认服务名为 `erp`,对应 `erp.service`。如服务名不同,在 ERP 的 systemd 配置中增加: + +```ini +[Service] +Environment=HAPPY_UPDATE_SERVICE=your-service-name +Environment=SECURE_COOKIE=1 +``` + +服务名不含 `.service` 后缀。应用代码仍由现有 ERP 单元启动,更新任务用相同的 Node 可执行文件运行,并将该 Node 所在目录放在 PATH 首位,避免服务器旧 Node 抢先被使用。 + +更新源默认是 `Drsakura/happy-export`,可以通过服务器环境变量 `HAPPY_UPDATE_REPO=owner/repo` 配置;网页不能临时指定其他安装来源。当前自动安装从 GitHub 公共 HTTPS 仓库获取版本,不提供私有仓库令牌配置。 + +## 执行过程 + +1. 管理员权限校验、当前密码验证、并发更新锁和工作目录检查。 +2. 读取配置仓库的最新 Release/tag,获取指定标签的提交,在独立 Git worktree 准备新版本。 +3. 后端 `npm ci --omit=dev --ignore-scripts`,前端 `npm ci --include=dev`,再构建前端。 +4. 使用 SQLite 在线备份 API 备份数据库,包含 WAL 中尚未合并的数据。 +5. 再次确认运行目录没有发生代码修改,切换后端依赖、前端构建和 Git 提交。 +6. 重启 ERP 服务,核验健康状态及实际版本,成功后删除旧依赖和旧前端构建。 + +下载、安装或构建失败不会切换正在运行的应用。切换后重启或健康检查失败,会尝试恢复旧 Git 提交、后端依赖和前端构建并重启。任务通过独立 systemd 临时服务执行,因此 ERP 重启不会终止更新任务。 + +## 日志、备份与恢复 + +每次任务的日志和数据库备份保存在: + +```text +/system-update/<任务ID>/update.log +/system-update/<任务ID>/trade-before-update.db +/system-update/status.json +``` + +日志与备份不会自动过期,管理员可按自己的备份策略清理。状态接口只向管理员返回日志末尾 12 KB。 + +自动恢复只回退应用代码与依赖,不自动覆盖业务数据库,以保留更新期间的业务写入。后续发布应保持数据库迁移向后兼容;如果新版本迁移了不兼容的数据结构,需要管理员停服后从备份恢复数据库,恢复时同时处理旧 WAL/SHM。 + +意外断电或更新任务被终止后,网页会识别任务已停止并提示失败。若残留 `backend/node_modules.update-old` 或 `frontend/dist.update-old`,新任务会停止并提示人工检查;先确认服务实际运行版本和数据库,再决定恢复或清理这些目录,避免覆盖恢复所需文件。 + +Windows 本地环境支持检查更新,安装按钮会提示只支持 Linux systemd 部署。 + +## 用户名修改 + +「个人资料 → 修改用户名」输入新用户名和当前密码即可。新用户名为 3–50 位字母、数字或 `_ . @ -`,以字母、数字或下划线开头;不能与其他用户的用户名重名(不区分大小写)。修改保留当前会话,其他会话失效,之后用新用户名登录。业务归属使用用户 ID,不会随用户名变化。 + +初始管理员只在用户表为空时创建;管理员改名之后,重新启动不会重新生成默认账号。登录页面不展示默认用户名或密码。 diff --git a/frontend/src/amountInWords.js b/frontend/src/amountInWords.js index e3f0658..f1a8ef5 100644 --- a/frontend/src/amountInWords.js +++ b/frontend/src/amountInWords.js @@ -2,14 +2,14 @@ * 金额英文大写(外贸单证用) * * 目标格式与信用证 / 发票上的写法一致: - * 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND THIRTY-SIX CENTS ONLY + * 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND CENTS THIRTY-SIX ONLY * 1000 CNY → SAY CHINESE YUAN ONE THOUSAND ONLY - * 0.5 EUR → SAY EUROS ZERO AND FIFTY CENTS ONLY + * 0.5 EUR → SAY EUROS ZERO AND CENTS FIFTY ONLY * * 几条实务约定(不是「随便翻译一下」): * 1. 整句以 SAY 开头、以 ONLY 结尾,这是单证上的固定套路。 * 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。 - * 「组」与「组」之间不重复加 AND,避免 ONE THOUSAND AND TWO HUNDRED AND ... 这种啰嗦写法。 + * 最后的个位组小于 100 时,前面补 AND,如 ONE THOUSAND AND FIVE。 * 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词, * 单证上不写 ONE US DOLLAR。 * 4. 日元等无小数币种不带辅币段;小数位按币种配置取。 @@ -22,7 +22,7 @@ const ONES = [ 'SEVENTEEN', 'EIGHTEEN', 'NINETEEN' ] const TENS = ['', '', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY', 'SIXTY', 'SEVENTY', 'EIGHTY', 'NINETY'] -const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION'] +const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION', 'QUADRILLION'] /** * 币种配置。 @@ -100,6 +100,9 @@ function integerToWords(value) { rest = Math.floor(rest / 1000) scale += 1 } + if (value >= 1000 && value % 1000 > 0 && value % 1000 < 100) { + groups[groups.length - 1] = `AND ${groups[groups.length - 1]}` + } return groups.join(' ') } @@ -124,6 +127,7 @@ export function amountToWords(amount, currency = 'USD') { /* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */ const total = Math.round(Math.abs(num) * factor) const major = Math.floor(total / factor) + if (!Number.isSafeInteger(major)) return '' const minor = total % factor const parts = [`SAY ${meta.head}`] @@ -132,8 +136,8 @@ export function amountToWords(amount, currency = 'USD') { // 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略) if (major === 0) parts.push('ZERO') parts.push('AND') - parts.push(integerToWords(minor)) parts.push(meta.centsHead) + parts.push(integerToWords(minor)) } parts.push('ONLY') diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index 33dc692..f6a1b56 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -148,6 +148,12 @@ function ImportExportSettings() { 版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。 --------------------------------------------------------------------------- */ function SystemUpdateSettings() { + const { user } = useAuth() + const isAdmin = user?.role === 'admin' + const [job, setJob] = useState(null) + const [updatePassword, setUpdatePassword] = useState('') + const [starting, setStarting] = useState(false) + const [connectionLost, setConnectionLost] = useState(false) const brand = useBranding() const [info, setInfo] = useState(null) const [repo, setRepo] = useState('') @@ -193,6 +199,38 @@ function SystemUpdateSettings() { } } + const updating = starting || (job && !['idle', 'completed', 'failed'].includes(job.state)) + useEffect(() => { + if (!isAdmin) return + let alive = true + let timer + const poll = async () => { + try { + const { data } = await axios.get('/api/system/update/status', { timeout: 8000 }) + if (alive) { setJob(data); setConnectionLost(false) } + } catch { + if (alive) setConnectionLost(true) + } finally { + if (alive) timer = setTimeout(poll, 2500) + } + } + poll() + return () => { alive = false; clearTimeout(timer) } + }, [isAdmin]) + + const install = async event => { + event.preventDefault() + setStarting(true) + setError('') + try { + const { data } = await axios.post('/api/system/update', { current_password: updatePassword }, { timeout: 60000 }) + setJob(current => ({ ...current, ...data })) + setUpdatePassword('') + } catch (e) { + setError(e?.response?.data?.error || '未能确认更新任务状态,请查看下方进度后再试') + } finally { setStarting(false) } + } + const fmtTime = (value) => { if (!value) return '' try { @@ -205,7 +243,7 @@ function SystemUpdateSettings() { return (

系统与更新

-

查看当前版本与运行环境,并从官方仓库检查是否有新版本。

+

查看当前版本;系统管理员可在此安装更新。

当前版本

@@ -229,16 +267,16 @@ function SystemUpdateSettings() { value={repo} placeholder="owner/repo" onChange={(e) => setRepo(e.target.value)} - disabled={checking} + disabled />
-

默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。

+

更新源由服务器配置,网页安装使用此仓库的最新正式发布版本或版本标签。

检查更新

-
@@ -300,6 +338,33 @@ function SystemUpdateSettings() { )}
+ {isAdmin && ( +
+

安装更新

+

系统先构建新版本并备份数据库,再切换应用和重启。重启时页面可能短暂断开,进度会自动恢复。

+ {job?.capability && !job.capability.supported &&

{job.capability.reason}

} + {result?.hasUpdate && job?.capability?.supported && !updating && !(job?.state === 'completed' && job?.version === result.latest) && ( +
+
+ + setUpdatePassword(event.target.value)} /> +
+ +
+ )} + {job && job.state !== 'idle' && ( +
+ {updating && }{job.message} +
+ )} + {connectionLost &&

暂时无法连接服务,正在自动重试…

} + {job?.state === 'completed' && } + {job?.log &&
更新日志
{job.log}
} +
+ )} ) } diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 4b3e85f..d904ed4 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -95,7 +95,6 @@ function LoginPage() { {busy ? '登录中…' : '登 录'} -

首次使用默认账号 admin,登录后请在「个人资料」里修改密码。

{brand.name} · 外贸供应链管理系统

diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index a210529..7bf8b7d 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -5,7 +5,7 @@ import { SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon, EyeIcon, EyeOffIcon, UserIcon } from '../components/Icons' -import { useAuth, roleLabel } from '../auth' +import { useAuth, roleLabel, hasPermission } from '../auth' import { setPageTitle } from '../branding' import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard' @@ -60,6 +60,11 @@ function ProfilePage() { const [notice, setNotice] = useState(null) const fileRef = useRef(null) + const [username, setUsername] = useState('') + const [usernamePassword, setUsernamePassword] = useState('') + const [usernameBusy, setUsernameBusy] = useState(false) + const [usernameNotice, setUsernameNotice] = useState(null) + const canManageUsers = hasPermission(user, 'user.manage') const [password, setPassword] = useState({ current: '', next: '', confirm: '' }) const [passwordBusy, setPasswordBusy] = useState(false) const [passwordNotice, setPasswordNotice] = useState(null) @@ -108,14 +113,16 @@ function ProfilePage() { phone: payload.phone, address: payload.address, avatar: payload.avatar, - role: payload.role, - org_id: payload.org_id === '' ? undefined : Number(payload.org_id) + ...(hasPermission(user, 'user.manage') ? { + role: payload.role, + org_id: payload.org_id === '' ? undefined : Number(payload.org_id) + } : {}) }) const updated = response.data.user setUser(updated) setSaved(toForm(updated)) return updated - }, [setUser]) + }, [setUser, user]) /* 让外壳在跳转前能问一句「要不要先保存」 */ useEffect(() => { @@ -155,6 +162,21 @@ function ProfilePage() { } } + const submitUsername = async event => { + event.preventDefault() + setUsernameBusy(true) + setUsernameNotice(null) + try { + const { data } = await axios.patch('/api/me/username', { username: username.trim(), current_password: usernamePassword }) + setUser(data.user) + setUsername('') + setUsernamePassword('') + setUsernameNotice({ type: 'ok', text: data.message }) + } catch (error) { + setUsernameNotice({ type: 'error', text: error?.response?.data?.error || '修改用户名失败' }) + } finally { setUsernameBusy(false) } + } + const submitPassword = async event => { event.preventDefault() if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return } @@ -258,7 +280,7 @@ function ProfilePage() {
- setField('org_id', event.target.value)}> {organizations.map(org => ( @@ -267,7 +289,7 @@ function ProfilePage() {
- setField('role', event.target.value)}> {roles.map(role => ( ))} @@ -293,6 +315,25 @@ function ProfilePage() { {/* ---------------- 修改密码 ---------------- */}
+
修改用户名
+
+

当前用户名:{user?.username}。修改后请使用新用户名登录,业务数据归属保持不变。

+
+ + setUsername(event.target.value)} placeholder="3–50 位字母、数字或 _ . @ -" /> +
+
+ + setUsernamePassword(event.target.value)} placeholder="输入当前密码以验证身份" /> +
+ {usernameNotice &&

{usernameNotice.text}

} + +
+
修改密码
{[