diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 4a89812e84..0d2837996b 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -36,6 +36,10 @@ def _get_env(var: str, fallback: str | None = None) -> str | None: LIBRARY_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/library" RESOURCES_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/resources" ASSETS_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/assets" +# Opaque storage for RetroArch Cloud Sync categories RomM has no concept of +# (config/, thumbnails/, system/) — unrelated to any ROM, so it lives outside +# the asset tree but still under the same persistent volume. +CLOUD_SYNC_BLOB_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/cloud_sync_blobs" ZIP_CACHE_PATH: Final[str] = f"{ROMM_BASE_PATH}/cache/zips" FRONTEND_RESOURCES_PATH: Final[str] = "/assets/romm/resources" diff --git a/backend/endpoints/cloud_sync.py b/backend/endpoints/cloud_sync.py index 51da59908f..78915361a2 100644 --- a/backend/endpoints/cloud_sync.py +++ b/backend/endpoints/cloud_sync.py @@ -10,6 +10,8 @@ heap. """ +import os + from fastapi import APIRouter, Request, Response, status from fastapi.responses import JSONResponse @@ -18,7 +20,7 @@ from handler.auth.dependencies import get_permissions from handler.cloud_sync_handler import MANIFEST_FILE_NAME, AssetKind, CloudSyncPath from handler.database import db_save_handler, db_state_handler -from handler.filesystem import fs_asset_handler +from handler.filesystem import fs_asset_handler, fs_cloud_sync_blob_handler from handler.filesystem.assets_handler import build_asset_file_response from handler.scan_handler import scan_save, scan_state from logger.formatter import BLUE @@ -121,6 +123,22 @@ async def cloud_sync_get(request: Request, file_path: str) -> Response: ) return JSONResponse(content=manifest) + blob_path = cloud_sync_handler.parse_cloud_sync_blob_path(file_path) + if blob_path: + try: + resolved_path = fs_cloud_sync_blob_handler.validate_path( + cloud_sync_handler.user_blob_path(request.user, blob_path) + ) + except ValueError: + return _empty(status.HTTP_404_NOT_FOUND) + + if not resolved_path.is_file(): + return _empty(status.HTTP_404_NOT_FOUND) + + return build_asset_file_response( + resolved_path, filename=os.path.basename(blob_path) + ) + parsed = cloud_sync_handler.parse_cloud_sync_path(file_path) if not parsed: return _empty(status.HTTP_404_NOT_FOUND) @@ -157,9 +175,23 @@ async def cloud_sync_put(request: Request, file_path: str) -> Response: return _empty(status.HTTP_204_NO_CONTENT) # RetroArch also offers config/, thumbnails/ and system/ when those settings - # are on. RomM has nowhere to put opaque blobs, and answering OK would put - # them in the client's local manifest, so the next sync would read their - # absence from ours as a remote delete and wipe the local copies. + # are on. None of these belong to a ROM, so they're stored as opaque + # per-user blobs instead of going through the asset/ROM matching below. + blob_path = cloud_sync_handler.parse_cloud_sync_blob_path(file_path) + if blob_path: + disk_path = cloud_sync_handler.user_blob_path(request.user, blob_path) + existed = await fs_cloud_sync_blob_handler.file_exists(disk_path) + + await fs_cloud_sync_blob_handler.write_file( + file=await request.body(), + path=os.path.dirname(disk_path), + filename=os.path.basename(disk_path), + ) + + return _empty( + status.HTTP_204_NO_CONTENT if existed else status.HTTP_201_CREATED + ) + parsed = cloud_sync_handler.parse_cloud_sync_path(file_path) if not parsed: return _empty(status.HTTP_409_CONFLICT) @@ -248,6 +280,17 @@ async def cloud_sync_delete(request: Request, file_path: str) -> Response: if denied: return denied + blob_path = cloud_sync_handler.parse_cloud_sync_blob_path(file_path) + if blob_path: + try: + await fs_cloud_sync_blob_handler.remove_file( + file_path=cloud_sync_handler.user_blob_path(request.user, blob_path) + ) + except FileNotFoundError: + return _empty(status.HTTP_404_NOT_FOUND) + + return _empty(status.HTTP_204_NO_CONTENT) + 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/cloud_sync_emulator_names.py b/backend/handler/cloud_sync_emulator_names.py new file mode 100644 index 0000000000..3dd65d1506 --- /dev/null +++ b/backend/handler/cloud_sync_emulator_names.py @@ -0,0 +1,98 @@ +"""Translates between RomM's `emulator` field convention (lowercase libretro +core identifier, e.g. "snes9x") and RetroArch's actual local save/state +directory name (its display name, e.g. "Snes9x"). These are not the same +string, and naively round-tripping one as the other has two failure modes: + +- Writing RetroArch's raw folder name straight into `emulator` stores a save + RomM's own web player can never select again -- `EmulatorJS.vue` filters + saves by an exact match against the lowercase libretro core id, and + `_EJS_CORES_MAP` in the frontend confirms that convention is RomM-wide, not + cloud-sync-specific. +- Handing that raw value back out unchanged in the manifest can point + RetroArch at a folder its own local install never uses (it's case- and + spacing-sensitive), so the file silently never resolves as "already + synced" and keeps re-appearing as a diff. + +The table mirrors the community romm-retroarch-sync project +(github.com/Covin90/romm-retroarch-sync), which had already solved this +exact problem for the cores below. Anything outside the table round-trips +unchanged on the way back out to RetroArch rather than guessing at a casing +or spacing that hasn't been verified against a real install. +""" + +RETROARCH_DIR_BY_ROMM_EMULATOR: dict[str, str] = { + # SNES + "snes9x": "Snes9x", + "bsnes": "bsnes", + "mesen-s": "Mesen-S", + # NES + "nestopia": "Nestopia", + "fceumm": "FCEUmm", + "mesen": "Mesen", + # PlayStation + "beetle_psx": "Beetle PSX", + "beetle_psx_hw": "Beetle PSX HW", + "pcsx_rearmed": "PCSX-ReARMed", + "swanstation": "SwanStation", + "mednafen_psx": "Beetle PSX", + "mednafen_psx_hw": "Beetle PSX HW", + # Game Boy + "gambatte": "Gambatte", + "sameboy": "SameBoy", + "tgbdual": "TGB Dual", + "mgba": "mGBA", + "vba_next": "VBA Next", + "vbam": "VBA-M", + # Genesis / Mega Drive + "genesis_plus_gx": "Genesis Plus GX", + "blastem": "BlastEm", + "picodrive": "PicoDrive", + # Nintendo 64 + "mupen64plus_next": "Mupen64Plus-Next", + "parallel_n64": "ParaLLEl N64", + # Saturn + "beetle_saturn": "Beetle Saturn", + "kronos": "Kronos", + "mednafen_saturn": "Beetle Saturn", + # Arcade / Neo Geo + "mame": "MAME", + "fbneo": "FBNeo", + "fbalpha": "FB Alpha", + # PlayStation 2 / GameCube + "pcsx2": "PCSX2", + "play": "Play!", + "dolphin": "Dolphin", + # Dreamcast + "flycast": "Flycast", + "redream": "Redream", + # Atari + "stella": "Stella", + # PC Engine + "beetle_pce": "Beetle PCE", + "beetle_pce_fast": "Beetle PCE Fast", + "mednafen_pce": "Beetle PCE", + "mednafen_pce_fast": "Beetle PCE Fast", + # Other common cores + "dosbox_pure": "DOSBox-Pure", + "scummvm": "ScummVM", + "ppsspp": "PPSSPP", + "desmume": "DeSmuME", + "melonds": "melonDS", + "citra": "Citra", +} + + +def to_romm_emulator(retroarch_dir_name: str) -> str: + """RetroArch's local directory name (e.g. "Snes9x") -> RomM's `emulator` + convention (e.g. "snes9x"). A plain, universally-safe normalization -- + RomM's own convention is always lowercase with underscores, so this + never needs a lookup table.""" + return retroarch_dir_name.lower().replace(" ", "_").replace("-", "_") + + +def to_retroarch_dir_name(romm_emulator: str) -> str: + """RomM's `emulator` value -> RetroArch's local directory name. Cores + outside the table round-trip unchanged: guessing at a casing or spacing + that hasn't been verified against a real RetroArch install risks + inventing a folder that's just as wrong as the untranslated one.""" + return RETROARCH_DIR_BY_ROMM_EMULATOR.get(romm_emulator.lower(), romm_emulator) diff --git a/backend/handler/cloud_sync_handler.py b/backend/handler/cloud_sync_handler.py index fb623258da..183153c227 100644 --- a/backend/handler/cloud_sync_handler.py +++ b/backend/handler/cloud_sync_handler.py @@ -17,8 +17,9 @@ from dataclasses import dataclass from typing import Literal +from handler.cloud_sync_emulator_names import to_retroarch_dir_name, to_romm_emulator from handler.database import db_rom_handler, db_save_handler, db_state_handler -from handler.filesystem import fs_asset_handler +from handler.filesystem import fs_asset_handler, fs_cloud_sync_blob_handler from handler.redis_handler import async_cache from models.assets import Save, State from models.rom import Rom @@ -30,6 +31,12 @@ ASSET_ROOTS: dict[str, AssetKind] = {"saves": "saves", "states": "states"} +# RetroArch's other three Cloud Sync categories (Settings -> Saving -> Cloud +# Sync -> Sync Configuration/Thumbnails/System Files). Unlike saves/states, +# none of these belong to a ROM, so they're kept as opaque per-user blobs +# instead of going through the asset/ROM matching machinery below. +BLOB_CATEGORIES = ("config", "thumbnails", "system") + # `.state`, `.state3`, `.state.auto` — the auto suffix makes # this a two-segment extension, which splitext alone gets wrong. STATE_SUFFIX_PATTERN = re.compile(r"\.state\d*(?:\.auto)?$", re.IGNORECASE) @@ -53,7 +60,11 @@ def parse_cloud_sync_path(path: str) -> CloudSyncPath | None: """Parse a client path, or None when it is not a supported asset path. Accepts ``/`` and ``//``; RetroArch produces - the latter when "sort saves into folders by core name" is on. + the latter when "sort saves into folders by core name" is on. The core + segment is RetroArch's own directory casing (e.g. "Snes9x"), normalized + here to RomM's `emulator` convention (e.g. "snes9x") -- storing it + unnormalized would make the save invisible to RomM's own web player, + which matches saves against the lowercase libretro core id. """ segments = [segment for segment in path.strip("/").split("/") if segment] if not 2 <= len(segments) <= 3: @@ -68,7 +79,7 @@ def parse_cloud_sync_path(path: str) -> CloudSyncPath | None: return CloudSyncPath( kind=kind, - emulator=segments[1] if len(segments) == 3 else None, + emulator=to_romm_emulator(segments[1]) if len(segments) == 3 else None, file_name=segments[-1], ) @@ -85,7 +96,7 @@ def game_name_from_file_name(kind: AssetKind, file_name: str) -> str: def build_cloud_sync_path(kind: AssetKind, emulator: str | None, file_name: str) -> str: if emulator: - return f"{kind}/{emulator}/{file_name}" + return f"{kind}/{to_retroarch_dir_name(emulator)}/{file_name}" return f"{kind}/{file_name}" @@ -108,6 +119,74 @@ def build_asset_file_path( ) +def parse_cloud_sync_blob_path(path: str) -> str | None: + """A client path under one of the opaque blob categories, normalized to + a plain ``category/...`` posix string, or None if it isn't one. + + Unlike asset paths these keep arbitrary nesting: RetroArch mirrors its + own on-device directory tree here (e.g. thumbnail packs are organized as + ``thumbnails//Named_Boxarts/.png``), so there's no fixed + segment count to enforce. + """ + segments = [segment for segment in path.strip("/").split("/") if segment] + if len(segments) < 2: + return None + + if any(segment in (os.curdir, os.pardir) for segment in segments): + return None + + if segments[0] not in BLOB_CATEGORIES: + return None + + return "/".join(segments) + + +def user_blob_path(user: User, blob_path: str) -> str: + """Where a parsed blob path lives on disk, namespaced by user so two + RetroArch installs syncing to the same RomM instance under different + accounts never see each other's config/thumbnails/system files.""" + return f"{fs_asset_handler.user_folder_path(user)}/{blob_path}" + + +async def blob_md5(user: User, blob_path: str) -> str | None: + try: + resolved = fs_cloud_sync_blob_handler.validate_path( + user_blob_path(user, blob_path) + ) + stat = resolved.stat() + except (ValueError, OSError): + return None + + cache_key = ( + f"romm:cloud_sync:blob_md5:{user.id}:{blob_path}:{stat.st_size}:{stat.st_mtime}" + ) + cached = await async_cache.get(cache_key) + if cached: + return cached.decode() if isinstance(cached, bytes) else str(cached) + + digest = await fs_cloud_sync_blob_handler.compute_file_md5( + user_blob_path(user, blob_path) + ) + if digest: + await async_cache.set(cache_key, digest, ex=_HASH_CACHE_TTL_SECONDS) + + return digest + + +async def build_blob_manifest_entries(user: User) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for category in BLOB_CATEGORIES: + prefix = f"{fs_asset_handler.user_folder_path(user)}/{category}" + for relative in await fs_cloud_sync_blob_handler.list_blob_paths(prefix): + blob_path = f"{category}/{relative}" + digest = await blob_md5(user, blob_path) + if not digest: + continue + entries.append({"path": blob_path, "hash": digest}) + + return entries + + def resolve_rom(game_name: str, can_see: Callable[[Rom], bool]) -> Rom | None: """The ROM a cloud-sync file belongs to, matched on file name alone. @@ -172,5 +251,7 @@ async def build_manifest( } ) + entries += await build_blob_manifest_entries(user) + entries.sort(key=lambda entry: entry["path"]) return entries diff --git a/backend/handler/filesystem/__init__.py b/backend/handler/filesystem/__init__.py index d436085799..dcaee49ecb 100644 --- a/backend/handler/filesystem/__init__.py +++ b/backend/handler/filesystem/__init__.py @@ -1,4 +1,5 @@ from .assets_handler import FSAssetsHandler +from .cloud_sync_blob_handler import FSCloudSyncBlobHandler from .firmware_handler import FSFirmwareHandler from .launchbox_handler import FSLaunchboxHandler, get_fs_launchbox_handler from .platforms_handler import FSPlatformsHandler @@ -7,6 +8,7 @@ from .sync_handler import FSSyncHandler, get_fs_sync_handler fs_asset_handler = FSAssetsHandler() +fs_cloud_sync_blob_handler = FSCloudSyncBlobHandler() fs_firmware_handler = FSFirmwareHandler() fs_platform_handler = FSPlatformsHandler() fs_rom_handler = FSRomsHandler() @@ -14,6 +16,7 @@ __all__ = [ "FSAssetsHandler", + "FSCloudSyncBlobHandler", "FSFirmwareHandler", "FSLaunchboxHandler", "FSPlatformsHandler", @@ -21,6 +24,7 @@ "FSRomsHandler", "FSSyncHandler", "fs_asset_handler", + "fs_cloud_sync_blob_handler", "fs_firmware_handler", "fs_platform_handler", "fs_resource_handler", diff --git a/backend/handler/filesystem/cloud_sync_blob_handler.py b/backend/handler/filesystem/cloud_sync_blob_handler.py new file mode 100644 index 0000000000..be781a3e6d --- /dev/null +++ b/backend/handler/filesystem/cloud_sync_blob_handler.py @@ -0,0 +1,44 @@ +"""Local disk storage for the RetroArch Cloud Sync categories RomM has no +concept of at all: config/, thumbnails/, system/. These are unrelated to any +ROM in the library — the client just wants an opaque per-user bucket to keep +its own files in sync across devices — so they're stored as plain files, +namespaced by user, rather than going through the asset/ROM machinery. +""" + +import hashlib + +from config import CLOUD_SYNC_BLOB_BASE_PATH + +from .base_handler import FSHandler + + +class FSCloudSyncBlobHandler(FSHandler): + def __init__(self) -> None: + super().__init__(base_path=CLOUD_SYNC_BLOB_BASE_PATH) + + async def compute_file_md5(self, file_path: str) -> str | None: + """MD5 of the bytes on disk, `None` if the file can't be read.""" + try: + hash_obj = hashlib.md5(usedforsecurity=False) + async with await self.stream_file(file_path=file_path) as f: + while chunk := await f.read(8192): + hash_obj.update(chunk) + return hash_obj.hexdigest() + except OSError: + return None + + async def list_blob_paths(self, prefix: str) -> list[str]: + """Posix paths of every file under `prefix`, relative to `prefix` + itself. `prefix` is itself relative to the blob root (e.g. + `users//thumbnails`); the caller re-attaches whatever prefix + the manifest or on-disk path needs. + """ + try: + root = self.validate_path(prefix) + except ValueError: + return [] + + if not root.is_dir(): + return [] + + return [p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()] diff --git a/backend/tests/endpoints/test_cloud_sync.py b/backend/tests/endpoints/test_cloud_sync.py index a52170958d..125715efd4 100644 --- a/backend/tests/endpoints/test_cloud_sync.py +++ b/backend/tests/endpoints/test_cloud_sync.py @@ -4,6 +4,7 @@ from fastapi import status from handler import cloud_sync_handler +from handler.cloud_sync_emulator_names import to_retroarch_dir_name, to_romm_emulator from handler.database import db_save_handler, db_state_handler from handler.filesystem import fs_asset_handler from models.assets import Save, State @@ -19,14 +20,16 @@ def saves_path(admin_user: User, rom: Rom): user=admin_user, platform_fs_slug="test_platform_slug", rom_id=rom.id, - emulator="Snes9x", + emulator="snes9x", ) @pytest.fixture def synced_save(admin_user: User, rom: Rom, saves_path: str): """A save stored where the cloud-sync path `saves/Snes9x/test_rom.srm` - resolves to, unlike the shared fixtures' legacy layout.""" + resolves to, unlike the shared fixtures' legacy layout. `emulator` is + RomM's own convention (lowercase), not RetroArch's directory casing -- + see `parse_cloud_sync_path`.""" return db_save_handler.add_save( Save( rom_id=rom.id, @@ -34,20 +37,56 @@ def synced_save(admin_user: User, rom: Rom, saves_path: str): file_name="test_rom.srm", file_path=saves_path, file_size_bytes=4, - emulator="Snes9x", + emulator="snes9x", slot=None, ) ) +class TestCloudSyncEmulatorNames: + @pytest.mark.parametrize( + ("retroarch_dir_name", "romm_emulator"), + [ + ("Snes9x", "snes9x"), + ("Genesis Plus GX", "genesis_plus_gx"), + ("PCSX-ReARMed", "pcsx_rearmed"), + ("RetroArduous", "retroarduous"), + ], + ) + def test_to_romm_emulator(self, retroarch_dir_name, romm_emulator): + assert to_romm_emulator(retroarch_dir_name) == romm_emulator + + @pytest.mark.parametrize( + ("romm_emulator", "retroarch_dir_name"), + [ + ("snes9x", "Snes9x"), + ("genesis_plus_gx", "Genesis Plus GX"), + ("pcsx_rearmed", "PCSX-ReARMed"), + # A core outside the table round-trips unchanged rather than + # guessing at a casing/spacing that hasn't been verified. + ("retroarduous", "retroarduous"), + ("test_emulator", "test_emulator"), + ], + ) + def test_to_retroarch_dir_name(self, romm_emulator, retroarch_dir_name): + assert to_retroarch_dir_name(romm_emulator) == retroarch_dir_name + + class TestCloudSyncPathParsing: @pytest.mark.parametrize( ("path", "kind", "emulator", "file_name"), [ ("saves/test_rom.srm", "saves", None, "test_rom.srm"), - ("saves/Snes9x/test_rom.srm", "saves", "Snes9x", "test_rom.srm"), - ("states/Snes9x/test_rom.state", "states", "Snes9x", "test_rom.state"), + ("saves/Snes9x/test_rom.srm", "saves", "snes9x", "test_rom.srm"), + ("states/Snes9x/test_rom.state", "states", "snes9x", "test_rom.state"), ("/states/test_rom.state.auto", "states", None, "test_rom.state.auto"), + # A core outside the translation table round-trips unchanged. + ( + "saves/RetroArduous/test_rom.srm", + "saves", + "retroarduous", + "test_rom.srm", + ), ], ) def test_parses_supported_paths(self, path, kind, emulator, file_name): @@ -161,6 +200,27 @@ def test_empty_library_returns_empty_manifest(self, client, admin_user: User): assert response.status_code == status.HTTP_200_OK assert response.json() == [] + @mock.patch( + "handler.cloud_sync_handler.asset_md5", + new_callable=mock.AsyncMock, + return_value="d41d8cd98f00b204e9800998ecf8427e", + ) + def test_round_trips_emulator_casing_through_the_manifest( + self, _asset_md5: mock.AsyncMock, client, admin_user: User, synced_save: Save + ): + """`synced_save` is stored with RomM's own convention (`snes9x`, + lowercase). The manifest must hand RetroArch back its own directory + casing (`Snes9x`), not RomM's -- see `to_retroarch_dir_name`.""" + response = client.get("/api/cloud-sync/manifest.server", auth=ADMIN_AUTH) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + { + "path": "saves/Snes9x/test_rom.srm", + "hash": "d41d8cd98f00b204e9800998ecf8427e", + } + ] + class TestCloudSyncUpload: @mock.patch( @@ -195,7 +255,7 @@ def test_creates_save_for_matching_rom( saves = db_save_handler.get_saves(user_id=admin_user.id, rom_id=rom.id) assert len(saves) == 1 assert saves[0].file_name == "test_rom.srm" - assert saves[0].emulator == "Snes9x" + assert saves[0].emulator == "snes9x" assert saves[0].slot is None @mock.patch( @@ -257,7 +317,7 @@ def test_creates_state_from_auto_savestate_name( user=admin_user, platform_fs_slug="test_platform_slug", rom_id=rom.id, - emulator="Snes9x", + emulator="snes9x", ) mock_scan_state.return_value = State( file_name="test_rom.state.auto", @@ -291,7 +351,7 @@ def test_rejects_upload_with_no_matching_rom(self, client, admin_user: User): def test_rejects_unsupported_sync_root(self, client, admin_user: User, rom: Rom): response = client.put( - "/api/cloud-sync/config/retroarch.cfg", + "/api/cloud-sync/deleted/saves/test_rom.srm", content=b"data", auth=ADMIN_AUTH, ) @@ -374,3 +434,112 @@ def test_mkcol_succeeds_without_creating_anything(self, client, admin_user: User ) assert response.status_code == status.HTTP_201_CREATED + + +class TestCloudSyncBlobPathParsing: + @pytest.mark.parametrize( + ("path", "expected"), + [ + ("config/retroarch.cfg", "config/retroarch.cfg"), + ( + "thumbnails/Nintendo - Game Boy/Named_Boxarts/Game.png", + "thumbnails/Nintendo - Game Boy/Named_Boxarts/Game.png", + ), + ("system/bios/scph5501.bin", "system/bios/scph5501.bin"), + ("/system/bios.bin", "system/bios.bin"), + ], + ) + def test_parses_blob_paths(self, path, expected): + assert cloud_sync_handler.parse_cloud_sync_blob_path(path) == expected + + @pytest.mark.parametrize( + "path", + [ + "config", + "saves/test_rom.srm", + "deleted/config/retroarch.cfg", + "config/../../etc/passwd", + ], + ) + def test_rejects_non_blob_paths(self, path): + assert cloud_sync_handler.parse_cloud_sync_blob_path(path) is None + + +class TestCloudSyncBlobs: + def test_creates_and_downloads_config_blob(self, client, admin_user: User): + put_response = client.put( + "/api/cloud-sync/config/retroarch.cfg", + content=b"data", + auth=ADMIN_AUTH, + ) + assert put_response.status_code == status.HTTP_201_CREATED + + get_response = client.get( + "/api/cloud-sync/config/retroarch.cfg", auth=ADMIN_AUTH + ) + assert get_response.status_code == status.HTTP_200_OK + assert get_response.content == b"data" + + def test_overwrites_existing_blob_in_place(self, client, admin_user: User): + client.put("/api/cloud-sync/system/bios.bin", content=b"data", auth=ADMIN_AUTH) + + response = client.put( + "/api/cloud-sync/system/bios.bin", content=b"newdata", auth=ADMIN_AUTH + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + get_response = client.get("/api/cloud-sync/system/bios.bin", auth=ADMIN_AUTH) + assert get_response.content == b"newdata" + + def test_accepts_nested_thumbnail_paths(self, client, admin_user: User): + response = client.put( + "/api/cloud-sync/thumbnails/Nintendo - Game Boy/Named_Boxarts/Game.png", + content=b"pngdata", + auth=ADMIN_AUTH, + ) + + assert response.status_code == status.HTTP_201_CREATED + + def test_missing_blob_is_not_found(self, client, admin_user: User): + response = client.get("/api/cloud-sync/config/nope.cfg", auth=ADMIN_AUTH) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.content == b"" + + def test_delete_removes_the_blob(self, client, admin_user: User): + client.put( + "/api/cloud-sync/config/retroarch.cfg", content=b"data", auth=ADMIN_AUTH + ) + + response = client.request( + "DELETE", "/api/cloud-sync/config/retroarch.cfg", auth=ADMIN_AUTH + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + get_response = client.get( + "/api/cloud-sync/config/retroarch.cfg", auth=ADMIN_AUTH + ) + assert get_response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_of_unknown_blob_is_not_found(self, client, admin_user: User): + response = client.request( + "DELETE", "/api/cloud-sync/config/nope.cfg", auth=ADMIN_AUTH + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_manifest_includes_blobs_alongside_assets(self, client, admin_user: User): + client.put( + "/api/cloud-sync/config/retroarch.cfg", content=b"data", auth=ADMIN_AUTH + ) + + response = client.get("/api/cloud-sync/manifest.server", auth=ADMIN_AUTH) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + { + "path": "config/retroarch.cfg", + "hash": "8d777f385d3dfec8815d20f7496026dc", + } + ] diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index 14ac3b59e5..5a72929c49 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -937,24 +937,42 @@ PROPFIND). Point RetroArch's WebDAV URL at `https:///api/cloud-sync/` (trailing slash required), enable save/state sync only, and authenticate with a RomM username and password over HTTP Basic. -| Method | Path | Scope | Description | -| ----------- | ----------------------- | ------------ | ----------------------------------------------- | -| OPTIONS | `/{path}` | ASSETS_READ | Advertise DAV support | -| GET | `/manifest.server` | ASSETS_READ | Manifest of the caller's saves and states | -| GET | `/{root}/[core/]{file}` | ASSETS_READ | Download one save/state | -| PUT | `/{root}/[core/]{file}` | ASSETS_WRITE | Upload one save/state | -| DELETE/MOVE | `/{root}/[core/]{file}` | ASSETS_WRITE | Delete one save/state | -| MKCOL | `/{path}` | ASSETS_WRITE | Accepted no-op (layout is derived from the ROM) | +| Method | Path | Scope | Description | +| ----------- | ------------------------------------ | ------------ | ------------------------------------------------ | +| OPTIONS | `/{path}` | ASSETS_READ | Advertise DAV support | +| GET | `/manifest.server` | ASSETS_READ | Manifest of the caller's saves, states and blobs | +| GET | `/{root}/[core/]{file}` | ASSETS_READ | Download one save/state | +| PUT | `/{root}/[core/]{file}` | ASSETS_WRITE | Upload one save/state | +| DELETE/MOVE | `/{root}/[core/]{file}` | ASSETS_WRITE | Delete one save/state | +| GET | `/{config,thumbnails,system}/{path}` | ASSETS_READ | Download one opaque blob file | +| PUT | `/{config,thumbnails,system}/{path}` | ASSETS_WRITE | Upload one opaque blob file | +| DELETE/MOVE | `/{config,thumbnails,system}/{path}` | ASSETS_WRITE | Delete one opaque blob file | +| MKCOL | `/{path}` | ASSETS_WRITE | Accepted no-op (layout is derived from the ROM) | `{root}` is `saves` or `states`. Files are matched to a ROM by file name alone (`Super Mario World.srm` → the ROM whose `fs_name_no_ext` is `Super Mario World`), so a name shared across platforms resolves ambiguously. The optional -`core` segment maps to the asset's `emulator`, which namespaces storage exactly -as it does for uploads through `/api/saves`. Slotted saves are excluded from the -manifest: they are RomM's own versioned history and their datetime-tagged names -are not loadable by any core. Unlike the rest of the API this router gates -itself, so it can answer a 401 challenge rather than a 403, and it sends -body-less error responses because RetroArch's client mishandles large ones. +`core` segment is RetroArch's own directory casing (e.g. `Snes9x`), translated +through `cloud_sync_emulator_names.to_romm_emulator`/`to_retroarch_dir_name` to +and from the asset's `emulator` field, which namespaces storage exactly as it +does for uploads through `/api/saves` — storing RetroArch's raw casing instead +would make the save invisible to RomM's own web player, which matches saves +against the lowercase libretro core id. Cores outside the small translation +table round-trip unchanged rather than guessing at an unverified casing. +Slotted saves are excluded from the manifest: they are RomM's own versioned +history and their datetime-tagged names are not loadable by any core. Unlike +the rest of the API this router gates itself, so it can answer a 401 challenge +rather than a 403, and it sends body-less error responses because RetroArch's +client mishandles large ones. + +RetroArch's other three Cloud Sync categories (Sync Configuration/Thumbnails/ +System Files) have no ROM to attach to, so they're stored as opaque per-user +blobs under `CLOUD_SYNC_BLOB_BASE_PATH` (`FSCloudSyncBlobHandler`) instead of +going through the asset/ROM matching above — namespaced by user so two +RetroArch installs syncing to the same RomM instance under different accounts +never see each other's files. Unlike asset hashes, blob hashes are always real +MD5s of the file on disk, computed on every manifest build (still Redis-cached +by path+size+mtime, same as asset hashes) since these files are typically tiny. ### 6.10 Screenshots (`/api/screenshots`)