fix: harden backend stability and test production utilities
This commit is contained in:
29
.gitea/workflows/backend-tests.yml
Normal file
29
.gitea/workflows/backend-tests.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Backend tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: backend/package-lock.json
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Audit production dependencies
|
||||||
|
run: npm audit --omit=dev
|
||||||
|
- name: Run backend tests
|
||||||
|
run: npm test
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -89,9 +89,7 @@ package-lock.json.bak
|
|||||||
# Claude配置
|
# Claude配置
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# 测试脚本和报告
|
# 测试报告和本地验证产物
|
||||||
backend/test-*.js
|
|
||||||
backend/verify-*.js
|
|
||||||
backend/verify-*.sh
|
backend/verify-*.sh
|
||||||
backend/test-results-*.json
|
backend/test-results-*.json
|
||||||
backend/*最终*.js
|
backend/*最终*.js
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ sudo chown -R $USER:$USER /var/www/wanwanyun
|
|||||||
```bash
|
```bash
|
||||||
cd /var/www/wanwanyun/backend
|
cd /var/www/wanwanyun/backend
|
||||||
|
|
||||||
# 安装依赖
|
# 严格按锁文件安装生产依赖
|
||||||
npm install --production
|
npm ci --omit=dev
|
||||||
|
|
||||||
# 创建数据目录
|
# 创建数据目录
|
||||||
mkdir -p data storage
|
mkdir -p data storage
|
||||||
@@ -133,6 +133,8 @@ sudo systemctl reload nginx
|
|||||||
|
|
||||||
### 7. 配置系统服务
|
### 7. 配置系统服务
|
||||||
|
|
||||||
|
> 后端当前使用进程内限流器与用量缓存,只能运行一个实例。不要使用 PM2 cluster 模式或同时启动多个 systemd 实例。
|
||||||
|
|
||||||
创建 systemd 服务文件:
|
创建 systemd 服务文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -242,7 +244,7 @@ sudo tail -f /var/log/nginx/error.log
|
|||||||
```bash
|
```bash
|
||||||
cd /var/www/wanwanyun
|
cd /var/www/wanwanyun
|
||||||
sudo git pull
|
sudo git pull
|
||||||
cd backend && npm install --production
|
cd backend && npm ci --omit=dev
|
||||||
sudo systemctl restart wanwanyun
|
sudo systemctl restart wanwanyun
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,10 @@
|
|||||||
### 环境要求
|
### 环境要求
|
||||||
|
|
||||||
- **操作系统**: Linux (Ubuntu 18.04+ / Debian 10+ / CentOS 7+)
|
- **操作系统**: Linux (Ubuntu 18.04+ / Debian 10+ / CentOS 7+)
|
||||||
|
- **Node.js**: 20.x LTS(支持 20-24,推荐与 `.nvmrc` 保持一致)
|
||||||
- **内存**: 最低 1GB RAM(推荐 2GB+)
|
- **内存**: 最低 1GB RAM(推荐 2GB+)
|
||||||
- **磁盘空间**: 至少 2GB 可用空间
|
- **磁盘空间**: 至少 2GB 可用空间
|
||||||
|
- **后端实例数**: 当前限流与用量缓存为进程内状态,PM2 必须保持单实例 `fork` 模式
|
||||||
|
|
||||||
### 方式1: 一键部署(推荐)⭐
|
### 方式1: 一键部署(推荐)⭐
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ RUN apk add --no-cache python3 make g++ wget
|
|||||||
# 复制 package 文件
|
# 复制 package 文件
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# 安装依赖
|
# 严格按锁文件安装生产依赖
|
||||||
RUN npm install --production
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# 复制应用代码
|
# 复制应用代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
20
backend/middleware/error-handler.js
Normal file
20
backend/middleware/error-handler.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
function expressErrorHandler(err, req, res, next) {
|
||||||
|
if (res.headersSent) return next(err);
|
||||||
|
|
||||||
|
const requestedStatus = Number(err?.statusCode || err?.status);
|
||||||
|
const status = Number.isInteger(requestedStatus) && requestedStatus >= 400 && requestedStatus <= 599
|
||||||
|
? requestedStatus
|
||||||
|
: 500;
|
||||||
|
|
||||||
|
console.error(`[未处理错误] ${req.method} ${req.originalUrl}`, {
|
||||||
|
message: err?.message,
|
||||||
|
stack: err?.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(status).json({
|
||||||
|
success: false,
|
||||||
|
message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { expressErrorHandler };
|
||||||
931
backend/package-lock.json
generated
931
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,12 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"description": "玩玩云 - 云存储管理平台后端服务",
|
"description": "玩玩云 - 云存储管理平台后端服务",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20 <25"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js",
|
"dev": "node --watch server.js",
|
||||||
"test": "npm run test:unit && npm run test:integration && node test_download_quota_defaults.js",
|
"test": "npm run test:unit && npm run test:integration && node test_download_quota_defaults.js",
|
||||||
"test:unit": "node tests/run-all-tests.js",
|
"test:unit": "node tests/run-all-tests.js",
|
||||||
"test:integration": "node tests/full-audit-regression.js"
|
"test:integration": "node tests/full-audit-regression.js"
|
||||||
@@ -23,7 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.985.0",
|
"@aws-sdk/client-s3": "^3.985.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.985.0",
|
"@aws-sdk/s3-request-presigner": "^3.985.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
@@ -36,8 +39,5 @@
|
|||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"svg-captcha": "^1.4.0"
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^3.0.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const archiver = require('archiver');
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { exec, execSync, execFile } = require('child_process');
|
const { exec, execSync, execFile } = require('child_process');
|
||||||
const util = require('util');
|
const util = require('util');
|
||||||
@@ -95,6 +94,30 @@ const {
|
|||||||
} = require('./auth');
|
} = require('./auth');
|
||||||
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
|
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
|
||||||
const { encryptSecret, decryptSecret } = require('./utils/encryption');
|
const { encryptSecret, decryptSecret } = require('./utils/encryption');
|
||||||
|
const {
|
||||||
|
sanitizeInput,
|
||||||
|
decodeHtmlEntities,
|
||||||
|
escapeHtml,
|
||||||
|
isSafePathSegment,
|
||||||
|
isFileExtensionSafe
|
||||||
|
} = require('./utils/input-security');
|
||||||
|
const {
|
||||||
|
parseDateTimeValue,
|
||||||
|
formatDateTimeForSqlite,
|
||||||
|
getDateKeyFromDate,
|
||||||
|
getRecentDateKeys,
|
||||||
|
normalizeTimeHHmm,
|
||||||
|
isCurrentTimeInWindow
|
||||||
|
} = require('./utils/datetime');
|
||||||
|
const {
|
||||||
|
MAX_DOWNLOAD_TRAFFIC_BYTES,
|
||||||
|
normalizeDownloadTrafficQuota,
|
||||||
|
normalizeDownloadTrafficUsed,
|
||||||
|
getDownloadTrafficState,
|
||||||
|
resolveDownloadTrafficPolicyUpdates
|
||||||
|
} = require('./utils/download-quota');
|
||||||
|
const { expressErrorHandler } = require('./middleware/error-handler');
|
||||||
|
const { createZipArchive } = require('./utils/archive');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 40001;
|
const PORT = process.env.PORT || 40001;
|
||||||
@@ -102,7 +125,6 @@ const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英
|
|||||||
const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true';
|
const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true';
|
||||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024; // 10TB
|
|
||||||
const DOWNLOAD_POLICY_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30分钟
|
const DOWNLOAD_POLICY_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30分钟
|
||||||
const DOWNLOAD_RESERVATION_TTL_MS = Number(process.env.DOWNLOAD_RESERVATION_TTL_MS || (30 * 60 * 1000)); // 30分钟
|
const DOWNLOAD_RESERVATION_TTL_MS = Number(process.env.DOWNLOAD_RESERVATION_TTL_MS || (30 * 60 * 1000)); // 30分钟
|
||||||
const DOWNLOAD_LOG_RECONCILE_INTERVAL_MS = Number(process.env.DOWNLOAD_LOG_RECONCILE_INTERVAL_MS || (5 * 60 * 1000)); // 5分钟
|
const DOWNLOAD_LOG_RECONCILE_INTERVAL_MS = Number(process.env.DOWNLOAD_LOG_RECONCILE_INTERVAL_MS || (5 * 60 * 1000)); // 5分钟
|
||||||
@@ -637,7 +659,13 @@ if (ENABLE_CSRF) {
|
|||||||
// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置,
|
// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置,
|
||||||
// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头
|
// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (!ENFORCE_HTTPS) return next();
|
const remoteAddress = req.socket?.remoteAddress || '';
|
||||||
|
const isLocalHealthCheck = req.path === '/api/health' && [
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1',
|
||||||
|
'::ffff:127.0.0.1'
|
||||||
|
].includes(remoteAddress);
|
||||||
|
if (!ENFORCE_HTTPS || isLocalHealthCheck) return next();
|
||||||
|
|
||||||
// req.secure 由 Express 根据 trust proxy 配置计算:
|
// req.secure 由 Express 根据 trust proxy 配置计算:
|
||||||
// - 如果 trust proxy = false,仅检查直接连接是否为 TLS
|
// - 如果 trust proxy = false,仅检查直接连接是否为 TLS
|
||||||
@@ -657,95 +685,6 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* XSS过滤函数 - 过滤用户输入中的潜在XSS攻击代码
|
|
||||||
* 注意:不转义 / 因为它是文件路径的合法字符
|
|
||||||
* @param {string} str - 需要过滤的输入字符串
|
|
||||||
* @returns {string} 过滤后的安全字符串
|
|
||||||
*/
|
|
||||||
function sanitizeInput(str) {
|
|
||||||
if (typeof str !== 'string') return str;
|
|
||||||
|
|
||||||
// 1. 基础HTML实体转义(不包括 / 因为是路径分隔符,不包括 ` 因为是合法文件名字符)
|
|
||||||
let sanitized = str
|
|
||||||
.replace(/[&<>"']/g, (char) => {
|
|
||||||
const map = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
};
|
|
||||||
return map[char];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 过滤危险协议(javascript:, data:, vbscript:等)
|
|
||||||
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
|
|
||||||
|
|
||||||
// 3. 移除空字节
|
|
||||||
sanitized = sanitized.replace(/\x00/g, '');
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 HTML 实体解码为原始字符
|
|
||||||
* 用于处理经过XSS过滤后的文件名/路径字段,恢复原始字符
|
|
||||||
* 支持嵌套实体的递归解码(如 &#x60; -> ` -> `)
|
|
||||||
* @param {string} str - 包含HTML实体的字符串
|
|
||||||
* @returns {string} 解码后的原始字符串
|
|
||||||
*/
|
|
||||||
function decodeHtmlEntities(str) {
|
|
||||||
if (typeof str !== 'string') return str;
|
|
||||||
|
|
||||||
// 支持常见实体和数字实体(含多次嵌套,如 &#x60;)
|
|
||||||
const entityMap = {
|
|
||||||
amp: '&',
|
|
||||||
lt: '<',
|
|
||||||
gt: '>',
|
|
||||||
quot: '"',
|
|
||||||
apos: "'",
|
|
||||||
'#x27': "'",
|
|
||||||
'#x2F': '/',
|
|
||||||
'#x60': '`'
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeOnce = (input) =>
|
|
||||||
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
|
|
||||||
if (code[0] === '#') {
|
|
||||||
const isHex = code[1]?.toLowerCase() === 'x';
|
|
||||||
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
|
||||||
if (!Number.isNaN(num)) {
|
|
||||||
return String.fromCharCode(num);
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
const mapped = entityMap[code];
|
|
||||||
return mapped !== undefined ? mapped : match;
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = str;
|
|
||||||
let decoded = decodeOnce(output);
|
|
||||||
// 处理嵌套实体(如 &#x60;),直到稳定
|
|
||||||
while (decoded !== output) {
|
|
||||||
output = decoded;
|
|
||||||
decoded = decodeOnce(output);
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML转义(用于模板输出)
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (typeof str !== 'string') return str;
|
|
||||||
return str.replace(/[&<>"']/g, char => ({
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
}[char]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 规范化并校验HTTP直链前缀,只允许http/https
|
// 规范化并校验HTTP直链前缀,只允许http/https
|
||||||
function sanitizeHttpBaseUrl(raw) {
|
function sanitizeHttpBaseUrl(raw) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -790,56 +729,6 @@ function buildHttpDownloadUrl(rawBaseUrl, filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验文件名/路径片段安全(禁止分隔符、控制字符、..)
|
|
||||||
function isSafePathSegment(name) {
|
|
||||||
return (
|
|
||||||
typeof name === 'string' &&
|
|
||||||
name.length > 0 &&
|
|
||||||
name.length <= 255 && // 限制文件名长度
|
|
||||||
!name.includes('..') &&
|
|
||||||
!/[/\\]/.test(name) &&
|
|
||||||
!/[\x00-\x1F]/.test(name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 危险文件扩展名黑名单(仅限可能被Web服务器解析执行的脚本文件)
|
|
||||||
// 注意:这是网盘应用,.exe等可执行文件允许上传(服务器不会执行)
|
|
||||||
const DANGEROUS_EXTENSIONS = [
|
|
||||||
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP
|
|
||||||
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages
|
|
||||||
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET
|
|
||||||
'.htaccess', '.htpasswd' // Apache配置(可能改变服务器行为)
|
|
||||||
];
|
|
||||||
|
|
||||||
// 检查文件扩展名是否安全
|
|
||||||
function isFileExtensionSafe(filename) {
|
|
||||||
if (!filename || typeof filename !== 'string') return false;
|
|
||||||
|
|
||||||
const ext = path.extname(filename).toLowerCase();
|
|
||||||
const nameLower = filename.toLowerCase();
|
|
||||||
|
|
||||||
// 检查危险扩展名
|
|
||||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理:检查以危险名称开头的文件(如 .htaccess, .htpasswd)
|
|
||||||
// 因为 path.extname('.htaccess') 返回空字符串
|
|
||||||
const dangerousFilenames = ['.htaccess', '.htpasswd'];
|
|
||||||
if (dangerousFilenames.includes(nameLower)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行)
|
|
||||||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
|
||||||
if (nameLower.includes(dangerExt + '.')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用XSS过滤到所有POST/PUT请求的body
|
// 应用XSS过滤到所有POST/PUT请求的body
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
||||||
@@ -903,40 +792,6 @@ function normalizeOssQuota(rawQuota) {
|
|||||||
return parsedQuota;
|
return parsedQuota;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDownloadTrafficQuota(rawQuota) {
|
|
||||||
const parsedQuota = Number(rawQuota);
|
|
||||||
if (!Number.isFinite(parsedQuota)) {
|
|
||||||
return 0; // 0 表示禁止下载
|
|
||||||
}
|
|
||||||
if (parsedQuota < 0) {
|
|
||||||
return -1; // -1 表示不限流量
|
|
||||||
}
|
|
||||||
return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDownloadTrafficUsed(rawUsed, quota = 0) {
|
|
||||||
const parsedUsed = Number(rawUsed);
|
|
||||||
const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0
|
|
||||||
? Math.floor(parsedUsed)
|
|
||||||
: 0;
|
|
||||||
if (quota >= 0) {
|
|
||||||
return Math.min(normalizedUsed, quota);
|
|
||||||
}
|
|
||||||
return normalizedUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDownloadTrafficState(user) {
|
|
||||||
const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota);
|
|
||||||
const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota);
|
|
||||||
const isUnlimited = quota < 0;
|
|
||||||
return {
|
|
||||||
quota,
|
|
||||||
used,
|
|
||||||
isUnlimited,
|
|
||||||
remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBusyDownloadMessage() {
|
function getBusyDownloadMessage() {
|
||||||
return '当前网络繁忙,请稍后再试';
|
return '当前网络繁忙,请稍后再试';
|
||||||
}
|
}
|
||||||
@@ -1468,37 +1323,6 @@ function sendPlainTextError(res, statusCode, message) {
|
|||||||
return res.status(statusCode).type('text/plain; charset=utf-8').send(message);
|
return res.status(statusCode).type('text/plain; charset=utf-8').send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDateTimeValue(value) {
|
|
||||||
if (!value || typeof value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const directDate = new Date(value);
|
|
||||||
if (!Number.isNaN(directDate.getTime())) {
|
|
||||||
return directDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容 SQLite 常见 DATETIME 格式: YYYY-MM-DD HH:mm:ss
|
|
||||||
const normalized = value.replace(' ', 'T');
|
|
||||||
const normalizedDate = new Date(normalized);
|
|
||||||
if (!Number.isNaN(normalizedDate.getTime())) {
|
|
||||||
return normalizedDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTimeForSqlite(date = new Date()) {
|
|
||||||
const target = date instanceof Date ? date : new Date(date);
|
|
||||||
const year = target.getFullYear();
|
|
||||||
const month = String(target.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(target.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(target.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(target.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(target.getSeconds()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOssUploadReservationToken() {
|
function createOssUploadReservationToken() {
|
||||||
return crypto.randomBytes(24).toString('hex');
|
return crypto.randomBytes(24).toString('hex');
|
||||||
}
|
}
|
||||||
@@ -1519,132 +1343,6 @@ function encodeS3CopySource(bucket, key) {
|
|||||||
return `${encodedBucket}/${encodedKey}`;
|
return `${encodedBucket}/${encodedKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDateKeyFromDate(date = new Date()) {
|
|
||||||
const target = date instanceof Date ? date : new Date(date);
|
|
||||||
if (Number.isNaN(target.getTime())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const year = target.getFullYear();
|
|
||||||
const month = String(target.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(target.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRecentDateKeys(days = 30, now = new Date()) {
|
|
||||||
const safeDays = Math.max(1, Math.floor(Number(days) || 30));
|
|
||||||
const keys = [];
|
|
||||||
for (let i = safeDays - 1; i >= 0; i -= 1) {
|
|
||||||
const date = new Date(now.getTime());
|
|
||||||
date.setDate(date.getDate() - i);
|
|
||||||
const key = getDateKeyFromDate(date);
|
|
||||||
if (key) {
|
|
||||||
keys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNextDownloadResetTime(lastResetAt, resetCycle) {
|
|
||||||
const baseDate = parseDateTimeValue(lastResetAt);
|
|
||||||
if (!baseDate) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new Date(baseDate.getTime());
|
|
||||||
if (resetCycle === 'daily') {
|
|
||||||
next.setDate(next.getDate() + 1);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetCycle === 'weekly') {
|
|
||||||
next.setDate(next.getDate() + 7);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetCycle === 'monthly') {
|
|
||||||
next.setMonth(next.getMonth() + 1);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) {
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
updates: {},
|
|
||||||
hasUpdates: false,
|
|
||||||
expired: false,
|
|
||||||
resetApplied: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates = {};
|
|
||||||
let hasUpdates = false;
|
|
||||||
let expired = false;
|
|
||||||
let resetApplied = false;
|
|
||||||
|
|
||||||
const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota);
|
|
||||||
const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota);
|
|
||||||
if (normalizedQuota !== Number(user.download_traffic_quota || 0)) {
|
|
||||||
updates.download_traffic_quota = normalizedQuota;
|
|
||||||
hasUpdates = true;
|
|
||||||
}
|
|
||||||
if (normalizedUsed !== Number(user.download_traffic_used || 0)) {
|
|
||||||
updates.download_traffic_used = normalizedUsed;
|
|
||||||
hasUpdates = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle)
|
|
||||||
? user.download_traffic_reset_cycle
|
|
||||||
: 'none';
|
|
||||||
if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) {
|
|
||||||
updates.download_traffic_reset_cycle = resetCycle;
|
|
||||||
hasUpdates = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at);
|
|
||||||
if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) {
|
|
||||||
updates.download_traffic_quota_expires_at = null;
|
|
||||||
hasUpdates = true;
|
|
||||||
} else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) {
|
|
||||||
// 到期后自动恢复为不限并重置已用量
|
|
||||||
updates.download_traffic_quota = 0;
|
|
||||||
updates.download_traffic_used = 0;
|
|
||||||
updates.download_traffic_quota_expires_at = null;
|
|
||||||
updates.download_traffic_reset_cycle = 'none';
|
|
||||||
updates.download_traffic_last_reset_at = null;
|
|
||||||
hasUpdates = true;
|
|
||||||
expired = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!expired && resetCycle !== 'none') {
|
|
||||||
const lastResetAt = user.download_traffic_last_reset_at;
|
|
||||||
if (!lastResetAt) {
|
|
||||||
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
|
|
||||||
hasUpdates = true;
|
|
||||||
} else {
|
|
||||||
const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle);
|
|
||||||
if (nextResetAt && now >= nextResetAt) {
|
|
||||||
updates.download_traffic_used = 0;
|
|
||||||
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
|
|
||||||
hasUpdates = true;
|
|
||||||
resetApplied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (resetCycle === 'none' && user.download_traffic_last_reset_at) {
|
|
||||||
updates.download_traffic_last_reset_at = null;
|
|
||||||
hasUpdates = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
updates,
|
|
||||||
hasUpdates,
|
|
||||||
expired,
|
|
||||||
resetApplied
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const enforceDownloadTrafficPolicyTransaction = db.transaction((userId, trigger = 'runtime') => {
|
const enforceDownloadTrafficPolicyTransaction = db.transaction((userId, trigger = 'runtime') => {
|
||||||
let user = UserDB.findById(userId);
|
let user = UserDB.findById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -3262,43 +2960,6 @@ function dedupeOnlineDeviceRows(rows = [], currentSessionId = '') {
|
|||||||
return Array.from(deduped.values());
|
return Array.from(deduped.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTimeHHmm(value) {
|
|
||||||
if (typeof value !== 'string') return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
const match = trimmed.match(/^(\d{2}):(\d{2})$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const hours = Number(match[1]);
|
|
||||||
const minutes = Number(match[2]);
|
|
||||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
|
||||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
|
||||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMinutesOfDay(hhmm) {
|
|
||||||
const normalized = normalizeTimeHHmm(hhmm);
|
|
||||||
if (!normalized) return null;
|
|
||||||
const [hh, mm] = normalized.split(':').map(Number);
|
|
||||||
return hh * 60 + mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCurrentTimeInWindow(startTime, endTime, now = new Date()) {
|
|
||||||
const start = toMinutesOfDay(startTime);
|
|
||||||
const end = toMinutesOfDay(endTime);
|
|
||||||
if (start === null || end === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
||||||
if (start === end) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (start < end) {
|
|
||||||
return nowMinutes >= start && nowMinutes < end;
|
|
||||||
}
|
|
||||||
// 跨天窗口:如 22:00 - 06:00
|
|
||||||
return nowMinutes >= start || nowMinutes < end;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSharePolicySummary(share) {
|
function getSharePolicySummary(share) {
|
||||||
const maxDownloads = Number(share?.max_downloads);
|
const maxDownloads = Number(share?.max_downloads);
|
||||||
const whitelist = parseShareIpWhitelist(share?.ip_whitelist || '');
|
const whitelist = parseShareIpWhitelist(share?.ip_whitelist || '');
|
||||||
@@ -7916,7 +7577,7 @@ app.get('/api/upload/download-tool', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
// 创建文件写入流
|
// 创建文件写入流
|
||||||
const output = fs.createWriteStream(tempZipPath);
|
const output = fs.createWriteStream(tempZipPath);
|
||||||
const archive = archiver('zip', {
|
const archive = await createZipArchive({
|
||||||
store: true // 使用STORE模式,不压缩,速度最快
|
store: true // 使用STORE模式,不压缩,速度最快
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -11791,6 +11452,35 @@ app.get("/s/:code", (req, res) => {
|
|||||||
res.redirect(frontendUrl);
|
res.redirect(frontendUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep this after every route so Express can normalize synchronous and next(err) failures.
|
||||||
|
app.use(expressErrorHandler);
|
||||||
|
|
||||||
|
let server = null;
|
||||||
|
let fatalShutdownStarted = false;
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('[unhandledRejection]', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('[uncaughtException]', error);
|
||||||
|
if (fatalShutdownStarted) return;
|
||||||
|
fatalShutdownStarted = true;
|
||||||
|
|
||||||
|
const forceExitTimer = setTimeout(() => process.exit(1), 10_000);
|
||||||
|
forceExitTimer.unref();
|
||||||
|
const exitAfterClose = () => {
|
||||||
|
clearTimeout(forceExitTimer);
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server?.listening) {
|
||||||
|
server.close(exitAfterClose);
|
||||||
|
} else {
|
||||||
|
exitAfterClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 启动时清理旧临时文件
|
// 启动时清理旧临时文件
|
||||||
cleanupOldTempFiles();
|
cleanupOldTempFiles();
|
||||||
const desktopCleanupOnStartup = cleanupDesktopInstallerPackages(getDesktopUpdateConfig().installerUrl);
|
const desktopCleanupOnStartup = cleanupDesktopInstallerPackages(getDesktopUpdateConfig().installerUrl);
|
||||||
@@ -11799,7 +11489,7 @@ if (desktopCleanupOnStartup.executed && desktopCleanupOnStartup.removed > 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
server = app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`\n========================================`);
|
console.log(`\n========================================`);
|
||||||
console.log(`玩玩云已启动`);
|
console.log(`玩玩云已启动`);
|
||||||
console.log(`服务器地址: http://localhost:${PORT}`);
|
console.log(`服务器地址: http://localhost:${PORT}`);
|
||||||
|
|||||||
31
backend/tests/archive-tests.js
Normal file
31
backend/tests/archive-tests.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const { PassThrough } = require('stream');
|
||||||
|
const { finished } = require('stream/promises');
|
||||||
|
const { createZipArchive } = require('../utils/archive');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const archive = await createZipArchive({ store: true });
|
||||||
|
const output = new PassThrough();
|
||||||
|
const chunks = [];
|
||||||
|
output.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
archive.pipe(output);
|
||||||
|
archive.append(Buffer.from('wanwanyun archive smoke test'), { name: 'smoke.txt' });
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
await finished(output);
|
||||||
|
|
||||||
|
const zip = Buffer.concat(chunks);
|
||||||
|
assert.ok(zip.length > 30, 'ZIP output should not be empty');
|
||||||
|
assert.strictEqual(zip.subarray(0, 2).toString('ascii'), 'PK');
|
||||||
|
assert.ok(zip.includes(Buffer.from('smoke.txt')), 'ZIP should contain the requested entry');
|
||||||
|
|
||||||
|
console.log('通过: 1');
|
||||||
|
console.log('失败: 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
console.log('通过: 0');
|
||||||
|
console.log('失败: 1');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const {
|
||||||
|
sanitizeInput,
|
||||||
|
decodeHtmlEntities,
|
||||||
|
isSafePathSegment,
|
||||||
|
isFileExtensionSafe
|
||||||
|
} = require('../utils/input-security');
|
||||||
|
|
||||||
// 主函数包装器(支持 async/await)
|
// 主函数包装器(支持 async/await)
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
@@ -58,28 +64,6 @@ console.log('\n========== 1. 输入边界测试 ==========\n');
|
|||||||
function testSanitizeInput() {
|
function testSanitizeInput() {
|
||||||
console.log('--- 测试 XSS 过滤函数 sanitizeInput ---');
|
console.log('--- 测试 XSS 过滤函数 sanitizeInput ---');
|
||||||
|
|
||||||
// 从 server.js 复制的 sanitizeInput 函数
|
|
||||||
function sanitizeInput(str) {
|
|
||||||
if (typeof str !== 'string') return str;
|
|
||||||
|
|
||||||
let sanitized = str
|
|
||||||
.replace(/[&<>"']/g, (char) => {
|
|
||||||
const map = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
};
|
|
||||||
return map[char];
|
|
||||||
});
|
|
||||||
|
|
||||||
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
|
|
||||||
sanitized = sanitized.replace(/\x00/g, '');
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 空字符串测试
|
// 空字符串测试
|
||||||
test('空字符串输入应该返回空字符串', () => {
|
test('空字符串输入应该返回空字符串', () => {
|
||||||
assert.strictEqual(sanitizeInput(''), '');
|
assert.strictEqual(sanitizeInput(''), '');
|
||||||
@@ -263,17 +247,6 @@ console.log('\n========== 2. 文件操作边界测试 ==========\n');
|
|||||||
function testPathSecurity() {
|
function testPathSecurity() {
|
||||||
console.log('--- 测试路径安全校验 ---');
|
console.log('--- 测试路径安全校验 ---');
|
||||||
|
|
||||||
function isSafePathSegment(name) {
|
|
||||||
return (
|
|
||||||
typeof name === 'string' &&
|
|
||||||
name.length > 0 &&
|
|
||||||
name.length <= 255 &&
|
|
||||||
!name.includes('..') &&
|
|
||||||
!/[/\\]/.test(name) &&
|
|
||||||
!/[\x00-\x1F]/.test(name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('空文件名应该被拒绝', () => {
|
test('空文件名应该被拒绝', () => {
|
||||||
assert.strictEqual(isSafePathSegment(''), false);
|
assert.strictEqual(isSafePathSegment(''), false);
|
||||||
});
|
});
|
||||||
@@ -312,32 +285,6 @@ testPathSecurity();
|
|||||||
function testFileExtensionSecurity() {
|
function testFileExtensionSecurity() {
|
||||||
console.log('\n--- 测试文件扩展名安全 ---');
|
console.log('\n--- 测试文件扩展名安全 ---');
|
||||||
|
|
||||||
const DANGEROUS_EXTENSIONS = [
|
|
||||||
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar',
|
|
||||||
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf',
|
|
||||||
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx',
|
|
||||||
'.htaccess', '.htpasswd'
|
|
||||||
];
|
|
||||||
|
|
||||||
function isFileExtensionSafe(filename) {
|
|
||||||
if (!filename || typeof filename !== 'string') return false;
|
|
||||||
|
|
||||||
const ext = path.extname(filename).toLowerCase();
|
|
||||||
|
|
||||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameLower = filename.toLowerCase();
|
|
||||||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
|
||||||
if (nameLower.includes(dangerExt + '.')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('PHP 文件应该被拒绝', () => {
|
test('PHP 文件应该被拒绝', () => {
|
||||||
assert.strictEqual(isFileExtensionSafe('test.php'), false);
|
assert.strictEqual(isFileExtensionSafe('test.php'), false);
|
||||||
assert.strictEqual(isFileExtensionSafe('shell.phtml'), false);
|
assert.strictEqual(isFileExtensionSafe('shell.phtml'), false);
|
||||||
@@ -360,36 +307,8 @@ function testFileExtensionSecurity() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('.htaccess 和 .htpasswd 文件应该被拒绝', () => {
|
test('.htaccess 和 .htpasswd 文件应该被拒绝', () => {
|
||||||
// 更新测试以匹配修复后的 isFileExtensionSafe 函数
|
assert.strictEqual(isFileExtensionSafe('.htaccess'), false);
|
||||||
// 现在会检查 dangerousFilenames 列表
|
assert.strictEqual(isFileExtensionSafe('.htpasswd'), false);
|
||||||
const dangerousFilenames = ['.htaccess', '.htpasswd'];
|
|
||||||
|
|
||||||
function isFileExtensionSafeFixed(filename) {
|
|
||||||
if (!filename || typeof filename !== 'string') return false;
|
|
||||||
|
|
||||||
const ext = path.extname(filename).toLowerCase();
|
|
||||||
const nameLower = filename.toLowerCase();
|
|
||||||
|
|
||||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理:检查以危险名称开头的文件
|
|
||||||
if (dangerousFilenames.includes(nameLower)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
|
||||||
if (nameLower.includes(dangerExt + '.')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.strictEqual(isFileExtensionSafeFixed('.htaccess'), false);
|
|
||||||
assert.strictEqual(isFileExtensionSafeFixed('.htpasswd'), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('正常文件应该被接受', () => {
|
test('正常文件应该被接受', () => {
|
||||||
@@ -771,43 +690,6 @@ console.log('\n========== 7. HTML 实体解码测试 ==========\n');
|
|||||||
function testHtmlEntityDecoding() {
|
function testHtmlEntityDecoding() {
|
||||||
console.log('--- 测试 HTML 实体解码 ---');
|
console.log('--- 测试 HTML 实体解码 ---');
|
||||||
|
|
||||||
function decodeHtmlEntities(str) {
|
|
||||||
if (typeof str !== 'string') return str;
|
|
||||||
|
|
||||||
const entityMap = {
|
|
||||||
amp: '&',
|
|
||||||
lt: '<',
|
|
||||||
gt: '>',
|
|
||||||
quot: '"',
|
|
||||||
apos: "'",
|
|
||||||
'#x27': "'",
|
|
||||||
'#x2F': '/',
|
|
||||||
'#x60': '`'
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeOnce = (input) =>
|
|
||||||
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
|
|
||||||
if (code[0] === '#') {
|
|
||||||
const isHex = code[1]?.toLowerCase() === 'x';
|
|
||||||
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
|
||||||
if (!Number.isNaN(num)) {
|
|
||||||
return String.fromCharCode(num);
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
const mapped = entityMap[code];
|
|
||||||
return mapped !== undefined ? mapped : match;
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = str;
|
|
||||||
let decoded = decodeOnce(output);
|
|
||||||
while (decoded !== output) {
|
|
||||||
output = decoded;
|
|
||||||
decoded = decodeOnce(output);
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('基本 HTML 实体应该被解码', () => {
|
test('基本 HTML 实体应该被解码', () => {
|
||||||
assert.strictEqual(decodeHtmlEntities('<'), '<');
|
assert.strictEqual(decodeHtmlEntities('<'), '<');
|
||||||
assert.strictEqual(decodeHtmlEntities('>'), '>');
|
assert.strictEqual(decodeHtmlEntities('>'), '>');
|
||||||
|
|||||||
@@ -199,6 +199,17 @@ async function run() {
|
|||||||
assert.ok(jar.get('csrf_token'));
|
assert.ok(jar.get('csrf_token'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('malformed JSON receives the global JSON error response', async () => {
|
||||||
|
const malformed = await request(baseUrl, new CookieJar(), 'POST', '/api/login', {
|
||||||
|
csrf: false,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"invalid":}'
|
||||||
|
});
|
||||||
|
assert.strictEqual(malformed.status, 400);
|
||||||
|
assert.strictEqual(malformed.data.success, false);
|
||||||
|
assert.strictEqual(typeof malformed.data.message, 'string');
|
||||||
|
});
|
||||||
|
|
||||||
test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => {
|
test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => {
|
||||||
const login = await request(baseUrl, jar, 'POST', '/api/login', {
|
const login = await request(baseUrl, jar, 'POST', '/api/login', {
|
||||||
json: { username: 'admin', password: adminPassword }
|
json: { username: 'admin', password: adminPassword }
|
||||||
|
|||||||
244
backend/tests/production-utils-tests.js
Normal file
244
backend/tests/production-utils-tests.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const {
|
||||||
|
parseDateTimeValue,
|
||||||
|
formatDateTimeForSqlite,
|
||||||
|
getDateKeyFromDate,
|
||||||
|
getRecentDateKeys,
|
||||||
|
getNextDownloadResetTime,
|
||||||
|
normalizeTimeHHmm,
|
||||||
|
isCurrentTimeInWindow
|
||||||
|
} = require('../utils/datetime');
|
||||||
|
const {
|
||||||
|
MAX_DOWNLOAD_TRAFFIC_BYTES,
|
||||||
|
normalizeDownloadTrafficQuota,
|
||||||
|
normalizeDownloadTrafficUsed,
|
||||||
|
getDownloadTrafficState,
|
||||||
|
resolveDownloadTrafficPolicyUpdates
|
||||||
|
} = require('../utils/download-quota');
|
||||||
|
const { expressErrorHandler } = require('../middleware/error-handler');
|
||||||
|
|
||||||
|
const results = { passed: 0, failed: 0 };
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
results.passed += 1;
|
||||||
|
console.log(` [PASS] ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
results.failed += 1;
|
||||||
|
console.error(` [FAIL] ${name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResponse(headersSent = false) {
|
||||||
|
return {
|
||||||
|
headersSent,
|
||||||
|
statusCode: null,
|
||||||
|
payload: null,
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload) {
|
||||||
|
this.payload = payload;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n========== 生产工具模块测试 ==========\n');
|
||||||
|
|
||||||
|
test('SQLite 日期时间可以被解析', () => {
|
||||||
|
const parsed = parseDateTimeValue('2026-07-27 12:34:56');
|
||||||
|
assert.ok(parsed instanceof Date);
|
||||||
|
assert.strictEqual(parsed.getFullYear(), 2026);
|
||||||
|
assert.strictEqual(parsed.getMonth(), 6);
|
||||||
|
assert.strictEqual(parsed.getDate(), 27);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法日期时间返回 null', () => {
|
||||||
|
assert.strictEqual(parseDateTimeValue('not-a-date'), null);
|
||||||
|
assert.strictEqual(parseDateTimeValue(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('日期时间按 SQLite 格式输出', () => {
|
||||||
|
const value = new Date(2026, 6, 27, 3, 4, 5);
|
||||||
|
assert.strictEqual(formatDateTimeForSqlite(value), '2026-07-27 03:04:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('日期键校验有效和无效日期', () => {
|
||||||
|
assert.strictEqual(getDateKeyFromDate(new Date(2026, 0, 2)), '2026-01-02');
|
||||||
|
assert.strictEqual(getDateKeyFromDate('invalid'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('最近日期键按时间顺序生成', () => {
|
||||||
|
const keys = getRecentDateKeys(3, new Date(2026, 0, 2, 12, 0, 0));
|
||||||
|
assert.deepStrictEqual(keys, ['2025-12-31', '2026-01-01', '2026-01-02']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载配额重置时间支持日周月周期', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'daily')),
|
||||||
|
'2026-01-02 00:00:00'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'weekly')),
|
||||||
|
'2026-01-08 00:00:00'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'monthly')),
|
||||||
|
'2026-02-01 00:00:00'
|
||||||
|
);
|
||||||
|
assert.strictEqual(getNextDownloadResetTime('2026-01-01 00:00:00', 'none'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('访问时间格式被严格规范化', () => {
|
||||||
|
assert.strictEqual(normalizeTimeHHmm(' 09:05 '), '09:05');
|
||||||
|
assert.strictEqual(normalizeTimeHHmm('9:05'), null);
|
||||||
|
assert.strictEqual(normalizeTimeHHmm('24:00'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同日访问时间窗口正确判断', () => {
|
||||||
|
assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 12, 0)), true);
|
||||||
|
assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 18, 0)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跨日访问时间窗口正确判断', () => {
|
||||||
|
assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 23, 0)), true);
|
||||||
|
assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 12, 0)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法下载配额被禁止而负数统一为不限', () => {
|
||||||
|
assert.strictEqual(normalizeDownloadTrafficQuota('invalid'), 0);
|
||||||
|
assert.strictEqual(normalizeDownloadTrafficQuota(-99), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载配额被取整并限制在 10TB', () => {
|
||||||
|
assert.strictEqual(normalizeDownloadTrafficQuota(10.9), 10);
|
||||||
|
assert.strictEqual(normalizeDownloadTrafficQuota(MAX_DOWNLOAD_TRAFFIC_BYTES + 1), MAX_DOWNLOAD_TRAFFIC_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('已用流量按有限配额封顶', () => {
|
||||||
|
assert.strictEqual(normalizeDownloadTrafficUsed(150, 100), 100);
|
||||||
|
assert.strictEqual(normalizeDownloadTrafficUsed(150, -1), 150);
|
||||||
|
assert.strictEqual(normalizeDownloadTrafficUsed(-1, 100), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不限流量状态保留无限剩余额度', () => {
|
||||||
|
const state = getDownloadTrafficState({ download_traffic_quota: -1, download_traffic_used: 123 });
|
||||||
|
assert.strictEqual(state.isUnlimited, true);
|
||||||
|
assert.strictEqual(state.used, 123);
|
||||||
|
assert.strictEqual(state.remaining, Number.POSITIVE_INFINITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空用户不会产生配额更新', () => {
|
||||||
|
assert.deepStrictEqual(resolveDownloadTrafficPolicyUpdates(null), {
|
||||||
|
updates: {},
|
||||||
|
hasUpdates: false,
|
||||||
|
expired: false,
|
||||||
|
resetApplied: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法重置周期会被规范化', () => {
|
||||||
|
const result = resolveDownloadTrafficPolicyUpdates({
|
||||||
|
download_traffic_quota: 100,
|
||||||
|
download_traffic_used: 0,
|
||||||
|
download_traffic_reset_cycle: 'yearly'
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.updates.download_traffic_reset_cycle, 'none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('周期配额首次运行时记录重置时间', () => {
|
||||||
|
const now = new Date(2026, 6, 27, 12, 0, 0);
|
||||||
|
const result = resolveDownloadTrafficPolicyUpdates({
|
||||||
|
download_traffic_quota: 100,
|
||||||
|
download_traffic_used: 10,
|
||||||
|
download_traffic_reset_cycle: 'daily',
|
||||||
|
download_traffic_last_reset_at: null
|
||||||
|
}, now);
|
||||||
|
assert.strictEqual(result.updates.download_traffic_last_reset_at, '2026-07-27 12:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('到达周期时已用流量归零', () => {
|
||||||
|
const result = resolveDownloadTrafficPolicyUpdates({
|
||||||
|
download_traffic_quota: 100,
|
||||||
|
download_traffic_used: 90,
|
||||||
|
download_traffic_reset_cycle: 'daily',
|
||||||
|
download_traffic_last_reset_at: '2026-07-26 12:00:00'
|
||||||
|
}, new Date(2026, 6, 27, 12, 0, 0));
|
||||||
|
assert.strictEqual(result.resetApplied, true);
|
||||||
|
assert.strictEqual(result.updates.download_traffic_used, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('限时配额到期后恢复为不限流量', () => {
|
||||||
|
const result = resolveDownloadTrafficPolicyUpdates({
|
||||||
|
download_traffic_quota: 100,
|
||||||
|
download_traffic_used: 90,
|
||||||
|
download_traffic_quota_expires_at: '2026-07-26 12:00:00',
|
||||||
|
download_traffic_reset_cycle: 'daily',
|
||||||
|
download_traffic_last_reset_at: '2026-07-26 12:00:00'
|
||||||
|
}, new Date(2026, 6, 27, 12, 0, 0));
|
||||||
|
assert.strictEqual(result.expired, true);
|
||||||
|
assert.strictEqual(result.updates.download_traffic_quota, -1);
|
||||||
|
assert.strictEqual(result.updates.download_traffic_used, 0);
|
||||||
|
assert.strictEqual(result.updates.download_traffic_quota_expires_at, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Express 错误处理器保留四参数签名', () => {
|
||||||
|
assert.strictEqual(expressErrorHandler.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Express 错误处理器返回客户端错误 JSON', () => {
|
||||||
|
const response = createResponse();
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
expressErrorHandler(
|
||||||
|
Object.assign(new Error('请求 JSON 无效'), { status: 400 }),
|
||||||
|
{ method: 'POST', originalUrl: '/api/login' },
|
||||||
|
response,
|
||||||
|
() => assert.fail('不应调用 next')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
assert.strictEqual(response.statusCode, 400);
|
||||||
|
assert.deepStrictEqual(response.payload, { success: false, message: '请求 JSON 无效' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Express 错误处理器隐藏服务端异常详情', () => {
|
||||||
|
const response = createResponse();
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
expressErrorHandler(
|
||||||
|
new Error('sensitive detail'),
|
||||||
|
{ method: 'GET', originalUrl: '/api/example' },
|
||||||
|
response,
|
||||||
|
() => assert.fail('不应调用 next')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
assert.strictEqual(response.statusCode, 500);
|
||||||
|
assert.deepStrictEqual(response.payload, { success: false, message: '服务器内部错误' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('响应头已发送时错误处理器继续交给 Express', () => {
|
||||||
|
const response = createResponse(true);
|
||||||
|
const error = new Error('stream failed');
|
||||||
|
let forwarded = null;
|
||||||
|
expressErrorHandler(error, {}, response, (received) => {
|
||||||
|
forwarded = received;
|
||||||
|
});
|
||||||
|
assert.strictEqual(forwarded, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n========================================');
|
||||||
|
console.log('测试总结');
|
||||||
|
console.log('========================================');
|
||||||
|
console.log(`通过: ${results.passed}`);
|
||||||
|
console.log(`失败: ${results.failed}`);
|
||||||
|
|
||||||
|
process.exit(results.failed > 0 ? 1 : 0);
|
||||||
@@ -7,6 +7,8 @@ const path = require('path');
|
|||||||
|
|
||||||
const testFiles = [
|
const testFiles = [
|
||||||
'boundary-tests.js',
|
'boundary-tests.js',
|
||||||
|
'production-utils-tests.js',
|
||||||
|
'archive-tests.js',
|
||||||
'network-concurrent-tests.js',
|
'network-concurrent-tests.js',
|
||||||
'state-consistency-tests.js'
|
'state-consistency-tests.js'
|
||||||
];
|
];
|
||||||
@@ -42,7 +44,9 @@ function runTest(file) {
|
|||||||
const failMatch = output.match(/失败:\s*(\d+)/);
|
const failMatch = output.match(/失败:\s*(\d+)/);
|
||||||
|
|
||||||
const passed = passMatch ? parseInt(passMatch[1]) : 0;
|
const passed = passMatch ? parseInt(passMatch[1]) : 0;
|
||||||
const failed = failMatch ? parseInt(failMatch[1]) : 0;
|
const parsedFailed = failMatch ? parseInt(failMatch[1]) : 0;
|
||||||
|
// A syntax error or early crash may not print the normal summary.
|
||||||
|
const failed = code !== 0 && parsedFailed === 0 ? 1 : parsedFailed;
|
||||||
|
|
||||||
results.files.push({
|
results.files.push({
|
||||||
file,
|
file,
|
||||||
@@ -91,7 +95,8 @@ async function runAllTests() {
|
|||||||
console.log(`总计: 通过 ${results.total.passed}, 失败 ${results.total.failed}`);
|
console.log(`总计: 通过 ${results.total.passed}, 失败 ${results.total.failed}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
if (results.total.failed > 0) {
|
const hasProcessFailure = results.files.some(file => file.exitCode !== 0);
|
||||||
|
if (results.total.failed > 0 || hasProcessFailure) {
|
||||||
console.log('存在失败的测试,请检查输出以了解详情。');
|
console.log('存在失败的测试,请检查输出以了解详情。');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
9
backend/utils/archive.js
Normal file
9
backend/utils/archive.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
let archiverModulePromise = null;
|
||||||
|
|
||||||
|
async function createZipArchive(options = {}) {
|
||||||
|
archiverModulePromise ||= import('archiver');
|
||||||
|
const { ZipArchive } = await archiverModulePromise;
|
||||||
|
return new ZipArchive(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createZipArchive };
|
||||||
92
backend/utils/datetime.js
Normal file
92
backend/utils/datetime.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
function parseDateTimeValue(value) {
|
||||||
|
if (!value || typeof value !== 'string') return null;
|
||||||
|
|
||||||
|
const directDate = new Date(value);
|
||||||
|
if (!Number.isNaN(directDate.getTime())) return directDate;
|
||||||
|
|
||||||
|
const normalizedDate = new Date(value.replace(' ', 'T'));
|
||||||
|
return Number.isNaN(normalizedDate.getTime()) ? null : normalizedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeForSqlite(date = new Date()) {
|
||||||
|
const target = date instanceof Date ? date : new Date(date);
|
||||||
|
const year = target.getFullYear();
|
||||||
|
const month = String(target.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(target.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(target.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(target.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(target.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateKeyFromDate(date = new Date()) {
|
||||||
|
const target = date instanceof Date ? date : new Date(date);
|
||||||
|
if (Number.isNaN(target.getTime())) return null;
|
||||||
|
const year = target.getFullYear();
|
||||||
|
const month = String(target.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(target.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecentDateKeys(days = 30, now = new Date()) {
|
||||||
|
const safeDays = Math.max(1, Math.floor(Number(days) || 30));
|
||||||
|
const keys = [];
|
||||||
|
for (let i = safeDays - 1; i >= 0; i -= 1) {
|
||||||
|
const date = new Date(now.getTime());
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
const key = getDateKeyFromDate(date);
|
||||||
|
if (key) keys.push(key);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextDownloadResetTime(lastResetAt, resetCycle) {
|
||||||
|
const baseDate = parseDateTimeValue(lastResetAt);
|
||||||
|
if (!baseDate) return null;
|
||||||
|
|
||||||
|
const next = new Date(baseDate.getTime());
|
||||||
|
if (resetCycle === 'daily') next.setDate(next.getDate() + 1);
|
||||||
|
else if (resetCycle === 'weekly') next.setDate(next.getDate() + 7);
|
||||||
|
else if (resetCycle === 'monthly') next.setMonth(next.getMonth() + 1);
|
||||||
|
else return null;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimeHHmm(value) {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const match = value.trim().match(/^(\d{2}):(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMinutesOfDay(hhmm) {
|
||||||
|
const normalized = normalizeTimeHHmm(hhmm);
|
||||||
|
if (!normalized) return null;
|
||||||
|
const [hours, minutes] = normalized.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentTimeInWindow(startTime, endTime, now = new Date()) {
|
||||||
|
const start = toMinutesOfDay(startTime);
|
||||||
|
const end = toMinutesOfDay(endTime);
|
||||||
|
if (start === null || end === null) return true;
|
||||||
|
|
||||||
|
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
if (start === end) return true;
|
||||||
|
if (start < end) return nowMinutes >= start && nowMinutes < end;
|
||||||
|
return nowMinutes >= start || nowMinutes < end;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseDateTimeValue,
|
||||||
|
formatDateTimeForSqlite,
|
||||||
|
getDateKeyFromDate,
|
||||||
|
getRecentDateKeys,
|
||||||
|
getNextDownloadResetTime,
|
||||||
|
normalizeTimeHHmm,
|
||||||
|
toMinutesOfDay,
|
||||||
|
isCurrentTimeInWindow
|
||||||
|
};
|
||||||
107
backend/utils/download-quota.js
Normal file
107
backend/utils/download-quota.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
const {
|
||||||
|
parseDateTimeValue,
|
||||||
|
formatDateTimeForSqlite,
|
||||||
|
getNextDownloadResetTime
|
||||||
|
} = require('./datetime');
|
||||||
|
|
||||||
|
const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
function normalizeDownloadTrafficQuota(rawQuota) {
|
||||||
|
const parsedQuota = Number(rawQuota);
|
||||||
|
if (!Number.isFinite(parsedQuota)) return 0;
|
||||||
|
if (parsedQuota < 0) return -1;
|
||||||
|
return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDownloadTrafficUsed(rawUsed, quota = 0) {
|
||||||
|
const parsedUsed = Number(rawUsed);
|
||||||
|
const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0
|
||||||
|
? Math.floor(parsedUsed)
|
||||||
|
: 0;
|
||||||
|
return quota >= 0 ? Math.min(normalizedUsed, quota) : normalizedUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDownloadTrafficState(user) {
|
||||||
|
const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota);
|
||||||
|
const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota);
|
||||||
|
const isUnlimited = quota < 0;
|
||||||
|
return {
|
||||||
|
quota,
|
||||||
|
used,
|
||||||
|
isUnlimited,
|
||||||
|
remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) {
|
||||||
|
if (!user) {
|
||||||
|
return { updates: {}, hasUpdates: false, expired: false, resetApplied: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
let hasUpdates = false;
|
||||||
|
let expired = false;
|
||||||
|
let resetApplied = false;
|
||||||
|
|
||||||
|
const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota);
|
||||||
|
const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota);
|
||||||
|
if (normalizedQuota !== Number(user.download_traffic_quota || 0)) {
|
||||||
|
updates.download_traffic_quota = normalizedQuota;
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
if (normalizedUsed !== Number(user.download_traffic_used || 0)) {
|
||||||
|
updates.download_traffic_used = normalizedUsed;
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle)
|
||||||
|
? user.download_traffic_reset_cycle
|
||||||
|
: 'none';
|
||||||
|
if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) {
|
||||||
|
updates.download_traffic_reset_cycle = resetCycle;
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at);
|
||||||
|
if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) {
|
||||||
|
updates.download_traffic_quota_expires_at = null;
|
||||||
|
hasUpdates = true;
|
||||||
|
} else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) {
|
||||||
|
updates.download_traffic_quota = -1;
|
||||||
|
updates.download_traffic_used = 0;
|
||||||
|
updates.download_traffic_quota_expires_at = null;
|
||||||
|
updates.download_traffic_reset_cycle = 'none';
|
||||||
|
updates.download_traffic_last_reset_at = null;
|
||||||
|
hasUpdates = true;
|
||||||
|
expired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expired && resetCycle !== 'none') {
|
||||||
|
const lastResetAt = user.download_traffic_last_reset_at;
|
||||||
|
if (!lastResetAt) {
|
||||||
|
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
|
||||||
|
hasUpdates = true;
|
||||||
|
} else {
|
||||||
|
const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle);
|
||||||
|
if (nextResetAt && now >= nextResetAt) {
|
||||||
|
updates.download_traffic_used = 0;
|
||||||
|
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
|
||||||
|
hasUpdates = true;
|
||||||
|
resetApplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (resetCycle === 'none' && user.download_traffic_last_reset_at) {
|
||||||
|
updates.download_traffic_last_reset_at = null;
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updates, hasUpdates, expired, resetApplied };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MAX_DOWNLOAD_TRAFFIC_BYTES,
|
||||||
|
normalizeDownloadTrafficQuota,
|
||||||
|
normalizeDownloadTrafficUsed,
|
||||||
|
getDownloadTrafficState,
|
||||||
|
resolveDownloadTrafficPolicyUpdates
|
||||||
|
};
|
||||||
98
backend/utils/input-security.js
Normal file
98
backend/utils/input-security.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DANGEROUS_EXTENSIONS = [
|
||||||
|
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar',
|
||||||
|
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf',
|
||||||
|
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx',
|
||||||
|
'.htaccess', '.htpasswd'
|
||||||
|
];
|
||||||
|
|
||||||
|
function sanitizeInput(str) {
|
||||||
|
if (typeof str !== 'string') return str;
|
||||||
|
|
||||||
|
let sanitized = str.replace(/[&<>"']/g, (char) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[char]));
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
|
||||||
|
return sanitized.replace(/\x00/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(str) {
|
||||||
|
if (typeof str !== 'string') return str;
|
||||||
|
|
||||||
|
const entityMap = {
|
||||||
|
amp: '&',
|
||||||
|
lt: '<',
|
||||||
|
gt: '>',
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
'#x27': "'",
|
||||||
|
'#x2F': '/',
|
||||||
|
'#x60': '`'
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeOnce = (input) =>
|
||||||
|
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
|
||||||
|
if (code[0] === '#') {
|
||||||
|
const isHex = code[1]?.toLowerCase() === 'x';
|
||||||
|
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
||||||
|
return Number.isNaN(num) ? match : String.fromCharCode(num);
|
||||||
|
}
|
||||||
|
const mapped = entityMap[code];
|
||||||
|
return mapped !== undefined ? mapped : match;
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = str;
|
||||||
|
let decoded = decodeOnce(output);
|
||||||
|
while (decoded !== output) {
|
||||||
|
output = decoded;
|
||||||
|
decoded = decodeOnce(output);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (typeof str !== 'string') return str;
|
||||||
|
return str.replace(/[&<>"']/g, (char) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafePathSegment(name) {
|
||||||
|
return (
|
||||||
|
typeof name === 'string' &&
|
||||||
|
name.length > 0 &&
|
||||||
|
name.length <= 255 &&
|
||||||
|
!name.includes('..') &&
|
||||||
|
!/[/\\]/.test(name) &&
|
||||||
|
!/[\x00-\x1F]/.test(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileExtensionSafe(filename) {
|
||||||
|
if (!filename || typeof filename !== 'string') return false;
|
||||||
|
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
const nameLower = filename.toLowerCase();
|
||||||
|
if (DANGEROUS_EXTENSIONS.includes(ext)) return false;
|
||||||
|
if (['.htaccess', '.htpasswd'].includes(nameLower)) return false;
|
||||||
|
|
||||||
|
return !DANGEROUS_EXTENSIONS.some((dangerExt) => nameLower.includes(`${dangerExt}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sanitizeInput,
|
||||||
|
decodeHtmlEntities,
|
||||||
|
escapeHtml,
|
||||||
|
isSafePathSegment,
|
||||||
|
isFileExtensionSafe
|
||||||
|
};
|
||||||
13
install.sh
13
install.sh
@@ -2091,15 +2091,15 @@ install_backend_dependencies() {
|
|||||||
|
|
||||||
print_info "正在安装依赖包(包含数据库native模块,可能需要几分钟)..."
|
print_info "正在安装依赖包(包含数据库native模块,可能需要几分钟)..."
|
||||||
|
|
||||||
# 安装依赖,捕获错误
|
# 严格按锁文件安装依赖,捕获错误
|
||||||
if PYTHON=python3 npm install --production; then
|
if PYTHON=python3 npm ci --omit=dev; then
|
||||||
print_success "后端依赖安装完成"
|
print_success "后端依赖安装完成"
|
||||||
else
|
else
|
||||||
print_error "依赖安装失败"
|
print_error "依赖安装失败"
|
||||||
echo ""
|
echo ""
|
||||||
print_warning "可能的解决方案:"
|
print_warning "可能的解决方案:"
|
||||||
echo " 1. 检查网络连接"
|
echo " 1. 检查网络连接"
|
||||||
echo " 2. 手动执行: cd ${PROJECT_DIR}/backend && npm install --production"
|
echo " 2. 手动执行: cd ${PROJECT_DIR}/backend && npm ci --omit=dev"
|
||||||
echo " 3. 查看详细错误日志: ~/.npm/_logs/"
|
echo " 3. 查看详细错误日志: ~/.npm/_logs/"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -2875,7 +2875,7 @@ start_backend_service() {
|
|||||||
|
|
||||||
cd "${PROJECT_DIR}/backend"
|
cd "${PROJECT_DIR}/backend"
|
||||||
|
|
||||||
# 使用PM2启动
|
# 限流器与用量缓存是进程内状态,必须保持单实例 fork 模式。
|
||||||
pm2 start server.js --name ${PROJECT_NAME}-backend
|
pm2 start server.js --name ${PROJECT_NAME}-backend
|
||||||
pm2 save
|
pm2 save
|
||||||
|
|
||||||
@@ -3333,7 +3333,7 @@ confirm_update() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "【将要更新】"
|
echo "【将要更新】"
|
||||||
echo " ✓ 从仓库拉取最新代码"
|
echo " ✓ 从仓库拉取最新代码"
|
||||||
echo " ✓ 更新后端依赖(npm install)"
|
echo " ✓ 更新后端依赖(npm ci)"
|
||||||
echo " ✓ 重启后端服务"
|
echo " ✓ 重启后端服务"
|
||||||
echo ""
|
echo ""
|
||||||
echo "【将会保留】"
|
echo "【将会保留】"
|
||||||
@@ -3516,7 +3516,7 @@ update_install_dependencies() {
|
|||||||
|
|
||||||
print_info "正在重新安装依赖(可能需要几分钟)..."
|
print_info "正在重新安装依赖(可能需要几分钟)..."
|
||||||
|
|
||||||
if PYTHON=python3 npm install --production; then
|
if PYTHON=python3 npm ci --omit=dev; then
|
||||||
print_success "依赖更新完成"
|
print_success "依赖更新完成"
|
||||||
else
|
else
|
||||||
print_error "依赖更新失败"
|
print_error "依赖更新失败"
|
||||||
@@ -4173,6 +4173,7 @@ repair_restart_services() {
|
|||||||
else
|
else
|
||||||
print_warning "后端服务未运行,尝试启动..."
|
print_warning "后端服务未运行,尝试启动..."
|
||||||
cd "${PROJECT_DIR}/backend"
|
cd "${PROJECT_DIR}/backend"
|
||||||
|
# 限流器与用量缓存是进程内状态,必须保持单实例 fork 模式。
|
||||||
pm2 start server.js --name ${PROJECT_NAME}-backend
|
pm2 start server.js --name ${PROJECT_NAME}-backend
|
||||||
pm2 save
|
pm2 save
|
||||||
print_success "后端服务已启动"
|
print_success "后端服务已启动"
|
||||||
|
|||||||
Reference in New Issue
Block a user