jzyg123 commited on
Commit
9ecdee6
·
verified ·
1 Parent(s): 5160d1d

Create webdav_sync.py

Browse files
Files changed (1) hide show
  1. webdav_sync.py +195 -0
webdav_sync.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # webdav_sync.py
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ WebDAV 同步器:启动时从远端拉取 history.json,随后每 30 分钟将本地变动回传到远端。
5
+ - 仅依赖 requests
6
+ - 支持 Basic Auth
7
+ - 避免多进程/多实例重复同步:使用文件锁保证只有一个同步线程在运行
8
+ 环境变量(至少配置 WEBDAV_URL):
9
+ WEBDAV_URL : 远端文件的完整 URL(例如 https://dav.example.com/path/history.json)
10
+ WEBDAV_USERNAME : (可选)用户名
11
+ WEBDAV_PASSWORD : (可选)密码
12
+ WEBDAV_VERIFY_SSL : (可选)默认为 true;设为 "false" 可跳过证书验证
13
+ WEBDAV_SYNC_INTERVAL : (可选)同步间隔秒,默认 1800(30 分钟)
14
+ WEBDAV_SYNC_ENABLED : (可选)默认启用;设为 "false" 可关闭
15
+ """
16
+
17
+ import os
18
+ import time
19
+ import json
20
+ import hashlib
21
+ import threading
22
+ from contextlib import contextmanager
23
+ from typing import Optional
24
+
25
+ import requests
26
+
27
+ DEFAULT_INTERVAL = 1800 # 30 minutes
28
+ LOCKFILE_PATH = "/tmp/webdav_sync.lock" # 进程间锁,确保只跑一个同步线程
29
+
30
+ def _bool_env(name: str, default: bool) -> bool:
31
+ v = os.environ.get(name)
32
+ if v is None:
33
+ return default
34
+ return str(v).strip().lower() in ("1", "true", "yes", "on")
35
+
36
+ def _int_env(name: str, default: int) -> int:
37
+ v = os.environ.get(name)
38
+ try:
39
+ return int(v) if v is not None else default
40
+ except ValueError:
41
+ return default
42
+
43
+ def _sha1_of_file(path: str) -> Optional[str]:
44
+ if not os.path.isfile(path):
45
+ return None
46
+ h = hashlib.sha1()
47
+ with open(path, "rb") as f:
48
+ for chunk in iter(lambda: f.read(8192), b""):
49
+ h.update(chunk)
50
+ return h.hexdigest()
51
+
52
+ @contextmanager
53
+ def _interprocess_lock(path: str):
54
+ """简易文件锁(独占创建)。拿不到锁就不抛异常,直接 yield False。"""
55
+ fd = None
56
+ acquired = False
57
+ try:
58
+ # O_EXCL + O_CREAT:若文件已存在会失败
59
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
60
+ os.write(fd, str(os.getpid()).encode("utf-8"))
61
+ acquired = True
62
+ yield True
63
+ except FileExistsError:
64
+ yield False
65
+ finally:
66
+ if fd is not None:
67
+ try:
68
+ os.close(fd)
69
+ os.unlink(path)
70
+ except Exception:
71
+ pass
72
+
73
+ class WebDavSyncer:
74
+ def __init__(self, local_path: str):
75
+ self.local_path = local_path
76
+ self.url = os.environ.get("WEBDAV_URL", "").strip()
77
+ self.username = os.environ.get("WEBDAV_USERNAME", "")
78
+ self.password = os.environ.get("WEBDAV_PASSWORD", "")
79
+ self.verify_ssl = _bool_env("WEBDAV_VERIFY_SSL", True)
80
+ self.interval = _int_env("WEBDAV_SYNC_INTERVAL", DEFAULT_INTERVAL)
81
+ self.enabled = _bool_env("WEBDAV_SYNC_ENABLED", True) and bool(self.url)
82
+ self._lock = threading.Lock()
83
+ self._stop = threading.Event()
84
+ self._thread = None
85
+ self._last_pushed_hash = None
86
+
87
+ def _session(self) -> requests.Session:
88
+ s = requests.Session()
89
+ if self.username:
90
+ s.auth = (self.username, self.password)
91
+ s.verify = self.verify_ssl
92
+ s.headers.update({"User-Agent": "history-sync/1.0"})
93
+ return s
94
+
95
+ # ====== Pull(启动时执行一次)======
96
+ def initial_pull(self):
97
+ if not self.enabled:
98
+ print("[webdav-sync] Disabled or missing WEBDAV_URL; skip initial pull.")
99
+ return
100
+ try:
101
+ with self._session() as s:
102
+ # 先 HEAD 看看是否存在
103
+ h = s.head(self.url, timeout=10)
104
+ if h.status_code == 404:
105
+ print(f"[webdav-sync] Remote not found (404): {self.url}. Skip pull.")
106
+ return
107
+ if h.status_code >= 400:
108
+ print(f"[webdav-sync] HEAD failed: {h.status_code} {h.text[:200]}")
109
+ return
110
+ r = s.get(self.url, timeout=30)
111
+ if r.status_code == 404:
112
+ print(f"[webdav-sync] Remote not found on GET: {self.url}.")
113
+ return
114
+ r.raise_for_status()
115
+ content = r.content
116
+ # 简单校验是否是 JSON
117
+ try:
118
+ json.loads(content.decode("utf-8"))
119
+ except Exception:
120
+ print("[webdav-sync] Warning: remote content is not valid JSON; still writing as-is.")
121
+ # 写入本地
122
+ os.makedirs(os.path.dirname(self.local_path) or ".", exist_ok=True)
123
+ with open(self.local_path, "wb") as f:
124
+ f.write(content)
125
+ self._last_pushed_hash = _sha1_of_file(self.local_path)
126
+ print(f"[webdav-sync] Pulled remote -> {self.local_path}")
127
+ except requests.RequestException as e:
128
+ print(f"[webdav-sync] Initial pull failed: {e}")
129
+
130
+ # ====== Push(周期性,把本地变更上��)======
131
+ def push_if_changed(self):
132
+ if not self.enabled:
133
+ return
134
+ current_hash = _sha1_of_file(self.local_path)
135
+ if not current_hash:
136
+ # 本地没有文件就不 push
137
+ return
138
+ if current_hash == self._last_pushed_hash:
139
+ return
140
+ try:
141
+ with self._session() as s, open(self.local_path, "rb") as f:
142
+ r = s.put(self.url, data=f, timeout=30)
143
+ if r.status_code >= 400:
144
+ print(f"[webdav-sync] PUT failed: {r.status_code} {r.text[:200]}")
145
+ return
146
+ self._last_pushed_hash = current_hash
147
+ print(f"[webdav-sync] Pushed local -> {self.url}")
148
+ except requests.RequestException as e:
149
+ print(f"[webdav-sync] Push failed: {e}")
150
+
151
+ def _loop(self):
152
+ # 周期循环
153
+ while not self._stop.is_set():
154
+ try:
155
+ with self._lock:
156
+ self.push_if_changed()
157
+ except Exception as e:
158
+ print(f"[webdav-sync] Loop error: {e}")
159
+ self._stop.wait(self.interval)
160
+
161
+ def start(self):
162
+ if not self.enabled:
163
+ print("[webdav-sync] disabled; not starting background sync.")
164
+ return
165
+ # 进程间锁:只有一个进程会启动线程
166
+ with _interprocess_lock(LOCKFILE_PATH) as ok:
167
+ if not ok:
168
+ print("[webdav-sync] another process is already syncing; skip starting thread.")
169
+ return
170
+ # 记录当前 baseline
171
+ self._last_pushed_hash = _sha1_of_file(self.local_path)
172
+ self._thread = threading.Thread(target=self._loop, name="WebDavSyncer", daemon=True)
173
+ self._thread.start()
174
+ # 注意:离开 with 后锁文件会被释放——这是有意的;
175
+ # 目的只是避免“同时多个进程创建线程”。创建完成后就算释放,也只会有一个线程已在运行。
176
+
177
+ def stop(self):
178
+ self._stop.set()
179
+ if self._thread and self._thread.is_alive():
180
+ self._thread.join(timeout=3)
181
+
182
+ # ====== 供应用调用的便捷入口 ======
183
+
184
+ def start_webdav_sync_if_configured(local_history_path: str):
185
+ """
186
+ 在应用启动时调用:
187
+ 1) 先尝试拉取远端文件覆盖本地
188
+ 2) 启动后台线程每隔 interval 推送本地变更
189
+ """
190
+ syncer = WebDavSyncer(local_history_path)
191
+ # 先拉取一份,保证“新部署前从远端下载到运行目录”的要求
192
+ syncer.initial_pull()
193
+ # 再启动定时 push
194
+ syncer.start()
195
+ return syncer # 如需在应用关闭时手动 stop,可保留引用