diff --git a/README.md b/README.md index 9fda76d..5b22587 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 | +| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115 | | **Special** | Telegram, AList, Foxel-to-Foxel | ### 🔍 AI-Powered Semantic Search diff --git a/README_zh.md b/README_zh.md index 455613c..6c90332 100644 --- a/README_zh.md +++ b/README_zh.md @@ -40,7 +40,7 @@ | 分类 | 适配器 | |---|---| | **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP | -| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘 | +| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘 | | **特殊类型** | Telegram、AList、Foxel 互联 | ### 🔍 AI 语义搜索 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..7ac1fb6 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,28 @@ +# Third-Party Notices + +## 115 Download Wire Format + +`domain/adapters/providers/_115_cipher.py` adapts the m115 protocol implementation +from https://github.com/SheltonZhu/115driver/tree/main/pkg/crypto/m115. + +MIT License + +Copyright (c) 2022-2024 SheltonZhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/domain/adapters/providers/_115_cipher.py b/domain/adapters/providers/_115_cipher.py new file mode 100644 index 0000000..af4741d --- /dev/null +++ b/domain/adapters/providers/_115_cipher.py @@ -0,0 +1,59 @@ +"""115 download wire format, adapted from SheltonZhu/115driver (MIT). + +Copyright (c) 2022-2024 SheltonZhu. See THIRD_PARTY_NOTICES.md. +""" + +import base64 +import secrets + + +_MODULUS = int( + "8686980c0f5a24c4b9d43020cd2c22703ff3f450756529058b1cf88f09b86021" + "36477198a6e2683149659bd122c33592fdb5ad47944ad1ea4d36c6b172aad633" + "8c3bb6ac6227502d010993ac967d1aef00f0c8e038de2e4d3bc2ec368af2e9f1" + "0a6f1eda4f7262f136420c07c331b871bf139f74f3010e3c4fe57df3afb71683", 16) +_SEED = bytes.fromhex( + "f0e569aebfdcbf8a1a45e8be7da673b8de8fe7c445da86c49b648b146ab4f1aa" + "3801359e26692c86006b4fa5363462a62a966818f24afdbd6b978f4d8f8913b7" + "6c8e93ed0e0d483ed72f88d8fefe7e8650954fd1eb832634db667b9c7e9d7a81" + "32eab633de3aa95934663baaba816048b9d5819cf86c8477ff5478265fbee81e" + "369f34805c452c9b76d51b8fccc3b8f5") +_CLIENT_KEY = bytes.fromhex("7806ad4c33865d184c013f46") + + +def _derive(seed, length): + return bytes(((seed[i] + _SEED[length * i]) & 255) ^ _SEED[length * (length - i - 1)] + for i in range(length)) + + +def _xor(data, key): + prefix = len(data) % 4 + return bytes(value ^ key[(i if i < prefix else i - prefix) % len(key)] + for i, value in enumerate(data)) + + +def encode(data, key): + transformed = key + _xor(_xor(data, _derive(key, 4))[::-1], _CLIENT_KEY) + encrypted = bytearray() + for offset in range(0, len(transformed), 117): + chunk = transformed[offset:offset + 117] + padding = bytes(secrets.randbelow(255) + 1 for _ in range(125 - len(chunk))) + block = b"\0\2" + padding + b"\0" + chunk + encrypted.extend(pow(int.from_bytes(block), 65537, _MODULUS).to_bytes(128)) + return base64.b64encode(encrypted).decode("ascii") + + +def decode(data, key): + encrypted = base64.b64decode(data, validate=True) + if not encrypted or len(encrypted) % 128: + raise ValueError("Invalid 115 encrypted response") + decrypted = bytearray() + for offset in range(0, len(encrypted), 128): + block = pow(int.from_bytes(encrypted[offset:offset + 128]), 65537, _MODULUS).to_bytes(128) + separator = block.find(b"\0", 2) + if block[:2] != b"\0\1" or separator < 10: + raise ValueError("Invalid 115 RSA response padding") + decrypted.extend(block[separator + 1:]) + if len(decrypted) < 16: + raise ValueError("Invalid 115 response key") + return _xor(_xor(decrypted[16:], _derive(decrypted[:16], 12))[::-1], _derive(key, 4)) diff --git a/domain/adapters/providers/pan115.py b/domain/adapters/providers/pan115.py new file mode 100644 index 0000000..5541fb6 --- /dev/null +++ b/domain/adapters/providers/pan115.py @@ -0,0 +1,188 @@ +import asyncio +import json +import secrets +import time +from urllib.parse import urljoin + +from fastapi import HTTPException + +from ._remote import IDFileAdapter, clean_path, entry +from ._115_cipher import encode, decode + + +class Pan115Adapter(IDFileAdapter): + """115 Cloud authenticated with a browser Cookie or an authorized QR token.""" + + api_base = "https://webapi.115.com" + + def __init__(self, record): + self.record = record + cfg = record.config or {} + self.cookie = str(cfg.get("cookie") or "").strip() + self.qrcode_token = str(cfg.get("qrcode_token") or "").strip() + self.root_id = str(cfg.get("root_id", "0")) + self.timeout = float(cfg.get("timeout", 60)) + self.page_size = max(1, min(1000, int(cfg.get("page_size", 1000)))) + if not self.cookie and not self.qrcode_token: + raise ValueError("115 requires cookie or qrcode_token") + self._session_cookie = self.cookie + self._download_cookies = {} + self._auth_lock = asyncio.Lock() + + async def _ensure_cookie(self): + if self._session_cookie: + return + async with self._auth_lock: + if self._session_cookie: + return + async with self._client() as client: + response = await client.post("https://passportapi.115.com/app/1.0/web/1.0/login/qrcode", + data={"app": "web", "account": self.qrcode_token}) + response.raise_for_status() + payload = response.json() + cookies = (payload.get("data") or {}).get("cookie") or {} + if not payload.get("state") or not cookies: + raise HTTPException(401, "115 QR token is not authorized or has expired") + self._session_cookie = "; ".join(f"{k}={v}" for k, v in cookies.items()) + + def _headers(self): + return {"Cookie": self._session_cookie, "Referer": "https://115.com/", "Origin": "https://115.com", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131 Safari/537.36"} + + async def _api(self, method, path, *, params=None, data=None, retry=True): + await self._ensure_cookie() + async with self._client() as client: + response = await client.request(method, urljoin(self.api_base, path), headers=self._headers(), + params=params, data=data) + if response.url.host == "proapi.115.com" and response.url.path.endswith("downurl"): + self._download_cookies = dict(response.cookies.items()) + if response.status_code in (401, 403) or response.url.path.endswith("/login"): + raise HTTPException(401, "115 authentication expired; update the Cookie") + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise HTTPException(502, "115 returned an invalid JSON response") from exc + if isinstance(payload, dict) and payload.get("state") is False: + raise HTTPException(502, payload.get("error_msg") or payload.get("error") or "115 API request failed") + return payload + + async def _children(self, parent_id): + result = [] + offset = 0 + while True: + payload = await self._api("GET", "/files", params={"cid": parent_id, "offset": offset, "limit": self.page_size, + "show_dir": 1, "aid": 1, "o": "file_name", "asc": 1, "record_open_time": 1}) + items = payload.get("data") or [] + for item in items: + is_dir = not bool(item.get("fid")) + fid = item.get("cid") if is_dir else item["fid"] + result.append(entry(item["n"], is_dir, item.get("s"), item.get("te") or item.get("t"), + id=str(fid), pick_code=item.get("pc"), raw=item)) + offset += len(items) + if not items or offset >= int(payload.get("count", offset)): + return result + + async def _download(self, root, rel): + item = await self._resolve(root, rel) + if item["is_dir"]: + raise IsADirectoryError(rel) + key = secrets.token_bytes(16) + payload = await self._api("POST", "https://proapi.115.com/app/chrome/downurl", + params={"t": int(time.time())}, data={"data": encode(json.dumps({"pickcode": item["pick_code"]}).encode(), key)}) + try: + files = json.loads(decode(payload["data"], key)) + url = next(iter(files.values()))["url"]["url"] + except (ValueError, KeyError, TypeError, StopIteration) as exc: + raise HTTPException(502, "115 returned invalid download information") from exc + if not url: + raise HTTPException(502, "115 returned no download URL") + headers = self._headers() + cookies = dict(part.strip().split("=", 1) for part in self._session_cookie.split(";") if "=" in part) + cookies.update(self._download_cookies) + headers["Cookie"] = "; ".join(f"{key}={value}" for key, value in cookies.items()) + return url, headers + + async def mkdir(self, root, rel): + parent, name = await self._parent(root, rel) + await self._api("POST", "/files/add", data={"pid": parent["id"], "cname": name}) + + async def delete(self, root, rel): + await self._parent(root, rel) + item = await self._resolve(root, rel) + await self._api("POST", "/rb/delete", data={"fid[0]": item["id"]}) + + async def move(self, root, src_rel, dst_rel): + src_parent, _ = await self._parent(root, src_rel) + item = await self._resolve(root, src_rel) + parent, name = await self._parent(root, dst_rel) + if clean_path(dst_rel).startswith(clean_path(src_rel) + "/"): + raise ValueError("Cannot move into source directory") + if clean_path(src_rel) == clean_path(dst_rel): + return + if await self.exists(root, dst_rel): + raise FileExistsError(dst_rel) + if src_parent["id"] != parent["id"]: + await self._api("POST", "/files/move", data={"pid": parent["id"], "fid[0]": item["id"]}) + if name != item["name"]: + await self._api("POST", "/files/batch_rename", data={"fid": item["id"], "file_name": name, + f"files_new_name[{item['id']}]": name}) + + async def copy(self, root, src_rel, dst_rel, overwrite=False): + src_rel, dst_rel = clean_path(src_rel), clean_path(dst_rel) + if not src_rel or not dst_rel or src_rel == dst_rel or dst_rel.startswith(src_rel + "/"): + raise ValueError("Invalid copy destination") + item = await self._resolve(root, src_rel) + parent, name = await self._parent(root, dst_rel) + if name != item["name"]: + return await super().copy(root, src_rel, dst_rel, overwrite) + if await self.exists(root, dst_rel): + if not overwrite: + raise FileExistsError(dst_rel) + await self.delete(root, dst_rel) + await self._api("POST", "/files/copy", data={"pid": parent["id"], "fid[0]": item["id"]}) + + 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) + file_obj.seek(0, 2) + size = file_obj.tell() + file_obj.seek(0) + payload = await self._api("POST", "https://uplb.115.com/3.0/sampleinitupload.php", + data={"filename": name, "target": "U_1_" + parent["id"], "filesize": str(size)}) + fields = {"name": name, "key": payload["object"], "policy": payload["policy"], + "OSSAccessKeyId": payload["accessid"], "success_action_status": "200", + "callback": payload["callback"], "signature": payload["signature"]} + async with self._client() as client: + response = await client.post(payload["host"], data=fields, + files={"file": (name, file_obj, content_type or "application/octet-stream")}) + response.raise_for_status() + result = response.json() + if result.get("state") is False: + raise HTTPException(502, "115 upload callback failed") + return {"size": size} + + async def get_usage(self, root): + payload = await self._api("GET", "/files/index_info") + data = payload.get("data") or payload + space = data.get("space_info") or data + def value(key): + val = space.get(key) + return val.get("size") if isinstance(val, dict) else val + used = value("used_space") + total = value("all_total") + 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": int(total) - int(used) if total is not None and used is not None else None, + "source": "115", "scope": "account"} + + +ADAPTER_TYPE = "pan115" +ADAPTER_FACTORY = Pan115Adapter +CONFIG_SCHEMA = [ + {"key": "cookie", "label": "Cookie", "type": "password", "required": False, + "placeholder": "从 115.com 复制"}, + {"key": "qrcode_token", "label": "QR code token", "type": "string", "required": False}, + {"key": "root_id", "label": "Root ID", "type": "string", "default": "0"}, + {"key": "page_size", "label": "Page size", "type": "number", "default": 1000}, + {"key": "timeout", "label": "Timeout (seconds)", "type": "number", "default": 60}, +] diff --git a/tests/test_pan115_adapter.py b/tests/test_pan115_adapter.py new file mode 100644 index 0000000..1699055 --- /dev/null +++ b/tests/test_pan115_adapter.py @@ -0,0 +1,88 @@ +import json +from types import SimpleNamespace +import unittest +from unittest.mock import patch +from urllib.parse import parse_qs + +import httpx +from fastapi import HTTPException + +from domain.adapters.providers.pan115 import Pan115Adapter +from domain.adapters.providers._115_cipher import decode, encode + + +class Pan115AdapterTests(unittest.IsolatedAsyncioTestCase): + def make_adapter(self, handler): + adapter = Pan115Adapter(SimpleNamespace(config={"cookie": "UID=1; CID=2"})) + adapter._client = lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handler), **kwargs) + return adapter + + async def test_cookie_listing_and_nested_resolution(self): + calls = [] + def handler(request): + calls.append(request) + self.assertEqual(request.headers["Cookie"], "UID=1; CID=2") + return httpx.Response(200, json={"state": True, "data": [ + {"n": "folder", "cid": "10", "is_dir": 1}, + ]} if request.url.params["cid"] == "0" else {"state": True, "data": [ + {"n": "file.txt", "cid": "10", "fid": "11", "s": 12, "pc": "pick"}, + ]}) + adapter = self.make_adapter(handler) + items, total = await adapter.list_dir("0", "") + self.assertEqual((items[0]["name"], total), ("folder", 1)) + item = await adapter.stat_file("0", "folder/file.txt") + self.assertEqual(item["id"], "11") + self.assertEqual(len(calls), 3) + + async def test_download_handshake_and_cookie_headers(self): + def handler(request): + if request.url.path.endswith("downurl"): + self.assertTrue(parse_qs(request.content.decode())["data"][0]) + return httpx.Response(200, json={"state": True, "data": "encrypted"}, + headers={"Set-Cookie": "download_token=temporary; Path=/"}) + return httpx.Response(200, json={"state": True, "data": [{"n": "file", "fid": "1", "pc": "pick"}]}) + adapter = self.make_adapter(handler) + with patch("domain.adapters.providers.pan115.decode", return_value=b'{"1":{"url":{"url":"https://download.test/file"}}}'): + url, headers = await adapter._download("0", "file") + self.assertEqual(url, "https://download.test/file") + self.assertEqual(headers["Cookie"], "UID=1; CID=2; download_token=temporary") + + async def test_web_upload_signatures_are_not_account_cookies(self): + uploaded = [] + def handler(request): + if request.url.host == "upload.test": + self.assertNotIn("cookie", request.headers) + uploaded.append(request.content) + return httpx.Response(200, json={"state": True}) + fields = parse_qs(request.content.decode()) + self.assertEqual(fields["target"], ["U_1_0"]) + return httpx.Response(200, json={"host": "https://upload.test", "object": "object", + "policy": "policy", "accessid": "temporary", "callback": "callback", "signature": "signature"}) + import io + result = await self.make_adapter(handler).write_upload_file("0", "file.txt", io.BytesIO(b"exact content")) + self.assertEqual(result, {"size": 13}) + self.assertIn(b"exact content", uploaded[0]) + + async def test_lists_all_pages_and_uses_file_id_not_parent_id(self): + offsets = [] + def handler(request): + offset = int(request.url.params["offset"]) + offsets.append(offset) + return httpx.Response(200, json={"state": True, "count": 2, + "data": [{"n": str(offset), "fid": str(offset + 1), "cid": "0"}]}) + items, total = await self.make_adapter(handler).list_dir("0", "") + self.assertEqual(total, 2) + self.assertEqual(offsets, [0, 1]) + self.assertEqual([item["id"] for item in items], ["1", "2"]) + self.assertFalse(items[0]["is_dir"]) + + def test_cipher_rejects_truncated_responses_and_chunks_large_requests(self): + import base64 + encrypted = encode(b"a" * 300, bytes(16)) + self.assertEqual(len(base64.b64decode(encrypted)), 384) + with self.assertRaises(ValueError): + decode(base64.b64encode(b"short"), bytes(16)) + + def test_requires_cookie_or_qr_token(self): + with self.assertRaises(ValueError): + Pan115Adapter(SimpleNamespace(config={})) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 863fbae..e2302b8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -592,6 +592,7 @@ "adapter.type.quark": "Quark Drive", "adapter.type.uc": "UC Drive", "adapter.type.pan123": "123Pan (Account)", + "adapter.type.pan115": "115 Cloud (Cookie)", "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 0fd72c8..91b63b5 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -583,6 +583,7 @@ "adapter.type.quark": "夸克网盘", "adapter.type.uc": "UC 网盘", "adapter.type.pan123": "123 网盘(账号)", + "adapter.type.pan115": "115 网盘(Cookie)", "adapter.type.alist": "AList", "adapter.type.openlist": "OpenList", "Automation Tasks": "自动化任务",