diff --git a/backend/config/__init__.py b/backend/config/__init__.py
index 0d2837996b..ebd0b34e12 100644
--- a/backend/config/__init__.py
+++ b/backend/config/__init__.py
@@ -1,3 +1,4 @@
+import json
import os
from typing import Final, overload
@@ -40,6 +41,15 @@ def _get_env(var: str, fallback: str | None = None) -> str | None:
# (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"
+# Holds a PSP save folder's files that arrived before the folder could be
+# resolved to a rom (usually only until PARAM.SFO shows up) -- see
+# handler/cloud_sync_psp.py.
+CLOUD_SYNC_PSP_PENDING_PATH: Final[str] = f"{ROMM_BASE_PATH}/cloud_sync_psp_pending"
+# Serial (PARAM.SFO DISC_ID minus the trailing slot digits, e.g. "ULUS10336")
+# -> RomM rom title override, for PSP saves whose PARAM.SFO TITLE doesn't
+# normalize-match any rom automatically. JSON object, e.g.
+# {"ULUS10336": "Crisis Core - Final Fantasy VII"}.
+PSP_SERIAL_MAP: Final[dict[str, str]] = json.loads(_get_env("PSP_SERIAL_MAP", "{}"))
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 78915361a2..170371317a 100644
--- a/backend/endpoints/cloud_sync.py
+++ b/backend/endpoints/cloud_sync.py
@@ -11,29 +11,31 @@
"""
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, cloud_sync_psp, 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
-from handler.database import db_save_handler, db_state_handler
+from handler.database import db_save_handler, db_screenshot_handler, db_state_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 handler.scan_handler import scan_save, scan_screenshot, scan_state
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
-from models.assets import Save, State
+from models.assets import Save, Screenshot, State
from models.rom import Rom
from models.user import User
from utils.filesystem import sanitize_filename
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:
@@ -75,11 +77,10 @@ def _resolve_rom(request: Request, kind: AssetKind, file_name: str) -> Rom | Non
def _get_asset(
user: User, rom: Rom, parsed: CloudSyncPath, file_name: str
) -> Save | State | None:
- file_path = cloud_sync_handler.build_asset_file_path(
- user, rom, parsed.kind, parsed.emulator
- )
-
if parsed.kind == "saves":
+ file_path = cloud_sync_handler.build_asset_file_path(
+ user, rom, parsed.kind, parsed.emulator
+ )
return db_save_handler.get_save_by_path(
user_id=user.id,
rom_id=rom.id,
@@ -87,11 +88,14 @@ def _get_asset(
file_name=file_name,
)
- return db_state_handler.get_state_by_path(
- user_id=user.id,
- rom_id=rom.id,
- file_path=file_path,
- file_name=file_name,
+ # States have no `slot` column to key an exact-path lookup on, and the
+ # requested `file_name` is the canonical name `build_manifest` made up
+ # (`cloud_sync_handler.canonical_state_file_name`) -- it may not match
+ # any single row's actual `file_name` (e.g. a web-player-created state).
+ # Re-derive the same (rom, emulator, slot) bucket instead of trusting an
+ # exact match.
+ return cloud_sync_handler.resolve_state_by_slot(
+ user, rom, parsed.emulator, file_name
)
@@ -104,7 +108,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 +373,37 @@ async def cloud_sync_get(request: Request, file_path: str) -> Response:
resolved_path, filename=os.path.basename(blob_path)
)
+ psp_path = cloud_sync_psp.resolve_psp_path(file_path)
+ if psp_path == "ignore":
+ return _empty(status.HTTP_404_NOT_FOUND)
+ if psp_path:
+ data = await cloud_sync_psp.get_psp_file(request.user, psp_path)
+ if data is None:
+ return _empty(status.HTTP_404_NOT_FOUND)
+ return Response(content=data, media_type="application/octet-stream")
+
+ 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)
@@ -147,7 +412,16 @@ async def cloud_sync_get(request: Request, file_path: str) -> Response:
if not rom:
return _empty(status.HTTP_404_NOT_FOUND)
- asset = _get_asset(request.user, rom, parsed, parsed.file_name)
+ asset: Save | State | Screenshot | None
+ if parsed.kind == "states" and cloud_sync_handler.is_state_screenshot_path(
+ parsed.file_name
+ ):
+ asset = cloud_sync_handler.resolve_state_screenshot_by_slot(
+ request.user, rom, parsed.emulator, parsed.file_name
+ )
+ else:
+ asset = _get_asset(request.user, rom, parsed, parsed.file_name)
+
if not asset:
return _empty(status.HTTP_404_NOT_FOUND)
@@ -192,6 +466,23 @@ async def cloud_sync_put(request: Request, file_path: str) -> Response:
status.HTTP_204_NO_CONTENT if existed else status.HTTP_201_CREATED
)
+ psp_path = cloud_sync_psp.resolve_psp_path(file_path)
+ if psp_path == "ignore":
+ # PSP engine cache file (shader cache etc.), not save data.
+ return _empty(status.HTTP_204_NO_CONTENT)
+ if psp_path:
+ permissions = get_permissions(request)
+ try:
+ await cloud_sync_psp.put_psp_file(
+ request.user,
+ psp_path,
+ await request.body(),
+ lambda rom: permissions.can_see_rom(rom.id, rom.platform_id),
+ )
+ except cloud_sync_psp.PspFolderUnresolved:
+ return _empty(status.HTTP_409_CONFLICT)
+ return _empty(status.HTTP_201_CREATED)
+
parsed = cloud_sync_handler.parse_cloud_sync_path(file_path)
if not parsed:
return _empty(status.HTTP_409_CONFLICT)
@@ -206,18 +497,80 @@ async def cloud_sync_put(request: Request, file_path: str) -> Response:
log.warning(f"Cloud sync upload {hl(file_path)} matches no ROM in the library")
return _empty(status.HTTP_409_CONFLICT)
+ log.info(f"Cloud sync upload {hl(file_name)} for {hl(str(rom.name), color=BLUE)}")
+
+ # RetroArch syncs a state's screenshot as `.png` --
+ # store it as a Screenshot attached to the ROM, not a State (there's no
+ # state binary here, just an image).
+ if parsed.kind == "states" and cloud_sync_handler.is_state_screenshot_path(
+ file_name
+ ):
+ # `file_name` is the canonical `.png` name -- but the state it
+ # belongs to (found the same way `resolve_state_by_slot` would) may
+ # have its own, different real file name (e.g. a web-player upload).
+ # Writing under the canonical name while an existing screenshot's
+ # row still points at that other name would create a second,
+ # untracked file on disk instead of updating the real one.
+ owning_state = cloud_sync_handler.resolve_state_by_slot(
+ request.user, rom, parsed.emulator, file_name[: -len(".png")]
+ )
+ screenshot_file_name = (
+ f"{owning_state.file_name}.png" if owning_state else file_name
+ )
+
+ screenshot_path = fs_asset_handler.build_screenshots_file_path(
+ user=request.user,
+ platform_fs_slug=rom.platform.fs_slug,
+ rom_id=rom.id,
+ )
+ await fs_asset_handler.write_file(
+ file=await request.body(),
+ path=screenshot_path,
+ filename=screenshot_file_name,
+ )
+
+ scanned_screenshot = await scan_screenshot(
+ file_name=screenshot_file_name,
+ user=request.user,
+ platform_fs_slug=rom.platform.fs_slug,
+ rom_id=rom.id,
+ )
+ existing_screenshot = db_screenshot_handler.get_screenshot(
+ rom_id=rom.id, user_id=request.user.id, file_name=screenshot_file_name
+ )
+ if existing_screenshot:
+ db_screenshot_handler.update_screenshot(
+ existing_screenshot.id,
+ {"file_size_bytes": scanned_screenshot.file_size_bytes},
+ )
+ return _empty(status.HTTP_204_NO_CONTENT)
+
+ scanned_screenshot.rom_id = rom.id
+ scanned_screenshot.user_id = request.user.id
+ db_screenshot_handler.add_screenshot(screenshot=scanned_screenshot)
+ return _empty(status.HTTP_201_CREATED)
+
asset_path = cloud_sync_handler.build_asset_file_path(
request.user, rom, parsed.kind, parsed.emulator
)
- log.info(f"Cloud sync upload {hl(file_name)} for {hl(str(rom.name), color=BLUE)}")
+ # For states, `file_name` is the *canonical* slot name RetroArch always
+ # uses -- but `existing` (resolved by slot, not by exact path) may be a
+ # row whose own `file_name` is something else entirely (e.g. a
+ # web-player upload). Writing the new bytes under the canonical name
+ # while only patching that other row's `file_size_bytes` would leave the
+ # DB row pointing at stale, now-orphaned content on disk -- silently
+ # diverging RomM's own view of "the current state" from what's actually
+ # on disk, which resurfaces as a spurious sync conflict on every
+ # subsequent sync. Writing to the existing row's own real file name
+ # instead keeps disk and DB in agreement.
+ existing = _get_asset(request.user, rom, parsed, file_name)
+ write_file_name = existing.file_name if existing else file_name
await fs_asset_handler.write_file(
- file=await request.body(), path=asset_path, filename=file_name
+ file=await request.body(), path=asset_path, filename=write_file_name
)
- existing = _get_asset(request.user, rom, parsed, file_name)
-
if parsed.kind == "saves":
scanned_save = await scan_save(
file_name=file_name,
@@ -241,7 +594,7 @@ async def cloud_sync_put(request: Request, file_path: str) -> Response:
db_save_handler.add_save(save=scanned_save)
else:
scanned_state = await scan_state(
- file_name=file_name,
+ file_name=write_file_name,
user=request.user,
platform_fs_slug=rom.platform.fs_slug,
rom_id=rom.id,
@@ -291,6 +644,16 @@ async def cloud_sync_delete(request: Request, file_path: str) -> Response:
return _empty(status.HTTP_204_NO_CONTENT)
+ psp_path = cloud_sync_psp.resolve_psp_path(file_path)
+ if psp_path:
+ # Best-effort, same as every other delete here: RetroArch deletes a
+ # PSP save folder file-by-file, so the first of the folder's several
+ # DELETEs removes the whole bundle and the rest find nothing left to
+ # remove.
+ if psp_path != "ignore":
+ await cloud_sync_psp.delete_psp_folder(request.user, psp_path.save_folder)
+ 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)
@@ -299,6 +662,24 @@ async def cloud_sync_delete(request: Request, file_path: str) -> Response:
if not rom:
return _empty(status.HTTP_404_NOT_FOUND)
+ if parsed.kind == "states" and cloud_sync_handler.is_state_screenshot_path(
+ parsed.file_name
+ ):
+ screenshot = cloud_sync_handler.resolve_state_screenshot_by_slot(
+ request.user, rom, parsed.emulator, parsed.file_name
+ )
+ if not screenshot:
+ return _empty(status.HTTP_404_NOT_FOUND)
+
+ log.info(f"Cloud sync delete {hl(screenshot.file_name)} [{rom.platform_slug}]")
+ db_screenshot_handler.delete_screenshot(screenshot.id)
+ try:
+ await fs_asset_handler.remove_file(file_path=screenshot.full_path)
+ except FileNotFoundError:
+ pass
+
+ return _empty(status.HTTP_204_NO_CONTENT)
+
asset = _get_asset(request.user, rom, parsed, parsed.file_name)
if not asset:
return _empty(status.HTTP_404_NOT_FOUND)
diff --git a/backend/handler/cloud_sync_handler.py b/backend/handler/cloud_sync_handler.py
index 183153c227..e4d6f84349 100644
--- a/backend/handler/cloud_sync_handler.py
+++ b/backend/handler/cloud_sync_handler.py
@@ -13,15 +13,16 @@
import os
import re
-from collections.abc import Callable
+from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Literal
+from handler import cloud_sync_psp
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, fs_cloud_sync_blob_handler
from handler.redis_handler import async_cache
-from models.assets import Save, State
+from models.assets import Save, Screenshot, State
from models.rom import Rom
from models.user import User
@@ -84,16 +85,128 @@ def parse_cloud_sync_path(path: str) -> CloudSyncPath | None:
)
+def is_state_screenshot_path(file_name: str) -> bool:
+ """Whether a `states/...` file is the PNG screenshot RetroArch captures
+ and syncs alongside a state (`.png`), rather than the
+ state itself."""
+ return file_name.lower().endswith(".png")
+
+
def game_name_from_file_name(kind: AssetKind, file_name: str) -> str:
"""The ROM file name (minus extension) an asset file belongs to."""
if kind == "states":
- stripped = STATE_SUFFIX_PATTERN.sub("", file_name)
- if stripped != file_name:
+ base = file_name[: -len(".png")] if is_state_screenshot_path(file_name) else file_name
+ stripped = STATE_SUFFIX_PATTERN.sub("", base)
+ if stripped != base:
return stripped
+ return os.path.splitext(base)[0]
return os.path.splitext(file_name)[0]
+def state_slot_suffix(file_name: str) -> str:
+ """The RetroArch slot suffix (``state``, ``state1``, ..., ``state.auto``)
+ a state's file name ends in -- RomM has no ``slot`` column for states
+ (unlike saves), so this is the only way to group a rom's states into the
+ numbered load slots (0-999) RetroArch's own Load State menu offers.
+
+ A state uploaded through RomM's web player instead carries a display
+ label and timestamp (e.g. `` [2026-07-24 12-04-52-733].state``);
+ that still ends in a bare ``.state``, so it lands in slot 0 alongside
+ (and competing on recency with) any RetroArch-native slot-0 state --
+ mirroring the shim's `assetHistoryKey`/`splitAssetFileName`, which
+ resolved the identical ambiguity for the same reason: without this, a
+ web-uploaded state either has no reachable slot at all, or (naively
+ keyed by its raw file name) its own permanent one-off slot that grows
+ without bound.
+ """
+ match = STATE_SUFFIX_PATTERN.search(file_name)
+ if match:
+ return match.group(0)[1:].lower()
+ return os.path.splitext(file_name)[1][1:].lower()
+
+
+def latest_state_for_slot(
+ states: Iterable[State], rom_id: int, emulator: str | None, slot_suffix: str
+) -> State | None:
+ """The state RetroArch would see for a given (rom, emulator, slot) --
+ whichever matching row was updated most recently, regardless of whether
+ it came from a real RetroArch upload or RomM's own web player. Ties
+ (e.g. a bulk migration timestamp shared by several rows) break on `id`,
+ the same deterministic tiebreaker the shim's `sortByRecency` uses, so a
+ manifest build and a later GET/DELETE for the same slot always agree on
+ which row "the newest" actually is.
+ """
+ candidates = [
+ state
+ for state in states
+ if state.rom_id == rom_id
+ and state.emulator == emulator
+ and state_slot_suffix(state.file_name) == slot_suffix
+ ]
+ if not candidates:
+ return None
+
+ return max(candidates, key=lambda state: (state.updated_at, state.id))
+
+
+def group_states_by_slot(
+ states: Iterable[State],
+) -> dict[tuple[int, str | None, str], State]:
+ """Every (rom, emulator, slot) bucket collapsed to its newest state --
+ the same grouping `latest_state_for_slot` performs, computed once for
+ every state instead of once per slot so `build_manifest` doesn't rescan
+ the full state list for every rom it lists."""
+ latest: dict[tuple[int, str | None, str], State] = {}
+ for state in states:
+ key = (state.rom_id, state.emulator, state_slot_suffix(state.file_name))
+ current = latest.get(key)
+ if current is None or (state.updated_at, state.id) > (
+ current.updated_at,
+ current.id,
+ ):
+ latest[key] = state
+
+ return latest
+
+
+def canonical_state_file_name(rom: Rom, slot_suffix: str) -> str:
+ """The file name RetroArch's own upload for this (rom, slot) would carry
+ -- what the manifest advertises, and what a GET/DELETE for this slot
+ must resolve back to the real underlying row via
+ ``resolve_state_by_slot``, regardless of that row's actual file name."""
+ return f"{rom.fs_name_no_ext}.{slot_suffix}"
+
+
+def resolve_state_by_slot(
+ user: User, rom: Rom, emulator: str | None, requested_file_name: str
+) -> State | None:
+ """The state a GET/DELETE for `requested_file_name` resolves to -- the
+ same "newest row in this (rom, emulator, slot) bucket" `build_manifest`
+ already advertised, found by re-deriving the slot from the *requested*
+ canonical name rather than trusting any single row's own file name to
+ match it exactly (it usually won't, for a web-player-created state)."""
+ slot_suffix = state_slot_suffix(requested_file_name)
+ states = db_state_handler.get_states(user_id=user.id, rom_id=rom.id)
+ return latest_state_for_slot(states, rom.id, emulator, slot_suffix)
+
+
+def resolve_state_screenshot_by_slot(
+ user: User, rom: Rom, emulator: str | None, requested_file_name: str
+) -> Screenshot | None:
+ """The screenshot a GET/DELETE for `.png` resolves to -- whatever
+ is attached to the same state ``resolve_state_by_slot`` would return for
+ that slot, since RetroArch always syncs a state's screenshot under
+ ``.png``."""
+ if not is_state_screenshot_path(requested_file_name):
+ return None
+
+ state = resolve_state_by_slot(
+ user, rom, emulator, requested_file_name[: -len(".png")]
+ )
+ return state.screenshot if state else None
+
+
def build_cloud_sync_path(kind: AssetKind, emulator: str | None, file_name: str) -> str:
if emulator:
return f"{kind}/{to_retroarch_dir_name(emulator)}/{file_name}"
@@ -200,7 +313,7 @@ def resolve_rom(game_name: str, can_see: Callable[[Rom], bool]) -> Rom | None:
return None
-async def asset_md5(asset: Save | State) -> str | None:
+async def asset_md5(asset: Save | State | Screenshot) -> str | None:
cache_key = (
f"romm:cloud_sync:md5:{asset.full_path}"
f":{asset.file_size_bytes}:{asset.updated_at.timestamp()}"
@@ -224,34 +337,66 @@ async def build_manifest(
Slotted saves are RomM's own versioned history: every revision carries a
datetime tag in its file name, so surfacing them would hand RetroArch a
- growing pile of files no core would ever load.
+ growing pile of files no core would ever load. States have no such
+ `slot` column, so they're grouped into RetroArch's own numbered slots by
+ file-name suffix instead (`group_states_by_slot`) -- the newest state in
+ each (rom, emulator, slot) bucket is surfaced under the canonical name
+ RetroArch itself would use, regardless of who actually created it.
"""
- assets: list[tuple[AssetKind, Save | State]] = [
- ("saves", save)
- for save in db_save_handler.get_saves(user_id=user.id)
- if save.slot is None
- ]
- assets += [
- ("states", state) for state in db_state_handler.get_states(user_id=user.id)
- ]
-
entries: list[dict[str, str]] = []
- for kind, asset in assets:
- if asset.missing_from_fs or not can_see(asset.rom):
+
+ for save in db_save_handler.get_saves(user_id=user.id):
+ if (
+ save.slot is not None
+ or save.missing_from_fs
+ or not can_see(save.rom)
+ or cloud_sync_psp.is_psp_bundle_file_name(save.file_name)
+ ):
+ continue
+
+ digest = await asset_md5(save)
+ if not digest:
+ continue
+
+ entries.append(
+ {
+ "path": build_cloud_sync_path("saves", save.emulator, save.file_name),
+ "hash": digest,
+ }
+ )
+
+ states_by_slot = group_states_by_slot(db_state_handler.get_states(user_id=user.id))
+ for (_rom_id, emulator, slot_suffix), state in states_by_slot.items():
+ if state.missing_from_fs or not can_see(state.rom):
continue
- digest = await asset_md5(asset)
+ digest = await asset_md5(state)
if not digest:
continue
+ file_name = canonical_state_file_name(state.rom, slot_suffix)
entries.append(
{
- "path": build_cloud_sync_path(kind, asset.emulator, asset.file_name),
+ "path": build_cloud_sync_path("states", emulator, file_name),
"hash": digest,
}
)
+ screenshot = state.screenshot
+ if screenshot and not screenshot.missing_from_fs:
+ screenshot_digest = await asset_md5(screenshot)
+ if screenshot_digest:
+ entries.append(
+ {
+ "path": build_cloud_sync_path(
+ "states", emulator, f"{file_name}.png"
+ ),
+ "hash": screenshot_digest,
+ }
+ )
+
entries += await build_blob_manifest_entries(user)
+ entries += await cloud_sync_psp.build_psp_manifest_entries(user, can_see)
entries.sort(key=lambda entry: entry["path"])
return entries
diff --git a/backend/handler/cloud_sync_psp.py b/backend/handler/cloud_sync_psp.py
new file mode 100644
index 0000000000..85288b6cda
--- /dev/null
+++ b/backend/handler/cloud_sync_psp.py
@@ -0,0 +1,469 @@
+"""RetroArch Cloud Sync support for PPSSPP's PSP save-folder layout.
+
+PPSSPP (the PSP core) doesn't save a single file per game like every other
+core -- it mirrors a real PSP's memory stick layout under RetroArch's own
+saves directory: ``saves//PSP/SAVEDATA//`` holds several small
+files (PARAM.SFO, the actual save data, ICON0.PNG, PIC1.PNG, ...) that only
+make sense as a set, plus ``saves//PSP/SYSTEM/CACHE/`` holds pure
+engine caches (shader caches etc.) with no save data at all.
+
+This mirrors the retroarch-webdav-romm shim's ``pspSave.ts``: a save
+folder's files are bundled into a single zip stored as one RomM ``Save``,
+and unbundled again for GET/manifest purposes -- everywhere else in
+cloud sync, "one WebDAV path = one RomM asset" holds, but it doesn't here.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+import struct
+import zipfile
+from collections.abc import Callable
+from dataclasses import dataclass
+from io import BytesIO
+from typing import Literal
+
+from config import CLOUD_SYNC_PSP_PENDING_PATH, PSP_SERIAL_MAP
+from handler.cloud_sync_emulator_names import to_retroarch_dir_name
+from utils.zip_cache import _ensure_zipfile_writable
+from handler.database import db_platform_handler, db_rom_handler, db_save_handler
+from handler.filesystem import fs_asset_handler
+from handler.filesystem.base_handler import FSHandler
+from logger.formatter import highlight as hl
+from logger.logger import log
+from models.assets import Save
+from models.rom import Rom
+from models.user import User
+
+_IGNORED_CATEGORY = "SYSTEM"
+_SAVEDATA_CATEGORY = "SAVEDATA"
+
+fs_psp_pending_handler = FSHandler(base_path=CLOUD_SYNC_PSP_PENDING_PATH)
+
+
+class PspFolderUnresolved(Exception):
+ """A PSP save folder's files arrived but couldn't (yet) be matched to a
+ rom -- buffered on disk until a later file (usually PARAM.SFO) resolves
+ it, or forever if it never does (add it to PSP_SERIAL_MAP)."""
+
+
+@dataclass(frozen=True)
+class PspFilePath:
+ """A parsed ``saves//PSP/SAVEDATA//``
+ cloud-sync path."""
+
+ emulator: str
+ save_folder: str
+ file_name: str
+
+
+def resolve_psp_path(file_path: str) -> PspFilePath | Literal["ignore"] | None:
+ """Classifies a ``saves/...`` cloud-sync path as a PSP save-folder file,
+ PSP engine-cache noise to ignore, or neither (a normal single-file save
+ -- None, let the generic save/state path handle it)."""
+ segments = [s for s in file_path.strip("/").split("/") if s]
+ if len(segments) < 4 or segments[0] != "saves":
+ return None
+ if segments[2].upper() != "PSP":
+ return None
+
+ category = segments[3].upper()
+ if category == _IGNORED_CATEGORY:
+ return "ignore"
+ if category != _SAVEDATA_CATEGORY:
+ return None
+ if len(segments) < 6:
+ return None
+
+ return PspFilePath(
+ emulator=segments[1],
+ save_folder=segments[4],
+ file_name="/".join(segments[5:]),
+ )
+
+
+def _bundle_base_name(save_folder: str) -> str:
+ return f"PSP-{save_folder}.zip"
+
+
+def is_psp_bundle_file_name(file_name: str) -> bool:
+ """Whether a stored save's file name is a PSP bundle -- used by
+ `build_manifest` to exclude these from normal single-file save
+ handling."""
+ return re.match(r"^PSP-.+\.zip$", file_name) is not None
+
+
+def _bundle_pattern(save_folder: str) -> re.Pattern[str]:
+ """Matches a stored bundle filename by prefix/suffix only, tolerating
+ whatever else ends up between them -- there is nothing else to key on,
+ since bundles are update-in-place (one row per save folder), not
+ history-preserving."""
+ return re.compile(rf"^PSP-{re.escape(save_folder)}\b.*\.zip$")
+
+
+def _find_bundle_by_folder(user: User, save_folder: str) -> Save | None:
+ """Finds the current bundle for a save folder by its name alone -- the
+ folder name (e.g. "ULUS10336DATA0") is already a globally unique
+ identifier for a given game+slot, so this works without ever needing
+ PSP_SERIAL_MAP or PARAM.SFO except on the very first upload of a new
+ folder."""
+ pattern = _bundle_pattern(save_folder)
+ saves = db_save_handler.get_saves(user_id=user.id)
+ candidates = [s for s in saves if pattern.match(s.file_name)]
+ if not candidates:
+ return None
+ return max(candidates, key=lambda s: (s.updated_at, s.id))
+
+
+def _derive_serial(save_folder: str) -> str:
+ return re.sub(r"DATA\d+$", "", save_folder, flags=re.IGNORECASE)
+
+
+def _normalize_title(s: str) -> str:
+ """Loose title comparison for matching a PARAM.SFO TITLE (e.g. "CRISIS
+ CORE -FINAL FANTASY VII-") against RomM's filename-derived titles (e.g.
+ "Crisis Core - Final Fantasy VII (USA)") -- collapses both down to bare
+ alphanumerics so punctuation/casing/spacing differences (the norm
+ between PSF titles and filename-derived ones) don't block an otherwise
+ obvious match."""
+ return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()
+
+
+def parse_sfo(data: bytes) -> dict[str, str | int]:
+ """Minimal parser for the PSP's PARAM.SFO ("PSF") format -- the file
+ PPSSPP writes into every save folder describing that save. Only two of
+ its fields matter here: DISC_ID (the game's serial, e.g. "ULUS10336")
+ and TITLE (the game's display name), to resolve a save folder to a RomM
+ rom.
+
+ Format: a 20-byte header, a fixed-size index table (one 16-byte entry
+ per key), a key table (NUL-terminated ASCII strings), and a data table
+ (UTF-8 strings or little-endian integers, per entry).
+ """
+ if len(data) < 20 or data[0:4] != b"\x00PSF":
+ raise ValueError("Not a PARAM.SFO file (bad magic)")
+
+ key_table_offset, data_table_offset, entry_count = struct.unpack_from(
+ "= 4 else 0
+ else:
+ nul = raw_value.find(b"\x00")
+ result[key] = raw_value[: nul if nul != -1 else None].decode(
+ "utf-8", errors="replace"
+ )
+
+ return result
+
+
+def _match_by_normalized_title(
+ title: str, can_see: Callable[[Rom], bool]
+) -> Rom | None:
+ platform = db_platform_handler.get_platform_by_fs_slug("psp")
+ platform_ids = [platform.id] if platform else None
+ candidates = [
+ rom
+ for rom in db_rom_handler.get_roms_scalar(
+ search_term=title, platform_ids=platform_ids
+ )
+ if can_see(rom)
+ ]
+
+ target = _normalize_title(title)
+ for attr in ("fs_name_no_tags", "name", "fs_name_no_ext"):
+ for rom in candidates:
+ value = getattr(rom, attr, None)
+ if value and _normalize_title(value) == target:
+ return rom
+
+ return None
+
+
+def _resolve_rom(
+ save_folder: str, sfo_title: str | None, can_see: Callable[[Rom], bool]
+) -> Rom | None:
+ """Resolves a PSP save folder to a RomM rom. Tries the PARAM.SFO title
+ first -- it's already sent as part of every save, so this is what makes
+ PSP saves sync automatically with zero manual setup for the common
+ case. PSP_SERIAL_MAP is an explicit override checked first when
+ present, and the fallback when SFO title matching doesn't find
+ anything -- some games' PSF titles are abbreviated/stylized enough that
+ normalization alone won't bridge the gap to RomM's filename-derived
+ title."""
+ serial = _derive_serial(save_folder)
+ mapped_title = PSP_SERIAL_MAP.get(serial)
+ if mapped_title:
+ candidates = [
+ rom
+ for rom in db_rom_handler.get_roms_by_fs_name_no_ext(mapped_title)
+ if can_see(rom)
+ ]
+ if candidates:
+ return candidates[0]
+ log.warning(
+ f"PSP_SERIAL_MAP entry for {hl(serial)} ({hl(mapped_title)}) "
+ "didn't match any rom in the library"
+ )
+
+ if sfo_title:
+ rom = _match_by_normalized_title(sfo_title, can_see)
+ if rom:
+ log.info(
+ f"Resolved PSP save folder {hl(save_folder)} via PARAM.SFO "
+ f"title {hl(sfo_title)} to {hl(str(rom.name))}"
+ )
+ return rom
+ log.warning(
+ f"Couldn't auto-match PARAM.SFO title {hl(sfo_title)} for PSP save "
+ f"folder {hl(save_folder)} -- add serial {hl(serial)} to "
+ "PSP_SERIAL_MAP if this keeps happening"
+ )
+
+ return None
+
+
+def _load_bundle_entries(zip_bytes: bytes) -> dict[str, bytes]:
+ with zipfile.ZipFile(BytesIO(zip_bytes)) as zf:
+ return {name: zf.read(name) for name in zf.namelist()}
+
+
+def _write_bundle(entries: dict[str, bytes]) -> bytes:
+ # `zipfile_inflate64` (pulled in elsewhere for ROM archive reading)
+ # replaces `zipfile._get_compressor` with a signature CPython 3.13's
+ # own `ZipFile.writestr()` can't call -- see `_ensure_zipfile_writable`.
+ _ensure_zipfile_writable()
+ buffer = BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ for name, data in entries.items():
+ zf.writestr(name, data)
+ return buffer.getvalue()
+
+
+def _pending_dir(user: User, save_folder: str) -> str:
+ return f"{user.id}/{save_folder}"
+
+
+
+
+# Per-(user, save folder) locks. FastAPI/uvicorn typically runs this as a
+# single worker process for a RomM instance, so an in-process asyncio.Lock
+# is enough to serialize the read-modify-write over a folder's bundle the
+# same way the shim's single-threaded Node process naturally did --
+# concurrent multi-worker deployments would need a distributed lock
+# instead, which nothing else in cloud sync uses either.
+_folder_locks: dict[str, asyncio.Lock] = {}
+
+
+def _get_folder_lock(key: str) -> asyncio.Lock:
+ lock = _folder_locks.get(key)
+ if lock is None:
+ lock = asyncio.Lock()
+ _folder_locks[key] = lock
+ return lock
+
+
+async def put_psp_file(
+ user: User, info: PspFilePath, content: bytes, can_see: Callable[[Rom], bool]
+) -> None:
+ """Merges one uploaded file into its save folder's bundle and
+ re-uploads it. Deliberately does not preserve per-file history the way
+ normal saves/states do: PPSSPP writes a save folder as a burst of
+ several individual file PUTs a fraction of a second apart, so keeping
+ every intermediate partially-merged bundle as its own history entry
+ would just be noise -- only the final, fully-merged state after a save
+ event is a meaningful checkpoint. The previous bundle row is deleted
+ once the merged one is up, so RomM holds exactly one row per (rom, save
+ folder) at a time.
+
+ Raises `PspFolderUnresolved` if the folder can't yet be matched to a
+ rom -- the file is buffered on disk and will be folded in once it is.
+ """
+ lock = _get_folder_lock(f"{user.id}:{info.save_folder}")
+ async with lock:
+ existing = _find_bundle_by_folder(user, info.save_folder)
+
+ prior_entries: dict[str, bytes] = {}
+ if existing:
+ rom_id = existing.rom_id
+ zip_bytes = await fs_asset_handler.read_file(existing.full_path)
+ prior_entries = _load_bundle_entries(zip_bytes)
+ else:
+ sfo_title = None
+ if info.file_name.upper() == "PARAM.SFO":
+ try:
+ parsed = parse_sfo(content)
+ if isinstance(parsed.get("TITLE"), str):
+ sfo_title = str(parsed["TITLE"])
+ except ValueError as exc:
+ log.warning(f"Failed to parse PARAM.SFO: {exc}")
+
+ rom = _resolve_rom(info.save_folder, sfo_title, can_see)
+ if rom is None:
+ pending_dir = _pending_dir(user, info.save_folder)
+ await fs_psp_pending_handler.write_file(
+ file=content,
+ path=pending_dir,
+ filename=info.file_name,
+ )
+ serial = _derive_serial(info.save_folder)
+ log.warning(
+ f"No rom found yet for PSP save folder {hl(info.save_folder)} "
+ f"(serial {hl(serial)}) -- buffered {hl(info.file_name)}, will "
+ "merge it in once resolved (e.g. PARAM.SFO arrives)"
+ )
+ raise PspFolderUnresolved(info.save_folder)
+ rom_id = rom.id
+
+ # Now resolved (either an existing bundle, or fresh via this call)
+ # -- fold in anything buffered earlier while this folder was
+ # unresolved.
+ pending_dir = _pending_dir(user, info.save_folder)
+ pending: dict[str, bytes] = {}
+ try:
+ pending_names = await fs_psp_pending_handler.list_files(pending_dir)
+ except FileNotFoundError:
+ pending_names = []
+ for name in pending_names:
+ pending[name] = await fs_psp_pending_handler.read_file(
+ f"{pending_dir}/{name}"
+ )
+
+ merged = {**prior_entries, **pending}
+ merged[info.file_name] = content
+ zip_bytes = _write_bundle(merged)
+
+ rom = db_rom_handler.get_rom(rom_id)
+ assert rom is not None
+ saves_path = fs_asset_handler.build_saves_file_path(
+ user=user,
+ platform_fs_slug=rom.platform.fs_slug,
+ rom_id=rom.id,
+ emulator=info.emulator,
+ )
+ bundle_name = _bundle_base_name(info.save_folder)
+ await fs_asset_handler.write_file(
+ file=zip_bytes, path=saves_path, filename=bundle_name
+ )
+
+ if existing:
+ db_save_handler.update_save(
+ existing.id, {"file_size_bytes": len(zip_bytes)}
+ )
+ else:
+ db_save_handler.add_save(
+ Save(
+ rom_id=rom_id,
+ user_id=user.id,
+ file_name=bundle_name,
+ file_path=saves_path,
+ file_size_bytes=len(zip_bytes),
+ emulator=info.emulator,
+ slot=None,
+ )
+ )
+
+ if pending_names:
+ for name in pending_names:
+ try:
+ await fs_psp_pending_handler.remove_file(
+ f"{pending_dir}/{name}"
+ )
+ except FileNotFoundError:
+ pass
+
+
+async def get_psp_file(user: User, info: PspFilePath) -> bytes | None:
+ bundle = _find_bundle_by_folder(user, info.save_folder)
+ if not bundle:
+ return None
+ zip_bytes = await fs_asset_handler.read_file(bundle.full_path)
+ entries = _load_bundle_entries(zip_bytes)
+ return entries.get(info.file_name)
+
+
+async def delete_psp_folder(user: User, save_folder: str) -> bool:
+ """Drops an entire PSP save folder's bundle -- RetroArch deletes a save
+ folder file-by-file, but since the bundle is one row, the first delete
+ for a folder removes it and the rest are silent no-ops (matching
+ `find_bundle_by_folder` returning nothing for them)."""
+ bundle = _find_bundle_by_folder(user, save_folder)
+ if not bundle:
+ return False
+
+ db_save_handler.delete_save(bundle.id)
+ try:
+ await fs_asset_handler.remove_file(file_path=bundle.full_path)
+ except FileNotFoundError:
+ pass
+ return True
+
+
+_BUNDLE_FOLDER_PATTERN = re.compile(r"^PSP-(.+?)(?: \[.*])?\.zip$")
+
+
+async def build_psp_manifest_entries(
+ user: User, can_see: Callable[[Rom], bool]
+) -> list[dict[str, str]]:
+ """Lists every member of every PSP bundle as its own manifest entry --
+ RetroArch diffs per-file, so each PARAM.SFO/ICON0.PNG/save-data file
+ within a folder needs its own {path, hash}, not one entry for the whole
+ bundle."""
+ import hashlib
+
+ saves = db_save_handler.get_saves(user_id=user.id)
+
+ latest_by_folder: dict[str, Save] = {}
+ for save in saves:
+ match = _BUNDLE_FOLDER_PATTERN.match(save.file_name)
+ if not match:
+ continue
+ if save.missing_from_fs or not can_see(save.rom):
+ continue
+ save_folder = match.group(1)
+ current = latest_by_folder.get(save_folder)
+ if current is None or (save.updated_at, save.id) > (
+ current.updated_at,
+ current.id,
+ ):
+ latest_by_folder[save_folder] = save
+
+ entries: list[dict[str, str]] = []
+ for save_folder, save in latest_by_folder.items():
+ try:
+ zip_bytes = await fs_asset_handler.read_file(save.full_path)
+ members = _load_bundle_entries(zip_bytes)
+ except (FileNotFoundError, zipfile.BadZipFile) as exc:
+ log.warning(
+ f"Failed to read PSP bundle for {hl(save_folder)}, skipping "
+ f"from manifest: {exc}"
+ )
+ continue
+
+ dir_name = to_retroarch_dir_name(save.emulator) if save.emulator else "PPSSPP"
+ for member_name, data in members.items():
+ entries.append(
+ {
+ "path": f"saves/{dir_name}/PSP/SAVEDATA/{save_folder}/{member_name}",
+ "hash": hashlib.md5(data, usedforsecurity=False).hexdigest(),
+ }
+ )
+
+ return entries
diff --git a/backend/handler/webdav_browser.py b/backend/handler/webdav_browser.py
new file mode 100644
index 0000000000..bed069caed
--- /dev/null
+++ b/backend/handler/webdav_browser.py
@@ -0,0 +1,182 @@
+"""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 urllib.parse import quote
+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)
+
+
+# This module's PropfindEntry.href values are relative to the router's own
+# mount point (e.g. "roms/", "saves/Snes9x/"), matching the shim's
+# `romBrowser.ts`/`webdavXml.ts` -- but WebDAV clients expect every
+# in a multistatus response to be an absolute path from the *server* root,
+# not relative to the collection being PROPFIND'd. Verified live: without
+# this prefix, a client (Cyberduck) couldn't match the "self" entry's href
+# back to the path it had just requested, and rendered it as an extra,
+# never-ending nested subfolder instead of recognizing it as the current
+# directory -- the same mismatch made every subfolder look like it
+# contained a copy of the whole tree again.
+WEBDAV_MOUNT_PREFIX = "/api/cloud-sync"
+
+
+def _href_escape(path: str) -> str:
+ full_path = f"{WEBDAV_MOUNT_PREFIX}/{path}" if path else f"{WEBDAV_MOUNT_PREFIX}/"
+ segments = full_path.strip("/").split("/")
+ escaped = "/" + "/".join(quote(segment, safe="") for segment in segments)
+ return escaped + "/" if full_path.endswith("/") else escaped
+
+
+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..fae35270d2 100644
--- a/backend/tests/endpoints/test_cloud_sync.py
+++ b/backend/tests/endpoints/test_cloud_sync.py
@@ -3,11 +3,11 @@
import pytest
from fastapi import status
-from handler import cloud_sync_handler
+from handler import cloud_sync_handler, cloud_sync_psp
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.database import db_save_handler, db_screenshot_handler, db_state_handler
from handler.filesystem import fs_asset_handler
-from models.assets import Save, State
+from models.assets import Save, Screenshot, State
from models.rom import Rom
from models.user import User
@@ -43,6 +43,67 @@ def synced_save(admin_user: User, rom: Rom, saves_path: str):
)
+@pytest.fixture
+def states_path(admin_user: User, rom: Rom):
+ return fs_asset_handler.build_states_file_path(
+ user=admin_user,
+ platform_fs_slug="test_platform_slug",
+ rom_id=rom.id,
+ emulator="snes9x",
+ )
+
+
+@pytest.fixture
+def synced_state(admin_user: User, rom: Rom, states_path: str):
+ """A state named the way RetroArch itself would name one -- `.state`
+ -- unlike the shared `state` fixture, whose file name is a test-only
+ placeholder unrelated to `rom.fs_name_no_ext`."""
+ return db_state_handler.add_state(
+ State(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name="test_rom.state",
+ file_path=states_path,
+ file_size_bytes=4,
+ emulator="snes9x",
+ )
+ )
+
+
+@pytest.fixture
+def synced_state_screenshot(admin_user: User, rom: Rom, synced_state: State):
+ """The screenshot RetroArch captures and syncs alongside a state, under
+ `.png` -- attached to the ROM, not the state row
+ itself (there's no `screenshot_id` column on `State`; `state.screenshot`
+ finds it by matching file name stems)."""
+ return db_screenshot_handler.add_screenshot(
+ Screenshot(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name=f"{synced_state.file_name}.png",
+ file_path=synced_state.file_path,
+ file_size_bytes=8,
+ )
+ )
+
+
+@pytest.fixture
+def web_state(admin_user: User, rom: Rom, states_path: str):
+ """A state named the way RomM's own web player names one: a display
+ label plus a timestamp, with no relation to RetroArch's `.state[N]`
+ numbered-slot convention -- see `is_retroarch_loadable_state`."""
+ return db_state_handler.add_state(
+ State(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name="test_rom [2026-07-24 12-04-52-733].state",
+ file_path=states_path,
+ file_size_bytes=4,
+ emulator="snes9x",
+ )
+ )
+
+
class TestCloudSyncEmulatorNames:
@pytest.mark.parametrize(
("retroarch_dir_name", "romm_emulator"),
@@ -139,8 +200,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")
@@ -153,6 +215,73 @@ def test_put_without_credentials_challenges(self, client):
assert response.status_code == status.HTTP_401_UNAUTHORIZED
+class TestCloudSyncStateSlotResolution:
+ def test_resolves_canonical_name_to_the_web_created_row(
+ self, admin_user: User, rom: Rom, web_state: State
+ ):
+ """A GET/DELETE for the canonical slot name the manifest advertised
+ must resolve back to the real row even though its actual `file_name`
+ (a web-player timestamp label) never matches that canonical name."""
+ resolved = cloud_sync_handler.resolve_state_by_slot(
+ admin_user, rom, "snes9x", "test_rom.state"
+ )
+
+ assert resolved is not None
+ assert resolved.id == web_state.id
+
+ def test_resolves_to_the_newer_of_two_competing_states(
+ self, admin_user: User, rom: Rom, states_path: str
+ ):
+ older = db_state_handler.add_state(
+ State(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name="test_rom.state",
+ file_path=states_path,
+ file_size_bytes=4,
+ emulator="snes9x",
+ )
+ )
+ newer = db_state_handler.add_state(
+ State(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name="test_rom [2026-07-24 12-04-52-733].state",
+ file_path=states_path,
+ file_size_bytes=4,
+ emulator="snes9x",
+ )
+ )
+ assert newer.id > older.id
+
+ resolved = cloud_sync_handler.resolve_state_by_slot(
+ admin_user, rom, "snes9x", "test_rom.state"
+ )
+
+ assert resolved is not None
+ assert resolved.id == newer.id
+
+ def test_does_not_cross_slots(self, admin_user: User, rom: Rom, states_path: str):
+ """A slot-1 state must never resolve for a slot-0 request, even
+ though both belong to the same rom/emulator."""
+ db_state_handler.add_state(
+ State(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name="test_rom.state1",
+ file_path=states_path,
+ file_size_bytes=4,
+ emulator="snes9x",
+ )
+ )
+
+ resolved = cloud_sync_handler.resolve_state_by_slot(
+ admin_user, rom, "snes9x", "test_rom.state"
+ )
+
+ assert resolved is None
+
+
class TestCloudSyncManifest:
@mock.patch(
"handler.cloud_sync_handler.asset_md5",
@@ -165,7 +294,7 @@ def test_lists_saves_and_states(
client,
admin_user: User,
archival_save: Save,
- state: State,
+ synced_state: State,
):
response = client.get("/api/cloud-sync/manifest.server", auth=ADMIN_AUTH)
@@ -176,11 +305,86 @@ def test_lists_saves_and_states(
"hash": "d41d8cd98f00b204e9800998ecf8427e",
},
{
- "path": "states/test_emulator/test_state.state",
+ "path": "states/Snes9x/test_rom.state",
"hash": "d41d8cd98f00b204e9800998ecf8427e",
},
]
+ @mock.patch(
+ "handler.cloud_sync_handler.asset_md5",
+ new_callable=mock.AsyncMock,
+ return_value="d41d8cd98f00b204e9800998ecf8427e",
+ )
+ def test_remaps_web_player_state_to_canonical_slot(
+ self, _asset_md5: mock.AsyncMock, client, admin_user: User, web_state: State
+ ):
+ """A state uploaded through RomM's own web player carries a display
+ label and timestamp in its file name, not a RetroArch slot number --
+ RetroArch's Load State menu only ever offers numbered slots 0-999
+ (verified live: RetroArch fetched such a file during a real sync,
+ and it never appeared as a loadable slot because its raw file name
+ was surfaced as-is). It still belongs to slot 0 like any other
+ untagged state, so the manifest advertises it under RetroArch's own
+ canonical name for that slot instead of its raw file name."""
+ response = client.get("/api/cloud-sync/manifest.server", auth=ADMIN_AUTH)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == [
+ {
+ "path": "states/Snes9x/test_rom.state",
+ "hash": "d41d8cd98f00b204e9800998ecf8427e",
+ }
+ ]
+
+ @mock.patch(
+ "handler.cloud_sync_handler.asset_md5",
+ new_callable=mock.AsyncMock,
+ return_value="d41d8cd98f00b204e9800998ecf8427e",
+ )
+ def test_newest_state_in_a_slot_wins_regardless_of_origin(
+ self,
+ _asset_md5: mock.AsyncMock,
+ client,
+ admin_user: User,
+ rom: Rom,
+ states_path: str,
+ ):
+ """Two states competing for the same (rom, emulator, slot) bucket --
+ an older RetroArch-native one and a newer web-player one -- resolve
+ to whichever is actually newest, same as the shim's `sortByRecency`
+ picking "the" state for a slot regardless of who created it."""
+ older = db_state_handler.add_state(
+ State(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name="test_rom.state",
+ file_path=states_path,
+ file_size_bytes=4,
+ emulator="snes9x",
+ )
+ )
+ newer = db_state_handler.add_state(
+ State(
+ rom_id=rom.id,
+ user_id=admin_user.id,
+ file_name="test_rom [2026-07-24 12-04-52-733].state",
+ file_path=states_path,
+ file_size_bytes=4,
+ emulator="snes9x",
+ )
+ )
+ assert newer.id > older.id
+
+ response = client.get("/api/cloud-sync/manifest.server", auth=ADMIN_AUTH)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == [
+ {
+ "path": "states/Snes9x/test_rom.state",
+ "hash": "d41d8cd98f00b204e9800998ecf8427e",
+ }
+ ]
+
@mock.patch(
"handler.cloud_sync_handler.asset_md5",
new_callable=mock.AsyncMock,
@@ -222,6 +426,119 @@ def test_round_trips_emulator_casing_through_the_manifest(
]
+class TestCloudSyncStateScreenshots:
+ def test_game_name_strips_png_before_state_suffix(self):
+ """RetroArch syncs a state's screenshot as `.png`
+ (e.g. `test_rom.state.png`) -- the ROM name must resolve the same
+ way it would for the state itself, not stop at the `.state` segment
+ (verified live: RetroArch's upload of this file 409'd because the
+ naive last-dot split reported the game name as `test_rom.state`)."""
+ assert (
+ cloud_sync_handler.game_name_from_file_name(
+ "states", "test_rom.state.png"
+ )
+ == "test_rom"
+ )
+ assert (
+ cloud_sync_handler.game_name_from_file_name(
+ "states", "test_rom.state3.png"
+ )
+ == "test_rom"
+ )
+ assert (
+ cloud_sync_handler.game_name_from_file_name(
+ "states", "test_rom.state.auto.png"
+ )
+ == "test_rom"
+ )
+
+ @mock.patch(
+ "handler.cloud_sync_handler.asset_md5",
+ new_callable=mock.AsyncMock,
+ return_value="d41d8cd98f00b204e9800998ecf8427e",
+ )
+ def test_manifest_includes_the_state_screenshot(
+ self,
+ _asset_md5: mock.AsyncMock,
+ client,
+ admin_user: User,
+ synced_state: State,
+ synced_state_screenshot: Screenshot,
+ ):
+ response = client.get("/api/cloud-sync/manifest.server", auth=ADMIN_AUTH)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == [
+ {
+ "path": "states/Snes9x/test_rom.state",
+ "hash": "d41d8cd98f00b204e9800998ecf8427e",
+ },
+ {
+ "path": "states/Snes9x/test_rom.state.png",
+ "hash": "d41d8cd98f00b204e9800998ecf8427e",
+ },
+ ]
+
+ @mock.patch(
+ "endpoints.cloud_sync.fs_asset_handler.write_file", new_callable=mock.AsyncMock
+ )
+ @mock.patch("endpoints.cloud_sync.scan_screenshot", new_callable=mock.AsyncMock)
+ def test_creates_screenshot_for_a_new_state(
+ self,
+ mock_scan_screenshot: mock.AsyncMock,
+ _mock_write_file: mock.AsyncMock,
+ client,
+ admin_user: User,
+ rom: Rom,
+ synced_state: State,
+ ):
+ mock_scan_screenshot.return_value = Screenshot(
+ file_name="test_rom.state.png",
+ file_path=synced_state.file_path,
+ file_size_bytes=8,
+ )
+
+ response = client.put(
+ "/api/cloud-sync/states/Snes9x/test_rom.state.png",
+ content=b"pngdata",
+ auth=ADMIN_AUTH,
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ screenshots = db_screenshot_handler.get_screenshot(
+ rom_id=rom.id, user_id=admin_user.id, file_name="test_rom.state.png"
+ )
+ assert screenshots is not None
+
+ @mock.patch(
+ "endpoints.cloud_sync.fs_asset_handler.write_file", new_callable=mock.AsyncMock
+ )
+ @mock.patch("endpoints.cloud_sync.scan_screenshot", new_callable=mock.AsyncMock)
+ def test_overwrites_existing_screenshot_for_a_state(
+ self,
+ mock_scan_screenshot: mock.AsyncMock,
+ _mock_write_file: mock.AsyncMock,
+ client,
+ admin_user: User,
+ rom: Rom,
+ synced_state: State,
+ synced_state_screenshot: Screenshot,
+ ):
+ mock_scan_screenshot.return_value = Screenshot(
+ file_name="test_rom.state.png",
+ file_path=synced_state.file_path,
+ file_size_bytes=16,
+ )
+
+ response = client.put(
+ "/api/cloud-sync/states/Snes9x/test_rom.state.png",
+ content=b"newpngdata",
+ auth=ADMIN_AUTH,
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+
class TestCloudSyncUpload:
@mock.patch(
"endpoints.cloud_sync.fs_asset_handler.write_file", new_callable=mock.AsyncMock
@@ -427,6 +744,129 @@ def test_delete_of_unknown_file_is_not_found(self, client, admin_user: User):
assert response.status_code == status.HTTP_404_NOT_FOUND
+class TestCloudSyncPsp:
+ """End-to-end coverage of the PPSSPP save-folder bundling wired into the
+ GET/PUT/DELETE endpoints and the manifest -- unit coverage for the pure
+ parsing/matching logic lives in tests/handler/test_cloud_sync_psp.py.
+
+ Uses PSP_SERIAL_MAP to resolve the rom deterministically instead of a
+ real PARAM.SFO capture + fulltext title search, which would make this
+ test depend on the DB driver's fulltext support.
+ """
+
+ @pytest.fixture(autouse=True)
+ def _serial_map(self, monkeypatch: pytest.MonkeyPatch, rom: Rom):
+ monkeypatch.setattr(
+ cloud_sync_psp, "PSP_SERIAL_MAP", {"TEST12345": rom.fs_name_no_ext}
+ )
+
+ def test_ignores_system_cache_files(self, client, admin_user: User):
+ response = client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SYSTEM/CACHE/shader.bin",
+ content=b"cache data",
+ auth=ADMIN_AUTH,
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+ def test_bundles_multiple_files_into_one_save(
+ self, client, admin_user: User, rom: Rom
+ ):
+ put_sfo = client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/PARAM.SFO",
+ content=b"not real sfo bytes, resolved via PSP_SERIAL_MAP instead",
+ auth=ADMIN_AUTH,
+ )
+ assert put_sfo.status_code == status.HTTP_201_CREATED
+
+ put_data = client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/SAVE.BIN",
+ content=b"the actual save data",
+ auth=ADMIN_AUTH,
+ )
+ assert put_data.status_code == status.HTTP_201_CREATED
+
+ saves = db_save_handler.get_saves(user_id=admin_user.id, rom_id=rom.id)
+ assert len(saves) == 1
+ assert saves[0].file_name == "PSP-TEST12345DATA0.zip"
+
+ get_sfo = client.get(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/PARAM.SFO",
+ auth=ADMIN_AUTH,
+ )
+ assert get_sfo.status_code == status.HTTP_200_OK
+ assert get_sfo.content == b"not real sfo bytes, resolved via PSP_SERIAL_MAP instead"
+
+ get_data = client.get(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/SAVE.BIN",
+ auth=ADMIN_AUTH,
+ )
+ assert get_data.status_code == status.HTTP_200_OK
+ assert get_data.content == b"the actual save data"
+
+ def test_manifest_lists_each_bundle_member_separately(
+ self, client, admin_user: User
+ ):
+ client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/PARAM.SFO",
+ content=b"sfo",
+ auth=ADMIN_AUTH,
+ )
+ client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/SAVE.BIN",
+ 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
+ paths = {entry["path"] for entry in response.json()}
+ assert paths == {
+ "saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/PARAM.SFO",
+ "saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/SAVE.BIN",
+ }
+
+ def test_unresolved_folder_is_buffered_and_conflicts(
+ self, client, admin_user: User, monkeypatch: pytest.MonkeyPatch
+ ):
+ monkeypatch.setattr(cloud_sync_psp, "PSP_SERIAL_MAP", {})
+
+ response = client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/UNKNOWN99999DATA0/SAVE.BIN",
+ content=b"orphaned save data",
+ auth=ADMIN_AUTH,
+ )
+
+ assert response.status_code == status.HTTP_409_CONFLICT
+
+ def test_delete_removes_the_whole_bundle(self, client, admin_user: User, rom: Rom):
+ client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/PARAM.SFO",
+ content=b"sfo",
+ auth=ADMIN_AUTH,
+ )
+ client.put(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/SAVE.BIN",
+ content=b"data",
+ auth=ADMIN_AUTH,
+ )
+
+ response = client.request(
+ "DELETE",
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/SAVE.BIN",
+ auth=ADMIN_AUTH,
+ )
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert db_save_handler.get_saves(user_id=admin_user.id, rom_id=rom.id) == []
+
+ get_response = client.get(
+ "/api/cloud-sync/saves/PPSSPP/PSP/SAVEDATA/TEST12345DATA0/PARAM.SFO",
+ auth=ADMIN_AUTH,
+ )
+ assert get_response.status_code == status.HTTP_404_NOT_FOUND
+
+
class TestCloudSyncMkcol:
def test_mkcol_succeeds_without_creating_anything(self, client, admin_user: User):
response = client.request(
@@ -543,3 +983,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("/api/cloud-sync/roms/" in body
+ assert "/api/cloud-sync/saves/" in body
+ assert "/api/cloud-sync/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"/api/cloud-sync/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"/api/cloud-sync/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 "/api/cloud-sync/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 "/api/cloud-sync/saves/Snes9x/test_rom.srm" in response.text
diff --git a/backend/tests/handler/test_cloud_sync_psp.py b/backend/tests/handler/test_cloud_sync_psp.py
new file mode 100644
index 0000000000..150546549f
--- /dev/null
+++ b/backend/tests/handler/test_cloud_sync_psp.py
@@ -0,0 +1,94 @@
+import struct
+
+import pytest
+
+from handler.cloud_sync_psp import (
+ PspFilePath,
+ is_psp_bundle_file_name,
+ parse_sfo,
+ resolve_psp_path,
+)
+
+
+def _build_fake_sfo(fields: dict[str, str | int]) -> bytes:
+ """Builds a minimal valid PARAM.SFO buffer for the given fields, to
+ round-trip against `parse_sfo` without needing a real PPSSPP capture."""
+ key_table = bytearray()
+ data_table = bytearray()
+ entries = []
+
+ for key, value in fields.items():
+ key_offset = len(key_table)
+ key_table += key.encode("ascii") + b"\x00"
+
+ data_offset = len(data_table)
+ if isinstance(value, int):
+ data_fmt = 0x0404
+ data_bytes = struct.pack("+\0')
+INVALID_AFTER_SANITIZE = set('\\/:|*?"<>\0')
class TestLinkOrCopyFile:
diff --git a/backend/utils/filesystem.py b/backend/utils/filesystem.py
index 0bbfa85f06..e5bcb8c483 100644
--- a/backend/utils/filesystem.py
+++ b/backend/utils/filesystem.py
@@ -88,7 +88,12 @@ def link_or_copy_file(source: Path, dest: Path) -> None:
INVALID_CHARS_HYPHENS = re.compile(r"[\\/:|]")
-INVALID_CHARS_EMPTY = re.compile(r'[*?"<>+]')
+# "+" is not invalid on any major filesystem (Windows' actual reserved set is
+# `< > : " / \ | ? *`) -- stripping it here broke cloud-sync's filename-based
+# ROM matching for any game whose real fs_name contains one (e.g. "Super
+# Mario All-Stars + Super Mario World (USA)"), which RomM itself stores
+# on disk with the "+" intact.
+INVALID_CHARS_EMPTY = re.compile(r'[*?"<>]')
def sanitize_filename(filename: str) -> str: