From a5b57a77d5199582ccd549b85f73d88cdebbbb27 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sat, 3 Oct 2026 17:08:09 +0800 Subject: [PATCH] feat(adapters): add 189 Cloud cookie adapter --- README.md | 2 +- README_zh.md | 2 +- domain/adapters/providers/cloud189.py | 273 ++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_cloud189_adapter.py | 115 +++++++++++ uv.lock | 2 + web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + 8 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 domain/adapters/providers/cloud189.py create mode 100644 tests/test_cloud189_adapter.py diff --git a/README.md b/README.md index 5b22587..e6a073e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Utilizes an extensible adapter pattern to easily integrate various storage types | Category | Adapters | |---|---| | **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP | -| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115 | +| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115, 189 | | **Special** | Telegram, AList, Foxel-to-Foxel | ### 🔍 AI-Powered Semantic Search diff --git a/README_zh.md b/README_zh.md index 6c90332..b270051 100644 --- a/README_zh.md +++ b/README_zh.md @@ -40,7 +40,7 @@ | 分类 | 适配器 | |---|---| | **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP | -| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘 | +| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘、天翼云盘 | | **特殊类型** | Telegram、AList、Foxel 互联 | ### 🔍 AI 语义搜索 diff --git a/domain/adapters/providers/cloud189.py b/domain/adapters/providers/cloud189.py new file mode 100644 index 0000000..09221aa --- /dev/null +++ b/domain/adapters/providers/cloud189.py @@ -0,0 +1,273 @@ +import asyncio +import base64 +import hashlib +import hmac +import json +import posixpath +import time +import uuid +from urllib.parse import parse_qs, quote_plus, unquote, urljoin + +import httpx +from cryptography.hazmat.primitives import padding, serialization +from cryptography.hazmat.primitives.asymmetric import padding as rsa_padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from fastapi import HTTPException + +from ._remote import IDFileAdapter, clean_path, entry + + +class Cloud189Adapter(IDFileAdapter): + api_base = "https://cloud.189.cn" + part_size = 10 * 1024 * 1024 + + def __init__(self, record): + self.record = record + cfg = record.config or {} + self.cookie = str(cfg.get("cookie") or "").strip() + self.username, self.password = cfg.get("username"), cfg.get("password") + self.root_id = str(cfg.get("root_id", "-11")) + self.timeout = float(cfg.get("timeout", 60)) + if not self.cookie and not (self.username and self.password): + raise ValueError("189 Cloud requires cookie or username/password") + self._cookies = httpx.Cookies() + for part in self.cookie.split(";"): + key, sep, value = part.strip().partition("=") + if sep: + self._cookies.set(key, value, domain="cloud.189.cn") + self._authenticated = bool(self.cookie) + self._login_lock = asyncio.Lock() + + def _headers(self): + return {"Referer": self.api_base + "/", "Origin": self.api_base, + "User-Agent": "Mozilla/5.0", "Accept": "application/json"} + + @staticmethod + def _rsa(key, value): + public = serialization.load_der_public_key(base64.b64decode(key)) + return public.encrypt(value.encode(), rsa_padding.PKCS1v15()) + + async def _login(self): + if self._authenticated: + return + async with self._login_lock: + if self._authenticated: + return + if not self.username or not self.password: + raise HTTPException(401, "189 Cloud session expired; update the Cookie") + async with self._client(cookies=self._cookies, follow_redirects=True) as client: + response = await client.get(self.api_base + "/api/portal/loginUrl.action", + params={"redirectURL": self.api_base + "/main.action"}) + response.raise_for_status() + query = parse_qs(response.url.query.decode()) + app_id = query.get("appId", ["cloud"])[0] + headers = {**self._headers(), "lt": query.get("lt", [""])[0], + "reqid": query.get("reqId", [""])[0], "Referer": str(response.url), + "Origin": "https://open.e.189.cn"} + async def post(path, data): + res = await client.post("https://open.e.189.cn" + path, headers=headers, data=data) + res.raise_for_status() + payload = res.json() + if str(payload.get("result")) != "0": + raise HTTPException(401, "189 Cloud login requires account verification; supply a browser Cookie") + return payload + conf = (await post("/api/logbox/oauth2/appConf.do", {"version": "2.0", "appKey": app_id}))["data"] + crypto = (await post("/api/logbox/config/encryptConf.do", {"appId": app_id}))["data"] + body = {key: conf.get(key, "") for key in ("accountType", "returnUrl", "mailSuffix", "clientType", "paramId")} + body.update({"version": "v2.0", "appKey": app_id, "apToken": "", "captchaType": "", + "validateCode": "", "smsValidateCode": "", "captchaToken": "", "state": "", + "dynamicCheck": "FALSE", "cb_SaveName": "3", "isOauth2": str(conf.get("isOauth2", False)).lower(), + "userName": crypto.get("pre", "") + self._rsa(crypto["pubKey"], self.username).hex(), + "epd": crypto.get("pre", "") + self._rsa(crypto["pubKey"], self.password).hex()}) + result = await post("/api/logbox/oauth2/loginSubmit.do", body) + if not result.get("toUrl"): + raise HTTPException(502, "189 Cloud login returned no session URL") + response = await client.get(result["toUrl"]) + response.raise_for_status() + self._cookies.update(client.cookies) + self._authenticated = True + + async def _api(self, method, path, *, params=None, data=None): + for attempt in range(2): + await self._login() + async with self._client(cookies=self._cookies) as client: + response = await client.request(method, urljoin(self.api_base, path), + headers=self._headers(), params=params, data=data) + self._cookies.update(client.cookies) + if response.status_code == 404: + raise FileNotFoundError(path) + try: + payload = response.json() + except ValueError as exc: + raise HTTPException(502, "189 Cloud returned an invalid JSON response") from exc + if response.status_code in (401, 403) or payload.get("errorCode") == "InvalidSessionKey": + self._authenticated = False + if attempt == 0 and self.username and self.password: + continue + raise HTTPException(401, "189 Cloud session expired; update the Cookie") + response.raise_for_status() + if payload.get("res_code") not in (None, 0, "0") or payload.get("errorCode"): + raise HTTPException(502, payload.get("res_message") or payload.get("errorCode") or "189 Cloud API failed") + return payload + + async def _children(self, parent_id): + result, page = [], 1 + while True: + payload = await self._api("GET", "/api/open/file/listFiles.action", params={ + "pageSize": 60, "pageNum": page, "mediaType": 0, "folderId": parent_id, + "iconOption": 5, "orderBy": "lastOpTime", "descending": "true"}) + listing = payload.get("fileListAO") or {} + batch = [(item, True) for item in listing.get("folderList") or []] + batch += [(item, False) for item in listing.get("fileList") or []] + for item, is_dir in batch: + modified = item.get("lastOpTime") + if modified and len(str(modified)) == 19: + modified = str(modified) + "+08:00" + result.append(entry(item["name"], is_dir, item.get("size"), modified, id=str(item["id"]), raw=item)) + if len(batch) < 60: + return result + page += 1 + + async def _download(self, root, rel): + item = await self._resolve(root, rel) + if item["is_dir"]: + raise IsADirectoryError(rel) + payload = await self._api("GET", "/api/portal/getFileInfo.action", params={"fileId": item["id"]}) + url = payload.get("fileDownloadUrl") or payload.get("downloadUrl") + if not url: + raise HTTPException(502, "189 Cloud returned no download URL") + return ("https:" + url if url.startswith("//") else url), self._headers() + + async def mkdir(self, root, rel): + parent, name = await self._parent(root, rel) + await self._api("POST", "/api/open/file/createFolder.action", + data={"parentFolderId": parent["id"], "folderName": name}) + + async def _task(self, kind, item, parent_id=""): + payload = await self._api("POST", "/api/open/batch/createBatchTask.action", data={ + "type": kind, "targetFolderId": parent_id, "taskInfos": json.dumps([ + {"fileId": item["id"], "fileName": item["name"], "isFolder": int(item["is_dir"])}])}) + task_id = payload.get("taskId") + if not task_id: + raise HTTPException(502, "189 Cloud returned no batch task ID") + deadline = time.monotonic() + self.timeout + while True: + result = await self._api("POST", "/api/open/batch/checkBatchTask.action", data={"type": kind, "taskId": task_id}) + if int(result.get("failedCount", 0)) or int(result.get("skipCount", 0)) or int(result.get("taskStatus", 0)) == 2: + raise HTTPException(502, "189 Cloud batch task failed or conflicted") + if int(result.get("taskStatus", 0)) == 4: + return + if time.monotonic() >= deadline: + raise HTTPException(504, "189 Cloud batch task timed out") + await asyncio.sleep(0.3) + + async def delete(self, root, rel): + await self._parent(root, rel) + await self._task("DELETE", await self._resolve(root, rel)) + + async def move(self, root, src_rel, dst_rel): + src, dst = clean_path(src_rel), clean_path(dst_rel) + if not src or not dst or dst.startswith(src + "/"): + raise ValueError("Invalid move destination") + if src == dst: + return + if await self.exists(root, dst): + raise FileExistsError(dst) + item = await self._resolve(root, src) + old_parent, _ = await self._parent(root, src) + parent, name = await self._parent(root, dst) + if old_parent["id"] != parent["id"]: + await self._task("MOVE", item, parent["id"]) + if name != item["name"]: + await self._api("POST", "/api/open/file/renameFolder.action" if item["is_dir"] else "/api/open/file/renameFile.action", + data={("folderId" if item["is_dir"] else "fileId"): item["id"], + ("destFolderName" if item["is_dir"] else "destFileName"): name}) + + async def copy(self, root, src_rel, dst_rel, overwrite=False): + src, dst = clean_path(src_rel), clean_path(dst_rel) + if not src or not dst or src == dst or dst.startswith(src + "/"): + raise ValueError("Invalid copy destination") + if posixpath.basename(src) != posixpath.basename(dst): + return await super().copy(root, src, dst, overwrite) + item = await self._resolve(root, src) + parent, _ = await self._parent(root, dst) + if await self.exists(root, dst): + if not overwrite: + raise FileExistsError(dst) + await self.delete(root, dst) + await self._task("COPY", item, parent["id"]) + + async def _upload_api(self, operation, fields, session_key, crypto): + secret = uuid.uuid4().hex[:24] + plain = "&".join(f"{key}={fields[key]}" for key in sorted(fields)).encode() + padder = padding.PKCS7(128).padder() + padded = padder.update(plain) + padder.finalize() + encryptor = Cipher(algorithms.AES(secret[:16].encode()), modes.ECB()).encryptor() + encrypted = (encryptor.update(padded) + encryptor.finalize()).hex() + uri, date = "/person/" + operation, str(int(time.time() * 1000)) + signature = hmac.new(secret.encode(), + f"SessionKey={session_key}&Operate=GET&RequestURI={uri}&Date={date}¶ms={encrypted}".encode(), hashlib.sha1).hexdigest() + headers = {**self._headers(), "SessionKey": session_key, "Signature": signature, + "X-Request-Date": date, "X-Request-ID": str(uuid.uuid4()), "PkId": str(crypto["pkId"]), + "EncryptionText": base64.b64encode(self._rsa(crypto["pubKey"], secret)).decode()} + async with self._client() as client: + response = await client.get("https://upload.cloud.189.cn" + uri, params={"params": encrypted}, headers=headers) + response.raise_for_status() + payload = response.json() + if payload.get("code") != "SUCCESS": + raise HTTPException(502, "189 Cloud upload operation failed") + return payload + + async def write_upload_file(self, root, rel, file_obj, filename=None, file_size=None, content_type=None): + parent, name = await self._parent(root, rel) + def measure(): + file_obj.seek(0) + full, parts, size = hashlib.md5(), [], 0 + while chunk := file_obj.read(self.part_size): + full.update(chunk) + parts.append(hashlib.md5(chunk).hexdigest().upper()) + size += len(chunk) + file_obj.seek(0) + md5 = full.hexdigest() + return size, md5, md5 if size <= self.part_size else hashlib.md5("\n".join(parts).encode()).hexdigest() + size, md5, slice_md5 = await asyncio.to_thread(measure) + session = await self._api("GET", "/v2/getUserBriefInfo.action") + crypto = await self._api("GET", "/api/security/generateRsaKey.action") + async def call(operation, fields): + return await self._upload_api(operation, fields, session["sessionKey"], crypto) + init = (await call("initMultiUpload", {"parentFolderId": parent["id"], "fileName": quote_plus(name), + "fileSize": size, "sliceSize": self.part_size, "fileMd5": md5, "sliceMd5": slice_md5}))["data"] + upload_id = init["uploadFileId"] + if str(init.get("fileDataExists")) != "1": + for number in range(1, (size + self.part_size - 1) // self.part_size + 1): + chunk = await asyncio.to_thread(file_obj.read, self.part_size) + part_md5 = base64.b64encode(hashlib.md5(chunk).digest()).decode() + result = await call("getMultiUploadUrls", {"uploadFileId": upload_id, "partInfo": f"{number}-{part_md5}"}) + part = result["uploadUrls"][f"partNumber_{number}"] + headers = dict(piece.partition("=")[::2] for piece in unquote(part.get("requestHeader", "")).split("&") if "=" in piece) + async with self._client() as client: + response = await client.put(part["requestURL"], content=chunk, headers=headers) + response.raise_for_status() + await call("commitMultiUploadFile", {"uploadFileId": upload_id, "fileMd5": md5, + "sliceMd5": slice_md5, "lazyCheck": 1, "opertype": 3}) + return {"size": size} + + async def get_usage(self, root): + payload = await self._api("GET", "/api/portal/getUserSizeInfo.action") + info = payload.get("cloudCapacityInfo") or {} + used, total = info.get("usedSize"), info.get("totalSize") + return {"used_bytes": int(used) if used is not None else None, + "total_bytes": int(total) if total is not None else None, + "free_bytes": max(0, int(total) - int(used)) if total is not None and used is not None else None, + "source": "189", "scope": "account"} + + +ADAPTER_TYPE = "cloud189" +ADAPTER_FACTORY = Cloud189Adapter +CONFIG_SCHEMA = [ + {"key": "cookie", "label": "Cookie", "type": "password"}, + {"key": "username", "label": "Username", "type": "string"}, + {"key": "password", "label": "Password", "type": "password"}, + {"key": "root_id", "label": "Root ID", "type": "string", "default": "-11"}, + {"key": "timeout", "label": "Timeout (seconds)", "type": "number", "default": 60}, +] diff --git a/pyproject.toml b/pyproject.toml index 89cefba..222749f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "aioboto3>=15.5.0", "bcrypt>=5.0.0", "croniter>=6.2.4", + "cryptography>=46.0.0", "fastapi>=0.142.2", "mcp>=2.2.0", "httpx>=0.28.1,<1", diff --git a/tests/test_cloud189_adapter.py b/tests/test_cloud189_adapter.py new file mode 100644 index 0000000..cd1f4e9 --- /dev/null +++ b/tests/test_cloud189_adapter.py @@ -0,0 +1,115 @@ +from types import SimpleNamespace +import io +import base64 +import hashlib +import hmac +from urllib.parse import parse_qs +from unittest.mock import AsyncMock +import unittest + +import httpx +from fastapi import HTTPException +from cryptography.hazmat.primitives import serialization, padding +from cryptography.hazmat.primitives.asymmetric import rsa, padding as rsa_padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from domain.adapters.providers.cloud189 import Cloud189Adapter + + +class Cloud189AdapterTests(unittest.IsolatedAsyncioTestCase): + def make_adapter(self, handler): + adapter = Cloud189Adapter(SimpleNamespace(config={"cookie": "SESSION=one"})) + adapter._client = lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handler), **kwargs) + return adapter + + async def test_list_resolves_folders_and_files(self): + def handler(request): + self.assertEqual(request.headers["Cookie"], "SESSION=one") + if request.url.path.endswith("listFiles.action"): + return httpx.Response(200, json={"res_code": 0, "fileListAO": { + "count": 2, "folderList": [{"id": 1, "name": "docs"}], + "fileList": [{"id": 2, "name": "a.txt", "size": 3}]}}) + return httpx.Response(200, json={"res_code": 0, "fileDownloadUrl": "//download.test/a"}) + adapter = self.make_adapter(handler) + items, total = await adapter.list_dir("-11", "") + self.assertEqual((total, items[0]["name"], items[1]["name"]), (2, "docs", "a.txt")) + url, headers = await adapter._download("-11", "a.txt") + self.assertEqual(url, "https://download.test/a") + self.assertNotIn("Cookie", headers) + + async def test_password_configuration_is_lazy(self): + adapter = Cloud189Adapter(SimpleNamespace(config={"username": "a", "password": "b"})) + self.assertFalse(adapter._authenticated) + + async def test_upload_failure_never_commits(self): + adapter = self.make_adapter(lambda request: httpx.Response(500)) + adapter._parent = AsyncMock(return_value=({"id": "-11"}, "a.txt")) + adapter._api = AsyncMock(side_effect=[{"sessionKey": "key"}, {"pubKey": "key", "pkId": 1}]) + adapter._upload_api = AsyncMock(side_effect=[{"data": {"uploadFileId": "up", "fileDataExists": 0}}, + {"uploadUrls": {"partNumber_1": {"requestURL": "https://put.test/", "requestHeader": "Content-MD5=a%2Bb%3D"}}}]) + with self.assertRaises(httpx.HTTPStatusError): + await adapter.write_upload_file("-11", "a.txt", io.BytesIO(b"abc")) + self.assertEqual(adapter._upload_api.await_count, 2) + + async def test_batch_failure_is_reported(self): + adapter = self.make_adapter(lambda request: httpx.Response(200)) + adapter._api = AsyncMock(side_effect=[{"taskId": "task"}, {"taskStatus": 4, "failedCount": 1}]) + with self.assertRaises(HTTPException): + await adapter._task("MOVE", {"id": "1", "name": "a", "is_dir": False}, "2") + + async def test_expired_cookie_has_bounded_attempts(self): + count = 0 + def handler(request): + nonlocal count + count += 1 + return httpx.Response(200, json={"errorCode": "InvalidSessionKey"}) + adapter = self.make_adapter(handler) + with self.assertRaises(HTTPException) as caught: + await adapter._api("GET", "/test") + self.assertEqual((caught.exception.status_code, count), (401, 1)) + + async def test_signed_upload_can_be_decrypted_and_verified(self): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public = base64.b64encode(private.public_key().public_bytes(serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo)).decode() + def handler(request): + secret = private.decrypt(base64.b64decode(request.headers["EncryptionText"]), rsa_padding.PKCS1v15()) + encrypted = request.url.params["params"] + decrypt = Cipher(algorithms.AES(secret[:16]), modes.ECB()).decryptor() + padded = decrypt.update(bytes.fromhex(encrypted)) + decrypt.finalize() + unpad = padding.PKCS7(128).unpadder() + plain = (unpad.update(padded) + unpad.finalize()).decode() + self.assertEqual(plain, "fileName=a%2Bb.txt&fileSize=3") + signed = f"SessionKey=session&Operate=GET&RequestURI={request.url.path}&Date={request.headers['X-Request-Date']}¶ms={encrypted}" + self.assertEqual(request.headers["Signature"], hmac.new(secret, signed.encode(), hashlib.sha1).hexdigest()) + return httpx.Response(200, json={"code": "SUCCESS"}) + adapter = self.make_adapter(handler) + await adapter._upload_api("initMultiUpload", {"fileName": "a%2Bb.txt", "fileSize": 3}, "session", {"pubKey": public, "pkId": "1"}) + + async def test_password_login_encrypts_credentials_and_follows_session_url(self): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public = base64.b64encode(private.public_key().public_bytes(serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo)).decode() + def handler(request): + path = request.url.path + if path.endswith("loginUrl.action"): + return httpx.Response(302, headers={"Location": "https://open.e.189.cn/login?appId=cloud<=lt&reqId=req"}) + if path == "/login": + return httpx.Response(200) + if path.endswith("appConf.do"): + return httpx.Response(200, json={"result": "0", "data": {"clientType": 1}}) + if path.endswith("encryptConf.do"): + return httpx.Response(200, json={"result": 0, "data": {"pubKey": public, "pre": "{RSA}"}}) + if path.endswith("loginSubmit.do"): + body = parse_qs(request.content.decode()) + for key, expected in (("userName", "user"), ("epd", "password")): + cipher = bytes.fromhex(body[key][0].removeprefix("{RSA}")) + self.assertEqual(private.decrypt(cipher, rsa_padding.PKCS1v15()).decode(), expected) + return httpx.Response(200, json={"result": 0, "toUrl": "https://cloud.189.cn/session"}) + if path == "/session": + return httpx.Response(200, headers={"Set-Cookie": "SESSION=authenticated; Path=/"}) + self.assertIn("SESSION=authenticated", request.headers["Cookie"]) + return httpx.Response(200, json={"res_code": 0}) + adapter = Cloud189Adapter(SimpleNamespace(config={"username": "user", "password": "password"})) + adapter._client = lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handler), **kwargs) + await adapter._api("GET", "/test") diff --git a/uv.lock b/uv.lock index 46af78d..f4d4300 100644 --- a/uv.lock +++ b/uv.lock @@ -587,6 +587,7 @@ dependencies = [ { name = "aioboto3" }, { name = "bcrypt" }, { name = "croniter" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "mcp" }, @@ -613,6 +614,7 @@ requires-dist = [ { name = "aioboto3", specifier = ">=15.5.0" }, { name = "bcrypt", specifier = ">=5.0.0" }, { name = "croniter", specifier = ">=6.2.4" }, + { name = "cryptography", specifier = ">=46.0.0" }, { name = "fastapi", specifier = ">=0.142.2" }, { name = "httpx", specifier = ">=0.28.1,<1" }, { name = "mcp", specifier = ">=2.2.0" }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e2302b8..cc9c6cd 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -593,6 +593,7 @@ "adapter.type.uc": "UC Drive", "adapter.type.pan123": "123Pan (Account)", "adapter.type.pan115": "115 Cloud (Cookie)", + "adapter.type.cloud189": "189 Cloud", "adapter.type.alist": "AList", "adapter.type.openlist": "OpenList", "Automation Tasks": "Automation Tasks", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 91b63b5..7ca4d70 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -584,6 +584,7 @@ "adapter.type.uc": "UC 网盘", "adapter.type.pan123": "123 网盘(账号)", "adapter.type.pan115": "115 网盘(Cookie)", + "adapter.type.cloud189": "天翼云盘", "adapter.type.alist": "AList", "adapter.type.openlist": "OpenList", "Automation Tasks": "自动化任务",