Skip to content
Merged
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
16 changes: 15 additions & 1 deletion asgidav/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from .folder import Folder
from .member import Member
from .reqres import PropfindRequest, propfind
from .reqres import PropfindRequest, propfind, proppatch
from .resource import Resource

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -44,6 +44,7 @@ def extract_path_from_destination(destination: str) -> str:
"DELETE",
"OPTIONS",
"PROPFIND",
"PROPPATCH",
"COPY",
"MOVE",
"MKCOL",
Expand Down Expand Up @@ -105,6 +106,19 @@ async def handle_propfind(request: Request, path: str):
)
return NOT_FOUND

@app.api_route("/{path:path}", methods=["PROPPATCH"])
async def handle_proppatch(request: Request, path: str):
if member := await get_member(path):
resp = await proppatch(member, request, base_path)
return Response(
resp,
status_code=HTTPStatus.MULTI_STATUS,
media_type="application/xml; charset=utf-8",
headers=common_headers
| {"Content-Type": "application/xml; charset=utf-8"},
)
return NOT_FOUND

@app.head("/{path:path}")
async def head(request: Request, path: str):
if member := await get_member(path):
Expand Down
35 changes: 35 additions & 0 deletions asgidav/reqres.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,38 @@ async def propfind(

et.register_namespace("D", DAV_NS)
return et.tostring(root, encoding="unicode")


async def proppatch(
member: Member,
request: Request,
base_path: str,
) -> str:
root = et.Element(_tag("multistatus"), nsmap=NS_MAP)
response_elem = et.SubElement(root, _tag("response"))

href = et.SubElement(response_elem, _tag("href"))
href.text = quote(f"{base_path}{member.path}", safe="/")

propstat_elem = et.SubElement(response_elem, _tag("propstat"))
prop_elem = et.SubElement(propstat_elem, _tag("prop"))

try:
body = await request.body()
if body:
parsed = et.fromstring(body)
nodes = parsed.xpath(
".//*[local-name()='set' or local-name()='remove']/*[local-name()='prop']/*"
)
if isinstance(nodes, list):
for child in nodes:
if isinstance(child, Element) and isinstance(child.tag, str):
et.SubElement(prop_elem, child.tag)
except (et.XMLSyntaxError, TypeError, ValueError):
pass

status = et.SubElement(propstat_elem, _tag("status"))
status.text = "HTTP/1.1 200 OK"

et.register_namespace("D", DAV_NS)
return et.tostring(root, encoding="unicode")
9 changes: 8 additions & 1 deletion dcfs/app/webdav/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ async def _get_member(path: str, clients: Clients) -> Optional[Member]:
folders[client_name] = folder
return RootFolder(folders)

client_name, sub_path = split_global_path(path)
try:
client_name, sub_path = split_global_path(path)
except Exception:
return None

if client_name not in clients or client_name not in gfc:
return None

cache = gfc[client_name]

if not (root := cache.get("/")):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_asgidav/test_app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from asgidav.app import extract_path_from_destination, split_path


Expand Down Expand Up @@ -41,3 +42,31 @@ def test_extract_path_from_destination_encoded(self):
path = "/webdav/path%20with%20spaces/file.txt"
result = extract_path_from_destination(path)
assert result == "/webdav/path with spaces/file.txt"


class TestAppEndpoints:
@pytest.mark.asyncio
async def test_proppatch_endpoint_found(self, mocker):
from fastapi.testclient import TestClient
from asgidav.app import create_app
from .common import MockResource

mock_get_member = mocker.AsyncMock(return_value=MockResource("/test.txt"))
app = create_app(get_member=mock_get_member)
client = TestClient(app)

response = client.request("PROPPATCH", "/test.txt")
assert response.status_code == 207
assert "multistatus" in response.text

@pytest.mark.asyncio
async def test_proppatch_endpoint_not_found(self, mocker):
from fastapi.testclient import TestClient
from asgidav.app import create_app

mock_get_member = mocker.AsyncMock(return_value=None)
app = create_app(get_member=mock_get_member)
client = TestClient(app)

response = client.request("PROPPATCH", "/nonexistent.txt")
assert response.status_code == 404
24 changes: 23 additions & 1 deletion tests/test_asgidav/test_reqres.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from fastapi import Request

from asgidav.reqres import PropfindRequest, _propfind_response, _propstat, propfind
from asgidav.reqres import PropfindRequest, _propfind_response, _propstat, propfind, proppatch

from .common import MockFolder, MockResource

Expand Down Expand Up @@ -159,3 +159,25 @@ async def test_propfind_empty_members(self):

assert isinstance(result, str)
assert "multistatus" in result

@pytest.mark.asyncio
async def test_proppatch(self, mocker):
resource = MockResource("/test.txt")
mock_request = mocker.Mock(spec=Request)
mock_request.body = mocker.AsyncMock(
return_value=b"""<?xml version="1.0" encoding="utf-8" ?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
<Win32LastModifiedTime xmlns="DAV:">Wed, 01 Sep 2026 13:39:00 GMT</Win32LastModifiedTime>
</D:prop>
</D:set>
</D:propertyupdate>"""
)

result = await proppatch(resource, mock_request, "/webdav")

assert isinstance(result, str)
assert "multistatus" in result
assert "Win32LastModifiedTime" in result
assert "HTTP/1.1 200 OK" in result
Loading