mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 05:02:42 +08:00
472 lines
17 KiB
Python
472 lines
17 KiB
Python
import asyncio
|
|
import base64
|
|
import json
|
|
import mimetypes
|
|
import re
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import AsyncIterator, Dict, List, Tuple
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
from fastapi.responses import Response, StreamingResponse
|
|
|
|
from models import StorageAdapter
|
|
|
|
DROPBOX_OAUTH_URL = "https://api.dropboxapi.com/oauth2/token"
|
|
DROPBOX_API_URL = "https://api.dropboxapi.com/2"
|
|
DROPBOX_CONTENT_URL = "https://content.dropboxapi.com/2"
|
|
|
|
|
|
def _normalize_dbx_path(path: str | None) -> 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.endswith("/"):
|
|
path = path.rstrip("/")
|
|
return path
|
|
|
|
|
|
def _join_dbx_path(base: str, rel: str) -> str:
|
|
base = _normalize_dbx_path(base)
|
|
rel = (rel or "").replace("\\", "/").strip("/")
|
|
if not rel:
|
|
return base
|
|
if not base:
|
|
return "/" + rel
|
|
return f"{base}/{rel}"
|
|
|
|
|
|
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"
|
|
dt = datetime.fromisoformat(text)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return int(dt.timestamp())
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
class DropboxAdapter:
|
|
def __init__(self, record: StorageAdapter):
|
|
self.record = record
|
|
cfg = record.config or {}
|
|
|
|
self.app_key: str = str(cfg.get("app_key") or "").strip()
|
|
self.app_secret: str = str(cfg.get("app_secret") or "").strip()
|
|
self.refresh_token: str = str(cfg.get("refresh_token") or "").strip()
|
|
self.root_path: str = _normalize_dbx_path(str(cfg.get("root") or "/"))
|
|
self.enable_redirect_307: bool = bool(cfg.get("enable_direct_download_307"))
|
|
self.timeout: float = float(cfg.get("timeout", 60))
|
|
|
|
if not (self.app_key and self.app_secret and self.refresh_token):
|
|
raise ValueError("Dropbox 适配器需要 app_key, app_secret, refresh_token")
|
|
|
|
self._access_token: str | None = None
|
|
self._token_expiry: datetime | None = None
|
|
self._token_lock = asyncio.Lock()
|
|
|
|
def get_effective_root(self, sub_path: str | None) -> str:
|
|
base = _normalize_dbx_path(self.root_path)
|
|
if sub_path:
|
|
return _join_dbx_path(base, sub_path)
|
|
return base
|
|
|
|
async def _get_access_token(self) -> str:
|
|
if self._access_token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
|
|
return self._access_token
|
|
|
|
async with self._token_lock:
|
|
if self._access_token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
|
|
return self._access_token
|
|
|
|
basic = base64.b64encode(f"{self.app_key}:{self.app_secret}".encode("utf-8")).decode("ascii")
|
|
headers = {"Authorization": f"Basic {basic}"}
|
|
data = {"grant_type": "refresh_token", "refresh_token": self.refresh_token}
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
resp = await client.post(DROPBOX_OAUTH_URL, data=data, headers=headers)
|
|
resp.raise_for_status()
|
|
|
|
payload = resp.json()
|
|
token = str(payload.get("access_token") or "").strip()
|
|
if not token:
|
|
raise HTTPException(502, detail="Dropbox oauth: missing access_token")
|
|
expires_in = int(payload.get("expires_in") or 3600)
|
|
self._access_token = token
|
|
self._token_expiry = datetime.now(timezone.utc) + timedelta(seconds=max(60, expires_in - 300))
|
|
return token
|
|
|
|
async def _api_json(self, endpoint: str, body: Dict) -> httpx.Response:
|
|
token = await self._get_access_token()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
return await client.post(f"{DROPBOX_API_URL}{endpoint}", json=body, headers=headers)
|
|
|
|
async def _content_request(
|
|
self,
|
|
endpoint: str,
|
|
api_arg: Dict,
|
|
*,
|
|
content: bytes | None = None,
|
|
data_iter: AsyncIterator[bytes] | None = None,
|
|
extra_headers: Dict[str, str] | None = None,
|
|
) -> httpx.Response:
|
|
token = await self._get_access_token()
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Dropbox-API-Arg": json.dumps(api_arg, separators=(",", ":"), ensure_ascii=False),
|
|
}
|
|
if extra_headers:
|
|
headers.update(extra_headers)
|
|
|
|
if data_iter is None:
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
return await client.post(f"{DROPBOX_CONTENT_URL}{endpoint}", headers=headers, content=content or b"")
|
|
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
return await client.post(f"{DROPBOX_CONTENT_URL}{endpoint}", headers=headers, content=data_iter)
|
|
|
|
@staticmethod
|
|
def _raise_dbx_error(resp: httpx.Response, *, rel: str):
|
|
try:
|
|
payload = resp.json()
|
|
except Exception:
|
|
payload = None
|
|
summary = ""
|
|
if isinstance(payload, dict):
|
|
summary = str(payload.get("error_summary") or "")
|
|
if "not_found" in summary:
|
|
raise FileNotFoundError(rel)
|
|
if "conflict" in summary or "already_exists" in summary:
|
|
raise FileExistsError(rel)
|
|
if "is_folder" in summary:
|
|
raise IsADirectoryError(rel)
|
|
if "not_folder" in summary:
|
|
raise NotADirectoryError(rel)
|
|
raise HTTPException(502, detail=f"Dropbox API error: {summary or resp.text}")
|
|
|
|
def _format_entry(self, entry: Dict) -> Dict:
|
|
tag = entry.get(".tag")
|
|
is_dir = tag == "folder"
|
|
mtime = _parse_iso_to_epoch(entry.get("server_modified") if not is_dir else None)
|
|
return {
|
|
"name": entry.get("name") or "",
|
|
"is_dir": is_dir,
|
|
"size": 0 if is_dir else int(entry.get("size") or 0),
|
|
"mtime": mtime,
|
|
"type": "dir" if is_dir else "file",
|
|
}
|
|
|
|
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_dbx_path(root, rel)
|
|
body = {"path": path, "recursive": False, "include_deleted": False, "limit": 2000}
|
|
resp = await self._api_json("/files/list_folder", body)
|
|
if resp.status_code == 409:
|
|
try:
|
|
payload = resp.json()
|
|
except Exception:
|
|
payload = None
|
|
summary = str((payload or {}).get("error_summary") or "")
|
|
if "not_found" in summary:
|
|
return [], 0
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
|
|
all_entries: List[Dict] = []
|
|
all_entries.extend(payload.get("entries") or [])
|
|
cursor = payload.get("cursor")
|
|
has_more = bool(payload.get("has_more"))
|
|
while has_more and cursor:
|
|
resp2 = await self._api_json("/files/list_folder/continue", {"cursor": cursor})
|
|
resp2.raise_for_status()
|
|
p2 = resp2.json()
|
|
all_entries.extend(p2.get("entries") or [])
|
|
cursor = p2.get("cursor")
|
|
has_more = bool(p2.get("has_more"))
|
|
|
|
items = [self._format_entry(e) for e in all_entries if isinstance(e, dict)]
|
|
|
|
reverse = sort_order.lower() == "desc"
|
|
|
|
def get_sort_key(item):
|
|
key = (not item["is_dir"],)
|
|
f = sort_by.lower()
|
|
if f == "name":
|
|
key += (item["name"].lower(),)
|
|
elif f == "size":
|
|
key += (item["size"],)
|
|
elif f == "mtime":
|
|
key += (item["mtime"],)
|
|
else:
|
|
key += (item["name"].lower(),)
|
|
return key
|
|
|
|
items.sort(key=get_sort_key, reverse=reverse)
|
|
|
|
total = len(items)
|
|
start = (page_num - 1) * page_size
|
|
end = start + page_size
|
|
return items[start:end], total
|
|
|
|
async def stat_file(self, root: str, rel: str):
|
|
path = _join_dbx_path(root, rel)
|
|
resp = await self._api_json("/files/get_metadata", {"path": path, "include_deleted": False})
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
meta = resp.json()
|
|
if not isinstance(meta, dict):
|
|
raise HTTPException(502, detail="Dropbox metadata: invalid response")
|
|
return self._format_entry(meta)
|
|
|
|
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 read_file(self, root: str, rel: str) -> bytes:
|
|
path = _join_dbx_path(root, rel)
|
|
resp = await self._content_request("/files/download", {"path": path})
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
return resp.content
|
|
|
|
async def write_file(self, root: str, rel: str, data: bytes):
|
|
path = _join_dbx_path(root, rel)
|
|
arg = {
|
|
"path": path,
|
|
"mode": "overwrite",
|
|
"autorename": False,
|
|
"mute": False,
|
|
"strict_conflict": False,
|
|
}
|
|
resp = await self._content_request(
|
|
"/files/upload",
|
|
arg,
|
|
content=data,
|
|
extra_headers={"Content-Type": "application/octet-stream"},
|
|
)
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
return True
|
|
|
|
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
|
path = _join_dbx_path(root, rel)
|
|
|
|
size = 0
|
|
session_id: str | None = None
|
|
offset = 0
|
|
|
|
async for chunk in data_iter:
|
|
if not chunk:
|
|
continue
|
|
if session_id is None:
|
|
resp = await self._content_request(
|
|
"/files/upload_session_start",
|
|
{"close": False},
|
|
content=chunk,
|
|
extra_headers={"Content-Type": "application/octet-stream"},
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
session_id = str(payload.get("session_id") or "")
|
|
if not session_id:
|
|
raise HTTPException(502, detail="Dropbox upload_session_start: missing session_id")
|
|
offset += len(chunk)
|
|
size += len(chunk)
|
|
continue
|
|
|
|
arg = {"cursor": {"session_id": session_id, "offset": offset}, "close": False}
|
|
resp = await self._content_request(
|
|
"/files/upload_session_append_v2",
|
|
arg,
|
|
content=chunk,
|
|
extra_headers={"Content-Type": "application/octet-stream"},
|
|
)
|
|
resp.raise_for_status()
|
|
offset += len(chunk)
|
|
size += len(chunk)
|
|
|
|
if session_id is None:
|
|
await self.write_file(root, rel, b"")
|
|
return 0
|
|
|
|
finish_arg = {
|
|
"cursor": {"session_id": session_id, "offset": offset},
|
|
"commit": {
|
|
"path": path,
|
|
"mode": "overwrite",
|
|
"autorename": False,
|
|
"mute": False,
|
|
"strict_conflict": False,
|
|
},
|
|
}
|
|
resp = await self._content_request(
|
|
"/files/upload_session_finish",
|
|
finish_arg,
|
|
content=b"",
|
|
extra_headers={"Content-Type": "application/octet-stream"},
|
|
)
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
return size
|
|
|
|
async def mkdir(self, root: str, rel: str):
|
|
path = _join_dbx_path(root, rel)
|
|
resp = await self._api_json("/files/create_folder_v2", {"path": path, "autorename": False})
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
return True
|
|
|
|
async def delete(self, root: str, rel: str):
|
|
path = _join_dbx_path(root, rel)
|
|
resp = await self._api_json("/files/delete_v2", {"path": path})
|
|
if resp.status_code == 409:
|
|
try:
|
|
payload = resp.json()
|
|
except Exception:
|
|
payload = None
|
|
summary = str((payload or {}).get("error_summary") or "")
|
|
if "not_found" in summary:
|
|
return
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
return True
|
|
|
|
async def move(self, root: str, src_rel: str, dst_rel: str):
|
|
src = _join_dbx_path(root, src_rel)
|
|
dst = _join_dbx_path(root, dst_rel)
|
|
resp = await self._api_json(
|
|
"/files/move_v2",
|
|
{"from_path": src, "to_path": dst, "autorename": False, "allow_shared_folder": True},
|
|
)
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=src_rel)
|
|
resp.raise_for_status()
|
|
return True
|
|
|
|
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
|
return await self.move(root, src_rel, dst_rel)
|
|
|
|
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
|
src = _join_dbx_path(root, src_rel)
|
|
dst = _join_dbx_path(root, dst_rel)
|
|
resp = await self._api_json(
|
|
"/files/copy_v2",
|
|
{"from_path": src, "to_path": dst, "autorename": False, "allow_shared_folder": True},
|
|
)
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=dst_rel if overwrite else dst_rel)
|
|
resp.raise_for_status()
|
|
return True
|
|
|
|
async def get_direct_download_response(self, root: str, rel: str):
|
|
if not self.enable_redirect_307:
|
|
return None
|
|
|
|
path = _join_dbx_path(root, rel)
|
|
resp = await self._api_json("/files/get_temporary_link", {"path": path})
|
|
if resp.status_code == 409:
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
link = (payload.get("link") if isinstance(payload, dict) else None) or ""
|
|
link = str(link).strip()
|
|
if not link:
|
|
return None
|
|
return Response(status_code=307, headers={"Location": link})
|
|
|
|
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
|
path = _join_dbx_path(root, rel)
|
|
token = await self._get_access_token()
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Dropbox-API-Arg": json.dumps({"path": path}, separators=(",", ":"), ensure_ascii=False),
|
|
}
|
|
if range_header:
|
|
headers["Range"] = range_header
|
|
|
|
client = httpx.AsyncClient(timeout=None)
|
|
stream_cm = client.stream("POST", f"{DROPBOX_CONTENT_URL}/files/download", headers=headers)
|
|
try:
|
|
resp = await stream_cm.__aenter__()
|
|
except Exception:
|
|
await client.aclose()
|
|
raise
|
|
|
|
if resp.status_code == 409:
|
|
try:
|
|
content = await resp.aread()
|
|
_ = content
|
|
finally:
|
|
await stream_cm.__aexit__(None, None, None)
|
|
await client.aclose()
|
|
self._raise_dbx_error(resp, rel=rel)
|
|
|
|
if resp.status_code >= 400:
|
|
try:
|
|
await resp.aread()
|
|
finally:
|
|
await stream_cm.__aexit__(None, None, None)
|
|
await client.aclose()
|
|
resp.raise_for_status()
|
|
|
|
content_type = resp.headers.get("Content-Type") or (mimetypes.guess_type(rel)[0] or "application/octet-stream")
|
|
out_headers = {}
|
|
for key in ("Accept-Ranges", "Content-Range", "Content-Length"):
|
|
value = resp.headers.get(key)
|
|
if value:
|
|
out_headers[key] = value
|
|
|
|
async def iterator():
|
|
try:
|
|
async for chunk in resp.aiter_bytes():
|
|
if chunk:
|
|
yield chunk
|
|
finally:
|
|
await stream_cm.__aexit__(None, None, None)
|
|
await client.aclose()
|
|
|
|
return StreamingResponse(iterator(), status_code=resp.status_code, headers=out_headers, media_type=content_type)
|
|
|
|
|
|
ADAPTER_TYPE = "dropbox"
|
|
CONFIG_SCHEMA = [
|
|
{"key": "app_key", "label": "App Key", "type": "string", "required": True},
|
|
{"key": "app_secret", "label": "App Secret", "type": "password", "required": True},
|
|
{"key": "refresh_token", "label": "Refresh Token", "type": "password", "required": True},
|
|
{"key": "root", "label": "Root Path", "type": "string", "required": False, "default": "/", "placeholder": "/ or /Apps/Foxel"},
|
|
{"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 60},
|
|
{"key": "enable_direct_download_307", "label": "Enable 307 redirect download", "type": "boolean", "default": False},
|
|
]
|
|
|
|
|
|
def ADAPTER_FACTORY(rec): return DropboxAdapter(rec)
|
|
|