Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cloud_storage/cloud_storage/webdav/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright (c) 2026, AgriTheory and contributors
# For license information, please see license.txt
139 changes: 139 additions & 0 deletions cloud_storage/cloud_storage/webdav/buffers.py
Original file line number Diff line number Diff line change
@@ -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())
74 changes: 74 additions & 0 deletions cloud_storage/cloud_storage/webdav/locks.py
Original file line number Diff line number Diff line change
@@ -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]
30 changes: 30 additions & 0 deletions cloud_storage/cloud_storage/webdav/memory.py
Original file line number Diff line number Diff line change
@@ -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
141 changes: 141 additions & 0 deletions cloud_storage/cloud_storage/webdav/paths.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading