fix: harden backend stability and test production utilities
This commit is contained in:
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
|
||||
};
|
||||
Reference in New Issue
Block a user