diff --git a/DEPLOYMENT_VERSION b/DEPLOYMENT_VERSION new file mode 100644 index 0000000..a611e2e --- /dev/null +++ b/DEPLOYMENT_VERSION @@ -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 diff --git a/VERSION b/VERSION index d1d899f..b49b253 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5 +0.5.6 diff --git a/cloudsearch_transfer/Dockerfile b/cloudsearch_transfer/Dockerfile index 7aee082..bf790cd 100644 --- a/cloudsearch_transfer/Dockerfile +++ b/cloudsearch_transfer/Dockerfile @@ -2,10 +2,13 @@ FROM python:3.12-slim WORKDIR /app -COPY requirements.txt . +ENV PYTHONPATH=/app + +COPY cloudsearch_transfer/requirements.txt ./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 TRANSFER_CONFIG_PATH=/data/transfer_config.json @@ -13,6 +16,6 @@ ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json EXPOSE 9528 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"] diff --git a/cloudsearch_transfer/adapter/aliyun/__init__.py b/cloudsearch_transfer/adapter/aliyun/__init__.py index 3a6e5be..a402a1f 100644 --- a/cloudsearch_transfer/adapter/aliyun/__init__.py +++ b/cloudsearch_transfer/adapter/aliyun/__init__.py @@ -15,8 +15,8 @@ import logging from typing import List, Dict, Tuple, Optional from ..base import BaseCloudDriveAdapter, FileInfo, match_url -from ..config import PlatformConfig, TransferConfig -from ..errors import TransferError, TransferErrorCode +from ...config import PlatformConfig, TransferConfig +from ...errors import TransferError, TransferErrorCode from .credential import AliyunCredentialManager from .transfer import AliyunTransfer @@ -47,23 +47,29 @@ class AliyunAdapter(BaseCloudDriveAdapter): "Referer": "https://aliyundrive.com", } - def __init__(self, config: PlatformConfig, transfer_config: TransferConfig): - super().__init__(config, transfer_config) + capabilities: Dict[str, bool] = { + **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 "" self._credential = AliyunCredentialManager(refresh_token=refresh_token) - - # 初始化 drive_id self._drive_id = "" - - # 创建子模块 self._transfer: Optional[AliyunTransfer] = None self._cleanup: Optional[AliyunCleanup] = None + super().__init__(config, transfer_config) def _setup_session(self): """初始化 session 和凭证""" - if self._credential.refresh_token: + refresh_token = getattr(self._credential, "refresh_token", "") + if refresh_token: # 验证 refresh_token 并获取 drive_id if self._credential.validate(): self._drive_id = self._credential.get_drive_id() @@ -164,8 +170,10 @@ class AliyunAdapter(BaseCloudDriveAdapter): platform=self.PLATFORM_KEY, ) - # 确定目标目录 + # 确定目标目录:路径先解析/创建为 file_id。 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() 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: """ 清理文件(移入回收站),返回详细结果。 diff --git a/cloudsearch_transfer/adapter/baidu/__init__.py b/cloudsearch_transfer/adapter/baidu/__init__.py index 5e20d45..2c261f4 100644 --- a/cloudsearch_transfer/adapter/baidu/__init__.py +++ b/cloudsearch_transfer/adapter/baidu/__init__.py @@ -6,7 +6,7 @@ """ import logging -from typing import List, Tuple +from typing import List, Tuple, Dict from ..base import BaseCloudDriveAdapter, FileInfo from ...config import PlatformConfig, TransferConfig @@ -36,6 +36,15 @@ class BaiduAdapter(BaseCloudDriveAdapter): 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): super().__init__(config, transfer_config) @@ -123,11 +132,13 @@ class BaiduAdapter(BaseCloudDriveAdapter): fs_ids = detail["fs_ids"] 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 - 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 使用 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: """便捷删除方法(直接调用 cleanup)""" return self._cleanup.delete_files(paths) diff --git a/cloudsearch_transfer/adapter/xunlei/__init__.py b/cloudsearch_transfer/adapter/xunlei/__init__.py index 13307e3..88ce380 100644 --- a/cloudsearch_transfer/adapter/xunlei/__init__.py +++ b/cloudsearch_transfer/adapter/xunlei/__init__.py @@ -8,7 +8,7 @@ PLATFORM_KEY = 'xunlei' from __future__ import annotations import logging -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Dict from ..base import ( BaseCloudDriveAdapter, @@ -32,11 +32,24 @@ class XunleiAdapter(BaseCloudDriveAdapter): PLATFORM_KEY = "xunlei" 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): - super().__init__(config, transfer_config) + # BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential + # state must exist before super().__init__. self._credential = XunleiCredentialManager(config) self._transfer_engine: Optional[XunleiTransfer] = None - self._cleanup = XunleiCleanup() + self._cleanup = XunleiCleanup(self._credential) + super().__init__(config, transfer_config) def _setup_session(self): """初始化 session 认证头""" @@ -54,27 +67,35 @@ class XunleiAdapter(BaseCloudDriveAdapter): """懒加载转存引擎""" if self._transfer_engine is None: self._transfer_engine = XunleiTransfer( - self.session, - self._credential, - self.config, - self.transfer_config, + credential=self._credential, + timeout=self.transfer_config.request_timeout, + poll_interval=self.transfer_config.task_poll_interval, + poll_max_attempts=self.transfer_config.task_poll_max_attempts, ) + self._transfer_engine.session = self.session return self._transfer_engine # ─── 抽象方法实现 ────────────────────────────── def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict: 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]: 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, password: str = "") -> Tuple[str, str]: 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]: files = detail.get("files", []) @@ -94,13 +115,80 @@ class XunleiAdapter(BaseCloudDriveAdapter): def get_files(self, parent_fid: str = "0") -> List[FileInfo]: 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: self._ensure_auth() - return self._cleanup.delete_files( - self.session, self._credential, file_ids - ) + return self._cleanup.delete_files(file_ids) def _get_banned_keywords(self) -> List[str]: return self.config.banned_keywords or self.transfer_config.default_banned_keywords diff --git a/cloudsearch_transfer/adapter/xunlei/transfer.py b/cloudsearch_transfer/adapter/xunlei/transfer.py index 2b763e8..d92740d 100644 --- a/cloudsearch_transfer/adapter/xunlei/transfer.py +++ b/cloudsearch_transfer/adapter/xunlei/transfer.py @@ -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= @@ -88,6 +88,8 @@ class XunleiTransfer: """ url = f"{XUNLEI_PAN_API}/drive/v1/share" params: Dict[str, str] = {"share_id": share_id} + if passcode: + params["pass_code"] = passcode headers = self.credential.get_headers() logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id) @@ -303,6 +305,7 @@ class XunleiTransfer: def _create_share( self, file_ids: List[str], + password: str = "", expiration_days: str = "-1", ) -> Tuple[str, str]: """步骤④:创建新分享链接。 @@ -331,6 +334,8 @@ class XunleiTransfer: "file_ids": file_ids, "expiration_days": expiration_days, } + if password: + body["pass_code"] = password # share 操作可能需要 captcha_token headers = self.credential.get_headers_with_captcha(action="share") headers.setdefault("Content-Type", "application/json") @@ -367,7 +372,7 @@ class XunleiTransfer: share_url, pass_code, ) - return share_url, pass_code + return share_url, pass_code or password # ─── 公开入口 ───────────────────────────────────────────────── diff --git a/cloudsearch_transfer/config.py b/cloudsearch_transfer/config.py index 0f6bd6e..d16a190 100644 --- a/cloudsearch_transfer/config.py +++ b/cloudsearch_transfer/config.py @@ -150,8 +150,8 @@ class ConfigManager: "platforms": { name: { "enabled": cfg.enabled, - "cookie": cfg.cookie[:20] + "..." if cfg.cookie else "", - "refresh_token": cfg.refresh_token[:20] + "..." if cfg.refresh_token else "", + "cookie": cfg.cookie, + "refresh_token": cfg.refresh_token, "account_name": cfg.account_name, "save_dir": cfg.save_dir, "share_password": cfg.share_password, diff --git a/cloudsearch_transfer/server.py b/cloudsearch_transfer/server.py index 0f74547..2d63ef2 100644 --- a/cloudsearch_transfer/server.py +++ b/cloudsearch_transfer/server.py @@ -7,8 +7,8 @@ import os import uuid import logging from flask import Flask, request, jsonify -from config import ConfigManager -from orchestration.transfer import TransferOrchestrator +from cloudsearch_transfer.config import ConfigManager +from cloudsearch_transfer.orchestration.transfer import TransferOrchestrator # ─── 初始化 ──────────────────────────────────────────── @@ -149,6 +149,18 @@ def 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"]) @@ -169,14 +181,17 @@ def get_platforms(): @app.route("/api/config/platforms/", methods=["PUT"]) def update_platform(name): """更新平台配置""" + auth_error = require_config_auth() + if auth_error: + return auth_error data = request.get_json() or {} if name not in config.platforms: - from config import PlatformConfig + from cloudsearch_transfer.config import PlatformConfig config.platforms[name] = PlatformConfig() cfg = config.platforms[name] if "enabled" in data: - cfg.enabled = data["enabled"] + cfg.enabled = bool(data["enabled"]) if "cookie" in data: cfg.cookie = data["cookie"] if "refresh_token" in data: diff --git a/cloudsearch_transfer/tests/test_p1_drive_api_capabilities_unittest.py b/cloudsearch_transfer/tests/test_p1_drive_api_capabilities_unittest.py new file mode 100644 index 0000000..efbe103 --- /dev/null +++ b/cloudsearch_transfer/tests/test_p1_drive_api_capabilities_unittest.py @@ -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() diff --git a/cloudsearch_transfer/tests/test_server_runtime_unittest.py b/cloudsearch_transfer/tests/test_server_runtime_unittest.py new file mode 100644 index 0000000..4737a1d --- /dev/null +++ b/cloudsearch_transfer/tests/test_server_runtime_unittest.py @@ -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() diff --git a/source_clean/VERSION b/source_clean/VERSION index d1d899f..b49b253 100644 --- a/source_clean/VERSION +++ b/source_clean/VERSION @@ -1 +1 @@ -0.5.5 +0.5.6 diff --git a/source_clean/docs/superpowers/plans/2026-05-23-single-container-drive-p0.md b/source_clean/docs/superpowers/plans/2026-05-23-single-container-drive-p0.md new file mode 100644 index 0000000..4be0455 --- /dev/null +++ b/source_clean/docs/superpowers/plans/2026-05-23-single-container-drive-p0.md @@ -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 服务集成。 diff --git a/source_clean/frontend-src/index.html b/source_clean/frontend-src/index.html index af521c5..84fab85 100755 --- a/source_clean/frontend-src/index.html +++ b/source_clean/frontend-src/index.html @@ -3,16 +3,51 @@ + + + + + + + + + + + + CloudSearch - 网盘资源搜索 @@ -20,4 +55,4 @@
- \ No newline at end of file + diff --git a/source_clean/frontend-src/package-lock.json b/source_clean/frontend-src/package-lock.json index 2902f79..501ab3c 100755 --- a/source_clean/frontend-src/package-lock.json +++ b/source_clean/frontend-src/package-lock.json @@ -23,6 +23,7 @@ "@vitejs/plugin-vue": "^5.1.0", "typescript": "^5.6.0", "vite": "^5.4.0", + "vitest": "^2.1.9", "vue-tsc": "^2.1.0" } }, @@ -869,6 +870,121 @@ "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": { "version": "2.4.15", "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" } }, + "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": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1121,6 +1246,15 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1141,6 +1275,31 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -1194,6 +1353,23 @@ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -1202,6 +1378,15 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1294,6 +1479,12 @@ "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": { "version": "1.1.1", "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", "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": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1568,6 +1768,12 @@ "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": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1623,6 +1829,12 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -1698,6 +1910,21 @@ "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": { "version": "1.1.1", "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", "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1853,6 +2086,18 @@ "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": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1877,6 +2122,45 @@ "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": { "version": "2.3.0", "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": { "version": "3.1.0", "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", "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": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/source_clean/frontend-src/package.json b/source_clean/frontend-src/package.json index d7a4a38..12cdf3b 100755 --- a/source_clean/frontend-src/package.json +++ b/source_clean/frontend-src/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -24,6 +25,7 @@ "@vitejs/plugin-vue": "^5.1.0", "typescript": "^5.6.0", "vite": "^5.4.0", + "vitest": "^2.1.9", "vue-tsc": "^2.1.0" } -} \ No newline at end of file +} diff --git a/source_clean/frontend-src/public/robots.txt b/source_clean/frontend-src/public/robots.txt new file mode 100644 index 0000000..af0b0cc --- /dev/null +++ b/source_clean/frontend-src/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://zy.hk.timxx.cn/sitemap.xml diff --git a/source_clean/frontend-src/public/sitemap.xml b/source_clean/frontend-src/public/sitemap.xml new file mode 100644 index 0000000..7f15a4d --- /dev/null +++ b/source_clean/frontend-src/public/sitemap.xml @@ -0,0 +1,28 @@ + + + + https://zy.hk.timxx.cn/ + daily + 1.0 + + + https://zy.hk.timxx.cn/search + daily + 0.8 + + + https://zy.hk.timxx.cn/disclaimer/ + monthly + 0.4 + + + https://zy.hk.timxx.cn/admin/login + monthly + 0.2 + + + https://zy.hk.timxx.cn/user/login + monthly + 0.2 + + diff --git a/source_clean/frontend-src/src/App.vue b/source_clean/frontend-src/src/App.vue index c4087eb..c0448a6 100755 --- a/source_clean/frontend-src/src/App.vue +++ b/source_clean/frontend-src/src/App.vue @@ -3,6 +3,49 @@ diff --git a/source_clean/frontend-src/src/pages/HomePage.vue b/source_clean/frontend-src/src/pages/HomePage.vue index 800f477..f088555 100755 --- a/source_clean/frontend-src/src/pages/HomePage.vue +++ b/source_clean/frontend-src/src/pages/HomePage.vue @@ -1,12 +1,13 @@ @@ -35,6 +37,7 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { Search } from '@element-plus/icons-vue' import { getCategorizedRankings, getSiteConfig } from '../api' +import { createDisclaimerTarget, createSearchPath } from '../utils/home-helpers' const router = useRouter() 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 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 searchTag(t:string) { router.push('/search?q='+encodeURIComponent(t)) } -function openDisclaimer() { window.open('/disclaimer/', '_blank') } +function handleSearch() { const path = createSearchPath(query.value); if(path) router.push(path) } +function searchTag(t:string) { const path = createSearchPath(t); if(path) router.push(path) } +function openDisclaimer() { window.location.assign(createDisclaimerTarget()) } onMounted(async () => { currentQuote.value = QS[Math.floor(Math.random()*QS.length)] @@ -81,6 +84,7 @@ onMounted(async () => { diff --git a/source_clean/frontend-src/src/router.ts b/source_clean/frontend-src/src/router.ts index 88a7016..8d149ca 100755 --- a/source_clean/frontend-src/src/router.ts +++ b/source_clean/frontend-src/src/router.ts @@ -1,6 +1,6 @@ -import { createRouter, createWebHistory } from 'vue-router' +import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router' -const routes = [ +export const routes = [ { path: '/', name: 'home', @@ -16,6 +16,12 @@ const routes = [ name: 'result-detail', component: () => import('./pages/ResultDetail.vue'), }, + { + path: '/disclaimer', + alias: '/disclaimer/', + name: 'disclaimer', + component: () => import('./pages/Disclaimer.vue'), + }, { path: '/admin/login', name: 'admin-login', @@ -71,10 +77,15 @@ const routes = [ path: '/user', redirect: '/user/login', }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('./pages/NotFound.vue'), + }, ] const router = createRouter({ - history: createWebHistory(), + history: typeof window === 'undefined' ? createMemoryHistory() : createWebHistory(), routes, }) diff --git a/source_clean/frontend-src/src/seo.test.ts b/source_clean/frontend-src/src/seo.test.ts new file mode 100644 index 0000000..9f56d18 --- /dev/null +++ b/source_clean/frontend-src/src/seo.test.ts @@ -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('管理后台') + }) +}) diff --git a/source_clean/frontend-src/src/seo.ts b/source_clean/frontend-src/src/seo.ts new file mode 100644 index 0000000..b77520b --- /dev/null +++ b/source_clean/frontend-src/src/seo.ts @@ -0,0 +1,125 @@ +export interface SeoState { + title: string; + description: string; + canonical: string; + image: string; + robots: string; +} + +function getSearchQuery(query: Record): 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 { + 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 = {}, + 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); +} diff --git a/source_clean/frontend-src/src/utils/home-helpers.test.ts b/source_clean/frontend-src/src/utils/home-helpers.test.ts new file mode 100644 index 0000000..963ef1d --- /dev/null +++ b/source_clean/frontend-src/src/utils/home-helpers.test.ts @@ -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('测试环境'); + }); +}); diff --git a/source_clean/frontend-src/src/utils/home-helpers.ts b/source_clean/frontend-src/src/utils/home-helpers.ts new file mode 100644 index 0000000..db269cf --- /dev/null +++ b/source_clean/frontend-src/src/utils/home-helpers.ts @@ -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 ? '视频解析仍在开发中,当前测试环境未启用,不影响核心搜索服务。' : '', + }; +} diff --git a/source_clean/frontend-src/src/utils/route-helpers.test.ts b/source_clean/frontend-src/src/utils/route-helpers.test.ts new file mode 100644 index 0000000..9f8cafd --- /dev/null +++ b/source_clean/frontend-src/src/utils/route-helpers.test.ts @@ -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'); + }); +}); diff --git a/source_clean/frontend-src/src/utils/route-helpers.ts b/source_clean/frontend-src/src/utils/route-helpers.ts new file mode 100644 index 0000000..a2f8778 --- /dev/null +++ b/source_clean/frontend-src/src/utils/route-helpers.ts @@ -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)); +} diff --git a/source_clean/src/cloud/cloud.service.ts b/source_clean/src/cloud/cloud.service.ts index e55f9a3..006f6dd 100644 --- a/source_clean/src/cloud/cloud.service.ts +++ b/source_clean/src/cloud/cloud.service.ts @@ -1,8 +1,7 @@ import { getDb } from '../database/database'; import { localTimestamp, formatLocalDateTime } from '../utils/time'; import { getSystemConfig } from '../admin/system-config.service'; -import { QuarkDriver } from './drivers/quark.driver'; -import { BaiduDriver } from './drivers/baidu.driver'; +import { createCloudDriver, supportsSaveFromShare } from './driver-factory'; import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; import { lookupIpLocation } from './ip-lookup'; import { notifyConfigEvent } from './notification.service'; @@ -173,22 +172,13 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? try { let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string }; - switch (cloudType) { - case 'quark': { - const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname }); - 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} 的保存功能` }; + const driver = createCloudDriver(cloudType, { cookie: config.cookie!, nickname: config.nickname }); + if (!supportsSaveFromShare(driver)) { + return { success: false, message: `暂不支持 ${cloudType} 的保存功能` }; } + driverResult = cloudType === 'quark' + ? await driver.saveFromShare(shareUrl, sourceTitle, retrySave) + : await driver.saveFromShare(shareUrl, sourceTitle); const durationMs = Date.now() - startTime; diff --git a/source_clean/src/cloud/credential.service.ts b/source_clean/src/cloud/credential.service.ts index 1d7ad30..8e51795 100644 --- a/source_clean/src/cloud/credential.service.ts +++ b/source_clean/src/cloud/credential.service.ts @@ -1,4 +1,5 @@ import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api'; +import { createCloudDriver, supportsValidate } from './driver-factory'; import { getDb } from '../database/database'; import { encrypt, decrypt, isEncrypted } from '../utils/crypto'; import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time'; @@ -237,11 +238,13 @@ export async function testCloudConnection(id: number): Promise<{ let storageUsed = config.storage_used || ''; let storageTotal = config.storage_total || ''; - if (config.cloud_type === 'baidu') { - const { BaiduDriver } = require('./drivers/baidu.driver'); - const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname }); + const driver = createCloudDriver(config.cloud_type, { cookie: cookie, nickname: config.nickname }) as any; + if (supportsValidate(driver)) { valid = await driver.validate(); - if (valid) { + } + + if (valid) { + if (config.cloud_type === 'baidu' && typeof driver.getUserInfo === 'function') { const info = await driver.getUserInfo(); if (info) { nickname = config.nickname || info.nickname || '百度网盘'; @@ -249,19 +252,24 @@ export async function testCloudConnection(id: number): Promise<{ storageUsed = fmt(info.usedBytes); storageTotal = fmt(info.totalBytes); } - } - } else { - const { QuarkDriver } = require('./drivers/quark.driver'); - const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname }); - valid = await driver.validate(); - if (valid) { + } else if (config.cloud_type === 'quark') { nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘'; - const storage = await driver.getStorageInfoQuick(); - storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || ''); - storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || ''); + if (typeof driver.getStorageInfoQuick === 'function') { + const storage = await driver.getStorageInfoQuick(); + 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(); if (!valid) { db.prepare( @@ -342,6 +350,18 @@ export async function testCloudConnectionWithCookie(cloudType: string, cookie: s message: 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 { return { success: true, @@ -405,14 +425,11 @@ export async function getAndValidateCredential(cloudType: string): Promise { + 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', + }); + }); +}); diff --git a/source_clean/src/cloud/driver-boundaries.test.ts b/source_clean/src/cloud/driver-boundaries.test.ts new file mode 100644 index 0000000..decdd1f --- /dev/null +++ b/source_clean/src/cloud/driver-boundaries.test.ts @@ -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([]); + }); +}); diff --git a/source_clean/src/cloud/driver-factory.test.ts b/source_clean/src/cloud/driver-factory.test.ts new file mode 100644 index 0000000..0a32695 --- /dev/null +++ b/source_clean/src/cloud/driver-factory.test.ts @@ -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(); + }); +}); diff --git a/source_clean/src/cloud/driver-factory.ts b/source_clean/src/cloud/driver-factory.ts new file mode 100644 index 0000000..6658864 --- /dev/null +++ b/source_clean/src/cloud/driver-factory.ts @@ -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 } { + return !!driver && typeof (driver as any).saveFromShare === 'function'; +} + +export function supportsValidate(driver: CloudDriver | null): driver is CloudDriver & { validate: () => Promise } { + return !!driver && typeof (driver as any).validate === 'function'; +} diff --git a/source_clean/src/cloud/drivers/uc-api.ts b/source_clean/src/cloud/drivers/uc-api.ts new file mode 100644 index 0000000..d1af25b --- /dev/null +++ b/source_clean/src/cloud/drivers/uc-api.ts @@ -0,0 +1 @@ +export * from './uc/api'; diff --git a/source_clean/src/cloud/drivers/uc.driver.test.ts b/source_clean/src/cloud/drivers/uc.driver.test.ts new file mode 100644 index 0000000..7e25ea8 --- /dev/null +++ b/source_clean/src/cloud/drivers/uc.driver.test.ts @@ -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); + }); +}); diff --git a/source_clean/src/cloud/drivers/uc.driver.ts b/source_clean/src/cloud/drivers/uc.driver.ts new file mode 100644 index 0000000..84db0c9 --- /dev/null +++ b/source_clean/src/cloud/drivers/uc.driver.ts @@ -0,0 +1 @@ +export * from './uc/driver'; diff --git a/source_clean/src/cloud/drivers/uc/api.ts b/source_clean/src/cloud/drivers/uc/api.ts new file mode 100644 index 0000000..bf8e1e0 --- /dev/null +++ b/source_clean/src/cloud/drivers/uc/api.ts @@ -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 { + 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 = {}): URLSearchParams { + return new URLSearchParams({ + pr: 'UCBrowser', + fr: 'pc', + ...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, String(v)])), + }); +} diff --git a/source_clean/src/cloud/drivers/uc/driver.ts b/source_clean/src/cloud/drivers/uc/driver.ts new file mode 100644 index 0000000..e1388e2 --- /dev/null +++ b/source_clean/src/cloud/drivers/uc/driver.ts @@ -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 { + return this.cookie.length >= 50; + } + + async getNickname(): Promise { + return this.config.nickname || 'UC网盘'; + } + + private headers(json = false): Record { + return { + ...getUcHeaders(this.cookie), + ...(json ? { 'Content-Type': 'application/json' } : {}), + }; + } + + private async readJson(response: Response, context: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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转存失败' }; + } + } +} diff --git a/source_clean/src/cloud/drivers/xunlei.driver.test.ts b/source_clean/src/cloud/drivers/xunlei.driver.test.ts new file mode 100644 index 0000000..5a19991 --- /dev/null +++ b/source_clean/src/cloud/drivers/xunlei.driver.test.ts @@ -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); + }); +}); diff --git a/source_clean/src/cloud/drivers/xunlei.driver.ts b/source_clean/src/cloud/drivers/xunlei.driver.ts new file mode 100644 index 0000000..3c656f3 --- /dev/null +++ b/source_clean/src/cloud/drivers/xunlei.driver.ts @@ -0,0 +1 @@ +export * from './xunlei/driver'; diff --git a/source_clean/src/cloud/drivers/xunlei/api.ts b/source_clean/src/cloud/drivers/xunlei/api.ts new file mode 100644 index 0000000..8a942dc --- /dev/null +++ b/source_clean/src/cloud/drivers/xunlei/api.ts @@ -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 { + const headers: Record = { + '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; +} diff --git a/source_clean/src/cloud/drivers/xunlei/driver.ts b/source_clean/src/cloud/drivers/xunlei/driver.ts new file mode 100644 index 0000000..7bd8d7d --- /dev/null +++ b/source_clean/src/cloud/drivers/xunlei/driver.ts @@ -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 { + // 测试环境先做离线格式校验,真实 API 调用在 saveFromShare 时换取 access_token。 + return this.refreshToken.length >= 20; + } + + async getNickname(): Promise { + return this.config.nickname || '迅雷网盘'; + } + + private async readJson(response: Response, context: string): Promise { + 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 { + 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> { + return getXunleiHeaders(await this.getAccessToken()); + } + + private async getShareInfo(shareId: string, passcode = ''): Promise { + 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 { + 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 { + 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 { + 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 { + 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 || '迅雷转存失败' }; + } + } +} diff --git a/source_clean/src/routes/admin-config-helpers.test.ts b/source_clean/src/routes/admin-config-helpers.test.ts new file mode 100644 index 0000000..ab9ef9a --- /dev/null +++ b/source_clean/src/routes/admin-config-helpers.test.ts @@ -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 } }, + }); + }); +}); diff --git a/source_clean/src/routes/admin-config-helpers.ts b/source_clean/src/routes/admin-config-helpers.ts new file mode 100644 index 0000000..94b842b --- /dev/null +++ b/source_clean/src/routes/admin-config-helpers.ts @@ -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), + ); +} diff --git a/source_clean/src/routes/admin.routes.ts b/source_clean/src/routes/admin.routes.ts index ab37f89..aacdfab 100644 --- a/source_clean/src/routes/admin.routes.ts +++ b/source_clean/src/routes/admin.routes.ts @@ -19,6 +19,8 @@ import { reconnectRedis, testRedisConnection } from '../middleware/cache'; import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; import { BaiduDriver } from '../cloud/drivers/baidu.driver'; import { testProxyConnection } from '../utils/proxy-agent'; +import { mergeRedactedUpdate, redactSensitive } from '../utils/redact'; +import { mergeNotifySettingsUpdate, mergePushUserNotifyConfigUpdate, mergeSystemConfigEntriesUpdate } from './admin-config-helpers'; const router = Router(); @@ -147,7 +149,7 @@ router.use('/admin', authMiddleware); router.get('/admin/cloud-configs', (_req: Request, res: Response) => { try { const configs = getCloudConfigs(); - res.json(configs); + res.json(redactSensitive(configs)); } catch (err: any) { 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) { 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' }); return; } - const saved = saveCloudConfig({ ...req.body, id }); - res.json(saved); + const update = mergeRedactedUpdate(req.body, existing); + const saved = saveCloudConfig({ ...update, id }); + res.json(redactSensitive(saved)); } catch (err: any) { 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) => { try { const configs = getAllSystemConfigs(); - res.json(configs); + res.json(redactSensitive(configs)); } catch (err: any) { 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' }); return; } - updateSystemConfigs(entries); + const mergedEntries = mergeSystemConfigEntriesUpdate(entries, getAllSystemConfigs()); + updateSystemConfigs(mergedEntries); res.json({ success: true }); } catch (err: any) { 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, ...counts, redis_status, - redis_url, + redis_url: redis_url ? '***REDACTED***' : redis_url, }); } catch (err: any) { 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 { const id = parseInt(req.params.id as string); const settings = getConfigNotifySettingsJSON(id); - res.json(settings); + res.json(redactSensitive(settings)); } catch (err: any) { 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) => { try { 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); res.json({ success: true, message: 'Push config saved' }); } catch (err: any) { @@ -764,7 +769,7 @@ router.get('/admin/notify/providers', (_req: Request, res: Response) => { router.get('/admin/notify/global-config', (_req, res) => { try { const cfg = getGlobalNotifyConfig(); - res.json(cfg); + res.json(redactSensitive(cfg)); } catch (err: any) { 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' }); 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 }); } catch (err: any) { 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, notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(), })); - res.json(parsed); + res.json(redactSensitive(parsed)); } catch (err: any) { 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 { const { account, notify_config } = req.body; 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); - res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); + res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) })); } catch (err: any) { 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 { account, notify_config } = req.body; 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); - res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); + res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) })); } catch (err: any) { 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 { const { getDailyReportConfig } = require('../services/daily-report.service'); const cfg = getDailyReportConfig(); - res.json(cfg); + res.json(redactSensitive(cfg)); } catch (err: any) { 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'); saveDailyReportConfig(req.body); const { getDailyReportConfig } = require('../services/daily-report.service'); - res.json(getDailyReportConfig()); + res.json(redactSensitive(getDailyReportConfig())); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to save daily report config' }); } diff --git a/source_clean/src/routes/index.ts b/source_clean/src/routes/index.ts index b9e9d49..d472fc7 100755 --- a/source_clean/src/routes/index.ts +++ b/source_clean/src/routes/index.ts @@ -4,6 +4,7 @@ import adminRoutes from "./admin.routes"; import uploadRoutes from "./upload.routes"; import cleanupRoutes from "./cleanup.routes"; import { getAllSystemConfigs } from "../admin/system-config.service"; +import { publicSystemConfigs } from "../utils/redact"; const router = Router(); @@ -11,7 +12,7 @@ const router = Router(); router.get("/system-configs", (_req, res) => { try { const configs = getAllSystemConfigs(); - res.json(configs); + res.json(publicSystemConfigs(configs)); } catch (err: any) { res.status(500).json({ error: err.message || "Failed to get system configs" }); } diff --git a/source_clean/src/search/keyword-quality.test.ts b/source_clean/src/search/keyword-quality.test.ts new file mode 100644 index 0000000..9605f8b --- /dev/null +++ b/source_clean/src/search/keyword-quality.test.ts @@ -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('')).toBe(false); + expect(isPublicRankingKeyword('')).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: '', count: 998 }, + { keyword: '庆余年', count: 10 }, + { keyword: '繁花', count: 9 }, + ]; + + expect(filterPublicRankingRows(rows, 2).map(row => row.keyword)).toEqual(['庆余年', '繁花']); + }); + +}); diff --git a/source_clean/src/search/keyword-quality.ts b/source_clean/src/search/keyword-quality.ts new file mode 100644 index 0000000..248977c --- /dev/null +++ b/source_clean/src/search/keyword-quality.ts @@ -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(rows: T[], limit: number): T[] { + return rows.filter(row => isPublicRankingKeyword(row.keyword)).slice(0, limit); +} diff --git a/source_clean/src/search/rankings.service.ts b/source_clean/src/search/rankings.service.ts index 017be0d..b2d3e14 100755 --- a/source_clean/src/search/rankings.service.ts +++ b/source_clean/src/search/rankings.service.ts @@ -6,6 +6,7 @@ */ import { getDb } from '../database/database'; import { formatLocalDateTime } from '../utils/time'; +import { filterPublicRankingRows, isPublicRankingKeyword } from './keyword-quality'; // ==================== 类型定义 ==================== @@ -131,9 +132,10 @@ async function generateSearchPulse(): Promise { const totalSearches = stats?.totalSearches || 0; // 2. 热榜 Top 20 - const hotRows = db.prepare( - 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' + const rawHotRows = db.prepare( + 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 500' ).all() as any[]; + const hotRows = filterPublicRankingRows(rawHotRows, 20); const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1; @@ -151,9 +153,10 @@ async function generateSearchPulse(): Promise { }); // 3. 最新搜索 Top 15 - const recentRows = db.prepare( - 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 15' + const rawRecentRows = db.prepare( + 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 500' ).all() as any[]; + const recentRows = filterPublicRankingRows(rawRecentRows, 15); const recentList: HotKeyword[] = recentRows.map((row: any) => { const genre = detectGenre(row.keyword); @@ -169,9 +172,9 @@ async function generateSearchPulse(): Promise { }); // 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' - ).all() as any[]; + ).all() as any[]).filter(row => isPublicRankingKeyword(row.keyword)); const categoryMap = new Map(); for (const row of allKeywords) { diff --git a/source_clean/src/utils/redact.test.ts b/source_clean/src/utils/redact.test.ts new file mode 100644 index 0000000..9d7fa3f --- /dev/null +++ b/source_clean/src/utils/redact.test.ts @@ -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' }, + ]); + }); + +}); diff --git a/source_clean/src/utils/redact.ts b/source_clean/src/utils/redact.ts new file mode 100644 index 0000000..62ea73b --- /dev/null +++ b/source_clean/src/utils/redact.ts @@ -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): 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; + 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(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)); +}