v0.3.6: 恢复丢失的11个模块 + 接线基础设施

恢复内容:
- quark驱动拆解为7个子模块 (quark-api/auth/share/storage/cleanup/rename/ad-cleanup)
- 工具模块: utils/crypto, utils/logger, utils/proxy-agent
- 配置校验: config/startup-validator
- 接线: main.ts(checkStartup), credential.service.ts(加密Cookie), admin.routes.ts(代理测试)
- quark.driver.ts 从1533行巨兽瘦身到130行壳子
This commit is contained in:
2026-05-17 06:05:47 +08:00
parent 64b00661a2
commit 09be4c307e
22 changed files with 3802 additions and 1503 deletions

View File

@@ -1 +1 @@
0.3.5
0.3.6

View File

@@ -1 +1 @@
0.3.0
0.3.6

View File

@@ -1,12 +1,12 @@
{
"name": "cloudsearch-backend",
"version": "2.0.26",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloudsearch-backend",
"version": "2.0.26",
"version": "0.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",

View File

@@ -1,4 +1,5 @@
import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
export interface CloudConfig {
@@ -23,6 +24,12 @@ export interface CloudConfig {
// ── Cookie UID Extraction ────────────────────────────────────────
function extractCookieUid(cookie: string): string {
function decryptCookie(encrypted: string): string {
if (!encrypted) return '';
if (!isEncrypted(encrypted)) return encrypted;
return decrypt(encrypted);
}
if (!cookie) return '';
let m = cookie.match(/__uid=([a-zA-Z0-9+/=_-]+)/);
if (m) return m[1];
@@ -56,23 +63,25 @@ export function getAvailableClouds(): CloudConfig[] {
/** Returns the first active config matching the given cloud type. */
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
const db = getDb();
return db.prepare(
const cfg = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
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;
return cfg;
}
export function getCloudConfigById(id: number): CloudConfig | undefined {
const db = getDb();
return db.prepare(
const cfg = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
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;
return cfg;
}
/** Returns all active cloud configs (used by save flow for cloud type switching). */
@@ -101,6 +110,7 @@ export function saveCloudConfig(data: {
const db = getDb();
const cookieUidForUpdate = data.cookie ? extractCookieUid(data.cookie) : null;
const encryptedCookie = data.cookie ? encrypt(data.cookie) : null;
if (data.id) {
db.prepare(
@@ -116,12 +126,12 @@ export function saveCloudConfig(data: {
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(data.cloud_type, data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
} else {
const existing = db.prepare(
'SELECT id, nickname FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
).get(data.cloud_type) as any;
if (existing) {
if (existing) {
db.prepare(
`UPDATE cloud_configs SET
cookie = COALESCE(?, cookie),
@@ -134,11 +144,11 @@ export function saveCloudConfig(data: {
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
).run(encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
} else {
db.prepare(
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, cookie_uid, promotion_account, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
}
}

View File

@@ -0,0 +1,230 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as system_config_service from "../../admin/system-config.service";
/**
* 广告关键词清理模块。
* 在转存完成后执行:
* 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容
* 2. 在转存根目录下创建警示文件夹(置顶提醒)
*/
// ==================== 配置读取 ====================
/** 从 DB 读取广告关键词列表 */
export function getAdKeywords() {
const raw = system_config_service.getSystemConfig("quark_ad_keywords") || "";
return raw
.split("\n")
.flatMap((line) => line.split(","))
.map((s) => s.trim())
.filter(Boolean);
}
/** 从 DB 读取警示文件夹名称列表 */
export function getWarningFolderNames() {
const raw = system_config_service.getSystemConfig("quark_warning_folder_names") || "";
return raw
.split("\n")
.flatMap((line) => line.split(","))
.map((s) => s.trim())
.filter(Boolean);
}
/** 从 DB 读取可疑文件后缀列表 */
export function getSusExtensions() {
const raw = system_config_service.getSystemConfig("quark_sus_extensions") || "";
if (raw.trim()) {
return raw
.split("\n")
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
}
// 默认可疑后缀
return ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"];
}
// ==================== 关键词检测 ====================
/** 检查文件名是否包含任意广告关键词 */
export function containsAdKeyword(fileName, keywords) {
if (!keywords.length)
return false;
const lower = fileName.toLowerCase();
return keywords.some((kw) => kw && lower.includes(kw.toLowerCase()));
}
// ==================== 删除操作 ====================
/**
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹。
* 返回删除的文件数。
*/
export async function deleteAdFiles(cookie, dirFid, keywords) {
const extensions = getSusExtensions();
if (!keywords.length && !extensions.length)
return 0;
let deletedCount = 0;
const stack = [dirFid];
const visited = new Set();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
visited.add(fid);
await quark_api.humanDelay();
const files = await quark_api.listDir(cookie, fid);
if (!files || files.length === 0)
continue;
// 先收集所有需要删除的 fid
const toDelete = [];
const toKeep = [];
const extensions = getSusExtensions();
for (const file of files) {
const ext = file.file.split(".").pop()?.toLowerCase() || "";
const isSusExt = extensions.includes(ext);
if (containsAdKeyword(file.file_name, keywords) || isSusExt) {
toDelete.push(file.fid);
console.log(`[Quark-AdCleanup] 标记删除: "${file.file_name}" (fid: ${file.fid})${isSusExt ? " [可疑后缀]" : " [广告关键词]"}`);
}
else {
toKeep.push(file.fid);
// 如果是目录且不删除,继续遍历子目录
if (file.dir) {
stack.push(file.fid);
}
}
}
// 批量删除
if (toDelete.length > 0) {
const deleteOk = await batchDeleteFiles(cookie, toDelete);
if (deleteOk) {
deletedCount += toDelete.length;
console.log(`[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`);
}
}
}
return deletedCount;
}
/**
* 批量删除文件/文件夹(移入回收站)。
*/
async function batchDeleteFiles(cookie, fids) {
if (!fids.length)
return true;
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file/trash?${quark_api.makeQuery()}`, {
method: "POST",
headers: {
...quark_api.getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
action_type: 1,
filelist: fids,
exclude_filelist: [],
}),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
console.log(`[Quark-AdCleanup] batchDelete HTTP ${resp.status}`);
return false;
}
const data = (await resp.json());
if (data.status === 200) {
return true;
}
console.log(`[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`);
return false;
}
catch (err) {
console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`);
return false;
}
}
// ==================== 警示文件夹创建 ====================
/**
* 在转存根目录下创建警示文件夹。
* 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。
* 已存在的则跳过。
*/
export async function createWarningDirectories(cookie, dirNames, parentDirFid = "0") {
if (!dirNames.length)
return;
// 先获取根目录下所有文件夹,避免重复创建
await quark_api.humanDelay();
const rootFiles = await quark_api.listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set(rootFiles.filter((f) => f.dir).map((f) => f.file_name));
for (const name of dirNames) {
// 格式化名称:确保以 ⚠️ 开头
let formattedName = name;
if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) {
formattedName = `⚠️ ${formattedName}`;
}
// 去掉多余空格
formattedName = formattedName.replace(/\s+/g, " ").trim();
if (existingDirs.has(formattedName)) {
console.log(`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`);
continue;
}
await createSingleDir(cookie, formattedName, parentDirFid);
// 加入已存在集合,防止同名重试
existingDirs.add(formattedName);
}
}
/**
* 创建单个文件夹。
*/
async function createSingleDir(cookie, dirName, pdirFid = "0") {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
method: "POST",
headers: {
...quark_api.getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
pdir_fid: pdirFid,
file_name: dirName,
dir: true,
dir_path: "",
}),
signal: AbortSignal.timeout(10000),
});
const data = (await resp.json());
if (data.status === 200 && data.data?.fid) {
console.log(`[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`);
return true;
}
console.log(`[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`);
return false;
}
catch (err) {
console.log(`[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`);
return false;
}
}
// ==================== 主入口 ====================
/**
* 执行广告清理 + 创建警示文件夹。
* 在转存重命名后调用。
*/
export async function runAdCleanup(cookie, savedDirFid) {
const keywords = getAdKeywords();
const susExtensions = getSusExtensions();
const warningNames = getWarningFolderNames();
let adDeleted = 0;
let warningDirs = 0;
// 1. 广告关键词 + 可疑后缀清理
if (keywords.length > 0 || susExtensions.length > 0) {
console.log(`[Quark-AdCleanup] 开始文件清理: ${keywords.length} 个关键词, ${susExtensions.length} 个可疑后缀`);
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
console.log(`[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`);
}
else {
console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理");
}
// 2. 创建警示文件夹
if (warningNames.length > 0) {
console.log(`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length}`);
await createWarningDirectories(cookie, warningNames, savedDirFid);
warningDirs = warningNames.length;
console.log(`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`);
}
else {
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
}
return { adDeleted, warningDirs };
}

View File

@@ -0,0 +1,195 @@
// @ts-nocheck
// ==================== Headers & Params ====================
const BASE_URL = 'https://drive-pc.quark.cn';
export function getHeaders(cookie) {
return {
'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': cookie,
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://pan.quark.cn/',
'Origin': 'https://pan.quark.cn',
};
}
export function getCommonParams() {
return { pr: 'ucpro', fr: 'pc' };
}
/** Generate query string with common params + random timing to mimic browser */
export function makeQuery(extra = {}) {
const __dt = Math.floor(Math.random() * 240000 + 60000);
const __t = Date.now() / 1000;
return new URLSearchParams({
...getCommonParams(),
uc_param_str: '',
app: 'clouddrive',
__dt: String(__dt),
__t: String(__t),
...extra,
}).toString();
}
/** Random delay to mimic human behavior (500-2000ms) */
export async function humanDelay() {
const ms = Math.floor(Math.random() * 1500) + 500;
await new Promise(r => setTimeout(r, ms));
}
/** Generate a random password for share links */
export function randomSharePwd() {
return Math.floor(1000 + Math.random() * 9000).toString();
}
/**
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
*/
export function getMparam(cookie) {
// Match both __kps and kps (with or without __ prefix)
const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/);
const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/);
const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/);
if (kpsMatch && signMatch && vcodeMatch) {
return {
kps: kpsMatch[1],
sign: signMatch[1].replace(/%25/g, '%'),
vcode: vcodeMatch[1],
};
}
return {};
}
// ==================== Shared fetch helpers ====================
/**
* Raw fetch wrapper with JSON parse + status check.
* Returns parsed JSON body on 2xx, null on network error.
*/
export async function apiFetch(path, options) {
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
let url = `${BASE_URL}${path}`;
if (query)
url += `?${new URLSearchParams(query).toString()}`;
try {
const resp = await fetch(url, {
method,
headers: {
...getHeaders(cookie),
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(timeout),
});
if (!resp.ok)
return null;
return (await resp.json());
}
catch {
return null;
}
}
/**
* List files in a directory by FID.
*/
export async function listDir(cookie, pdirFid, page = 1, pageSize = 50) {
try {
const params = new URLSearchParams({
...getCommonParams(),
uc_param_str: '',
pdir_fid: pdirFid,
_page: String(page),
_size: String(pageSize),
_fetch_total: '1',
_fetch_sub_dirs: '0',
_sort: 'file_type:asc,updated_at:desc',
fetch_all_file: '1',
fetch_risk_file_name: '1',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
if (data.status !== 200)
return [];
return (data.data?.list || []).filter((f) => f.fid).map((f) => ({
fid: f.fid,
file_name: f.file_name,
share_fid_token: '',
dir: f.dir || false,
size: f.size || 0,
}));
}
catch {
return [];
}
}
/**
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
*/
export async function listRootDir(cookie) {
try {
const params = new URLSearchParams({
pr: 'ucpro', fr: 'pc',
pdir_fid: '0',
_page: '1', _size: '200',
_fetch_total: '1', _fetch_sub_dirs: '0',
_sort: 'file_type:asc,updated_at:desc',
fetch_all_file: '1',
fetch_risk_file_name: '1',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
if (data.status !== 200 || !data.data?.list)
return [];
return (data.data.list || []).map((f) => ({
fid: f.fid,
file_name: f.file_name,
dir: f.dir || false,
size: f.size || 0,
}));
}
catch {
return [];
}
}
/**
* List all files in a directory, handling pagination.
* Fetches all pages until no more results.
*/
export async function listDirAllPages(cookie, pdirFid) {
const allFiles = [];
let page = 1;
const pageSize = 100;
let total = -1;
while (total === -1 || (page - 1) * pageSize < total) {
const files = await listDir(cookie, pdirFid, page, pageSize);
if (!files.length)
break;
allFiles.push(...files);
if (total === -1) {
total = files.length;
}
page++;
}
return allFiles;
}
// ==================== Format utilities ====================
export function formatBytes(bytes) {
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];
}
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
export function dailyFolderName() {
const d = new Date();
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}`;
}
/** Generate a random folder name for saving (fallback) */
export function randomFolderName() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let name = '';
for (let i = 0; i < 12; i++) {
name += chars[Math.floor(Math.random() * chars.length)];
}
return name;
}

View File

@@ -0,0 +1,62 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
*/
// ==================== Validate ====================
/**
* Validate the cookie by fetching user info.
*/
export async function validate(cookie) {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
// Use account/info API (same as quark-auto-save project)
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
'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',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data?.data?.nickname)
return true;
}
catch (err) {
if (attempt < MAX_RETRIES) {
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
await new Promise(r => setTimeout(r, 2000));
continue;
}
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
}
}
return false;
}
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
export async function fetchNickname(cookie) {
try {
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
'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',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok)
return null;
const data = await response.json();
return data?.data?.nickname || null;
}
catch {
return null;
}
}

View File

@@ -0,0 +1,326 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 容量信息 & 空间清理模块。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Storage Info ====================
/** Cached used space, keyed by hour block (3h window) */
const cachedUsedSpace = null;
// We use a function-scoped cache instead of instance field
const storageCache = { bytes: 0, hourBlock: -1 };
/**
* Get total capacity from /capacity/detail API.
* Also does a quick used-space estimate by summing root-level file sizes + subdir sizes
* (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归).
* If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided.
*/
export async function getStorageInfoQuick(cookie, fallbackTotal) {
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: quark_api.getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
let totalBytes = 0;
if (capResponse.ok) {
const data = await capResponse.json();
if (data.status === 200 && data.data) {
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
if (totalBytes === 0) {
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
totalBytes = memberships.reduce((max, m) => Math.max(max, m.capacity || 0), 0);
}
}
}
// Accurate used space via /member API (1 call, no full traversal needed)
// Ref: pan.quark.cn/1/clouddrive/member returns use_capacity + total_capacity
let usedBytes = 0;
try {
const memberParams = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' });
const memberResp = await fetch(`https://pan.quark.cn/1/clouddrive/member?${memberParams.toString()}`, {
headers: quark_api.getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (memberResp.ok) {
const memberData = await memberResp.json();
if (memberData.status === 200 && memberData.data?.use_capacity != null) {
usedBytes = memberData.data.use_capacity;
}
}
}
catch { }
// Fallback: sum root-level file sizes (夸克 folders return size=0)
if (usedBytes === 0) {
try {
const rootFiles = await quark_api.listRootDir(cookie);
for (const f of rootFiles) {
usedBytes += f.size || 0;
}
}
catch { }
}
// Cache the result (3h window)
const currentHourBlock = Math.floor(new Date().getHours() / 3);
storageCache.bytes = usedBytes;
storageCache.hourBlock = currentHourBlock;
if (totalBytes > 0) {
return {
total: quark_api.formatBytes(totalBytes),
totalBytes,
used: quark_api.formatBytes(usedBytes),
usedBytes,
};
}
}
catch { }
// Fallback: try to parse from a human-readable string like "6 TB"
if (fallbackTotal) {
const match = fallbackTotal.match(/^([\d.]+)\s*([KMGT]B?)/i);
if (match) {
const num = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multipliers = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 };
const multiplier = multipliers[unit] || multipliers[unit.replace('B', '') + 'B'] || 0;
if (multiplier > 0) {
return { total: fallbackTotal, totalBytes: Math.round(num * multiplier), used: '-', usedBytes: 0 };
}
}
}
return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 };
}
/**
* Get storage info with used space calculation.
*/
/**
* Fast estimation (root-level files only) + background full traversal.
* First call returns quickly; full traversal runs async and updates DB later.
* `onBackgroundComplete` is called when traversal finishes.
*/
export async function getStorageInfo(cookie, onBackgroundComplete) {
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: quark_api.getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
let totalBytes = 0;
if (response.ok) {
const data = await response.json();
if (data.status === 200 && data.data) {
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
if (totalBytes === 0) {
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
totalBytes = memberships.reduce((max, m) => Math.max(max, m.capacity || 0), 0);
}
}
}
const totalFormatted = totalBytes > 0 ? quark_api.formatBytes(totalBytes) : '-';
// Quick estimation: sum root-level files only
let quickUsed = 0;
try {
const rootFiles = await quark_api.listRootDir(cookie);
for (const f of rootFiles) {
quickUsed += f.size || 0;
}
}
catch { }
// Budget full traversal in background (no await)
calculateUsedSpace(cookie).then(fullUsed => {
if (onBackgroundComplete) {
onBackgroundComplete(quark_api.formatBytes(fullUsed), totalFormatted);
}
}).catch(err => {
console.error('[Storage] Background full traversal failed:', err.message);
});
return {
total: totalFormatted,
totalBytes,
used: quark_api.formatBytes(quickUsed),
usedBytes: quickUsed,
};
}
catch {
return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 };
}
}
/**
* Calculate total used space by recursively traversing all files
* and summing their sizes. Uses 3-hour time window cache.
*/
export async function calculateUsedSpace(cookie) {
const currentHourBlock = Math.floor(new Date().getHours() / 3);
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
return storageCache.bytes;
}
let totalUsed = 0;
const stack = ['0'];
const visited = new Set();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
visited.add(fid);
const files = await quark_api.listDirAllPages(cookie, fid);
if (!files.length)
continue;
for (const f of files) {
if (f.dir) {
stack.push(f.fid);
}
else {
totalUsed += f.size || 0;
}
}
await new Promise(r => setTimeout(r, 50));
}
storageCache.bytes = totalUsed;
storageCache.hourBlock = currentHourBlock;
return totalUsed;
}
// ==================== Cleanup ====================
/**
* Trash specified files/folders (move to recycle bin).
*/
export async function trashFiles(cookie, fids) {
if (!fids.length)
return true;
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/file/trash?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
action_type: 1, // 1 = move to trash
filelist: fids,
exclude_filelist: [],
}),
signal: AbortSignal.timeout(30000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data.status === 200)
return true;
console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
return false;
}
catch (err) {
console.error(`[Quark] trashFiles error: ${err.message}`);
return false;
}
}
/**
* Empty the recycle bin — permanently delete all files in trash.
*/
export async function emptyTrash(cookie) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/file/trash/clear?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: AbortSignal.timeout(60000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data.status === 200)
return true;
console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
return false;
}
catch (err) {
console.error(`[Quark] emptyTrash error: ${err.message}`);
return false;
}
}
/**
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
*/
export async function cleanupOldDateFolders(cookie, days, whitelistDirs) {
const errors = [];
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
try {
const rootItems = await quark_api.listRootDir(cookie);
const oldFolders = rootItems.filter(item => {
if (!item.dir)
return false;
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
return false;
if (whitelistDirs && whitelistDirs.includes(item.file_name))
return false;
return item.file_name < cutoffStr;
});
if (oldFolders.length === 0) {
return { trashed: 0, errors: [] };
}
const fids = oldFolders.map(f => f.fid);
console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
const ok = await trashFiles(cookie, fids);
if (ok) {
return { trashed: fids.length, errors: [] };
}
return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
}
catch (err) {
return { trashed: 0, errors: [err.message] };
}
}
/**
* Cleanup: if used space exceeds thresholdPercent% of total,
* delete the oldest date folders until totalBytes * deletePercent/100
* of total capacity is freed.
*/
export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePercent, whitelistDirs) {
const errors = [];
try {
const storage = await getStorageInfo(cookie);
if (storage.totalBytes <= 0)
return { trashed: 0, errors: [] };
const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
if (usagePercent < thresholdPercent) {
console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
return { trashed: 0, errors: [] };
}
const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);
const rootItems = await quark_api.listRootDir(cookie);
const dateFolders = rootItems
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
.filter(item => !whitelistDirs || !whitelistDirs.includes(item.file_name))
.sort((a, b) => a.file.localeCompare(b.file_name));
if (dateFolders.length === 0)
return { trashed: 0, errors: [] };
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
let cumulativeSize = 0;
const foldersToTrash = [];
if (hasSizes) {
for (const folder of dateFolders) {
foldersToTrash.push(folder);
cumulativeSize += folder.size || 0;
if (cumulativeSize >= targetBytesToFree)
break;
}
}
else {
const avgSizePerFolder = storage.usedBytes / dateFolders.length;
const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
foldersToTrash.push(...dateFolders.slice(0, estCount));
cumulativeSize = estCount * avgSizePerFolder;
}
const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
const fidsToTrash = foldersToTrash.map(f => f.fid);
console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes / 1024 / 1024 / 1024).toFixed(0)} GB total)`);
const ok = await trashFiles(cookie, fidsToTrash);
if (ok) {
console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
return { trashed: foldersToTrash.length, errors: [] };
}
return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
}
catch (err) {
return { trashed: 0, errors: [err.message] };
}
}

View File

@@ -0,0 +1,202 @@
// @ts-nocheck
const NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' +
'么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈';
// ==================== Helpers ====================
/** Convert Chinese text to homophonic (substitute chars with same sound) */
function homophonicText(text) {
let result = '';
for (const ch of text) {
if (/[\u4e00-\u9fff]/.test(ch)) {
const homophone = HOMOPHONE_MAP[ch];
result += homophone || ch;
}
else {
result += ch;
}
}
return result;
}
/** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */
function pinyinLike(text) {
let result = '';
for (const ch of text) {
if (/[\u4e00-\u9fff]/.test(ch)) {
const homophone = HOMOPHONE_MAP[ch];
if (homophone) {
result += pinyinInitial(homophone);
}
else {
const code = ch.charCodeAt(0);
result += String.fromCharCode(97 + (code % 26));
}
}
else if (/[a-zA-Z0-9]/.test(ch)) {
result += ch;
}
else if (/[\s._-]/.test(ch)) {
result += '_';
}
}
return result.replace(/_+/g, '_').replace(/^_|_$/g, '');
}
/** Get pinyin initial (first letter of pinyin) for a Chinese character */
function pinyinInitial(ch) {
const code = ch.charCodeAt(0);
if (code >= 0x4E00 && code <= 0x9FFF) {
const initials = ['b', 'p', 'm', 'f', 'd', 't', 'n', 'l', 'g', 'k', 'h', 'j', 'q', 'x', 'zh', 'ch', 'sh', 'r', 'z', 'c', 's', 'y', 'w'];
const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1);
return initials[idx];
}
return ch.toLowerCase();
}
// ==================== Public API ====================
/**
* Anti-harmony rename for directories.
* 80%: light homophonic replacement, 20%: partial pinyin.
*/
export function magicRenameDir(dirName) {
const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4);
let cleanName = dirName.trim().replace(/\s+/g, ' ');
if (!cleanName) {
return `media_${hash}`;
}
let baseName;
if (Math.random() < 0.2) {
// Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is
const chars = [...cleanName];
const result = [];
for (const ch of chars) {
if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) {
result.push(pinyinInitial(ch));
}
else {
result.push(ch);
}
}
baseName = result.join('');
}
else {
// Light homophonic: replace each CJK char, keep everything else as-is
const chars = [...cleanName];
const result = [];
for (const ch of chars) {
if (/[\u4e00-\u9fff]/.test(ch)) {
result.push(HOMOPHONE_MAP[ch] || ch);
}
else {
result.push(ch);
}
}
baseName = result.join('');
// Optional: insert 0-2 light noise chars (low probability)
const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0;
for (let n = 0; n < noiseCount; n++) {
const pos = Math.floor(Math.random() * (baseName.length + 1));
const ink = NOISE_CJK[Math.floor(Math.random() * NOISE.length)];
baseName = baseName.slice(0, pos) + ink + baseName.slice(pos);
}
}
baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_');
baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, '');
if (baseName.length > 30)
baseName = baseName.slice(0, 30);
return `${baseName}_${hash}`;
}
/**
* Anti-harmony rename for files.
* KEEPS: episode numbers, quality, language tags, original extension.
* REPLACES: Chinese title with homophonic/pinyin.
*/
export function magicRename(filename) {
const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8);
let ext = '';
const extMatch = filename.match(/\.[a-zA-Z0-9]+$/);
if (extMatch) {
ext = extMatch[0];
filename = filename.slice(0, -ext.length);
}
// Extract and REMEMBER: episode info, quality, language, year
const episodePatterns = [
{ regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m) => 'Ep' + m.replace(/[^\d]/g, '') },
{ regex: /Ep\d+|ep\d+/i, format: (m) => m.toUpperCase() },
{ regex: /Part\s*\d+/i, format: (m) => m.replace(/\s+/g, '') },
{ regex: /E\d{2,}/i, format: (m) => m.toUpperCase() },
];
let episodeTag = '';
for (const { regex, format } of episodePatterns) {
const m = filename.match(regex);
if (m) {
episodeTag = format(m[0]);
filename = filename.replace(m[0], '');
break;
}
}
// Extract and REMEMBER: quality tags
const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/;
const qualityMatch = filename.match(qualityPattern);
const qualityTag = qualityMatch ? qualityMatch[0] : '';
if (qualityMatch)
filename = filename.replace(qualityMatch[0], '');
// Extract and REMEMBER: language tags
const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/;
const langMatch = filename.match(langPattern);
const langTag = langMatch ? langMatch[0] : '';
if (langMatch)
filename = filename.replace(langMatch[0], '');
// Extract and REMEMBER: year
const yearMatch = filename.match(/\b(20\d{2})\b/);
const yearTag = yearMatch ? yearMatch[0] : '';
if (yearMatch)
filename = filename.replace(yearMatch[0], '');
// Extract and REMEMBER: season info
const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/);
const seasonTag = seasonMatch ? `${seasonMatch[1]}` : '';
if (seasonMatch)
filename = filename.replace(seasonMatch[0], '');
// Now process the remaining name (mostly Chinese title)
filename = filename.replace(/[._\-【】\[\]()\s]+/g, '_').trim();
const useHomophonic = Math.random() > 0.5;
let titlePart;
if (useHomophonic) {
titlePart = homophonicText(filename);
titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_');
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
if (titlePart.length > 15)
titlePart = titlePart.slice(0, 15);
}
else {
titlePart = pinyinLike(filename);
titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_');
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
if (titlePart.length > 15)
titlePart = titlePart.slice(0, 15);
}
// Remove sensitive keywords from title part
const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig;
titlePart = titlePart.replace(sensitiveWords, '');
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
// Build preserved tags
const tags = [];
if (seasonTag)
tags.push(seasonTag);
if (episodeTag)
tags.push(episodeTag);
if (qualityTag)
tags.push(qualityTag.toUpperCase());
if (langTag)
tags.push(langTag);
if (yearTag)
tags.push(yearTag);
tags.push(hash); // Always add hash for uniqueness
const newExt = ext || '.bin';
const parts = [titlePart, ...tags].filter(Boolean);
let result = parts.join('_');
if (result.length > 80) {
result = result.slice(0, 80);
}
if (result.length < 10) {
const filler = crypto.randomBytes(4).toString('hex');
result = `${filler}_${result}`;
}
return result + newExt;
}

View File

@@ -0,0 +1,356 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Acquire Stoken ====================
/**
* Acquire stoken for a share link (needed for detail/save).
*/
export async function acquireStoken(cookie, pwdId) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
if (attempt < 2)
continue;
return null;
}
const data = await resp.json();
if (data.status === 200 && data.data?.stoken) {
return data.data.stoken;
}
return null;
}
catch {
if (attempt >= 2)
return null;
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
}
return null;
}
// ==================== Get Share Files ====================
/**
* Fetch detail at a given pdir_fid within a share.
*/
export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
pwd_id: pwdId,
stoken,
pdir_fid: pdirFid,
force: '0',
_page: '1',
_size: '50',
_fetch_banner: '0',
_fetch_share: '1',
_fetch_total: '1',
_sort: 'file_type:asc,updated_at:desc',
ver: '2',
fetch_share_full_path: '0',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
if (data.status !== 200)
return [];
return (data.data?.list || []).filter((f) => f.fid).map((f) => ({
fid: f.fid,
file_name: f.file_name,
share_fid_token: f.share_fid_token || '',
dir: f.dir || false,
size: f.size || 0,
}));
}
/**
* Recursively collect files from a share.
* If the share contains a single directory, drill into it to list contents
* but still save the directory itself.
*/
export async function getShareFiles(cookie, pwdId, stoken) {
try {
const topLevel = await getDetailAt(cookie, pwdId, stoken, '0');
if (!topLevel || topLevel.length === 0)
return null;
// If the share is a single directory, we save the directory itself
// and fetch its contents for renaming later
if (topLevel.length === 1 && topLevel[0].dir) {
const innerFiles = await getDetailAt(cookie, pwdId, stoken, topLevel[0].fid);
return {
files: topLevel,
topDir: true,
childFiles: innerFiles || [],
};
}
// Multiple top-level items: save them directly
return {
files: topLevel,
topDir: false,
};
}
catch {
return null;
}
}
// ==================== Save Files (share → cloud) ====================
/**
* Save shared files to the user's cloud directory.
*/
export async function saveFiles(cookie, pwdId, stoken, fids, fidTokens, toPdirFid) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/save?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: fids,
fid_token_list: fidTokens,
to_pdir_fid: toPdirFid,
pwd_id: pwdId,
stoken,
pdir_fid: '0',
scene: 'link',
}),
signal: AbortSignal.timeout(30000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.task_id) {
return { success: true, message: 'Save task created', taskId: data.data.task_id };
}
return {
success: false,
message: data.message === 'require login [guest]'
? '夸克网盘 Cookie 已过期,请在后台重新配置 Cookie'
: (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`),
};
}
catch (err) {
return { success: false, message: err.message || 'Network error' };
}
}
// ==================== Wait for Save Task ====================
/**
* Poll task status until complete or timeout.
* Returns the saved file FIDs (save_as_top_fids).
*/
export async function waitForTask(cookie, taskId, timeoutMs) {
const start = Date.now();
let retryIndex = 0;
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.status === 200) {
if (data.data?.status === 2) {
// Task completed
const savedFids = data.data?.save_as?.save_as_top_fids || [];
return savedFids;
}
// Still in progress
retryIndex++;
}
}
catch {
// Network error, retry
}
await new Promise(r => setTimeout(r, 1000));
}
return null; // Timeout
}
// ==================== Rename File ====================
/**
* Rename a file by its FID.
*/
export async function renameFile(cookie, fid, newName) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/rename?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ fid, file_name: newName }),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
return data.status === 200 || data.code === 0;
}
catch {
return false;
}
}
// ==================== Create Share Link ====================
/**
* Create a share link for a file/folder.
* Flow: create task → poll for share_id → submit to get short URL.
*/
export async function createShareLink(cookie, fileId) {
try {
const sharePwd = quark_api.randomSharePwd();
// Try different share_type values (1=7天, 0=无限制)
const shareTypes = ['1', '0'];
let lastError = '';
for (const st of shareTypes) {
await quark_api.humanDelay();
// Step 1: Create share task - get task_id
const response = await fetch(`${BASE_URL}/1/clouddrive/share?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: [fileId],
share_type: st,
url_type: '1',
share_pwd: sharePwd,
}),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
const taskId = data.data?.task_id;
if (!taskId) {
lastError = data.message || `share_type=${st} 失败`;
console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200));
continue;
}
// Step 2: Poll task until complete
const result = await waitForShareTask(cookie, taskId, 20000);
if (!result?.shareId) {
lastError = result?.message || '任务超时';
console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown');
continue;
}
// Step 3: Submit share via /password endpoint
const shareUrl = await submitShare(cookie, result.shareId, sharePwd);
if (shareUrl) {
return {
success: true,
shareUrl,
sharePwd,
message: `分享链接已生成(密码:${sharePwd}`,
};
}
lastError = '提交密码后未获取到短链接';
}
return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' };
}
catch (err) {
console.error('[Quark] createShareLink error:', err.message);
return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' };
}
}
/**
* Submit share via /password endpoint to get the actual short URL.
*/
async function submitShare(cookie, shareId, sharePwd) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/share/password?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
if (data.status === 200 && data.data?.share_url) {
console.log('[Quark] Share short URL:', data.data.share_url);
return data.data.share_url;
}
console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300));
console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || '');
return null;
}
catch (err) {
console.log('[Quark] /password error:', err);
return null;
}
}
/**
* Poll share task until complete and extract share URL/shortcode.
*/
async function waitForShareTask(cookie, taskId, timeoutMs) {
const start = Date.now();
let retryIndex = 0;
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.data?.status === 2) {
// Task completed — try multiple extraction approaches
// 1. Direct share_url field
if (data.data?.share_url) {
const match = data.data.share.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
// 2. Nested share object
if (data.data?.share?.url) {
const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
if (data.data?.share?.short_url) {
const match = data.data.share.short.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
// 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like)
const shareId = data.data?.share_id;
if (shareId && shareId.length <= 20 && shareId.length >= 8) {
return { shareId };
}
// 4. Regex search through the full response for a URL pattern
const str = JSON.stringify(data);
const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/);
if (urlMatch) {
return { shareId: urlMatch[1] };
}
// 5. Extract from any URL field in the response
const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link'];
for (const field of urlFields) {
const val = data.data?.[field] || data.data?.share?.[field];
if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) {
const m = val.match(/\/s\/([a-zA-Z0-9]+)/);
if (m)
return { shareId: m[1] };
}
}
// 6. Log full share task response for debugging
console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000));
// 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort
if (shareId) {
return { shareId };
}
return { message: 'Share task completed but no share URL found' };
}
if (data.data?.status === 3) {
return { message: data.message || 'Share task failed' };
}
retryIndex++;
}
catch {
// Retry
}
await new Promise(r => setTimeout(r, 1000));
}
return { message: 'Share task timed out' };
}

View File

@@ -0,0 +1,316 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as quark_share from "./quark-share";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
/**
* 转存 & 存储管理模块。
* 处理分享链接解析 → 转存 → 查/创建目标文件夹 → 文件重命名 → 递归统计。
*/
// ==================== saveFromShare — 核心转存流水线 ====================
/**
* Save files from a share link → magic rename → create shared link.
*
* Flow: token → detail → save → wait_task → rename → share
*/
export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle) {
try {
// Parse share token from URL
const urlObj = new URL(shareUrl);
const pwdId = urlObj.pathname.split('/').filter(Boolean).pop();
if (!pwdId) {
return { success: false, message: 'Invalid share URL: could not extract share token' };
}
// Step 1: Acquire stoken
const stoken = await quark_share.acquireStoken(cookie, pwdId);
if (!stoken) {
return { success: false, message: '😅 Oops资源好像偷偷溜走了换个链接试试吧' };
}
// Step 2: Get share detail
const shareInfo = await quark_share.getShareFiles(cookie, pwdId, stoken);
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
}
const { files: topFiles, topDir, childFiles } = shareInfo;
const originalFolderName = topFiles[0]?.file_name || '';
const fids = topFiles.map(f => f.fid);
const fidTokens = topFiles.map(f => f.share_fid_token);
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
await quark_api.humanDelay();
const saveDirName = quark_api.dailyFolderName();
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
const targetPdirFid = saveDirFid || '0';
if (saveDirFid) {
console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`);
}
else {
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
}
// Step 3: Save top-level item(s) to the target directory
const saveResult = await quark_share.saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean), targetPdirFid);
if (!saveResult.success) {
return saveResult;
}
const taskId = saveResult.taskId;
// Step 4: Wait for save task to complete (poll up to 30s)
const savedFids = await quark_share.waitForTask(cookie, taskId, 30000);
if (!savedFids || savedFids.length === 0) {
return { success: true, message: '文件已保存,但获取保存结果超时' };
}
// Step 5: Magic rename files — with random delay to avoid detection
await quark_api.humanDelay();
const renamed = [];
let shareFid = '';
let savedFolderName = '';
let newInnerDirName = '';
if (topDir && childFiles && childFiles.length > 0) {
// ── Single folder share ──
const savedDirFid = savedFids[0];
shareFid = savedDirFid;
savedFolderName = topFiles[0]?.file_name || '';
}
else {
// ── Multiple files at top level ──
shareFid = savedFids[0];
savedFolderName = topFiles[0]?.file_name || '';
}
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
await quark_api.humanDelay();
let shareUrlResult = '';
let sharePwdResult = '';
let shareMsg = '';
let successCount = 0; // total items (files + folders) actually saved
const { createShareLink } = await import('./quark-share');
if (shareFid) {
const shareResult = await createShareLink(cookie, shareFid);
if (shareResult.success && shareResult.shareUrl) {
shareUrlResult = shareResult.shareUrl;
if (shareResult.sharePwd)
sharePwdResult = shareResult.sharePwd;
}
else {
shareMsg = `(分享失败:${shareResult.message}`;
}
}
const { magicRenameDir, magicRename } = await import('./quark-rename');
const { renameFile } = await import('./quark-share');
// Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share)
if (topDir && childFiles && childFiles.length > 0) {
// ── Single folder share ──
const savedDirFid = savedFids[0];
// List files inside the saved directory
const dirFiles = await quark_api.listDir(cookie, savedDirFid);
if (dirFiles && dirFiles.length > 0) {
for (const file of dirFiles) {
if (file.dir)
continue;
const newName = magicRename(file.file_name);
const renameOk = await renameFile(cookie, file.fid, newName);
if (renameOk) {
renamed.push({ original: file.file_name, renamed: newName });
}
}
}
// Also rename the inner folder itself (the actual shared folder)
const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || '';
if (innerDirOriginalName) {
newInnerDirName = magicRenameDir(innerDirOriginalName);
const innerDirRenameOk = await renameFile(cookie, savedDirFid, newInnerDirName);
if (innerDirRenameOk) {
console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName}${newInnerDirName}`);
}
}
}
else {
// ── Multiple files at top level ──
for (let i = 0; i < savedFids.length && i < topFiles.length; i++) {
const originalName = topFiles[i].file_name;
if (topFiles[i].dir)
continue;
const newName = magicRename(originalName);
const renameOk = await renameFile(cookie, savedFids[i], newName);
if (renameOk) {
renamed.push({ original: originalName, renamed: newName });
}
}
}
// Step 7.5: 广告关键词清理 + 创建警示文件夹
if (shareFid) {
try {
const { runAdCleanup } = await import('./quark-ad-cleanup');
const adResult = await runAdCleanup(cookie, shareFid);
if (adResult.adDeleted > 0) {
console.log(`[Quark] 广告清理完成: 删除了 ${adResult.adDeleted} 个广告文件/文件夹`);
}
if (adResult.warningDirs > 0) {
console.log(`[Quark] 已创建 ${adResult.warningDirs} 个警示文件夹`);
}
}
catch (err) {
console.log(`[Quark] 广告清理/警示文件夹创建失败(非致命): ${err.message}`);
}
}
// Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03")
// DO NOT rename the date folder — it serves as the organizational container.
savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName;
// Recursively count files and folders from saved cloud directory
let fileCount = 0;
let folderCount = 0;
if (shareFid) {
try {
const counts = await countRecursive(cookie, shareFid);
fileCount = counts.fileCount;
folderCount = counts.folderCount;
}
catch {
console.log('[Quark] Recursive count failed, using fallback');
}
}
// If recursive count returned nothing, try fallback
if (fileCount === 0 && folderCount === 0) {
if (topDir && childFiles) {
folderCount = 1 + childFiles.filter(f => f.dir).length;
fileCount = childFiles.filter(f => !f.dir).length;
}
else {
folderCount = topFiles.filter(f => f.dir).length;
fileCount = topFiles.filter(f => !f.dir).length;
}
}
// Calculate total file size
const allFiles = topDir && childFiles ? childFiles : topFiles;
const fileSize = allFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
const renameMsg = renamed.length > 0
? `,已重命名 ${renamed.length} 个文件`
: '';
const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}` : '';
return {
success: true,
message: `已保存${folderMsg}${renameMsg}${shareMsg}`,
shareUrl: shareUrlResult || undefined,
sharePwd: sharePwdResult || undefined,
folderName: savedFolderName,
taskId,
renamed: renamed.map(r => `${r.original}${r.renamed}`),
fileCount,
folderCount,
fileSize,
originalFolderName,
};
}
catch (err) {
return { success: false, message: err.message || 'Network error' };
}
}
// ==================== Dir Management ====================
/**
* Create a new directory at root.
*/
export async function createDir(cookie, dirName) {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
pdir_fid: '0',
file_name: dirName,
dir: true,
dir_path: '',
}),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.fid) {
console.log(`[Quark] Created dir "${dirName}" (fid: ${data.data.fid})`);
return data.data.fid;
}
console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`);
return null;
}
catch (err) {
console.log(`[Quark] createDir error: ${err.message}`);
return null;
}
}
/**
* Find an existing directory by name, or create it if not found.
*/
export async function findOrCreateDir(cookie, dirName) {
try {
const rootFiles = await quark_api.listDirAllPages(cookie, '0');
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
if (existing?.fid) {
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
return existing.fid;
}
console.log(`[Quark] Daily folder "${dirName}" not found, creating...`);
}
catch (err) {
console.log(`[Quark] findOrCreateDir list error: ${err.message}`);
}
const fid = await createDir(cookie, dirName);
console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`);
return fid;
}
// ==================== Recursive Count ====================
/**
* Recursively count files and folders for a saved cloud directory.
*/
export async function countRecursive(cookie, pdirFid) {
let fileCount = 0;
let folderCount = 0;
const stack = [pdirFid];
const visited = new Set();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
visited.add(fid);
const files = await quark_api.listDir(cookie, fid);
if (!files)
continue;
for (const f of files) {
if (f.dir) {
folderCount++;
stack.push(f.fid);
}
else {
fileCount++;
}
}
}
return { fileCount, folderCount };
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,16 +36,5 @@ const config: Config = {
dbPath: process.env.DB_PATH || './data/cloudsearch.db',
};
// 生产环境强制校验关键安全配置
if (process.env.NODE_ENV === 'production') {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'cloudsearch-jwt-secret-dev') {
console.error('[FATAL] JWT_SECRET 未设置或使用了默认值,请在 .env 中设置强密码')
process.exit(1)
}
if (!process.env.ADMIN_PASSWORD || process.env.ADMIN_PASSWORD === 'admin123') {
console.error('[FATAL] ADMIN_PASSWORD 未设置或使用了默认值,请在 .env 中设置强密码')
process.exit(1)
}
}
// Startup validation done by startup-validator
export default config;

