538 lines
16 KiB
TypeScript
Executable File
538 lines
16 KiB
TypeScript
Executable File
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 {}
|
|
try {
|
|
session.page.context().browser()?.close().catch(() => {});
|
|
} catch {}
|
|
SESSIONS.delete(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract QR code URL from the Quark login page canvas using jsQR.
|
|
*/
|
|
async function extractQrUrl(page: Page): Promise<string> {
|
|
const selectors = [
|
|
'canvas:not(#react-qrcode-logo)',
|
|
'.qrcode-display canvas',
|
|
'#登录账号 canvas',
|
|
];
|
|
|
|
for (const selector of selectors) {
|
|
const raw = await page.evaluate(`(sel => {
|
|
const canvas = document.querySelector(sel);
|
|
if (!canvas || !canvas.getContext) return null;
|
|
try {
|
|
var ctx = canvas.getContext('2d');
|
|
if (!ctx) return null;
|
|
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
return {
|
|
w: canvas.width,
|
|
h: canvas.height,
|
|
data: Array.from(imageData.data)
|
|
};
|
|
} catch(e) { return null; }
|
|
})('${selector}')`).catch(() => null) as { w: number; h: number; data: number[] } | null;
|
|
|
|
if (raw && raw.data && raw.data.length > 0) {
|
|
const code = jsQR(new Uint8ClampedArray(raw.data), raw.w, raw.h);
|
|
if (code && code.data) {
|
|
if (code.data.includes('su.quark.cn')) {
|
|
return code.data;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: scan all canvases
|
|
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');
|
|
}
|
|
|
|
let bestUrl = '';
|
|
for (const canvas of raw) {
|
|
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
|
|
if (code && code.data) {
|
|
if (code.data.includes('su.quark.cn')) {
|
|
return code.data;
|
|
}
|
|
if (!bestUrl) {
|
|
bestUrl = code.data;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestUrl) {
|
|
return bestUrl;
|
|
}
|
|
|
|
throw new Error('无法解析二维码内容');
|
|
}
|
|
|
|
/**
|
|
* Test if a cookie string can actually access Quark API.
|
|
* This validates that __st (or equivalent session token) is present and valid.
|
|
*/
|
|
async function isCookieValid(cookieStr: string): Promise<boolean> {
|
|
try {
|
|
const response = await fetch('https://pan.quark.cn/account/info', {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Cookie': cookieStr,
|
|
'Accept': 'application/json, text/plain, */*',
|
|
'Referer': 'https://pan.quark.cn/',
|
|
'Origin': 'https://pan.quark.cn',
|
|
},
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!response.ok) return false;
|
|
const data = await response.json() as any;
|
|
return data?.status === 200 && data?.data?.nickname ? true : false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if cookies contain __st or equivalent session token.
|
|
* __st is the critical token needed for API access.
|
|
* Also accepts __pus, __ktd, pus as valid session indicators.
|
|
*/
|
|
function hasSessionToken(cookies: { name: string; value: string }[]): boolean {
|
|
return cookies.some(
|
|
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verify cookies by actually calling the Quark API from within the browser context
|
|
* (which has full JS context for signing etc.)
|
|
*/
|
|
async function verifyCookieInBrowser(session: QrSession): Promise<boolean> {
|
|
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);
|
|
return data?.status === 200 && !!data?.data?.nickname;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for __st cookie to appear after login.
|
|
* Keeps checking for up to `timeoutMs` milliseconds.
|
|
*/
|
|
async function waitForStCookie(session: QrSession, timeoutMs: number): Promise<boolean> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const cookies = await session.browserContext.cookies();
|
|
if (hasSessionToken(cookies)) {
|
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
|
session.cookieSnapshot = cookieStr;
|
|
return true;
|
|
}
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ==================== Public API ====================
|
|
|
|
/**
|
|
* Start a QR code login session.
|
|
*/
|
|
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 {
|
|
await page.goto('https://pan.quark.cn/', {
|
|
waitUntil: 'commit',
|
|
timeout: 30000,
|
|
});
|
|
|
|
await page.waitForSelector('canvas', { timeout: 15000 });
|
|
await page.waitForTimeout(2000);
|
|
|
|
const qrUrl = await extractQrUrl(page);
|
|
|
|
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) {
|
|
try { await browserContext.close(); } catch {}
|
|
try { browser.close().catch(() => {}); } catch {}
|
|
SESSIONS.delete(sessionId);
|
|
throw new Error(`启动扫码登录失败: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll login status in background.
|
|
* FIXED: Now specifically waits for __st cookie (the critical session token).
|
|
*/
|
|
async function pollLoginStatus(session: QrSession) {
|
|
let foundLogin = false;
|
|
|
|
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('; ');
|
|
|
|
// Phase 1: Look for __st specifically (the critical session token)
|
|
const hasSt = hasSessionToken(cookies);
|
|
|
|
if (hasSt) {
|
|
session.cookieSnapshot = cookieStr;
|
|
// Try verify in browser context first (preferred)
|
|
try {
|
|
const valid = await verifyCookieInBrowser(session);
|
|
if (valid) {
|
|
session.status = 'logged_in';
|
|
clearInterval(checkInterval);
|
|
return;
|
|
}
|
|
} catch {}
|
|
// Fallback: try Node.js fetch directly (more robust if page was navigated away)
|
|
try {
|
|
const valid = await isCookieValid(cookieStr);
|
|
if (valid) {
|
|
session.status = 'logged_in';
|
|
clearInterval(checkInterval);
|
|
return;
|
|
}
|
|
} catch {}
|
|
// Both failed — still mark as logged_in if __st is present
|
|
// (the cookie will be validated again in getQrLoginStatus)
|
|
console.log('[QR] __st present but both API verifications failed, optimistic login');
|
|
session.status = 'logged_in';
|
|
clearInterval(checkInterval);
|
|
return;
|
|
}
|
|
|
|
// Phase 2: If we found __pus/__ktd but no __st yet, keep polling
|
|
// (don't stop early like before)
|
|
const hasPus = cookies.some(
|
|
c => (c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
|
);
|
|
|
|
if (hasPus && !foundLogin) {
|
|
foundLogin = true;
|
|
console.log('[QR] QR scanned, waiting for __st cookie...');
|
|
session.cookieSnapshot = cookieStr;
|
|
// Don't mark as logged_in — keep polling for __st
|
|
}
|
|
|
|
// 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('; ');
|
|
|
|
if (hasSessionToken(cookies)) {
|
|
session.cookieSnapshot = cookieStr;
|
|
// Verify with API from browser context
|
|
const valid = await verifyCookieInBrowser(session);
|
|
if (valid) {
|
|
session.status = 'logged_in';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fallback: check if we can get account info
|
|
if (cookies.length > 3) {
|
|
session.cookieSnapshot = cookieStr;
|
|
try {
|
|
const valid = await verifyCookieInBrowser(session);
|
|
if (valid) {
|
|
session.status = 'logged_in';
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Get the login status for a session.
|
|
* FIXED: Now validates the cookie works before returning.
|
|
*/
|
|
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
|
|
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';
|
|
}
|
|
} catch {}
|
|
|
|
// Build full cookie string
|
|
const cookies = await session.browserContext.cookies();
|
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
|
|
|
// Extract __uid for duplicate detection
|
|
const uidMatch = cookieStr.match(/__uid=([a-zA-Z0-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) {
|
|
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 {}
|
|
}
|
|
|
|
// Validate the cookie actually works with API before returning
|
|
const cookieValid = await isCookieValid(cookieStr);
|
|
if (!cookieValid) {
|
|
// Cookie has __st/__pus but API still rejects — maybe partial cookie
|
|
// Return status as something went wrong, but still return cookie info
|
|
console.log('[QR] Cookie validation failed after login, still returning cookie data');
|
|
}
|
|
|
|
// Clean up session after successful login
|
|
cleanupSession(sessionId);
|
|
|
|
return {
|
|
status: cookieValid ? 'logged_in' : '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);
|
|
}
|