v0.2.7: 修复Redis连接 + 启动管理后台

- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
2026-05-17 02:22:18 +08:00
commit 83cbfaf03f
164 changed files with 25195 additions and 0 deletions

View 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);
}

View 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
source_clean/src/utils/time.ts Executable file
View 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}`;
}
}