Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions backend/alembic/versions/0087_save_state_visibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add is_public flag to saves and states for per-user community sharing
(mirrors screenshots/notes visibility).

Revision ID: 0087_save_state_visibility
Revises: 0086_screenshot_visibility
Create Date: 2026-06-20 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0087_save_state_visibility"
down_revision = "0086_screenshot_visibility"
branch_labels = None
depends_on = None


def upgrade() -> None:
for table in ("saves", "states"):
op.add_column(
table,
sa.Column(
"is_public",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
if_not_exists=True,
)
op.create_index(f"idx_{table}_public", table, ["is_public"], if_not_exists=True)


def downgrade() -> None:
for table in ("saves", "states"):
op.drop_index(f"idx_{table}_public", table_name=table, if_exists=True)
op.drop_column(table, "is_public", if_exists=True)
22 changes: 22 additions & 0 deletions backend/endpoints/responses/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class SaveSchema(BaseAsset):
emulator: str | None
slot: str | None = None
content_hash: str | None = None
is_public: bool = False
screenshot: ScreenshotSchema | None
origin_device_id: str | None = None
device_syncs: list[DeviceSyncSchema] = []
Expand All @@ -72,6 +73,16 @@ def handle_lazy_relationships(cls, data: Any) -> Any:
return result


class UserSaveSchema(SaveSchema):
"""A save enriched with its owner's username, for the community (My /
Community) view. Mirrors UserScreenshotSchema."""

username: str
# Author identity for rendering an avatar next to community saves.
user_avatar_path: str = ""
user_updated_at: UTCDatetime | None = None


class SlotSummarySchema(BaseModel):
slot: str | None
count: int
Expand All @@ -85,4 +96,15 @@ class SaveSummarySchema(BaseModel):

class StateSchema(BaseAsset):
emulator: str | None
is_public: bool = False
screenshot: ScreenshotSchema | None


class UserStateSchema(StateSchema):
"""A state enriched with its owner's username, for the community (My /
Community) view. Mirrors UserScreenshotSchema."""

username: str
# Author identity for rendering an avatar next to community states.
user_avatar_path: str = ""
user_updated_at: UTCDatetime | None = None
40 changes: 40 additions & 0 deletions backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
SaveSchema,
ScreenshotSchema,
StateSchema,
UserSaveSchema,
UserScreenshotSchema,
UserStateSchema,
)
from handler.metadata.flashpoint_handler import FlashpointMetadata
from handler.metadata.gamelist_handler import GamelistMetadata
Expand Down Expand Up @@ -467,6 +469,8 @@ def for_user(
class DetailedRomSchema(RomSchema):
user_saves: list[SaveSchema]
user_states: list[StateSchema]
all_user_saves: list[UserSaveSchema]
all_user_states: list[UserStateSchema]
user_screenshots: list[ScreenshotSchema]
all_user_screenshots: list[UserScreenshotSchema]
user_collections: list[UserCollectionSchema]
Expand Down Expand Up @@ -558,6 +562,42 @@ def from_orm_with_request(cls, db_rom: Rom, request: Request) -> DetailedRomSche
for s in gallery_screenshots
]

# Saves/states visible to this user: own (public + private) plus other
# users' public ones. Mirrors the screenshots flow above.
from handler.database import db_save_handler, db_state_handler

# SaveSchema.model_validate handles the lazy `device_syncs` relationship
# (skips it when unloaded); reuse it rather than getattr-ing raw fields.
shared_saves = db_save_handler.get_rom_shared_saves(
rom_id=db_rom.id, user_id=user_id
)
db_rom.all_user_saves = [ # type: ignore[assignment]
UserSaveSchema.model_validate(
{
**SaveSchema.model_validate(s).model_dump(),
"username": s.user.username,
"user_avatar_path": s.user.avatar_path,
"user_updated_at": s.user.updated_at,
}
)
for s in shared_saves
]

shared_states = db_state_handler.get_rom_shared_states(
rom_id=db_rom.id, user_id=user_id
)
db_rom.all_user_states = [ # type: ignore[assignment]
UserStateSchema.model_validate(
{
**StateSchema.model_validate(s).model_dump(),
"username": s.user.username,
"user_avatar_path": s.user.avatar_path,
"user_updated_at": s.user.updated_at,
}
)
for s in shared_states
]

return cls.model_validate(db_rom)

@field_validator("user_saves")
Expand Down
32 changes: 29 additions & 3 deletions backend/endpoints/saves.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,14 @@ def download_save(
device_id, request.user.id, request.auth.scopes, Scope.DEVICES_READ
)

save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not save:
# Owner can download any of their saves; everyone else only public ones.
save = db_save_handler.get_save_by_id(id)
if not save or (save.user_id != request.user.id and not save.is_public):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Save with ID {id} not found",
)
is_owner = save.user_id == request.user.id

try:
file_path = fs_asset_handler.validate_path(save.full_path)
Expand All @@ -467,7 +469,8 @@ def download_save(
detail="Save file not found on disk",
)

