chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
111
packages/backend/src/utils/crypto.ts
Normal file
111
packages/backend/src/utils/crypto.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* AES-256-GCM 加解密工具
|
||||
*
|
||||
* 用于保护数据库中存储的网盘 Cookie。
|
||||
* 加密密钥从环境变量 COOKIE_ENCRYPTION_KEY 读取,
|
||||
* 未设置时自动生成随机密钥(仅当前进程有效,重启后旧数据不可解密)。
|
||||
*
|
||||
* 生产环境必须设置 COOKIE_ENCRYPTION_KEY!
|
||||
*/
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // 96-bit nonce for GCM
|
||||
const TAG_LENGTH = 16; // 128-bit auth tag
|
||||
const KEY_LENGTH = 32; // 256-bit key
|
||||
|
||||
let ENCRYPTION_KEY: Buffer | null = null;
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (ENCRYPTION_KEY) return ENCRYPTION_KEY;
|
||||
|
||||
const envKey = process.env.COOKIE_ENCRYPTION_KEY;
|
||||
if (envKey && envKey.length >= 32) {
|
||||
// Use SHA-256 to derive a consistent 32-byte key from any length input
|
||||
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
|
||||
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)');
|
||||
} else if (envKey) {
|
||||
// Short key: still use SHA-256
|
||||
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
|
||||
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
|
||||
} else {
|
||||
// Default stable key (not ephemeral) — data survives container restart
|
||||
ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest();
|
||||
console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)');
|
||||
}
|
||||
return ENCRYPTION_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext. Returns base64-encoded ciphertext (includes IV + auth tag).
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
if (!plaintext) return '';
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv (12) + tag (16) + ciphertext
|
||||
const combined = Buffer.concat([iv, tag, encrypted]);
|
||||
return combined.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt base64-encoded ciphertext. Returns original plaintext.
|
||||
* Returns empty string if decryption fails (corrupted data or wrong key).
|
||||
*/
|
||||
export function decrypt(encoded: string): string {
|
||||
if (!encoded) return '';
|
||||
try {
|
||||
const key = getKey();
|
||||
const combined = Buffer.from(encoded, 'base64');
|
||||
|
||||
if (combined.length < IV_LENGTH + TAG_LENGTH + 1) {
|
||||
console.warn('[Crypto] Ciphertext too short, returning as-is (possibly unencrypted legacy data)');
|
||||
// Legacy data: stored as plaintext before encryption was added
|
||||
return encoded;
|
||||
}
|
||||
|
||||
const iv = combined.subarray(0, IV_LENGTH);
|
||||
const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (err: any) {
|
||||
// If it looks like base64 but decryption fails, it might be legacy plaintext
|
||||
// stored before encryption was enabled. Try returning as-is.
|
||||
if (err.message?.includes('unsupported state') || err.message?.includes('authentication')) {
|
||||
console.warn('[Crypto] Decryption failed (possibly legacy plaintext), returning as-is');
|
||||
return encoded;
|
||||
}
|
||||
console.error('[Crypto] Decryption error:', err.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string appears to be encrypted (base64 with IV+tag prefix).
|
||||
* Used for migration: re-encrypt legacy plaintext cookies.
|
||||
*/
|
||||
export function isEncrypted(value: string): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const combined = Buffer.from(value, 'base64');
|
||||
return combined.length > IV_LENGTH + TAG_LENGTH;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
73
packages/backend/src/utils/logger.ts
Normal file
73
packages/backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 结构化日志工具
|
||||
*
|
||||
* 统一日志格式,支持请求追踪。
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
module?: string;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
let currentLevel: LogLevel =
|
||||
(process.env.LOG_LEVEL as LogLevel) ||
|
||||
(process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||
}
|
||||
|
||||
function formatLog(entry: LogEntry): string {
|
||||
const parts = [
|
||||
`[${entry.timestamp}]`,
|
||||
`[${entry.level.toUpperCase()}]`,
|
||||
];
|
||||
if (entry.module) parts.push(`[${entry.module}]`);
|
||||
parts.push(entry.message);
|
||||
if (entry.duration !== undefined) parts.push(`(${entry.duration}ms)`);
|
||||
if (entry.error) parts.push(`\n ${entry.error}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function log(level: LogLevel, message: string, module?: string, extra?: Record<string, any>): void {
|
||||
if (!shouldLog(level)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
module,
|
||||
...extra,
|
||||
};
|
||||
|
||||
const formatted = formatLog(entry);
|
||||
switch (level) {
|
||||
case 'error': console.error(formatted); break;
|
||||
case 'warn': console.warn(formatted); break;
|
||||
default: console.log(formatted); break;
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (msg: string, module?: string) => log('debug', msg, module),
|
||||
info: (msg: string, module?: string) => log('info', msg, module),
|
||||
warn: (msg: string, module?: string) => log('warn', msg, module),
|
||||
error: (msg: string, module?: string, err?: Error) =>
|
||||
log('error', msg, module, err ? { error: err.stack || err.message } : undefined),
|
||||
|
||||
/** Log with duration (for performance tracking) */
|
||||
perf: (msg: string, durationMs: number, module?: string) =>
|
||||
log('info', msg, module, { duration: durationMs }),
|
||||
};
|
||||
143
packages/backend/src/utils/proxy-agent.ts
Normal file
143
packages/backend/src/utils/proxy-agent.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 统一代理工具 — 支持 HTTP/HTTPS/SOCKS5/SOCKS5h 协议
|
||||
*
|
||||
* Node 20+ 原生 fetch() 使用 undici Dispatcher,但 socks-proxy-agent 不实现此接口。
|
||||
* 解决方案:使用 http.Agent 接口 + http/https.request()。
|
||||
*/
|
||||
|
||||
let HttpsProxyAgent: any;
|
||||
let SocksProxyAgent: any;
|
||||
|
||||
try {
|
||||
HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
|
||||
} catch {
|
||||
try { HttpsProxyAgent = require('https-proxy-agent'); } catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent;
|
||||
} catch {
|
||||
try { SocksProxyAgent = require('socks-proxy-agent'); } catch {}
|
||||
}
|
||||
|
||||
/** Create an http.Agent for the given proxy URL (works with https.request) */
|
||||
function createProxyAgent(proxyUrl: string): any | null {
|
||||
if (!proxyUrl || typeof proxyUrl !== 'string') return null;
|
||||
const trimmed = proxyUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
try {
|
||||
if (lower.startsWith('socks5://') || lower.startsWith('socks5h://')) {
|
||||
if (!SocksProxyAgent) {
|
||||
console.warn('[Proxy] socks-proxy-agent not installed');
|
||||
return null;
|
||||
}
|
||||
return new SocksProxyAgent(trimmed);
|
||||
}
|
||||
if (lower.startsWith('http://') || lower.startsWith('https://')) {
|
||||
if (!HttpsProxyAgent) {
|
||||
console.warn('[Proxy] No HTTP proxy agent available');
|
||||
return null;
|
||||
}
|
||||
return new HttpsProxyAgent(trimmed);
|
||||
}
|
||||
// Unknown scheme — try as HTTP proxy
|
||||
if (HttpsProxyAgent) return new HttpsProxyAgent(trimmed);
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
console.error(`[Proxy] Failed to create proxy agent: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with proxy support.
|
||||
* Uses native fetch() when no proxy, or http/https.request() with agent when proxy is set.
|
||||
*/
|
||||
export async function proxiedFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
proxyUrl?: string,
|
||||
): Promise<Response> {
|
||||
if (!proxyUrl) return fetch(url, init);
|
||||
|
||||
const agent = createProxyAgent(proxyUrl);
|
||||
if (!agent) return fetch(url, init);
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const mod = parsedUrl.protocol === 'https:' ? require('https') : require('http');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (init?.headers) {
|
||||
const h = init.headers as any;
|
||||
if (h instanceof Headers) {
|
||||
h.forEach((v, k) => { headers[k] = v; });
|
||||
} else if (typeof h === 'object') {
|
||||
Object.assign(headers, h);
|
||||
}
|
||||
}
|
||||
|
||||
const options: any = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: init?.method || 'GET',
|
||||
headers,
|
||||
agent,
|
||||
};
|
||||
|
||||
const req = mod.request(options, (res: any) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
resolve(new Response(body, {
|
||||
status: res.statusCode || 502,
|
||||
statusText: res.statusMessage || '',
|
||||
headers: new Headers(res.headers || {}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (init?.signal) {
|
||||
init.signal.addEventListener('abort', () => req.destroy());
|
||||
}
|
||||
|
||||
if (init?.body) {
|
||||
req.write(
|
||||
typeof init.body === 'string' ? init.body :
|
||||
init.body instanceof Buffer ? init.body :
|
||||
init.body instanceof ArrayBuffer ? Buffer.from(init.body) :
|
||||
Buffer.from(String(init.body))
|
||||
);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function testProxyConnection(
|
||||
proxyUrl: string,
|
||||
testUrl?: string,
|
||||
): Promise<{ ok: boolean; latency: number; info: string }> {
|
||||
const target = testUrl || 'https://www.baidu.com';
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await proxiedFetch(target, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}, proxyUrl);
|
||||
const latency = Date.now() - start;
|
||||
return { ok: true, latency, info: `连接成功 (${res.status})` };
|
||||
} catch (err: any) {
|
||||
return { ok: false, latency: Date.now() - start, info: `代理连接失败: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy compat — no longer returns dispatcher, kept for type compatibility
|
||||
export function createProxyDispatcher(proxyUrl: string): { agent?: any } | null {
|
||||
const agent = createProxyAgent(proxyUrl);
|
||||
return agent ? { agent } : null;
|
||||
}
|
||||
407
packages/backend/src/utils/qr-login.service.ts
Executable file
407
packages/backend/src/utils/qr-login.service.ts
Executable file
@@ -0,0 +1,407 @@
|
||||
import { chromium, BrowserContext, Page } from 'playwright';
|
||||
import jsQR from 'jsqr';
|
||||
import { getDb } from '../database/database';
|
||||
import { escapeLike } from '../utils/time';
|
||||
|
||||
interface QrSession {
|
||||
id: string;
|
||||
browserContext: BrowserContext;
|
||||
page: Page;
|
||||
createdAt: number;
|
||||
cookieSnapshot: string;
|
||||
lastPollAt: number;
|
||||
qrUrl: string;
|
||||
status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SESSIONS = new Map<string, QrSession>();
|
||||
const SESSION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks
|
||||
|
||||
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser';
|
||||
|
||||
// Clean up old sessions periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of SESSIONS.entries()) {
|
||||
if (now - session.createdAt > SESSION_TTL) {
|
||||
cleanupSession(id);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function cleanupSession(id: string) {
|
||||
const session = SESSIONS.get(id);
|
||||
if (session) {
|
||||
try {
|
||||
session.browserContext.close().catch(() => {});
|
||||
} catch {}
|
||||
SESSIONS.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract QR code URL from the login page canvas using jsQR.
|
||||
* The actual login QR code is Canvas #0 (anonymous, 177x177), NOT #react-qrcode-logo.
|
||||
*/
|
||||
async function extractQrUrl(page: Page): Promise<string> {
|
||||
// Run inside Playwright's browser context (as a string to avoid Node TS type errors)
|
||||
const raw = await page.evaluate(`(() => {
|
||||
const canvases = document.querySelectorAll('canvas');
|
||||
var results = [];
|
||||
for (var i = 0; i < canvases.length; i++) {
|
||||
try {
|
||||
var c = canvases[i];
|
||||
var ctx = c.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
var imageData = ctx.getImageData(0, 0, c.width, c.height);
|
||||
results.push({
|
||||
index: i,
|
||||
w: c.width,
|
||||
h: c.height,
|
||||
data: Array.from(imageData.data)
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
return results;
|
||||
})()`) as unknown as { index: number; w: number; h: number; data: number[] }[];
|
||||
|
||||
if (!raw || raw.length === 0) {
|
||||
throw new Error('页面没有可用的 canvas');
|
||||
}
|
||||
|
||||
// Try to decode each canvas, preferring the one with su.quark.cn URL
|
||||
let bestUrl = '';
|
||||
let bestResult: { index: number; w: number; h: number; data: number[] } | null = null;
|
||||
|
||||
for (const canvas of raw) {
|
||||
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
|
||||
if (code && code.data) {
|
||||
// If this is the login QR code (has su.quark.cn), use it immediately
|
||||
if (code.data.includes('su.quark.cn')) {
|
||||
return code.data;
|
||||
}
|
||||
// Otherwise keep it as fallback
|
||||
if (!bestUrl) {
|
||||
bestUrl = code.data;
|
||||
bestResult = canvas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestUrl) {
|
||||
return bestUrl;
|
||||
}
|
||||
|
||||
throw new Error('无法解析二维码内容');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a QR code login session.
|
||||
* Launches headless Chromium, navigates to Quark login page, extracts QR code URL.
|
||||
*/
|
||||
export async function startQrLogin(): Promise<{
|
||||
sessionId: string;
|
||||
qrUrl: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
// Clean up any existing expired sessions
|
||||
for (const [id, session] of SESSIONS.entries()) {
|
||||
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||
cleanupSession(id);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: CHROMIUM_PATH,
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
],
|
||||
});
|
||||
|
||||
const browserContext = await browser.newContext({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1280, height: 800 },
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
try {
|
||||
// Navigate to Quark login page (now the homepage itself has QR login)
|
||||
await page.goto('https://pan.quark.cn/', {
|
||||
waitUntil: 'commit',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Wait for the QR code canvas to appear
|
||||
await page.waitForSelector('canvas', { timeout: 15000 });
|
||||
|
||||
// Extra wait for the QR code to fully render
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Extract the QR code URL from the canvas
|
||||
const qrUrl = await extractQrUrl(page);
|
||||
|
||||
// Take initial cookie snapshot
|
||||
const cookies = await browserContext.cookies();
|
||||
const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
const session: QrSession = {
|
||||
id: sessionId,
|
||||
browserContext,
|
||||
page,
|
||||
createdAt: Date.now(),
|
||||
cookieSnapshot,
|
||||
lastPollAt: Date.now(),
|
||||
qrUrl,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
SESSIONS.set(sessionId, session);
|
||||
|
||||
// Start background polling for login detection
|
||||
pollLoginStatus(session);
|
||||
|
||||
// Handle page navigation (like redirect after login)
|
||||
page.on('framenavigated', async (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
const url = frame.url();
|
||||
if (url === 'about:blank') {
|
||||
await checkAndCaptureCookies(session);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle popups/dialogs
|
||||
page.on('popup', async (popup) => {
|
||||
try {
|
||||
await popup.waitForLoadState('networkidle', { timeout: 10000 });
|
||||
await checkAndCaptureCookies(session);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
qrUrl,
|
||||
expiresIn: SESSION_TTL / 1000,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// Clean up on failure
|
||||
try { await browserContext.close(); } catch {}
|
||||
try { browser.close().catch(() => {}); } catch {}
|
||||
SESSIONS.delete(sessionId);
|
||||
throw new Error(`启动扫码登录失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll login status in background.
|
||||
* Checks cookies every COOKIE_CHECK_INTERVAL ms for new session tokens.
|
||||
*/
|
||||
async function pollLoginStatus(session: QrSession) {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if expired
|
||||
if (now - session.createdAt > SESSION_TTL) {
|
||||
clearInterval(checkInterval);
|
||||
session.status = 'expired';
|
||||
cleanupSession(session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
session.lastPollAt = now;
|
||||
|
||||
// Check cookies
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
// Check for session cookies indicating login
|
||||
const hasSessionCookie = cookies.some(
|
||||
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||
);
|
||||
|
||||
if (hasSessionCookie) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
session.status = 'logged_in';
|
||||
clearInterval(checkInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check URL change as alternative indicator
|
||||
const url = session.page.url();
|
||||
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
|
||||
await checkAndCaptureCookies(session);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Page might have been closed
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}, COOKIE_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check cookies after navigation/redirect and capture them if login succeeded.
|
||||
*/
|
||||
async function checkAndCaptureCookies(session: QrSession) {
|
||||
try {
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
const hasSessionCookie = cookies.some(
|
||||
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||
);
|
||||
|
||||
if (hasSessionCookie) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
session.status = 'logged_in';
|
||||
} else if (cookies.length > 3) {
|
||||
const newCookies = cookies.filter(
|
||||
c => !['ctoken', 'b-user-id', '__wpkreporterwid_'].includes(c.name)
|
||||
);
|
||||
if (newCookies.length > 0) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
try {
|
||||
const resp = await session.page.evaluate(async () => {
|
||||
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||
credentials: 'include',
|
||||
});
|
||||
return await r.text();
|
||||
});
|
||||
const data = JSON.parse(resp);
|
||||
if (data?.data?.nickname) {
|
||||
session.status = 'logged_in';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the login status for a session.
|
||||
*/
|
||||
export async function getQrLoginStatus(sessionId: string): Promise<{
|
||||
status: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
autoUpdated?: boolean;
|
||||
updatedConfigId?: number;
|
||||
}> {
|
||||
const session = SESSIONS.get(sessionId);
|
||||
if (!session) {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||
session.status = 'expired';
|
||||
cleanupSession(sessionId);
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
if (session.status === 'logged_in') {
|
||||
// Try to get nickname too
|
||||
let nickname = '';
|
||||
try {
|
||||
const resp = await session.page.evaluate(async () => {
|
||||
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||
credentials: 'include',
|
||||
});
|
||||
return await r.text();
|
||||
});
|
||||
const data = JSON.parse(resp);
|
||||
nickname = data?.data?.nickname || '';
|
||||
} catch {}
|
||||
|
||||
// Fetch capacity info from within the browser context (has full JS signing)
|
||||
let storageTotal = '';
|
||||
let storageUsed = '';
|
||||
try {
|
||||
const capResp = await session.page.evaluate(async () => {
|
||||
const r = await fetch(
|
||||
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
return await r.text();
|
||||
});
|
||||
const capData = JSON.parse(capResp);
|
||||
if (capData.status === 200 && capData.data?.capacity_summary) {
|
||||
const summary = capData.data.capacity_summary;
|
||||
const total = summary.sum_capacity || 0;
|
||||
storageTotal = formatBytes(total);
|
||||
storageUsed = '0 B'; // capacity/detail doesn't return used_size
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Build full cookie string including httpOnly cookies
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
// Extract __uid from cookie for duplicate detection
|
||||
const uidMatch = cookieStr.match(/(?<!\\w)__uid=([a-f0-9-]+)/);
|
||||
let autoUpdated = false;
|
||||
let updatedConfigId: number | undefined;
|
||||
|
||||
if (uidMatch) {
|
||||
const uid = uidMatch[1];
|
||||
try {
|
||||
const db = getDb();
|
||||
const existing = db.prepare(
|
||||
`SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?`
|
||||
).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
// Same account → auto-update cookie with capacity info too
|
||||
const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?`
|
||||
).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id);
|
||||
autoUpdated = true;
|
||||
updatedConfigId = existing.id;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Clean up session after successful login
|
||||
cleanupSession(sessionId);
|
||||
|
||||
return {
|
||||
status: 'logged_in',
|
||||
cookie: cookieStr,
|
||||
nickname,
|
||||
storage_used: storageUsed,
|
||||
storage_total: storageTotal,
|
||||
autoUpdated,
|
||||
updatedConfigId,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: session.status };
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a QR login session.
|
||||
*/
|
||||
export async function cancelQrLogin(sessionId: string): Promise<void> {
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
27
packages/backend/src/utils/response.ts
Executable file
27
packages/backend/src/utils/response.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
/**
|
||||
* Send a successful JSON response.
|
||||
* Uses the standard format: { error: null, data }
|
||||
*/
|
||||
export function sendSuccess<T>(res: Response, data: T, status: number = 200) {
|
||||
res.status(status).json({ error: null, ...(data as any) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error JSON response.
|
||||
* Uses the standard format: { error: string }
|
||||
* All routes should use this for consistent frontend error handling.
|
||||
*/
|
||||
export function sendError(res: Response, status: number, message: string) {
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a 500 error response from a caught exception.
|
||||
* Prevents leaking stack traces in production.
|
||||
*/
|
||||
export function sendServerError(res: Response, err: unknown, fallbackMessage: string = 'Internal server error') {
|
||||
const message = err instanceof Error ? err.message : fallbackMessage;
|
||||
res.status(500).json({ error: message || fallbackMessage });
|
||||
}
|
||||
116
packages/backend/src/utils/time.ts
Executable file
116
packages/backend/src/utils/time.ts
Executable file
@@ -0,0 +1,116 @@
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
/**
|
||||
* Get the current timezone from DB config, with fallback.
|
||||
*/
|
||||
export function getTimezone(): string {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get('timezone') as any;
|
||||
return row?.value || 'Asia/Shanghai';
|
||||
} catch {
|
||||
return 'Asia/Shanghai';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current local time as an ISO 8601 string with timezone offset.
|
||||
* Example: "2026-05-02T14:32:23+08:00"
|
||||
* This format is reliably parsed by JavaScript Date() in all browsers.
|
||||
*/
|
||||
export function localTimestamp(): string {
|
||||
const tz = getTimezone();
|
||||
const now = new Date();
|
||||
// Format as local time string with timezone offset
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||
const dateStr = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
|
||||
// Calculate timezone offset for the configured timezone
|
||||
// Use getTimezoneOffset difference between UTC and the target timezone
|
||||
const utcMs = now.getTime();
|
||||
const localStr = dateStr.replace('T', ' ');
|
||||
// Get offset in minutes for the configured timezone
|
||||
const formatter = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'longOffset',
|
||||
});
|
||||
const tzName = formatter.formatToParts(now).find(p => p.type === 'timeZoneName')?.value || '';
|
||||
// tzName is like "GMT+8" or "GMT-05:00"
|
||||
let offset = '+00:00';
|
||||
if (tzName) {
|
||||
const match = tzName.match(/GMT([+-])(\d+)(?::(\d+))?/);
|
||||
if (match) {
|
||||
const sign = match[1];
|
||||
const hours = match[2].padStart(2, '0');
|
||||
const mins = (match[3] || '00').padStart(2, '0');
|
||||
offset = `${sign}${hours}:${mins}`;
|
||||
}
|
||||
}
|
||||
return dateStr + offset;
|
||||
} catch {
|
||||
// Fallback: use UTC
|
||||
return now.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns today's (or given date's) date string in the configured timezone.
|
||||
* Example: "2026-05-04"
|
||||
*/
|
||||
export function formatLocalDate(date?: Date): string {
|
||||
const tz = getTimezone();
|
||||
const d = date || new Date();
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(d);
|
||||
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
} catch {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape SQL LIKE wildcards (% and _) in user input to prevent unintended pattern matching.
|
||||
*/
|
||||
export function escapeLike(str: string): string {
|
||||
return str.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a local datetime string in the configured timezone.
|
||||
* Example: "2026-05-04 14:32:23" — intentionally space-separated (no T) for DB compatibility.
|
||||
*/
|
||||
export function formatLocalDateTime(date?: Date): string {
|
||||
const tz = getTimezone();
|
||||
const d = date || new Date();
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
|
||||
} catch {
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const da = String(d.getDate()).padStart(2, '0');
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user