Backend: - Add uuid, sync_version, is_deleted fields to all syncable models - Add SyncSettings model for WebDAV configuration (AES-256-GCM encrypted passwords) - Add crypto.py: AES-256-GCM encryption derived from JWT_SECRET via PBKDF2 - Add sync_lock.py: thread-level sync lock with 503 middleware for write blocking - Add webdav.py: WebDAV client using requests (PUT/GET/MKCOL/DELETE) - Add sync_service.py: push/pull/bidirectional merge with LWW conflict resolution - Add sync router with 8 endpoints: config, test, push, pull, sync, status, remote delete - Add UUID backfill for existing records in init_db() - Add SQLAlchemy before_update event to auto-increment sync_version - Register sync middleware to block writes during sync (503) Frontend: - Add sync API client (WebUI/src/api/sync.ts) - Add useSyncStore with config, test, push/pull/sync operations - Add WebDAV config + sync UI in SettingsView - Add 503 status code handling in axios interceptor - Add uuid field to all TypeScript type definitions Scripts: - Add scripts/start.bat and scripts/stop.bat for project management Design doc: docs/plan/webdav-sync-design.md
150 lines
6.3 KiB
Python
150 lines
6.3 KiB
Python
"""
|
|
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 |