diff --git a/asgidav/app.py b/asgidav/app.py index a76221a..50a00d4 100644 --- a/asgidav/app.py +++ b/asgidav/app.py @@ -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__) @@ -44,6 +44,7 @@ def extract_path_from_destination(destination: str) -> str: "DELETE", "OPTIONS", "PROPFIND", + "PROPPATCH", "COPY", "MOVE", "MKCOL", @@ -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): diff --git a/asgidav/reqres.py b/asgidav/reqres.py index cd9f26e..ab4a739 100644 --- a/asgidav/reqres.py +++ b/asgidav/reqres.py @@ -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") diff --git a/dcfs/app/webdav/__init__.py b/dcfs/app/webdav/__init__.py index 0027e48..df3f40e 100644 --- a/dcfs/app/webdav/__init__.py +++ b/dcfs/app/webdav/__init__.py @@ -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("/")): diff --git a/tests/test_asgidav/test_app.py b/tests/test_asgidav/test_app.py index 2c7ab19..eff98ad 100644 --- a/tests/test_asgidav/test_app.py +++ b/tests/test_asgidav/test_app.py @@ -1,3 +1,4 @@ +import pytest from asgidav.app import extract_path_from_destination, split_path @@ -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 diff --git a/tests/test_asgidav/test_reqres.py b/tests/test_asgidav/test_reqres.py index 653dbc9..a60d64d 100644 --- a/tests/test_asgidav/test_reqres.py +++ b/tests/test_asgidav/test_reqres.py @@ -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 @@ -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""" + + + + Wed, 01 Sep 2026 13:39:00 GMT + + + """ + ) + + 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