deploy: sync server CloudSearch 0.5.6

This commit is contained in:
2026-06-25 02:08:39 +08:00
parent 75f5d26964
commit ce3f95b8b9
53 changed files with 2740 additions and 116 deletions
+4
View File
@@ -0,0 +1,4 @@
version=0.5.6
source=restored-cloudsearch-existing-source
updated_at=2026-05-23T19:14:59+08:00
backup=/root/cloudsearch_deploy_backups/source_clean_before_d7e1d90_20260523-191331.tgz
+1 -1
View File
@@ -1 +1 @@
0.5.5 0.5.6
+7 -4
View File
@@ -2,10 +2,13 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . ENV PYTHONPATH=/app
COPY cloudsearch_transfer/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY cloudsearch_transfer ./cloudsearch_transfer
RUN mkdir -p /data
ENV PORT=9528 ENV PORT=9528
ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
@@ -13,6 +16,6 @@ ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
EXPOSE 9528 EXPOSE 9528
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["python", "server.py"] CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % os.getenv('PORT', '9528'), timeout=3)"
CMD ["python", "server.py"] CMD ["python", "-m", "cloudsearch_transfer.server"]
+87 -11
View File
@@ -15,8 +15,8 @@ import logging
from typing import List, Dict, Tuple, Optional from typing import List, Dict, Tuple, Optional
from ..base import BaseCloudDriveAdapter, FileInfo, match_url from ..base import BaseCloudDriveAdapter, FileInfo, match_url
from ..config import PlatformConfig, TransferConfig from ...config import PlatformConfig, TransferConfig
from ..errors import TransferError, TransferErrorCode from ...errors import TransferError, TransferErrorCode
from .credential import AliyunCredentialManager from .credential import AliyunCredentialManager
from .transfer import AliyunTransfer from .transfer import AliyunTransfer
@@ -47,23 +47,29 @@ class AliyunAdapter(BaseCloudDriveAdapter):
"Referer": "https://aliyundrive.com", "Referer": "https://aliyundrive.com",
} }
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig): capabilities: Dict[str, bool] = {
super().__init__(config, transfer_config) **BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
# 创建凭证管理器(AliyunCredentialManager def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
# BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
refresh_token = config.refresh_token or config.cookie or "" refresh_token = config.refresh_token or config.cookie or ""
self._credential = AliyunCredentialManager(refresh_token=refresh_token) self._credential = AliyunCredentialManager(refresh_token=refresh_token)
# 初始化 drive_id
self._drive_id = "" self._drive_id = ""
# 创建子模块
self._transfer: Optional[AliyunTransfer] = None self._transfer: Optional[AliyunTransfer] = None
self._cleanup: Optional[AliyunCleanup] = None self._cleanup: Optional[AliyunCleanup] = None
super().__init__(config, transfer_config)
def _setup_session(self): def _setup_session(self):
"""初始化 session 和凭证""" """初始化 session 和凭证"""
if self._credential.refresh_token: refresh_token = getattr(self._credential, "refresh_token", "")
if refresh_token:
# 验证 refresh_token 并获取 drive_id # 验证 refresh_token 并获取 drive_id
if self._credential.validate(): if self._credential.validate():
self._drive_id = self._credential.get_drive_id() self._drive_id = self._credential.get_drive_id()
@@ -164,8 +170,10 @@ class AliyunAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY, platform=self.PLATFORM_KEY,
) )
# 确定目标目录 # 确定目标目录:路径先解析/创建为 file_id。
to_parent = save_dir if save_dir and save_dir != "/" else "root" to_parent = save_dir if save_dir and save_dir != "/" else "root"
if isinstance(to_parent, str) and to_parent.startswith("/"):
to_parent = self.ensure_dir(to_parent)
transfer = self._get_transfer() transfer = self._get_transfer()
new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent) new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent)
@@ -237,6 +245,74 @@ class AliyunAdapter(BaseCloudDriveAdapter):
# ─── 扩展功能 ────────────────────────────────────────── # ─── 扩展功能 ──────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def get_fids(self, file_paths: List[str]) -> List[Dict]:
wanted = {p.rstrip("/") or "/" for p in file_paths}
found: List[Dict] = []
for path in wanted:
if path == "/":
found.append({"file_path": "/", "fid": "root"})
continue
parent = path.rsplit("/", 1)[0] or "/"
name = path.rsplit("/", 1)[-1]
for item in self.get_files(parent):
if item.is_dir and item.name == name:
found.append({"file_path": path, "fid": item.fid})
break
return found
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "root"
current = ""
last_fid = "root"
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
matches = self.get_fids([current])
if matches:
last_fid = matches[0].get("fid") or matches[0].get("file_id") or last_fid
continue
created = self.mkdir(current)
data = created.get("data", created) if isinstance(created, dict) else {}
last_fid = data.get("fid") or data.get("file_id") or data.get("id") or last_fid
return last_fid
def mkdir(self, dir_path: str) -> Dict:
parent = dir_path.rsplit("/", 1)[0] or "/"
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
parent_fid = "root" if parent == "/" else self.ensure_dir(parent)
url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders"
body = {
"drive_id": self._drive_id or self._credential.get_drive_id(),
"parent_file_id": parent_fid,
"name": name,
"type": "folder",
"check_name_mode": "refuse",
}
resp = self._post(url, json_data=body, headers=self._credential.get_headers())
data = self._drive_api_json(resp, context="阿里云盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
url = "https://api.aliyundrive.com/v3/file/update"
body = {"drive_id": self._drive_id or self._credential.get_drive_id(), "file_id": fid, "name": file_name, "check_name_mode": "refuse"}
data = self._drive_api_json(self._post(url, json_data=body, headers=self._credential.get_headers()), context="阿里云盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
if isinstance(to_pdir_fid, str) and to_pdir_fid.startswith("/"):
to_pdir_fid = self.ensure_dir(to_pdir_fid)
drive_id = self._drive_id or self._credential.get_drive_id()
requests = [{"id": fid, "method": "POST", "url": "/file/move", "headers": {"Content-Type": "application/json"}, "body": {"drive_id": drive_id, "file_id": fid, "to_parent_file_id": to_pdir_fid}} for fid in fids]
data = self._drive_api_json(self._post("https://api.aliyundrive.com/adrive/v4/batch", json_data={"requests": requests, "resource": "file"}, headers=self._credential.get_headers()), context="阿里云盘移动文件")
return {"code": 0, "status": 200, "data": data}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def cleanup_files(self, file_ids: List[str]) -> Dict: def cleanup_files(self, file_ids: List[str]) -> Dict:
""" """
清理文件(移入回收站),返回详细结果。 清理文件(移入回收站),返回详细结果。
+79 -3
View File
@@ -6,7 +6,7 @@
""" """
import logging import logging
from typing import List, Tuple from typing import List, Tuple, Dict
from ..base import BaseCloudDriveAdapter, FileInfo from ..base import BaseCloudDriveAdapter, FileInfo
from ...config import PlatformConfig, TransferConfig from ...config import PlatformConfig, TransferConfig
@@ -36,6 +36,15 @@ class BaiduAdapter(BaseCloudDriveAdapter):
r'pan\.baidu\.com/s/1([A-Za-z0-9_-]+)', r'pan\.baidu\.com/s/1([A-Za-z0-9_-]+)',
] ]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig): def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config) super().__init__(config, transfer_config)
@@ -123,11 +132,13 @@ class BaiduAdapter(BaseCloudDriveAdapter):
fs_ids = detail["fs_ids"] fs_ids = detail["fs_ids"]
filenames = detail.get("filenames", []) filenames = detail.get("filenames", [])
target_dir = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "/")
# ③ 转存 # ③ 转存
self._transfer._transfer_files(shareid, uk, fs_ids, save_dir, bdstoken) self._transfer._transfer_files(shareid, uk, fs_ids, target_dir, bdstoken)
# ④ 列出目录匹配新 fs_id # ④ 列出目录匹配新 fs_id
new_fs_ids = self._transfer._list_and_match(save_dir, filenames, bdstoken) new_fs_ids = self._transfer._list_and_match(target_dir, filenames, bdstoken)
# 暂存文件信息供 _filter_ads + _create_share 使用 # 暂存文件信息供 _filter_ads + _create_share 使用
self._last_transfer_files = [ self._last_transfer_files = [
@@ -248,6 +259,71 @@ class BaiduAdapter(BaseCloudDriveAdapter):
# ─── 扩展方法 ──────────────────────────────────────────── # ─── 扩展方法 ────────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "/"
current = ""
for part in [p for p in normalized.split("/") if p]:
parent = current or "/"
current = f"{current}/{part}" if current else f"/{part}"
exists = any(item.is_dir and item.name == part for item in self.get_files(parent))
if not exists:
self.mkdir(current)
return normalized
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing Baidu paths by listing their parent directories."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": "/", "path": "/"})
continue
parent, name = normalized.rsplit("/", 1)
parent = parent or "/"
match = next((item for item in self.get_files(parent) if item.name == name), None)
if match:
results.append({"file_path": path, "fid": match.fid, "path": normalized})
return results
def mkdir(self, dir_path: str) -> Dict:
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/create"
params = {"a": "commit", "bdstoken": bdstoken}
data = {"path": dir_path, "isdir": 1, "block_list": "[]"}
resp = self._post(url, data=data, params=params, headers=self.credential.get_headers())
payload = self._drive_api_json(resp, context="百度网盘创建目录")
errno = payload.get("errno", 0)
if errno not in (0, -8):
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度创建目录失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": {"path": dir_path, **payload}}
def rename(self, fid: str, file_name: str) -> Dict:
return self._filemanager("rename", [{"path": fid, "newname": file_name}])
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
return self._filemanager("move", [{"path": fid, "dest": target} for fid in fids])
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def _filemanager(self, opera: str, filelist: List[Dict]) -> Dict:
import json
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/filemanager"
params = {"opera": opera, "bdstoken": bdstoken}
data = {"filelist": json.dumps(filelist, ensure_ascii=False)}
payload = self._drive_api_json(self._post(url, data=data, params=params, headers=self.credential.get_headers()), context=f"百度网盘{opera}")
errno = payload.get("errno", 0)
if errno != 0:
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度文件操作失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": payload}
def delete_paths(self, paths: List[str]) -> bool: def delete_paths(self, paths: List[str]) -> bool:
"""便捷删除方法(直接调用 cleanup)""" """便捷删除方法(直接调用 cleanup)"""
return self._cleanup.delete_files(paths) return self._cleanup.delete_files(paths)
+102 -14
View File
@@ -8,7 +8,7 @@ PLATFORM_KEY = 'xunlei'
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import List, Optional, Tuple from typing import List, Optional, Tuple, Dict
from ..base import ( from ..base import (
BaseCloudDriveAdapter, BaseCloudDriveAdapter,
@@ -32,11 +32,24 @@ class XunleiAdapter(BaseCloudDriveAdapter):
PLATFORM_KEY = "xunlei" PLATFORM_KEY = "xunlei"
URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"] URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
# batchDelete is permanent on Xunlei, so do not advertise it as generic delete_files.
"delete_files": False,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig): def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config) # BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
self._credential = XunleiCredentialManager(config) self._credential = XunleiCredentialManager(config)
self._transfer_engine: Optional[XunleiTransfer] = None self._transfer_engine: Optional[XunleiTransfer] = None
self._cleanup = XunleiCleanup() self._cleanup = XunleiCleanup(self._credential)
super().__init__(config, transfer_config)
def _setup_session(self): def _setup_session(self):
"""初始化 session 认证头""" """初始化 session 认证头"""
@@ -54,27 +67,35 @@ class XunleiAdapter(BaseCloudDriveAdapter):
"""懒加载转存引擎""" """懒加载转存引擎"""
if self._transfer_engine is None: if self._transfer_engine is None:
self._transfer_engine = XunleiTransfer( self._transfer_engine = XunleiTransfer(
self.session, credential=self._credential,
self._credential, timeout=self.transfer_config.request_timeout,
self.config, poll_interval=self.transfer_config.task_poll_interval,
self.transfer_config, poll_max_attempts=self.transfer_config.task_poll_max_attempts,
) )
self._transfer_engine.session = self.session
return self._transfer_engine return self._transfer_engine
# ─── 抽象方法实现 ────────────────────────────── # ─── 抽象方法实现 ──────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict: def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
self._ensure_auth() self._ensure_auth()
return self._transfer.get_share_info(pwd_id, passcode) return self._transfer._get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]: def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
self._ensure_auth() self._ensure_auth()
return self._transfer.save_files(pwd_id, detail, save_dir) target = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "")
files = detail.get("files", [])
file_ids = [f.get("file_id") or f.get("fid") or f.get("id") for f in files if f.get("file_id") or f.get("fid") or f.get("id")]
if not file_ids:
raise RuntimeError("无法从分享中提取文件ID")
task_id = self._transfer._restore_files(pwd_id, detail.get("pass_code_token", ""), file_ids, parent_id=target)
mapping = self._transfer._poll_restore_task(task_id)
return [mapping.get(fid, "") for fid in file_ids if mapping.get(fid, "")]
def _create_share(self, file_ids: List[str], title: str, def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]: password: str = "") -> Tuple[str, str]:
self._ensure_auth() self._ensure_auth()
return self._transfer.create_share(file_ids, title, password) return self._transfer._create_share(file_ids, password=password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]: def _extract_file_list(self, detail: dict) -> List[FileInfo]:
files = detail.get("files", []) files = detail.get("files", [])
@@ -94,13 +115,80 @@ class XunleiAdapter(BaseCloudDriveAdapter):
def get_files(self, parent_fid: str = "0") -> List[FileInfo]: def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
self._ensure_auth() self._ensure_auth()
return self._transfer.list_files(parent_fid) url = "https://api-pan.xunlei.com/drive/v1/files"
params = {"parent_id": "" if parent_fid in ("0", "/") else parent_fid}
data = self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="迅雷网盘列目录")
items = data.get("files", data.get("list", []))
return [FileInfo(fid=item.get("id") or item.get("file_id", ""), name=item.get("name", ""), size=item.get("size", 0), is_dir=item.get("kind") == "drive#folder" or item.get("is_dir", False)) for item in items]
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return ""
parent_id = ""
current = ""
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
existing = next((item for item in self.get_files(parent_id or "0") if item.is_dir and item.name == part), None)
if existing:
parent_id = existing.fid
continue
result = self.mkdir(current if not parent_id else part, parent_id=parent_id)
parent_id = result.get("data", {}).get("fid", parent_id)
return parent_id
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing file/directory paths without creating anything."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": ""})
continue
parent_id = ""
found: Optional[FileInfo] = None
missing = False
parts = [p for p in normalized.split("/") if p]
for index, part in enumerate(parts):
found = next((item for item in self.get_files(parent_id or "0") if item.name == part), None)
if not found:
missing = True
break
if index < len(parts) - 1 and not found.is_dir:
missing = True
break
parent_id = found.fid
if not missing and found:
results.append({"file_path": path, "fid": found.fid})
return results
def mkdir(self, dir_path: str, parent_id: str = "") -> Dict:
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
body = {"kind": "drive#folder", "name": name, "parent_id": parent_id or ""}
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files", json_data=body, headers=self._credential.get_headers_with_captcha(action="mkdir")), context="迅雷网盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("id") or data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
data = self._drive_api_json(self._post(f"https://api-pan.xunlei.com/drive/v1/files/{fid}", json_data={"name": file_name}, headers=self._credential.get_headers()), context="迅雷网盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files:batchMove", json_data={"ids": fids, "parent_id": target}, headers=self._credential.get_headers()), context="迅雷网盘移动文件")
return {"code": 0, "status": 200, "data": data}
def query_task(self, task_id: str) -> Dict:
return self.poll_task(task_id)
def poll_task(self, task_id: str) -> Dict:
return {"code": 0, "status": 200, "data": self._transfer._poll_restore_task(task_id)}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def delete(self, file_ids: List[str]) -> bool: def delete(self, file_ids: List[str]) -> bool:
self._ensure_auth() self._ensure_auth()
return self._cleanup.delete_files( return self._cleanup.delete_files(file_ids)
self.session, self._credential, file_ids
)
def _get_banned_keywords(self) -> List[str]: def _get_banned_keywords(self) -> List[str]:
return self.config.banned_keywords or self.transfer_config.default_banned_keywords return self.config.banned_keywords or self.transfer_config.default_banned_keywords
@@ -70,7 +70,7 @@ class XunleiTransfer:
# ─── 步骤 ①:获取分享详情 ───────────────────────────────────── # ─── 步骤 ①:获取分享详情 ─────────────────────────────────────
def _get_share_info(self, share_id: str) -> Dict[str, Any]: def _get_share_info(self, share_id: str, passcode: str = "") -> Dict[str, Any]:
"""步骤①:获取分享详情。 """步骤①:获取分享详情。
GET /drive/v1/share?share_id=<share_id> GET /drive/v1/share?share_id=<share_id>
@@ -88,6 +88,8 @@ class XunleiTransfer:
""" """
url = f"{XUNLEI_PAN_API}/drive/v1/share" url = f"{XUNLEI_PAN_API}/drive/v1/share"
params: Dict[str, str] = {"share_id": share_id} params: Dict[str, str] = {"share_id": share_id}
if passcode:
params["pass_code"] = passcode
headers = self.credential.get_headers() headers = self.credential.get_headers()
logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id) logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id)
@@ -303,6 +305,7 @@ class XunleiTransfer:
def _create_share( def _create_share(
self, self,
file_ids: List[str], file_ids: List[str],
password: str = "",
expiration_days: str = "-1", expiration_days: str = "-1",
) -> Tuple[str, str]: ) -> Tuple[str, str]:
"""步骤④:创建新分享链接。 """步骤④:创建新分享链接。
@@ -331,6 +334,8 @@ class XunleiTransfer:
"file_ids": file_ids, "file_ids": file_ids,
"expiration_days": expiration_days, "expiration_days": expiration_days,
} }
if password:
body["pass_code"] = password
# share 操作可能需要 captcha_token # share 操作可能需要 captcha_token
headers = self.credential.get_headers_with_captcha(action="share") headers = self.credential.get_headers_with_captcha(action="share")
headers.setdefault("Content-Type", "application/json") headers.setdefault("Content-Type", "application/json")
@@ -367,7 +372,7 @@ class XunleiTransfer:
share_url, share_url,
pass_code, pass_code,
) )
return share_url, pass_code return share_url, pass_code or password
# ─── 公开入口 ───────────────────────────────────────────────── # ─── 公开入口 ─────────────────────────────────────────────────
+2 -2
View File
@@ -150,8 +150,8 @@ class ConfigManager:
"platforms": { "platforms": {
name: { name: {
"enabled": cfg.enabled, "enabled": cfg.enabled,
"cookie": cfg.cookie[:20] + "..." if cfg.cookie else "", "cookie": cfg.cookie,
"refresh_token": cfg.refresh_token[:20] + "..." if cfg.refresh_token else "", "refresh_token": cfg.refresh_token,
"account_name": cfg.account_name, "account_name": cfg.account_name,
"save_dir": cfg.save_dir, "save_dir": cfg.save_dir,
"share_password": cfg.share_password, "share_password": cfg.share_password,
+19 -4
View File
@@ -7,8 +7,8 @@ import os
import uuid import uuid
import logging import logging
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from config import ConfigManager from cloudsearch_transfer.config import ConfigManager
from orchestration.transfer import TransferOrchestrator from cloudsearch_transfer.orchestration.transfer import TransferOrchestrator
# ─── 初始化 ──────────────────────────────────────────── # ─── 初始化 ────────────────────────────────────────────
@@ -149,6 +149,18 @@ def stats():
return jsonify(orchestrator.get_stats()) return jsonify(orchestrator.get_stats())
def require_config_auth():
token = os.getenv("TRANSFER_API_TOKEN", "")
if not token:
return jsonify({"error": "TRANSFER_API_TOKEN is required"}), 401
supplied = request.headers.get("X-Transfer-Token") or request.headers.get("Authorization", "")
if supplied.startswith("Bearer "):
supplied = supplied[7:]
if supplied != token:
return jsonify({"error": "unauthorized"}), 401
return None
# ─── 配置管理 ────────────────────────────────────────── # ─── 配置管理 ──────────────────────────────────────────
@app.route("/api/config/platforms", methods=["GET"]) @app.route("/api/config/platforms", methods=["GET"])
@@ -169,14 +181,17 @@ def get_platforms():
@app.route("/api/config/platforms/<name>", methods=["PUT"]) @app.route("/api/config/platforms/<name>", methods=["PUT"])
def update_platform(name): def update_platform(name):
"""更新平台配置""" """更新平台配置"""
auth_error = require_config_auth()
if auth_error:
return auth_error
data = request.get_json() or {} data = request.get_json() or {}
if name not in config.platforms: if name not in config.platforms:
from config import PlatformConfig from cloudsearch_transfer.config import PlatformConfig
config.platforms[name] = PlatformConfig() config.platforms[name] = PlatformConfig()
cfg = config.platforms[name] cfg = config.platforms[name]
if "enabled" in data: if "enabled" in data:
cfg.enabled = data["enabled"] cfg.enabled = bool(data["enabled"])
if "cookie" in data: if "cookie" in data:
cfg.cookie = data["cookie"] cfg.cookie = data["cookie"]
if "refresh_token" in data: if "refresh_token" in data:
@@ -0,0 +1,198 @@
import unittest
from unittest.mock import patch
from cloudsearch_transfer.adapter.aliyun import AliyunAdapter
from cloudsearch_transfer.adapter.baidu import BaiduAdapter
from cloudsearch_transfer.adapter.xunlei import XunleiAdapter
from cloudsearch_transfer.config import PlatformConfig, TransferConfig
from cloudsearch_transfer.adapter.base import FileInfo
class DummyCredential:
cookie = "k=xxxxxxxx"
refresh_token = "rt"
def validate(self):
return True
def get_bdstoken(self):
return "bdstoken"
def get_drive_id(self):
return "drive-id"
def get_headers(self):
return {}
def get_auth_headers(self):
return {}
def make_config(**kwargs):
data = dict(enabled=True, cookie="k=" + "x" * 80, refresh_token="rt", account_name="test", save_dir="/")
data.update(kwargs)
return PlatformConfig(**data)
def make_transfer_config():
return TransferConfig(request_timeout=1, max_retries=0, ad_filter_enabled=False, task_poll_interval=0, task_poll_max_attempts=2)
class P1DriveApiCapabilityTests(unittest.TestCase):
def make_aliyun(self):
with patch("cloudsearch_transfer.adapter.aliyun.AliyunCredentialManager", return_value=DummyCredential()):
return AliyunAdapter(make_config(), make_transfer_config())
def make_baidu(self):
with patch("cloudsearch_transfer.adapter.baidu.BaiduCredentialManager", return_value=DummyCredential()):
return BaiduAdapter(make_config(), make_transfer_config())
def make_xunlei(self):
with patch("cloudsearch_transfer.adapter.xunlei.XunleiCredentialManager", return_value=DummyCredential()):
return XunleiAdapter(make_config(), make_transfer_config())
def test_p1_adapters_declare_callable_core_drive_api_methods(self):
expectations = {
"aliyun": (self.make_aliyun(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"baidu": (self.make_baidu(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"xunlei": (self.make_xunlei(), ["ensure_dir", "save_files", "poll_task", "rename", "move_files"]),
}
for platform, (adapter, required) in expectations.items():
with self.subTest(platform=platform):
for capability in required:
self.assertTrue(adapter.capabilities.get(capability), f"{platform}.{capability} capability not declared")
for method in ["ensure_dir", "mkdir", "rename", "move_files"]:
self.assertNotEqual(getattr(type(adapter), method), getattr(adapter.__class__.__mro__[1], method, None), f"{platform}.{method} not overridden")
if adapter.capabilities.get("delete_files"):
self.assertNotEqual(getattr(type(adapter), "delete_files"), getattr(adapter.__class__.__mro__[1], "delete_files", None), f"{platform}.delete_files not overridden")
def test_aliyun_save_files_resolves_path_to_file_id_before_copy(self):
adapter = self.make_aliyun()
adapter.ensure_dir = lambda path: "target-folder-id"
captured = {}
transfer = adapter._get_transfer()
def fake_batch_copy(share_id, share_token, file_ids, to_parent):
captured["to_parent"] = to_parent
return ["new-file"]
transfer._batch_copy = fake_batch_copy
detail = {"share_token": "share-token", "files": [{"file_id": "src-file"}]}
self.assertEqual(adapter._save_files("share-id", detail, "/Media"), ["new-file"])
self.assertEqual(captured["to_parent"], "target-folder-id")
def test_aliyun_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_aliyun()
existing = {"/A": "fid-A"}
created = []
adapter.get_fids = lambda paths: [{"file_path": p, "fid": existing[p]} for p in paths if p in existing]
def fake_mkdir(path):
created.append(path)
existing[path] = "fid-" + path.rsplit("/", 1)[-1]
return {"code": 0, "data": {"fid": existing[path]}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "fid-B")
self.assertEqual(created, ["/A/B"])
def test_baidu_save_files_ensures_path_before_transfer_and_matching(self):
adapter = self.make_baidu()
adapter.ensure_dir = lambda path: "/CloudSearch/Media"
captured = {}
adapter.credential.get_bdstoken = lambda: "bdstoken"
def fake_transfer_files(shareid, uk, fs_ids, save_dir, token):
captured["transfer_dir"] = save_dir
adapter._transfer._transfer_files = fake_transfer_files
def fake_list_and_match(save_dir, filenames, token):
captured["list_dir"] = save_dir
return ["1001"]
adapter._transfer._list_and_match = fake_list_and_match
detail = {"shareid": "sid", "uk": "uk", "fs_ids": ["old"], "filenames": ["demo.mkv"]}
self.assertEqual(adapter._save_files("pwd", detail, "/CloudSearch/Media"), ["1001"])
self.assertEqual(captured["transfer_dir"], "/CloudSearch/Media")
self.assertEqual(captured["list_dir"], "/CloudSearch/Media")
def test_baidu_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_baidu()
dirs = {"/A"}
created = []
adapter.get_files = lambda parent="/": [FileInfo(fid=parent.rstrip("/") + "/A", name="A", is_dir=True)] if parent == "/" else []
def fake_mkdir(path):
created.append(path)
dirs.add(path)
return {"code": 0, "data": {"path": path}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "/A/B")
self.assertEqual(created, ["/A/B"])
def test_xunlei_save_files_resolves_path_and_polls_restore_task(self):
adapter = self.make_xunlei()
adapter.ensure_dir = lambda path: "target-parent-id"
transfer = adapter._transfer
transfer._restore_files = lambda share_id, token, fids, parent_id="": "task-1" if parent_id == "target-parent-id" else (_ for _ in ()).throw(AssertionError(parent_id))
transfer._poll_restore_task = lambda task_id: {"old-1": "new-1"}
detail = {"share_id": "share", "pass_code_token": "pct", "files": [{"id": "old-1", "name": "demo"}]}
self.assertEqual(adapter._save_files("share", detail, "/Media"), ["new-1"])
def test_xunlei_query_task_delegates_to_restore_poll(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.query_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_does_not_advertise_permanent_delete_as_drive_delete_capability(self):
adapter = self.make_xunlei()
self.assertFalse(adapter.capabilities.get("delete_files"), "迅雷 batchDelete is permanent and must not be advertised as generic delete_files")
def test_xunlei_get_share_detail_passes_extraction_code_to_transfer_engine(self):
adapter = self.make_xunlei()
captured = {}
def fake_get_share_info(share_id, passcode=""):
captured["share_id"] = share_id
captured["passcode"] = passcode
return {"title": "demo", "files": [{"id": "old"}], "pass_code_token": "pct"}
adapter._transfer._get_share_info = fake_get_share_info
detail = adapter._get_share_detail("share-id", "abcd")
self.assertEqual(detail["title"], "demo")
self.assertEqual(captured, {"share_id": "share-id", "passcode": "abcd"})
def test_xunlei_create_share_forwards_requested_share_password(self):
adapter = self.make_xunlei()
captured = {}
def fake_create_share(file_ids, password=""):
captured["file_ids"] = file_ids
captured["password"] = password
return ("https://pan.xunlei.com/s/new", password)
adapter._transfer._create_share = fake_create_share
self.assertEqual(adapter._create_share(["fid-1"], "demo", "xy12"), ("https://pan.xunlei.com/s/new", "xy12"))
self.assertEqual(captured, {"file_ids": ["fid-1"], "password": "xy12"})
def test_xunlei_poll_task_alias_matches_declared_capability(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.poll_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_get_fids_is_read_only_and_does_not_create_missing_paths(self):
adapter = self.make_xunlei()
calls = []
def fake_get_files(parent="0"):
calls.append(("get_files", parent))
if parent in ("0", ""):
return [FileInfo(fid="fid-A", name="A", is_dir=True)]
if parent == "fid-A":
return [FileInfo(fid="fid-B", name="B", is_dir=False)]
return []
def fail_mkdir(*args, **kwargs):
raise AssertionError("get_fids must not create directories")
adapter.get_files = fake_get_files
adapter.mkdir = fail_mkdir
self.assertEqual(adapter.get_fids(["/A/B", "/A/Missing"]), [{"file_path": "/A/B", "fid": "fid-B"}])
self.assertEqual(calls, [("get_files", "0"), ("get_files", "fid-A"), ("get_files", "0"), ("get_files", "fid-A")])
def test_baidu_get_fids_checks_existing_parent_listing(self):
adapter = self.make_baidu()
calls = []
def fake_get_files(parent="/"):
calls.append(parent)
if parent == "/A":
return [FileInfo(fid="1001", name="movie.mkv", is_dir=False)]
return []
adapter.get_files = fake_get_files
self.assertEqual(adapter.get_fids(["/A/movie.mkv", "/A/missing.mkv"]), [{"file_path": "/A/movie.mkv", "fid": "1001", "path": "/A/movie.mkv"}])
self.assertEqual(calls, ["/A", "/A"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,135 @@
import subprocess
import unittest
from pathlib import Path
class TransferServerRuntimeTests(unittest.TestCase):
def test_transfer_image_imports_server_module_from_app_workdir(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config.json",
"cloudsearch-transfer:test",
"python", "-c",
"import cloudsearch_transfer.server as s; print(hasattr(s, 'app'))",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("True", result.stdout)
def test_update_platform_creates_config_with_package_import(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_server_test.json",
"cloudsearch-transfer:test",
"python", "-c",
"""
import os
os.environ['TRANSFER_API_TOKEN'] = 'test-token'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'test-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(resp.status_code)
print(resp.get_data(as_text=True))
""",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("200", result.stdout)
self.assertIn("newplatform", result.stdout)
def test_dockerfile_healthcheck_uses_http_health_probe(self):
dockerfile = Path(__file__).resolve().parents[1] / "Dockerfile"
content = dockerfile.read_text()
healthcheck_block = content.split("HEALTHCHECK", 1)[1].split("\n\nCMD", 1)[0]
self.assertIn("/health", healthcheck_block)
self.assertNotIn('python -m cloudsearch_transfer.server', healthcheck_block)
def test_config_update_requires_api_token_when_configured(self):
script = """
import os
os.environ['TRANSFER_API_TOKEN'] = 'secret-token'
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
unauth = client.put('/api/config/platforms/newplatform', json={'enabled': True})
auth = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'secret-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(unauth.status_code)
print(auth.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
self.assertIn("200", result.stdout)
def test_config_update_requires_token_by_default(self):
script = """
import os
os.environ.pop('TRANSFER_API_TOKEN', None)
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_default_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', json={'enabled': True})
print(resp.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_default_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
def test_config_save_persists_full_credentials_for_runtime_reload(self):
script = """
from cloudsearch_transfer.config import ConfigManager, PlatformConfig
path = '/tmp/transfer_config_persist_test.json'
cfg = ConfigManager(path)
cfg.platforms['quark'] = PlatformConfig(enabled=True, cookie='cookie-value-that-is-longer-than-twenty-characters', refresh_token='refresh-token-that-is-longer-than-twenty-characters')
cfg.save()
reloaded = ConfigManager(path)
print(reloaded.platforms['quark'].cookie)
print(reloaded.platforms['quark'].refresh_token)
"""
result = subprocess.run(
["docker", "run", "--rm", "cloudsearch-transfer:test", "python", "-c", script],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("cookie-value-that-is-longer-than-twenty-characters", result.stdout)
self.assertIn("refresh-token-that-is-longer-than-twenty-characters", result.stdout)
self.assertNotIn("...", result.stdout)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1 +1 @@
0.5.5 0.5.6
@@ -0,0 +1,58 @@
# CloudSearch 单容器网盘能力 P0 实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 将 UC 网盘转存能力和主应用云盘 driver 分发能力集成进 `source_clean`,由现有 `CloudSearch_App` 单容器构建和运行,不新增长期 transfer 容器。
**架构:** 在 Node/TypeScript 主应用内新增 `UcDriver` 和轻量 driver factory。`saveFromShare()`、凭据验证、连接测试仍走现有 `cloud_configs``save_records``/api/save` 路径,只扩展 `uc` 类型,不改变前端主流程。
**技术栈:** Node 20 fetch、TypeScript commonjs、Express、better-sqlite3、Vitest、Docker Compose。
---
## 文件结构
- 创建:`src/cloud/drivers/uc-api.ts` — UC API 常量、请求头、URL 解析、任务轮询辅助。
- 创建:`src/cloud/drivers/uc.driver.ts` — UC Cookie 验证、7 步转存、创建新分享,对齐现有 driver 返回结构。
- 创建:`src/cloud/drivers/uc.driver.test.ts` — UC driver 红绿回归测试,mock fetch 验证 API 流程。
- 创建:`src/cloud/driver-factory.ts` — 根据 `cloud_type` 创建 driver,避免 `credential.service.ts``cloud.service.ts` 分散 switch。
- 创建:`src/cloud/driver-factory.test.ts` — 验证 `uc` 使用 `UcDriver`,未知类型返回 `null`
- 修改:`src/cloud/cloud.service.ts` — 保存入口支持 `uc`,复用统一 driver factory。
- 修改:`src/cloud/credential.service.ts` — Cookie 测试和运行前凭据验证支持 `uc`
## 任务 1:写失败测试
- [ ] 编写 `src/cloud/driver-factory.test.ts`:断言 `createCloudDriver(uc, {cookie})` 产物 class 为 `UcDriver``unknown``null`
- [ ] 编写 `src/cloud/drivers/uc.driver.test.ts`:断言 UC URL 解析、Cookie 长度校验、mock fetch 的完整转存链路。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
- [ ] 预期:失败,原因是 `driver-factory``uc.driver` 不存在。
## 任务 2:实现 UC driver 和 factory
- [ ] 新增 `uc-api.ts``UC_API_BASE=https://pc-api.uc.cn``UC_SHARE_API``getUcHeaders()``parseUcShareUrl()`
- [ ] 新增 `uc.driver.ts`:实现 `validate()``saveFromShare()`,流程为 stoken、detail、save、poll save task、create share、poll share task、set password。
- [ ] 新增 `driver-factory.ts`:支持 `quark``baidu``aliyun``uc`
- [ ] 运行同一组测试,预期通过。
## 任务 3:接入主保存和凭据验证路径
- [ ] 修改 `cloud.service.ts`:用 factory 创建 driver`uc` 调用 `saveFromShare()`,返回字段写入现有 `save_records`
- [ ] 修改 `credential.service.ts``testCloudConnectionWithCookie(uc)``getAndValidateCredential(uc)` 使用 `UcDriver.validate()`
- [ ] 新增或扩展测试覆盖 factory 接入。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
## 任务 4:构建与单容器部署验证
- [ ] 运行:`npm run build`
- [ ] 运行:`docker compose build app`
- [ ] 运行:`docker compose up -d app`
- [ ] 验证:`docker compose ps` 只有 `app/redis/pansou`,没有 transfer 容器。
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/health`
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/api/admin/cloud-types` 能看到 `uc`
## 验收标准
- `CloudSearch_App` 单容器内的主应用构建包含 `UcDriver`
- `/api/save``target_cloud=uc` 不再返回“暂不支持”。
- 无长期 `cloudsearch-transfer``cloud-auto-save-test` 容器。
- 没有收益上报、签到、独立 transfer 服务集成。
+39 -4
View File
@@ -3,16 +3,51 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="robots" content="index,follow" />
<meta property="og:type" content="website" />
<meta property="og:title" content="CloudSearch - 网盘资源搜索" />
<meta property="og:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta property="og:url" content="https://zy.hk.timxx.cn/" />
<meta property="og:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="CloudSearch - 网盘资源搜索" />
<meta name="twitter:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="twitter:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<link rel="canonical" href="https://zy.hk.timxx.cn/" />
<title>CloudSearch - 网盘资源搜索</title> <title>CloudSearch - 网盘资源搜索</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script> <script>
(function() { (function() {
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){ function setMeta(selector, attr, value) {
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索'; var el = document.querySelector(selector);
}).catch(function(){}); if (!el) return;
el.setAttribute(attr, value);
}
function sync(cfg) {
var origin = window.location.origin;
var siteName = cfg.site_name || 'CloudSearch';
var title = siteName + ' - 网盘资源搜索';
var description = cfg.site_disclaimer || '随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。';
var image = cfg.site_logo ? new URL(cfg.site_logo, origin).href : origin + '/api/uploads/logo/site_logo.png';
var canonical = origin + '/';
document.title = title;
setMeta('meta[name="description"]', 'content', description);
setMeta('meta[name="robots"]', 'content', 'index,follow');
setMeta('meta[property="og:title"]', 'content', title);
setMeta('meta[property="og:description"]', 'content', description);
setMeta('meta[property="og:url"]', 'content', canonical);
setMeta('meta[property="og:image"]', 'content', image);
setMeta('meta[name="twitter:title"]', 'content', title);
setMeta('meta[name="twitter:description"]', 'content', description);
setMeta('meta[name="twitter:image"]', 'content', image);
setMeta('link[rel="canonical"]', 'href', canonical);
}
sync({});
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){ sync(cfg || {}); }).catch(function(){});
})(); })();
</script> </script>
</head> </head>
@@ -20,4 +55,4 @@
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+387
View File
@@ -23,6 +23,7 @@
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
} }
}, },
@@ -869,6 +870,121 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": { "node_modules/@volar/language-core": {
"version": "2.4.15", "version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
@@ -1086,6 +1202,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/async-validator": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1121,6 +1246,15 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1141,6 +1275,31 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"engines": {
"node": ">= 16"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -1194,6 +1353,23 @@
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true "dev": true
}, },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decamelize": { "node_modules/decamelize": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@@ -1202,6 +1378,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1294,6 +1479,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
},
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -1362,6 +1553,15 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1568,6 +1768,12 @@
"lodash-es": "*" "lodash-es": "*"
} }
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1623,6 +1829,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1698,6 +1910,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1845,6 +2072,12 @@
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1853,6 +2086,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1877,6 +2122,45 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
@@ -1960,6 +2244,93 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vscode-uri": { "node_modules/vscode-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -2060,6 +2431,22 @@
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+3 -1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"test": "vitest run",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -24,6 +25,7 @@
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
} }
} }
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://zy.hk.timxx.cn/sitemap.xml
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://zy.hk.timxx.cn/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/disclaimer/</loc>
<changefreq>monthly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/admin/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/user/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
</urlset>
+43
View File
@@ -3,6 +3,49 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { applySeo, resolveSeo } from './seo'
interface SiteConfig {
site_name?: string
site_logo?: string
site_disclaimer?: string
}
const route = useRoute()
let currentSiteName = 'CloudSearch'
let currentSiteLogo = ''
let currentSiteDisclaimer = ''
function refreshSeo() {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
applySeo(resolveSeo(route.path, route.query as Record<string, unknown>, currentSiteName, origin, currentSiteLogo, currentSiteDisclaimer))
}
async function loadSiteConfig() {
try {
const resp = await fetch('/api/site-config')
if (!resp.ok) return
const cfg = (await resp.json()) as SiteConfig
if (cfg.site_name) currentSiteName = cfg.site_name
if (cfg.site_logo) currentSiteLogo = cfg.site_logo
if (cfg.site_disclaimer) currentSiteDisclaimer = cfg.site_disclaimer
} catch {
// keep defaults
}
}
onMounted(async () => {
await loadSiteConfig()
refreshSeo()
})
watch(
() => route.fullPath,
() => refreshSeo(),
{ immediate: true },
)
</script> </script>
<style> <style>
@@ -0,0 +1,42 @@
<template>
<main class="disclaimer-page">
<section class="disclaimer-card">
<h1>免责声明</h1>
<p>CloudSearch 仅聚合和展示公开网络资源线索不存储不上传不分发任何网盘文件内容</p>
<p>搜索结果来自第三方公开页面或接口资源有效性合法性与安全性需由用户自行判断</p>
<p>请勿使用本站搜索保存或传播侵权违法违规内容如权利人认为相关信息侵犯权益请联系站点管理员处理</p>
<router-link class="back-link" to="/">返回首页</router-link>
</section>
</main>
</template>
<style scoped>
.disclaimer-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
background: #f8fafc;
}
.disclaimer-card {
max-width: 760px;
padding: 32px;
border-radius: 18px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
color: #334155;
line-height: 1.8;
}
.disclaimer-card h1 {
margin: 0 0 20px;
color: #0f172a;
}
.back-link {
display: inline-flex;
margin-top: 20px;
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
</style>
@@ -1,12 +1,13 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<div class="hero-section"> <div class="hero-section">
<h1 class="sr-only">网盘资源搜索</h1>
<template v-if="configLoaded"> <template v-if="configLoaded">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" /> <img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div> <div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
</template> </template>
<div class="search-box"> <div class="search-box">
<el-input v-model="query" placeholder="搜索网盘资源..." size="large" clearable @keyup.enter="handleSearch"> <el-input v-model="query" aria-label="搜索网盘资源" name="q" placeholder="搜索网盘资源..." size="large" clearable @keyup.enter="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">搜索</el-button> <el-button type="primary" size="large" @click="handleSearch" class="search-btn">搜索</el-button>
@@ -27,6 +28,7 @@
<div class="footer-inner">{{ siteDisclaimer }}</div> <div class="footer-inner">{{ siteDisclaimer }}</div>
<el-button class="footer-btn" size="small" @click="openDisclaimer">免责声明</el-button> <el-button class="footer-btn" size="small" @click="openDisclaimer">免责声明</el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -35,6 +37,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { getCategorizedRankings, getSiteConfig } from '../api' import { getCategorizedRankings, getSiteConfig } from '../api'
import { createDisclaimerTarget, createSearchPath } from '../utils/home-helpers'
const router = useRouter() const router = useRouter()
const query = ref('') const query = ref('')
@@ -50,9 +53,9 @@ const QS = ['学而时习之,不亦说乎。', '温故而知新,可以为师
const CS = ['#e74c3c', '#e67e22', '#f39c12', '#27ae60', '#2980b9', '#8e44ad', '#2c3e50', '#16a085', '#d35400', '#c0392b', '#1abc9c', '#3498db', '#9b59b6', '#34495e', '#e91e63', '#009688', '#ff5722', '#795548', '#607d8b', '#673ab7'] const CS = ['#e74c3c', '#e67e22', '#f39c12', '#27ae60', '#2980b9', '#8e44ad', '#2c3e50', '#16a085', '#d35400', '#c0392b', '#1abc9c', '#3498db', '#9b59b6', '#34495e', '#e91e63', '#009688', '#ff5722', '#795548', '#607d8b', '#673ab7']
const SS = [48,42,38,34,30,28,26,24,22,20,18,17,16,15,14,13,12] const SS = [48,42,38,34,30,28,26,24,22,20,18,17,16,15,14,13,12]
function handleSearch() { const q = query.value.trim(); if(q) router.push('/search?q='+encodeURIComponent(q)) } function handleSearch() { const path = createSearchPath(query.value); if(path) router.push(path) }
function searchTag(t:string) { router.push('/search?q='+encodeURIComponent(t)) } function searchTag(t:string) { const path = createSearchPath(t); if(path) router.push(path) }
function openDisclaimer() { window.open('/disclaimer/', '_blank') } function openDisclaimer() { window.location.assign(createDisclaimerTarget()) }
onMounted(async () => { onMounted(async () => {
currentQuote.value = QS[Math.floor(Math.random()*QS.length)] currentQuote.value = QS[Math.floor(Math.random()*QS.length)]
@@ -81,6 +84,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.home-page{min-height:100vh;background:#f5f7fa} .home-page{min-height:100vh;background:#f5f7fa}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.hero-section{text-align:center;padding:56px 24px 36px;background:#f5f7fa} .hero-section{text-align:center;padding:56px 24px 36px;background:#f5f7fa}
.logo-text{font-size:48px;font-weight:700;color:#1d2129;margin-bottom:24px} .logo-text{font-size:48px;font-weight:700;color:#1d2129;margin-bottom:24px}
.logo-img{display:block;max-width:400px;max-height:100px;margin:0 auto 24px} .logo-img{display:block;max-width:400px;max-height:100px;margin:0 auto 24px}
@@ -0,0 +1,27 @@
<template>
<main class="not-found-page">
<section class="not-found-card">
<p class="eyebrow">404</p>
<h1>页面不存在</h1>
<p class="description">你访问的页面不存在或已被移动</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</section>
</main>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
router.push('/')
}
</script>
<style scoped>
.not-found-page{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f5f7fa;padding:24px}
.not-found-card{width:min(460px,100%);padding:40px 32px;text-align:center;background:#fff;border-radius:18px;box-shadow:0 12px 32px rgba(31,45,61,.08)}
.eyebrow{margin:0 0 8px;color:#409eff;font-size:14px;font-weight:700;letter-spacing:4px}
h1{margin:0 0 12px;font-size:28px;color:#1d2129}
.description{margin:0 0 24px;color:#606266;line-height:1.7}
</style>
+14 -3
View File
@@ -1,6 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
const routes = [ export const routes = [
{ {
path: '/', path: '/',
name: 'home', name: 'home',
@@ -16,6 +16,12 @@ const routes = [
name: 'result-detail', name: 'result-detail',
component: () => import('./pages/ResultDetail.vue'), component: () => import('./pages/ResultDetail.vue'),
}, },
{
path: '/disclaimer',
alias: '/disclaimer/',
name: 'disclaimer',
component: () => import('./pages/Disclaimer.vue'),
},
{ {
path: '/admin/login', path: '/admin/login',
name: 'admin-login', name: 'admin-login',
@@ -71,10 +77,15 @@ const routes = [
path: '/user', path: '/user',
redirect: '/user/login', redirect: '/user/login',
}, },
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('./pages/NotFound.vue'),
},
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: typeof window === 'undefined' ? createMemoryHistory() : createWebHistory(),
routes, routes,
}) })
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveSeo } from './seo'
describe('resolveSeo', () => {
it('builds home page seo for the site root', () => {
const seo = resolveSeo('/', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('随便下 | 资源小站 - 网盘资源搜索')
expect(seo.description).toContain('网盘资源搜索')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/')
expect(seo.robots).toBe('index,follow')
})
it('builds search page seo from the q parameter', () => {
const seo = resolveSeo('/search', { q: '阿凡达' }, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('阿凡达 - 搜索结果 | 随便下 | 资源小站')
expect(seo.description).toContain('阿凡达')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/search?q=%E9%98%BF%E5%87%A1%E8%BE%BE')
})
it('marks admin pages as noindex', () => {
const seo = resolveSeo('/admin/login', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.robots).toBe('noindex,nofollow')
expect(seo.title).toContain('管理后台')
})
})
+125
View File
@@ -0,0 +1,125 @@
export interface SeoState {
title: string;
description: string;
canonical: string;
image: string;
robots: string;
}
function getSearchQuery(query: Record<string, unknown>): string {
const q = query.q;
if (typeof q === 'string') return q.trim();
const input = query.input;
if (typeof input === 'string') return input.trim();
return '';
}
function normalizePath(pathname: string): string {
if (!pathname) return '/';
if (!pathname.startsWith('/')) return `/${pathname}`;
return pathname;
}
function buildCanonical(origin: string, pathname: string, query: Record<string, unknown>): string {
const base = origin ? origin.replace(/\/+$/, '') : '';
const normalizedPath = normalizePath(pathname);
const params = new URLSearchParams();
if (normalizedPath.startsWith('/search')) {
const q = getSearchQuery(query);
if (q) params.set('q', q);
}
const suffix = params.toString() ? `?${params.toString()}` : '';
return `${base}${normalizedPath}${suffix}` || normalizedPath;
}
export function resolveSeo(
pathname: string,
query: Record<string, unknown> = {},
siteName = 'CloudSearch',
origin = '',
siteLogo = '',
siteDisclaimer = '',
): SeoState {
const normalizedPath = normalizePath(pathname);
const q = getSearchQuery(query);
const titleSuffix = `${siteName} - 网盘资源搜索`;
let title = titleSuffix;
let description = `${siteName} 提供网盘资源搜索、热门榜单和内容聚合。`;
let robots = 'index,follow';
if (normalizedPath.startsWith('/search')) {
if (q) {
title = `${q} - 搜索结果 | ${siteName}`;
description = `搜索 ${q} 的网盘资源结果,查看链接、分类和保存记录。`;
} else {
title = `搜索结果 | ${siteName}`;
description = `${siteName} 的网盘资源搜索结果页。`;
}
} else if (normalizedPath.startsWith('/result/')) {
title = `资源详情 | ${siteName}`;
description = `查看资源详情、链接信息和关联内容。`;
} else if (normalizedPath.startsWith('/disclaimer')) {
title = `免责声明 | ${siteName}`;
description = siteDisclaimer || `查看 ${siteName} 的免责声明和使用说明。`;
} else if (normalizedPath.startsWith('/admin')) {
title = `管理后台 | ${siteName}`;
description = `访问 ${siteName} 的管理后台。`;
robots = 'noindex,nofollow';
} else if (normalizedPath.startsWith('/user')) {
title = `用户中心 | ${siteName}`;
description = `访问 ${siteName} 的用户中心。`;
robots = 'noindex,nofollow';
}
const canonical = buildCanonical(origin, normalizedPath, query);
const image = siteLogo
? new URL(siteLogo, origin || 'https://zy.hk.timxx.cn').href
: `${origin ? origin.replace(/\/+$/, '') : ''}/api/uploads/logo/site_logo.png` || '/api/uploads/logo/site_logo.png';
return {
title,
description,
canonical,
image,
robots,
};
}
function setMeta(selector: string, attr: string, value: string): void {
if (typeof document === 'undefined') return;
let el = document.querySelector(selector) as HTMLMetaElement | HTMLLinkElement | null;
if (!el) {
el = document.createElement(selector.startsWith('link') ? 'link' : 'meta');
if (selector.startsWith('meta[name="')) {
const name = selector.match(/meta\[name="([^"]+)"\]/)?.[1];
if (name) (el as HTMLMetaElement).setAttribute('name', name);
}
if (selector.startsWith('meta[property="')) {
const property = selector.match(/meta\[property="([^"]+)"\]/)?.[1];
if (property) (el as HTMLMetaElement).setAttribute('property', property);
}
if (selector.startsWith('link[')) {
const rel = selector.match(/link\[rel="([^"]+)"\]/)?.[1];
if (rel) (el as HTMLLinkElement).setAttribute('rel', rel);
}
document.head.appendChild(el);
}
el.setAttribute(attr, value);
}
export function applySeo(seo: SeoState): void {
if (typeof document === 'undefined') return;
document.title = seo.title;
setMeta('meta[name="description"]', 'content', seo.description);
setMeta('meta[name="robots"]', 'content', seo.robots);
setMeta('meta[property="og:title"]', 'content', seo.title);
setMeta('meta[property="og:description"]', 'content', seo.description);
setMeta('meta[property="og:type"]', 'content', 'website');
setMeta('meta[property="og:url"]', 'content', seo.canonical);
setMeta('meta[property="og:image"]', 'content', seo.image);
setMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
setMeta('meta[name="twitter:title"]', 'content', seo.title);
setMeta('meta[name="twitter:description"]', 'content', seo.description);
setMeta('meta[name="twitter:image"]', 'content', seo.image);
setMeta('link[rel="canonical"]', 'href', seo.canonical);
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { createDisclaimerTarget, createSearchPath, getHealthDisplay } from './home-helpers';
describe('home helpers', () => {
it('builds an encoded search path from trimmed user input', () => {
expect(createSearchPath(' 周杰伦 演唱会 ')).toBe('/search?q=%E5%91%A8%E6%9D%B0%E4%BC%A6%20%E6%BC%94%E5%94%B1%E4%BC%9A');
expect(createSearchPath(' ')).toBeNull();
});
it('uses same-tab navigation for the full disclaimer page', () => {
expect(createDisclaimerTarget()).toBe('/disclaimer');
});
it('marks optional video parser gaps as a test-environment note, not a core outage', () => {
const display = getHealthDisplay('ok', {
videoParser: 'unreachable',
videoParserRequired: false,
});
expect(display.isCoreHealthy).toBe(true);
expect(display.label).toBe('核心服务正常');
expect(display.note).toContain('视频解析');
expect(display.note).toContain('测试环境');
});
});
@@ -0,0 +1,35 @@
export interface HealthDisplayComponents {
videoParser?: string;
videoParserRequired?: boolean;
}
export interface HealthDisplay {
label: string;
isCoreHealthy: boolean;
note: string;
}
export function createSearchPath(query: string): string | null {
const trimmed = query.trim();
if (!trimmed) return null;
return `/search?q=${encodeURIComponent(trimmed)}`;
}
export function createDisclaimerTarget(path = '/disclaimer'): string {
return path;
}
export function getHealthDisplay(status: string, components: HealthDisplayComponents = {}): HealthDisplay {
const isCoreHealthy = status === 'ok' || (!components.videoParserRequired && status === 'degraded');
const label = isCoreHealthy ? '核心服务正常' : status === 'degraded' ? '部分服务降级' : '服务异常';
const videoParserOptionalGap = !components.videoParserRequired
&& components.videoParser
&& components.videoParser !== 'ok';
return {
label,
isCoreHealthy,
note: videoParserOptionalGap ? '视频解析仍在开发中,当前测试环境未启用,不影响核心搜索服务。' : '',
};
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { createRouter, createMemoryHistory } from 'vue-router';
import { routes } from '../router';
describe('application routes', () => {
function makeRouter() {
return createRouter({ history: createMemoryHistory(), routes });
}
it('resolves public and admin routes before the catch-all not-found route', () => {
const router = makeRouter();
expect(router.resolve('/').name).toBe('home');
expect(router.resolve('/search?q=test').name).toBe('search');
expect(router.resolve('/result/abc123').name).toBe('result-detail');
expect(router.resolve('/admin/login').name).toBe('admin-login');
expect(router.resolve('/admin/dashboard').matched.some(route => route.name === 'admin-dashboard')).toBe(true);
});
it('has an explicit disclaimer route instead of relying on the not-found fallback', () => {
const router = makeRouter();
expect(router.resolve('/disclaimer').name).toBe('disclaimer');
expect(router.resolve('/disclaimer/').name).toBe('disclaimer');
});
it('resolves unknown frontend routes to the not-found page', () => {
const router = makeRouter();
expect(router.resolve('/nonexistent-ui-audit').name).toBe('not-found');
expect(router.resolve('/unknown/path').name).toBe('not-found');
});
});
@@ -0,0 +1,14 @@
const KNOWN_PUBLIC_ROUTE_PATTERNS = [
/^\/$/,
/^\/search\/?$/,
/^\/result\/[^/]+\/?$/,
/^\/admin\/login\/?$/,
/^\/user\/login\/?$/,
/^\/user\/dashboard\/?$/,
/^\/user\/?$/,
];
export function isKnownPublicRoute(path: string): boolean {
const cleanPath = path.split('?')[0].split('#')[0] || '/';
return KNOWN_PUBLIC_ROUTE_PATTERNS.some(pattern => pattern.test(cleanPath));
}
+7 -17
View File
@@ -1,8 +1,7 @@
import { getDb } from '../database/database'; import { getDb } from '../database/database';
import { localTimestamp, formatLocalDateTime } from '../utils/time'; import { localTimestamp, formatLocalDateTime } from '../utils/time';
import { getSystemConfig } from '../admin/system-config.service'; import { getSystemConfig } from '../admin/system-config.service';
import { QuarkDriver } from './drivers/quark.driver'; import { createCloudDriver, supportsSaveFromShare } from './driver-factory';
import { BaiduDriver } from './drivers/baidu.driver';
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
import { lookupIpLocation } from './ip-lookup'; import { lookupIpLocation } from './ip-lookup';
import { notifyConfigEvent } from './notification.service'; import { notifyConfigEvent } from './notification.service';
@@ -173,22 +172,13 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
try { try {
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string }; let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
switch (cloudType) { const driver = createCloudDriver(cloudType, { cookie: config.cookie!, nickname: config.nickname });
case 'quark': { if (!supportsSaveFromShare(driver)) {
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname }); return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
driverResult = await driver.saveFromShare(shareUrl, sourceTitle, retrySave);
break;
}
case 'baidu': {
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
break;
}
case 'aliyun':
return { success: false, message: '阿里云盘保存功能暂未实现' };
default:
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
} }
driverResult = cloudType === 'quark'
? await driver.saveFromShare(shareUrl, sourceTitle, retrySave)
: await driver.saveFromShare(shareUrl, sourceTitle);
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
+37 -20
View File
@@ -1,4 +1,5 @@
import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api'; import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api';
import { createCloudDriver, supportsValidate } from './driver-factory';
import { getDb } from '../database/database'; import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto'; import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time'; import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
@@ -237,11 +238,13 @@ export async function testCloudConnection(id: number): Promise<{
let storageUsed = config.storage_used || ''; let storageUsed = config.storage_used || '';
let storageTotal = config.storage_total || ''; let storageTotal = config.storage_total || '';
if (config.cloud_type === 'baidu') { const driver = createCloudDriver(config.cloud_type, { cookie: cookie, nickname: config.nickname }) as any;
const { BaiduDriver } = require('./drivers/baidu.driver'); if (supportsValidate(driver)) {
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate(); valid = await driver.validate();
if (valid) { }
if (valid) {
if (config.cloud_type === 'baidu' && typeof driver.getUserInfo === 'function') {
const info = await driver.getUserInfo(); const info = await driver.getUserInfo();
if (info) { if (info) {
nickname = config.nickname || info.nickname || '百度网盘'; nickname = config.nickname || info.nickname || '百度网盘';
@@ -249,19 +252,24 @@ export async function testCloudConnection(id: number): Promise<{
storageUsed = fmt(info.usedBytes); storageUsed = fmt(info.usedBytes);
storageTotal = fmt(info.totalBytes); storageTotal = fmt(info.totalBytes);
} }
} } else if (config.cloud_type === 'quark') {
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘'; nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘';
const storage = await driver.getStorageInfoQuick(); if (typeof driver.getStorageInfoQuick === 'function') {
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || ''); const storage = await driver.getStorageInfoQuick();
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || ''); storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
}
} else if (config.cloud_type === 'uc') {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || 'UC网盘';
} else if (config.cloud_type === 'aliyun') {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || '阿里云盘';
} }
} }
if (valid && !nickname) {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || config.cloud_type;
}
const db = getDb(); const db = getDb();
if (!valid) { if (!valid) {
db.prepare( db.prepare(
@@ -342,6 +350,18 @@ export async function testCloudConnectionWithCookie(cloudType: string, cookie: s
message: nickname ? '连接成功' : '连接成功(无法获取昵称)', message: nickname ? '连接成功' : '连接成功(无法获取昵称)',
nickname: nickname || '阿里云盘', nickname: nickname || '阿里云盘',
}; };
} else if (cloudType === 'uc') {
const driver = createCloudDriver('uc', { cookie, nickname: '' }) as any;
const valid = supportsValidate(driver) ? await driver.validate() : false;
if (!valid) {
return { success: false, message: '连接失败:UC Cookie 无效或长度不足' };
}
const nickname = typeof driver.getNickname === 'function' ? await driver.getNickname() : 'UC网盘';
return {
success: true,
message: '连接成功',
nickname: nickname || 'UC网盘',
};
} else { } else {
return { return {
success: true, success: true,
@@ -405,14 +425,11 @@ export async function getAndValidateCredential(cloudType: string): Promise<Crede
try { try {
let cookieValid = false; let cookieValid = false;
if (cloudType === 'baidu') { const driver = createCloudDriver(cloudType, { cookie: cookie, nickname: config.nickname });
const { BaiduDriver } = require('./drivers/baidu.driver'); if (supportsValidate(driver)) {
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate();
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate(); cookieValid = await driver.validate();
} else if (cloudType === 'aliyun') {
cookieValid = true;
} }
if (!cookieValid) { if (!cookieValid) {
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
let tempDir = '';
async function loadCredentialService() {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudsearch-credential-'));
process.env.DB_PATH = path.join(tempDir, 'cloudsearch.db');
process.env.DATA_DIR = tempDir;
process.env.COOKIE_ENCRYPTION_KEY = 'test-cookie-encryption-key-000000000000';
const service = await import('./credential.service');
return service;
}
describe('credential service UC integration', () => {
beforeEach(async () => {
await import('vitest').then(({ vi }) => vi.resetModules());
});
afterEach(async () => {
await import('vitest').then(({ vi }) => vi.resetModules());
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
delete process.env.DB_PATH;
delete process.env.DATA_DIR;
delete process.env.COOKIE_ENCRYPTION_KEY;
});
it('tests a saved Xunlei cloud config through the Xunlei driver instead of the unsupported fallback', async () => {
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
const saved = saveCloudConfig({
cloud_type: 'xunlei',
cookie: 'r'.repeat(32),
nickname: 'Xunlei Account',
is_active: 1,
});
const result = await testCloudConnection(saved.id);
expect(result).toMatchObject({
success: true,
message: '连接成功',
nickname: 'Xunlei Account',
});
});
it('tests a saved UC cloud config through the UC driver instead of the Quark fallback', async () => {
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
const saved = saveCloudConfig({
cloud_type: 'uc',
cookie: 'k=' + 'x'.repeat(80),
nickname: 'UC Account',
is_active: 1,
});
const result = await testCloudConnection(saved.id);
expect(result).toMatchObject({
success: true,
message: '连接成功',
nickname: 'UC Account',
});
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
const cloudDir = path.resolve(__dirname);
const ucDir = path.join(cloudDir, 'drivers', 'uc');
const xunleiDir = path.join(cloudDir, 'drivers', 'xunlei');
const legacyUcFiles = [
path.join(cloudDir, 'drivers', 'uc.driver.ts'),
path.join(cloudDir, 'drivers', 'uc-api.ts'),
];
function readTsFiles(dir: string): Array<{ file: string; content: string }> {
if (!fs.existsSync(dir)) return [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: Array<{ file: string; content: string }> = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...readTsFiles(full));
if (entry.isFile() && full.endsWith('.ts')) files.push({ file: full, content: fs.readFileSync(full, 'utf8') });
}
return files;
}
describe('cloud driver module boundaries', () => {
it('keeps UC implementation in its own driver folder', () => {
expect(fs.existsSync(path.join(ucDir, 'driver.ts'))).toBe(true);
expect(fs.existsSync(path.join(ucDir, 'api.ts'))).toBe(true);
});
it('does not keep UC implementation in the flat drivers directory', () => {
for (const file of legacyUcFiles) {
if (!fs.existsSync(file)) continue;
const content = fs.readFileSync(file, 'utf8');
expect(content.trim()).toMatch(/^export \* from '\.\/uc\//);
}
});
it('does not let the UC module import or require Quark modules', () => {
const offenders = readTsFiles(ucDir).filter(({ content }) => /from ['"].*quark|require\(['"].*quark/.test(content));
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
});
it('keeps Xunlei implementation in its own driver folder', () => {
expect(fs.existsSync(path.join(xunleiDir, 'driver.ts'))).toBe(true);
expect(fs.existsSync(path.join(xunleiDir, 'api.ts'))).toBe(true);
});
it('does not let the Xunlei module import or require Quark/UC modules', () => {
const offenders = readTsFiles(xunleiDir).filter(({ content }) => /from ['"].*(quark|uc)|require\(['"].*(quark|uc)/.test(content));
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { createCloudDriver } from './driver-factory';
import { UcDriver } from './drivers/uc/driver';
import { XunleiDriver } from './drivers/xunlei/driver';
describe('createCloudDriver', () => {
it('creates a UC driver inside the main app process', () => {
const driver = createCloudDriver('uc', { cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' });
expect(driver).toBeInstanceOf(UcDriver);
});
it('creates a Xunlei driver inside the main app process', () => {
const driver = createCloudDriver('xunlei', { refreshToken: 'r'.repeat(32), nickname: 'xl-test' });
expect(driver).toBeInstanceOf(XunleiDriver);
});
it('returns null for unsupported cloud types', () => {
expect(createCloudDriver('unsupported-drive', { cookie: 'x' })).toBeNull();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { QuarkDriver, QuarkConfig } from './drivers/quark.driver';
import { BaiduDriver, BaiduConfig } from './drivers/baidu.driver';
import { AliyunDriver, AliyunConfig } from './drivers/aliyun.driver';
import { UcDriver, UcConfig } from './drivers/uc/driver';
import { XunleiDriver, XunleiConfig } from './drivers/xunlei/driver';
export type CloudDriver = QuarkDriver | BaiduDriver | AliyunDriver | UcDriver | XunleiDriver;
export type CloudDriverConfig = QuarkConfig | BaiduConfig | AliyunConfig | UcConfig | XunleiConfig;
export function createCloudDriver(cloudType: string, config: CloudDriverConfig): CloudDriver | null {
switch (cloudType) {
case 'quark':
return new QuarkDriver(config as QuarkConfig);
case 'baidu':
return new BaiduDriver(config as BaiduConfig);
case 'aliyun':
return new AliyunDriver(config as AliyunConfig);
case 'uc':
return new UcDriver(config as UcConfig);
case 'xunlei':
return new XunleiDriver(config as XunleiConfig);
default:
return null;
}
}
export function supportsSaveFromShare(driver: CloudDriver | null): driver is CloudDriver & { saveFromShare: (...args: any[]) => Promise<any> } {
return !!driver && typeof (driver as any).saveFromShare === 'function';
}
export function supportsValidate(driver: CloudDriver | null): driver is CloudDriver & { validate: () => Promise<boolean> } {
return !!driver && typeof (driver as any).validate === 'function';
}
+1
View File
@@ -0,0 +1 @@
export * from './uc/api';
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseUcShareUrl, UC_API_BASE, UC_SHARE_API } from './uc/api';
import { UcDriver } from './uc/driver';
class MockResponse {
ok = true;
status = 200;
constructor(private payload: any) {}
async json() { return this.payload; }
}
describe('UcDriver', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('parses UC share ids from drive.uc.cn links', () => {
expect(parseUcShareUrl('https://drive.uc.cn/s/abcdef')).toBe('abcdef');
expect(parseUcShareUrl('https://example.com/s/abcdef')).toBeNull();
});
it('validates cookie presence with the same lightweight rule used by transfer reference', async () => {
await expect(new UcDriver({ cookie: 'too-short' }).validate()).resolves.toBe(false);
await expect(new UcDriver({ cookie: 'k=' + 'x'.repeat(80) }).validate()).resolves.toBe(true);
});
it('runs the UC save/share API flow and returns CloudSearch SaveResult shape', async () => {
const calls: Array<{ url: string; method?: string; body?: any }> = [];
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: any, init: any = {}) => {
const url = String(input);
const body = init.body ? JSON.parse(String(init.body)) : undefined;
calls.push({ url, method: init.method, body });
if (url.startsWith(`${UC_SHARE_API}/sharepage/v2/detail`)) {
return new MockResponse({ status: 0, data: { token_info: { stoken: 'stoken-1' } } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/sharepage/detail`)) {
return new MockResponse({ status: 0, data: { title: 'Demo Folder', fid: 'src-fid', share_fid_token: 'src-token' } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/sharepage/save`)) {
expect(body).toMatchObject({
fid_list: ['src-fid'],
fid_token_list: ['src-token'],
to_pdir_fid: '0',
pwd_id: 'abcdef',
stoken: 'stoken-1',
});
return new MockResponse({ status: 0, data: { task_id: 'save-task' } }) as any;
}
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('save-task')) {
return new MockResponse({ status: 200, data: { status: 2, save_as: { save_as_top_fids: ['new-fid'] } } }) as any;
}
if (url === UC_SHARE_API) {
expect(body).toMatchObject({ fid_list: ['new-fid'], title: 'Demo Folder' });
return new MockResponse({ status: 0, data: { task_id: 'share-task' } }) as any;
}
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('share-task')) {
return new MockResponse({ status: 200, data: { status: 2, share_id: 'new-share-id' } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/password`)) {
expect(body).toMatchObject({ share_id: 'new-share-id' });
return new MockResponse({ status: 0, data: { share_url: 'https://drive.uc.cn/s/newshare', passcode: 'pw12' } }) as any;
}
throw new Error(`Unexpected fetch: ${url}`);
});
const result = await new UcDriver({ cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' }).saveFromShare('https://drive.uc.cn/s/abcdef');
expect(result).toMatchObject({
success: true,
message: '转存成功',
shareUrl: 'https://drive.uc.cn/s/newshare',
sharePwd: 'pw12',
folderName: 'Demo Folder',
fileCount: 1,
folderCount: 0,
});
expect(fetchMock).toHaveBeenCalledTimes(7);
expect(calls.map(c => c.url)).toContain(UC_SHARE_API);
});
});
@@ -0,0 +1 @@
export * from './uc/driver';
+44
View File
@@ -0,0 +1,44 @@
// UC 网盘 API 常量与轻量工具
export const UC_API_BASE = 'https://pc-api.uc.cn';
export const UC_WEB_HOST = 'https://drive.uc.cn';
export const UC_SHARE_API = `${UC_API_BASE}/1/clouddrive/share`;
export const UC_EP = {
SHARE_PAGE_TOKEN: '/sharepage/v2/detail',
SHARE_PAGE_DETAIL: '/sharepage/detail',
SHARE_PAGE_SAVE: '/sharepage/save',
SHARE_PASSWORD: '/password',
TASK: '/1/clouddrive/task',
} as const;
export function getUcHeaders(cookie: string): Record<string, string> {
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',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Cookie': cookie,
'Referer': `${UC_WEB_HOST}/`,
'Origin': UC_WEB_HOST,
};
}
export function parseUcShareUrl(shareUrl: string): string | null {
try {
const url = new URL(shareUrl);
if (!url.hostname.includes('drive.uc.cn')) return null;
const match = url.pathname.match(/\/s\/([a-zA-Z0-9_-]+)/);
return match?.[1] || null;
} catch {
const match = shareUrl.match(/drive\.uc\.cn\/s\/([a-zA-Z0-9_-]+)/);
return match?.[1] || null;
}
}
export function getUcCommonParams(extra: Record<string, string | number> = {}): URLSearchParams {
return new URLSearchParams({
pr: 'UCBrowser',
fr: 'pc',
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, String(v)])),
});
}
+207
View File
@@ -0,0 +1,207 @@
import { getUcCommonParams, getUcHeaders, parseUcShareUrl, UC_API_BASE, UC_EP, UC_SHARE_API } from './api';
export interface UcConfig {
cookie?: string;
nickname?: string;
}
interface UcDetail {
title?: string;
fid?: string;
fid_list?: string[];
share_fid_token?: string;
fid_token_list?: string[];
[key: string]: any;
}
interface UcDriverResult {
success: boolean;
message: string;
shareUrl?: string;
sharePwd?: string;
folderName?: string;
fileCount?: number;
folderCount?: number;
}
export class UcDriver {
private config: UcConfig;
constructor(config: UcConfig = {}) {
this.config = { ...config };
}
private get cookie(): string {
return this.config.cookie || '';
}
async validate(): Promise<boolean> {
return this.cookie.length >= 50;
}
async getNickname(): Promise<string | null> {
return this.config.nickname || 'UC网盘';
}
private headers(json = false): Record<string, string> {
return {
...getUcHeaders(this.cookie),
...(json ? { 'Content-Type': 'application/json' } : {}),
};
}
private async readJson(response: Response, context: string): Promise<any> {
if (!response.ok) {
throw new Error(`${context}失败: HTTP ${response.status}`);
}
return response.json();
}
private apiOk(data: any): boolean {
return data?.status === 0 || data?.status === 200 || data?.code === 0;
}
private async getStoken(pwdId: string, passcode = ''): Promise<string> {
const url = `${UC_SHARE_API}${UC_EP.SHARE_PAGE_TOKEN}?${getUcCommonParams().toString()}`;
const response = await fetch(url, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ passcode, pwd_id: pwdId }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取UC stoken');
const stoken = data?.data?.token_info?.stoken;
if (!stoken) throw new Error(`获取UC stoken失败: ${data?.message || 'stoken缺失'}`);
return stoken;
}
private async getDetail(pwdId: string, stoken: string): Promise<UcDetail> {
const params = getUcCommonParams({ pwd_id: pwdId, stoken, _fetch_share: '1' });
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_DETAIL}?${params.toString()}`, {
headers: this.headers(),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取UC分享详情');
if (!this.apiOk(data) || !data?.data) {
throw new Error(`获取UC分享详情失败: ${data?.message || '详情为空'}`);
}
return data.data;
}
private normalizeDetailLists(detail: UcDetail): { fidList: string[]; fidTokenList: string[] } {
let fidList = detail.fid_list || (detail.fid ? [detail.fid] : []);
let fidTokenList = detail.fid_token_list || (detail.share_fid_token ? [detail.share_fid_token] : []);
if (!Array.isArray(fidList)) fidList = fidList ? [fidList] : [];
if (!Array.isArray(fidTokenList)) fidTokenList = fidTokenList ? [fidTokenList] : [];
return { fidList, fidTokenList };
}
private async initSave(pwdId: string, stoken: string, detail: UcDetail, toPdirFid = '0'): Promise<string> {
const { fidList, fidTokenList } = this.normalizeDetailLists(detail);
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_SAVE}`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({
fid_list: fidList,
fid_token_list: fidTokenList,
to_pdir_fid: toPdirFid,
pwd_id: pwdId,
stoken,
pdir_fid: '0',
scene: 'link',
}),
signal: AbortSignal.timeout(30000),
});
const data = await this.readJson(response, '发起UC转存');
if (!this.apiOk(data) || !data?.data?.task_id) {
throw new Error(`发起UC转存失败: ${data?.message || 'task_id缺失'}`);
}
return data.data.task_id;
}
private async pollTask(taskId: string, mode: 'save' | 'share'): Promise<any> {
for (let retryIndex = 0; retryIndex < 50; retryIndex++) {
const params = getUcCommonParams({ task_id: taskId, retry_index: retryIndex });
const response = await fetch(`${UC_API_BASE}${UC_EP.TASK}?${params.toString()}`, {
headers: this.headers(),
signal: AbortSignal.timeout(10000),
});
const data = await this.readJson(response, '查询UC任务');
const status = data?.data?.status;
if (status === 2) return data.data;
if (status === -1 || status === 3) throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务失败: ${data?.message || taskId}`);
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务超时: ${taskId}`);
}
private async initShare(fileIds: string[], title: string): Promise<string> {
const response = await fetch(UC_SHARE_API, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ fid_list: fileIds, title: title || '分享', expired_type: 1 }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '创建UC分享');
if (!this.apiOk(data) || !data?.data?.task_id) {
throw new Error(`创建UC分享失败: ${data?.message || 'task_id缺失'}`);
}
return data.data.task_id;
}
private async setPassword(shareId: string, password = ''): Promise<{ shareUrl: string; passcode: string }> {
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PASSWORD}`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ share_id: shareId }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '设置UC分享密码');
if (!this.apiOk(data)) {
throw new Error(`设置UC分享密码失败: ${data?.message || 'API错误'}`);
}
return {
shareUrl: data?.data?.share_url || `https://drive.uc.cn/s/${shareId}`,
passcode: data?.data?.passcode || password || '',
};
}
async saveFromShare(shareUrl: string, _sourceTitle?: string): Promise<UcDriverResult> {
if (!(await this.validate())) {
return { success: false, message: 'UC Cookie 无效或长度不足' };
}
const pwdId = parseUcShareUrl(shareUrl);
if (!pwdId) {
return { success: false, message: '无法解析UC分享链接' };
}
try {
const stoken = await this.getStoken(pwdId);
const detail = await this.getDetail(pwdId, stoken);
const saveTaskId = await this.initSave(pwdId, stoken, detail, '0');
const saveTask = await this.pollTask(saveTaskId, 'save');
const newFileIds: string[] = saveTask?.save_as?.save_as_top_fids || [];
if (!newFileIds.length) throw new Error('UC转存完成但未返回文件ID');
const title = detail.title || _sourceTitle || '分享';
const shareTaskId = await this.initShare(newFileIds, title);
const shareTask = await this.pollTask(shareTaskId, 'share');
const shareId = shareTask?.share_id || shareTask?.result?.share_id;
if (!shareId) throw new Error('UC分享任务完成但未返回share_id');
const shared = await this.setPassword(shareId);
return {
success: true,
message: '转存成功',
shareUrl: shared.shareUrl,
sharePwd: shared.passcode,
folderName: title,
fileCount: newFileIds.length,
folderCount: 0,
};
} catch (err: any) {
return { success: false, message: err?.message || 'UC转存失败' };
}
}
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { parseXunleiShareUrl, getXunleiHeaders } from './xunlei/api';
import { XunleiDriver } from './xunlei/driver';
describe('Xunlei cloud driver', () => {
it('parses common Xunlei share URLs', () => {
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz?pwd=7k9m')).toEqual({ shareId: 'VNabc123xyz', passcode: '7k9m' });
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz')).toEqual({ shareId: 'VNabc123xyz', passcode: '' });
});
it('rejects non-Xunlei share URLs', () => {
expect(parseXunleiShareUrl('https://drive.uc.cn/s/abcdef')).toBeNull();
});
it('builds bearer headers from refresh-token derived access tokens', () => {
expect(getXunleiHeaders('access-token-1').Authorization).toBe('Bearer access-token-1');
});
it('validates refresh token presence without doing network IO', async () => {
await expect(new XunleiDriver({ refreshToken: '' }).validate()).resolves.toBe(false);
await expect(new XunleiDriver({ refreshToken: 'r'.repeat(32), nickname: 'xl-test' }).validate()).resolves.toBe(true);
});
});
@@ -0,0 +1 @@
export * from './xunlei/driver';
@@ -0,0 +1,50 @@
// 迅雷网盘 API 常量与轻量工具
export const XUNLEI_PAN_API = 'https://api-pan.xunlei.com';
export const XUNLEI_AUTH_API = 'https://xluser-ssl.xunlei.com';
export const XUNLEI_WEB_HOST = 'https://pan.xunlei.com';
export const XUNLEI_CLIENT_ID = 'Xqp0kJBXWhwaTpB6';
export const XUNLEI_DEVICE_ID = '925b7631473a13716b791d7f28289cad';
export const XUNLEI_EP = {
SHARE_INFO: '/drive/v1/share',
SHARE_RESTORE: '/drive/v1/share/restore',
TASK: '/drive/v1/tasks',
CREATE_SHARE: '/drive/v1/share',
FILES: '/drive/v1/files',
TOKEN: '/v1/auth/token',
} as const;
export interface XunleiShareParts {
shareId: string;
passcode: string;
}
export function parseXunleiShareUrl(shareUrl: string): XunleiShareParts | null {
try {
const url = new URL(shareUrl);
if (!url.hostname.includes('pan.xunlei.com')) return null;
const match = url.pathname.match(/\/s\/([A-Za-z0-9_-]+)/);
if (!match) return null;
return { shareId: match[1], passcode: url.searchParams.get('pwd') || '' };
} catch {
const match = shareUrl.match(/pan\.xunlei\.com\/s\/([A-Za-z0-9_-]+)/);
if (!match) return null;
const pwd = shareUrl.match(/[?&]pwd=([A-Za-z0-9_-]+)/)?.[1] || '';
return { shareId: match[1], passcode: pwd };
}
}
export function getXunleiHeaders(accessToken = '', captchaToken = ''): Record<string, string> {
const headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'x-client-id': XUNLEI_CLIENT_ID,
'x-device-id': XUNLEI_DEVICE_ID,
};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
if (captchaToken) headers['x-captcha-token'] = captchaToken;
return headers;
}
@@ -0,0 +1,179 @@
import {
getXunleiHeaders,
parseXunleiShareUrl,
XUNLEI_AUTH_API,
XUNLEI_CLIENT_ID,
XUNLEI_EP,
XUNLEI_PAN_API,
} from './api';
export interface XunleiConfig {
refreshToken?: string;
cookie?: string;
nickname?: string;
}
interface XunleiDriverResult {
success: boolean;
message: string;
shareUrl?: string;
sharePwd?: string;
folderName?: string;
fileCount?: number;
folderCount?: number;
}
interface XunleiShareInfo {
pass_code_token?: string;
files?: Array<{ id?: string; file_id?: string; name?: string; kind?: string; is_dir?: boolean }>;
title?: string;
share_name?: string;
[key: string]: any;
}
export class XunleiDriver {
private config: XunleiConfig;
private accessToken = '';
private accessTokenExpiresAt = 0;
constructor(config: XunleiConfig = {}) {
this.config = { ...config };
}
private get refreshToken(): string {
return (this.config.refreshToken || this.config.cookie || '').trim();
}
async validate(): Promise<boolean> {
// 测试环境先做离线格式校验,真实 API 调用在 saveFromShare 时换取 access_token。
return this.refreshToken.length >= 20;
}
async getNickname(): Promise<string | null> {
return this.config.nickname || '迅雷网盘';
}
private async readJson(response: Response, context: string): Promise<any> {
if (!response.ok) throw new Error(`${context}失败: HTTP ${response.status}`);
return response.json();
}
private apiOk(data: any): boolean {
return data?.errcode === 0 || data?.error_code === 0 || data?.code === 0 || (!data?.errcode && !data?.error_code && !data?.code);
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.accessTokenExpiresAt - 60000) return this.accessToken;
if (!(await this.validate())) throw new Error('迅雷 refresh_token 无效或长度不足');
const response = await fetch(`${XUNLEI_AUTH_API}${XUNLEI_EP.TOKEN}`, {
method: 'POST',
headers: getXunleiHeaders(),
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.refreshToken, client_id: XUNLEI_CLIENT_ID }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '刷新迅雷 access_token');
if (!data?.access_token) throw new Error(`刷新迅雷 access_token失败: ${data?.message || data?.error || 'access_token缺失'}`);
this.accessToken = data.access_token;
this.accessTokenExpiresAt = Date.now() + Number(data.expires_in || 7200) * 1000;
if (data.refresh_token) this.config.refreshToken = data.refresh_token;
return this.accessToken;
}
private async headers(): Promise<Record<string, string>> {
return getXunleiHeaders(await this.getAccessToken());
}
private async getShareInfo(shareId: string, passcode = ''): Promise<XunleiShareInfo> {
const params = new URLSearchParams({ share_id: shareId });
if (passcode) params.set('pass_code', passcode);
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_INFO}?${params.toString()}`, {
headers: await this.headers(),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取迅雷分享详情');
if (!this.apiOk(data)) throw new Error(`获取迅雷分享详情失败: ${data?.message || data?.error || 'API错误'}`);
const files = data.files || data.data?.files || [];
if (!files.length) throw new Error('迅雷分享内容为空');
return { ...data, ...(data.data || {}), files };
}
private extractFileIds(files: XunleiShareInfo['files']): string[] {
return (files || []).map(file => file.id || file.file_id || '').filter(Boolean);
}
private async restoreFiles(shareId: string, passCodeToken: string, fileIds: string[]): Promise<string> {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_RESTORE}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify({ file_ids: fileIds, pass_code_token: passCodeToken, share_id: shareId, parent_id: '', specify_parent_id: true }),
signal: AbortSignal.timeout(30000),
});
const data = await this.readJson(response, '发起迅雷转存');
if (!this.apiOk(data)) throw new Error(`发起迅雷转存失败: ${data?.message || data?.error || 'API错误'}`);
const taskId = data.restore_task_id || data.task_id || data.data?.restore_task_id || data.data?.task_id;
if (!taskId) throw new Error('发起迅雷转存失败: task_id缺失');
return taskId;
}
private parseTraceFileIds(trace: any): Record<string, string> {
if (!trace) return {};
const parsed = typeof trace === 'string' ? JSON.parse(trace || '{}') : trace;
return Object.fromEntries(Object.entries(parsed).map(([oldId, value]) => [oldId, typeof value === 'string' ? value : (value as any)?.id || (value as any)?.file_id || '']).filter(([, newId]) => !!newId));
}
private async pollRestoreTask(taskId: string): Promise<string[]> {
for (let attempt = 0; attempt < 50; attempt++) {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.TASK}/${taskId}`, {
headers: await this.headers(),
signal: AbortSignal.timeout(10000),
});
const data = await this.readJson(response, '查询迅雷转存任务');
if (data.status === 'failed' || data.status === 'error') throw new Error(`迅雷转存任务失败: ${taskId}`);
if (data.progress === 100 || data.status === 'success' || data.status === 'complete') {
const mapping = this.parseTraceFileIds(data.params?.trace_file_ids || data.trace_file_ids || data.data?.params?.trace_file_ids);
const ids = Object.values(mapping);
if (ids.length) return ids;
if (Array.isArray(data.file_ids)) return data.file_ids;
if (Array.isArray(data.data?.file_ids)) return data.data.file_ids;
throw new Error('迅雷转存完成但未返回文件ID');
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error(`迅雷转存任务超时: ${taskId}`);
}
private async createShare(fileIds: string[], title: string): Promise<{ shareUrl: string; passcode: string }> {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.CREATE_SHARE}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify({ file_ids: fileIds, title: title || '分享' }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '创建迅雷分享');
if (!this.apiOk(data)) throw new Error(`创建迅雷分享失败: ${data?.message || data?.error || 'API错误'}`);
const payload = data.data || data;
const shareUrl = payload.share_url || payload.url;
if (!shareUrl) throw new Error('创建迅雷分享失败: share_url缺失');
return { shareUrl, passcode: payload.pass_code || payload.passcode || '' };
}
async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<XunleiDriverResult> {
if (!(await this.validate())) return { success: false, message: '迅雷 refresh_token 无效或长度不足' };
const parsed = parseXunleiShareUrl(shareUrl);
if (!parsed) return { success: false, message: '无法解析迅雷分享链接' };
try {
const info = await this.getShareInfo(parsed.shareId, parsed.passcode);
const sourceFileIds = this.extractFileIds(info.files);
if (!sourceFileIds.length) throw new Error('无法从迅雷分享中提取文件ID');
const restoreTaskId = await this.restoreFiles(parsed.shareId, info.pass_code_token || '', sourceFileIds);
const savedFileIds = await this.pollRestoreTask(restoreTaskId);
const title = info.title || info.share_name || sourceTitle || '分享';
const shared = await this.createShare(savedFileIds, title);
return { success: true, message: '转存成功', shareUrl: shared.shareUrl, sharePwd: shared.passcode, folderName: title, fileCount: savedFileIds.length, folderCount: 0 };
} catch (err: any) {
return { success: false, message: err?.message || '迅雷转存失败' };
}
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
mergeNotifySettingsUpdate,
mergePushUserNotifyConfigUpdate,
mergeSystemConfigEntriesUpdate,
} from './admin-config-helpers';
describe('admin config update helpers', () => {
it('preserves existing secrets when redacted system config values are submitted', () => {
const existing = [
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'site_name', value: 'Old name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: true } } }) },
];
const incoming = [
{ key: 'tmdb_api_token', value: '***REDACTED***' },
{ key: 'site_name', value: 'New name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: '***REDACTED***', enabled: false } } }) },
];
const merged = mergeSystemConfigEntriesUpdate(incoming, existing);
expect(merged).toEqual([
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'site_name', value: 'New name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: false } } }) },
]);
});
it('preserves existing per-cloud notification secrets from redacted placeholders', () => {
const existing = { channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: true } } };
const incoming = { channels: { webhook: { webhook_url: '***REDACTED***', enabled: false } } };
expect(mergeNotifySettingsUpdate(incoming, existing)).toEqual({
channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: false } },
});
});
it('preserves existing push-user notification secrets from redacted placeholders', () => {
const existing = { notify_config: JSON.stringify({ channels: { bark: { token: 'real-token', enabled: true } } }) };
const incoming = { notify_config: { channels: { bark: { token: '***REDACTED***', enabled: false } } } };
expect(mergePushUserNotifyConfigUpdate(incoming, existing)).toEqual({
channels: { bark: { token: 'real-token', enabled: false } },
});
});
});
@@ -0,0 +1,46 @@
import { SystemConfigEntry } from '../admin/system-config.service';
import { mergeRedactedUpdate } from '../utils/redact';
function parseJsonObject(value: unknown): any {
if (!value) return {};
if (typeof value === 'object') return value;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function mergeJsonStringUpdate(incoming: string, existing: string | undefined): string {
try {
const parsedIncoming = JSON.parse(incoming);
const parsedExisting = parseJsonObject(existing);
return JSON.stringify(mergeRedactedUpdate(parsedIncoming, parsedExisting));
} catch {
return mergeRedactedUpdate(incoming, existing);
}
}
export function mergeSystemConfigEntriesUpdate(
entries: Array<{ key: string; value: string }>,
existing: SystemConfigEntry[],
): Array<{ key: string; value: string }> {
const existingByKey = new Map(existing.map(entry => [entry.key, entry.value]));
return entries.map(entry => {
const oldValue = existingByKey.get(entry.key);
return { ...entry, value: mergeJsonStringUpdate(entry.value, oldValue) };
});
}
export function mergeNotifySettingsUpdate(incoming: any, existing: any): any {
return mergeRedactedUpdate(incoming || {}, existing || {});
}
export function mergePushUserNotifyConfigUpdate(incoming: { notify_config?: any }, existing?: { notify_config?: string }): any {
return mergeNotifySettingsUpdate(
parseJsonObject(incoming?.notify_config),
parseJsonObject(existing?.notify_config),
);
}
+29 -18
View File
@@ -19,6 +19,8 @@ import { reconnectRedis, testRedisConnection } from '../middleware/cache';
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
import { BaiduDriver } from '../cloud/drivers/baidu.driver'; import { BaiduDriver } from '../cloud/drivers/baidu.driver';
import { testProxyConnection } from '../utils/proxy-agent'; import { testProxyConnection } from '../utils/proxy-agent';
import { mergeRedactedUpdate, redactSensitive } from '../utils/redact';
import { mergeNotifySettingsUpdate, mergePushUserNotifyConfigUpdate, mergeSystemConfigEntriesUpdate } from './admin-config-helpers';
const router = Router(); const router = Router();
@@ -147,7 +149,7 @@ router.use('/admin', authMiddleware);
router.get('/admin/cloud-configs', (_req: Request, res: Response) => { router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
try { try {
const configs = getCloudConfigs(); const configs = getCloudConfigs();
res.json(configs); res.json(redactSensitive(configs));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' }); res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
} }
@@ -182,7 +184,7 @@ router.post('/admin/cloud-configs', async (req: Request, res: Response) => {
} }
} }
res.json(saved); res.json(redactSensitive(saved));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save cloud config' }); res.status(500).json({ error: err.message || 'Failed to save cloud config' });
} }
@@ -197,8 +199,9 @@ router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
res.status(404).json({ error: 'Cloud config not found' }); res.status(404).json({ error: 'Cloud config not found' });
return; return;
} }
const saved = saveCloudConfig({ ...req.body, id }); const update = mergeRedactedUpdate(req.body, existing);
res.json(saved); const saved = saveCloudConfig({ ...update, id });
res.json(redactSensitive(saved));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update cloud config' }); res.status(500).json({ error: err.message || 'Failed to update cloud config' });
} }
@@ -368,7 +371,7 @@ router.get('/admin/save-records', (req: Request, res: Response) => {
router.get('/admin/system-configs', (_req: Request, res: Response) => { router.get('/admin/system-configs', (_req: Request, res: Response) => {
try { try {
const configs = getAllSystemConfigs(); const configs = getAllSystemConfigs();
res.json(configs); res.json(redactSensitive(configs));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get system configs' }); res.status(500).json({ error: err.message || 'Failed to get system configs' });
} }
@@ -382,7 +385,8 @@ router.put('/admin/system-configs', (req: Request, res: Response) => {
res.status(400).json({ error: 'entries array is required' }); res.status(400).json({ error: 'entries array is required' });
return; return;
} }
updateSystemConfigs(entries); const mergedEntries = mergeSystemConfigEntriesUpdate(entries, getAllSystemConfigs());
updateSystemConfigs(mergedEntries);
res.json({ success: true }); res.json({ success: true });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update system configs' }); res.status(500).json({ error: err.message || 'Failed to update system configs' });
@@ -479,7 +483,7 @@ router.get('/admin/db-status', async (_req: Request, res: Response) => {
db_path: dbFile, db_path: dbFile,
...counts, ...counts,
redis_status, redis_status,
redis_url, redis_url: redis_url ? '***REDACTED***' : redis_url,
}); });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get DB status' }); res.status(500).json({ error: err.message || 'Failed to get DB status' });
@@ -720,7 +724,7 @@ router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try { try {
const id = parseInt(req.params.id as string); const id = parseInt(req.params.id as string);
const settings = getConfigNotifySettingsJSON(id); const settings = getConfigNotifySettingsJSON(id);
res.json(settings); res.json(redactSensitive(settings));
} catch (err: any) { } catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to get notification settings' }); res.status(400).json({ error: err.message || 'Failed to get notification settings' });
} }
@@ -730,7 +734,8 @@ router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try { try {
const id = parseInt(req.params.id as string); const id = parseInt(req.params.id as string);
const settings = req.body; const existing = getConfigNotifySettingsJSON(id);
const settings = mergeNotifySettingsUpdate(req.body, existing);
saveConfigNotifySettings(id, settings); saveConfigNotifySettings(id, settings);
res.json({ success: true, message: 'Push config saved' }); res.json({ success: true, message: 'Push config saved' });
} catch (err: any) { } catch (err: any) {
@@ -764,7 +769,7 @@ router.get('/admin/notify/providers', (_req: Request, res: Response) => {
router.get('/admin/notify/global-config', (_req, res) => { router.get('/admin/notify/global-config', (_req, res) => {
try { try {
const cfg = getGlobalNotifyConfig(); const cfg = getGlobalNotifyConfig();
res.json(cfg); res.json(redactSensitive(cfg));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get global config' }); res.status(500).json({ error: err.message || 'Failed to get global config' });
} }
@@ -778,7 +783,9 @@ router.put('/admin/notify/global-config', (req, res) => {
res.status(400).json({ error: 'Invalid config object' }); res.status(400).json({ error: 'Invalid config object' });
return; return;
} }
updateSystemConfig('global_notify_config', JSON.stringify(cfg)); const existing = getGlobalNotifyConfig();
const merged = mergeNotifySettingsUpdate(cfg, existing);
updateSystemConfig('global_notify_config', JSON.stringify(merged));
res.json({ success: true }); res.json({ success: true });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save global config' }); res.status(500).json({ error: err.message || 'Failed to save global config' });
@@ -793,7 +800,7 @@ router.get('/admin/push-users', (_req: Request, res: Response) => {
...u, ...u,
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(), notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
})); }));
res.json(parsed); res.json(redactSensitive(parsed));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to list push users' }); res.status(500).json({ error: err.message || 'Failed to list push users' });
} }
@@ -804,9 +811,11 @@ router.post('/admin/push-users', (req: Request, res: Response) => {
try { try {
const { account, notify_config } = req.body; const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' }); if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); const existing = getAllPushUsers().find(u => u.account === account);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = upsertPushUser(account, configStr); const user = upsertPushUser(account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) { } catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to save push user' }); res.status(400).json({ error: err.message || 'Failed to save push user' });
} }
@@ -818,9 +827,11 @@ router.put('/admin/push-users/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id as string); const id = parseInt(req.params.id as string);
const { account, notify_config } = req.body; const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' }); if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); const existing = getAllPushUsers().find(u => u.id === id);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = updatePushUser(id, account, configStr); const user = updatePushUser(id, account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) { } catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to update push user' }); res.status(400).json({ error: err.message || 'Failed to update push user' });
} }
@@ -849,7 +860,7 @@ router.get('/admin/daily-report/config', (_req, res) => {
try { try {
const { getDailyReportConfig } = require('../services/daily-report.service'); const { getDailyReportConfig } = require('../services/daily-report.service');
const cfg = getDailyReportConfig(); const cfg = getDailyReportConfig();
res.json(cfg); res.json(redactSensitive(cfg));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get daily report config' }); res.status(500).json({ error: err.message || 'Failed to get daily report config' });
} }
@@ -861,7 +872,7 @@ router.put('/admin/daily-report/config', (req, res) => {
const { saveDailyReportConfig } = require('../services/daily-report.service'); const { saveDailyReportConfig } = require('../services/daily-report.service');
saveDailyReportConfig(req.body); saveDailyReportConfig(req.body);
const { getDailyReportConfig } = require('../services/daily-report.service'); const { getDailyReportConfig } = require('../services/daily-report.service');
res.json(getDailyReportConfig()); res.json(redactSensitive(getDailyReportConfig()));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save daily report config' }); res.status(500).json({ error: err.message || 'Failed to save daily report config' });
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import adminRoutes from "./admin.routes";
import uploadRoutes from "./upload.routes"; import uploadRoutes from "./upload.routes";
import cleanupRoutes from "./cleanup.routes"; import cleanupRoutes from "./cleanup.routes";
import { getAllSystemConfigs } from "../admin/system-config.service"; import { getAllSystemConfigs } from "../admin/system-config.service";
import { publicSystemConfigs } from "../utils/redact";
const router = Router(); const router = Router();
@@ -11,7 +12,7 @@ const router = Router();
router.get("/system-configs", (_req, res) => { router.get("/system-configs", (_req, res) => {
try { try {
const configs = getAllSystemConfigs(); const configs = getAllSystemConfigs();
res.json(configs); res.json(publicSystemConfigs(configs));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || "Failed to get system configs" }); res.status(500).json({ error: err.message || "Failed to get system configs" });
} }
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { filterPublicRankingRows, isPublicRankingKeyword } from './keyword-quality';
describe('isPublicRankingKeyword', () => {
it('rejects script probes and html-like tags from public rankings', () => {
expect(isPublicRankingKeyword('<script>alert(1)</script>')).toBe(false);
expect(isPublicRankingKeyword('<img src=x onerror=alert(1)>')).toBe(false);
});
it('rejects obvious test keywords from public rankings', () => {
expect(isPublicRankingKeyword('test')).toBe(false);
expect(isPublicRankingKeyword('测试')).toBe(false);
expect(isPublicRankingKeyword(' 测试数据 ')).toBe(false);
});
it('keeps normal user search keywords', () => {
expect(isPublicRankingKeyword('庆余年')).toBe(true);
expect(isPublicRankingKeyword('4K 纪录片')).toBe(true);
});
it('keeps normal business keywords that start with testing-related words', () => {
expect(isPublicRankingKeyword('测试工程师教程')).toBe(true);
expect(isPublicRankingKeyword('测试开发面试')).toBe(true);
});
it('fills public ranking rows after filtering junk from a larger candidate set', () => {
const rows = [
{ keyword: 'test', count: 999 },
{ keyword: '<script>alert(1)</script>', count: 998 },
{ keyword: '庆余年', count: 10 },
{ keyword: '繁花', count: 9 },
];
expect(filterPublicRankingRows(rows, 2).map(row => row.keyword)).toEqual(['庆余年', '繁花']);
});
});
@@ -0,0 +1,24 @@
const TEST_KEYWORDS = new Set(['test', '测试', '测试数据']);
const HTML_TAG_PATTERN = /<\s*\/?\s*[a-z][^>]*>/i;
const SCRIPTISH_PATTERN = /(javascript\s*:|on\w+\s*=|<\s*script\b|alert\s*\()/i;
export interface PublicRankingRow {
keyword: string | null | undefined;
[key: string]: any;
}
export function isPublicRankingKeyword(keyword: string | null | undefined): boolean {
const normalized = String(keyword ?? '').trim();
if (!normalized) return false;
const lower = normalized.toLowerCase();
if (TEST_KEYWORDS.has(lower)) return false;
if (/^测试(?:\s*\d+|数据\d*)$/i.test(normalized)) return false;
if (HTML_TAG_PATTERN.test(normalized)) return false;
if (SCRIPTISH_PATTERN.test(normalized)) return false;
return true;
}
export function filterPublicRankingRows<T extends PublicRankingRow>(rows: T[], limit: number): T[] {
return rows.filter(row => isPublicRankingKeyword(row.keyword)).slice(0, limit);
}
+9 -6
View File
@@ -6,6 +6,7 @@
*/ */
import { getDb } from '../database/database'; import { getDb } from '../database/database';
import { formatLocalDateTime } from '../utils/time'; import { formatLocalDateTime } from '../utils/time';
import { filterPublicRankingRows, isPublicRankingKeyword } from './keyword-quality';
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
@@ -131,9 +132,10 @@ async function generateSearchPulse(): Promise<SearchPulse> {
const totalSearches = stats?.totalSearches || 0; const totalSearches = stats?.totalSearches || 0;
// 2. 热榜 Top 20 // 2. 热榜 Top 20
const hotRows = db.prepare( const rawHotRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 500'
).all() as any[]; ).all() as any[];
const hotRows = filterPublicRankingRows(rawHotRows, 20);
const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1; const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1;
@@ -151,9 +153,10 @@ async function generateSearchPulse(): Promise<SearchPulse> {
}); });
// 3. 最新搜索 Top 15 // 3. 最新搜索 Top 15
const recentRows = db.prepare( const rawRecentRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 15' 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 500'
).all() as any[]; ).all() as any[];
const recentRows = filterPublicRankingRows(rawRecentRows, 15);
const recentList: HotKeyword[] = recentRows.map((row: any) => { const recentList: HotKeyword[] = recentRows.map((row: any) => {
const genre = detectGenre(row.keyword); const genre = detectGenre(row.keyword);
@@ -169,9 +172,9 @@ async function generateSearchPulse(): Promise<SearchPulse> {
}); });
// 4. 按分类聚合 // 4. 按分类聚合
const allKeywords = db.prepare( const allKeywords = (db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords WHERE search_count >= 3 ORDER BY search_count DESC' 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords WHERE search_count >= 3 ORDER BY search_count DESC'
).all() as any[]; ).all() as any[]).filter(row => isPublicRankingKeyword(row.keyword));
const categoryMap = new Map<string, HotKeyword[]>(); const categoryMap = new Map<string, HotKeyword[]>();
for (const row of allKeywords) { for (const row of allKeywords) {
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { mergeRedactedUpdate, publicSystemConfigs, redactSensitive } from './redact';
describe('redactSensitive', () => {
it('redacts sensitive fields recursively while preserving non-sensitive data', () => {
const input = {
id: 1,
cookie: 'raw-cookie-secret',
password_hash: '$2a$secret-hash',
nested: {
token: 'secret-token',
webhook_url: 'https://example.invalid/hook/key',
keep: 'ok',
},
arr: [{ password: 'secret-password', value: 1 }],
};
expect(redactSensitive(input)).toEqual({
id: 1,
cookie: '***REDACTED***',
password_hash: '***REDACTED***',
nested: {
token: '***REDACTED***',
webhook_url: '***REDACTED***',
keep: 'ok',
},
arr: [{ password: '***REDACTED***', value: 1 }],
});
});
it('redacts system config values based on their config key while preserving key names', () => {
const input = [
{ key: 'tmdb_api_token', value: 'real-token', description: 'TMDB token' },
{ key: 'site_name', value: 'CloudSearch' },
];
expect(redactSensitive(input)).toEqual([
{ key: 'tmdb_api_token', value: '***REDACTED***', description: 'TMDB token' },
{ key: 'site_name', value: 'CloudSearch' },
]);
});
it('preserves existing secret values when redacted placeholders are submitted in updates', () => {
const existing = {
cookie: 'real-cookie',
notify_config: {
webhook_url: 'https://example.invalid/hook/real',
enabled: true,
},
nickname: 'old',
};
const incoming = {
cookie: '***REDACTED***',
notify_config: {
webhook_url: '***REDACTED***',
enabled: false,
},
nickname: 'new',
};
expect(mergeRedactedUpdate(incoming, existing)).toEqual({
cookie: 'real-cookie',
notify_config: {
webhook_url: 'https://example.invalid/hook/real',
enabled: false,
},
nickname: 'new',
});
});
it('redacts sensitive values inside JSON string system config values', () => {
const input = [
{
key: 'global_notify_config',
value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', token: 'real-token' } } }),
},
];
const output = redactSensitive(input);
const parsed = JSON.parse(output[0].value);
expect(parsed.channels.webhook.webhook_url).toBe('***REDACTED***');
expect(parsed.channels.webhook.token).toBe('***REDACTED***');
});
it('returns only safe public system config entries', () => {
const input = [
{ key: 'site_name', value: 'CloudSearch' },
{ key: 'site_disclaimer', value: 'For search only' },
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real' } } }) },
];
expect(publicSystemConfigs(input)).toEqual([
{ key: 'site_disclaimer', value: 'For search only' },
{ key: 'site_name', value: 'CloudSearch' },
]);
});
});
+87
View File
@@ -0,0 +1,87 @@
export const REDACTED_PLACEHOLDER = '***REDACTED***';
const SENSITIVE_FIELD_RE = /(cookie|password|password_hash|token|secret|authorization|webhook_url|access_token|refresh_token|redis_url|proxy_url)/i;
const SENSITIVE_CONFIG_KEY_RE = /(cookie|password|password_hash|token|secret|authorization|webhook_url|access_token|refresh_token|api[_-]?key|redis_url|proxy_url)/i;
const PUBLIC_SYSTEM_CONFIG_KEYS = new Set([
'site_name',
'site_logo',
'site_disclaimer',
'search_placeholder',
'search_fallback_image',
'whitelist_dirs',
'custom_footer',
]);
export interface SystemConfigLike {
key: string;
value: string;
description?: string;
updated_at?: string;
}
function shouldRedactField(key: string): boolean {
return SENSITIVE_FIELD_RE.test(key);
}
function shouldRedactConfigValue(obj: Record<string, any>): boolean {
return typeof obj.key === 'string' && SENSITIVE_CONFIG_KEY_RE.test(obj.key);
}
function redactJsonString(value: string): string {
const trimmed = value.trim();
if (!trimmed || !(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
try {
return JSON.stringify(redactSensitive(JSON.parse(value)));
} catch {
return value;
}
}
export function isRedactedPlaceholder(value: unknown): boolean {
return value === REDACTED_PLACEHOLDER;
}
export function redactSensitive(value: any): any {
if (Array.isArray(value)) return value.map(item => redactSensitive(item));
if (!value || typeof value !== 'object') return value;
const input = value as Record<string, any>;
const out: any = {};
const redactConfigValue = shouldRedactConfigValue(input);
for (const [key, val] of Object.entries(input)) {
if (key === 'value' && redactConfigValue) {
out[key] = val ? REDACTED_PLACEHOLDER : val;
} else if (key === 'value' && typeof val === 'string') {
out[key] = redactJsonString(val);
} else if (shouldRedactField(key)) {
out[key] = val ? REDACTED_PLACEHOLDER : val;
} else {
out[key] = redactSensitive(val);
}
}
return out;
}
export function mergeRedactedUpdate<T = any>(incoming: T, existing: any): T {
if (isRedactedPlaceholder(incoming)) return existing;
if (Array.isArray(incoming)) {
if (!Array.isArray(existing)) return incoming;
return incoming.map((item, index) => mergeRedactedUpdate(item, existing[index])) as T;
}
if (!incoming || typeof incoming !== 'object') return incoming;
const out: any = { ...(incoming as any) };
for (const [key, val] of Object.entries(incoming as any)) {
out[key] = mergeRedactedUpdate(val, existing?.[key]);
}
return out;
}
export function publicSystemConfigs(configs: SystemConfigLike[]): SystemConfigLike[] {
return configs
.filter(cfg => PUBLIC_SYSTEM_CONFIG_KEYS.has(cfg.key))
.map(cfg => ({ key: cfg.key, value: cfg.value, ...(cfg.description !== undefined ? { description: cfg.description } : {}) }))
.sort((a, b) => a.key.localeCompare(b.key));
}