Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
102 changes: 102 additions & 0 deletions backend/alembic/versions/0068_save_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Add device-based save synchronization

Revision ID: 0068_save_sync
Revises: 0067_romfile_category_enum_cheat
Create Date: 2026-01-17

"""

import sqlalchemy as sa
from alembic import op

revision = "0068_save_sync"
down_revision = "0067_romfile_category_enum_cheat"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"devices",
sa.Column("id", sa.String(255), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("platform", sa.String(50), nullable=True),
sa.Column("client", sa.String(50), nullable=True),
sa.Column("client_version", sa.String(50), nullable=True),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("mac_address", sa.String(17), nullable=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column(
"sync_mode",
sa.Enum("API", "FILE_TRANSFER", "PUSH_PULL", name="syncmode"),
nullable=False,
server_default="API",
),
sa.Column("sync_enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("last_seen", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)

op.create_table(
"device_save_sync",
sa.Column("device_id", sa.String(255), nullable=False),
sa.Column("save_id", sa.Integer(), nullable=False),
sa.Column("last_synced_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("is_untracked", sa.Boolean(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["device_id"], ["devices.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["save_id"], ["saves.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "save_id"),
)

with op.batch_alter_table("saves", schema=None) as batch_op:
batch_op.add_column(sa.Column("slot", sa.String(255), nullable=True))
batch_op.add_column(sa.Column("content_hash", sa.String(32), nullable=True))

op.create_index("ix_devices_user_id", "devices", ["user_id"])
op.create_index("ix_devices_last_seen", "devices", ["last_seen"])
op.create_index("ix_device_save_sync_save_id", "device_save_sync", ["save_id"])
op.create_index("ix_saves_slot", "saves", ["slot"])
op.create_index(
"ix_saves_rom_user_hash", "saves", ["rom_id", "user_id", "content_hash"]
)


def downgrade():
op.drop_index("ix_saves_rom_user_hash", "saves")
op.drop_index("ix_saves_slot", "saves")
op.drop_index("ix_device_save_sync_save_id", "device_save_sync")
op.drop_index("ix_devices_last_seen", "devices")
op.drop_index("ix_devices_user_id", "devices")

with op.batch_alter_table("saves", schema=None) as batch_op:
batch_op.drop_column("content_hash")
batch_op.drop_column("slot")

op.drop_table("device_save_sync")
op.drop_table("devices")
op.execute("DROP TYPE IF EXISTS syncmode")
179 changes: 179 additions & 0 deletions backend/endpoints/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import uuid
from datetime import datetime, timezone

from fastapi import HTTPException, Request, Response, status
from pydantic import BaseModel, model_validator

from decorators.auth import protected_route
from endpoints.responses.device import DeviceCreateResponse, DeviceSchema
from handler.auth.constants import Scope
from handler.database import db_device_handler, db_device_save_sync_handler
from logger.logger import log
from models.device import Device
from utils.router import APIRouter

router = APIRouter(
prefix="/devices",
tags=["devices"],
)


class DeviceCreatePayload(BaseModel):
name: str | None = None
platform: str | None = None
client: str | None = None
client_version: str | None = None
ip_address: str | None = None
mac_address: str | None = None
hostname: str | None = None
allow_existing: bool = True
allow_duplicate: bool = False
reset_syncs: bool = False

@model_validator(mode="after")
def _duplicate_disables_existing(self) -> "DeviceCreatePayload":
if self.allow_duplicate:
self.allow_existing = False
return self


class DeviceUpdatePayload(BaseModel):
name: str | None = None
platform: str | None = None
client: str | None = None
client_version: str | None = None
ip_address: str | None = None
mac_address: str | None = None
hostname: str | None = None
sync_enabled: bool | None = None


@protected_route(router.post, "", [Scope.DEVICES_WRITE])
def register_device(
request: Request,
response: Response,
payload: DeviceCreatePayload,
) -> DeviceCreateResponse:
existing_device = None
if not payload.allow_duplicate:
existing_device = db_device_handler.get_device_by_fingerprint(
user_id=request.user.id,
mac_address=payload.mac_address,
hostname=payload.hostname,
platform=payload.platform,
)

if existing_device:
if not payload.allow_existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": "device_exists",
"message": "A device with this fingerprint already exists",
"device_id": existing_device.id,
},
)

if payload.reset_syncs:
db_device_save_sync_handler.delete_syncs_for_device(
device_id=existing_device.id
)

db_device_handler.update_last_seen(
device_id=existing_device.id, user_id=request.user.id
)
log.info(
f"Returned existing device {existing_device.id} for user {request.user.username}"
)

response.status_code = status.HTTP_200_OK
return DeviceCreateResponse(
device_id=existing_device.id,
name=existing_device.name,
created_at=existing_device.created_at,
)

response.status_code = status.HTTP_201_CREATED
device_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)

device = Device(
id=device_id,
user_id=request.user.id,
name=payload.name,
platform=payload.platform,
client=payload.client,
client_version=payload.client_version,
ip_address=payload.ip_address,
mac_address=payload.mac_address,
hostname=payload.hostname,
last_seen=now,
)

db_device = db_device_handler.add_device(device)
log.info(f"Registered device {device_id} for user {request.user.username}")

return DeviceCreateResponse(
device_id=db_device.id,
name=db_device.name,
created_at=db_device.created_at,
)


@protected_route(router.get, "", [Scope.DEVICES_READ])
def get_devices(request: Request) -> list[DeviceSchema]:
devices = db_device_handler.get_devices(user_id=request.user.id)
return [DeviceSchema.model_validate(device) for device in devices]


@protected_route(router.get, "/{device_id}", [Scope.DEVICES_READ])
def get_device(request: Request, device_id: str) -> DeviceSchema:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)
return DeviceSchema.model_validate(device)


@protected_route(router.put, "/{device_id}", [Scope.DEVICES_WRITE])
def update_device(
request: Request,
device_id: str,
payload: DeviceUpdatePayload,
) -> DeviceSchema:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)

update_data = payload.model_dump(exclude_unset=True)
if update_data:
device = db_device_handler.update_device(
device_id=device_id,
user_id=request.user.id,
data=update_data,
)

return DeviceSchema.model_validate(device)


@protected_route(
router.delete,
"/{device_id}",
[Scope.DEVICES_WRITE],
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_device(request: Request, device_id: str) -> None:
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device with ID {device_id} not found",
)

db_device_handler.delete_device(device_id=device_id, user_id=request.user.id)
log.info(f"Deleted device {device_id} for user {request.user.username}")
39 changes: 39 additions & 0 deletions backend/endpoints/responses/assets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from datetime import datetime
from typing import Any

from pydantic import model_validator
from sqlalchemy import inspect
from sqlalchemy.exc import InvalidRequestError

from .base import BaseModel
from .device import DeviceSyncSchema


class BaseAsset(BaseModel):
Expand Down Expand Up @@ -31,7 +37,40 @@ class ScreenshotSchema(BaseAsset):

class SaveSchema(BaseAsset):
emulator: str | None
slot: str | None = None
content_hash: str | None = None
screenshot: ScreenshotSchema | None
device_syncs: list[DeviceSyncSchema] = []

@model_validator(mode="before")
@classmethod
def handle_lazy_relationships(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
try:
state = inspect(data)
except Exception:
return data
result = {}
for field_name in cls.model_fields:
if field_name in state.unloaded:
continue
try:
result[field_name] = getattr(data, field_name)
except InvalidRequestError:
continue
return result


class SlotSummarySchema(BaseModel):
slot: str | None
count: int
latest: SaveSchema


class SaveSummarySchema(BaseModel):
total_count: int
slots: list[SlotSummarySchema]


class StateSchema(BaseAsset):
Expand Down
42 changes: 42 additions & 0 deletions backend/endpoints/responses/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from datetime import datetime

from models.device import SyncMode

from .base import BaseModel


class DeviceSyncSchema(BaseModel):
device_id: str
device_name: str | None
last_synced_at: datetime
is_untracked: bool
is_current: bool

class Config:
from_attributes = True


class DeviceSchema(BaseModel):
id: str
user_id: int
name: str | None
platform: str | None
client: str | None
client_version: str | None
ip_address: str | None
mac_address: str | None
hostname: str | None
sync_mode: SyncMode
sync_enabled: bool
last_seen: datetime | None
created_at: datetime
updated_at: datetime

class Config:
from_attributes = True


class DeviceCreateResponse(BaseModel):
device_id: str
name: str | None
created_at: datetime
Loading
Loading