feat(adapters): add 115 cookie adapter with downloads and web uploads
This commit is contained in:
1 parent
d7f9a0b304
commit
62def8bb76
8 files changed
+367
-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 |
|
||||
| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115 |
|
||||
| **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 网盘 |
|
||||
| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘 |
|
||||
| **特殊类型** | Telegram、AList、Foxel 互联 |
|
||||
|
||||
### 🔍 AI 语义搜索
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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},
|
||||
]
|
||||
@@ -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={}))
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "自动化任务",
|
||||
|
||||
Reference in new issue
Block a user