From 5b5074635fe481c6331a3a0cd2e205fc02a6a1d8 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sat, 3 Oct 2026 17:23:17 +0800 Subject: [PATCH] feat(adapters): add Seafile library adapter --- README.md | 2 +- README_zh.md | 2 +- domain/adapters/providers/seafile.py | 189 +++++++++++++++++++++++++++ tests/test_seafile_adapter.py | 79 +++++++++++ web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + 6 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 domain/adapters/providers/seafile.py create mode 100644 tests/test_seafile_adapter.py diff --git a/README.md b/README.md index 490c805..be9d2d1 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Utilizes an extensible adapter pattern to easily integrate various storage types |---|---| | **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP, SMB | | **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115, 189 | -| **Special** | Telegram, AList, Foxel-to-Foxel | +| **Special** | Telegram, AList, Foxel-to-Foxel, Seafile | ### 🔍 AI-Powered Semantic Search diff --git a/README_zh.md b/README_zh.md index 5ffaa12..3fad644 100644 --- a/README_zh.md +++ b/README_zh.md @@ -41,7 +41,7 @@ |---|---| | **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP、SMB | | **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘、天翼云盘 | -| **特殊类型** | Telegram、AList、Foxel 互联 | +| **特殊类型** | Telegram、AList、Foxel 互联、Seafile | ### 🔍 AI 语义搜索 diff --git a/domain/adapters/providers/seafile.py b/domain/adapters/providers/seafile.py new file mode 100644 index 0000000..beb2433 --- /dev/null +++ b/domain/adapters/providers/seafile.py @@ -0,0 +1,189 @@ +import asyncio +import posixpath +import time +from urllib.parse import quote, urlsplit + +from fastapi import HTTPException + +from ._remote import RemoteFileAdapter, entry, join_path, paginate, clean_path + + +class SeafileAdapter(RemoteFileAdapter): + def __init__(self, record): + self.record = record + cfg = record.config or {} + self.base_url = str(cfg.get("base_url") or "").rstrip("/") + self.repo_id = str(cfg.get("repo_id") or "") + self.username, self.password = cfg.get("username"), cfg.get("password") + self._token = cfg.get("token") + self.repo_password = cfg.get("repo_password") + self.root_path = join_path("/", cfg.get("root", "/")) + self.timeout = float(cfg.get("timeout", 60)) + if urlsplit(self.base_url).scheme not in ("http", "https") or not self.repo_id: + raise ValueError("Seafile requires base_url and repo_id") + if not self._token and not (self.username and self.password): + raise ValueError("Seafile requires a user token or username/password") + self._auth_lock = asyncio.Lock() + self._repo_lock = asyncio.Lock() + self._repo_checked_until = 0 + + def get_effective_root(self, sub_path): + return join_path(self.root_path, sub_path) + + async def _ensure_token(self): + if self._token: + return + async with self._auth_lock: + if self._token: + return + async with self._client() as client: + response = await client.post(self.base_url + "/api2/auth-token/", + data={"username": self.username, "password": self.password}) + response.raise_for_status() + self._token = response.json().get("token") + if not self._token: + raise HTTPException(502, "Seafile login returned no token") + + def _repo_url(self, resource=""): + return f"/api2/repos/{quote(self.repo_id, safe='')}/{resource}" + + async def _api(self, method, endpoint, **kwargs): + for attempt in range(2): + await self._ensure_token() + async with self._client() as client: + response = await client.request(method, self.base_url + endpoint, + headers={"Authorization": "Token " + self._token}, **kwargs) + if response.status_code == 401 and attempt == 0 and self.username and self.password: + self._token = None + continue + if response.status_code == 404: + raise FileNotFoundError((kwargs.get("params") or {}).get("p", endpoint)) + response.raise_for_status() + if not response.content: + return None + return response.json() + raise HTTPException(401, "Seafile authentication expired") + + async def _ensure_repo(self): + if time.monotonic() < self._repo_checked_until: + return + async with self._repo_lock: + if time.monotonic() < self._repo_checked_until: + return + info = await self._api("GET", self._repo_url()) + if info.get("encrypted"): + if not self.repo_password: + raise HTTPException(400, "Seafile library password is required") + await self._api("POST", self._repo_url(), data={"password": self.repo_password}) + self._repo_checked_until = time.monotonic() + 25 * 60 + + async def _items(self, path): + await self._ensure_repo() + result = await self._api("GET", self._repo_url("dir/"), params={"p": path}) + return [entry(item["name"], item["type"] == "dir", item.get("size"), item.get("mtime"), + id=item.get("id")) for item in result] + + async def list_dir(self, root, rel, page_num=1, page_size=50, sort_by="name", sort_order="asc"): + return paginate(await self._items(join_path(root, rel)), page_num, page_size, sort_by, sort_order) + + async def stat_file(self, root, rel): + path = join_path(root, rel) + if path == "/": + await self._ensure_repo() + return entry("", True) + parent, name = posixpath.split(path) + item = next((item for item in await self._items(parent) if item["name"] == name), None) + if item is None: + raise FileNotFoundError(rel) + return item + + async def _download(self, root, rel): + if (await self.stat_file(root, rel))["is_dir"]: + raise IsADirectoryError(rel) + url = await self._api("GET", self._repo_url("file/"), params={"p": join_path(root, rel), "reuse": 1}) + if not isinstance(url, str) or urlsplit(url).scheme not in ("http", "https"): + raise HTTPException(502, "Seafile returned no download URL") + return url, {} + + async def mkdir(self, root, rel): + if not clean_path(rel): + raise ValueError("Cannot modify mount root") + await self._ensure_repo() + await self._api("POST", self._repo_url("dir/"), params={"p": join_path(root, rel)}, data={"operation": "mkdir"}) + + async def delete(self, root, rel): + if not clean_path(rel): + raise ValueError("Cannot delete mount root") + item = await self.stat_file(root, rel) + await self._api("DELETE", self._repo_url("dir/" if item["is_dir"] else "file/"), params={"p": join_path(root, rel)}) + + async def move(self, root, src_rel, dst_rel): + src, dst = join_path(root, src_rel), join_path(root, dst_rel) + if not clean_path(src_rel) or not clean_path(dst_rel) or dst.startswith(src + "/"): + raise ValueError("Invalid move destination") + if src == dst: + return + if await self.exists(root, dst_rel): + raise FileExistsError(dst_rel) + item = await self.stat_file(root, src_rel) + endpoint = self._repo_url("dir/" if item["is_dir"] else "file/") + src_parent, name = posixpath.split(src) + dst_parent, dst_name = posixpath.split(dst) + if src_parent != dst_parent: + await self._api("POST", endpoint, params={"p": src}, + data={"operation": "move", "dst_repo": self.repo_id, "dst_dir": dst_parent}) + if name != dst_name: + await self._api("POST", endpoint, params={"p": join_path(dst_parent, name)}, + data={"operation": "rename", "newname": dst_name}) + + async def copy(self, root, src_rel, dst_rel, overwrite=False): + src, dst = join_path(root, src_rel), join_path(root, dst_rel) + if not clean_path(src_rel) or not clean_path(dst_rel) 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_rel, dst_rel, overwrite) + item = await self.stat_file(root, src_rel) + if await self.exists(root, dst_rel): + if not overwrite: + raise FileExistsError(dst_rel) + await self.delete(root, dst_rel) + await self._api("POST", self._repo_url("dir/" if item["is_dir"] else "file/"), params={"p": src}, + data={"operation": "copy", "dst_repo": self.repo_id, "dst_dir": posixpath.dirname(dst)}) + + async def write_upload_file(self, root, rel, file_obj, filename=None, file_size=None, content_type=None): + if not clean_path(rel): + raise ValueError("Cannot overwrite mount root") + await self._ensure_repo() + file_obj.seek(0, 2) + size = file_obj.tell() + file_obj.seek(0) + parent, name = posixpath.split(join_path(root, rel)) + url = await self._api("GET", self._repo_url("upload-link/"), params={"p": parent}) + if not isinstance(url, str) or urlsplit(url).scheme not in ("https", "http"): + raise HTTPException(502, "Seafile returned no upload URL") + async with self._client() as client: + response = await client.post(url, params={"ret-json": 1}, + data={"parent_dir": parent, "replace": "1"}, + files={"file": (name, file_obj, content_type or "application/octet-stream")}) + response.raise_for_status() + return {"size": size} + + async def get_usage(self, root): + info = await self._api("GET", "/api2/account/info/") + used, total = int(info["usage"]), int(info["total"]) + return {"used_bytes": used, "total_bytes": total if total >= 0 else None, + "free_bytes": max(0, total - used) if total >= 0 else None, "source": "seafile", "scope": "account"} + + +ADAPTER_TYPE = "seafile" +ADAPTER_FACTORY = SeafileAdapter +CONFIG_SCHEMA = [ + {"key": "base_url", "label": "Service URL", "type": "string", "required": True}, + {"key": "repo_id", "label": "Library ID", "type": "string", "required": True}, + {"key": "username", "label": "Username", "type": "string"}, + {"key": "password", "label": "Password", "type": "password"}, + {"key": "token", "label": "User token", "type": "password"}, + {"key": "repo_password", "label": "Library password", "type": "password"}, + {"key": "root", "label": "Root directory", "type": "string", "default": "/"}, + {"key": "timeout", "label": "Timeout (seconds)", "type": "number", "default": 60}, +] diff --git a/tests/test_seafile_adapter.py b/tests/test_seafile_adapter.py new file mode 100644 index 0000000..af2d41c --- /dev/null +++ b/tests/test_seafile_adapter.py @@ -0,0 +1,79 @@ +import io +import json +from types import SimpleNamespace +import unittest +from urllib.parse import parse_qs + +import httpx + +from domain.adapters.providers.seafile import SeafileAdapter + + +class SeafileAdapterTests(unittest.IsolatedAsyncioTestCase): + def make(self, handler, **config): + adapter = SeafileAdapter(SimpleNamespace(config={"base_url": "https://seafile.test", + "repo_id": "library", "username": "u", "password": "p", **config})) + adapter._client = lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handler), **kwargs) + return adapter + + async def test_authentication_library_unlock_and_subpath(self): + unlocks = [] + def handler(request): + if request.url.path == "/api2/auth-token/": + self.assertEqual(parse_qs(request.content.decode())["username"], ["u"]) + return httpx.Response(200, json={"token": "session"}) + self.assertEqual(request.headers["Authorization"], "Token session") + if request.url.path == "/api2/repos/library/": + if request.method == "POST": + unlocks.append(parse_qs(request.content.decode())) + return httpx.Response(200, json="success") + return httpx.Response(200, json={"encrypted": True}) + self.assertEqual(request.url.params["p"], "/docs/sub") + return httpx.Response(200, json=[{"name": "file", "type": "file", "size": 4, "mtime": 10}]) + adapter = self.make(handler, root="/docs", repo_password="library password") + items, total = await adapter.list_dir(adapter.get_effective_root("sub"), "") + self.assertEqual((total, items[0]["size"]), (1, 4)) + self.assertEqual(unlocks, [{"password": ["library password"]}]) + + async def test_missing_files_and_directory_delete(self): + deleted = [] + def handler(request): + if request.method == "DELETE": + deleted.append(request.url.path) + return httpx.Response(200, json="success") + if request.url.path.endswith("dir/"): + return httpx.Response(200, json=[{"name": "dir", "type": "dir"}]) + return httpx.Response(200, json={"encrypted": False}) + adapter = self.make(handler, token="token") + self.assertFalse(await adapter.exists("/", "missing")) + await adapter.delete("/", "dir") + self.assertEqual(deleted, ["/api2/repos/library/dir/"]) + + async def test_signed_upload_uses_destination_filename(self): + uploaded = [] + def handler(request): + if request.url.host == "upload.test": + self.assertNotIn("authorization", request.headers) + uploaded.append(request.content) + return httpx.Response(200, json=[]) + if request.url.path.endswith("upload-link/"): + return httpx.Response(200, json="https://upload.test/upload") + return httpx.Response(200, json={"encrypted": False}) + adapter = self.make(handler, token="token") + result = await adapter.write_upload_file("/", "renamed.txt", io.BytesIO(b"data"), "original.txt") + self.assertEqual(result, {"size": 4}) + self.assertIn(b'filename="renamed.txt"', uploaded[0]) + self.assertIn(b"data", uploaded[0]) + + async def test_expired_token_relogs_in_once(self): + requests = [] + def handler(request): + requests.append(request.url.path) + if request.url.path.endswith("auth-token/"): + return httpx.Response(200, json={"token": "new"}) + if request.headers["Authorization"] == "Token expired": + return httpx.Response(401) + return httpx.Response(200, json={"usage": 10, "total": 100}) + usage = await self.make(handler, token="expired").get_usage("/") + self.assertEqual(usage["free_bytes"], 90) + self.assertEqual(len(requests), 3) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index fc75c52..f9bd3ab 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -595,6 +595,7 @@ "adapter.type.pan115": "115 Cloud (Cookie)", "adapter.type.cloud189": "189 Cloud", "adapter.type.smb": "SMB Share", + "adapter.type.seafile": "Seafile", "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 901becf..41826ab 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -586,6 +586,7 @@ "adapter.type.pan115": "115 网盘(Cookie)", "adapter.type.cloud189": "天翼云盘", "adapter.type.smb": "SMB 共享", + "adapter.type.seafile": "Seafile", "adapter.type.alist": "AList", "adapter.type.openlist": "OpenList", "Automation Tasks": "自动化任务",