mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-12 02:20:28 +08:00
516 lines
20 KiB
Python
516 lines
20 KiB
Python
import asyncio
|
|
import mimetypes
|
|
import re
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, AsyncIterator, Dict, List, Tuple
|
|
from urllib.parse import quote, urljoin
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
from fastapi.responses import Response, StreamingResponse
|
|
|
|
from models import StorageAdapter
|
|
|
|
|
|
def _normalize_fs_path(path: str) -> str:
|
|
path = (path or "").replace("\\", "/").strip()
|
|
if not path or path == "/":
|
|
return "/"
|
|
if not path.startswith("/"):
|
|
path = "/" + path
|
|
path = re.sub(r"/{2,}", "/", path)
|
|
if path != "/" and path.endswith("/"):
|
|
path = path.rstrip("/")
|
|
return path or "/"
|
|
|
|
|
|
def _join_fs_path(base: str, rel: str) -> str:
|
|
base = _normalize_fs_path(base)
|
|
rel = (rel or "").replace("\\", "/").lstrip("/")
|
|
if not rel:
|
|
return base
|
|
if base == "/":
|
|
return "/" + rel
|
|
return f"{base}/{rel}"
|
|
|
|
|
|
def _split_parent_and_name(path: str) -> Tuple[str, str]:
|
|
path = _normalize_fs_path(path)
|
|
if path == "/":
|
|
return "/", ""
|
|
parent, _, name = path.rpartition("/")
|
|
if not parent:
|
|
parent = "/"
|
|
return parent, name
|
|
|
|
|
|
def _parse_iso_to_epoch(value: str | None) -> int:
|
|
if not value:
|
|
return 0
|
|
text = str(value).strip()
|
|
if not text:
|
|
return 0
|
|
try:
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
m = re.match(r"^(.*?)(\.\d+)([+-]\d\d:\d\d)?$", text)
|
|
if m:
|
|
head, frac, tz = m.group(1), m.group(2), m.group(3) or ""
|
|
digits = frac[1:]
|
|
if len(digits) > 6:
|
|
frac = "." + digits[:6]
|
|
text = head + frac + tz
|
|
dt = datetime.fromisoformat(text)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return int(dt.timestamp())
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
class AListApiAdapterBase:
|
|
def __init__(self, record: StorageAdapter, *, product_name: str):
|
|
self.record = record
|
|
self.product_name = product_name
|
|
|
|
cfg = record.config or {}
|
|
self.base_url: str = str(cfg.get("base_url", "")).rstrip("/")
|
|
if not self.base_url.startswith("http"):
|
|
raise ValueError(f"{product_name} requires base_url http/https")
|
|
self.username: str = str(cfg.get("username") or "")
|
|
self.password: str = str(cfg.get("password") or "")
|
|
if (self.username and not self.password) or (self.password and not self.username):
|
|
raise ValueError(f"{product_name} requires both username and password")
|
|
self.use_auth: bool = bool(self.username and self.password)
|
|
|
|
self.timeout: float = float(cfg.get("timeout", 30))
|
|
self.root_path: str = _normalize_fs_path(str(cfg.get("root") or "/"))
|
|
self.enable_redirect_307: bool = bool(cfg.get("enable_direct_download_307"))
|
|
|
|
self._token: str | None = None
|
|
self._login_lock = asyncio.Lock()
|
|
|
|
def get_effective_root(self, sub_path: str | None) -> str:
|
|
base = _normalize_fs_path(self.root_path)
|
|
if sub_path:
|
|
return _join_fs_path(base, sub_path)
|
|
return base
|
|
|
|
async def _ensure_token(self) -> str:
|
|
if not self.use_auth:
|
|
return ""
|
|
if self._token:
|
|
return self._token
|
|
async with self._login_lock:
|
|
if self._token:
|
|
return self._token
|
|
self._token = await self._login()
|
|
return self._token
|
|
|
|
async def _login(self) -> str:
|
|
url = self.base_url + "/api/auth/login"
|
|
body = {"username": self.username, "password": self.password}
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
resp = await client.post(url, json=body)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(502, detail=f"{self.product_name} login: invalid response")
|
|
code = payload.get("code")
|
|
if code not in (0, 200):
|
|
raise HTTPException(502, detail=f"{self.product_name} login failed: {payload.get('message')}")
|
|
data = payload.get("data") or {}
|
|
token = (data.get("token") if isinstance(data, dict) else None) or ""
|
|
token = str(token).strip()
|
|
if not token:
|
|
raise HTTPException(502, detail=f"{self.product_name} login: missing token")
|
|
return token
|
|
|
|
async def _api_json(
|
|
self,
|
|
method: str,
|
|
endpoint: str,
|
|
*,
|
|
json: Dict[str, Any] | None = None,
|
|
headers: Dict[str, str] | None = None,
|
|
retry: bool = True,
|
|
files: Any = None,
|
|
) -> Any:
|
|
token = await self._ensure_token()
|
|
url = self.base_url + endpoint
|
|
req_headers: Dict[str, str] = {}
|
|
if token:
|
|
req_headers["Authorization"] = token
|
|
if headers:
|
|
req_headers.update(headers)
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
resp = await client.request(method, url, json=json, headers=req_headers, files=files)
|
|
if resp.status_code == 401 and retry and self.use_auth:
|
|
self._token = None
|
|
return await self._api_json(method, endpoint, json=json, headers=headers, retry=False, files=files)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(502, detail=f"{self.product_name} api: invalid response")
|
|
|
|
code = payload.get("code")
|
|
if code in (0, 200):
|
|
return payload.get("data")
|
|
if code in (401, 403) and retry and self.use_auth:
|
|
self._token = None
|
|
return await self._api_json(method, endpoint, json=json, headers=headers, retry=False, files=files)
|
|
if code == 404:
|
|
raise FileNotFoundError(json.get("path") if json else "")
|
|
msg = payload.get("message") or payload.get("msg") or ""
|
|
raise HTTPException(502, detail=f"{self.product_name} api error code={code} msg={msg}")
|
|
|
|
def _abs_url(self, url: str) -> str:
|
|
u = (url or "").strip()
|
|
if not u:
|
|
return ""
|
|
if u.startswith("http://") or u.startswith("https://"):
|
|
return u
|
|
return urljoin(self.base_url.rstrip("/") + "/", u.lstrip("/"))
|
|
|
|
async def _fs_list(self, path: str) -> Dict[str, Any]:
|
|
body = {"path": path, "password": "", "page": 1, "per_page": 0, "refresh": False}
|
|
data = await self._api_json("POST", "/api/fs/list", json=body)
|
|
return data or {}
|
|
|
|
async def _fs_get(self, path: str) -> Dict[str, Any]:
|
|
body = {"path": path, "password": "", "page": 1, "per_page": 0, "refresh": False}
|
|
data = await self._api_json("POST", "/api/fs/get", json=body)
|
|
return data or {}
|
|
|
|
async def list_dir(
|
|
self,
|
|
root: str,
|
|
rel: str,
|
|
page_num: int = 1,
|
|
page_size: int = 50,
|
|
sort_by: str = "name",
|
|
sort_order: str = "asc",
|
|
) -> Tuple[List[Dict], int]:
|
|
path = _join_fs_path(root, rel)
|
|
data = await self._fs_list(path)
|
|
content = data.get("content") or []
|
|
if not isinstance(content, list):
|
|
raise HTTPException(502, detail=f"{self.product_name} list_dir: invalid content")
|
|
|
|
entries: List[Dict] = []
|
|
for it in content:
|
|
if not isinstance(it, dict):
|
|
continue
|
|
name = str(it.get("name") or "")
|
|
if not name:
|
|
continue
|
|
is_dir = bool(it.get("is_dir"))
|
|
size = int(it.get("size") or 0) if not is_dir else 0
|
|
mtime = _parse_iso_to_epoch(it.get("modified"))
|
|
entries.append(
|
|
{
|
|
"name": name,
|
|
"is_dir": is_dir,
|
|
"size": size,
|
|
"mtime": mtime,
|
|
"type": "dir" if is_dir else "file",
|
|
}
|
|
)
|
|
|
|
reverse = sort_order.lower() == "desc"
|
|
|
|
def get_sort_key(item: Dict) -> Tuple:
|
|
key = (not item.get("is_dir"),)
|
|
f = sort_by.lower()
|
|
if f == "name":
|
|
key += (str(item.get("name", "")).lower(),)
|
|
elif f == "size":
|
|
key += (int(item.get("size", 0)),)
|
|
elif f == "mtime":
|
|
key += (int(item.get("mtime", 0)),)
|
|
else:
|
|
key += (str(item.get("name", "")).lower(),)
|
|
return key
|
|
|
|
entries.sort(key=get_sort_key, reverse=reverse)
|
|
total = len(entries)
|
|
start = (page_num - 1) * page_size
|
|
end = start + page_size
|
|
return entries[start:end], total
|
|
|
|
async def stat_file(self, root: str, rel: str):
|
|
path = _join_fs_path(root, rel)
|
|
data = await self._fs_get(path)
|
|
if not data:
|
|
raise FileNotFoundError(rel)
|
|
is_dir = bool(data.get("is_dir"))
|
|
name = str(data.get("name") or (rel.rstrip("/").split("/")[-1] if rel else ""))
|
|
size = int(data.get("size") or 0) if not is_dir else 0
|
|
mtime = _parse_iso_to_epoch(data.get("modified"))
|
|
info = {
|
|
"name": name,
|
|
"is_dir": is_dir,
|
|
"size": size,
|
|
"mtime": mtime,
|
|
"type": "dir" if is_dir else "file",
|
|
"path": path,
|
|
}
|
|
return info
|
|
|
|
async def stat_path(self, root: str, rel: str):
|
|
try:
|
|
info = await self.stat_file(root, rel)
|
|
return {"exists": True, "is_dir": bool(info.get("is_dir")), "path": info.get("path")}
|
|
except FileNotFoundError:
|
|
return {"exists": False, "is_dir": None, "path": _join_fs_path(root, rel)}
|
|
|
|
async def exists(self, root: str, rel: str) -> bool:
|
|
try:
|
|
await self.stat_file(root, rel)
|
|
return True
|
|
except FileNotFoundError:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
async def get_direct_download_response(self, root: str, rel: str):
|
|
if not self.enable_redirect_307:
|
|
return None
|
|
data = await self._fs_get(_join_fs_path(root, rel))
|
|
if not data:
|
|
raise FileNotFoundError(rel)
|
|
if bool(data.get("is_dir")):
|
|
raise IsADirectoryError(rel)
|
|
raw_url = self._abs_url(str(data.get("raw_url") or ""))
|
|
if not raw_url:
|
|
return None
|
|
return Response(status_code=307, headers={"Location": raw_url})
|
|
|
|
async def _get_raw_url_and_meta(self, root: str, rel: str) -> Tuple[str, int, str]:
|
|
data = await self._fs_get(_join_fs_path(root, rel))
|
|
if not data:
|
|
raise FileNotFoundError(rel)
|
|
if bool(data.get("is_dir")):
|
|
raise IsADirectoryError(rel)
|
|
raw_url = self._abs_url(str(data.get("raw_url") or ""))
|
|
if not raw_url:
|
|
raise HTTPException(502, detail=f"{self.product_name} missing raw_url")
|
|
size = int(data.get("size") or 0)
|
|
name = str(data.get("name") or "")
|
|
return raw_url, size, name
|
|
|
|
async def read_file(self, root: str, rel: str) -> bytes:
|
|
raw_url, _, _ = await self._get_raw_url_and_meta(root, rel)
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
resp = await client.get(raw_url)
|
|
resp.raise_for_status()
|
|
return resp.content
|
|
|
|
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
|
raw_url, file_size, name = await self._get_raw_url_and_meta(root, rel)
|
|
mime, _ = mimetypes.guess_type(name or rel)
|
|
content_type = mime or "application/octet-stream"
|
|
|
|
start = 0
|
|
end = max(file_size - 1, 0)
|
|
status = 200
|
|
headers = {
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Type": content_type,
|
|
}
|
|
if file_size >= 0:
|
|
headers["Content-Length"] = str(file_size)
|
|
|
|
if range_header and range_header.startswith("bytes="):
|
|
try:
|
|
part = range_header.removeprefix("bytes=")
|
|
s, e = part.split("-", 1)
|
|
if s.strip():
|
|
start = int(s)
|
|
if e.strip():
|
|
end = int(e)
|
|
if file_size and start >= file_size:
|
|
raise HTTPException(416, detail="Requested Range Not Satisfiable")
|
|
if file_size and end >= file_size:
|
|
end = file_size - 1
|
|
status = 206
|
|
except ValueError:
|
|
raise HTTPException(400, detail="Invalid Range header")
|
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
|
headers["Content-Length"] = str(end - start + 1)
|
|
|
|
async def agen():
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
req_headers = {"Range": f"bytes={start}-{end}"} if status == 206 else {}
|
|
async with client.stream("GET", raw_url, headers=req_headers) as resp:
|
|
resp.raise_for_status()
|
|
async for chunk in resp.aiter_bytes():
|
|
if chunk:
|
|
yield chunk
|
|
|
|
return StreamingResponse(agen(), status_code=status, headers=headers, media_type=content_type)
|
|
|
|
async def _upload_file(self, full_path: str, file_path: Path) -> Any:
|
|
token = await self._ensure_token()
|
|
headers = {"File-Path": quote(full_path, safe="/")}
|
|
if token:
|
|
headers["Authorization"] = token
|
|
with file_path.open("rb") as f:
|
|
files = {"file": (file_path.name, f, "application/octet-stream")}
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
resp = await client.put(self.base_url + "/api/fs/form", headers=headers, files=files)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(502, detail=f"{self.product_name} upload: invalid response")
|
|
code = payload.get("code")
|
|
if code not in (0, 200):
|
|
msg = payload.get("message") or payload.get("msg") or ""
|
|
raise HTTPException(502, detail=f"{self.product_name} upload failed: {msg}")
|
|
return payload.get("data")
|
|
|
|
async def write_file(self, root: str, rel: str, data: bytes):
|
|
full_path = _join_fs_path(root, rel)
|
|
suffix = Path(rel).suffix
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf:
|
|
tf.write(data)
|
|
tmp_path = Path(tf.name)
|
|
try:
|
|
await self._upload_file(full_path, tmp_path)
|
|
finally:
|
|
try:
|
|
tmp_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
|
full_path = _join_fs_path(root, rel)
|
|
token = await self._ensure_token()
|
|
headers = {"File-Path": quote(full_path, safe="/")}
|
|
if token:
|
|
headers["Authorization"] = token
|
|
name = filename or Path(rel).name or "file"
|
|
mime = content_type or "application/octet-stream"
|
|
files = {"file": (name, file_obj, mime)}
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
resp = await client.put(self.base_url + "/api/fs/form", headers=headers, files=files)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(502, detail=f"{self.product_name} upload: invalid response")
|
|
code = payload.get("code")
|
|
if code not in (0, 200):
|
|
msg = payload.get("message") or payload.get("msg") or ""
|
|
raise HTTPException(502, detail=f"{self.product_name} upload failed: {msg}")
|
|
data = payload.get("data")
|
|
if isinstance(data, dict) and file_size is not None and "size" not in data:
|
|
data["size"] = file_size
|
|
return data
|
|
|
|
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
|
full_path = _join_fs_path(root, rel)
|
|
suffix = Path(rel).suffix
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf:
|
|
tmp_path = Path(tf.name)
|
|
size = 0
|
|
try:
|
|
with tmp_path.open("wb") as f:
|
|
async for chunk in data_iter:
|
|
if not chunk:
|
|
continue
|
|
f.write(chunk)
|
|
size += len(chunk)
|
|
await self._upload_file(full_path, tmp_path)
|
|
return size
|
|
finally:
|
|
try:
|
|
tmp_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
async def mkdir(self, root: str, rel: str):
|
|
path = _join_fs_path(root, rel)
|
|
await self._api_json("POST", "/api/fs/mkdir", json={"path": path})
|
|
|
|
async def delete(self, root: str, rel: str):
|
|
path = _join_fs_path(root, rel)
|
|
parent, name = _split_parent_and_name(path)
|
|
if not name:
|
|
return
|
|
await self._api_json("POST", "/api/fs/remove", json={"dir": parent, "names": [name]})
|
|
|
|
async def move(self, root: str, src_rel: str, dst_rel: str):
|
|
src_path = _join_fs_path(root, src_rel)
|
|
dst_path = _join_fs_path(root, dst_rel)
|
|
src_dir, src_name = _split_parent_and_name(src_path)
|
|
dst_dir, dst_name = _split_parent_and_name(dst_path)
|
|
if not src_name or not dst_name:
|
|
raise HTTPException(400, detail="Invalid move path")
|
|
|
|
if src_dir == dst_dir:
|
|
if src_name == dst_name:
|
|
return
|
|
await self._api_json("POST", "/api/fs/rename", json={"path": src_path, "name": dst_name})
|
|
return
|
|
|
|
await self._api_json("POST", "/api/fs/move", json={"src_dir": src_dir, "dst_dir": dst_dir, "names": [src_name]})
|
|
if src_name != dst_name:
|
|
await self._api_json("POST", "/api/fs/rename", json={"path": _join_fs_path(dst_dir, src_name), "name": dst_name})
|
|
|
|
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
|
await self.move(root, src_rel, dst_rel)
|
|
|
|
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
|
src_path = _join_fs_path(root, src_rel)
|
|
dst_path = _join_fs_path(root, dst_rel)
|
|
src_dir, src_name = _split_parent_and_name(src_path)
|
|
dst_dir, dst_name = _split_parent_and_name(dst_path)
|
|
if not src_name or not dst_name:
|
|
raise HTTPException(400, detail="Invalid copy path")
|
|
|
|
src_info = await self._fs_get(src_path)
|
|
if not src_info:
|
|
raise FileNotFoundError(src_rel)
|
|
|
|
if src_name != dst_name and not bool(src_info.get("is_dir")):
|
|
raw_url, _, _ = await self._get_raw_url_and_meta(root, src_rel)
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
async with client.stream("GET", raw_url) as resp:
|
|
resp.raise_for_status()
|
|
|
|
async def gen():
|
|
async for chunk in resp.aiter_bytes():
|
|
if chunk:
|
|
yield chunk
|
|
|
|
await self.write_file_stream(root, dst_rel, gen())
|
|
return
|
|
|
|
await self._api_json("POST", "/api/fs/copy", json={"src_dir": src_dir, "dst_dir": dst_dir, "names": [src_name]})
|
|
if src_name != dst_name:
|
|
await self._api_json("POST", "/api/fs/rename", json={"path": _join_fs_path(dst_dir, src_name), "name": dst_name})
|
|
|
|
|
|
class AListAdapter(AListApiAdapterBase):
|
|
def __init__(self, record: StorageAdapter):
|
|
super().__init__(record, product_name="AList")
|
|
|
|
|
|
class OpenListAdapter(AListApiAdapterBase):
|
|
def __init__(self, record: StorageAdapter):
|
|
super().__init__(record, product_name="OpenList")
|
|
|
|
|
|
ADAPTER_TYPES = {"alist": AListAdapter, "openlist": OpenListAdapter}
|
|
|
|
CONFIG_SCHEMA = [
|
|
{"key": "base_url", "label": "基础地址", "type": "string", "required": True, "placeholder": "http://127.0.0.1:5244"},
|
|
{"key": "username", "label": "用户名", "type": "string", "required": False, "placeholder": "留空则匿名访问"},
|
|
{"key": "password", "label": "密码", "type": "password", "required": False, "placeholder": "留空则匿名访问"},
|
|
{"key": "root", "label": "根目录", "type": "string", "required": False, "default": "/"},
|
|
{"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 30},
|
|
{"key": "enable_direct_download_307", "label": "启用 307 直链下载", "type": "boolean", "default": False},
|
|
]
|