if device and optimistic:
# Sync bookkeeping only makes sense for the owner's own saves.
if device and optimistic and is_owner:
db_device_save_sync_handler.upsert_sync(
device_id=device.id,
save_id=save.id,
Expand Down Expand Up @@ -605,6 +608,29 @@ async def update_save(
return _build_save_schema(db_save, _syncs_for_save(db_save.id, device), device)


@protected_route(
router.put,
"/{id}/visibility",
[Scope.ASSETS_WRITE],
responses={status.HTTP_404_NOT_FOUND: {}},
)
def update_save_visibility(
request: Request,
id: int,
is_public: Annotated[bool, Body(embed=True)],
) -> SaveSchema:
"""Toggle a save's public/private visibility (owner only)."""
save = db_save_handler.get_save_by_id(id)
if not save or save.user_id != request.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Save with ID {id} not found",
)

updated = db_save_handler.update_save(id, {"is_public": is_public})
return _build_save_schema(updated)


@protected_route(
router.post,
"/delete",
Expand Down
23 changes: 23 additions & 0 deletions backend/endpoints/states.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,29 @@ async def update_state(
return StateSchema.model_validate(db_state)


@protected_route(
router.put,
"/{id}/visibility",
[Scope.ASSETS_WRITE],
responses={status.HTTP_404_NOT_FOUND: {}},
)
def update_state_visibility(
request: Request,
id: int,
is_public: Annotated[bool, Body(embed=True)],
) -> StateSchema:
"""Toggle a state's public/private visibility (owner only)."""
state = db_state_handler.get_state_by_id(id)
if not state or state.user_id != request.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"State with ID {id} not found",
)

updated = db_state_handler.update_state(id, {"is_public": is_public})
return StateSchema.model_validate(updated)


@protected_route(
router.post,
"/delete",
Expand Down
34 changes: 33 additions & 1 deletion backend/handler/database/saves_handler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections.abc import Sequence
from typing import Literal

from sqlalchemy import and_, asc, delete, desc, func, select, update
from sqlalchemy import and_, asc, delete, desc, func, or_, select, update
from sqlalchemy.orm import QueryableAttribute, Session, load_only

from decorators.database import begin_session
Expand Down Expand Up @@ -100,6 +100,38 @@ def get_saves(

return session.scalars(query).all()

@begin_session
def get_save_by_id(
self,
id: int,
session: Session = None, # type: ignore
) -> Save | None:
"""Fetch a save by id without scoping to an owner. Used for the
visibility toggle and community downloads, where the caller may not own
the save. Mirrors db_screenshot_handler.get_screenshot_by_id."""
return session.get(Save, id)

@begin_session
def get_rom_shared_saves(
self,
rom_id: int,
user_id: int,
public_only: bool = False,
session: Session = None, # type: ignore
) -> Sequence[Save]:
"""Saves for a ROM visible to the requesting user: own (public +
private) plus other users' public ones. Mirrors
db_screenshot_handler.get_rom_gallery_screenshots."""
query = select(Save).filter(Save.rom_id == rom_id)

if public_only:
query = query.filter(Save.is_public)
else:
query = query.filter(or_(Save.user_id == user_id, Save.is_public))

query = query.order_by(desc(Save.updated_at))
return session.scalars(query).all()

@begin_session
def update_save(
self,
Expand Down
34 changes: 33 additions & 1 deletion backend/handler/database/states_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from collections.abc import Sequence

from sqlalchemy import and_, delete, select, update
from sqlalchemy import and_, delete, desc, or_, select, update
from sqlalchemy.orm import QueryableAttribute, Session, load_only

from decorators.database import begin_session
Expand Down Expand Up @@ -66,6 +66,38 @@ def get_states(

return session.scalars(query).all()

@begin_session
def get_state_by_id(
self,
id: int,
session: Session = None, # type: ignore
) -> State | None:
"""Fetch a state by id without scoping to an owner. Used for the
visibility toggle and community downloads, where the caller may not own
the state. Mirrors db_screenshot_handler.get_screenshot_by_id."""
return session.get(State, id)

@begin_session
def get_rom_shared_states(
self,
rom_id: int,
user_id: int,
public_only: bool = False,
session: Session = None, # type: ignore
) -> Sequence[State]:
"""States for a ROM visible to the requesting user: own (public +
private) plus other users' public ones. Mirrors
db_screenshot_handler.get_rom_gallery_screenshots."""
query = select(State).filter(State.rom_id == rom_id)

if public_only:
query = query.filter(State.is_public)
else:
query = query.filter(or_(State.user_id == user_id, State.is_public))

query = query.order_by(desc(State.updated_at))
return session.scalars(query).all()

@begin_session
def update_state(
self,
Expand Down
6 changes: 6 additions & 0 deletions backend/models/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ class Save(RomAsset):
ForeignKey("devices.id", ondelete="SET NULL"),
default=None,
)
# `is_public` mirrors Screenshot/RomNote — lets other users browse and
# download a user's public saves (community). Defaults false (private).
is_public: Mapped[bool] = mapped_column(default=False)

rom: Mapped[Rom] = relationship(lazy="joined", back_populates="saves")
user: Mapped[User] = relationship(lazy="joined", back_populates="saves")
Expand All @@ -118,6 +121,9 @@ class State(RomAsset):
__table_args__ = {"extend_existing": True}

emulator: Mapped[str | None] = mapped_column(String(length=50))
# `is_public` mirrors Screenshot/RomNote — lets other users browse and
# download a user's public states (community). Defaults false (private).
is_public: Mapped[bool] = mapped_column(default=False)

rom: Mapped[Rom] = relationship(lazy="joined", back_populates="states")
user: Mapped[User] = relationship(lazy="joined", back_populates="states")
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/__generated__/index.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading