diff --git a/cloud_storage/cloud_storage/webdav/__init__.py b/cloud_storage/cloud_storage/webdav/__init__.py new file mode 100644 index 0000000..ea2ab3f --- /dev/null +++ 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/buffers.py b/cloud_storage/cloud_storage/webdav/buffers.py new file mode 100644 index 0000000..e097df8 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/buffers.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# Write buffers used by wsgidav resources. + +import io +import mimetypes +from typing import Any + +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: Any) -> 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: Any) -> 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: Any) -> 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/locks.py b/cloud_storage/cloud_storage/webdav/locks.py new file mode 100644 index 0000000..a107eb2 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/locks.py @@ -0,0 +1,74 @@ +# 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 +from collections.abc import 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 # type: ignore[attr-defined,has-type] + self._dict = _RedisDict(_NAMESPACE) # type: ignore[attr-defined] + + def close(self) -> None: + # 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/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..e929fe9 --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/paths.py @@ -0,0 +1,141 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# WebDAV-specific S3 path strategy and upload/version pipeline. + +import mimetypes +from urllib.parse import quote + +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, + get_cloud_storage_client, +) + + +_WEBDAV_PREFIX = "webdav" + + +def _default_webdav_path(file: File, folder: str | None) -> str: + """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) + + +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 + + +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 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 new file mode 100644 index 0000000..ee2462f --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/provider.py @@ -0,0 +1,824 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt +# Maps Frappe File/folder doctypes to wsgidav DAV resources. + +import io +import mimetypes +import uuid +from typing import Any +from urllib.parse import unquote + +import frappe +from frappe.model.rename_doc import rename_doc +from wsgidav.dav_error import ( + DAVError, + HTTP_FORBIDDEN, + HTTP_METHOD_NOT_ALLOWED, + HTTP_NOT_FOUND, +) +from wsgidav.dav_provider import DAVCollection, DAVNonCollection, DAVProvider + +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 +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"} +_OS_FILES = frozenset({".DS_Store", "desktop.ini", "Thumbs.db", ".Trashes", ".Spotlight-V100"}) +_OS_PREFIXES = ("._",) +# 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-",) + + +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 _is_os_metadata(name) or name.startswith(_TEMP_PREFIXES) + + +_MOUNT_PREFIX = "/dav" +_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 _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 | None) -> tuple[Any, 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[Any, 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[Any, 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 + "/"): + return path[len(_MOUNT_PREFIX) :] + if path in (_MOUNT_PREFIX, _MOUNT_PREFIX + "/"): + return "/" + 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]: + parts = _split_dav_path(dest_path) + 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): + """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: Any | None = None + + def _get_meta(self) -> Any | None: + if self._meta is None: + parent = frappe_folder_parent(self.frappe_folder) + if not parent: + 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) -> float | None: + meta = self._get_meta() + return meta.creation.timestamp() if meta else None + + def get_last_modified(self) -> float | None: + 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]: + # 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}, + fields=["name", "file_name"], + ) + 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 os_file_store.contains(child_path): + return _MemoryFile(child_path, self.environ, name) + return None + + # 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_rows = frappe.get_all( + "File", + filters={"folder": self.frappe_folder, "file_name": name, "is_folder": 0}, + fields=_FILE_FIELDS, + limit=1, + ) + 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 + # 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): + 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: + folder = frappe.new_doc("File") + 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() + except frappe.PermissionError: + raise DAVError(HTTP_FORBIDDEN) + except frappe.ValidationError as e: + raise DAVError(HTTP_FORBIDDEN, str(e)) + + def delete(self): + 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) + if not _can(folder_name, "delete"): + raise DAVError(HTTP_FORBIDDEN) + 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 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) + 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 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 stores folder path in both `name` (PK) and display fields + # (`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", folder_name, "file_name", new_name) + if new_parent != parent: + frappe.db.set_value("File", folder_name, "folder", new_parent) + + if new_frappe_folder != folder_name: + rename_doc( + "File", + folder_name, + new_frappe_folder, + merge=False, + force=True, + show_alert=False, + validate=False, + ) + + # 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", + or_filters=[ + ["folder", "=", old], + ["folder", "like", f"{old}/%"], + ], + fields=["name", "folder"], + ) + _logger.debug(f"folder move reparenting {len(affected)} descendant(s)") + for f in affected: + 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 [] + 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: + return True + + +_FILE_FIELDS = [ + "name", + "file_name", + "folder", + "file_size", + "content_hash", + "modified", + "creation", + "s3_key", + "is_private", + "file_url", +] + + +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) or 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) -> Any: + return io.BytesIO(self._content()) + + def begin_write(self, content_type: str | None = None): + return _MemoryBuffer(self.path) + + 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(unquote(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.""" + + 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: 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): + raise DAVError(HTTP_FORBIDDEN) + + +class FrappeFile(DAVNonCollection): + """A Frappe File record exposed as a WebDAV resource.""" + + def __init__(self, path: str, environ: dict, file_doc: Any) -> None: + super().__init__(path, environ) + self.file_doc = file_doc + + 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 "") + return mime or "application/octet-stream" + + 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) -> float | None: + if self.file_doc.creation: + return self.file_doc.creation.timestamp() + return None + + def get_etag(self) -> str | None: + 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 True + + def support_modified(self) -> bool: + return True + + def support_ranges(self) -> bool: + return False + + 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) + return response["Body"] + + if not self.file_doc.file_size: + # Empty placeholder from Finder's CL=0 PUT — no content yet. + return io.BytesIO(b"") + + # 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: 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: + 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 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}" + ) + + # 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", + { + "folder": new_folder, + "file_name": new_name, + "is_folder": 0, + "name": ["!=", self.file_doc.name], + }, + ["name", "s3_key"], + 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) + + 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) + + # S3 rename order: copy → commit DB → delete old key. + # If commit fails, clean up the new source copy and restore overwritten S3. + client = None + new_key = None + savepoint = "webdav_file_move" + frappe.db.savepoint(savepoint) + try: + 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 + + if is_rename: + client = get_cloud_storage_client() + 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, + ) + file_doc.s3_key = new_key + file_doc.file_url = FILE_URL.format(path=new_key) + + file_doc.save() + frappe.db.commit() + 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}") + 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 _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 + 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) + + 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) + _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") + 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 [] + 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 + + def support_recursive_move(self, dest_path: str) -> bool: + return True + + +class FrappeDAVProvider(DAVProvider): + """Root WebDAV provider: maps URL paths to Frappe File doctypes.""" + + def get_resource_inst(self, path: str, environ: dict): + path = _strip_dav_prefix(path) + parts = _split_dav_path(path) + + if not parts: + return FrappeCollection("/", environ, "Home") + + # 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 + 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] + + if _is_os_metadata(last): + if os_file_store.contains(path): + return _MemoryFile(path, environ, last) + return None + + 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) + + file_rows = frappe.get_all( + "File", + filters={"folder": frappe_folder, "file_name": last, "is_folder": 0}, + fields=_FILE_FIELDS, + limit=1, + ) + if file_rows: + if not _can(file_rows[0].name, "read"): + return None + return FrappeFile(path, environ, file_rows[0]) + + return None + + def is_readonly(self) -> bool: + return False diff --git a/cloud_storage/cloud_storage/webdav/renderer.py b/cloud_storage/cloud_storage/webdav/renderer.py new file mode 100644 index 0000000..2403c3c --- /dev/null +++ b/cloud_storage/cloud_storage/webdav/renderer.py @@ -0,0 +1,228 @@ +# 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, 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/. +""" + +import io +import threading + +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 + + +_logger = frappe.logger("webdav", allow_site=False) + + +_WEBDAV_METHODS = { + "PROPFIND", + "MKCOL", + "MOVE", + "LOCK", + "UNLOCK", + "OPTIONS", + "PUT", + "DELETE", +} +_UNSUPPORTED_WEBDAV_METHODS = {"COPY", "PROPPATCH"} + +_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) + + +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. + + 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 in _UNSUPPORTED_WEBDAV_METHODS: + 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 + + # 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): + _logger.debug(f"{request.method} {request.path} → 401 (no auth)") + raise WebdavResponse(_auth_challenge()) + + resp = _invoke_webdav(request) + _logger.debug(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 _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 + if _webdav_app is not None: + return _webdav_app + with _webdav_app_lock: + if _webdav_app is not None: + return _webdav_app + config = { + # 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 + # require_authentication=False above means it's never invoked. + "accept_basic": True, + "accept_digest": False, + "default_to_digest": False, + }, + # Redis-backed so locks survive across gunicorn workers — see + # webdav/locks.py for the rationale. + "lock_storage": RedisLockStorage(), + "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) + # 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 + # 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: list[str] = [] + headers_holder: list[tuple[str, str]] = [] + + 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 = None + out = b"" + status_code = 500 + try: + body_iter = app(environ, start_response) + out = b"".join(body_iter) + finally: + 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 + 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 diff --git a/cloud_storage/hooks.py b/cloud_storage/hooks.py index 674381f..a400d42 100644 --- a/cloud_storage/hooks.py +++ b/cloud_storage/hooks.py @@ -194,3 +194,12 @@ 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"] + +# WebDAV (spike) +# -------------------------------- +page_renderer = ["cloud_storage.cloud_storage.webdav.renderer.WebdavRenderer"] +before_request = ["cloud_storage.cloud_storage.webdav.renderer.handle_webdav_methods"] 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 1c6d42a..c74492e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Cloud Storage Documentation @@ -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 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..87d0c31 --- /dev/null +++ b/docs/webdav.md @@ -0,0 +1,120 @@ + + +# 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 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. + +## 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 + +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 + +Create a WebDAV remote with rclone's interactive setup: + +```shell +rclone config +``` + +Suggested setup: + +```shell +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 +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 +``` + +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, +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. 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" diff --git a/pyproject.toml b/pyproject.toml index b92f4bc..efef191 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"