feat(adapters): add 189 Cloud cookie adapter
This commit is contained in:
1 parent
62def8bb76
commit
a5b57a77d5
8 files changed
+395
-2
No files matched your search
@@ -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
@@ -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 语义搜索
|
||||
|
||||
@@ -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},
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "自动化任务",
|
||||
|
||||
Reference in new issue
Block a user