import { getDb } from '../database/database'; import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time'; import { encrypt, decrypt, isEncrypted } from '../utils/crypto'; // ── Background Used-Space Calculation ────────────────────────── /** * Fire-and-forget: recursively calculate used space for a quark drive * and update the database when done. */ async function calculateUsedSpaceAsync(cookie: string, configId: number): Promise { const { calculateUsedSpace } = require('./drivers/quark-cleanup'); const usedBytes = await calculateUsedSpace(cookie); if (usedBytes > 0) { const usedFormatted = usedBytes >= 1024 ** 4 ? (usedBytes / 1024 ** 4).toFixed(1) + ' TB' : usedBytes >= 1024 ** 3 ? (usedBytes / 1024 ** 3).toFixed(1) + ' GB' : (usedBytes / 1024 ** 2).toFixed(1) + ' MB'; const db = getDb(); db.prepare( `UPDATE cloud_configs SET storage_used = ?, updated_at = ? WHERE id = ?` ).run(usedFormatted, localTimestamp(), configId); console.log(`[UsedSpace] Updated config #${configId}: used=${usedFormatted}`); } } export interface CloudConfig { id: number; cloud_type: string; cookie?: string; nickname?: string; is_active: number; promotion_account?: string; is_transfer_enabled: number; is_primary: number; storage_used?: string; storage_total?: string; checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped' last_checkin_at?: string; checkin_message?: string; consecutive_failures: number; last_used_at?: string; total_saves: number; created_at: string; updated_at: string; verification_status?: string; cloud_type_uid?: string; } // ── Cookie Encryption Helper ────────────────────────────────────── /** Decrypt cookie. Handles legacy plaintext data transparently. */ function decryptCookie(encrypted: string | null | undefined): string { if (!encrypted) return ''; // If already plaintext (legacy data), return as-is if (!isEncrypted(encrypted)) return encrypted; return decrypt(encrypted); } /** * Extract Quark __uid from cookie string. * Used for dedup: same cloud_type + same __uid = same account. */ function extractQuarkUid(cookie: string): string | null { const match = cookie.match(/(?:^|;\s*)__uid=([^;]+)/); return match ? match[1] : null; } // ── Config CRUD ────────────────────────────────────────────────── export function getCloudConfigs(): CloudConfig[] { const db = getDb(); return db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total, cloud_type_uid, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at, verification_status FROM cloud_configs ORDER BY id ASC` ).all() as CloudConfig[]; } export function getAvailableClouds(): CloudConfig[] { const db = getDb(); return db.prepare( `SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total, cloud_type_uid, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC` ).all() as CloudConfig[]; } /** Returns the first active config matching the given cloud type. */ export function getCloudConfigByType(cloudType: string): CloudConfig | undefined { const db = getDb(); return db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total, cloud_type_uid, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at, verification_status FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 ORDER BY id ASC LIMIT 1` ).get(cloudType) as CloudConfig | undefined; } export function getCloudConfigById(id: number): CloudConfig | undefined { const db = getDb(); return db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total, cloud_type_uid, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at, verification_status FROM cloud_configs WHERE id = ?` ).get(id) as CloudConfig | undefined; } /** Returns all active cloud configs (used by save flow for cloud type switching). */ export function getActiveCloudConfigs(): CloudConfig[] { const db = getDb(); return db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total, cloud_type_uid, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at FROM cloud_configs WHERE is_active = 1 ORDER BY cloud_type ASC, id ASC` ).all() as CloudConfig[]; } /** * Toggle the is_primary flag for a cloud config. * Enforces max 2 primary accounts per cloud type. */ export function togglePrimary(id: number, setPrimary: boolean): CloudConfig { const db = getDb(); const config = getCloudConfigById(id); if (!config) throw new Error(`Cloud config ${id} not found`); if (setPrimary) { // Check how many primary accounts already exist for this cloud type const primaryCount = db.prepare( `SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND id != ?` ).get(config.cloud_type, id) as { c: number }; if (primaryCount.c >= 2) { throw new Error(`同类型网盘最多只能设置 2 个默认账号(已存在 ${primaryCount.c} 个)`); } } db.prepare(`UPDATE cloud_configs SET is_primary = ?, updated_at = datetime('now', 'localtime') WHERE id = ?`) .run(setPrimary ? 1 : 0, id); return getCloudConfigById(id)!; } export function saveCloudConfig(data: { id?: number; cloud_type: string; cookie?: string; nickname?: string; is_active?: number; promotion_account?: string; is_transfer_enabled?: number; storage_used?: string; storage_total?: string; }): CloudConfig { const db = getDb(); // Encrypt cookie before storing const encryptedCookie = data.cookie ? encrypt(data.cookie) : null; // Extract cloud_type_uid from cookie (Quark __uid) let cloudTypeUid: string | null = null; if (data.cookie) { cloudTypeUid = extractQuarkUid(data.cookie); } if (data.id) { // Update by ID — always succeeds db.prepare( `UPDATE cloud_configs SET cloud_type = COALESCE(?, cloud_type), cookie = COALESCE(?, cookie), nickname = COALESCE(?, nickname), is_active = COALESCE(?, is_active), promotion_account = COALESCE(?, promotion_account), is_transfer_enabled = COALESCE(?, is_transfer_enabled), storage_used = COALESCE(?, storage_used), storage_total = COALESCE(?, storage_total), cloud_type_uid = COALESCE(?, cloud_type_uid), consecutive_failures = 0, updated_at = ? WHERE id = ?` ).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id); } else { // Try to find existing config by cloud_type + cloud_type_uid let existing: any = null; if (cloudTypeUid) { existing = db.prepare( `SELECT id FROM cloud_configs WHERE cloud_type = ? AND cloud_type_uid = ? LIMIT 1` ).get(data.cloud_type, cloudTypeUid); } // Fallback: match by cloud_type alone (legacy records without cloud_type_uid) if (!existing) { existing = db.prepare( 'SELECT id FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1' ).get(data.cloud_type) as any; } if (existing) { db.prepare( `UPDATE cloud_configs SET cookie = COALESCE(?, cookie), nickname = COALESCE(?, nickname), is_active = COALESCE(?, is_active), promotion_account = COALESCE(?, promotion_account), is_transfer_enabled = COALESCE(?, is_transfer_enabled), storage_used = COALESCE(?, storage_used), storage_total = COALESCE(?, storage_total), cloud_type_uid = COALESCE(?, cloud_type_uid), consecutive_failures = 0, updated_at = ? WHERE id = ?` ).run(encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id); // Re-read savedId for return const savedId = existing.id; return db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total, cloud_type_uid, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at FROM cloud_configs WHERE id = ?` ).get(savedId) as CloudConfig; } // No existing config found — insert new db.prepare( 'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' ).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null); } const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id; return db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total, cloud_type_uid, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at FROM cloud_configs WHERE id = ?` ).get(savedId) as CloudConfig; } export function deleteCloudConfig(id: number): boolean { const db = getDb(); const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id); return result.changes > 0; } // ── Cookie Validation ──────────────────────────────────────────── async function fetchQuarkNickname(cookie: string): Promise { const MAX_RETRIES = 2; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { const response = await fetch('https://pan.quark.cn/account/info?fr=pc&platform=pc', { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch', 'Cookie': cookie, 'Accept': 'application/json', 'Referer': 'https://pan.quark.cn/', }, signal: AbortSignal.timeout(15000), }); if (!response.ok) return null; const data = await response.json() as any; if (data?.data?.nickname) return data.data.nickname; } catch { if (attempt < MAX_RETRIES) { await new Promise(r => setTimeout(r, 1500)); continue; } } } return null; } export async function testCloudConnection(id: number): Promise<{ success: boolean; message: string; nickname?: string; storage_used?: string; storage_total?: string; }> { const config = getCloudConfigById(id); if (!config) { return { success: false, message: 'Cloud config not found' }; } if (!config.cookie) { return { success: false, message: 'Cookie not configured' }; } try { let valid = false; let nickname = ''; let storageUsed = config.storage_used || ''; let storageTotal = config.storage_total || ''; if (config.cloud_type === 'baidu') { const { BaiduDriver } = require('./drivers/baidu.driver'); const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname }); valid = await driver.validate(); if (valid) { const info = await driver.getUserInfo(); if (info) { nickname = config.nickname || info.nickname || '百度网盘'; const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB'; storageUsed = fmt(info.usedBytes); storageTotal = fmt(info.totalBytes); } } } else { const decodedCookie = decrypt(config.cookie); const { QuarkDriver } = require('./drivers/quark.driver'); const driver = new QuarkDriver({ cookie: decodedCookie, nickname: config.nickname }); valid = await driver.validate(); if (valid) { nickname = config.nickname || (await fetchQuarkNickname(decodedCookie)) || '夸克网盘'; const storage = await driver.getStorageInfoQuick(config.storage_total); storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || ''); storageUsed = (storage.used && storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || ''); } } const db = getDb(); if (!valid) { db.prepare( `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` ).run(localTimestamp(), id); return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' }; } db.prepare( `UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?` ).run(nickname, storageTotal, storageUsed, localTimestamp(), id); // Fire-and-forget: recalculate used space in background (slow for big drives) if (config.cloud_type === 'quark') { calculateUsedSpaceAsync(decrypt(config.cookie), id).catch(err => console.error(`[UsedSpace] Background calc failed for #${id}:`, err.message)); } return { success: true, message: '连接成功', nickname, storage_used: storageUsed, storage_total: storageTotal, }; } catch (err: any) { try { const db = getDb(); db.prepare( `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` ).run(localTimestamp(), id); } catch {} return { success: false, message: `连接失败:${err.message || '未知错误'}` }; } } export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{ success: boolean; message: string; nickname?: string; storage_used?: string; storage_total?: string; }> { try { const { QuarkDriver } = require('./drivers/quark.driver'); const driver = new QuarkDriver({ cookie, nickname: '' }); const valid = await driver.validate(); if (!valid) { return { success: false, message: '连接失败:Cookie 无效或已过期' }; } const nickname = (await fetchQuarkNickname(cookie)) || cloudType; // getStorageInfo may timeout from overseas servers, don't fail if it does let storage: { used: string; total: string } = { used: '-', total: '-' }; try { const s = await driver.getStorageInfoQuick(); if (s) { storage = { used: s.used || '-', total: s.total || '-' }; } } catch { // storage info is optional } return { success: true, message: '连接成功', nickname, storage_used: storage.used, storage_total: storage.total, }; } catch (err: any) { return { success: false, message: `连接失败:${err.message || '未知错误'}` }; } } // ── Unified Credential Validation ───────────────────────────────── export interface CredentialValidationResult { valid: boolean; config?: CloudConfig; errorCode?: string; message: string; } /** * Get and validate a credential for the given cloud type. * * This is the unified entry point for all save/transfer operations. * It handles: * 1. Finding an active config with < 5 consecutive failures (round-robin) * 2. Validating cookie freshness via driver.validate() * 3. Returning structured result with error codes * * Reference: search-ucmao get_and_validate_credential() pattern. */ export async function getAndValidateCredential(cloudType: string, ipAddress?: string): Promise { const db = getDb(); let config: CloudConfig | undefined; if (!ipAddress) { // No IP info — fallback to simple LUR config = db.prepare( `SELECT * FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 AND consecutive_failures < 5 ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST LIMIT 1` ).get(cloudType) as CloudConfig | undefined; } else { // Get today's date in Shanghai time const today = (() => { const now = new Date(); const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' })); return shanghai.toISOString().slice(0, 10); })(); // Count how many times this IP has saved today for this cloud type const ipCountRow = db.prepare( `SELECT COALESCE(SUM(save_count), 0) as total FROM ip_daily_save_counts WHERE ip_address = ? AND date = ? AND cloud_type = ?` ).get(ipAddress, today, cloudType) as { total: number }; const ipTodayCount = ipCountRow?.total || 0; // How many primary accounts does this cloud type have? const primaryCountRow = db.prepare( `SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND is_active = 1` ).get(cloudType) as { c: number }; const primaryCount = primaryCountRow?.c || 0; const primaryThreshold = primaryCount * 2; // Each primary account gets 2 uses per IP if (ipTodayCount < primaryThreshold) { // First N saves (primaryCount × 2) — use primary accounts (is_primary=1), fallback to any healthy config = db.prepare( `SELECT * FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 AND consecutive_failures < 5 ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST LIMIT 1` ).get(cloudType) as CloudConfig | undefined; } else { // After primary threshold — exclude accounts this IP has already used today, // fall back to other available accounts round-robin const usedConfigIds = db.prepare( `SELECT DISTINCT config_id FROM ip_daily_save_counts WHERE ip_address = ? AND date = ? AND cloud_type = ? ORDER BY config_id` ).all(ipAddress, today, cloudType) as { config_id: number }[]; const usedIds = usedConfigIds.map(r => r.config_id); const placeholders = usedIds.length > 0 ? usedIds.map(() => '?').join(',') : '-1'; config = db.prepare( `SELECT * FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 AND consecutive_failures < 5 AND id NOT IN (${placeholders}) ORDER BY last_used_at ASC NULLS FIRST LIMIT 1` ).get(cloudType, ...usedIds) as CloudConfig | undefined; // If all accounts have been used by this IP, fall back to primary if (!config) { config = db.prepare( `SELECT * FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 AND consecutive_failures < 5 ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST LIMIT 1` ).get(cloudType) as CloudConfig | undefined; } } } if (!config) { return { valid: false, errorCode: 'NO_AVAILABLE_DRIVE', message: `Cloud type "${cloudType}" is not configured or no available drives`, }; } if (!config.cookie) { return { valid: false, errorCode: 'COOKIE_MISSING', message: `Cookie not configured for ${cloudType} drive #${config.id}`, }; } try { // Decrypt cookie before validation const decryptedCookie = decryptCookie(config.cookie); if (!decryptedCookie) { return { valid: false, errorCode: 'COOKIE_MISSING', message: `Cookie not configured for ${cloudType} drive #${config.id}`, }; } let cookieValid = false; if (cloudType === 'baidu') { const { BaiduDriver } = require('./drivers/baidu.driver'); const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: config.nickname }); cookieValid = await driver.validate(); } else { const { QuarkDriver } = require('./drivers/quark.driver'); const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: config.nickname }); cookieValid = await driver.validate(); } if (!cookieValid) { db.prepare( `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` ).run(localTimestamp(), config.id); return { valid: false, errorCode: 'COOKIE_EXPIRED', message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`, }; } // Track IP daily usage count (if ipAddress provided) if (ipAddress && config) { const today = (() => { const now = new Date(); const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' })); return shanghai.toISOString().slice(0, 10); })(); db.prepare( `INSERT INTO ip_daily_save_counts (ip_address, date, cloud_type, config_id, save_count) VALUES (?, ?, ?, ?, 1) ON CONFLICT(ip_address, date, cloud_type, config_id) DO UPDATE SET save_count = save_count + 1` ).run(ipAddress, today, cloudType, config.id); } return { valid: true, config: { ...config, cookie: decryptedCookie }, message: 'ok', }; } catch (err: any) { return { valid: false, errorCode: 'VALIDATION_ERROR', message: `Credential validation failed: ${err.message}`, }; } }