Files
romm/backend/endpoints/collections.py
zurdi 4e686e1d74 fix(permissions): address PR review (Copilot + adversarial) findings
Resolves the CI blocker and a cluster of opt-out visibility "fail-open"
gaps surfaced in review of the granular permission system.

Security / correctness:
- admin oauth_scopes projection keeps canonical FULL_SCOPES order
  (order_scopes) instead of sorting alphabetically, fixing the red
  test_user.py::test_admin on MariaDB + Postgres.
- default-group hides no longer fail open: the resolver resolves the
  effective (own-or-default) group before the hidden-entity lookup.
- /roms/by-hash and /roms/by-metadata-provider now 404-mask hidden roms.
- USERS-entity grant no longer enables admin creation: add_user and
  invite-link require a real admin to mint admin accounts.

Visibility leaks closed on secondary read paths:
- feeds, sibling roms (list query + single-rom schemas), /stats counts
  and per-platform breakdowns, collection rom_ids/rom_count, search_rom.

Hardening / cleanups:
- firmware/platform PUT 404-mask hidden entities; group rename conflict
  returns 400 not 500; guard against removing the last default group;
  kiosk read-only enforced at the fine layer; add_hidden_entity rejects
  non-cascading entity types.

Frontend:
- permissionGroups.ensureLoaded coalesces concurrent callers on one
  in-flight request; permissions.setGrants resets isAdmin/hidden;
  CreateUserDialog no longer orphans a user when group assignment fails;
  HiddenGamesPicker search rows are native buttons (keyboard/gamepad);
  invite-role labels and group swatch aria-label use i18n; drop dead code
  (originalRole, unused permissionsApi export).

AI assistance: changes authored with Claude Code (Claude Opus), driven by
the Copilot review and a multi-agent adversarial review, then verified
(backend pytest, frontend typecheck/vitest, i18n parity, trunk).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:37:58 +00:00

700 lines
22 KiB
Python

