feat(adapters): add 189 Cloud cookie adapter

This commit is contained in:
shiyu committed 2026-10-03 17:57:25 +08:00
1 parent 62def8bb76
commit a5b57a77d5
8 files changed
+395 -2

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 语义搜索
+273
View File
@@ -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}&params={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},
]
+1
View File
@@ -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",
+115
View File
@@ -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']}&params={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=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")
Generated
+2
View File
@@ -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" },
+1
View File
@@ -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",
+1
View File
@@ -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": "自动化任务",