Handle removed WebDAV roots without blocking sync

This commit is contained in:
Terranom674
2026-09-06 21:09:39 +02:00
parent 6983a98875
commit eb002b007f

View File

@@ -32,6 +32,17 @@ SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
PLACEHOLDER = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==") PLACEHOLDER = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==")
class WebDavAuthenticationError(RuntimeError):
pass
class WebDavPathUnavailable(RuntimeError):
def __init__(self, relative: str, status: int) -> None:
self.relative = validate_relative(relative)
self.status = int(status)
super().__init__(f"Nextcloud path unavailable with HTTP {self.status}: {self.relative or '/'}")
def fail(message: str) -> None: def fail(message: str) -> None:
raise RuntimeError(message) raise RuntimeError(message)
@@ -136,8 +147,10 @@ class WebDavClient:
status = response.status status = response.status
payload = response.read() payload = response.read()
except urllib.error.HTTPError as error: except urllib.error.HTTPError as error:
if error.code in {401, 403}: if error.code == 401:
fail("Nextcloud rejected the WebDAV credentials or directory access") raise WebDavAuthenticationError("Nextcloud rejected the WebDAV credentials") from error
if error.code in {403, 404}:
raise WebDavPathUnavailable(relative, error.code) from error
fail(f"Nextcloud PROPFIND failed with HTTP {error.code}") fail(f"Nextcloud PROPFIND failed with HTTP {error.code}")
except urllib.error.URLError as error: except urllib.error.URLError as error:
fail(f"Nextcloud WebDAV is unreachable: {error.reason}") fail(f"Nextcloud WebDAV is unreachable: {error.reason}")
@@ -189,6 +202,25 @@ class WebDavClient:
return current, children return current, children
def confirmed_removed_root(client: WebDavClient, remote_root: str, error: WebDavPathUnavailable) -> bool:
remote_root = validate_relative(remote_root)
if remote_root == "":
return False
if error.status == 404:
return True
if error.status != 403:
return False
parent_path = PurePosixPath(remote_root).parent
parent = "" if str(parent_path) == "." else str(parent_path).strip("/")
_, siblings = client.list_collection(parent)
wanted = PurePosixPath(remote_root).name.casefold()
for sibling in siblings:
if str(sibling.get("display_name", "")).casefold() == wanted:
return False
return True
def link_placeholder(seed: Path, target: Path) -> None: def link_placeholder(seed: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
try: try:
@@ -301,14 +333,34 @@ def main() -> int:
staging.mkdir(parents=True) staging.mkdir(parents=True)
client = WebDavClient(args.base_url, args.user, password, max(1, args.timeout)) client = WebDavClient(args.base_url, args.user, password, max(1, args.timeout))
# A successful request against the user's WebDAV root proves that the
# credentials and the Nextcloud endpoint are healthy. Only after this
# guard may an individual configured root be interpreted as removed.
client.list_collection("")
mapping: dict[str, dict[str, object]] = {} mapping: dict[str, dict[str, object]] = {}
manifest: list[str] = [] manifest: list[str] = []
total_files = total_folders = total_skipped = 0 total_files = total_folders = total_skipped = 0
used_names: set[str] = set() used_names: set[str] = set()
removed_roots: list[dict[str, object]] = []
for remote_root_raw in args.root: for remote_root_raw in args.root:
remote_root = validate_relative(remote_root_raw) remote_root = validate_relative(remote_root_raw)
try:
current, _ = client.list_collection(remote_root) current, _ = client.list_collection(remote_root)
except WebDavPathUnavailable as error:
if not confirmed_removed_root(client, remote_root, error):
raise RuntimeError(
f"Configured WebDAV root cannot be read safely; existing mirror is preserved: {remote_root or '/'} (HTTP {error.status})"
) from error
removed_roots.append({"path": remote_root, "status": error.status})
print(
f"webdav-placeholder: configured root no longer available and will be removed from the mirror: {remote_root or '/'} (HTTP {error.status})",
file=sys.stderr,
)
continue
fileid = int(current["fileid"]) fileid = int(current["fileid"])
display = str(current.get("display_name", "")).strip() or (PurePosixPath(remote_root).name if remote_root else args.user) display = str(current.get("display_name", "")).strip() or (PurePosixPath(remote_root).name if remote_root else args.user)
local_name = f"root-{fileid}" local_name = f"root-{fileid}"
@@ -366,6 +418,9 @@ def main() -> int:
}) })
print(json.dumps({ print(json.dumps({
"roots": len(args.root), "roots": len(args.root),
"roots_available": len(args.root) - len(removed_roots),
"roots_removed": len(removed_roots),
"removed_root_details": removed_roots,
"files": total_files, "files": total_files,
"folders": total_folders, "folders": total_folders,
"skipped": total_skipped, "skipped": total_skipped,