From d1faedc0117661c2ca9159372205b1784d4c386d Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 25 Apr 2026 18:20:55 +0000 Subject: [PATCH] WebDAV support! --- cista/app.py | 3 - cista/fileserver.py | 196 ++++++++++++++++++- tests/test_files_path_security.py | 2 +- tests/test_files_rest_api.py | 2 +- tests/test_files_static_streaming.py | 2 +- tests/test_files_webdav.py | 276 +++++++++++++++++++++++++++ 6 files changed, 472 insertions(+), 9 deletions(-) create mode 100644 tests/test_files_webdav.py diff --git a/cista/app.py b/cista/app.py index 08c04f0..01cc33f 100644 --- a/cista/app.py +++ b/cista/app.py @@ -37,9 +37,6 @@ app.router.ALLOWED_METHODS = ( "MOVE", "COPY", "PROPFIND", - "PROPPATCH", - "LOCK", - "UNLOCK", ) configure_main_logging() diff --git a/cista/fileserver.py b/cista/fileserver.py index 931e108..b270ecf 100644 --- a/cista/fileserver.py +++ b/cista/fileserver.py @@ -3,11 +3,13 @@ import mimetypes import os import re import shutil +import xml.etree.ElementTree as ET +from datetime import datetime, timezone from pathlib import Path, PurePosixPath -from urllib.parse import unquote +from urllib.parse import quote as url_quote, unquote, urlparse from wsgiref.handlers import format_date_time -from sanic import Blueprint, empty, json +from sanic import Blueprint, HTTPResponse, empty, json from sanic.exceptions import BadRequest, NotFound from cista import auth, config, watching @@ -20,6 +22,13 @@ _CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$") _RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$") _FILE_CHUNK_SIZE = 1 << 20 +_DAV_NS = "DAV:" +ET.register_namespace("D", _DAV_NS) + + +def _dav_tag(name: str) -> str: + return f"{{{_DAV_NS}}}{name}" + @bp.on_request async def verify_fileserver(request): @@ -108,7 +117,8 @@ async def create_folder(request, name): return empty(status=201) -@bp.post("/") +@bp.post("/", name="post_root", strict_slashes=False) +@bp.post("/", name="post_path") async def copy_or_move(request, name=""): provided_args = set(request.args.keys()) if not provided_args: @@ -263,6 +273,99 @@ async def head_file(request, name=""): return await _send_static_file(request, name, head_only=True) +@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False) +@bp.route("/", methods=["OPTIONS"], name="options_path") +async def dav_options(request, name=""): + return HTTPResponse( + status=200, + headers={ + "Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST", + "DAV": "1", + "MS-Author-Via": "DAV", + }, + ) + + +@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False) +@bp.route("/", methods=["PROPFIND"], name="propfind_path") +async def dav_propfind(request, name=""): + rel, path = _safe_relpath(name) + if not path.exists(): + raise NotFound(f"Not found: {name}") + depth = request.headers.get("depth", "1").strip() + if depth == "infinity": + return HTTPResponse(status=403) + entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth) + return HTTPResponse( + body=_build_propfind_xml(entries), + status=207, + content_type='application/xml; charset="utf-8"', + ) + + +@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False) +@bp.route("/", methods=["COPY"], name="copy_path") +async def dav_copy(request, name=""): + dest_header = request.headers.get("destination") + if not dest_header: + raise BadRequest("Missing Destination header") + overwrite = request.headers.get("overwrite", "T").strip().upper() != "F" + src_rel, src_abs = _safe_relpath(name) + dst_rel, dst_abs = _parse_webdav_destination(dest_header) + request.ctx._log_extra = f"→ {dst_rel}" + if not src_abs.exists(): + raise NotFound(f"Source not found: {name}") + if src_abs == dst_abs: + raise BadRequest("Source and destination are the same") + dst_existed = dst_abs.exists() + if dst_existed and not overwrite: + return HTTPResponse(status=412) + if not dst_abs.parent.is_dir(): + return HTTPResponse(status=409) + + def _do_copy(): + if dst_existed: + shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink() + if src_abs.is_dir(): + shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True) + else: + shutil.copy2(src_abs, dst_abs) + + await asyncio.to_thread(_do_copy) + watching.notify_change(dst_rel, *dst_rel.parents) + return HTTPResponse(status=201 if not dst_existed else 204) + + +@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False) +@bp.route("/", methods=["MOVE"], name="move_path") +async def dav_move(request, name=""): + dest_header = request.headers.get("destination") + if not dest_header: + raise BadRequest("Missing Destination header") + overwrite = request.headers.get("overwrite", "T").strip().upper() != "F" + src_rel, src_abs = _safe_relpath(name) + dst_rel, dst_abs = _parse_webdav_destination(dest_header) + request.ctx._log_extra = f"→ {dst_rel}" + if not src_abs.exists(): + raise NotFound(f"Source not found: {name}") + if src_abs == dst_abs: + return HTTPResponse(status=204) + dst_existed = dst_abs.exists() + if dst_existed and not overwrite: + return HTTPResponse(status=412) + if not dst_abs.parent.is_dir(): + return HTTPResponse(status=409) + + def _do_move(): + if dst_existed: + shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink() + shutil.move(src_abs, dst_abs) + + await asyncio.to_thread(_do_move) + watching.notify_change(src_rel, *src_rel.parents, dst_rel, *dst_rel.parents) + return HTTPResponse(status=201 if not dst_existed else 204) + + def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]: m = _CONTENT_RANGE_RE.fullmatch(header.strip()) if m is None: @@ -413,3 +516,90 @@ def _get_key_paths(wanted: set[str]) -> dict[str, PurePosixPath]: if len(ret) == len(wanted): break return ret + + +# --------------------------------------------------------------------------- +# WebDAV helpers +# --------------------------------------------------------------------------- + + +def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]: + """Parse a WebDAV Destination header and resolve it to a storage path.""" + parsed = urlparse(dest_header) + raw_path = parsed.path # still percent-encoded + prefix = "/files" + if raw_path in (prefix, prefix + "/"): + rel_str = "" + elif raw_path.startswith(prefix + "/"): + rel_str = raw_path[len(prefix) + 1:] + else: + raise BadRequest("Destination must be within /files") + return _safe_relpath(rel_str) + + +def _rel_to_href(rel: PurePosixPath, is_dir: bool) -> str: + """Build a DAV href from a storage-relative path.""" + parts = rel.parts + if not parts: + return "/files/" + encoded = "/".join(url_quote(p, safe="") for p in parts) + href = f"/files/{encoded}" + return href + "/" if is_dir else href + + +def _dav_xml(element: ET.Element) -> bytes: + """Serialise an ElementTree element to UTF-8 bytes with XML declaration.""" + return ( + b'' + + ET.tostring(element, encoding="unicode").encode("utf-8") + ) + + +def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]: + entries = [_propfind_entry(rel, path)] + if depth == "1" and path.is_dir(): + for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)): + child_rel = rel / child.name if rel.parts else PurePosixPath(child.name) + try: + entries.append(_propfind_entry(child_rel, child)) + except OSError: + pass + return entries + + +def _propfind_entry(rel: PurePosixPath, path: Path) -> dict: + st = path.stat() + is_dir = path.is_dir() + return { + "href": _rel_to_href(rel, is_dir), + "name": rel.parts[-1] if rel.parts else "", + "is_dir": is_dir, + "size": st.st_size, + "etag": f'"{st.st_mtime:.0f}-{st.st_size}"', + "content_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream", + "last_modified": format_date_time(st.st_mtime), + "created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + } + + +def _build_propfind_xml(entries: list[dict]) -> bytes: + multistatus = ET.Element(_dav_tag("multistatus")) + for e in entries: + response = ET.SubElement(multistatus, _dav_tag("response")) + ET.SubElement(response, _dav_tag("href")).text = e["href"] + propstat = ET.SubElement(response, _dav_tag("propstat")) + prop = ET.SubElement(propstat, _dav_tag("prop")) + rt = ET.SubElement(prop, _dav_tag("resourcetype")) + if e["is_dir"]: + ET.SubElement(rt, _dav_tag("collection")) + ET.SubElement(prop, _dav_tag("displayname")).text = e["name"] + ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"] + ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"] + if not e["is_dir"]: + ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"]) + ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"] + ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"] + ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK" + return _dav_xml(multistatus) diff --git a/tests/test_files_path_security.py b/tests/test_files_path_security.py index d3d551f..07af0a6 100644 --- a/tests/test_files_path_security.py +++ b/tests/test_files_path_security.py @@ -22,7 +22,7 @@ def setup_storage(tmp_path: Path): @pytest_asyncio.fixture() async def client(setup_storage: Path): app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True) - app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL") + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") app.blueprint(fileserver_bp) yield app.asgi_client diff --git a/tests/test_files_rest_api.py b/tests/test_files_rest_api.py index 02b0749..23aced4 100644 --- a/tests/test_files_rest_api.py +++ b/tests/test_files_rest_api.py @@ -22,7 +22,7 @@ def setup_storage(tmp_path: Path): @pytest_asyncio.fixture() async def client(setup_storage: Path): app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True) - app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL") + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") app.blueprint(fileserver_bp) yield app.asgi_client diff --git a/tests/test_files_static_streaming.py b/tests/test_files_static_streaming.py index f1954b3..9049931 100644 --- a/tests/test_files_static_streaming.py +++ b/tests/test_files_static_streaming.py @@ -21,7 +21,7 @@ def setup_storage(tmp_path: Path): @pytest_asyncio.fixture() async def client(setup_storage: Path): app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True) - app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL") + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") app.blueprint(fileserver_bp) yield app.asgi_client diff --git a/tests/test_files_webdav.py b/tests/test_files_webdav.py new file mode 100644 index 0000000..b3a5135 --- /dev/null +++ b/tests/test_files_webdav.py @@ -0,0 +1,276 @@ +"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK.""" +import xml.etree.ElementTree as ET +from pathlib import Path +from uuid import uuid4 + +import pytest +import pytest_asyncio +from sanic import Sanic + +from cista import config, watching +from cista.fileserver import bp as fileserver_bp +from cista.protocol import FileEntry + +_DAV_NS = "DAV:" +_METHODS = ("MKCOL", "MOVE", "COPY", "PROPFIND") + + +@pytest.fixture() +def setup_storage(tmp_path: Path): + config.config = config.Config(path=tmp_path, listen=":0", public=True) + watching.state.root = [] + watching.rootpath = tmp_path + yield tmp_path + watching.state.root = [] + + +@pytest_asyncio.fixture() +async def client(setup_storage: Path): + app = Sanic(f"files-dav-test-{uuid4().hex}", strict_slashes=True) + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, *_METHODS) + app.blueprint(fileserver_bp) + yield app.asgi_client + + +def _dav(tag: str) -> str: + return f"{{{_DAV_NS}}}{tag}" + + +def _parse_multistatus(body: bytes) -> list[ET.Element]: + root = ET.fromstring(body) + assert root.tag == _dav("multistatus") + return root.findall(_dav("response")) + + +# --------------------------------------------------------------------------- +# OPTIONS +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_options_advertises_dav_class(client): + _, res = await client.options("/files/") + + assert res.status_code == 200 + assert "1" in res.headers.get("dav", "") + assert "PROPFIND" in res.headers.get("allow", "") + assert "COPY" in res.headers.get("allow", "") + assert "MOVE" in res.headers.get("allow", "") + + +@pytest.mark.asyncio +async def test_options_without_trailing_slash(client): + """WebDAV clients (e.g. Windows) send OPTIONS /files without trailing slash.""" + _, res = await client.options("/files") + + assert res.status_code == 200 + assert "1" in res.headers.get("dav", "") + + +# --------------------------------------------------------------------------- +# PROPFIND +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_propfind_root_depth0(client, setup_storage: Path): + _, res = await client.request("PROPFIND", "/files/", headers={"Depth": "0"}) + + assert res.status_code == 207 + responses = _parse_multistatus(res.body) + assert len(responses) == 1 + href = responses[0].findtext(_dav("href")) + assert href == "/files/" + rt = responses[0].find(f".//{_dav('resourcetype')}/{_dav('collection')}") + assert rt is not None, "Root should be a collection" + + +@pytest.mark.asyncio +async def test_propfind_root_depth1_lists_children(client, setup_storage: Path): + (setup_storage / "alpha.txt").write_text("a", encoding="utf-8") + (setup_storage / "beta").mkdir() + + _, res = await client.request("PROPFIND", "/files/", headers={"Depth": "1"}) + + assert res.status_code == 207 + responses = _parse_multistatus(res.body) + hrefs = [r.findtext(_dav("href")) for r in responses] + assert "/files/" in hrefs + assert "/files/alpha.txt" in hrefs + assert "/files/beta/" in hrefs + + +@pytest.mark.asyncio +async def test_propfind_file_has_content_length(client, setup_storage: Path): + (setup_storage / "data.txt").write_text("hello", encoding="utf-8") + + _, res = await client.request("PROPFIND", "/files/data.txt", headers={"Depth": "0"}) + + assert res.status_code == 207 + responses = _parse_multistatus(res.body) + cl = responses[0].findtext(f".//{_dav('getcontentlength')}") + assert cl == "5" + + +@pytest.mark.asyncio +async def test_propfind_depth_infinity_rejected(client, setup_storage: Path): + _, res = await client.request( + "PROPFIND", "/files/", headers={"Depth": "infinity"} + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_propfind_missing_resource_returns_404(client): + _, res = await client.request("PROPFIND", "/files/no-such-file.txt") + assert res.status_code == 404 + + +# --------------------------------------------------------------------------- +# COPY +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_copy_file_to_new_path(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("copy me", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={"Destination": "http://localhost/files/dst.txt"}, + ) + + assert res.status_code == 201 + assert (setup_storage / "src.txt").is_file() + assert (setup_storage / "dst.txt").read_text() == "copy me" + + +@pytest.mark.asyncio +async def test_copy_overwrites_existing_by_default(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("new", encoding="utf-8") + (setup_storage / "dst.txt").write_text("old", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={"Destination": "http://localhost/files/dst.txt"}, + ) + + assert res.status_code == 204 + assert (setup_storage / "dst.txt").read_text() == "new" + + +@pytest.mark.asyncio +async def test_copy_overwrite_false_returns_412(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("x", encoding="utf-8") + (setup_storage / "dst.txt").write_text("y", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={ + "Destination": "http://localhost/files/dst.txt", + "Overwrite": "F", + }, + ) + + assert res.status_code == 412 + assert (setup_storage / "dst.txt").read_text() == "y" + + +@pytest.mark.asyncio +async def test_copy_directory_recursively(client, setup_storage: Path): + (setup_storage / "src").mkdir() + (setup_storage / "src" / "child.txt").write_text("child", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src", + headers={"Destination": "http://localhost/files/dst"}, + ) + + assert res.status_code == 201 + assert (setup_storage / "dst" / "child.txt").read_text() == "child" + assert (setup_storage / "src" / "child.txt").is_file() + + +@pytest.mark.asyncio +async def test_copy_missing_parent_returns_409(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("x", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={"Destination": "http://localhost/files/nodir/dst.txt"}, + ) + + assert res.status_code == 409 + + +# --------------------------------------------------------------------------- +# MOVE +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_move_renames_file(client, setup_storage: Path): + (setup_storage / "old.txt").write_text("data", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/old.txt", + headers={"Destination": "http://localhost/files/new.txt"}, + ) + + assert res.status_code == 201 + assert not (setup_storage / "old.txt").exists() + assert (setup_storage / "new.txt").read_text() == "data" + + +@pytest.mark.asyncio +async def test_move_overwrites_existing(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("src", encoding="utf-8") + (setup_storage / "dst.txt").write_text("dst", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/src.txt", + headers={"Destination": "http://localhost/files/dst.txt"}, + ) + + assert res.status_code == 204 + assert not (setup_storage / "src.txt").exists() + assert (setup_storage / "dst.txt").read_text() == "src" + + +@pytest.mark.asyncio +async def test_move_overwrite_false_returns_412(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("src", encoding="utf-8") + (setup_storage / "dst.txt").write_text("dst", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/src.txt", + headers={ + "Destination": "http://localhost/files/dst.txt", + "Overwrite": "F", + }, + ) + + assert res.status_code == 412 + assert (setup_storage / "src.txt").is_file() + + +@pytest.mark.asyncio +async def test_move_same_source_and_dest_is_noop(client, setup_storage: Path): + (setup_storage / "file.txt").write_text("x", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/file.txt", + headers={"Destination": "http://localhost/files/file.txt"}, + ) + + assert res.status_code == 204 + assert (setup_storage / "file.txt").is_file()