diff --git a/backend/endpoints/cloud_sync.py b/backend/endpoints/cloud_sync.py
index 78915361a2..2fa779909a 100644
--- a/backend/endpoints/cloud_sync.py
+++ b/backend/endpoints/cloud_sync.py
@@ -11,11 +11,13 @@
"""
import os
+import uuid
+from urllib.parse import quote
from fastapi import APIRouter, Request, Response, status
-from fastapi.responses import JSONResponse
+from fastapi.responses import JSONResponse, RedirectResponse
-from handler import cloud_sync_handler
+from handler import cloud_sync_handler, webdav_browser
from handler.auth.constants import Scope
from handler.auth.dependencies import get_permissions
from handler.cloud_sync_handler import MANIFEST_FILE_NAME, AssetKind, CloudSyncPath
@@ -33,7 +35,7 @@
router = APIRouter(prefix="/cloud-sync", tags=["cloud-sync"])
-ALLOWED_METHODS = "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE"
+ALLOWED_METHODS = "OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, LOCK, UNLOCK"
def _empty(status_code: int, headers: dict[str, str] | None = None) -> Response:
@@ -104,7 +106,237 @@ def cloud_sync_options(request: Request, file_path: str) -> Response:
return _empty(
status.HTTP_200_OK,
- {"DAV": "1", "Allow": ALLOWED_METHODS, "MS-Author-Via": "DAV"},
+ # Class 2 (locking) is advertised alongside the fake LOCK/UNLOCK
+ # below -- some WebDAV clients (iOS Files among them, by report)
+ # refuse to treat a server as mountable at all without it, even for
+ # read-only browsing.
+ {"DAV": "1, 2", "Allow": ALLOWED_METHODS, "MS-Author-Via": "DAV"},
+ )
+
+
+@router.api_route("/{file_path:path}", methods=["LOCK"], include_in_schema=False)
+def cloud_sync_lock(request: Request, file_path: str) -> Response:
+ """Fake, always-succeeds locking. Nothing here is actually lockable --
+ RetroArch's own Cloud Sync client never sends LOCK, and this WebDAV
+ surface has no concept of concurrent writers to guard against -- but
+ some WebDAV clients (iOS Files among them, by report) won't complete
+ "Connect to Server" without a server that at least answers LOCK/UNLOCK,
+ so this exists purely for that compatibility handshake."""
+ denied = _authorize(request, Scope.ASSETS_READ)
+ if denied:
+ return denied
+
+ token = f"opaquelocktoken:{uuid.uuid4()}"
+ body = (
+ ''
+ ''
+ ""
+ ""
+ "0"
+ "Second-3600"
+ f"{token}"
+ ""
+ )
+ return Response(
+ content=body,
+ media_type="text/xml; charset=utf-8",
+ headers={"Lock-Token": f"<{token}>"},
+ )
+
+
+@router.api_route("/{file_path:path}", methods=["UNLOCK"], include_in_schema=False)
+def cloud_sync_unlock(request: Request, file_path: str) -> Response:
+ denied = _authorize(request, Scope.ASSETS_READ)
+ if denied:
+ return denied
+
+ return _empty(status.HTTP_204_NO_CONTENT)
+
+
+@router.api_route("/{file_path:path}", methods=["PROPFIND"], include_in_schema=False)
+async def cloud_sync_propfind(request: Request, file_path: str) -> Response:
+ """Read-only directory browsing for `roms/` (RomM's own library) and
+ `saves/`/`states/` (the current cloud-sync manifest), so a real WebDAV
+ client (iOS Files' "Connect to Server", Cyberduck, ...) can mount this
+ same URL and browse it like a normal file share.
+
+ RetroArch's own Cloud Sync client never issues PROPFIND -- verified
+ against its source -- so none of this is on RetroArch's actual sync
+ path; it exists solely for read-only human browsing. Unlike the
+ retroarch-webdav-romm shim this mirrors, saves/states browsing here
+ only shows the manifest's *current* entries, not every historical
+ revision -- RomM's own web UI is the place to browse save history.
+ """
+ denied = _authorize(request, Scope.ASSETS_READ)
+ if denied:
+ return denied
+
+ depth = 0 if request.headers.get("depth") == "0" else 1
+ permissions = get_permissions(request)
+ parts = [p for p in file_path.strip("/").split("/") if p]
+
+ entries: list[webdav_browser.PropfindEntry] | None
+ if not parts:
+ entries = [_root_entry()]
+ if depth != 0:
+ entries += [
+ _roms_root_entry(),
+ _virtual_root_entry("saves"),
+ _virtual_root_entry("states"),
+ ]
+ elif parts == ["roms"]:
+ entries = [_roms_root_entry()]
+ if depth != 0:
+ platforms = webdav_browser.list_platforms(permissions.can_see_platform)
+ entries += [
+ webdav_browser.PropfindEntry(
+ href=f"roms/{p.fs_slug}/", is_collection=True, display_name=p.name
+ )
+ for p in platforms
+ ]
+ elif len(parts) == 2 and parts[0] == "roms":
+ entries = _platform_listing(parts[1], depth, permissions)
+ elif len(parts) == 3 and parts[0] == "roms":
+ entries = _rom_file_entry(parts[1], parts[2], permissions)
+ elif parts[0] in ("saves", "states"):
+ entries = await _save_state_listing(parts, depth, request.user, permissions)
+ else:
+ entries = None
+
+ if entries is None:
+ return _empty(status.HTTP_404_NOT_FOUND)
+
+ body = webdav_browser.build_multistatus(entries)
+ return Response(
+ content=body,
+ status_code=207,
+ # iOS Files' WebDAV client is known to be picky about this --
+ # "text/xml" (the traditional WebDAV content type) is the safer bet
+ # over "application/xml", which some Apple WebDAV client versions
+ # have reportedly failed to parse.
+ media_type="text/xml; charset=utf-8",
+ )
+
+
+def _root_entry() -> "webdav_browser.PropfindEntry":
+ return webdav_browser.PropfindEntry(href="", is_collection=True, display_name="")
+
+
+def _roms_root_entry() -> "webdav_browser.PropfindEntry":
+ return webdav_browser.PropfindEntry(
+ href="roms/", is_collection=True, display_name="roms"
+ )
+
+
+def _virtual_root_entry(name: str) -> "webdav_browser.PropfindEntry":
+ return webdav_browser.PropfindEntry(
+ href=f"{name}/", is_collection=True, display_name=name
+ )
+
+
+def _platform_listing(
+ slug: str, depth: int, permissions
+) -> list["webdav_browser.PropfindEntry"] | None:
+ platforms = webdav_browser.list_platforms(permissions.can_see_platform)
+ platform = next((p for p in platforms if p.fs_slug == slug), None)
+ if not platform:
+ return None
+
+ self_entry = webdav_browser.PropfindEntry(
+ href=f"roms/{slug}/", is_collection=True, display_name=platform.name
+ )
+ if depth == 0:
+ return [self_entry]
+
+ files = (
+ webdav_browser.list_rom_files(
+ slug, lambda rom: permissions.can_see_rom(rom.id, rom.platform_id)
+ )
+ or []
+ )
+ return [self_entry] + [
+ webdav_browser.PropfindEntry(
+ href=f"roms/{slug}/{f.display_name}",
+ is_collection=False,
+ display_name=f.display_name,
+ content_length=f.size_bytes,
+ last_modified=f.updated_at,
+ )
+ for f in files
+ ]
+
+
+def _rom_file_entry(
+ slug: str, file_name: str, permissions
+) -> list["webdav_browser.PropfindEntry"] | None:
+ file = webdav_browser.find_rom_file(
+ slug, file_name, lambda rom: permissions.can_see_rom(rom.id, rom.platform_id)
+ )
+ if not file:
+ return None
+
+ return [
+ webdav_browser.PropfindEntry(
+ href=f"roms/{slug}/{file_name}",
+ is_collection=False,
+ display_name=file_name,
+ content_length=file.size_bytes,
+ last_modified=file.updated_at,
+ )
+ ]
+
+
+async def _save_state_listing(
+ parts: list[str], depth: int, user: User, permissions
+) -> list["webdav_browser.PropfindEntry"] | None:
+ manifest = await cloud_sync_handler.build_manifest(
+ user, lambda rom: permissions.can_see_rom(rom.id, rom.platform_id)
+ )
+ clean = "/".join(parts)
+
+ exact = next((e for e in manifest if e["path"] == clean), None) if len(parts) > 1 else None
+ if exact:
+ return [_manifest_file_entry(exact)]
+
+ prefix = f"{clean}/"
+ has_children = any(e["path"].startswith(prefix) for e in manifest)
+ if len(parts) > 1 and not has_children:
+ return None
+
+ self_entry = webdav_browser.PropfindEntry(
+ href=prefix, is_collection=True, display_name=parts[-1]
+ )
+ if depth == 0:
+ return [self_entry]
+
+ child_folders: set[str] = set()
+ child_files = []
+ for entry in manifest:
+ if not entry["path"].startswith(prefix):
+ continue
+ rest = entry["path"][len(prefix) :]
+ if "/" in rest:
+ child_folders.add(rest.split("/", 1)[0])
+ else:
+ child_files.append(entry)
+
+ return (
+ [self_entry]
+ + [
+ webdav_browser.PropfindEntry(
+ href=f"{prefix}{folder}/", is_collection=True, display_name=folder
+ )
+ for folder in sorted(child_folders)
+ ]
+ + [_manifest_file_entry(entry) for entry in child_files]
+ )
+
+
+def _manifest_file_entry(entry: dict[str, str]) -> "webdav_browser.PropfindEntry":
+ return webdav_browser.PropfindEntry(
+ href=entry["path"],
+ is_collection=False,
+ display_name=entry["path"].rsplit("/", 1)[-1],
)
@@ -139,6 +371,28 @@ async def cloud_sync_get(request: Request, file_path: str) -> Response:
resolved_path, filename=os.path.basename(blob_path)
)
+ rom_parts = [p for p in file_path.strip("/").split("/") if p]
+ if len(rom_parts) == 3 and rom_parts[0] == "roms":
+ permissions = get_permissions(request)
+ file = webdav_browser.find_rom_file(
+ rom_parts[1],
+ rom_parts[2],
+ lambda rom: permissions.can_see_rom(rom.id, rom.platform_id),
+ )
+ if not file:
+ return _empty(status.HTTP_404_NOT_FOUND)
+
+ # RomM's own content endpoint already handles Range requests, the
+ # multi-file zip cache and (in production) nginx X-Accel-Redirect --
+ # duplicating that here would either miss the X-Accel-Redirect step
+ # (nothing would actually stream in production) or reimplement it
+ # badly. Basic Auth carries over on the redirect, so this stays a
+ # single unauthenticated-looking hop from the client's perspective.
+ return RedirectResponse(
+ url=f"/api/roms/{file.rom_id}/content/{quote(file.display_name)}",
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
+ )
+
parsed = cloud_sync_handler.parse_cloud_sync_path(file_path)
if not parsed:
return _empty(status.HTTP_404_NOT_FOUND)
diff --git a/backend/handler/webdav_browser.py b/backend/handler/webdav_browser.py
new file mode 100644
index 0000000000..d198f50c35
--- /dev/null
+++ b/backend/handler/webdav_browser.py
@@ -0,0 +1,165 @@
+"""Read-only WebDAV browsing (PROPFIND) for RomM's rom library, layered onto
+the same `/api/cloud-sync` WebDAV surface RetroArch's Cloud Sync uses.
+
+RetroArch's own Cloud Sync client never issues PROPFIND -- verified against
+its source, and already noted in `cloud_sync.py` -- so none of this is on
+RetroArch's actual sync path. It exists purely so a real WebDAV client (e.g.
+iOS Files app's "Connect to Server", Cyberduck, ...) can mount the same URL
+and browse/download the library as plain files, mirroring the
+retroarch-webdav-romm shim's `romBrowser.ts` + `webdavXml.ts`.
+
+Read-only by design: there is no PUT/DELETE support for `roms/`, only
+GET/HEAD/PROPFIND.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from datetime import datetime
+from xml.sax.saxutils import escape as xml_escape
+
+from handler.database import db_platform_handler, db_rom_handler
+from models.platform import Platform
+from models.rom import Rom
+
+
+@dataclass(frozen=True)
+class PropfindEntry:
+ """One `` entry. `href` is relative to the WebDAV root, e.g.
+ `roms/` or `roms/psx/Game.zip` -- never URL-encoded here, that's
+ `_href_escape`'s job at render time."""
+
+ href: str
+ is_collection: bool
+ display_name: str
+ content_length: int | None = None
+ last_modified: datetime | None = None
+
+
+@dataclass(frozen=True)
+class RomFile:
+ """A rom as it appears over WebDAV -- possibly a synthesized zip name
+ for a multi-file rom, never the raw per-part file names."""
+
+ rom_id: int
+ display_name: str
+ size_bytes: int
+ updated_at: datetime
+ file_ids: list[int] = field(default_factory=list)
+
+
+def _href_escape(path: str) -> str:
+ return "/" + "/".join(segment for segment in path.split("/"))
+
+
+def _response_xml(entry: PropfindEntry) -> str:
+ resource_type = "" if entry.is_collection else ""
+ extra = (
+ ""
+ if entry.is_collection
+ else (
+ f"{entry.content_length or 0}"
+ "application/octet-stream"
+ )
+ )
+ last_modified = (
+ f"{entry.last_modified.strftime('%a, %d %b %Y %H:%M:%S GMT')}"
+ if entry.last_modified
+ else ""
+ )
+
+ return (
+ ""
+ f"{xml_escape(_href_escape(entry.href))}"
+ ""
+ f"{resource_type}"
+ f"{xml_escape(entry.display_name)}"
+ f"{extra}{last_modified}"
+ "HTTP/1.1 200 OK"
+ ""
+ )
+
+
+def build_multistatus(entries: list[PropfindEntry]) -> str:
+ body = "".join(_response_xml(entry) for entry in entries)
+ return (
+ ''
+ '' + body + ""
+ )
+
+
+def _display_name(rom: Rom) -> str:
+ """RomM zips up genuinely multi-file roms (multi-disc/multi-track games)
+ on download and includes an .m3u -- the WebDAV listing should show that
+ reality (a .zip) rather than the original fs_name. `has_nested_single_file`
+ (one real file sitting a folder deep) still downloads as the raw file, not
+ a zip -- only `has_multiple_files` actually triggers zipping server-side.
+
+ For the nested-single-file case, `fs_name` is the *folder* name with no
+ extension; the real filename (with extension) is on `files[0].file_name`.
+ """
+ if rom.has_multiple_files:
+ return f"{rom.fs_name_no_ext}.zip"
+ files = sorted(rom.files, key=lambda f: f.file_name)
+ return files[0].file_name if files else rom.fs_name
+
+
+def list_platforms(
+ can_see_platform: Callable[[int], bool],
+) -> list[Platform]:
+ platforms = db_platform_handler.get_platforms()
+ return [p for p in platforms if p.rom_count > 0 and can_see_platform(p.id)]
+
+
+def _visible_roms_for_platform(
+ platform_fs_slug: str, can_see_rom: Callable[[Rom], bool]
+) -> tuple[Platform, list[Rom]] | None:
+ platform = db_platform_handler.get_platform_by_fs_slug(platform_fs_slug)
+ if not platform:
+ return None
+
+ # `get_roms_scalar` doesn't eager-load `files`/`multi_file`/
+ # `top_level_file_count` -- filtering visibility only needs `id` and
+ # `platform_id`, cheap on the plain query, but `_display_name` below
+ # needs those eager-loaded columns, so the visible ids are re-fetched
+ # via `get_roms_by_ids` (which does eager-load them) rather than risking
+ # a `DetachedInstanceError` on first access outside this session.
+ candidate_ids = [
+ rom.id
+ for rom in db_rom_handler.get_roms_scalar(platform_ids=[platform.id])
+ if can_see_rom(rom)
+ ]
+ roms = db_rom_handler.get_roms_by_ids(candidate_ids)
+ return platform, list(roms)
+
+
+def list_rom_files(
+ platform_fs_slug: str, can_see_rom: Callable[[Rom], bool]
+) -> list[RomFile] | None:
+ """None means the platform itself doesn't exist/isn't visible; an empty
+ list means it exists but has nothing the caller can see."""
+ resolved = _visible_roms_for_platform(platform_fs_slug, can_see_rom)
+ if resolved is None:
+ return None
+
+ _platform, roms = resolved
+ return [
+ RomFile(
+ rom_id=rom.id,
+ display_name=_display_name(rom),
+ size_bytes=rom.fs_size_bytes,
+ updated_at=rom.updated_at,
+ file_ids=[f.id for f in rom.files],
+ )
+ for rom in roms
+ ]
+
+
+def find_rom_file(
+ platform_fs_slug: str, file_name: str, can_see_rom: Callable[[Rom], bool]
+) -> RomFile | None:
+ files = list_rom_files(platform_fs_slug, can_see_rom)
+ if not files:
+ return None
+ return next((f for f in files if f.display_name == file_name), None)
diff --git a/backend/tests/endpoints/test_cloud_sync.py b/backend/tests/endpoints/test_cloud_sync.py
index 125715efd4..fc3cb591b4 100644
--- a/backend/tests/endpoints/test_cloud_sync.py
+++ b/backend/tests/endpoints/test_cloud_sync.py
@@ -139,8 +139,9 @@ def test_options_with_basic_auth_advertises_dav(self, client, admin_user: User):
response = client.options("/api/cloud-sync/", auth=ADMIN_AUTH)
assert response.status_code == status.HTTP_200_OK
- assert response.headers["dav"] == "1"
+ assert response.headers["dav"] == "1, 2"
assert "MKCOL" in response.headers["allow"]
+ assert "PROPFIND" in response.headers["allow"]
def test_get_without_credentials_challenges(self, client):
response = client.get("/api/cloud-sync/manifest.server")
@@ -543,3 +544,114 @@ def test_manifest_includes_blobs_alongside_assets(self, client, admin_user: User
"hash": "8d777f385d3dfec8815d20f7496026dc",
}
]
+
+
+class TestCloudSyncWebdavBrowsing:
+ """PROPFIND/LOCK/UNLOCK + the `roms/` GET redirect -- read-only WebDAV
+ browsing layered onto the same surface, for real WebDAV clients (iOS
+ Files, Cyberduck, ...) rather than RetroArch itself (which never issues
+ PROPFIND)."""
+
+ def test_lock_succeeds(self, client, admin_user: User):
+ response = client.request("LOCK", "/api/cloud-sync/roms/", auth=ADMIN_AUTH)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.headers["lock-token"].startswith("/roms/" in body
+ assert "/saves/" in body
+ assert "/states/" in body
+
+ def test_propfind_roms_lists_platforms_with_roms(
+ self, client, admin_user: User, rom: Rom
+ ):
+ response = client.request(
+ "PROPFIND", "/api/cloud-sync/roms/", auth=ADMIN_AUTH
+ )
+
+ assert response.status_code == 207
+ assert f"/roms/{rom.platform.fs_slug}/" in response.text
+
+ def test_propfind_platform_lists_rom_files(
+ self, client, admin_user: User, rom: Rom
+ ):
+ response = client.request(
+ "PROPFIND",
+ f"/api/cloud-sync/roms/{rom.platform.fs_slug}/",
+ auth=ADMIN_AUTH,
+ )
+
+ assert response.status_code == 207
+ assert f"/roms/{rom.platform.fs_slug}/{rom.fs_name}" in response.text
+
+ def test_propfind_unknown_platform_is_not_found(self, client, admin_user: User):
+ response = client.request(
+ "PROPFIND", "/api/cloud-sync/roms/nope/", auth=ADMIN_AUTH
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_get_rom_file_redirects_to_rest_content_endpoint(
+ self, client, admin_user: User, rom: Rom
+ ):
+ response = client.get(
+ f"/api/cloud-sync/roms/{rom.platform.fs_slug}/{rom.fs_name}",
+ auth=ADMIN_AUTH,
+ follow_redirects=False,
+ )
+
+ assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
+ assert response.headers["location"] == f"/api/roms/{rom.id}/content/{rom.fs_name}"
+
+ def test_get_unknown_rom_file_is_not_found(
+ self, client, admin_user: User, rom: Rom
+ ):
+ response = client.get(
+ f"/api/cloud-sync/roms/{rom.platform.fs_slug}/nope.zip",
+ auth=ADMIN_AUTH,
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ @mock.patch(
+ "handler.cloud_sync_handler.asset_md5",
+ new_callable=mock.AsyncMock,
+ return_value="d41d8cd98f00b204e9800998ecf8427e",
+ )
+ def test_propfind_saves_lists_the_emulator_subfolder(
+ self, _asset_md5: mock.AsyncMock, client, admin_user: User, synced_save: Save
+ ):
+ response = client.request("PROPFIND", "/api/cloud-sync/saves/", auth=ADMIN_AUTH)
+
+ assert response.status_code == 207
+ assert "/saves/Snes9x/" in response.text
+
+ @mock.patch(
+ "handler.cloud_sync_handler.asset_md5",
+ new_callable=mock.AsyncMock,
+ return_value="d41d8cd98f00b204e9800998ecf8427e",
+ )
+ def test_propfind_saves_subfolder_lists_the_file(
+ self, _asset_md5: mock.AsyncMock, client, admin_user: User, synced_save: Save
+ ):
+ response = client.request(
+ "PROPFIND", "/api/cloud-sync/saves/Snes9x/", auth=ADMIN_AUTH
+ )
+
+ assert response.status_code == 207
+ assert "/saves/Snes9x/test_rom.srm" in response.text