""" WebDAV 客户端工具 基于 requests 实现,兼容 Alist 等 WebDAV 服务 """ import json from datetime import datetime from typing import Any import requests from requests.auth import HTTPBasicAuth from app.utils.logger import logger class WebDAVClient: """WebDAV 客户端,用于与 Alist 等 WebDAV 服务交互""" def __init__(self, url: str, username: str, password: str, path: str = "/elysia-todo/"): self.base_url = url.rstrip("/") self.username = username self.password = username # Alist 使用用户名作为密码 self.auth = HTTPBasicAuth(username, self.password) self.path = path if path.startswith("/") else f"/{path}" self._session = requests.Session() self._session.auth = self.auth self._session.timeout = 30 self._session.headers.update({"Content-Type": "application/json"}) @property def _data_url(self) -> str: return f"{self.base_url}{self.path}data/" @property def _backups_url(self) -> str: return f"{self.base_url}{self.path}backups/" def _url(self, filename: str) -> str: return f"{self._data_url}{filename}" def _manifest_url(self) -> str: return f"{self.base_url}{self.path}manifest.json" def test_connection(self) -> tuple[bool, str]: """测试 WebDAV 连接,返回 (成功, 消息)""" try: resp = self._session.request("PROPFIND", f"{self.base_url}{self.path}", headers={"Depth": "0"}) if resp.status_code in (200, 207, 404): return True, "连接成功" return False, f"连接失败: HTTP {resp.status_code}" except requests.ConnectionError: return False, "连接失败: 无法连接到服务器" except requests.Timeout: return False, "连接超时" except Exception as e: return False, f"连接失败: {str(e)}" def ensure_dirs(self) -> bool: """确保远端目录结构存在""" try: for path in [self.path, f"{self.path}data/", f"{self.path}backups/"]: url = f"{self.base_url}{path}" self._session.request("PROPFIND", url, headers={"Depth": "0"}) resp = self._session.request("MKCOL", url) if resp.status_code in (200, 201, 405, 301): pass return True except Exception as e: logger.error(f"创建远端目录失败: {e}") return False def upload_json(self, filename: str, data: Any) -> bool: """上传 JSON 数据到 WebDAV""" try: url = self._url(filename) if filename != "manifest.json" else self._manifest_url() content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8") headers = {"Content-Type": "application/json"} resp = self._session.put(url, data=content, headers=headers) if resp.status_code in (200, 201, 204): return True logger.error(f"上传 {filename} 失败: HTTP {resp.status_code}") return False except Exception as e: logger.error(f"上传 {filename} 异常: {e}") return False def download_json(self, filename: str) -> Any | None: """从 WebDAV 下载 JSON 数据""" try: url = self._url(filename) if filename != "manifest.json" else self._manifest_url() resp = self._session.get(url) if resp.status_code == 200: return resp.json() if resp.status_code == 404: return None logger.error(f"下载 {filename} 失败: HTTP {resp.status_code}") return None except Exception as e: logger.error(f"下载 {filename} 异常: {e}") return None def delete_file(self, filename: str) -> bool: """删除 WebDAV 上的文件""" try: url = self._url(filename) if filename != "manifest.json" else self._manifest_url() resp = self._session.delete(url) return resp.status_code in (200, 204, 404) except Exception as e: logger.error(f"删除 {filename} 异常: {e}") return False def backup_remote(self, timestamp: str) -> bool: """备份远端数据到 backups/{timestamp}/""" try: backup_path = f"{self.path}backups/{timestamp}/data/" backup_url = f"{self.base_url}{backup_path}" self._session.request("MKCOL", f"{self.base_url}{self.path}backups/") self._session.request("MKCOL", f"{self.base_url}{self.path}backups/{timestamp}/") self._session.request("MKCOL", backup_url) for filename in [ "manifest.json", "user_settings.json", "categories.json", "tasks.json", "tags.json", "task_tags.json", "habit_groups.json", "habits.json", "habit_checkins.json", "anniversary_categories.json", "anniversaries.json", "goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json", ]: data = self.download_json(filename) if data is not None: src_url = self._url(filename) if filename != "manifest.json" else self._manifest_url() dst_url = f"{backup_url}{filename}" if filename != "manifest.json" else f"{self.base_url}{backup_path}../manifest.json" content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8") self._session.put(dst_url, data=content, headers={"Content-Type": "application/json"}) return True except Exception as e: logger.error(f"备份远端数据失败: {e}") return False def clear_remote(self) -> bool: """清空远端数据目录""" filenames = [ "manifest.json", "user_settings.json", "categories.json", "tasks.json", "tags.json", "task_tags.json", "habit_groups.json", "habits.json", "habit_checkins.json", "anniversary_categories.json", "anniversaries.json", "goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json", ] for f in filenames: self.delete_file(f) return True