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