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
+3 -3
View File
@@ -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');
+21 -2
View File
@@ -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,
+149
View File
@@ -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; });
}
+2 -5
View File
@@ -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)
+83
View File
@@ -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 };
+28 -535
View File
@@ -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"
}
}
}
}
+3 -5
View File
@@ -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"
}
}
+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) {
+39 -9
View File
@@ -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,
+224
View File
@@ -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', '[email protected]']) {
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'), '');
});
});
+76
View File
@@ -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, /自动恢复失败/);
});
}