feat(adapters): add Seafile library adapter
This commit is contained in:
1 parent
980a1570db
commit
5b5074635f
6 files changed
+272
-2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
|---|---|
|
||||
| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP、SMB |
|
||||
| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘、天翼云盘 |
|
||||
| **特殊类型** | Telegram、AList、Foxel 互联 |
|
||||
| **特殊类型** | Telegram、AList、Foxel 互联、Seafile |
|
||||
|
||||
### 🔍 AI 语义搜索
|
||||
|
||||
|
||||
@@ -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},
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "自动化任务",
|
||||
|
||||
Reference in new issue
Block a user