import json
from datetime import datetime
from io import BytesIO
from typing import Annotated, TypeVar
from fastapi import File, Form, HTTPException
from fastapi import Path as PathVar
from fastapi import Query, Request, UploadFile, status
from pydantic import BaseModel as PydanticBaseModel
from decorators.auth import protected_route
from endpoints.responses.collection import (
CollectionSchema,
SmartCollectionSchema,
VirtualCollectionSchema,
)
from exceptions.endpoint_exceptions import (
CollectionAlreadyExistsException,
CollectionNotFoundInDatabaseException,
CollectionPermissionError,
)
from handler.auth.constants import Scope
from handler.auth.dependencies import get_permissions
from handler.database import db_collection_handler, db_rom_handler
from handler.filesystem import fs_resource_handler
from handler.filesystem.assets_handler import validate_image_upload
from handler.filesystem.base_handler import CoverSize
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.collection import (
Collection,
SmartCollection,
VirtualCollection,
)
from utils.router import APIRouter
from utils.validation import ValidationError
router = APIRouter(
prefix="/collections",
tags=["collections"],
)
COLLECTION_ARTWORK_FILE = File(default=None, description="Collection artwork file.")
CollectionSchemaT = TypeVar(
"CollectionSchemaT",
CollectionSchema,
VirtualCollectionSchema,
SmartCollectionSchema,
)
def _hide_collection_roms(
schemas: list[CollectionSchemaT], request: Request
) -> list[CollectionSchemaT]:
"""Drop hidden roms from each collection's `rom_ids`/`rom_count` for the caller.
Without this a collection leaks the ids (and inflated count) of roms hidden
from the user via the opt-out visibility model.
"""
if not request.user.is_authenticated or not schemas:
return schemas
perms = get_permissions(request)
if perms.is_admin or (not perms.hidden_platform_ids and not perms.hidden_rom_ids):
return schemas
all_ids = {rid for s in schemas for rid in s.rom_ids}
hidden = db_rom_handler.get_hidden_rom_ids_among(
list(all_ids),
list(perms.hidden_platform_ids),
list(perms.hidden_rom_ids),
)
if not hidden:
return schemas
for s in schemas:
visible = set(s.rom_ids) - hidden
if len(visible) != len(s.rom_ids):
s.rom_ids = visible
s.rom_count = len(visible)
return schemas
@protected_route(router.post, "", [Scope.COLLECTIONS_WRITE])
async def add_collection(
request: Request,
is_public: bool | None = None,
is_favorite: bool | None = None,
artwork: UploadFile | None = COLLECTION_ARTWORK_FILE,
name: str = Form(default=""),
description: str = Form(default=""),
url_cover: str = Form(
default="", description="Remote URL to fetch and use as cover artwork."
),
) -> CollectionSchema:
"""Create collection endpoint
Args:
request (Request): Fastapi Request object
Returns:
CollectionSchema: Just created collection
"""
cleaned_data = {
"name": name,
"description": description,
"url_cover": url_cover,
"is_public": is_public or False,
"is_favorite": is_favorite or False,
"user_id": request.user.id,
}
db_collection = db_collection_handler.get_collection_by_name(
cleaned_data["name"], request.user.id
)
if db_collection:
raise CollectionAlreadyExistsException(cleaned_data["name"])
_added_collection = db_collection_handler.add_collection(Collection(**cleaned_data))
try:
if artwork is not None and artwork.filename is not None:
file_ext = validate_image_upload(artwork, label="Artwork")
artwork_content = BytesIO(await artwork.read())
(
path_cover_l,
path_cover_s,
) = await fs_resource_handler.store_artwork(
_added_collection, artwork_content, file_ext
)
else:
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
entity=_added_collection,
overwrite=True,
url_cover=_added_collection.url_cover,
)
except ValidationError as e:
log.error(f"Invalid cover URL in add_collection: {str(e)}")
raise HTTPException(status_code=400, detail=str(e)) from e
_added_collection.path_cover_s = path_cover_s
_added_collection.path_cover_l = path_cover_l
# Update the collection with the cover path and update database
created_collection = db_collection_handler.update_collection(
_added_collection.id,
{
"path_cover_s": path_cover_s,
"path_cover_l": path_cover_l,
},
)
return CollectionSchema.model_validate(created_collection)
@protected_route(router.post, "/smart", [Scope.COLLECTIONS_WRITE])
async def add_smart_collection(
request: Request,
is_public: bool | None = None,
name: str = Form(default=""),
description: str = Form(default=""),
filter_criteria: str = Form(
default="{}",
description="Smart collection filters as a JSON string.",
),
) -> SmartCollectionSchema:
"""Create smart collection endpoint
Args:
request (Request): Fastapi Request object
Returns:
SmartCollectionSchema: Just created smart collection
"""
# Parse filter criteria from JSON string
try:
parsed_filter_criteria = json.loads(filter_criteria)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON for filter_criteria field",
) from e
cleaned_data = {
"name": name,
"description": description,
"filter_criteria": parsed_filter_criteria,
"is_public": is_public if is_public is not None else False,
"user_id": request.user.id,
}
db_smart_collection = db_collection_handler.get_smart_collection_by_name(
cleaned_data["name"], request.user.id
)
if db_smart_collection:
raise CollectionAlreadyExistsException(cleaned_data["name"])
created_smart_collection = db_collection_handler.add_smart_collection(
SmartCollection(**cleaned_data)
)
# Fetch the ROMs to update the database model
smart_collection = created_smart_collection.update_properties(request.user.id)
return SmartCollectionSchema.model_validate(smart_collection)
@protected_route(router.get, "", [Scope.COLLECTIONS_READ])
def get_collections(
request: Request,
updated_after: Annotated[
datetime | None,
Query(
description="Filter collections updated after this datetime (ISO 8601 format with timezone information)."
),
] = None,
) -> list[CollectionSchema]:
"""Get collections endpoint
Args:
request (Request): Fastapi Request object
updated_after: Filter collections updated after this datetime
Returns:
list[CollectionSchema]: List of collections
"""
collections = db_collection_handler.get_collections(updated_after=updated_after)
return _hide_collection_roms(
CollectionSchema.for_user(request.user.id, collections), request
)
@protected_route(router.get, "/identifiers", [Scope.COLLECTIONS_READ])
def get_collection_identifiers(
request: Request,
) -> list[int]:
"""Get collections identifiers endpoint
Args:
request (Request): Fastapi Request object
Returns:
list[int]: List of collection IDs
"""
collections = db_collection_handler.get_collections(
only_fields=[
Collection.id,
Collection.name,
Collection.user_id,
Collection.is_public,
],
)
return [c.id for c in collections if c.user_id == request.user.id or c.is_public]
@protected_route(router.get, "/virtual", [Scope.COLLECTIONS_READ])
def get_virtual_collections(
request: Request,
type: str,
limit: int | None = None,
) -> list[VirtualCollectionSchema]:
"""Get virtual collections endpoint
Args:
request (Request): Fastapi Request object
Returns:
list[VirtualCollectionSchema]: List of virtual collections
"""
virtual_collections = db_collection_handler.get_virtual_collections(
type=type, limit=limit
)
return _hide_collection_roms(
[VirtualCollectionSchema.model_validate(vc) for vc in virtual_collections],
request,
)
@protected_route(router.get, "/virtual/identifiers", [Scope.COLLECTIONS_READ])
def get_virtual_collection_identifiers(
request: Request,
) -> list[str]:
"""Get virtual collections identifiers endpoint
Args:
request (Request): Fastapi Request object
Returns:
list[str]: List of generated virtual collection IDs
"""
virtual_collections = db_collection_handler.get_virtual_collections(
type="all",
only_fields=[VirtualCollection.name, VirtualCollection.type],
)
return [s.id for s in virtual_collections]
@protected_route(router.get, "/smart", [Scope.COLLECTIONS_READ])
def get_smart_collections(
request: Request,
updated_after: Annotated[
datetime | None,
Query(
description="Filter smart collections updated after this datetime (ISO 8601 format with timezone information)."
),
] = None,
) -> list[SmartCollectionSchema]:
"""Get smart collections endpoint
Args:
request (Request): Fastapi Request object
updated_after: Filter smart collections updated after this datetime
Returns:
list[SmartCollectionSchema]: List of smart collections
"""
smart_collections = db_collection_handler.get_smart_collections(
request.user.id, updated_after=updated_after
)
return _hide_collection_roms(
SmartCollectionSchema.for_user(request.user.id, smart_collections), request
)
@protected_route(router.get, "/smart/identifiers", [Scope.COLLECTIONS_READ])
def get_smart_collection_identifiers(
request: Request,
) -> list[int]:
"""Get smart collections identifiers endpoint
Args:
request (Request): Fastapi Request object
Returns:
list[int]: List of smart collection IDs
"""
smart_collections = db_collection_handler.get_smart_collections(
request.user.id,
only_fields=[SmartCollection.id],
)
return [s.id for s in smart_collections]
@protected_route(router.get, "/{id}", [Scope.COLLECTIONS_READ])
def get_collection(request: Request, id: int) -> CollectionSchema:
"""Get collections endpoint
Args:
request (Request): Fastapi Request object
id (int, optional): Collection id. Defaults to None.
Returns:
CollectionSchema: Collection
"""
collection = db_collection_handler.get_collection(id)
if not collection:
raise CollectionNotFoundInDatabaseException(id)
if collection.user_id != request.user.id and not collection.is_public:
raise CollectionPermissionError(id)
return _hide_collection_roms(
[CollectionSchema.model_validate(collection)], request
)[0]
@protected_route(router.get, "/virtual/{id}", [Scope.COLLECTIONS_READ])
def get_virtual_collection(request: Request, id: str) -> VirtualCollectionSchema:
"""Get virtual collections endpoint
Args:
request (Request): Fastapi Request object
id (str): Virtual collection id
Returns:
VirtualCollectionSchema: Virtual collection
"""
virtual_collection = db_collection_handler.get_virtual_collection(id)
if not virtual_collection:
raise CollectionNotFoundInDatabaseException(id)
return _hide_collection_roms(
[VirtualCollectionSchema.model_validate(virtual_collection)], request
)[0]
@protected_route(router.get, "/smart/{id}", [Scope.COLLECTIONS_READ])
def get_smart_collection(request: Request, id: int) -> SmartCollectionSchema:
"""Get smart collection endpoint
Args:
request (Request): Fastapi Request object
id (int): Smart collection id
Returns:
SmartCollectionSchema: Smart collection
"""
smart_collection = db_collection_handler.get_smart_collection(id)
if not smart_collection:
raise CollectionNotFoundInDatabaseException(id)
if smart_collection.user_id != request.user.id and not smart_collection.is_public:
raise CollectionPermissionError(id)
return _hide_collection_roms(
[SmartCollectionSchema.model_validate(smart_collection)], request
)[0]
@protected_route(router.put, "/{id}", [Scope.COLLECTIONS_WRITE])
async def update_collection(
request: Request,
id: int,
remove_cover: bool = False,
is_public: bool | None = None,
artwork: UploadFile | None = COLLECTION_ARTWORK_FILE,
rom_ids: str = Form(
...,
description="Collection ROM IDs as a JSON array string (e.g. [1,2,3]).",
),
name: str | None = Form(default=None),
description: str | None = Form(default=None),
url_cover: str | None = Form(default=None, description="Updated remote cover URL."),
) -> CollectionSchema:
"""Update collection endpoint
Args:
request (Request): Fastapi Request object
Returns:
CollectionSchema: Updated collection
"""
collection = db_collection_handler.get_collection(id)
if not collection:
raise CollectionNotFoundInDatabaseException(id)
if collection.user_id != request.user.id:
raise CollectionPermissionError(id)
if not collection:
raise CollectionNotFoundInDatabaseException(id)
try:
parsed_rom_ids = json.loads(rom_ids)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=422,
detail="Invalid list for rom_ids field in update collection",
) from e
cleaned_data = {
"name": name if name is not None else collection.name,
"description": (
description if description is not None else collection.description
),
"is_public": is_public if is_public is not None else collection.is_public,
"user_id": request.user.id,
}
if remove_cover:
cleaned_data.update(await fs_resource_handler.remove_cover(collection))
cleaned_data.update({"url_cover": ""})
else:
if artwork is not None and artwork.filename is not None:
file_ext = validate_image_upload(artwork, label="Artwork")
artwork_content = BytesIO(await artwork.read())
(
path_cover_l,
path_cover_s,
) = await fs_resource_handler.store_artwork(
collection, artwork_content, file_ext
)
cleaned_data.update(
{
"url_cover": "",
"path_cover_s": path_cover_s,
"path_cover_l": path_cover_l,
}
)
else:
current_url_cover = (
url_cover if url_cover is not None else collection.url_cover
)
if (
current_url_cover != collection.url_cover
or not fs_resource_handler.cover_exists(collection, CoverSize.BIG)
):
try:
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
entity=collection,
overwrite=True,
url_cover=current_url_cover,
)
cleaned_data.update(
{
"url_cover": current_url_cover,
"path_cover_s": path_cover_s,
"path_cover_l": path_cover_l,
}
)
except ValidationError as e:
log.error(f"Invalid cover URL in update_collection: {str(e)}")
raise HTTPException(status_code=400, detail=str(e)) from e
updated_collection = db_collection_handler.update_collection(
id, cleaned_data, parsed_rom_ids
)
return CollectionSchema.model_validate(updated_collection)
class CollectionRomsPayload(PydanticBaseModel):
rom_ids: list[int]
@protected_route(router.post, "/{id}/roms", [Scope.COLLECTIONS_WRITE])
async def add_roms_to_collection(
request: Request,
id: int,
payload: CollectionRomsPayload,
) -> CollectionSchema:
"""Atomically add ROMs to a collection without replacing the full list.
Args:
request (Request): Fastapi Request object
id (int): Collection id
payload (CollectionRomsPayload): ROM IDs to add
Returns:
CollectionSchema: Updated collection
"""
collection = db_collection_handler.get_collection(id)
if not collection:
raise CollectionNotFoundInDatabaseException(id)
if collection.user_id != request.user.id:
raise CollectionPermissionError(id)
updated_collection = db_collection_handler.add_roms_to_collection(
id, payload.rom_ids
)
return CollectionSchema.model_validate(updated_collection)
@protected_route(router.delete, "/{id}/roms", [Scope.COLLECTIONS_WRITE])
async def remove_roms_from_collection(
request: Request,
id: int,
payload: CollectionRomsPayload,
) -> CollectionSchema:
"""Atomically remove ROMs from a collection without replacing the full list.
Args:
request (Request): Fastapi Request object
id (int): Collection id
payload (CollectionRomsPayload): ROM IDs to remove
Returns:
CollectionSchema: Updated collection
"""
collection = db_collection_handler.get_collection(id)
if not collection:
raise CollectionNotFoundInDatabaseException(id)
if collection.user_id != request.user.id:
raise CollectionPermissionError(id)
updated_collection = db_collection_handler.remove_roms_from_collection(
id, payload.rom_ids
)
return CollectionSchema.model_validate(updated_collection)
@protected_route(router.put, "/smart/{id}", [Scope.COLLECTIONS_WRITE])
async def update_smart_collection(
request: Request,
id: int,
is_public: bool | None = None,
name: str | None = Form(default=None),
description: str | None = Form(default=None),
filter_criteria: str | None = Form(
default=None,
description="Updated smart collection filters as a JSON string.",
),
) -> SmartCollectionSchema:
"""Update smart collection endpoint
Args:
request (Request): Fastapi Request object
id (int): Smart collection id
Returns:
SmartCollectionSchema: Updated smart collection
"""
smart_collection = db_collection_handler.get_smart_collection(id)
if not smart_collection:
raise CollectionNotFoundInDatabaseException(id)
if smart_collection.user_id != request.user.id:
raise CollectionPermissionError(id)
# Parse filter criteria if provided
parsed_filter_criteria = smart_collection.filter_criteria
if filter_criteria is not None:
try:
parsed_filter_criteria = json.loads(filter_criteria)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON for filter_criteria field",
) from e
cleaned_data = {
"name": name if name is not None else smart_collection.name,
"description": (
description if description is not None else smart_collection.description
),
"filter_criteria": parsed_filter_criteria,
"is_public": is_public if is_public is not None else smart_collection.is_public,
"user_id": request.user.id,
}
updated_smart_collection = db_collection_handler.update_smart_collection(
id, cleaned_data
)
# Fetch the ROMs to update the database model
smart_collection = updated_smart_collection.update_properties(request.user.id)
return SmartCollectionSchema.model_validate(smart_collection)
@protected_route(
router.delete,
"/{id}",
[Scope.COLLECTIONS_WRITE],
responses={status.HTTP_404_NOT_FOUND: {}},
)
async def delete_collection(
request: Request,
id: Annotated[int, PathVar(description="Collection internal id.", ge=1)],
) -> None:
"""Delete a collection by ID."""
collection = db_collection_handler.get_collection(id)
if not collection:
raise CollectionNotFoundInDatabaseException(id)
if collection.user_id != request.user.id:
raise CollectionPermissionError(id)
log.info(f"Deleting {hl(collection.name, color=BLUE)} from database")
db_collection_handler.delete_collection(id)
try:
await fs_resource_handler.remove_directory(collection.fs_resources_path)
except FileNotFoundError:
log.error(
f"Couldn't find resources to delete for {hl(collection.name, color=BLUE)}"
)
@protected_route(
router.delete,
"/smart/{id}",
[Scope.COLLECTIONS_WRITE],
responses={status.HTTP_404_NOT_FOUND: {}},
)
async def delete_smart_collection(
request: Request,
id: Annotated[int, PathVar(description="Smart collection internal id.", ge=1)],
) -> None:
"""Delete a smart collection by ID."""
smart_collection = db_collection_handler.get_smart_collection(id)
if not smart_collection:
raise CollectionNotFoundInDatabaseException(id)
if smart_collection.user_id != request.user.id:
raise CollectionPermissionError(id)
log.info(f"Deleting {hl(smart_collection.name, color=BLUE)} from database")
db_collection_handler.delete_smart_collection(id)