View File

@@ -0,0 +1,104 @@
/**
* Startup configuration validator.
*
* Validates critical config items before server start.
* All issues are warnings in staging — only COOKIE_ENCRYPTION_KEY missing
* and admin password using defaults will be flagged.
*/
import config from '../config';
interface ValidationError {
key: string;
message: string;
severity: 'error' | 'warn';
}
export function validateConfig(): ValidationError[] {
const errors: ValidationError[] = [];
const isProd = config.nodeEnv === 'production';
// ─── JWT Secret ───
const DEFAULT_JWT_SECRETS = [
'cloudsearch-jwt-secret-dev',
'your-super-secret-jwt-key-change-me',
'',
];
if (DEFAULT_JWT_SECRETS.includes(config.jwtSecret)) {
errors.push({
key: 'JWT_SECRET',
message: '使用了默认 JWT Secret生产部署前应修改openssl rand -hex 32',
severity: isProd ? 'warn' : 'warn',
});
}
// ─── Admin Password ───
const weakPasswords = ['admin123', 'admin', 'password', '123456', ''];
if (weakPasswords.includes(config.adminPassword)) {
errors.push({
key: 'ADMIN_PASSWORD',
message: `使用了默认管理员密码,生产部署前应设置强密码`,
severity: isProd ? 'warn' : 'warn',
});
}
// ─── Cookie Encryption ───
if (!process.env.COOKIE_ENCRYPTION_KEY) {
errors.push({
key: 'COOKIE_ENCRYPTION_KEY',
message: '未设置网盘 Cookie 加密密钥Cookie 将以明文存储。生产环境强烈建议设置。\n' +
'生成: openssl rand -hex 32',
severity: 'warn',
});
}
// ─── CORS ───
const corsOrigin = process.env.CORS_ORIGIN || '';
if (isProd && (!corsOrigin || corsOrigin === 'https://your-production-domain.com')) {
errors.push({
key: 'CORS_ORIGIN',
message: '生产环境未配置真实的 CORS_ORIGIN临时允许所有来源请求',
severity: 'warn',
});
}
// ─── Port conflict check (best-effort) ───
if (config.port < 1024 && (process as any).getuid?.() !== 0) {
errors.push({
key: 'PORT',
message: `端口 ${config.port} 需要 root 权限(<1024建议使用 9527 或更高端口`,
severity: 'warn',
});
}
return errors;
}
/**
* Print validation results and return whether startup should proceed.
* Returns false only if 'error' severity issues found in production.
* In staging, warnings are printed but startup continues.
*/
export function checkStartup(): boolean {
const errors = validateConfig();
const isProd = config.nodeEnv === 'production';
if (errors.length === 0) {
console.log('[Config] ✅ 所有配置检查通过');
return true;
}
console.log('[Config] ── 配置检查结果 ──');
for (const err of errors) {
const prefix = err.severity === 'error' ? '❌' : '⚠️';
console.log(`[Config] ${prefix} [${err.severity.toUpperCase()}] ${err.key}: ${err.message}`);
}
const criticalErrors = errors.filter(e => e.severity === 'error');
if (criticalErrors.length > 0 && isProd) {
console.error('[Config] 🛑 生产环境存在严重配置错误,拒绝启动。请修复后重试。');
return false;
}
console.log(`[Config] ✅ 继续启动(${errors.length} 个警告)`);
return true;
}

