mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
b7e5e811d1
Navigating from login reused a send_email_code token on register. Captcha failure used 401 so the client stored /register as the post-login target and never left the page. Permission denials now return 403, and the API client no longer wipes the session on 401.
168 lines
4.9 KiB
TypeScript
168 lines
4.9 KiB
TypeScript
import { clsx, type ClassValue } 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 = '/home',
|
|
): 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;
|
|
}
|
|
|
|
if (
|
|
pathPart === '/login' ||
|
|
pathPart === '/register' ||
|
|
pathPart === '/callback' ||
|
|
pathPart.startsWith('/login/') ||
|
|
pathPart.startsWith('/register/')
|
|
) {
|
|
return fallback;
|
|
}
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
|
|
return url;
|
|
}
|