mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
158 lines
4.7 KiB
TypeScript
158 lines
4.7 KiB
TypeScript
import { type ClassValue, clsx } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
export function formatDateTime(dateStr: string | Date) {
|
|
try {
|
|
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
timeZone: 'Asia/Shanghai',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
}).format(date);
|
|
} catch {
|
|
return String(dateStr);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 格式化日期为本地时间字符串(带时区)
|
|
* @param date 要格式化的日期
|
|
* @returns 格式化后的日期字符串,如 "2024-01-15T00:00:00+08:00"
|
|
*/
|
|
export function formatLocalDate(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+08:00`;
|
|
}
|
|
|
|
type Base64Buffer = {
|
|
from: (
|
|
input: string,
|
|
encoding: 'utf-8',
|
|
) => { toString: (encoding: 'base64') => string };
|
|
};
|
|
|
|
/**
|
|
* Base64 编码
|
|
* @param value 待编码字符串
|
|
* @returns Base64 编码后的字符串
|
|
*/
|
|
export function encodeBase64(value: string): string {
|
|
if (typeof globalThis.btoa === 'function') {
|
|
return globalThis.btoa(value);
|
|
}
|
|
|
|
const bufferConstructor = (
|
|
globalThis as typeof globalThis & { Buffer?: Base64Buffer }
|
|
).Buffer;
|
|
if (bufferConstructor) {
|
|
return bufferConstructor.from(value, 'utf-8').toString('base64');
|
|
}
|
|
|
|
throw new Error('当前环境不支持 Base64 编码');
|
|
}
|
|
|
|
/**
|
|
* 生成交易缓存的唯一键
|
|
* @param params 交易查询参数
|
|
* @returns 缓存键字符串
|
|
*/
|
|
export function generateTransactionCacheKey(params: {
|
|
types?: string[];
|
|
statuses?: string[];
|
|
payee_transfer_status?: string;
|
|
client_id?: string;
|
|
page?: number;
|
|
page_size?: number;
|
|
startTime?: string;
|
|
endTime?: string;
|
|
id?: string;
|
|
order_name?: string;
|
|
payer_username?: string;
|
|
payee_username?: string;
|
|
}): string {
|
|
const typesKey = params.types?.length ? params.types.sort().join(',') : 'all';
|
|
const statusesKey = params.statuses?.length
|
|
? params.statuses.sort().join(',')
|
|
: 'all';
|
|
const transferStatusKey = params.payee_transfer_status || 'all';
|
|
const clientIdKey = params.client_id || 'all';
|
|
const startTimeKey = params.startTime || 'no-start';
|
|
const endTimeKey = params.endTime || 'no-end';
|
|
const idKey = params.id || 'no-id';
|
|
const orderNameKey = params.order_name || 'no-name';
|
|
const payerKey = params.payer_username || 'no-payer';
|
|
const payeeKey = params.payee_username || 'no-payee';
|
|
|
|
return `${typesKey}_${statusesKey}_${transferStatusKey}_${clientIdKey}_${params.page}_${params.page_size}_${startTimeKey}_${endTimeKey}_${idKey}_${orderNameKey}_${payerKey}_${payeeKey}`;
|
|
}
|
|
|
|
/**
|
|
* 验证并净化重定向目标 URL,防止 Open Redirect 和 XSS 攻击。
|
|
* 只允许以单个斜杠 "/" 开头的相对路径,拒绝 "//"、协议、反斜杠、控制字符等编码变体。
|
|
*/
|
|
export function safeRedirectTarget(
|
|
url: string | null | undefined,
|
|
fallback = '/',
|
|
): string {
|
|
if (!url) return fallback;
|
|
|
|
// 1. 拒绝包含控制字符、空白字符或反斜杠的 URL
|
|
if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(url)) {
|
|
return fallback;
|
|
}
|
|
|
|
// 2. 必须以单个 '/' 开头,且不能以 '//' 开头
|
|
if (!url.startsWith('/') || url.startsWith('//')) {
|
|
return fallback;
|
|
}
|
|
|
|
// 3. 递归 URL 解码并检测潜在的绕过载荷
|
|
try {
|
|
let decoded = url;
|
|
let prev = '';
|
|
let attempts = 0;
|
|
while (decoded !== prev && decoded.includes('%') && attempts < 3) {
|
|
prev = decoded;
|
|
decoded = decodeURIComponent(decoded);
|
|
attempts++;
|
|
}
|
|
|
|
// 检查解码后的内容是否包含控制字符、空白字符或反斜杠
|
|
if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(decoded)) {
|
|
return fallback;
|
|
}
|
|
|
|
// 检查解码后的内容是否以单个 '/' 开头,且不能以 '//' 开头
|
|
if (!decoded.startsWith('/') || decoded.startsWith('//')) {
|
|
return fallback;
|
|
}
|
|
|
|
// 提取路径部分(即问号 ? 或井号 # 之前的内容),确保其中不含冒号(防止 scheme)、双斜杠或反斜杠
|
|
const pathPart = decoded.split(/[?#]/)[0];
|
|
if (
|
|
pathPart.includes(':') ||
|
|
pathPart.includes('//') ||
|
|
pathPart.includes('\\')
|
|
) {
|
|
return fallback;
|
|
}
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
|
|
return url;
|
|
}
|