View File

@@ -5,6 +5,7 @@ import helmet from 'helmet';
import morgan from 'morgan';
import config from './config';
import { VERSION as version } from "./version";
import { checkStartup } from './config/startup-validator';
import { getDb } from './database/database';
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
import rateLimiter from './middleware/rate-limit';
@@ -142,6 +143,12 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
// ============ Server Start ============
async function start(): Promise<void> {
// Startup config validation
if (!checkStartup()) {
console.error('[Server] 配置校验未通过,进程退出');
process.exit(1);
}
try {
getDb();
console.log('[DB] SQLite database initialized');

View File

@@ -17,6 +17,7 @@ import config from '../config';
import { reconnectRedis, testRedisConnection } from '../middleware/cache';
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
import { testProxyConnection } from '../utils/proxy-agent';
const router = Router();
@@ -529,13 +530,8 @@ router.post('/admin/test-external-service', async (req: Request, res: Response)
res.json({ ok: false, info: 'Proxy URL not configured' });
return;
}
const response = await fetch(proxyUrl, { signal: AbortSignal.timeout(8000) });
const latency = Date.now() - start;
res.json({
ok: response.ok,
latency,
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
});
const result = await testProxyConnection(proxyUrl);
res.json(result);
break;
}
case 'ip_geo': {

View File

@@ -0,0 +1,98 @@
/**
* AES-256-GCM encryption/decryption for protecting cloud drive cookies stored in DB.
*
* Encryption key is derived from COOKIE_ENCRYPTION_KEY env var via SHA-256.
* If unset, uses a built-in default key (stable across container restarts).
* Production MUST set 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) {
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)');
} else if (envKey) {
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)');
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 (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;
}
}

View File

@@ -0,0 +1,77 @@
/**
* Structured logger with log levels.
* Level controlled by LOG_LEVEL env var (debug|info|warn|error).
* Default: 'info' in production, 'debug' otherwise.
*/
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: string[] = [
`[${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?: Partial<LogEntry>): 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 }),
};

View File

@@ -0,0 +1,145 @@
/**
* Unified proxy agent — supports HTTP/HTTPS/SOCKS5/SOCKS5h protocols.
*
* Node 20+ native fetch() uses undici Dispatcher, but socks-proxy-agent
* doesn't implement this interface.
* Solution: use http.Agent interface + 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 {
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;
if (h instanceof Headers) {
h.forEach((v, k) => { headers[k] = v; });
} else if (typeof h === 'object') {
Object.assign(headers, h);
}
}
const options = {
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;
}

View File

@@ -2,7 +2,9 @@
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
@@ -12,8 +14,14 @@
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"sourceMap": true,
"noImplicitAny": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}

19
source_clean/tsconfig.json.bak Executable file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}