From 74e626bebe273c1ffb93c48df76322480e06f3c4 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Fri, 22 May 2026 14:09:22 +0000 Subject: [PATCH 01/23] docs: rclone documentation --- docs/rclone.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/rclone.md diff --git a/docs/rclone.md b/docs/rclone.md new file mode 100644 index 0000000..040310a --- /dev/null +++ b/docs/rclone.md @@ -0,0 +1,101 @@ + + +# Desktop File Access via WebDAV + +Cloud Storage includes a built-in WebDAV server that lets users browse and download their ERPNext files from any WebDAV-compatible desktop client. Files remain stored in S3; ERPNext remains the authoritative registry. + +## Starting the Server + +```bash +bench webdav-server +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--site` | first site | Frappe site to serve | +| `--port` | `8010` | Port to listen on | +| `--host` | `0.0.0.0` | Host/IP to bind to | + +## Generating API Credentials + +Each user connects with their own Frappe API key and secret: + +1. Click your avatar (top right) → **My Settings** +2. Scroll to **API Access** → **Generate Keys** +3. Copy the **API Key** and **API Secret** — the secret is shown only once + +## Connecting a Desktop Client + +### macOS — Finder + +1. **Go → Connect to Server…** (⌘K) +2. Enter `http://yourserver:8010/` +3. Username: **API Key** · Password: **API Secret** + +### Windows — Explorer + +1. **This PC → Computer → Map network drive** +2. Enter `http://yourserver:8010/` +3. Check **Connect using different credentials**, enter API Key and Secret + +### rclone (any OS) + +Add to `~/.config/rclone/rclone.conf`: + +```ini +[frappe] +type = webdav +url = http://yourserver:8010/ +vendor = other +user = +pass = > +``` + +Basic usage: + +```bash +rclone ls frappe: +rclone copy "frappe:report.pdf" ~/Downloads/ +``` + +## Production + +The built-in server (`wsgiref`) is single-threaded. For production, run through gunicorn + nginx. + +**Supervisor** (`/etc/supervisor/conf.d/frappe-webdav.conf`): + +```ini +[program:frappe-webdav] +command=/path/to/bench/env/bin/gunicorn \ + --bind 127.0.0.1:8010 \ + --workers 2 \ + --worker-class sync \ + "cloud_storage.cloud_storage.webdav.app:create_webdav_app('mysite.localhost')" +directory=/path/to/bench +user=frappe +autostart=true +autorestart=true +stderr_logfile=/var/log/frappe-webdav.err.log +stdout_logfile=/var/log/frappe-webdav.out.log +``` + +**nginx** (add to site config): + +```nginx +location /dav/ { + proxy_pass http://127.0.0.1:8010/; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + proxy_pass_header Authorization; + client_max_body_size 100m; +} +``` + +Update your rclone remote URL to `https://mysite.example.com/dav/`. + +## Security + +- **Use HTTPS in production.** API credentials are sent as HTTP Basic Auth — always terminate TLS at nginx before exposing outside localhost. +- **Per-user access.** Each user sees only the files they have permission to access in ERPNext. +- Regenerate your API secret in **My Settings → API Access** From 3b62ba1feca5b029674b8037e9aeb16409312052 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Fri, 22 May 2026 14:10:06 +0000 Subject: [PATCH 02/23] docs: rclone documentation --- docs/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.md b/docs/index.md index 1c6d42a..be9d28c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ See the following pages for detailed instructions on Cloud Storage installation - [Cloud Storage Developer Environment Installation](./development.md) - [Cloud Storage Production Environment Installation](./production.md) - [Cloud Storage Configuration](./configuration.md) +- [Desktop File Access with rclone](./rclone.md) - [Hooks](./hooks.md) - [Commands](./commands.md) From ef6b1d0e3e99b97203126cd7bc5f5c2fb4b0c960 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Fri, 22 May 2026 14:10:42 +0000 Subject: [PATCH 03/23] feat: add webdav server + rclone integration --- .../cloud_storage/webdav/__init__.py | 0 cloud_storage/cloud_storage/webdav/app.py | 85 ++++++ .../cloud_storage/webdav/provider.py | 264 ++++++++++++++++++ cloud_storage/commands.py | 47 ++++ cloud_storage/hooks.py | 4 + pyproject.toml | 1 + 6 files changed, 401 insertions(+) create mode 100644 cloud_storage/cloud_storage/webdav/__init__.py create mode 100644 cloud_storage/cloud_storage/webdav/app.py create mode 100644 cloud_storage/cloud_storage/webdav/provider.py diff --git a/cloud_storage/cloud_storage/webdav/__init__.py b/cloud_storage/cloud_storage/webdav/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloud_storage/cloud_storage/webdav/app.py b/cloud_storage/cloud_storage/webdav/app.py new file mode 100644 index 0000000..31d345e --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/app.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +from __future__ import annotations + +import frappe +from wsgidav.dc.base_dc import BaseDomainController +from wsgidav.wsgidav_app import WsgiDAVApp + +from .provider import FrappeDAVProvider + + +class FrappeDomainController(BaseDomainController): + """Validates rclone Basic Auth credentials against Frappe's api_key / api_secret.""" + + def get_domain_realm(self, path_info: str, environ) -> str: + return "Frappe Cloud Storage" + + def require_authentication(self, realm: str, environ) -> bool: + return True + + def supports_http_digest_auth(self) -> bool: + return False + + def digest_auth_user(self, realm: str, user_name: str, environ: dict) -> str: + raise NotImplementedError + + def basic_auth_user(self, realm: str, user_name: str, password: str, environ: dict) -> bool: + """user_name is the Frappe api_key, password is the api_secret.""" + try: + user = frappe.db.get_value("User", {"api_key": user_name}, "name") + if not user: + return False + from frappe.utils.password import get_decrypted_password + + stored_secret = get_decrypted_password("User", user, fieldname="api_secret") + if password == stored_secret: + frappe.set_user(user) + return True + return False + except Exception: + frappe.logger().exception("WebDAV basic auth error") + return False + + +class FrappeInitMiddleware: + """Wraps the WebDAV WSGI app to initialise / tear down Frappe's per-request context. + + WsgiDAVApp.__call__ is a generator (it uses ``yield from`` internally). If we call + it and return the generator object, the body won't run until wsgiref iterates it — + which is after our finally-block has called frappe.destroy(). Making this middleware + also a generator (via ``yield from``) keeps frappe alive for the entire response. + """ + + def __init__(self, app, site: str) -> None: + self.app = app + self.site = site + + def __call__(self, environ: dict, start_response): + frappe.init(site=self.site) + frappe.connect() + try: + yield from self.app(environ, start_response) + finally: + frappe.destroy() + + +def create_webdav_app(site: str) -> FrappeInitMiddleware: + """Return a WSGI application serving the Frappe File tree over WebDAV.""" + provider = FrappeDAVProvider() + config = { + "provider_mapping": {"/": provider}, + "http_authenticator": { + "domain_controller": FrappeDomainController, + "accept_basic": True, + "accept_digest": False, + "default_to_digest": False, + }, + "verbose": 1, + "logging": { + "enable_loggers": [], + }, + } + webdav_app = WsgiDAVApp(config) + return FrappeInitMiddleware(webdav_app, site) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py new file mode 100644 index 0000000..ee061c3 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -0,0 +1,264 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +from __future__ import annotations + +import io +import mimetypes +from typing import Optional + +import frappe +from wsgidav.dav_error import DAVError, HTTP_FORBIDDEN, HTTP_NOT_FOUND +from wsgidav.dav_provider import DAVCollection, DAVNonCollection, DAVProvider + + +def _dav_path_to_frappe_folder(dav_path: str) -> str: + """Map a WebDAV path to the corresponding Frappe folder name. + + / → Home + /Attachments/ → Home/Attachments + /a/b/c/ → Home/a/b/c + """ + parts = [p for p in dav_path.strip("/").split("/") if p] + if not parts: + return "Home" + return "Home/" + "/".join(parts) + + +def _folder_display_name(frappe_folder: str) -> str: + return frappe_folder.rsplit("/", 1)[-1] + + +class FrappeCollection(DAVCollection): + """A Frappe File folder exposed as a WebDAV collection.""" + + def __init__(self, path: str, environ: dict, frappe_folder: str) -> None: + super().__init__(path, environ) + self.frappe_folder = frappe_folder + self._meta: Optional[dict] = None + + def _get_meta(self) -> Optional[dict]: + if self._meta is None: + parent = frappe_folder_parent(self.frappe_folder) + if not parent: + # "Home" is the virtual root — no File doc exists for it + return None + name = _folder_display_name(self.frappe_folder) + self._meta = frappe.db.get_value( + "File", + {"file_name": name, "folder": parent, "is_folder": 1}, + ["creation", "modified"], + as_dict=True, + ) + return self._meta + + def get_creation_date(self) -> Optional[float]: + meta = self._get_meta() + return meta.creation.timestamp() if meta else None + + def get_last_modified(self) -> Optional[float]: + meta = self._get_meta() + return meta.modified.timestamp() if meta else None + + def get_display_name(self) -> str: + return _folder_display_name(self.frappe_folder) + + def get_etag(self) -> None: + return None + + def get_member_names(self) -> list[str]: + names: list[str] = [] + + folders = frappe.get_all( + "File", + filters={"folder": self.frappe_folder, "is_folder": 1}, + fields=["file_name"], + ) + names.extend(f.file_name for f in folders) + + files = frappe.get_all( + "File", + filters={"folder": self.frappe_folder, "is_folder": 0}, + fields=["file_name"], + ) + names.extend(f.file_name for f in files) + + return names + + def get_member(self, name: str): + child_path = self.path.rstrip("/") + "/" + name + child_frappe_folder = f"{self.frappe_folder}/{name}" + + if frappe.db.exists("File", {"folder": self.frappe_folder, "file_name": name, "is_folder": 1}): + return FrappeCollection(child_path + "/", self.environ, child_frappe_folder) + + file_doc = frappe.db.get_value( + "File", + {"folder": self.frappe_folder, "file_name": name, "is_folder": 0}, + _FILE_FIELDS, + as_dict=True, + ) + if file_doc: + return FrappeFile(child_path, self.environ, file_doc) + + return None + + # v1: read-only — reject all write operations + def create_empty_resource(self, name: str): + raise DAVError(HTTP_FORBIDDEN) + + def create_collection(self, name: str): + raise DAVError(HTTP_FORBIDDEN) + + def delete(self): + raise DAVError(HTTP_FORBIDDEN) + + def copy_move_single(self, dest_path: str, is_move: bool): + raise DAVError(HTTP_FORBIDDEN) + + def support_recursive_delete(self) -> bool: + return False + + def support_recursive_move(self, dest_path: str) -> bool: + return False + + +_FILE_FIELDS = [ + "name", + "file_name", + "file_size", + "content_hash", + "modified", + "creation", + "s3_key", + "is_private", + "file_url", +] + + +class FrappeFile(DAVNonCollection): + """A Frappe File record exposed as a WebDAV resource.""" + + def __init__(self, path: str, environ: dict, file_doc: dict) -> None: + super().__init__(path, environ) + self.file_doc = file_doc + + def get_content_length(self) -> Optional[int]: + return self.file_doc.file_size or None + + def get_content_type(self) -> str: + mime, _ = mimetypes.guess_type(self.file_doc.file_name or "") + return mime or "application/octet-stream" + + def get_last_modified(self) -> Optional[float]: + if self.file_doc.modified: + return self.file_doc.modified.timestamp() + return None + + def get_creation_date(self) -> Optional[float]: + if self.file_doc.creation: + return self.file_doc.creation.timestamp() + return None + + def get_etag(self) -> Optional[str]: + return self.file_doc.content_hash or None + + def get_display_name(self) -> str: + return self.file_doc.file_name + + def support_etag(self) -> bool: + return bool(self.file_doc.content_hash) + + def support_content_length(self) -> bool: + return bool(self.file_doc.file_size) + + def support_modified(self) -> bool: + return True + + def support_ranges(self) -> bool: + return False + + def get_content(self) -> io.RawIOBase: + from cloud_storage.cloud_storage.overrides.file import get_cloud_storage_client + + if self.file_doc.s3_key: + client = get_cloud_storage_client() + response = client.get_object(Bucket=client.bucket, Key=self.file_doc.s3_key) + return response["Body"] + + if self.file_doc.is_private: + file_path = frappe.get_site_path("private", "files", self.file_doc.file_name) + else: + file_path = frappe.get_site_path("public", "files", self.file_doc.file_name) + return open(file_path, "rb") + + # v1: read-only + def begin_write(self, content_type: Optional[str] = None): + raise DAVError(HTTP_FORBIDDEN) + + def delete(self): + raise DAVError(HTTP_FORBIDDEN) + + def copy_move_single(self, dest_path: str, is_move: bool): + raise DAVError(HTTP_FORBIDDEN) + + def support_recursive_delete(self) -> bool: + return False + + def support_recursive_move(self, dest_path: str) -> bool: + return False + + +class FrappeDAVProvider(DAVProvider): + """Root WebDAV provider: maps URL paths to Frappe File doctypes.""" + + def get_resource_inst(self, path: str, environ: dict): + """Return the DAVCollection or DAVNonCollection for the given path, or None.""" + norm = path.rstrip("/") + + if not norm: + return FrappeCollection("/", environ, "Home") + + parts = [p for p in norm.split("/") if p] + + # Walk intermediate folder segments to build the parent folder path + frappe_folder = "Home" + for part in parts[:-1]: + parent = frappe_folder + if not frappe.db.exists("File", {"folder": parent, "file_name": part, "is_folder": 1}): + return None + frappe_folder = f"{parent}/{part}" + + last = parts[-1] + + # Last segment: folder? + if frappe.db.exists("File", {"folder": frappe_folder, "file_name": last, "is_folder": 1}): + child_folder = f"{frappe_folder}/{last}" + canonical = path if path.endswith("/") else path + "/" + return FrappeCollection(canonical, environ, child_folder) + + # Last segment: file? + file_doc = frappe.db.get_value( + "File", + {"folder": frappe_folder, "file_name": last, "is_folder": 0}, + _FILE_FIELDS, + as_dict=True, + ) + if file_doc: + return FrappeFile(path, environ, file_doc) + + return None + + def is_readonly(self) -> bool: + return False + + +def frappe_folder_parent(frappe_folder: str) -> str: + """Return the parent folder of a Frappe folder path. + + Home/Attachments → Home + Home → (empty string, root) + """ + if "/" not in frappe_folder: + return "" + return frappe_folder.rsplit("/", 1)[0] diff --git a/cloud_storage/commands.py b/cloud_storage/commands.py index 0aa8617..2b96dc7 100644 --- a/cloud_storage/commands.py +++ b/cloud_storage/commands.py @@ -94,7 +94,54 @@ def migrate_cloud_storage_paths(context, site=None, dry_run=False, limit=None, b frappe.destroy() +@click.command("webdav-server") +@click.option("--site", help="Site name") +@click.option("--port", type=int, default=8010, help="Port to listen on (default: 8010)") +@click.option("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") +@pass_context +def webdav_server(context, site=None, port=8010, host="0.0.0.0"): + """ + Start a WebDAV server for rclone / desktop client integration. + + Files are served read-only from the Frappe File tree. Authenticate with + a Frappe API key and secret (Settings → My Account → API Access). + + Examples: + bench --site mysite.localhost webdav-server + bench --site mysite.localhost webdav-server --port 8010 + + rclone config (add to ~/.config/rclone/rclone.conf): + [frappe] + type = webdav + url = http://localhost:8010/ + vendor = other + user = + pass = )> + """ + from wsgiref.simple_server import make_server + + from cloud_storage.cloud_storage.webdav.app import create_webdav_app + + site = site or context.sites[0] + app = create_webdav_app(site=site) + + print(f"WebDAV server running on http://{host}:{port}/") + print(f"Site: {site}") + print() + print("rclone config snippet:") + print(" [frappe]") + print(" type = webdav") + print(f" url = http://localhost:{port}/") + print(" vendor = other") + print(" user = ") + print(" pass = >") + + httpd = make_server(host, port, app) + httpd.serve_forever() + + commands = [ migrate_files_to_cloud_storage, migrate_cloud_storage_paths, + webdav_server, ] diff --git a/cloud_storage/hooks.py b/cloud_storage/hooks.py index 674381f..125d8d3 100644 --- a/cloud_storage/hooks.py +++ b/cloud_storage/hooks.py @@ -194,3 +194,7 @@ write_file = "cloud_storage.cloud_storage.overrides.file.write_file" delete_file_data_content = "cloud_storage.cloud_storage.overrides.file.delete_file" # cloud_storage_path_generator = "my_custom_app.utils.custom_get_file_path" + +# Bench commands +# -------------------------------- +commands = ["cloud_storage.commands"] diff --git a/pyproject.toml b/pyproject.toml index 0022be5..8fa04d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ boto3 = "^1.26.41" botocore = "^1.29.41" python-magic = "^0.4.27" moto = {version = "^4.1.6", extras = ["s3"]} +wsgidav = ">=4.3" [tool.poetry.group.dev.dependencies] pytest = "^7.2.2" From c64d84de193c27cc98c6b01b5693366e44e38ca9 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Fri, 22 May 2026 14:50:37 +0000 Subject: [PATCH 04/23] feat: delete file --- .../cloud_storage/webdav/provider.py | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index ee061c3..7ce35d6 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -29,6 +29,9 @@ def _folder_display_name(frappe_folder: str) -> str: return frappe_folder.rsplit("/", 1)[-1] +_SYSTEM_FOLDERS = {"Home", "Home/Attachments"} + + class FrappeCollection(DAVCollection): """A Frappe File folder exposed as a WebDAV collection.""" @@ -103,7 +106,6 @@ def get_member(self, name: str): return None - # v1: read-only — reject all write operations def create_empty_resource(self, name: str): raise DAVError(HTTP_FORBIDDEN) @@ -111,7 +113,26 @@ def create_collection(self, name: str): raise DAVError(HTTP_FORBIDDEN) def delete(self): - raise DAVError(HTTP_FORBIDDEN) + if self.frappe_folder in _SYSTEM_FOLDERS: + raise DAVError(HTTP_FORBIDDEN) + + parent = frappe_folder_parent(self.frappe_folder) + display_name = _folder_display_name(self.frappe_folder) + folder_name = frappe.db.get_value( + "File", + {"folder": parent, "file_name": display_name, "is_folder": 1}, + "name", + ) + if not folder_name: + raise DAVError(HTTP_NOT_FOUND) + + try: + frappe.delete_doc("File", folder_name) + frappe.db.commit() + except frappe.PermissionError: + raise DAVError(HTTP_FORBIDDEN) + except frappe.ValidationError as e: + raise DAVError(HTTP_FORBIDDEN, str(e)) def copy_move_single(self, dest_path: str, is_move: bool): raise DAVError(HTTP_FORBIDDEN) @@ -192,12 +213,19 @@ def get_content(self) -> io.RawIOBase: file_path = frappe.get_site_path("public", "files", self.file_doc.file_name) return open(file_path, "rb") - # v1: read-only def begin_write(self, content_type: Optional[str] = None): raise DAVError(HTTP_FORBIDDEN) def delete(self): - raise DAVError(HTTP_FORBIDDEN) + try: + # Clear associations first so the hook proceeds to _delete_file_on_disk. + frappe.db.delete("File Association", {"parent": self.file_doc.name}) + frappe.delete_doc("File", self.file_doc.name) + frappe.db.commit() + except frappe.PermissionError: + raise DAVError(HTTP_FORBIDDEN) + except frappe.ValidationError as e: + raise DAVError(HTTP_FORBIDDEN, str(e)) def copy_move_single(self, dest_path: str, is_move: bool): raise DAVError(HTTP_FORBIDDEN) From 414456b750d26a1a95210a7274c463eb30178c1c Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 28 May 2026 15:42:50 +0000 Subject: [PATCH 05/23] fix: change sv to hook approach --- cloud_storage/cloud_storage/webdav/app.py | 142 ++++++ .../cloud_storage/webdav/provider.py | 448 +++++++++++++++++- .../cloud_storage/webdav/renderer.py | 211 +++++++++ cloud_storage/hooks.py | 5 + 4 files changed, 795 insertions(+), 11 deletions(-) create mode 100644 cloud_storage/cloud_storage/webdav/renderer.py diff --git a/cloud_storage/cloud_storage/webdav/app.py b/cloud_storage/cloud_storage/webdav/app.py index 31d345e..20bc1c8 100644 --- a/cloud_storage/cloud_storage/webdav/app.py +++ b/cloud_storage/cloud_storage/webdav/app.py @@ -5,6 +5,7 @@ import frappe from wsgidav.dc.base_dc import BaseDomainController +from wsgidav.lock_man.lock_storage import LockStorageDict from wsgidav.wsgidav_app import WsgiDAVApp from .provider import FrappeDAVProvider @@ -43,6 +44,132 @@ def basic_auth_user(self, realm: str, user_name: str, password: str, environ: di return False +class _LimitedInput: + """Wraps wsgi.input so that reads stop at CONTENT_LENGTH bytes. + + wsgiref passes the raw socket as wsgi.input without limiting it to + Content-Length. wsgidav's _stream_data reads in a loop until read() + returns b"" — on a live HTTP/1.1 connection the socket never returns b"" + after the body, so the PUT handler blocks forever and never sends a response. + """ + + def __init__(self, raw, length: int) -> None: + self._raw = raw + self._remaining = length + + def read(self, size: int = -1) -> bytes: + if self._remaining <= 0: + return b"" + n = self._remaining if size < 0 else min(size, self._remaining) + data = self._raw.read(n) + self._remaining -= len(data) + return data + + def readline(self, size: int = -1) -> bytes: + if self._remaining <= 0: + return b"" + n = self._remaining if size < 0 else min(size, self._remaining) + data = self._raw.readline(n) + self._remaining -= len(data) + return data + + +class _ChunkedInput: + """Decodes HTTP/1.1 Transfer-Encoding: chunked from wsgi.input. + + Required for macOS Finder, which sends large PUT bodies as chunked without + a Content-Length header. wsgiref doesn't decode chunks, so without this the + raw chunk framing ends up in the body and reads past the last chunk block. + + Uses a socket timeout to recover when Finder buffers its final small chunk + and the trailing CRLF / 0-terminator never arrives: by then we have every + byte of the file body, and treating the timeout as EOF lets the PUT complete. + """ + + _IDLE_TIMEOUT = 5.0 + + def __init__(self, raw) -> None: + self._raw = raw + self._chunk_remaining = 0 + self._eof = False + # Find the underlying socket so we can apply a per-read timeout. + probe = raw + while hasattr(probe, "raw"): + probe = probe.raw + self._sock = getattr(probe, "_sock", None) + + def _readline(self) -> bytes: + if self._sock is not None: + old = self._sock.gettimeout() + self._sock.settimeout(self._IDLE_TIMEOUT) + try: + return self._raw.readline() + except (TimeoutError, OSError): + return b"" + finally: + self._sock.settimeout(old) + return self._raw.readline() + + def _read(self, n: int) -> bytes: + if self._sock is not None: + old = self._sock.gettimeout() + self._sock.settimeout(self._IDLE_TIMEOUT) + try: + return self._raw.read(n) + except (TimeoutError, OSError): + return b"" + finally: + self._sock.settimeout(old) + return self._raw.read(n) + + def read(self, size: int = -1) -> bytes: + if self._eof: + return b"" + out = bytearray() + while size < 0 or len(out) < size: + if self._chunk_remaining == 0: + line = self._readline() + if not line: + self._eof = True + break + size_str = line.split(b";", 1)[0].strip() + try: + self._chunk_remaining = int(size_str, 16) + except ValueError: + self._eof = True + break + if self._chunk_remaining == 0: + while True: + trailer = self._readline() + if trailer in (b"\r\n", b"\n", b""): + break + self._eof = True + break + to_read = self._chunk_remaining + if size >= 0: + to_read = min(to_read, size - len(out)) + data = self._read(to_read) + if not data: + self._eof = True + break + out.extend(data) + self._chunk_remaining -= len(data) + if self._chunk_remaining == 0: + self._readline() # CRLF after chunk data (may time out — that's OK) + return bytes(out) + + def readline(self, size: int = -1) -> bytes: + out = bytearray() + while size < 0 or len(out) < size: + byte = self.read(1) + if not byte: + break + out.extend(byte) + if byte == b"\n": + break + return bytes(out) + + class FrappeInitMiddleware: """Wraps the WebDAV WSGI app to initialise / tear down Frappe's per-request context. @@ -57,11 +184,25 @@ def __init__(self, app, site: str) -> None: self.site = site def __call__(self, environ: dict, start_response): + method = environ.get("REQUEST_METHOD", "?") + path = environ.get("PATH_INFO", "?") + cl_raw = environ.get("CONTENT_LENGTH", "MISSING") + te = environ.get("HTTP_TRANSFER_ENCODING", "") + print(f"[REQ] {method} {path!r} CL={cl_raw!r} TE={te!r}", flush=True) frappe.init(site=self.site) frappe.connect() + # Wrap wsgi.input — wsgiref passes the raw socket without honouring + # Content-Length or decoding chunked transfer encoding, so any read past + # the body blocks forever waiting on the still-open connection. + if environ.get("HTTP_TRANSFER_ENCODING", "").lower() == "chunked": + environ["wsgi.input"] = _ChunkedInput(environ["wsgi.input"]) + else: + cl = int(environ.get("CONTENT_LENGTH") or 0) + environ["wsgi.input"] = _LimitedInput(environ["wsgi.input"], cl) try: yield from self.app(environ, start_response) finally: + print(f"[DONE] {method} {path!r}", flush=True) frappe.destroy() @@ -76,6 +217,7 @@ def create_webdav_app(site: str) -> FrappeInitMiddleware: "accept_digest": False, "default_to_digest": False, }, + "lock_storage": LockStorageDict(), "verbose": 1, "logging": { "enable_loggers": [], diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index 7ce35d6..5833319 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -8,7 +8,12 @@ from typing import Optional import frappe -from wsgidav.dav_error import DAVError, HTTP_FORBIDDEN, HTTP_NOT_FOUND +from wsgidav.dav_error import ( + DAVError, + HTTP_FORBIDDEN, + HTTP_METHOD_NOT_ALLOWED, + HTTP_NOT_FOUND, +) from wsgidav.dav_provider import DAVCollection, DAVNonCollection, DAVProvider @@ -30,6 +35,50 @@ def _folder_display_name(frappe_folder: str) -> str: _SYSTEM_FOLDERS = {"Home", "Home/Attachments"} +_OS_FILES = frozenset({".DS_Store", "desktop.ini", "Thumbs.db", ".Trashes", ".Spotlight-V100"}) +_OS_PREFIXES = ("._",) + +# Editor / OS atomic-save scratchpads. macOS TextEdit and many other editors +# write via MKCOL → PUT → MOVE → DELETE on a sibling folder named like +# ".sb-". They're real folders/files (we must let MKCOL/PUT/MOVE +# work on them) but they only ever exist for a few hundred ms — we hide them +# from listings so they don't appear in Finder if a save crashes mid-flight. +_TEMP_PREFIXES = (".sb-",) + +# In-memory store for OS metadata files (AppleDouble ._*, .DS_Store, ...). +# Finder requires these to read/write/lock successfully for drag-and-drop to work, +# but they're transient and shouldn't pollute Frappe / S3. They live here only +# for the lifetime of the server process — small, ephemeral, and per-server. +_OS_FILE_STORE: dict[str, bytes] = {} + + +def _is_os_metadata(name: str) -> bool: + return name in _OS_FILES or name.startswith(_OS_PREFIXES) + + +def _is_hidden_from_listing(name: str) -> bool: + return name.startswith(_OS_PREFIXES) or name in _OS_FILES or name.startswith(_TEMP_PREFIXES) + + +def _parse_dest(dest_path: str) -> tuple[str, str]: + """Parse a WebDAV destination path into (frappe_folder, new_name). + + wsgidav may pass the destination either with or without the /dav mount + prefix; we handle both. + """ + dest = dest_path + if dest.startswith("/dav/"): + dest = dest[len("/dav"):] + elif dest in ("/dav", "/dav/"): + dest = "/" + dest = dest.rstrip("/") + parts = [p for p in dest.split("/") if p] + if not parts: + raise DAVError(HTTP_FORBIDDEN, "invalid destination") + new_name = parts[-1] + parent_parts = parts[:-1] + new_folder = "Home/" + "/".join(parent_parts) if parent_parts else "Home" + return new_folder, new_name class FrappeCollection(DAVCollection): @@ -86,10 +135,16 @@ def get_member_names(self) -> list[str]: ) names.extend(f.file_name for f in files) - return names + return [n for n in names if not _is_hidden_from_listing(n)] def get_member(self, name: str): child_path = self.path.rstrip("/") + "/" + name + + if _is_os_metadata(name): + if child_path in _OS_FILE_STORE: + return _MemoryFile(child_path, self.environ, name) + return None + child_frappe_folder = f"{self.frappe_folder}/{name}" if frappe.db.exists("File", {"folder": self.frappe_folder, "file_name": name, "is_folder": 1}): @@ -107,10 +162,30 @@ def get_member(self, name: str): return None def create_empty_resource(self, name: str): - raise DAVError(HTTP_FORBIDDEN) + child_path = self.path.rstrip("/") + "/" + name + # macOS metadata files (._*, .DS_Store, ...) are handled in-memory — + # Finder uploads them before the real content and aborts the whole + # transfer if they fail, but they shouldn't pollute Frappe / S3. + if _is_os_metadata(name): + return _MemoryFile(child_path, self.environ, name) + return FrappeNewFile(child_path, self.environ, self.frappe_folder, name) def create_collection(self, name: str): - raise DAVError(HTTP_FORBIDDEN) + """MKCOL — create a Frappe folder.""" + if frappe.db.exists("File", {"folder": self.frappe_folder, "file_name": name}): + raise DAVError(HTTP_METHOD_NOT_ALLOWED, "destination already exists") + try: + folder = frappe.new_doc("File") + folder.file_name = name + folder.folder = self.frappe_folder + folder.is_folder = 1 + folder.flags.cloud_storage = True + folder.insert() + frappe.db.commit() + except frappe.PermissionError: + raise DAVError(HTTP_FORBIDDEN) + except frappe.ValidationError as e: + raise DAVError(HTTP_FORBIDDEN, str(e)) def delete(self): if self.frappe_folder in _SYSTEM_FOLDERS: @@ -135,13 +210,60 @@ def delete(self): raise DAVError(HTTP_FORBIDDEN, str(e)) def copy_move_single(self, dest_path: str, is_move: bool): - raise DAVError(HTTP_FORBIDDEN) + """MOVE — rename or move this folder (and all its descendants).""" + if not is_move: + raise DAVError(HTTP_FORBIDDEN, "COPY for folders is not supported") + if self.frappe_folder in _SYSTEM_FOLDERS: + raise DAVError(HTTP_FORBIDDEN, "cannot move system folders") + + new_parent, new_name = _parse_dest(dest_path) + new_frappe_folder = f"{new_parent}/{new_name}" + + parent = frappe_folder_parent(self.frappe_folder) + display = _folder_display_name(self.frappe_folder) + folder_name = frappe.db.get_value( + "File", + {"folder": parent, "file_name": display, "is_folder": 1}, + "name", + ) + if not folder_name: + raise DAVError(HTTP_NOT_FOUND) + if frappe.db.exists( + "File", + {"folder": new_parent, "file_name": new_name, "is_folder": 1, "name": ["!=", folder_name]}, + ): + raise DAVError(HTTP_METHOD_NOT_ALLOWED, "destination already exists") + + try: + frappe.db.set_value("File", folder_name, "file_name", new_name) + frappe.db.set_value("File", folder_name, "folder", new_parent) + # Reparent every descendant: any File whose `folder` starts with the + # old path (including itself) gets that prefix swapped for the new. + old = self.frappe_folder + affected = frappe.get_all( + "File", + or_filters=[ + ["folder", "=", old], + ["folder", "like", f"{old}/%"], + ], + fields=["name", "folder"], + ) + for f in affected: + new_folder = new_frappe_folder + f.folder[len(old):] + frappe.db.set_value("File", f.name, "folder", new_folder) + frappe.db.commit() + except frappe.PermissionError: + raise DAVError(HTTP_FORBIDDEN) + except frappe.ValidationError as e: + raise DAVError(HTTP_FORBIDDEN, str(e)) def support_recursive_delete(self) -> bool: return False def support_recursive_move(self, dest_path: str) -> bool: - return False + # We rename/move the folder doc + reparent descendants in one transaction + # inside copy_move_single — much faster than wsgidav's per-child loop. + return True _FILE_FIELDS = [ @@ -157,6 +279,239 @@ def support_recursive_move(self, dest_path: str) -> bool: ] +class _WriteBuffer(io.RawIOBase): + """Accumulates a WebDAV PUT body; on close creates a Frappe File doc and uploads to S3. + + macOS Finder uses a two-step upload: PUT Content-Length: 0 to claim the path, + then LOCK, then a second PUT with the real content. When content is empty we + create a placeholder doc so the LOCK handler can find the resource via + get_resource_inst() — without it, wsgidav's lock-discovery crashes on NoneType. + """ + + def __init__(self, file_name: str, frappe_folder: str, content_type: Optional[str]) -> None: + self._buf = io.BytesIO() + self._file_name = file_name + self._frappe_folder = frappe_folder + self._content_type = content_type + + def write(self, data: bytes) -> int: + return self._buf.write(data) + + def close(self) -> None: + if not self.closed: + super().close() # mark closed first so double-close is a no-op + try: + self._commit() + except Exception: + frappe.log_error("WebDAV upload error", frappe.get_traceback()) + raise + else: + super().close() + + def _commit(self) -> None: + from frappe.core.doctype.file.utils import get_content_hash + from cloud_storage.cloud_storage.overrides.file import upload_file + + content = self._buf.getvalue() + + file_doc = frappe.new_doc("File") + file_doc.file_name = self._file_name + file_doc.folder = self._frappe_folder + file_doc.is_private = 0 + # flags.cloud_storage skips validate_file_url (file_url is set later by upload_file) + file_doc.flags.cloud_storage = True + + if not content: + # Empty body: create a placeholder so LOCK can find the resource. + file_doc.insert() + frappe.db.commit() + return + + file_doc.content = content + file_doc.content_hash = get_content_hash(content) + mime, _ = mimetypes.guess_type(self._file_name or "") + file_doc.content_type = mime or "application/octet-stream" + file_doc.insert() + + config = frappe.conf.get("cloud_storage_settings", {}) + if not config or config.get("use_local"): + file_doc.save_file_on_filesystem() + else: + upload_file(file_doc) + frappe.db.commit() + + +class _OverwriteBuffer(io.RawIOBase): + """Accumulates a WebDAV PUT body for an existing resource; on close uploads to S3 + and updates the Frappe File doc in place. + + Used by FrappeFile.begin_write() for Finder's two-step upload (the second PUT + that follows the empty-placeholder PUT and the LOCK). + """ + + def __init__(self, doc_name: str) -> None: + self._buf = io.BytesIO() + self._doc_name = doc_name + + def write(self, data: bytes) -> int: + return self._buf.write(data) + + def close(self) -> None: + if not self.closed: + super().close() + try: + self._commit() + except Exception: + frappe.log_error("WebDAV overwrite error", frappe.get_traceback()) + raise + else: + super().close() + + def _commit(self) -> None: + from frappe.core.doctype.file.utils import get_content_hash + from cloud_storage.cloud_storage.overrides.file import upload_file + + content = self._buf.getvalue() + if not content: + return + + content_hash = get_content_hash(content) + file_doc = frappe.get_doc("File", self._doc_name) + file_doc.content = content + file_doc.content_hash = content_hash + file_doc.file_size = len(content) + # content_type is not a stored field; set it so upload_file can read it. + mime, _ = mimetypes.guess_type(file_doc.file_name or "") + file_doc.content_type = mime or "application/octet-stream" + file_doc.flags.cloud_storage = True + + config = frappe.conf.get("cloud_storage_settings", {}) + if not config or config.get("use_local"): + file_doc.save_file_on_filesystem() + else: + upload_file(file_doc) + file_doc.db_set("file_size", len(content)) + file_doc.db_set("content_hash", content_hash) + frappe.db.commit() + + +class _MemoryBuffer(io.RawIOBase): + """Write buffer that stashes content in _OS_FILE_STORE on close.""" + + def __init__(self, path: str) -> None: + self._buf = io.BytesIO() + self._path = path + + def write(self, data: bytes) -> int: + return self._buf.write(data) + + def close(self) -> None: + if not self.closed: + super().close() + _OS_FILE_STORE[self._path] = self._buf.getvalue() + else: + super().close() + + +class _MemoryFile(DAVNonCollection): + """In-memory resource for OS metadata files (._*, .DS_Store, ...).""" + + def __init__(self, path: str, environ: dict, file_name: str) -> None: + super().__init__(path, environ) + self.file_name = file_name + + def _content(self) -> bytes: + return _OS_FILE_STORE.get(self.path, b"") + + def get_content_length(self) -> int: + return len(self._content()) + + def get_content_type(self) -> str: + return "application/octet-stream" + + def get_last_modified(self) -> None: + return None + + def get_creation_date(self) -> None: + return None + + def get_etag(self) -> None: + return None + + def get_display_name(self) -> str: + return self.file_name + + def support_etag(self) -> bool: + return False + + def support_content_length(self) -> bool: + return True + + def support_modified(self) -> bool: + return False + + def get_content(self) -> io.RawIOBase: + return io.BytesIO(self._content()) + + def begin_write(self, content_type: Optional[str] = None): + return _MemoryBuffer(self.path) + + def delete(self): + _OS_FILE_STORE.pop(self.path, None) + + def copy_move_single(self, dest_path: str, is_move: bool): + raise DAVError(HTTP_FORBIDDEN) + + +class FrappeNewFile(DAVNonCollection): + """Transient resource representing a file being uploaded via WebDAV PUT.""" + + def __init__(self, path: str, environ: dict, frappe_folder: str, file_name: str) -> None: + super().__init__(path, environ) + self.frappe_folder = frappe_folder + self.file_name = file_name + + def get_content_length(self) -> None: + return None + + def get_content_type(self) -> str: + mime, _ = mimetypes.guess_type(self.file_name or "") + return mime or "application/octet-stream" + + def get_last_modified(self) -> None: + return None + + def get_creation_date(self) -> None: + return None + + def get_etag(self) -> None: + return None + + def get_display_name(self) -> str: + return self.file_name + + def support_etag(self) -> bool: + return False + + def support_content_length(self) -> bool: + return False + + def support_modified(self) -> bool: + return False + + def get_content(self): + raise DAVError(HTTP_NOT_FOUND) + + def begin_write(self, content_type: Optional[str] = None): + return _WriteBuffer(self.file_name, self.frappe_folder, content_type) + + def delete(self): + raise DAVError(HTTP_FORBIDDEN) + + def copy_move_single(self, dest_path: str, is_move: bool): + raise DAVError(HTTP_FORBIDDEN) + + class FrappeFile(DAVNonCollection): """A Frappe File record exposed as a WebDAV resource.""" @@ -164,8 +519,8 @@ def __init__(self, path: str, environ: dict, file_doc: dict) -> None: super().__init__(path, environ) self.file_doc = file_doc - def get_content_length(self) -> Optional[int]: - return self.file_doc.file_size or None + def get_content_length(self) -> int: + return self.file_doc.file_size or 0 def get_content_type(self) -> str: mime, _ = mimetypes.guess_type(self.file_doc.file_name or "") @@ -191,7 +546,7 @@ def support_etag(self) -> bool: return bool(self.file_doc.content_hash) def support_content_length(self) -> bool: - return bool(self.file_doc.file_size) + return True def support_modified(self) -> bool: return True @@ -207,6 +562,11 @@ def get_content(self) -> io.RawIOBase: response = client.get_object(Bucket=client.bucket, Key=self.file_doc.s3_key) return response["Body"] + if not self.file_doc.file_size: + # Empty placeholder created by Finder's two-step PUT (CL=0 then content). + # No s3_key and no on-disk file yet — return an empty stream. + return io.BytesIO(b"") + if self.file_doc.is_private: file_path = frappe.get_site_path("private", "files", self.file_doc.file_name) else: @@ -214,7 +574,7 @@ def get_content(self) -> io.RawIOBase: return open(file_path, "rb") def begin_write(self, content_type: Optional[str] = None): - raise DAVError(HTTP_FORBIDDEN) + return _OverwriteBuffer(self.file_doc.name) def delete(self): try: @@ -228,7 +588,59 @@ def delete(self): raise DAVError(HTTP_FORBIDDEN, str(e)) def copy_move_single(self, dest_path: str, is_move: bool): - raise DAVError(HTTP_FORBIDDEN) + """MOVE — rename and/or move this file (S3 object included).""" + if not is_move: + raise DAVError(HTTP_FORBIDDEN, "COPY is not supported") + + new_folder, new_name = _parse_dest(dest_path) + + # WebDAV semantics: MOVE overwrites the destination if it exists. + existing = frappe.db.get_value( + "File", + {"folder": new_folder, "file_name": new_name, "is_folder": 0, "name": ["!=", self.file_doc.name]}, + "name", + ) + if existing: + try: + frappe.db.delete("File Association", {"parent": existing}) + frappe.delete_doc("File", existing, ignore_permissions=False) + except frappe.PermissionError: + raise DAVError(HTTP_FORBIDDEN) + + old_name = self.file_doc.file_name + old_s3_key = self.file_doc.s3_key + doc_name = self.file_doc.name + + try: + frappe.db.set_value("File", doc_name, "file_name", new_name) + frappe.db.set_value("File", doc_name, "folder", new_folder) + + # Legacy S3 paths include the file_name → renaming requires S3 rename + # (copy to new key + delete old). A folder-only move keeps the key. + if old_s3_key and old_name != new_name: + from cloud_storage.cloud_storage.overrides.file import ( + FILE_URL, + get_cloud_storage_client, + get_file_path, + ) + + reloaded = frappe.get_doc("File", doc_name) + client = get_cloud_storage_client() + new_key = get_file_path(reloaded, client.folder) + client.copy_object( + Bucket=client.bucket, + CopySource={"Bucket": client.bucket, "Key": old_s3_key}, + Key=new_key, + ) + client.delete_object(Bucket=client.bucket, Key=old_s3_key) + frappe.db.set_value("File", doc_name, "s3_key", new_key) + frappe.db.set_value("File", doc_name, "file_url", FILE_URL.format(path=new_key)) + + frappe.db.commit() + except frappe.PermissionError: + raise DAVError(HTTP_FORBIDDEN) + except frappe.ValidationError as e: + raise DAVError(HTTP_FORBIDDEN, str(e)) def support_recursive_delete(self) -> bool: return False @@ -242,6 +654,14 @@ class FrappeDAVProvider(DAVProvider): def get_resource_inst(self, path: str, environ: dict): """Return the DAVCollection or DAVNonCollection for the given path, or None.""" + # wsgidav passes PATH_INFO (already /dav-stripped by our middleware) for + # the request resource, but the Destination header for MOVE/COPY arrives + # with the full URL path including /dav. Strip it here so both flows + # resolve to the same Frappe folder tree. + if path.startswith("/dav/"): + path = path[len("/dav"):] + elif path in ("/dav", "/dav/"): + path = "/" norm = path.rstrip("/") if not norm: @@ -259,6 +679,12 @@ def get_resource_inst(self, path: str, environ: dict): last = parts[-1] + # OS metadata files live only in _OS_FILE_STORE, not in Frappe. + if _is_os_metadata(last): + if path in _OS_FILE_STORE: + return _MemoryFile(path, environ, last) + return None + # Last segment: folder? if frappe.db.exists("File", {"folder": frappe_folder, "file_name": last, "is_folder": 1}): child_folder = f"{frappe_folder}/{last}" diff --git a/cloud_storage/cloud_storage/webdav/renderer.py b/cloud_storage/cloud_storage/webdav/renderer.py new file mode 100644 index 0000000..43a7d5a --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/renderer.py @@ -0,0 +1,211 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +""" +Serve WebDAV from inside Frappe (no second port). + +Two pieces: + * WebdavRenderer — page_renderer for GET/HEAD/POST (Frappe routes these + naturally into get_response()). + * handle_webdav_methods — before_request hook for WebDAV methods Frappe's + app.py would otherwise reject with NotFound + (PROPFIND, MKCOL, MOVE, COPY, LOCK, UNLOCK, PUT, + DELETE, OPTIONS). We short-circuit by raising + WebdavResponse — Frappe's top-level + `except HTTPException` returns it. + +Both paths invoke the same embedded WsgiDAVApp, mounted at /dav/. +""" + +from __future__ import annotations + +import io +import threading +import time + +import frappe +from frappe.auth import validate_auth +from werkzeug.exceptions import HTTPException +from werkzeug.wrappers import Response + + +def _log(msg: str) -> None: + """Direct-to-stdout logger so we see things even mid-request.""" + print(f"[DAV {time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +_WEBDAV_METHODS = { + "PROPFIND", + "PROPPATCH", + "MKCOL", + "COPY", + "MOVE", + "LOCK", + "UNLOCK", + "OPTIONS", + "PUT", + "DELETE", +} + +_MOUNT_PREFIX = "/dav" + +_webdav_app = None +_webdav_app_lock = threading.Lock() + + +class WebdavResponse(HTTPException): + """Carry a Werkzeug Response out of a before_request hook.""" + + def __init__(self, response: Response) -> None: + super().__init__() + self._response = response + self.code = response.status_code + + def get_response(self, environ=None): + return self._response + + +class WebdavRenderer: + def __init__(self, path: str, http_status_code: int | None = None) -> None: + self.path = path # PathResolver strips leading/trailing slashes + self.http_status_code = http_status_code or 200 + + def can_render(self) -> bool: + return self.path == "dav" or self.path.startswith("dav/") + + def render(self) -> Response: + request = frappe.local.request + if _needs_auth_challenge(request): + return _auth_challenge() + return _invoke_webdav(request) + + +def handle_webdav_methods() -> None: + """before_request hook: intercept WebDAV methods on /dav/* paths. + + Runs inside init_request() before app.py's `request.method in (GET,HEAD,POST)` + check, so PROPFIND etc. can be handled without app.py raising NotFound. + """ + request = getattr(frappe.local, "request", None) + if not request or not request.path.startswith(_MOUNT_PREFIX): + return + if request.method not in _WEBDAV_METHODS: + return + + # OPTIONS is a capability probe — let it through without auth so unmounted + # clients can discover us. Every other method needs a real user. + if request.method != "OPTIONS": + validate_auth() + if _needs_auth_challenge(request): + _log(f"{request.method} {request.path} → 401 (no auth, requesting credentials)") + raise WebdavResponse(_auth_challenge()) + + resp = _invoke_webdav(request) + _log(f"{request.method} {request.path} → {resp.status_code} user={frappe.session.user}") + raise WebdavResponse(resp) + + +def _needs_auth_challenge(request) -> bool: + """True when we should respond with 401 to make the client send credentials.""" + if frappe.session.user != "Guest": + return False + # Some clients (macOS Finder) silently mount as Guest if any operation + # succeeds anonymously. Force a Basic challenge whenever Guest tries + # anything other than OPTIONS so the credentials get cached + reused. + return request.method != "OPTIONS" + + +def _auth_challenge() -> Response: + resp = Response("Authentication required\n", status=401, mimetype="text/plain") + resp.headers["WWW-Authenticate"] = 'Basic realm="Frappe Cloud Storage"' + return resp + + +def _get_webdav_app(): + """Build the WsgiDAVApp once per process (thread-safe lazy init).""" + global _webdav_app + if _webdav_app is not None: + return _webdav_app + with _webdav_app_lock: + if _webdav_app is not None: + return _webdav_app + from wsgidav.dc.base_dc import BaseDomainController + from wsgidav.lock_man.lock_storage import LockStorageDict + from wsgidav.wsgidav_app import WsgiDAVApp + + from cloud_storage.cloud_storage.webdav.provider import FrappeDAVProvider + + class _NoAuthDC(BaseDomainController): + """wsgidav's auth is a no-op here — Frappe already authenticated.""" + + def get_domain_realm(self, path_info, environ): + return "Frappe" + + def require_authentication(self, realm, environ): + return False + + def supports_http_digest_auth(self): + return False + + def basic_auth_user(self, realm, user_name, password, environ): + return True + + def digest_auth_user(self, realm, user_name, environ): + raise NotImplementedError + + config = { + "provider_mapping": {"/": FrappeDAVProvider()}, + "http_authenticator": { + "domain_controller": _NoAuthDC, + # wsgidav refuses to init with both off — keep basic on, but + # require_authentication=False above means it's never invoked. + "accept_basic": True, + "accept_digest": False, + "default_to_digest": False, + }, + "lock_storage": LockStorageDict(), + "verbose": 1, + "logging": {"enable_loggers": []}, + } + _webdav_app = WsgiDAVApp(config) + return _webdav_app + + +def _invoke_webdav(request) -> Response: + """Run the embedded WsgiDAVApp with request's environ, return its Response.""" + environ = dict(request.environ) + # Mount wsgidav under /dav/ — strip the prefix from PATH_INFO and append it + # to SCRIPT_NAME so wsgidav-generated URLs (href, etc.) include /dav/. + path_info = environ.get("PATH_INFO", "/") + if path_info.startswith(_MOUNT_PREFIX): + environ["PATH_INFO"] = path_info[len(_MOUNT_PREFIX):] or "/" + environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + _MOUNT_PREFIX + + # Pre-read the body via werkzeug (which handles bounded reads & chunked TE + # properly) and replace wsgi.input with an in-memory BytesIO. wsgidav + # otherwise tries to read directly from the raw socket — which is where + # wsgiref/werkzeug-dev block waiting on bytes that buffered clients + # (Finder) don't flush until the response starts. + body = request.get_data() + environ["wsgi.input"] = io.BytesIO(body) + environ["CONTENT_LENGTH"] = str(len(body)) + environ.pop("HTTP_TRANSFER_ENCODING", None) + + status_holder = [] + headers_holder = [] + + def start_response(status, headers, exc_info=None): + status_holder.append(status) + headers_holder[:] = headers + return lambda data: None + + app = _get_webdav_app() + body_iter = app(environ, start_response) + try: + out = b"".join(body_iter) + finally: + if hasattr(body_iter, "close"): + body_iter.close() + + status_code = int(status_holder[0].split(" ", 1)[0]) if status_holder else 500 + return Response(out, status=status_code, headers=headers_holder) diff --git a/cloud_storage/hooks.py b/cloud_storage/hooks.py index 125d8d3..a400d42 100644 --- a/cloud_storage/hooks.py +++ b/cloud_storage/hooks.py @@ -198,3 +198,8 @@ # Bench commands # -------------------------------- commands = ["cloud_storage.commands"] + +# WebDAV (spike) +# -------------------------------- +page_renderer = ["cloud_storage.cloud_storage.webdav.renderer.WebdavRenderer"] +before_request = ["cloud_storage.cloud_storage.webdav.renderer.handle_webdav_methods"] From a3b3156d5e426b974ee7563089efd371212973bf Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 11:34:31 +0000 Subject: [PATCH 06/23] chore: update pyproject and precommit --- cloud_storage/commands.py | 47 --------------------------------------- 1 file changed, 47 deletions(-) diff --git a/cloud_storage/commands.py b/cloud_storage/commands.py index 2b96dc7..0aa8617 100644 --- a/cloud_storage/commands.py +++ b/cloud_storage/commands.py @@ -94,54 +94,7 @@ def migrate_cloud_storage_paths(context, site=None, dry_run=False, limit=None, b frappe.destroy() -@click.command("webdav-server") -@click.option("--site", help="Site name") -@click.option("--port", type=int, default=8010, help="Port to listen on (default: 8010)") -@click.option("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") -@pass_context -def webdav_server(context, site=None, port=8010, host="0.0.0.0"): - """ - Start a WebDAV server for rclone / desktop client integration. - - Files are served read-only from the Frappe File tree. Authenticate with - a Frappe API key and secret (Settings → My Account → API Access). - - Examples: - bench --site mysite.localhost webdav-server - bench --site mysite.localhost webdav-server --port 8010 - - rclone config (add to ~/.config/rclone/rclone.conf): - [frappe] - type = webdav - url = http://localhost:8010/ - vendor = other - user = - pass = )> - """ - from wsgiref.simple_server import make_server - - from cloud_storage.cloud_storage.webdav.app import create_webdav_app - - site = site or context.sites[0] - app = create_webdav_app(site=site) - - print(f"WebDAV server running on http://{host}:{port}/") - print(f"Site: {site}") - print() - print("rclone config snippet:") - print(" [frappe]") - print(" type = webdav") - print(f" url = http://localhost:{port}/") - print(" vendor = other") - print(" user = ") - print(" pass = >") - - httpd = make_server(host, port, app) - httpd.serve_forever() - - commands = [ migrate_files_to_cloud_storage, migrate_cloud_storage_paths, - webdav_server, ] From 2936ce7d511edede66794644772807eb700a1c47 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 11:35:14 +0000 Subject: [PATCH 07/23] fix: migrate commands to hooks --- cloud_storage/cloud_storage/webdav/app.py | 227 ---------------------- 1 file changed, 227 deletions(-) delete mode 100644 cloud_storage/cloud_storage/webdav/app.py diff --git a/cloud_storage/cloud_storage/webdav/app.py b/cloud_storage/cloud_storage/webdav/app.py deleted file mode 100644 index 20bc1c8..0000000 --- a/cloud_storage/cloud_storage/webdav/app.py +++ /dev/null @@ -1,227 +0,0 @@ -# Copyright (c) 2026, AgriTheory and contributors -# For license information, please see license.txt - -from __future__ import annotations - -import frappe -from wsgidav.dc.base_dc import BaseDomainController -from wsgidav.lock_man.lock_storage import LockStorageDict -from wsgidav.wsgidav_app import WsgiDAVApp - -from .provider import FrappeDAVProvider - - -class FrappeDomainController(BaseDomainController): - """Validates rclone Basic Auth credentials against Frappe's api_key / api_secret.""" - - def get_domain_realm(self, path_info: str, environ) -> str: - return "Frappe Cloud Storage" - - def require_authentication(self, realm: str, environ) -> bool: - return True - - def supports_http_digest_auth(self) -> bool: - return False - - def digest_auth_user(self, realm: str, user_name: str, environ: dict) -> str: - raise NotImplementedError - - def basic_auth_user(self, realm: str, user_name: str, password: str, environ: dict) -> bool: - """user_name is the Frappe api_key, password is the api_secret.""" - try: - user = frappe.db.get_value("User", {"api_key": user_name}, "name") - if not user: - return False - from frappe.utils.password import get_decrypted_password - - stored_secret = get_decrypted_password("User", user, fieldname="api_secret") - if password == stored_secret: - frappe.set_user(user) - return True - return False - except Exception: - frappe.logger().exception("WebDAV basic auth error") - return False - - -class _LimitedInput: - """Wraps wsgi.input so that reads stop at CONTENT_LENGTH bytes. - - wsgiref passes the raw socket as wsgi.input without limiting it to - Content-Length. wsgidav's _stream_data reads in a loop until read() - returns b"" — on a live HTTP/1.1 connection the socket never returns b"" - after the body, so the PUT handler blocks forever and never sends a response. - """ - - def __init__(self, raw, length: int) -> None: - self._raw = raw - self._remaining = length - - def read(self, size: int = -1) -> bytes: - if self._remaining <= 0: - return b"" - n = self._remaining if size < 0 else min(size, self._remaining) - data = self._raw.read(n) - self._remaining -= len(data) - return data - - def readline(self, size: int = -1) -> bytes: - if self._remaining <= 0: - return b"" - n = self._remaining if size < 0 else min(size, self._remaining) - data = self._raw.readline(n) - self._remaining -= len(data) - return data - - -class _ChunkedInput: - """Decodes HTTP/1.1 Transfer-Encoding: chunked from wsgi.input. - - Required for macOS Finder, which sends large PUT bodies as chunked without - a Content-Length header. wsgiref doesn't decode chunks, so without this the - raw chunk framing ends up in the body and reads past the last chunk block. - - Uses a socket timeout to recover when Finder buffers its final small chunk - and the trailing CRLF / 0-terminator never arrives: by then we have every - byte of the file body, and treating the timeout as EOF lets the PUT complete. - """ - - _IDLE_TIMEOUT = 5.0 - - def __init__(self, raw) -> None: - self._raw = raw - self._chunk_remaining = 0 - self._eof = False - # Find the underlying socket so we can apply a per-read timeout. - probe = raw - while hasattr(probe, "raw"): - probe = probe.raw - self._sock = getattr(probe, "_sock", None) - - def _readline(self) -> bytes: - if self._sock is not None: - old = self._sock.gettimeout() - self._sock.settimeout(self._IDLE_TIMEOUT) - try: - return self._raw.readline() - except (TimeoutError, OSError): - return b"" - finally: - self._sock.settimeout(old) - return self._raw.readline() - - def _read(self, n: int) -> bytes: - if self._sock is not None: - old = self._sock.gettimeout() - self._sock.settimeout(self._IDLE_TIMEOUT) - try: - return self._raw.read(n) - except (TimeoutError, OSError): - return b"" - finally: - self._sock.settimeout(old) - return self._raw.read(n) - - def read(self, size: int = -1) -> bytes: - if self._eof: - return b"" - out = bytearray() - while size < 0 or len(out) < size: - if self._chunk_remaining == 0: - line = self._readline() - if not line: - self._eof = True - break - size_str = line.split(b";", 1)[0].strip() - try: - self._chunk_remaining = int(size_str, 16) - except ValueError: - self._eof = True - break - if self._chunk_remaining == 0: - while True: - trailer = self._readline() - if trailer in (b"\r\n", b"\n", b""): - break - self._eof = True - break - to_read = self._chunk_remaining - if size >= 0: - to_read = min(to_read, size - len(out)) - data = self._read(to_read) - if not data: - self._eof = True - break - out.extend(data) - self._chunk_remaining -= len(data) - if self._chunk_remaining == 0: - self._readline() # CRLF after chunk data (may time out — that's OK) - return bytes(out) - - def readline(self, size: int = -1) -> bytes: - out = bytearray() - while size < 0 or len(out) < size: - byte = self.read(1) - if not byte: - break - out.extend(byte) - if byte == b"\n": - break - return bytes(out) - - -class FrappeInitMiddleware: - """Wraps the WebDAV WSGI app to initialise / tear down Frappe's per-request context. - - WsgiDAVApp.__call__ is a generator (it uses ``yield from`` internally). If we call - it and return the generator object, the body won't run until wsgiref iterates it — - which is after our finally-block has called frappe.destroy(). Making this middleware - also a generator (via ``yield from``) keeps frappe alive for the entire response. - """ - - def __init__(self, app, site: str) -> None: - self.app = app - self.site = site - - def __call__(self, environ: dict, start_response): - method = environ.get("REQUEST_METHOD", "?") - path = environ.get("PATH_INFO", "?") - cl_raw = environ.get("CONTENT_LENGTH", "MISSING") - te = environ.get("HTTP_TRANSFER_ENCODING", "") - print(f"[REQ] {method} {path!r} CL={cl_raw!r} TE={te!r}", flush=True) - frappe.init(site=self.site) - frappe.connect() - # Wrap wsgi.input — wsgiref passes the raw socket without honouring - # Content-Length or decoding chunked transfer encoding, so any read past - # the body blocks forever waiting on the still-open connection. - if environ.get("HTTP_TRANSFER_ENCODING", "").lower() == "chunked": - environ["wsgi.input"] = _ChunkedInput(environ["wsgi.input"]) - else: - cl = int(environ.get("CONTENT_LENGTH") or 0) - environ["wsgi.input"] = _LimitedInput(environ["wsgi.input"], cl) - try: - yield from self.app(environ, start_response) - finally: - print(f"[DONE] {method} {path!r}", flush=True) - frappe.destroy() - - -def create_webdav_app(site: str) -> FrappeInitMiddleware: - """Return a WSGI application serving the Frappe File tree over WebDAV.""" - provider = FrappeDAVProvider() - config = { - "provider_mapping": {"/": provider}, - "http_authenticator": { - "domain_controller": FrappeDomainController, - "accept_basic": True, - "accept_digest": False, - "default_to_digest": False, - }, - "lock_storage": LockStorageDict(), - "verbose": 1, - "logging": { - "enable_loggers": [], - }, - } - webdav_app = WsgiDAVApp(config) - return FrappeInitMiddleware(webdav_app, site) From a32a53315316f569628b1965a700d96a27339cc8 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 14:33:10 +0000 Subject: [PATCH 08/23] docs: webdav documentation --- docs/hooks.md | 54 ++++++++++++++++++++++++++++ docs/index.md | 2 +- docs/webdav.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 docs/webdav.md diff --git a/docs/hooks.md b/docs/hooks.md index 6e74061..bb33b70 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -92,3 +92,57 @@ def custom_get_file_path(file: File, folder: str | None = None) -> str: If your custom path generator fails, Cloud Storage will: 1. Log the error to the Error Log 2. Fall back to the default path generation strategy + +## WebDAV Path Generator Hook + +Cloud Storage also provides a separate hook for files uploaded through WebDAV. + +`cloud_storage_webdav_path_generator` + +By default, WebDAV uploads use a per-file document path: + +```python +# Default WebDAV path structure: +# {folder}/webdav/{file.name}/{file.file_name} +``` + +This differs from the standard `cloud_storage_path_generator` hook. Desktop +clients often save files by writing temporary files and moving them over the +original file. Including the File document name in the path avoids collisions +during those operations. + +### Configuration + +To use a custom WebDAV path generator, add the following to your custom app's +`hooks.py`: + +```python +# hooks.py +cloud_storage_webdav_path_generator = "my_custom_app.utils.custom_webdav_path_generator" +``` + +### Custom WebDAV Path Generator Function + +Your custom function must accept two parameters and return a string path: + +```python +def custom_webdav_path_generator(file, folder=None): + """ + Generate a custom S3 object key for a File document uploaded through WebDAV. + + Args: + file (File): The File document instance + folder (str|None): Optional base folder from Cloud Storage Settings + + Returns: + str: The S3 object key/path + """ + return f"{folder}/webdav/{file.name}/{file.file_name}" +``` + +### Error Handling + +If your custom WebDAV path generator fails, Cloud Storage will: +1. Log the error to the Error Log +2. Fall back to the default WebDAV path generation strategy + diff --git a/docs/index.md b/docs/index.md index be9d28c..ac69767 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ See the following pages for detailed instructions on Cloud Storage installation - [Cloud Storage Developer Environment Installation](./development.md) - [Cloud Storage Production Environment Installation](./production.md) - [Cloud Storage Configuration](./configuration.md) -- [Desktop File Access with rclone](./rclone.md) +- [Desktop File Access via WebDAV](./webdav.md) - [Hooks](./hooks.md) - [Commands](./commands.md) diff --git a/docs/webdav.md b/docs/webdav.md new file mode 100644 index 0000000..6f4eb75 --- /dev/null +++ b/docs/webdav.md @@ -0,0 +1,98 @@ + + +# Desktop File Access via WebDAV + + + + +Cloud Storage exposes the Frappe File list as a WebDAV endpoint at `/dav/`. +Users can connect to it from desktop clients such as macOS Finder, Windows +Explorer or rclone. + +Files remain stored in the configured cloud storage provider. Frappe remains the +source of truth for file metadata, folders, and permissions. + +## Before You Begin + +Make sure Cloud Storage is installed and configured for your site. See +[Cloud Storage Configuration](configuration.md) for provider setup. + +Each user connects with their own Frappe API key and API secret. To generate +credentials: + +1. Click your avatar in the top right of the Frappe interface. +2. Open **My Settings**. +3. Scroll to **API Access** and click **Generate Keys**. +4. Copy the **API Key** and **API Secret**. The secret is shown only once. + +## WebDAV URL + +Use your site URL with `/dav/` appended: + +```text +https://your-site.example.com/dav/ +``` + +For a local development site, use the bench port for your site: + +```text +http://your-site.localhost:8000/dav/ +``` + +## macOS Finder + +1. Open **Finder**. +2. Select **Go > Connect to Server...**. +3. Enter your WebDAV URL. +4. When prompted, use your Frappe API key as the username and your API secret as the password. + +## Windows Explorer + +1. Open **This PC**. +2. Select **Computer > Map network drive**. +3. Enter your WebDAV URL. +4. Select **Connect using different credentials**. +5. Use your Frappe API key as the username and your API secret as the password. + +## rclone + +Add a WebDAV remote to your rclone configuration: + +Use `rclone obscure` to store the API secret: + +```shell +rclone obscure "your-api-secret" +``` + +```ini +[cloud_storage] +type = webdav +url = https://your-site.example.com/dav/ +vendor = other +user = your-api-key +pass = obscured-api-secret +``` + +Example commands: + +```shell +rclone ls cloud_storage: +rclone copy ./report.pdf cloud_storage:Reports/report.pdf +rclone copy cloud_storage:Reports/report.pdf ./report.pdf +``` + +## Permissions + +The WebDAV mount follows Frappe File permissions. Users can only list, read, +create, move, or delete files when their Frappe permissions allow the same +operation. + +Files created through WebDAV are private by default. + +## Unsupported Operations + +Cloud Storage does not support WebDAV `COPY` or `PROPPATCH`. Clients should use +regular upload, download, move, and delete operations instead. From 854d3699c8e1a4d937f287c4b92bb1476b4e8d2e Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 14:33:35 +0000 Subject: [PATCH 09/23] docs: webdav documentation --- docs/rclone.md | 101 ------------------------------------------------- 1 file changed, 101 deletions(-) delete mode 100644 docs/rclone.md diff --git a/docs/rclone.md b/docs/rclone.md deleted file mode 100644 index 040310a..0000000 --- a/docs/rclone.md +++ /dev/null @@ -1,101 +0,0 @@ - - -# Desktop File Access via WebDAV - -Cloud Storage includes a built-in WebDAV server that lets users browse and download their ERPNext files from any WebDAV-compatible desktop client. Files remain stored in S3; ERPNext remains the authoritative registry. - -## Starting the Server - -```bash -bench webdav-server -``` - -| Option | Default | Description | -|--------|---------|-------------| -| `--site` | first site | Frappe site to serve | -| `--port` | `8010` | Port to listen on | -| `--host` | `0.0.0.0` | Host/IP to bind to | - -## Generating API Credentials - -Each user connects with their own Frappe API key and secret: - -1. Click your avatar (top right) → **My Settings** -2. Scroll to **API Access** → **Generate Keys** -3. Copy the **API Key** and **API Secret** — the secret is shown only once - -## Connecting a Desktop Client - -### macOS — Finder - -1. **Go → Connect to Server…** (⌘K) -2. Enter `http://yourserver:8010/` -3. Username: **API Key** · Password: **API Secret** - -### Windows — Explorer - -1. **This PC → Computer → Map network drive** -2. Enter `http://yourserver:8010/` -3. Check **Connect using different credentials**, enter API Key and Secret - -### rclone (any OS) - -Add to `~/.config/rclone/rclone.conf`: - -```ini -[frappe] -type = webdav -url = http://yourserver:8010/ -vendor = other -user = -pass = > -``` - -Basic usage: - -```bash -rclone ls frappe: -rclone copy "frappe:report.pdf" ~/Downloads/ -``` - -## Production - -The built-in server (`wsgiref`) is single-threaded. For production, run through gunicorn + nginx. - -**Supervisor** (`/etc/supervisor/conf.d/frappe-webdav.conf`): - -```ini -[program:frappe-webdav] -command=/path/to/bench/env/bin/gunicorn \ - --bind 127.0.0.1:8010 \ - --workers 2 \ - --worker-class sync \ - "cloud_storage.cloud_storage.webdav.app:create_webdav_app('mysite.localhost')" -directory=/path/to/bench -user=frappe -autostart=true -autorestart=true -stderr_logfile=/var/log/frappe-webdav.err.log -stdout_logfile=/var/log/frappe-webdav.out.log -``` - -**nginx** (add to site config): - -```nginx -location /dav/ { - proxy_pass http://127.0.0.1:8010/; - proxy_set_header Host $host; - proxy_set_header Authorization $http_authorization; - proxy_pass_header Authorization; - client_max_body_size 100m; -} -``` - -Update your rclone remote URL to `https://mysite.example.com/dav/`. - -## Security - -- **Use HTTPS in production.** API credentials are sent as HTTP Basic Auth — always terminate TLS at nginx before exposing outside localhost. -- **Per-user access.** Each user sees only the files they have permission to access in ERPNext. -- Regenerate your API secret in **My Settings → API Access** From 53f3b47e8345f0778a1f21d9a6b081302d4affe9 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 14:37:07 +0000 Subject: [PATCH 10/23] fix: linters --- docs/index.md | 2 +- docs/webdav.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index ac69767..c74492e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Cloud Storage Documentation diff --git a/docs/webdav.md b/docs/webdav.md index 6f4eb75..7986231 100644 --- a/docs/webdav.md +++ b/docs/webdav.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Desktop File Access via WebDAV From f5cb6ada8eaee82273f3d8bfa1b20416e840e81d Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 14:59:58 +0000 Subject: [PATCH 11/23] feat: webdav --- .../cloud_storage/webdav/__init__.py | 2 + cloud_storage/cloud_storage/webdav/locks.py | 75 +++ cloud_storage/cloud_storage/webdav/memory.py | 30 ++ cloud_storage/cloud_storage/webdav/paths.py | 66 +++ .../cloud_storage/webdav/provider.py | 510 +++++++++++------- .../cloud_storage/webdav/renderer.py | 107 ++-- 6 files changed, 544 insertions(+), 246 deletions(-) create mode 100644 cloud_storage/cloud_storage/webdav/locks.py create mode 100644 cloud_storage/cloud_storage/webdav/memory.py create mode 100644 cloud_storage/cloud_storage/webdav/paths.py diff --git a/cloud_storage/cloud_storage/webdav/__init__.py b/cloud_storage/cloud_storage/webdav/__init__.py index e69de29..ea2ab3f 100644 --- a/cloud_storage/cloud_storage/webdav/__init__.py +++ b/cloud_storage/cloud_storage/webdav/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt diff --git a/cloud_storage/cloud_storage/webdav/locks.py b/cloud_storage/cloud_storage/webdav/locks.py new file mode 100644 index 0000000..5f34fa3 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/locks.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# Redis-backed wsgidav LockStorage — keeps locks consistent across gunicorn workers. + +from typing import Any, Iterator + +import frappe +from wsgidav.lock_man.lock_storage import LockStorageDict + + +_NAMESPACE = "webdav:lockstorage" + + +class _RedisDict: + """Minimal dict facade over a Frappe Redis hash, sufficient for LockStorageDict.""" + + def __init__(self, namespace: str) -> None: + self._ns = namespace + + def _cache(self): + return frappe.cache() + + def __getitem__(self, key: str) -> Any: + val = self._cache().hget(self._ns, key) + if val is None: + raise KeyError(key) + return val + + def __setitem__(self, key: str, value: Any) -> None: + self._cache().hset(self._ns, key, value) + + def __delitem__(self, key: str) -> None: + self._cache().hdel(self._ns, key) + + def __contains__(self, key: str) -> bool: + return self._cache().hget(self._ns, key) is not None + + def __len__(self) -> int: + return len(self._hgetall_decoded()) + + def __iter__(self) -> Iterator[str]: + return iter(self._hgetall_decoded().keys()) + + def get(self, key: str, default: Any = None) -> Any: + val = self._cache().hget(self._ns, key) + return default if val is None else val + + def items(self): + return self._hgetall_decoded().items() + + def clear(self) -> None: + self._cache().delete_value(self._ns) + + def _hgetall_decoded(self) -> dict[str, Any]: + # hgetall unpickles values but leaves Redis hash keys as raw bytes; + # wsgidav compares them against str URLs so we decode here. + raw = self._cache().hgetall(self._ns) or {} + return { + (k.decode("utf-8") if isinstance(k, bytes) else k): v + for k, v in raw.items() + } + + +class RedisLockStorage(LockStorageDict): + """LockStorageDict backed by Redis instead of an in-process dict.""" + + def __repr__(self): + return f"RedisLockStorage(namespace={_NAMESPACE!r})" + + def open(self) -> None: + assert self._dict is None + self._dict = _RedisDict(_NAMESPACE) + + def close(self) -> None: + self._dict = None # don't wipe Redis — other workers may still be using it diff --git a/cloud_storage/cloud_storage/webdav/memory.py b/cloud_storage/cloud_storage/webdav/memory.py new file mode 100644 index 0000000..a351d14 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/memory.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# Redis-backed TTL store for macOS metadata files (._*, .DS_Store, ...). +# Acknowledges Finder's auxiliary PUTs without polluting Frappe/S3. + +import frappe + + +_PREFIX = "webdav:osfile:" +_TTL_SECONDS = 60 * 60 # 1 hour — these are transient by nature + + +def _key(path: str) -> str: + return _PREFIX + path + + +def get(path: str) -> bytes | None: + return frappe.cache().get_value(_key(path)) + + +def set(path: str, content: bytes) -> None: + frappe.cache().set_value(_key(path), content, expires_in_sec=_TTL_SECONDS) + + +def delete(path: str) -> None: + frappe.cache().delete_value(_key(path)) + + +def contains(path: str) -> bool: + return get(path) is not None diff --git a/cloud_storage/cloud_storage/webdav/paths.py b/cloud_storage/cloud_storage/webdav/paths.py new file mode 100644 index 0000000..71f3052 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/paths.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# WebDAV-specific S3 path strategy and upload pipeline. + +import frappe +from boto3.exceptions import S3UploadFailedError +from frappe.core.doctype.file.file import File + +from cloud_storage.cloud_storage.overrides.file import ( + FILE_URL, + get_cloud_storage_client, +) + + +_WEBDAV_PREFIX = "webdav" + + +def _default_webdav_path(file: File, folder: str | None) -> str: + """Per-doc S3 key. ``#`` is %-escaped to match legacy behaviour.""" + parts = [folder, _WEBDAV_PREFIX, file.name, file.file_name.replace("#", "%23")] + return "/".join(p for p in parts if p) + + +def get_webdav_path(file: File, folder: str | None) -> str: + """S3 key for a WebDAV file. Checks cloud_storage_webdav_path_generator first.""" + hooks = frappe.get_hooks("cloud_storage_webdav_path_generator") + if hooks: + try: + return frappe.get_attr(hooks[0])(file, folder) + except Exception as e: + frappe.log_error( + f"cloud_storage_webdav_path_generator failed: {e}", + "WebDAV Path Generator Error", + ) + return _default_webdav_path(file, folder) + + +def upload_via_webdav(file_doc: File, content: bytes, content_type: str) -> File: + """Upload content to S3 using the WebDAV-specific path (avoids name collision).""" + client = get_cloud_storage_client() + path = get_webdav_path(file_doc, client.folder) + file_doc.db_set("file_url", FILE_URL.format(path=path)) + + version_id = None + try: + response = client.put_object( + Body=content, + Bucket=client.bucket, + Key=path, + ContentType=content_type, + ) + version_id = response.get("VersionId") or file_doc.content_hash + file_doc.associate_files(file_doc.attached_to_doctype, file_doc.attached_to_name) + except S3UploadFailedError: + frappe.throw("File Upload Failed. Please try again.") + except Exception as e: + frappe.log_error("WebDAV upload error", e) + raise + + if version_id: + file_doc.add_file_version(version_id) + file_doc.db_set("s3_key", path) + file_doc.db_set("file_size", len(content)) + if file_doc.content_hash: + file_doc.db_set("content_hash", file_doc.content_hash) + return file_doc diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index 5833319..1623f0e 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -1,13 +1,13 @@ # Copyright (c) 2026, AgriTheory and contributors # For license information, please see license.txt - -from __future__ import annotations +# Maps Frappe File/folder doctypes to wsgidav DAV resources. import io import mimetypes -from typing import Optional import frappe +from frappe.core.doctype.file.utils import get_content_hash +from frappe.model.rename_doc import rename_doc from wsgidav.dav_error import ( DAVError, HTTP_FORBIDDEN, @@ -16,18 +16,9 @@ ) from wsgidav.dav_provider import DAVCollection, DAVNonCollection, DAVProvider - -def _dav_path_to_frappe_folder(dav_path: str) -> str: - """Map a WebDAV path to the corresponding Frappe folder name. - - / → Home - /Attachments/ → Home/Attachments - /a/b/c/ → Home/a/b/c - """ - parts = [p for p in dav_path.strip("/").split("/") if p] - if not parts: - return "Home" - return "Home/" + "/".join(parts) +from cloud_storage.cloud_storage.overrides.file import FILE_URL, get_cloud_storage_client +from cloud_storage.cloud_storage.webdav import memory as os_file_store +from cloud_storage.cloud_storage.webdav import paths def _folder_display_name(frappe_folder: str) -> str: @@ -37,20 +28,10 @@ def _folder_display_name(frappe_folder: str) -> str: _SYSTEM_FOLDERS = {"Home", "Home/Attachments"} _OS_FILES = frozenset({".DS_Store", "desktop.ini", "Thumbs.db", ".Trashes", ".Spotlight-V100"}) _OS_PREFIXES = ("._",) - -# Editor / OS atomic-save scratchpads. macOS TextEdit and many other editors -# write via MKCOL → PUT → MOVE → DELETE on a sibling folder named like -# ".sb-". They're real folders/files (we must let MKCOL/PUT/MOVE -# work on them) but they only ever exist for a few hundred ms — we hide them -# from listings so they don't appear in Finder if a save crashes mid-flight. +# Editor atomic-save scratchpads (e.g. TextEdit's ".sb-" folders). +# Functional but hidden from listings so they don't persist visibly if a save crashes. _TEMP_PREFIXES = (".sb-",) -# In-memory store for OS metadata files (AppleDouble ._*, .DS_Store, ...). -# Finder requires these to read/write/lock successfully for drag-and-drop to work, -# but they're transient and shouldn't pollute Frappe / S3. They live here only -# for the lifetime of the server process — small, ephemeral, and per-server. -_OS_FILE_STORE: dict[str, bytes] = {} - def _is_os_metadata(name: str) -> bool: return name in _OS_FILES or name.startswith(_OS_PREFIXES) @@ -60,18 +41,51 @@ def _is_hidden_from_listing(name: str) -> bool: return name.startswith(_OS_PREFIXES) or name in _OS_FILES or name.startswith(_TEMP_PREFIXES) -def _parse_dest(dest_path: str) -> tuple[str, str]: - """Parse a WebDAV destination path into (frappe_folder, new_name). +_MOUNT_PREFIX = "/dav" +_logger = frappe.logger("webdav", allow_site=False) + + +def _strip_dav_prefix(path: str) -> str: + """Strip /dav prefix so both PATH_INFO and Destination headers resolve uniformly.""" + if path.startswith(_MOUNT_PREFIX + "/"): + return path[len(_MOUNT_PREFIX):] + if path in (_MOUNT_PREFIX, _MOUNT_PREFIX + "/"): + return "/" + return path + + +def _can(target, ptype: str) -> bool: + """Per-doc permission check. target must be a doc name or doc object, not a doctype string.""" + return frappe.has_permission("File", doc=target, ptype=ptype, user=frappe.session.user) + + +def _can_create() -> bool: + """Doctype-level create check (no specific doc yet).""" + return frappe.has_permission("File", ptype="create", user=frappe.session.user) - wsgidav may pass the destination either with or without the /dav mount - prefix; we handle both. + +def _can_write_folder(frappe_folder: str) -> bool: + """Check write permission on a Frappe folder path (e.g. 'Home/Docs'). + + Returns True for the virtual root 'Home' (no doc exists for it) so that + PUT/MKCOL/MOVE into the root collection aren't incorrectly blocked. """ - dest = dest_path - if dest.startswith("/dav/"): - dest = dest[len("/dav"):] - elif dest in ("/dav", "/dav/"): - dest = "/" - dest = dest.rstrip("/") + if frappe_folder == "Home": + return True + parent = frappe_folder_parent(frappe_folder) + display = _folder_display_name(frappe_folder) + folder_name = frappe.db.get_value( + "File", + {"folder": parent, "file_name": display, "is_folder": 1}, + "name", + ) + if not folder_name: + return False + return _can(folder_name, "write") + + +def _parse_dest(dest_path: str) -> tuple[str, str]: + dest = _strip_dav_prefix(dest_path).rstrip("/") parts = [p for p in dest.split("/") if p] if not parts: raise DAVError(HTTP_FORBIDDEN, "invalid destination") @@ -87,13 +101,12 @@ class FrappeCollection(DAVCollection): def __init__(self, path: str, environ: dict, frappe_folder: str) -> None: super().__init__(path, environ) self.frappe_folder = frappe_folder - self._meta: Optional[dict] = None + self._meta: dict | None = None - def _get_meta(self) -> Optional[dict]: + def _get_meta(self) -> dict | None: if self._meta is None: parent = frappe_folder_parent(self.frappe_folder) if not parent: - # "Home" is the virtual root — no File doc exists for it return None name = _folder_display_name(self.frappe_folder) self._meta = frappe.db.get_value( @@ -104,11 +117,11 @@ def _get_meta(self) -> Optional[dict]: ) return self._meta - def get_creation_date(self) -> Optional[float]: + def get_creation_date(self) -> float | None: meta = self._get_meta() return meta.creation.timestamp() if meta else None - def get_last_modified(self) -> Optional[float]: + def get_last_modified(self) -> float | None: meta = self._get_meta() return meta.modified.timestamp() if meta else None @@ -119,59 +132,70 @@ def get_etag(self) -> None: return None def get_member_names(self) -> list[str]: - names: list[str] = [] - - folders = frappe.get_all( - "File", - filters={"folder": self.frappe_folder, "is_folder": 1}, - fields=["file_name"], - ) - names.extend(f.file_name for f in folders) - - files = frappe.get_all( + # get_all + _can(read): File's permission_query_conditions is owner-only + # for non-SM users but has_permission also allows public/attached/shared + # files. Filtering with _can mirrors what GET would actually serve. + rows = frappe.get_all( "File", - filters={"folder": self.frappe_folder, "is_folder": 0}, - fields=["file_name"], + filters={"folder": self.frappe_folder}, + fields=["name", "file_name"], ) - names.extend(f.file_name for f in files) - - return [n for n in names if not _is_hidden_from_listing(n)] + return [ + r.file_name for r in rows if not _is_hidden_from_listing(r.file_name) and _can(r.name, "read") + ] def get_member(self, name: str): child_path = self.path.rstrip("/") + "/" + name if _is_os_metadata(name): - if child_path in _OS_FILE_STORE: + if os_file_store.contains(child_path): return _MemoryFile(child_path, self.environ, name) return None - child_frappe_folder = f"{self.frappe_folder}/{name}" - - if frappe.db.exists("File", {"folder": self.frappe_folder, "file_name": name, "is_folder": 1}): + # Same get_all + _can rationale as get_member_names. + # Both "not found" and "no permission" return None → wsgidav sends 404, + # which doesn't leak resource existence to unauthorized callers. + folder_match = frappe.get_all( + "File", + filters={"folder": self.frappe_folder, "file_name": name, "is_folder": 1}, + pluck="name", + limit=1, + ) + if folder_match: + if not _can(folder_match[0], "read"): + return None + child_frappe_folder = f"{self.frappe_folder}/{name}" return FrappeCollection(child_path + "/", self.environ, child_frappe_folder) - file_doc = frappe.db.get_value( + file_rows = frappe.get_all( "File", - {"folder": self.frappe_folder, "file_name": name, "is_folder": 0}, - _FILE_FIELDS, - as_dict=True, + filters={"folder": self.frappe_folder, "file_name": name, "is_folder": 0}, + fields=_FILE_FIELDS, + limit=1, ) - if file_doc: - return FrappeFile(child_path, self.environ, file_doc) + if file_rows: + if not _can(file_rows[0].name, "read"): + return None + return FrappeFile(child_path, self.environ, file_rows[0]) return None def create_empty_resource(self, name: str): child_path = self.path.rstrip("/") + "/" + name - # macOS metadata files (._*, .DS_Store, ...) are handled in-memory — - # Finder uploads them before the real content and aborts the whole - # transfer if they fail, but they shouldn't pollute Frappe / S3. + # OS metadata files go to in-memory store, not Frappe/S3. if _is_os_metadata(name): return _MemoryFile(child_path, self.environ, name) + if not _can_create(): + raise DAVError(HTTP_FORBIDDEN) + if not _can_write_folder(self.frappe_folder): + raise DAVError(HTTP_FORBIDDEN) return FrappeNewFile(child_path, self.environ, self.frappe_folder, name) def create_collection(self, name: str): - """MKCOL — create a Frappe folder.""" + if not _can_create(): + raise DAVError(HTTP_FORBIDDEN) + if not _can_write_folder(self.frappe_folder): + raise DAVError(HTTP_FORBIDDEN) if frappe.db.exists("File", {"folder": self.frappe_folder, "file_name": name}): raise DAVError(HTTP_METHOD_NOT_ALLOWED, "destination already exists") try: @@ -179,6 +203,7 @@ def create_collection(self, name: str): folder.file_name = name folder.folder = self.frappe_folder folder.is_folder = 1 + folder.is_private = 1 folder.flags.cloud_storage = True folder.insert() frappe.db.commit() @@ -188,9 +213,6 @@ def create_collection(self, name: str): raise DAVError(HTTP_FORBIDDEN, str(e)) def delete(self): - if self.frappe_folder in _SYSTEM_FOLDERS: - raise DAVError(HTTP_FORBIDDEN) - parent = frappe_folder_parent(self.frappe_folder) display_name = _folder_display_name(self.frappe_folder) folder_name = frappe.db.get_value( @@ -200,7 +222,8 @@ def delete(self): ) if not folder_name: raise DAVError(HTTP_NOT_FOUND) - + if not _can(folder_name, "delete"): + raise DAVError(HTTP_FORBIDDEN) try: frappe.delete_doc("File", folder_name) frappe.db.commit() @@ -209,15 +232,40 @@ def delete(self): except frappe.ValidationError as e: raise DAVError(HTTP_FORBIDDEN, str(e)) - def copy_move_single(self, dest_path: str, is_move: bool): - """MOVE — rename or move this folder (and all its descendants).""" - if not is_move: - raise DAVError(HTTP_FORBIDDEN, "COPY for folders is not supported") + def handle_copy(self, dest_path: str, *, depth_infinity: bool) -> bool: + # COPY is not supported. Raising here prevents wsgidav from deleting + # the destination before discovering copy_move_single raises too. + raise DAVError(HTTP_FORBIDDEN) + + def handle_move(self, dest_path: str) -> bool | list: + # Handle MOVE natively so wsgidav never reaches its fallback path that + # deletes an existing destination before calling move_recursive(). + if self.frappe_folder in _SYSTEM_FOLDERS: + raise DAVError(HTTP_FORBIDDEN, "cannot move system folders") + parent = frappe_folder_parent(self.frappe_folder) + display = _folder_display_name(self.frappe_folder) + folder_name = frappe.db.get_value( + "File", + {"folder": parent, "file_name": display, "is_folder": 1}, + "name", + ) + if not folder_name or not _can(folder_name, "write"): + raise DAVError(HTTP_FORBIDDEN) + new_parent, _ = _parse_dest(dest_path) + if not _can_write_folder(new_parent): + raise DAVError(HTTP_FORBIDDEN) + return self.move_recursive(dest_path) + + def copy_move_single(self, dest_path: str, *, is_move: bool): + raise DAVError(HTTP_FORBIDDEN) + + def move_recursive(self, dest_path: str): if self.frappe_folder in _SYSTEM_FOLDERS: raise DAVError(HTTP_FORBIDDEN, "cannot move system folders") new_parent, new_name = _parse_dest(dest_path) new_frappe_folder = f"{new_parent}/{new_name}" + _logger.debug(f"folder move src={self.frappe_folder!r} → {new_frappe_folder!r}") parent = frappe_folder_parent(self.frappe_folder) display = _folder_display_name(self.frappe_folder) @@ -228,17 +276,40 @@ def copy_move_single(self, dest_path: str, is_move: bool): ) if not folder_name: raise DAVError(HTTP_NOT_FOUND) + if not _can(folder_name, "write"): + raise DAVError(HTTP_FORBIDDEN) + if not _can_write_folder(new_parent): + raise DAVError(HTTP_FORBIDDEN) if frappe.db.exists( "File", {"folder": new_parent, "file_name": new_name, "is_folder": 1, "name": ["!=", folder_name]}, ): raise DAVError(HTTP_METHOD_NOT_ALLOWED, "destination already exists") + savepoint = "webdav_folder_move" + frappe.db.savepoint(savepoint) try: - frappe.db.set_value("File", folder_name, "file_name", new_name) - frappe.db.set_value("File", folder_name, "folder", new_parent) - # Reparent every descendant: any File whose `folder` starts with the - # old path (including itself) gets that prefix swapped for the new. + # Frappe stores folder path in both `name` (PK) and display fields + # (`file_name`, `folder`). rename_doc updates the PK but leaves + # display fields stale — update them first. + if new_name != display: + frappe.db.set_value("File", self.frappe_folder, "file_name", new_name) + if new_parent != parent: + frappe.db.set_value("File", self.frappe_folder, "folder", new_parent) + + if new_frappe_folder != self.frappe_folder: + rename_doc( + "File", + self.frappe_folder, + new_frappe_folder, + merge=False, + force=True, + show_alert=False, + validate=False, + ) + + # Reparent descendants (rename_doc only updates direct children). + # get_all intentional: descendants must move atomically with parent. old = self.frappe_folder affected = frappe.get_all( "File", @@ -248,21 +319,24 @@ def copy_move_single(self, dest_path: str, is_move: bool): ], fields=["name", "folder"], ) + _logger.debug(f"folder move reparenting {len(affected)} descendant(s)") for f in affected: - new_folder = new_frappe_folder + f.folder[len(old):] - frappe.db.set_value("File", f.name, "folder", new_folder) + new_folder_path = new_frappe_folder + f.folder[len(old):] + frappe.db.set_value("File", f.name, "folder", new_folder_path) frappe.db.commit() - except frappe.PermissionError: - raise DAVError(HTTP_FORBIDDEN) - except frappe.ValidationError as e: - raise DAVError(HTTP_FORBIDDEN, str(e)) + return [] + except Exception as exc: + frappe.db.rollback(save_point=savepoint) + if isinstance(exc, frappe.PermissionError): + raise DAVError(HTTP_FORBIDDEN) + if isinstance(exc, frappe.ValidationError): + raise DAVError(HTTP_FORBIDDEN, str(exc)) + raise def support_recursive_delete(self) -> bool: return False def support_recursive_move(self, dest_path: str) -> bool: - # We rename/move the folder doc + reparent descendants in one transaction - # inside copy_move_single — much faster than wsgidav's per-child loop. return True @@ -280,15 +354,14 @@ def support_recursive_move(self, dest_path: str) -> bool: class _WriteBuffer(io.RawIOBase): - """Accumulates a WebDAV PUT body; on close creates a Frappe File doc and uploads to S3. + """Accumulates PUT body; on close inserts a File doc and uploads content. - macOS Finder uses a two-step upload: PUT Content-Length: 0 to claim the path, - then LOCK, then a second PUT with the real content. When content is empty we - create a placeholder doc so the LOCK handler can find the resource via - get_resource_inst() — without it, wsgidav's lock-discovery crashes on NoneType. + Finder's two-step upload sends PUT Content-Length:0 first (to claim the + path), then LOCK, then a second PUT with real content. An empty body + creates a placeholder doc so the LOCK handler can find the resource. """ - def __init__(self, file_name: str, frappe_folder: str, content_type: Optional[str]) -> None: + def __init__(self, file_name: str, frappe_folder: str, content_type: str | None) -> None: self._buf = io.BytesIO() self._file_name = file_name self._frappe_folder = frappe_folder @@ -299,7 +372,7 @@ def write(self, data: bytes) -> int: def close(self) -> None: if not self.closed: - super().close() # mark closed first so double-close is a no-op + super().close() try: self._commit() except Exception: @@ -309,20 +382,17 @@ def close(self) -> None: super().close() def _commit(self) -> None: - from frappe.core.doctype.file.utils import get_content_hash - from cloud_storage.cloud_storage.overrides.file import upload_file - content = self._buf.getvalue() file_doc = frappe.new_doc("File") file_doc.file_name = self._file_name file_doc.folder = self._frappe_folder - file_doc.is_private = 0 - # flags.cloud_storage skips validate_file_url (file_url is set later by upload_file) + # Private by default: non-private files skip expiration + perm checks in + # get_presigned_url, undermining the per-user WebDAV perm model. + file_doc.is_private = 1 file_doc.flags.cloud_storage = True if not content: - # Empty body: create a placeholder so LOCK can find the resource. file_doc.insert() frappe.db.commit() return @@ -335,19 +405,18 @@ def _commit(self) -> None: config = frappe.conf.get("cloud_storage_settings", {}) if not config or config.get("use_local"): + # save_file_on_filesystem reads _content, not content. + file_doc._content = content file_doc.save_file_on_filesystem() + file_doc.db_set("file_url", file_doc.file_url) + file_doc.db_set("file_size", len(content)) else: - upload_file(file_doc) + paths.upload_via_webdav(file_doc, content, file_doc.content_type) frappe.db.commit() class _OverwriteBuffer(io.RawIOBase): - """Accumulates a WebDAV PUT body for an existing resource; on close uploads to S3 - and updates the Frappe File doc in place. - - Used by FrappeFile.begin_write() for Finder's two-step upload (the second PUT - that follows the empty-placeholder PUT and the LOCK). - """ + """Accumulates PUT body for an existing resource; on close re-uploads content in place.""" def __init__(self, doc_name: str) -> None: self._buf = io.BytesIO() @@ -368,35 +437,38 @@ def close(self) -> None: super().close() def _commit(self) -> None: - from frappe.core.doctype.file.utils import get_content_hash - from cloud_storage.cloud_storage.overrides.file import upload_file - content = self._buf.getvalue() if not content: return content_hash = get_content_hash(content) file_doc = frappe.get_doc("File", self._doc_name) - file_doc.content = content - file_doc.content_hash = content_hash - file_doc.file_size = len(content) - # content_type is not a stored field; set it so upload_file can read it. mime, _ = mimetypes.guess_type(file_doc.file_name or "") - file_doc.content_type = mime or "application/octet-stream" + content_type = mime or "application/octet-stream" + file_doc.content_hash = content_hash + file_doc.content_type = content_type file_doc.flags.cloud_storage = True config = frappe.conf.get("cloud_storage_settings", {}) if not config or config.get("use_local"): + file_doc.content = content + file_doc.file_size = len(content) + # Clear the existing file_url (likely an S3 retrieve URL) so + # save_file_on_filesystem's validate doesn't reject it. + # Also assign _content — that's what the method actually reads. + file_doc.file_url = None + file_doc._content = content file_doc.save_file_on_filesystem() - else: - upload_file(file_doc) + file_doc.db_set("file_url", file_doc.file_url) file_doc.db_set("file_size", len(content)) file_doc.db_set("content_hash", content_hash) + else: + paths.upload_via_webdav(file_doc, content, content_type) frappe.db.commit() class _MemoryBuffer(io.RawIOBase): - """Write buffer that stashes content in _OS_FILE_STORE on close.""" + """Write buffer that stashes content in the Redis-backed OS metadata store.""" def __init__(self, path: str) -> None: self._buf = io.BytesIO() @@ -408,7 +480,7 @@ def write(self, data: bytes) -> int: def close(self) -> None: if not self.closed: super().close() - _OS_FILE_STORE[self._path] = self._buf.getvalue() + os_file_store.set(self._path, self._buf.getvalue()) else: super().close() @@ -421,7 +493,7 @@ def __init__(self, path: str, environ: dict, file_name: str) -> None: self.file_name = file_name def _content(self) -> bytes: - return _OS_FILE_STORE.get(self.path, b"") + return os_file_store.get(self.path) or b"" def get_content_length(self) -> int: return len(self._content()) @@ -453,13 +525,13 @@ def support_modified(self) -> bool: def get_content(self) -> io.RawIOBase: return io.BytesIO(self._content()) - def begin_write(self, content_type: Optional[str] = None): + def begin_write(self, content_type: str | None = None): return _MemoryBuffer(self.path) def delete(self): - _OS_FILE_STORE.pop(self.path, None) + os_file_store.delete(self.path) - def copy_move_single(self, dest_path: str, is_move: bool): + def copy_move_single(self, dest_path: str, *, is_move: bool): raise DAVError(HTTP_FORBIDDEN) @@ -502,13 +574,13 @@ def support_modified(self) -> bool: def get_content(self): raise DAVError(HTTP_NOT_FOUND) - def begin_write(self, content_type: Optional[str] = None): + def begin_write(self, content_type: str | None = None): return _WriteBuffer(self.file_name, self.frappe_folder, content_type) def delete(self): raise DAVError(HTTP_FORBIDDEN) - def copy_move_single(self, dest_path: str, is_move: bool): + def copy_move_single(self, dest_path: str, *, is_move: bool): raise DAVError(HTTP_FORBIDDEN) @@ -526,17 +598,17 @@ def get_content_type(self) -> str: mime, _ = mimetypes.guess_type(self.file_doc.file_name or "") return mime or "application/octet-stream" - def get_last_modified(self) -> Optional[float]: + def get_last_modified(self) -> float | None: if self.file_doc.modified: return self.file_doc.modified.timestamp() return None - def get_creation_date(self) -> Optional[float]: + def get_creation_date(self) -> float | None: if self.file_doc.creation: return self.file_doc.creation.timestamp() return None - def get_etag(self) -> Optional[str]: + def get_etag(self) -> str | None: return self.file_doc.content_hash or None def get_display_name(self) -> str: @@ -555,31 +627,29 @@ def support_ranges(self) -> bool: return False def get_content(self) -> io.RawIOBase: - from cloud_storage.cloud_storage.overrides.file import get_cloud_storage_client - if self.file_doc.s3_key: client = get_cloud_storage_client() response = client.get_object(Bucket=client.bucket, Key=self.file_doc.s3_key) return response["Body"] if not self.file_doc.file_size: - # Empty placeholder created by Finder's two-step PUT (CL=0 then content). - # No s3_key and no on-disk file yet — return an empty stream. + # Empty placeholder from Finder's CL=0 PUT — no content yet. return io.BytesIO(b"") - if self.file_doc.is_private: - file_path = frappe.get_site_path("private", "files", self.file_doc.file_name) - else: - file_path = frappe.get_site_path("public", "files", self.file_doc.file_name) + # Local mode: resolve path from file_url to handle any name sanitization. + file_doc = frappe.get_doc("File", self.file_doc.name) + file_path = file_doc.get_full_path() return open(file_path, "rb") - def begin_write(self, content_type: Optional[str] = None): + def begin_write(self, content_type: str | None = None): + if not _can(self.file_doc.name, "write"): + raise DAVError(HTTP_FORBIDDEN) return _OverwriteBuffer(self.file_doc.name) def delete(self): + if not _can(self.file_doc.name, "delete"): + raise DAVError(HTTP_FORBIDDEN) try: - # Clear associations first so the hook proceeds to _delete_file_on_disk. - frappe.db.delete("File Association", {"parent": self.file_doc.name}) frappe.delete_doc("File", self.file_doc.name) frappe.db.commit() except frappe.PermissionError: @@ -587,81 +657,110 @@ def delete(self): except frappe.ValidationError as e: raise DAVError(HTTP_FORBIDDEN, str(e)) - def copy_move_single(self, dest_path: str, is_move: bool): - """MOVE — rename and/or move this file (S3 object included).""" - if not is_move: - raise DAVError(HTTP_FORBIDDEN, "COPY is not supported") + def handle_copy(self, dest_path: str, *, depth_infinity: bool) -> bool: + raise DAVError(HTTP_FORBIDDEN) + def handle_move(self, dest_path: str) -> bool | list: + # Handle MOVE natively so wsgidav never reaches its fallback path that + # deletes an existing destination before calling move_recursive(). + if not _can(self.file_doc.name, "write"): + raise DAVError(HTTP_FORBIDDEN) + new_folder, _ = _parse_dest(dest_path) + if not _can_write_folder(new_folder): + raise DAVError(HTTP_FORBIDDEN) + return self.move_recursive(dest_path) + + def copy_move_single(self, dest_path: str, *, is_move: bool): + raise DAVError(HTTP_FORBIDDEN) + + def move_recursive(self, dest_path: str): + if not _can(self.file_doc.name, "write"): + raise DAVError(HTTP_FORBIDDEN) new_folder, new_name = _parse_dest(dest_path) + if not _can_write_folder(new_folder): + raise DAVError(HTTP_FORBIDDEN) + _logger.debug( + f"file move doc={self.file_doc.name} {self.file_doc.file_name!r} → " + f"{new_folder}/{new_name}" + ) - # WebDAV semantics: MOVE overwrites the destination if it exists. + # MOVE overwrites the destination if it exists; let on_trash guards run. existing = frappe.db.get_value( "File", - {"folder": new_folder, "file_name": new_name, "is_folder": 0, "name": ["!=", self.file_doc.name]}, + { + "folder": new_folder, + "file_name": new_name, + "is_folder": 0, + "name": ["!=", self.file_doc.name], + }, "name", ) if existing: try: - frappe.db.delete("File Association", {"parent": existing}) - frappe.delete_doc("File", existing, ignore_permissions=False) + frappe.delete_doc("File", existing) except frappe.PermissionError: raise DAVError(HTTP_FORBIDDEN) + except frappe.ValidationError as e: + raise DAVError(HTTP_FORBIDDEN, str(e)) old_name = self.file_doc.file_name old_s3_key = self.file_doc.s3_key - doc_name = self.file_doc.name + is_rename = bool(old_s3_key and old_name != new_name) + # S3 rename order: copy → commit DB → delete old key. + # If commit fails, orphan the new copy (harmless) — never lose the original. + client = None + new_key = None try: - frappe.db.set_value("File", doc_name, "file_name", new_name) - frappe.db.set_value("File", doc_name, "folder", new_folder) - - # Legacy S3 paths include the file_name → renaming requires S3 rename - # (copy to new key + delete old). A folder-only move keeps the key. - if old_s3_key and old_name != new_name: - from cloud_storage.cloud_storage.overrides.file import ( - FILE_URL, - get_cloud_storage_client, - get_file_path, - ) + file_doc = frappe.get_doc("File", self.file_doc.name) + file_doc.file_name = new_name + file_doc.folder = new_folder + file_doc.flags.cloud_storage = True - reloaded = frappe.get_doc("File", doc_name) + if is_rename: client = get_cloud_storage_client() - new_key = get_file_path(reloaded, client.folder) + new_key = paths.get_webdav_path(file_doc, client.folder) + _logger.debug(f"file move S3 rename {old_s3_key!r} → {new_key!r}") client.copy_object( Bucket=client.bucket, CopySource={"Bucket": client.bucket, "Key": old_s3_key}, Key=new_key, ) - client.delete_object(Bucket=client.bucket, Key=old_s3_key) - frappe.db.set_value("File", doc_name, "s3_key", new_key) - frappe.db.set_value("File", doc_name, "file_url", FILE_URL.format(path=new_key)) + file_doc.s3_key = new_key + file_doc.file_url = FILE_URL.format(path=new_key) + file_doc.save() frappe.db.commit() - except frappe.PermissionError: - raise DAVError(HTTP_FORBIDDEN) - except frappe.ValidationError as e: - raise DAVError(HTTP_FORBIDDEN, str(e)) + except Exception as exc: + if is_rename and client is not None and new_key: + try: + client.delete_object(Bucket=client.bucket, Key=new_key) + except Exception: + _logger.warning(f"failed to clean up orphan S3 key {new_key!r}") + if isinstance(exc, frappe.PermissionError): + raise DAVError(HTTP_FORBIDDEN) + if isinstance(exc, frappe.ValidationError): + raise DAVError(HTTP_FORBIDDEN, str(exc)) + raise + if is_rename and client is not None: + try: + client.delete_object(Bucket=client.bucket, Key=old_s3_key) + except Exception: + _logger.warning(f"failed to remove old S3 key {old_s3_key!r} after rename") + return [] def support_recursive_delete(self) -> bool: return False def support_recursive_move(self, dest_path: str) -> bool: - return False + return True class FrappeDAVProvider(DAVProvider): """Root WebDAV provider: maps URL paths to Frappe File doctypes.""" def get_resource_inst(self, path: str, environ: dict): - """Return the DAVCollection or DAVNonCollection for the given path, or None.""" - # wsgidav passes PATH_INFO (already /dav-stripped by our middleware) for - # the request resource, but the Destination header for MOVE/COPY arrives - # with the full URL path including /dav. Strip it here so both flows - # resolve to the same Frappe folder tree. - if path.startswith("/dav/"): - path = path[len("/dav"):] - elif path in ("/dav", "/dav/"): - path = "/" + path = _strip_dav_prefix(path) norm = path.rstrip("/") if not norm: @@ -669,37 +768,53 @@ def get_resource_inst(self, path: str, environ: dict): parts = [p for p in norm.split("/") if p] - # Walk intermediate folder segments to build the parent folder path + # get_all + _can(read): mirrors has_permission which is broader than + # the owner-only SQL filter in permission_query_conditions. frappe_folder = "Home" for part in parts[:-1]: parent = frappe_folder - if not frappe.db.exists("File", {"folder": parent, "file_name": part, "is_folder": 1}): + match = frappe.get_all( + "File", + filters={"folder": parent, "file_name": part, "is_folder": 1}, + pluck="name", + limit=1, + ) + if not match: + return None + if not _can(match[0], "read"): return None frappe_folder = f"{parent}/{part}" last = parts[-1] - # OS metadata files live only in _OS_FILE_STORE, not in Frappe. if _is_os_metadata(last): - if path in _OS_FILE_STORE: + if os_file_store.contains(path): return _MemoryFile(path, environ, last) return None - # Last segment: folder? - if frappe.db.exists("File", {"folder": frappe_folder, "file_name": last, "is_folder": 1}): + folder_match = frappe.get_all( + "File", + filters={"folder": frappe_folder, "file_name": last, "is_folder": 1}, + pluck="name", + limit=1, + ) + if folder_match: + if not _can(folder_match[0], "read"): + return None child_folder = f"{frappe_folder}/{last}" canonical = path if path.endswith("/") else path + "/" return FrappeCollection(canonical, environ, child_folder) - # Last segment: file? - file_doc = frappe.db.get_value( + file_rows = frappe.get_all( "File", - {"folder": frappe_folder, "file_name": last, "is_folder": 0}, - _FILE_FIELDS, - as_dict=True, + filters={"folder": frappe_folder, "file_name": last, "is_folder": 0}, + fields=_FILE_FIELDS, + limit=1, ) - if file_doc: - return FrappeFile(path, environ, file_doc) + if file_rows: + if not _can(file_rows[0].name, "read"): + return None + return FrappeFile(path, environ, file_rows[0]) return None @@ -708,11 +823,6 @@ def is_readonly(self) -> bool: def frappe_folder_parent(frappe_folder: str) -> str: - """Return the parent folder of a Frappe folder path. - - Home/Attachments → Home - Home → (empty string, root) - """ if "/" not in frappe_folder: return "" return frappe_folder.rsplit("/", 1)[0] diff --git a/cloud_storage/cloud_storage/webdav/renderer.py b/cloud_storage/cloud_storage/webdav/renderer.py index 43a7d5a..9343627 100644 --- a/cloud_storage/cloud_storage/webdav/renderer.py +++ b/cloud_storage/cloud_storage/webdav/renderer.py @@ -9,36 +9,34 @@ naturally into get_response()). * handle_webdav_methods — before_request hook for WebDAV methods Frappe's app.py would otherwise reject with NotFound - (PROPFIND, MKCOL, MOVE, COPY, LOCK, UNLOCK, PUT, - DELETE, OPTIONS). We short-circuit by raising - WebdavResponse — Frappe's top-level + (PROPFIND, MKCOL, MOVE, LOCK, UNLOCK, PUT, DELETE, + OPTIONS). COPY and PROPPATCH are explicitly rejected. + We short-circuit by raising WebdavResponse — Frappe's top-level `except HTTPException` returns it. Both paths invoke the same embedded WsgiDAVApp, mounted at /dav/. """ -from __future__ import annotations - import io import threading -import time import frappe from frappe.auth import validate_auth from werkzeug.exceptions import HTTPException from werkzeug.wrappers import Response +from wsgidav.dc.base_dc import BaseDomainController +from wsgidav.wsgidav_app import WsgiDAVApp + +from cloud_storage.cloud_storage.webdav.locks import RedisLockStorage +from cloud_storage.cloud_storage.webdav.provider import FrappeDAVProvider -def _log(msg: str) -> None: - """Direct-to-stdout logger so we see things even mid-request.""" - print(f"[DAV {time.strftime('%H:%M:%S')}] {msg}", flush=True) +_logger = frappe.logger("webdav", allow_site=False) _WEBDAV_METHODS = { "PROPFIND", - "PROPPATCH", "MKCOL", - "COPY", "MOVE", "LOCK", "UNLOCK", @@ -46,6 +44,7 @@ def _log(msg: str) -> None: "PUT", "DELETE", } +_UNSUPPORTED_WEBDAV_METHODS = {"COPY", "PROPPATCH"} _MOUNT_PREFIX = "/dav" @@ -80,6 +79,25 @@ def render(self) -> Response: return _invoke_webdav(request) +class _NoAuthDC(BaseDomainController): + """WsgiDAV auth adapter; Frappe has already authenticated the request.""" + + def get_domain_realm(self, path_info, environ): + return "Frappe" + + def require_authentication(self, realm, environ): + return False + + def supports_http_digest_auth(self): + return False + + def basic_auth_user(self, realm, user_name, password, environ): + return True + + def digest_auth_user(self, realm, user_name, environ): + raise NotImplementedError + + def handle_webdav_methods() -> None: """before_request hook: intercept WebDAV methods on /dav/* paths. @@ -89,6 +107,12 @@ def handle_webdav_methods() -> None: request = getattr(frappe.local, "request", None) if not request or not request.path.startswith(_MOUNT_PREFIX): return + if request.method in _UNSUPPORTED_WEBDAV_METHODS: + if request.method != "OPTIONS": + validate_auth() + if _needs_auth_challenge(request): + raise WebdavResponse(_auth_challenge()) + raise WebdavResponse(_method_not_allowed(request.method)) if request.method not in _WEBDAV_METHODS: return @@ -97,11 +121,11 @@ def handle_webdav_methods() -> None: if request.method != "OPTIONS": validate_auth() if _needs_auth_challenge(request): - _log(f"{request.method} {request.path} → 401 (no auth, requesting credentials)") + _logger.debug(f"{request.method} {request.path} → 401 (no auth)") raise WebdavResponse(_auth_challenge()) resp = _invoke_webdav(request) - _log(f"{request.method} {request.path} → {resp.status_code} user={frappe.session.user}") + _logger.debug(f"{request.method} {request.path} → {resp.status_code} user={frappe.session.user}") raise WebdavResponse(resp) @@ -121,6 +145,12 @@ def _auth_challenge() -> Response: return resp +def _method_not_allowed(method: str) -> Response: + resp = Response(f"{method} is not supported\n", status=405, mimetype="text/plain") + resp.headers["Allow"] = ", ".join(sorted(_WEBDAV_METHODS)) + return resp + + def _get_webdav_app(): """Build the WsgiDAVApp once per process (thread-safe lazy init).""" global _webdav_app @@ -129,32 +159,11 @@ def _get_webdav_app(): with _webdav_app_lock: if _webdav_app is not None: return _webdav_app - from wsgidav.dc.base_dc import BaseDomainController - from wsgidav.lock_man.lock_storage import LockStorageDict - from wsgidav.wsgidav_app import WsgiDAVApp - - from cloud_storage.cloud_storage.webdav.provider import FrappeDAVProvider - - class _NoAuthDC(BaseDomainController): - """wsgidav's auth is a no-op here — Frappe already authenticated.""" - - def get_domain_realm(self, path_info, environ): - return "Frappe" - - def require_authentication(self, realm, environ): - return False - - def supports_http_digest_auth(self): - return False - - def basic_auth_user(self, realm, user_name, password, environ): - return True - - def digest_auth_user(self, realm, user_name, environ): - raise NotImplementedError - config = { - "provider_mapping": {"/": FrappeDAVProvider()}, + # Mount the provider at /dav (not /) so wsgidav emits hrefs that + # include the prefix — Finder navigates via those hrefs and breaks + # if they point to / instead of /dav/. + "provider_mapping": {_MOUNT_PREFIX: FrappeDAVProvider()}, "http_authenticator": { "domain_controller": _NoAuthDC, # wsgidav refuses to init with both off — keep basic on, but @@ -163,7 +172,9 @@ def digest_auth_user(self, realm, user_name, environ): "accept_digest": False, "default_to_digest": False, }, - "lock_storage": LockStorageDict(), + # Redis-backed so locks survive across gunicorn workers — see + # webdav/locks.py for the rationale. + "lock_storage": RedisLockStorage(), "verbose": 1, "logging": {"enable_loggers": []}, } @@ -174,12 +185,8 @@ def digest_auth_user(self, realm, user_name, environ): def _invoke_webdav(request) -> Response: """Run the embedded WsgiDAVApp with request's environ, return its Response.""" environ = dict(request.environ) - # Mount wsgidav under /dav/ — strip the prefix from PATH_INFO and append it - # to SCRIPT_NAME so wsgidav-generated URLs (href, etc.) include /dav/. - path_info = environ.get("PATH_INFO", "/") - if path_info.startswith(_MOUNT_PREFIX): - environ["PATH_INFO"] = path_info[len(_MOUNT_PREFIX):] or "/" - environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + _MOUNT_PREFIX + # wsgidav's provider is mounted at /dav, so it handles PATH_INFO/SCRIPT_NAME + # routing itself. We just pass the environ through unchanged. # Pre-read the body via werkzeug (which handles bounded reads & chunked TE # properly) and replace wsgi.input with an in-memory BytesIO. wsgidav @@ -208,4 +215,12 @@ def start_response(status, headers, exc_info=None): body_iter.close() status_code = int(status_holder[0].split(" ", 1)[0]) if status_holder else 500 - return Response(out, status=status_code, headers=headers_holder) + resp = Response(out, status=status_code, headers=headers_holder) + if request.method == "OPTIONS" and resp.headers.get("Allow"): + allowed = [ + m.strip() + for m in resp.headers["Allow"].split(",") + if m.strip() not in _UNSUPPORTED_WEBDAV_METHODS + ] + resp.headers["Allow"] = ", ".join(allowed) + return resp From 804848ca6aa9e9257b0361bf8053be87c7565695 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 16:16:38 +0000 Subject: [PATCH 12/23] fix: cleanup --- .../cloud_storage/webdav/provider.py | 24 +++++++------------ .../cloud_storage/webdav/renderer.py | 7 +++--- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index 1623f0e..9b64afc 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -38,7 +38,7 @@ def _is_os_metadata(name: str) -> bool: def _is_hidden_from_listing(name: str) -> bool: - return name.startswith(_OS_PREFIXES) or name in _OS_FILES or name.startswith(_TEMP_PREFIXES) + return _is_os_metadata(name) or name.startswith(_TEMP_PREFIXES) _MOUNT_PREFIX = "/dav" @@ -290,17 +290,17 @@ def move_recursive(self, dest_path: str): frappe.db.savepoint(savepoint) try: # Frappe stores folder path in both `name` (PK) and display fields - # (`file_name`, `folder`). rename_doc updates the PK but leaves - # display fields stale — update them first. + # (`file_name`, `folder`). The WebDAV path may differ from the + # current PK after an ancestor move, so update/rename by folder_name. if new_name != display: - frappe.db.set_value("File", self.frappe_folder, "file_name", new_name) + frappe.db.set_value("File", folder_name, "file_name", new_name) if new_parent != parent: - frappe.db.set_value("File", self.frappe_folder, "folder", new_parent) + frappe.db.set_value("File", folder_name, "folder", new_parent) - if new_frappe_folder != self.frappe_folder: + if new_frappe_folder != folder_name: rename_doc( "File", - self.frappe_folder, + folder_name, new_frappe_folder, merge=False, force=True, @@ -308,8 +308,8 @@ def move_recursive(self, dest_path: str): validate=False, ) - # Reparent descendants (rename_doc only updates direct children). - # get_all intentional: descendants must move atomically with parent. + # Reparent descendants by current folder path. Their File.name may + # still be an old PK, but their `folder` value follows the visible path. old = self.frappe_folder affected = frappe.get_all( "File", @@ -378,8 +378,6 @@ def close(self) -> None: except Exception: frappe.log_error("WebDAV upload error", frappe.get_traceback()) raise - else: - super().close() def _commit(self) -> None: content = self._buf.getvalue() @@ -433,8 +431,6 @@ def close(self) -> None: except Exception: frappe.log_error("WebDAV overwrite error", frappe.get_traceback()) raise - else: - super().close() def _commit(self) -> None: content = self._buf.getvalue() @@ -481,8 +477,6 @@ def close(self) -> None: if not self.closed: super().close() os_file_store.set(self._path, self._buf.getvalue()) - else: - super().close() class _MemoryFile(DAVNonCollection): diff --git a/cloud_storage/cloud_storage/webdav/renderer.py b/cloud_storage/cloud_storage/webdav/renderer.py index 9343627..8bf827a 100644 --- a/cloud_storage/cloud_storage/webdav/renderer.py +++ b/cloud_storage/cloud_storage/webdav/renderer.py @@ -108,10 +108,9 @@ def handle_webdav_methods() -> None: if not request or not request.path.startswith(_MOUNT_PREFIX): return if request.method in _UNSUPPORTED_WEBDAV_METHODS: - if request.method != "OPTIONS": - validate_auth() - if _needs_auth_challenge(request): - raise WebdavResponse(_auth_challenge()) + validate_auth() + if _needs_auth_challenge(request): + raise WebdavResponse(_auth_challenge()) raise WebdavResponse(_method_not_allowed(request.method)) if request.method not in _WEBDAV_METHODS: return From 68ae6531a038276cf9d229cb1cf1c0b0ed78baf2 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 16:24:55 +0000 Subject: [PATCH 13/23] fix: backup s3 file before move --- .../cloud_storage/webdav/provider.py | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index 9b64afc..b8e2cb0 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -4,6 +4,7 @@ import io import mimetypes +import uuid import frappe from frappe.core.doctype.file.utils import get_content_hash @@ -45,6 +46,51 @@ def _is_hidden_from_listing(name: str) -> bool: _logger = frappe.logger("webdav", allow_site=False) +def _is_cloud_storage_enabled() -> bool: + config = frappe.conf.get("cloud_storage_settings", {}) + return bool(config and not config.get("use_local")) + + +def _backup_s3_object(key: str) -> tuple[object, str, str] | None: + if not key or not _is_cloud_storage_enabled(): + return None + client = get_cloud_storage_client() + backup_key = f"{key}.webdav-overwrite-backup-{uuid.uuid4().hex}" + client.copy_object( + Bucket=client.bucket, + CopySource={"Bucket": client.bucket, "Key": key}, + Key=backup_key, + ) + return client, key, backup_key + + +def _delete_s3_backup(backup: tuple[object, str, str] | None) -> None: + if not backup: + return + client, _, backup_key = backup + try: + client.delete_object(Bucket=client.bucket, Key=backup_key) + except Exception: + _logger.warning(f"failed to remove WebDAV overwrite backup {backup_key!r}") + + +def _restore_s3_backup(backup: tuple[object, str, str] | None) -> None: + if not backup: + return + client, original_key, backup_key = backup + try: + client.copy_object( + Bucket=client.bucket, + CopySource={"Bucket": client.bucket, "Key": backup_key}, + Key=original_key, + ) + except Exception: + _logger.warning(f"failed to restore WebDAV overwrite backup {backup_key!r}") + raise + finally: + _delete_s3_backup(backup) + + def _strip_dav_prefix(path: str) -> str: """Strip /dav prefix so both PATH_INFO and Destination headers resolve uniformly.""" if path.startswith(_MOUNT_PREFIX + "/"): @@ -678,7 +724,9 @@ def move_recursive(self, dest_path: str): f"{new_folder}/{new_name}" ) - # MOVE overwrites the destination if it exists; let on_trash guards run. + # MOVE overwrites the destination if it exists. If the destination has + # an S3 object, keep a temporary copy until the DB move commits: Frappe's + # on_trash hook deletes S3 immediately, while DB rollback cannot restore it. existing = frappe.db.get_value( "File", { @@ -687,25 +735,26 @@ def move_recursive(self, dest_path: str): "is_folder": 0, "name": ["!=", self.file_doc.name], }, - "name", + ["name", "s3_key"], + as_dict=True, ) - if existing: - try: - frappe.delete_doc("File", existing) - except frappe.PermissionError: - raise DAVError(HTTP_FORBIDDEN) - except frappe.ValidationError as e: - raise DAVError(HTTP_FORBIDDEN, str(e)) old_name = self.file_doc.file_name old_s3_key = self.file_doc.s3_key is_rename = bool(old_s3_key and old_name != new_name) # S3 rename order: copy → commit DB → delete old key. - # If commit fails, orphan the new copy (harmless) — never lose the original. + # If commit fails, clean up the new source copy and restore overwritten S3. client = None new_key = None + overwrite_backup = None + savepoint = "webdav_file_move" + frappe.db.savepoint(savepoint) try: + if existing: + overwrite_backup = _backup_s3_object(existing.s3_key) + frappe.delete_doc("File", existing.name) + file_doc = frappe.get_doc("File", self.file_doc.name) file_doc.file_name = new_name file_doc.folder = new_folder @@ -725,12 +774,15 @@ def move_recursive(self, dest_path: str): file_doc.save() frappe.db.commit() + _delete_s3_backup(overwrite_backup) except Exception as exc: + frappe.db.rollback(save_point=savepoint) if is_rename and client is not None and new_key: try: client.delete_object(Bucket=client.bucket, Key=new_key) except Exception: _logger.warning(f"failed to clean up orphan S3 key {new_key!r}") + _restore_s3_backup(overwrite_backup) if isinstance(exc, frappe.PermissionError): raise DAVError(HTTP_FORBIDDEN) if isinstance(exc, frappe.ValidationError): From 3296652fa6a5cd39462a4eb182aff4b1992fce1a Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 16:30:18 +0000 Subject: [PATCH 14/23] fix: refactor permissions and buffers --- cloud_storage/cloud_storage/webdav/buffers.py | 138 ++++++++++++++ .../cloud_storage/webdav/permissions.py | 41 ++++ .../cloud_storage/webdav/provider.py | 179 ++---------------- 3 files changed, 191 insertions(+), 167 deletions(-) create mode 100644 cloud_storage/cloud_storage/webdav/buffers.py create mode 100644 cloud_storage/cloud_storage/webdav/permissions.py diff --git a/cloud_storage/cloud_storage/webdav/buffers.py b/cloud_storage/cloud_storage/webdav/buffers.py new file mode 100644 index 0000000..aae0265 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/buffers.py @@ -0,0 +1,138 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# Write buffers used by wsgidav resources. + +import io +import mimetypes + +import frappe +from frappe.core.doctype.file.utils import get_content_hash + +from cloud_storage.cloud_storage.webdav import memory as os_file_store +from cloud_storage.cloud_storage.webdav import paths + + +class WriteBuffer(io.RawIOBase): + """Accumulates PUT body; on close inserts a File doc and uploads content. + + Finder's two-step upload sends PUT Content-Length:0 first (to claim the + path), then LOCK, then a second PUT with real content. An empty body + creates a placeholder doc so the LOCK handler can find the resource. + """ + + def __init__(self, file_name: str, frappe_folder: str, content_type: str | None) -> None: + self._buf = io.BytesIO() + self._file_name = file_name + self._frappe_folder = frappe_folder + self._content_type = content_type + + def write(self, data: bytes) -> int: + return self._buf.write(data) + + def close(self) -> None: + if not self.closed: + super().close() + try: + self._commit() + except Exception: + frappe.log_error("WebDAV upload error", frappe.get_traceback()) + raise + + def _commit(self) -> None: + content = self._buf.getvalue() + + file_doc = frappe.new_doc("File") + file_doc.file_name = self._file_name + file_doc.folder = self._frappe_folder + # Private by default: non-private files skip expiration + perm checks in + # get_presigned_url, undermining the per-user WebDAV perm model. + file_doc.is_private = 1 + file_doc.flags.cloud_storage = True + + if not content: + file_doc.insert() + frappe.db.commit() + return + + file_doc.content = content + file_doc.content_hash = get_content_hash(content) + mime, _ = mimetypes.guess_type(self._file_name or "") + file_doc.content_type = mime or "application/octet-stream" + file_doc.insert() + + config = frappe.conf.get("cloud_storage_settings", {}) + if not config or config.get("use_local"): + # save_file_on_filesystem reads _content, not content. + file_doc._content = content + file_doc.save_file_on_filesystem() + file_doc.db_set("file_url", file_doc.file_url) + file_doc.db_set("file_size", len(content)) + else: + paths.upload_via_webdav(file_doc, content, file_doc.content_type) + frappe.db.commit() + + +class OverwriteBuffer(io.RawIOBase): + """Accumulates PUT body for an existing resource; on close re-uploads content in place.""" + + def __init__(self, doc_name: str) -> None: + self._buf = io.BytesIO() + self._doc_name = doc_name + + def write(self, data: bytes) -> int: + return self._buf.write(data) + + def close(self) -> None: + if not self.closed: + super().close() + try: + self._commit() + except Exception: + frappe.log_error("WebDAV overwrite error", frappe.get_traceback()) + raise + + def _commit(self) -> None: + content = self._buf.getvalue() + if not content: + return + + content_hash = get_content_hash(content) + file_doc = frappe.get_doc("File", self._doc_name) + mime, _ = mimetypes.guess_type(file_doc.file_name or "") + content_type = mime or "application/octet-stream" + file_doc.content_hash = content_hash + file_doc.content_type = content_type + file_doc.flags.cloud_storage = True + + config = frappe.conf.get("cloud_storage_settings", {}) + if not config or config.get("use_local"): + file_doc.content = content + file_doc.file_size = len(content) + # Clear the existing file_url (likely an S3 retrieve URL) so + # save_file_on_filesystem's validate doesn't reject it. + # Also assign _content — that's what the method actually reads. + file_doc.file_url = None + file_doc._content = content + file_doc.save_file_on_filesystem() + file_doc.db_set("file_url", file_doc.file_url) + file_doc.db_set("file_size", len(content)) + file_doc.db_set("content_hash", content_hash) + else: + paths.upload_via_webdav(file_doc, content, content_type) + frappe.db.commit() + + +class MemoryBuffer(io.RawIOBase): + """Write buffer that stashes content in the Redis-backed OS metadata store.""" + + def __init__(self, path: str) -> None: + self._buf = io.BytesIO() + self._path = path + + def write(self, data: bytes) -> int: + return self._buf.write(data) + + def close(self) -> None: + if not self.closed: + super().close() + os_file_store.set(self._path, self._buf.getvalue()) diff --git a/cloud_storage/cloud_storage/webdav/permissions.py b/cloud_storage/cloud_storage/webdav/permissions.py new file mode 100644 index 0000000..2940d13 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/permissions.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# Frappe File permission helpers used by the WebDAV provider. + +import frappe + + +def folder_display_name(frappe_folder: str) -> str: + return frappe_folder.rsplit("/", 1)[-1] + + +def folder_parent(frappe_folder: str) -> str: + if "/" not in frappe_folder: + return "" + return frappe_folder.rsplit("/", 1)[0] + + +def can(target, ptype: str) -> bool: + """Per-doc permission check. target must be a doc name or doc object.""" + return frappe.has_permission("File", doc=target, ptype=ptype, user=frappe.session.user) + + +def can_create() -> bool: + """Doctype-level create check (no specific doc yet).""" + return frappe.has_permission("File", ptype="create", user=frappe.session.user) + + +def can_write_folder(frappe_folder: str) -> bool: + """Check write permission on a Frappe folder path, e.g. 'Home/Docs'.""" + if frappe_folder == "Home": + return True + parent = folder_parent(frappe_folder) + display = folder_display_name(frappe_folder) + folder_name = frappe.db.get_value( + "File", + {"folder": parent, "file_name": display, "is_folder": 1}, + "name", + ) + if not folder_name: + return False + return can(folder_name, "write") diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index b8e2cb0..a9128af 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -7,7 +7,6 @@ import uuid import frappe -from frappe.core.doctype.file.utils import get_content_hash from frappe.model.rename_doc import rename_doc from wsgidav.dav_error import ( DAVError, @@ -20,10 +19,18 @@ from cloud_storage.cloud_storage.overrides.file import FILE_URL, get_cloud_storage_client from cloud_storage.cloud_storage.webdav import memory as os_file_store from cloud_storage.cloud_storage.webdav import paths - - -def _folder_display_name(frappe_folder: str) -> str: - return frappe_folder.rsplit("/", 1)[-1] +from cloud_storage.cloud_storage.webdav.buffers import ( + MemoryBuffer as _MemoryBuffer, + OverwriteBuffer as _OverwriteBuffer, + WriteBuffer as _WriteBuffer, +) +from cloud_storage.cloud_storage.webdav.permissions import ( + can as _can, + can_create as _can_create, + can_write_folder as _can_write_folder, + folder_display_name as _folder_display_name, + folder_parent as frappe_folder_parent, +) _SYSTEM_FOLDERS = {"Home", "Home/Attachments"} @@ -100,36 +107,6 @@ def _strip_dav_prefix(path: str) -> str: return path -def _can(target, ptype: str) -> bool: - """Per-doc permission check. target must be a doc name or doc object, not a doctype string.""" - return frappe.has_permission("File", doc=target, ptype=ptype, user=frappe.session.user) - - -def _can_create() -> bool: - """Doctype-level create check (no specific doc yet).""" - return frappe.has_permission("File", ptype="create", user=frappe.session.user) - - -def _can_write_folder(frappe_folder: str) -> bool: - """Check write permission on a Frappe folder path (e.g. 'Home/Docs'). - - Returns True for the virtual root 'Home' (no doc exists for it) so that - PUT/MKCOL/MOVE into the root collection aren't incorrectly blocked. - """ - if frappe_folder == "Home": - return True - parent = frappe_folder_parent(frappe_folder) - display = _folder_display_name(frappe_folder) - folder_name = frappe.db.get_value( - "File", - {"folder": parent, "file_name": display, "is_folder": 1}, - "name", - ) - if not folder_name: - return False - return _can(folder_name, "write") - - def _parse_dest(dest_path: str) -> tuple[str, str]: dest = _strip_dav_prefix(dest_path).rstrip("/") parts = [p for p in dest.split("/") if p] @@ -399,132 +376,6 @@ def support_recursive_move(self, dest_path: str) -> bool: ] -class _WriteBuffer(io.RawIOBase): - """Accumulates PUT body; on close inserts a File doc and uploads content. - - Finder's two-step upload sends PUT Content-Length:0 first (to claim the - path), then LOCK, then a second PUT with real content. An empty body - creates a placeholder doc so the LOCK handler can find the resource. - """ - - def __init__(self, file_name: str, frappe_folder: str, content_type: str | None) -> None: - self._buf = io.BytesIO() - self._file_name = file_name - self._frappe_folder = frappe_folder - self._content_type = content_type - - def write(self, data: bytes) -> int: - return self._buf.write(data) - - def close(self) -> None: - if not self.closed: - super().close() - try: - self._commit() - except Exception: - frappe.log_error("WebDAV upload error", frappe.get_traceback()) - raise - - def _commit(self) -> None: - content = self._buf.getvalue() - - file_doc = frappe.new_doc("File") - file_doc.file_name = self._file_name - file_doc.folder = self._frappe_folder - # Private by default: non-private files skip expiration + perm checks in - # get_presigned_url, undermining the per-user WebDAV perm model. - file_doc.is_private = 1 - file_doc.flags.cloud_storage = True - - if not content: - file_doc.insert() - frappe.db.commit() - return - - file_doc.content = content - file_doc.content_hash = get_content_hash(content) - mime, _ = mimetypes.guess_type(self._file_name or "") - file_doc.content_type = mime or "application/octet-stream" - file_doc.insert() - - config = frappe.conf.get("cloud_storage_settings", {}) - if not config or config.get("use_local"): - # save_file_on_filesystem reads _content, not content. - file_doc._content = content - file_doc.save_file_on_filesystem() - file_doc.db_set("file_url", file_doc.file_url) - file_doc.db_set("file_size", len(content)) - else: - paths.upload_via_webdav(file_doc, content, file_doc.content_type) - frappe.db.commit() - - -class _OverwriteBuffer(io.RawIOBase): - """Accumulates PUT body for an existing resource; on close re-uploads content in place.""" - - def __init__(self, doc_name: str) -> None: - self._buf = io.BytesIO() - self._doc_name = doc_name - - def write(self, data: bytes) -> int: - return self._buf.write(data) - - def close(self) -> None: - if not self.closed: - super().close() - try: - self._commit() - except Exception: - frappe.log_error("WebDAV overwrite error", frappe.get_traceback()) - raise - - def _commit(self) -> None: - content = self._buf.getvalue() - if not content: - return - - content_hash = get_content_hash(content) - file_doc = frappe.get_doc("File", self._doc_name) - mime, _ = mimetypes.guess_type(file_doc.file_name or "") - content_type = mime or "application/octet-stream" - file_doc.content_hash = content_hash - file_doc.content_type = content_type - file_doc.flags.cloud_storage = True - - config = frappe.conf.get("cloud_storage_settings", {}) - if not config or config.get("use_local"): - file_doc.content = content - file_doc.file_size = len(content) - # Clear the existing file_url (likely an S3 retrieve URL) so - # save_file_on_filesystem's validate doesn't reject it. - # Also assign _content — that's what the method actually reads. - file_doc.file_url = None - file_doc._content = content - file_doc.save_file_on_filesystem() - file_doc.db_set("file_url", file_doc.file_url) - file_doc.db_set("file_size", len(content)) - file_doc.db_set("content_hash", content_hash) - else: - paths.upload_via_webdav(file_doc, content, content_type) - frappe.db.commit() - - -class _MemoryBuffer(io.RawIOBase): - """Write buffer that stashes content in the Redis-backed OS metadata store.""" - - def __init__(self, path: str) -> None: - self._buf = io.BytesIO() - self._path = path - - def write(self, data: bytes) -> int: - return self._buf.write(data) - - def close(self) -> None: - if not self.closed: - super().close() - os_file_store.set(self._path, self._buf.getvalue()) - - class _MemoryFile(DAVNonCollection): """In-memory resource for OS metadata files (._*, .DS_Store, ...).""" @@ -866,9 +717,3 @@ def get_resource_inst(self, path: str, environ: dict): def is_readonly(self) -> bool: return False - - -def frappe_folder_parent(frappe_folder: str) -> str: - if "/" not in frappe_folder: - return "" - return frappe_folder.rsplit("/", 1)[0] From a83395796c68bbd05748d48a4a7fbe00bb06fedf Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 16:32:14 +0000 Subject: [PATCH 15/23] fix: linters --- cloud_storage/cloud_storage/webdav/locks.py | 8 +++----- cloud_storage/cloud_storage/webdav/provider.py | 7 +++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/locks.py b/cloud_storage/cloud_storage/webdav/locks.py index 5f34fa3..dbedc58 100644 --- a/cloud_storage/cloud_storage/webdav/locks.py +++ b/cloud_storage/cloud_storage/webdav/locks.py @@ -2,7 +2,8 @@ # For license information, please see license.txt # Redis-backed wsgidav LockStorage — keeps locks consistent across gunicorn workers. -from typing import Any, Iterator +from typing import Any +from collections.abc import Iterator import frappe from wsgidav.lock_man.lock_storage import LockStorageDict @@ -55,10 +56,7 @@ def _hgetall_decoded(self) -> dict[str, Any]: # hgetall unpickles values but leaves Redis hash keys as raw bytes; # wsgidav compares them against str URLs so we decode here. raw = self._cache().hgetall(self._ns) or {} - return { - (k.decode("utf-8") if isinstance(k, bytes) else k): v - for k, v in raw.items() - } + return {(k.decode("utf-8") if isinstance(k, bytes) else k): v for k, v in raw.items()} class RedisLockStorage(LockStorageDict): diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index a9128af..f36c3f2 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -101,7 +101,7 @@ def _restore_s3_backup(backup: tuple[object, str, str] | None) -> None: def _strip_dav_prefix(path: str) -> str: """Strip /dav prefix so both PATH_INFO and Destination headers resolve uniformly.""" if path.startswith(_MOUNT_PREFIX + "/"): - return path[len(_MOUNT_PREFIX):] + return path[len(_MOUNT_PREFIX) :] if path in (_MOUNT_PREFIX, _MOUNT_PREFIX + "/"): return "/" return path @@ -344,7 +344,7 @@ def move_recursive(self, dest_path: str): ) _logger.debug(f"folder move reparenting {len(affected)} descendant(s)") for f in affected: - new_folder_path = new_frappe_folder + f.folder[len(old):] + new_folder_path = new_frappe_folder + f.folder[len(old) :] frappe.db.set_value("File", f.name, "folder", new_folder_path) frappe.db.commit() return [] @@ -571,8 +571,7 @@ def move_recursive(self, dest_path: str): if not _can_write_folder(new_folder): raise DAVError(HTTP_FORBIDDEN) _logger.debug( - f"file move doc={self.file_doc.name} {self.file_doc.file_name!r} → " - f"{new_folder}/{new_name}" + f"file move doc={self.file_doc.name} {self.file_doc.file_name!r} → " f"{new_folder}/{new_name}" ) # MOVE overwrites the destination if it exists. If the destination has From 53ac7a85df145d23461a305a3242b59bc47d35f5 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 17:23:34 +0000 Subject: [PATCH 16/23] feat: replace_existing_via_webdav for versioning --- cloud_storage/cloud_storage/webdav/paths.py | 76 ++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/cloud_storage/cloud_storage/webdav/paths.py b/cloud_storage/cloud_storage/webdav/paths.py index 71f3052..15a84ae 100644 --- a/cloud_storage/cloud_storage/webdav/paths.py +++ b/cloud_storage/cloud_storage/webdav/paths.py @@ -1,10 +1,13 @@ # Copyright (c) 2026, AgriTheory and contributors # For license information, please see license.txt -# WebDAV-specific S3 path strategy and upload pipeline. +# WebDAV-specific S3 path strategy and upload/version pipeline. + +import mimetypes import frappe from boto3.exceptions import S3UploadFailedError from frappe.core.doctype.file.file import File +from frappe.core.doctype.file.utils import get_content_hash from cloud_storage.cloud_storage.overrides.file import ( FILE_URL, @@ -64,3 +67,74 @@ def upload_via_webdav(file_doc: File, content: bytes, content_type: str) -> File if file_doc.content_hash: file_doc.db_set("content_hash", file_doc.content_hash) return file_doc + + +def _content_type_for(file_doc: File, fallback_name: str | None = None) -> str: + return ( + getattr(file_doc, "content_type", None) + or mimetypes.guess_type(fallback_name or file_doc.file_name or "")[0] + or "application/octet-stream" + ) + + +def replace_existing_via_webdav(existing_doc: File, source_doc: File) -> File: + """Replace an existing WebDAV File doc with source content, preserving versions.""" + content_type = _content_type_for(source_doc, existing_doc.file_name) + source_size = source_doc.file_size or 0 + source_hash = source_doc.content_hash + + existing_doc.flags.cloud_storage = True + existing_doc.content_type = content_type + existing_doc.file_size = source_size + if source_hash: + existing_doc.content_hash = source_hash + + config = frappe.conf.get("cloud_storage_settings", {}) + if not config or config.get("use_local"): + content = source_doc.get_content() + if isinstance(content, str): + content = content.encode() + source_hash = source_hash or get_content_hash(content) + + existing_doc.content = content + existing_doc.content_hash = source_hash + existing_doc.file_size = len(content) + # Clear the existing S3 URL so the local filesystem writer can validate. + existing_doc.file_url = None + existing_doc._content = content + existing_doc.save_file_on_filesystem() + existing_doc.db_set("file_url", existing_doc.file_url) + existing_doc.db_set("file_size", len(content)) + existing_doc.db_set("content_hash", source_hash) + existing_doc.add_file_version(source_hash) + return existing_doc + + client = get_cloud_storage_client() + dest_path = get_webdav_path(existing_doc, client.folder) + source_key = source_doc.s3_key + if not source_key: + frappe.throw("Source WebDAV file has no cloud storage key.") + + try: + response = client.copy_object( + Bucket=client.bucket, + CopySource={"Bucket": client.bucket, "Key": source_key}, + Key=dest_path, + ContentType=content_type, + MetadataDirective="REPLACE", + ) + version_id = response.get("VersionId") or source_hash + except S3UploadFailedError: + frappe.throw("File Upload Failed. Please try again.") + except Exception as e: + frappe.log_error("WebDAV replace error", e) + raise + + existing_doc.db_set("file_url", FILE_URL.format(path=dest_path)) + existing_doc.db_set("s3_key", dest_path) + existing_doc.db_set("file_size", source_size) + if source_hash: + existing_doc.db_set("content_hash", source_hash) + if version_id: + existing_doc.add_file_version(version_id) + return existing_doc From f0cb515baa05a394d3bec33582bb4005ccaab2f6 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 3 Jun 2026 17:24:44 +0000 Subject: [PATCH 17/23] fix: move memoryfile for temp files --- .../cloud_storage/webdav/provider.py | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index f36c3f2..1220f37 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -422,9 +422,20 @@ def begin_write(self, content_type: str | None = None): def delete(self): os_file_store.delete(self.path) + def handle_move(self, dest_path: str) -> bool: + dest = _strip_dav_prefix(dest_path).rstrip("/") + if not dest: + raise DAVError(HTTP_FORBIDDEN, "invalid destination") + os_file_store.set(dest, self._content()) + os_file_store.delete(self.path) + return True + def copy_move_single(self, dest_path: str, *, is_move: bool): raise DAVError(HTTP_FORBIDDEN) + def support_recursive_move(self, dest_path: str) -> bool: + return False + class FrappeNewFile(DAVNonCollection): """Transient resource representing a file being uploaded via WebDAV PUT.""" @@ -574,9 +585,8 @@ def move_recursive(self, dest_path: str): f"file move doc={self.file_doc.name} {self.file_doc.file_name!r} → " f"{new_folder}/{new_name}" ) - # MOVE overwrites the destination if it exists. If the destination has - # an S3 object, keep a temporary copy until the DB move commits: Frappe's - # on_trash hook deletes S3 immediately, while DB rollback cannot restore it. + # MOVE overwrites the destination if it exists. Preserve the destination + # File doc so Cloud Storage's File Version table keeps its history. existing = frappe.db.get_value( "File", { @@ -589,6 +599,11 @@ def move_recursive(self, dest_path: str): as_dict=True, ) + if existing: + if not _can(existing.name, "write"): + raise DAVError(HTTP_FORBIDDEN) + return self._replace_existing_destination(existing.name, existing.s3_key) + old_name = self.file_doc.file_name old_s3_key = self.file_doc.s3_key is_rename = bool(old_s3_key and old_name != new_name) @@ -597,14 +612,9 @@ def move_recursive(self, dest_path: str): # If commit fails, clean up the new source copy and restore overwritten S3. client = None new_key = None - overwrite_backup = None savepoint = "webdav_file_move" frappe.db.savepoint(savepoint) try: - if existing: - overwrite_backup = _backup_s3_object(existing.s3_key) - frappe.delete_doc("File", existing.name) - file_doc = frappe.get_doc("File", self.file_doc.name) file_doc.file_name = new_name file_doc.folder = new_folder @@ -624,7 +634,6 @@ def move_recursive(self, dest_path: str): file_doc.save() frappe.db.commit() - _delete_s3_backup(overwrite_backup) except Exception as exc: frappe.db.rollback(save_point=savepoint) if is_rename and client is not None and new_key: @@ -632,7 +641,6 @@ def move_recursive(self, dest_path: str): client.delete_object(Bucket=client.bucket, Key=new_key) except Exception: _logger.warning(f"failed to clean up orphan S3 key {new_key!r}") - _restore_s3_backup(overwrite_backup) if isinstance(exc, frappe.PermissionError): raise DAVError(HTTP_FORBIDDEN) if isinstance(exc, frappe.ValidationError): @@ -645,6 +653,33 @@ def move_recursive(self, dest_path: str): _logger.warning(f"failed to remove old S3 key {old_s3_key!r} after rename") return [] + def _replace_existing_destination(self, existing_name: str, existing_s3_key: str | None): + savepoint = "webdav_file_replace" + overwrite_backup = None + source_backup = None + frappe.db.savepoint(savepoint) + try: + source_doc = frappe.get_doc("File", self.file_doc.name) + existing_doc = frappe.get_doc("File", existing_name) + overwrite_backup = _backup_s3_object(existing_s3_key) + source_backup = _backup_s3_object(source_doc.s3_key) + + paths.replace_existing_via_webdav(existing_doc, source_doc) + frappe.delete_doc("File", source_doc.name) + frappe.db.commit() + _delete_s3_backup(overwrite_backup) + _delete_s3_backup(source_backup) + return [] + except Exception as exc: + frappe.db.rollback(save_point=savepoint) + _restore_s3_backup(overwrite_backup) + _restore_s3_backup(source_backup) + if isinstance(exc, frappe.PermissionError): + raise DAVError(HTTP_FORBIDDEN) + if isinstance(exc, frappe.ValidationError): + raise DAVError(HTTP_FORBIDDEN, str(exc)) + raise + def support_recursive_delete(self) -> bool: return False From 944bdd0288e332ddc84e15125059c05fba998786 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 4 Jun 2026 13:54:26 +0000 Subject: [PATCH 18/23] fix: versioning when edit a file --- .../cloud_storage/webdav/provider.py | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index 1220f37..045bc71 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -366,6 +366,7 @@ def support_recursive_move(self, dest_path: str) -> bool: _FILE_FIELDS = [ "name", "file_name", + "folder", "file_size", "content_hash", "modified", @@ -604,6 +605,17 @@ def move_recursive(self, dest_path: str): raise DAVError(HTTP_FORBIDDEN) return self._replace_existing_destination(existing.name, existing.s3_key) + displaced = self._find_displaced_atomic_save_destination(new_folder, new_name) + if displaced: + if not _can(displaced.name, "write"): + raise DAVError(HTTP_FORBIDDEN) + return self._replace_existing_destination( + displaced.name, + displaced.s3_key, + final_folder=new_folder, + final_name=new_name, + ) + old_name = self.file_doc.file_name old_s3_key = self.file_doc.s3_key is_rename = bool(old_s3_key and old_name != new_name) @@ -653,7 +665,40 @@ def move_recursive(self, dest_path: str): _logger.warning(f"failed to remove old S3 key {old_s3_key!r} after rename") return [] - def _replace_existing_destination(self, existing_name: str, existing_s3_key: str | None): + def _find_displaced_atomic_save_destination(self, new_folder: str, new_name: str): + source_folder = self.file_doc.get("folder") + if self.file_doc.file_name != new_name or not source_folder: + return None + if frappe_folder_parent(source_folder) != new_folder: + return None + + source_folder_name = _folder_display_name(source_folder) + if not source_folder_name.startswith(f"{new_name}.sb-"): + return None + + prefix = source_folder_name.rsplit("-", 1)[0] + "-" + rows = frappe.get_all( + "File", + filters={"folder": new_folder, "is_folder": 0, "name": ["!=", self.file_doc.name]}, + fields=["name", "file_name", "s3_key", "modified"], + order_by="modified desc", + ) + for row in rows: + if row.file_name.startswith(prefix): + _logger.debug( + f"atomic save replace final={new_folder}/{new_name!r} displaced={row.name!r}" + ) + return row + return None + + def _replace_existing_destination( + self, + existing_name: str, + existing_s3_key: str | None, + *, + final_folder: str | None = None, + final_name: str | None = None, + ): savepoint = "webdav_file_replace" overwrite_backup = None source_backup = None @@ -664,9 +709,24 @@ def _replace_existing_destination(self, existing_name: str, existing_s3_key: str overwrite_backup = _backup_s3_object(existing_s3_key) source_backup = _backup_s3_object(source_doc.s3_key) + if final_folder is not None: + existing_doc.folder = final_folder + if final_name is not None: + existing_doc.file_name = final_name + if final_folder is not None or final_name is not None: + existing_doc.flags.cloud_storage = True + existing_doc.save() + paths.replace_existing_via_webdav(existing_doc, source_doc) frappe.delete_doc("File", source_doc.name) frappe.db.commit() + current_s3_key = frappe.db.get_value("File", existing_doc.name, "s3_key") + if existing_s3_key and existing_s3_key != current_s3_key: + client = get_cloud_storage_client() + try: + client.delete_object(Bucket=client.bucket, Key=existing_s3_key) + except Exception: + _logger.warning(f"failed to remove old S3 key {existing_s3_key!r} after replace") _delete_s3_backup(overwrite_backup) _delete_s3_backup(source_backup) return [] From b11916b1fc43db7a13ff147aa5fb43c87d05fece Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 4 Jun 2026 16:29:55 +0000 Subject: [PATCH 19/23] fix: linters --- cloud_storage/cloud_storage/webdav/provider.py | 13 ++++++++++--- cloud_storage/cloud_storage/webdav/renderer.py | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index 045bc71..a8b5847 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -58,6 +58,14 @@ def _is_cloud_storage_enabled() -> bool: return bool(config and not config.get("use_local")) +def _detach_local_file_before_delete(file_doc) -> None: + """Delete a transient File doc without deleting the shared local payload.""" + if _is_cloud_storage_enabled() or file_doc.s3_key: + return + file_doc.file_url = None + file_doc.db_set("file_url", None) + + def _backup_s3_object(key: str) -> tuple[object, str, str] | None: if not key or not _is_cloud_storage_enabled(): return None @@ -685,9 +693,7 @@ def _find_displaced_atomic_save_destination(self, new_folder: str, new_name: str ) for row in rows: if row.file_name.startswith(prefix): - _logger.debug( - f"atomic save replace final={new_folder}/{new_name!r} displaced={row.name!r}" - ) + _logger.debug(f"atomic save replace final={new_folder}/{new_name!r} displaced={row.name!r}") return row return None @@ -718,6 +724,7 @@ def _replace_existing_destination( existing_doc.save() paths.replace_existing_via_webdav(existing_doc, source_doc) + _detach_local_file_before_delete(source_doc) frappe.delete_doc("File", source_doc.name) frappe.db.commit() current_s3_key = frappe.db.get_value("File", existing_doc.name, "s3_key") diff --git a/cloud_storage/cloud_storage/webdav/renderer.py b/cloud_storage/cloud_storage/webdav/renderer.py index 8bf827a..f31a6fc 100644 --- a/cloud_storage/cloud_storage/webdav/renderer.py +++ b/cloud_storage/cloud_storage/webdav/renderer.py @@ -206,8 +206,11 @@ def start_response(status, headers, exc_info=None): return lambda data: None app = _get_webdav_app() - body_iter = app(environ, start_response) + body_iter = None + out = b"" + status_code = 500 try: + body_iter = app(environ, start_response) out = b"".join(body_iter) finally: if hasattr(body_iter, "close"): From 7fcc01bfdbe97bcd15f0bf0fa015955a2f2582bf Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 4 Jun 2026 16:35:19 +0000 Subject: [PATCH 20/23] doc: windows case rclone + winfsp --- docs/webdav.md | 44 +++++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/webdav.md b/docs/webdav.md index 7986231..87d0c31 100644 --- a/docs/webdav.md +++ b/docs/webdav.md @@ -9,8 +9,8 @@ For license information, please see license.txt--> Cloud Storage exposes the Frappe File list as a WebDAV endpoint at `/dav/`. -Users can connect to it from desktop clients such as macOS Finder, Windows -Explorer or rclone. +Users can connect to it from desktop clients such as macOS Finder, Windows with +rclone, or any compatible WebDAV client. Files remain stored in the configured cloud storage provider. Frappe remains the source of truth for file metadata, folders, and permissions. @@ -49,24 +49,40 @@ http://your-site.localhost:8000/dav/ 3. Enter your WebDAV URL. 4. When prompted, use your Frappe API key as the username and your API secret as the password. -## Windows Explorer +## Windows -1. Open **This PC**. -2. Select **Computer > Map network drive**. -3. Enter your WebDAV URL. -4. Select **Connect using different credentials**. -5. Use your Frappe API key as the username and your API secret as the password. +Windows users can mount the WebDAV endpoint with rclone. + +1. Download and install rclone from [rclone.org/downloads](https://rclone.org/downloads/). +2. Download and install WinFsp from [winfsp.dev/rel](https://winfsp.dev/rel/). +3. Configure a WebDAV remote with `rclone config`. +4. Mount the remote with `rclone mount`. ## rclone -Add a WebDAV remote to your rclone configuration: +Create a WebDAV remote with rclone's interactive setup: + +```shell +rclone config +``` -Use `rclone obscure` to store the API secret: +Suggested setup: ```shell -rclone obscure "your-api-secret" +n) New remote +name> cloud_storage +Storage> webdav +url> https://your-site.example.com/dav/ +vendor> other +user> your-api-key +pass> your-api-secret ``` +Use your Frappe API key as the username and your API secret as the password. +rclone stores the password in its configuration using its own obscured format. + +The resulting configuration will look similar to this: + ```ini [cloud_storage] type = webdav @@ -84,6 +100,12 @@ rclone copy ./report.pdf cloud_storage:Reports/report.pdf rclone copy cloud_storage:Reports/report.pdf ./report.pdf ``` +On Windows, mount the remote as a drive letter: + +```shell +rclone mount cloud_storage: X: +``` + ## Permissions The WebDAV mount follows Frappe File permissions. Users can only list, read, From a26808b32f2ee39a1956a9165be57a24ddd83cc2 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 4 Jun 2026 16:55:20 +0000 Subject: [PATCH 21/23] fix: case space in names --- cloud_storage/cloud_storage/webdav/paths.py | 5 +++-- cloud_storage/cloud_storage/webdav/provider.py | 17 ++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/paths.py b/cloud_storage/cloud_storage/webdav/paths.py index 15a84ae..e929fe9 100644 --- a/cloud_storage/cloud_storage/webdav/paths.py +++ b/cloud_storage/cloud_storage/webdav/paths.py @@ -3,6 +3,7 @@ # WebDAV-specific S3 path strategy and upload/version pipeline. import mimetypes +from urllib.parse import quote import frappe from boto3.exceptions import S3UploadFailedError @@ -19,8 +20,8 @@ def _default_webdav_path(file: File, folder: str | None) -> str: - """Per-doc S3 key. ``#`` is %-escaped to match legacy behaviour.""" - parts = [folder, _WEBDAV_PREFIX, file.name, file.file_name.replace("#", "%23")] + """Per-doc S3 key with a URL-safe filename component.""" + parts = [folder, _WEBDAV_PREFIX, file.name, quote(file.file_name, safe="")] return "/".join(p for p in parts if p) diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index a8b5847..ffc8f4e 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -5,6 +5,7 @@ import io import mimetypes import uuid +from urllib.parse import unquote import frappe from frappe.model.rename_doc import rename_doc @@ -115,9 +116,13 @@ def _strip_dav_prefix(path: str) -> str: return path +def _split_dav_path(path: str) -> list[str]: + path = _strip_dav_prefix(path).strip("/") + return [unquote(part) for part in path.split("/") if part] + + def _parse_dest(dest_path: str) -> tuple[str, str]: - dest = _strip_dav_prefix(dest_path).rstrip("/") - parts = [p for p in dest.split("/") if p] + parts = _split_dav_path(dest_path) if not parts: raise DAVError(HTTP_FORBIDDEN, "invalid destination") new_name = parts[-1] @@ -435,7 +440,7 @@ def handle_move(self, dest_path: str) -> bool: dest = _strip_dav_prefix(dest_path).rstrip("/") if not dest: raise DAVError(HTTP_FORBIDDEN, "invalid destination") - os_file_store.set(dest, self._content()) + os_file_store.set(unquote(dest), self._content()) os_file_store.delete(self.path) return True @@ -759,13 +764,11 @@ class FrappeDAVProvider(DAVProvider): def get_resource_inst(self, path: str, environ: dict): path = _strip_dav_prefix(path) - norm = path.rstrip("/") + parts = _split_dav_path(path) - if not norm: + if not parts: return FrappeCollection("/", environ, "Home") - parts = [p for p in norm.split("/") if p] - # get_all + _can(read): mirrors has_permission which is broader than # the owner-only SQL filter in permission_query_conditions. frappe_folder = "Home" From a2aedb7a975930a4e0f80b5ec363c0bd84ebc1c2 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 4 Jun 2026 19:05:18 +0000 Subject: [PATCH 22/23] fix: linters --- cloud_storage/cloud_storage/webdav/buffers.py | 7 ++++--- cloud_storage/cloud_storage/webdav/locks.py | 7 ++++--- cloud_storage/cloud_storage/webdav/provider.py | 17 +++++++++-------- cloud_storage/cloud_storage/webdav/renderer.py | 6 +++--- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/cloud_storage/cloud_storage/webdav/buffers.py b/cloud_storage/cloud_storage/webdav/buffers.py index aae0265..e097df8 100644 --- a/cloud_storage/cloud_storage/webdav/buffers.py +++ b/cloud_storage/cloud_storage/webdav/buffers.py @@ -4,6 +4,7 @@ import io import mimetypes +from typing import Any import frappe from frappe.core.doctype.file.utils import get_content_hash @@ -26,7 +27,7 @@ def __init__(self, file_name: str, frappe_folder: str, content_type: str | None) self._frappe_folder = frappe_folder self._content_type = content_type - def write(self, data: bytes) -> int: + def write(self, data: Any) -> int: return self._buf.write(data) def close(self) -> None: @@ -79,7 +80,7 @@ def __init__(self, doc_name: str) -> None: self._buf = io.BytesIO() self._doc_name = doc_name - def write(self, data: bytes) -> int: + def write(self, data: Any) -> int: return self._buf.write(data) def close(self) -> None: @@ -129,7 +130,7 @@ def __init__(self, path: str) -> None: self._buf = io.BytesIO() self._path = path - def write(self, data: bytes) -> int: + def write(self, data: Any) -> int: return self._buf.write(data) def close(self) -> None: diff --git a/cloud_storage/cloud_storage/webdav/locks.py b/cloud_storage/cloud_storage/webdav/locks.py index dbedc58..a107eb2 100644 --- a/cloud_storage/cloud_storage/webdav/locks.py +++ b/cloud_storage/cloud_storage/webdav/locks.py @@ -66,8 +66,9 @@ def __repr__(self): return f"RedisLockStorage(namespace={_NAMESPACE!r})" def open(self) -> None: - assert self._dict is None - self._dict = _RedisDict(_NAMESPACE) + assert self._dict is None # type: ignore[attr-defined,has-type] + self._dict = _RedisDict(_NAMESPACE) # type: ignore[attr-defined] def close(self) -> None: - self._dict = None # don't wipe Redis — other workers may still be using it + # don't wipe Redis — other workers may still be using it + self._dict = None # type: ignore[assignment] diff --git a/cloud_storage/cloud_storage/webdav/provider.py b/cloud_storage/cloud_storage/webdav/provider.py index ffc8f4e..ee2462f 100644 --- a/cloud_storage/cloud_storage/webdav/provider.py +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -5,6 +5,7 @@ import io import mimetypes import uuid +from typing import Any from urllib.parse import unquote import frappe @@ -67,7 +68,7 @@ def _detach_local_file_before_delete(file_doc) -> None: file_doc.db_set("file_url", None) -def _backup_s3_object(key: str) -> tuple[object, str, str] | None: +def _backup_s3_object(key: str | None) -> tuple[Any, str, str] | None: if not key or not _is_cloud_storage_enabled(): return None client = get_cloud_storage_client() @@ -80,7 +81,7 @@ def _backup_s3_object(key: str) -> tuple[object, str, str] | None: return client, key, backup_key -def _delete_s3_backup(backup: tuple[object, str, str] | None) -> None: +def _delete_s3_backup(backup: tuple[Any, str, str] | None) -> None: if not backup: return client, _, backup_key = backup @@ -90,7 +91,7 @@ def _delete_s3_backup(backup: tuple[object, str, str] | None) -> None: _logger.warning(f"failed to remove WebDAV overwrite backup {backup_key!r}") -def _restore_s3_backup(backup: tuple[object, str, str] | None) -> None: +def _restore_s3_backup(backup: tuple[Any, str, str] | None) -> None: if not backup: return client, original_key, backup_key = backup @@ -137,9 +138,9 @@ class FrappeCollection(DAVCollection): def __init__(self, path: str, environ: dict, frappe_folder: str) -> None: super().__init__(path, environ) self.frappe_folder = frappe_folder - self._meta: dict | None = None + self._meta: Any | None = None - def _get_meta(self) -> dict | None: + def _get_meta(self) -> Any | None: if self._meta is None: parent = frappe_folder_parent(self.frappe_folder) if not parent: @@ -427,7 +428,7 @@ def support_content_length(self) -> bool: def support_modified(self) -> bool: return False - def get_content(self) -> io.RawIOBase: + def get_content(self) -> Any: return io.BytesIO(self._content()) def begin_write(self, content_type: str | None = None): @@ -503,7 +504,7 @@ def copy_move_single(self, dest_path: str, *, is_move: bool): class FrappeFile(DAVNonCollection): """A Frappe File record exposed as a WebDAV resource.""" - def __init__(self, path: str, environ: dict, file_doc: dict) -> None: + def __init__(self, path: str, environ: dict, file_doc: Any) -> None: super().__init__(path, environ) self.file_doc = file_doc @@ -542,7 +543,7 @@ def support_modified(self) -> bool: def support_ranges(self) -> bool: return False - def get_content(self) -> io.RawIOBase: + def get_content(self) -> Any: if self.file_doc.s3_key: client = get_cloud_storage_client() response = client.get_object(Bucket=client.bucket, Key=self.file_doc.s3_key) diff --git a/cloud_storage/cloud_storage/webdav/renderer.py b/cloud_storage/cloud_storage/webdav/renderer.py index f31a6fc..2403c3c 100644 --- a/cloud_storage/cloud_storage/webdav/renderer.py +++ b/cloud_storage/cloud_storage/webdav/renderer.py @@ -197,8 +197,8 @@ def _invoke_webdav(request) -> Response: environ["CONTENT_LENGTH"] = str(len(body)) environ.pop("HTTP_TRANSFER_ENCODING", None) - status_holder = [] - headers_holder = [] + status_holder: list[str] = [] + headers_holder: list[tuple[str, str]] = [] def start_response(status, headers, exc_info=None): status_holder.append(status) @@ -213,7 +213,7 @@ def start_response(status, headers, exc_info=None): body_iter = app(environ, start_response) out = b"".join(body_iter) finally: - if hasattr(body_iter, "close"): + if body_iter is not None and hasattr(body_iter, "close"): body_iter.close() status_code = int(status_holder[0].split(" ", 1)[0]) if status_holder else 500 From a5b6effe61777719d08a1b8eedd7eaaf6dc6bf22 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Fri, 5 Jun 2026 11:55:06 +0000 Subject: [PATCH 23/23] chore: wsgidav --- poetry.lock | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5a28e3a..30a2608 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,69 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "bcrypt" +version = "4.3.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"}, + {file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"}, + {file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"}, + {file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"}, + {file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"}, + {file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"}, + {file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"}, + {file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] [[package]] name = "boto3" @@ -354,6 +419,18 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "exceptiongroup" version = "1.2.1" @@ -424,6 +501,18 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "json5" +version = "0.14.0" +description = "A Python implementation of the JSON5 data format." +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a"}, + {file = "json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb"}, +] + [[package]] name = "markupsafe" version = "2.1.5" @@ -553,6 +642,24 @@ files = [ {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + [[package]] name = "pluggy" version = "1.5.0" @@ -782,10 +889,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.33.2,<2.0a.0" +botocore = ">=1.33.2,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] +crt = ["botocore[crt] (>=1.33.2,<2.0a0)"] [[package]] name = "six" @@ -848,6 +955,29 @@ MarkupSafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "wsgidav" +version = "4.3.4" +description = "Generic and extendable WebDAV server based on WSGI" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "wsgidav-4.3.4-py3-none-any.whl", hash = "sha256:7c3d0614d9603ad33066b02254f0597922497aaa21823e1e3f77ba88fb11d529"}, + {file = "wsgidav-4.3.4.tar.gz", hash = "sha256:96c87b43599b3a6f19f5b57e4596f6b5c544351cb5cec28311badf2f537a557f"}, +] + +[package.dependencies] +bcrypt = ">=4.0,<5" +defusedxml = "*" +Jinja2 = "*" +json5 = "*" +passlib = "*" +PyYAML = "*" + +[package.extras] +pam = ["python-pam"] + [[package]] name = "xmltodict" version = "0.13.0" @@ -863,4 +993,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "6c9e93e5c41f975319744ae64ea0be32f166834b5232b417fcac44bb4e31c30d" +content-hash = "64ad2173c52b94935611fadea950416e845f735c4b8bf2af6470c1983d4ccdc9"