Skip to content
Open
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
159 changes: 159 additions & 0 deletions backend/alembic/versions/0098_walkthrough_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Add walkthrough document support.

Adds the WALKTHROUGH value to the rom_files.category enum, plus two tables:
- rom_file_doc_meta: provenance sidecar for document-category files
- rom_file_user: per-user reading progress for document-category files

Revision ID: 0098_walkthrough_docs
Revises: 0097_roms_platform_fs_size_index
Create Date: 2026-07-18 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op # type: ignore[attr-defined]

from utils.database import is_postgresql

# revision identifiers, used by Alembic.
revision = "0098_walkthrough_docs"
down_revision = "0097_roms_platform_fs_size_index"
branch_labels = None
depends_on = None

# Full enum member set including WALKTHROUGH, in model declaration order.
ROM_FILE_CATEGORY_VALUES = (
"GAME",
"DLC",
"HACK",
"MANUAL",
"WALKTHROUGH",
"PATCH",
"UPDATE",
"MOD",
"DEMO",
"TRANSLATION",
"PROTOTYPE",
"CHEAT",
"SOUNDTRACK",
"SCREENSHOT",
)

# The pre-walkthrough set, used to narrow the enum back on downgrade.
ROM_FILE_CATEGORY_VALUES_PRE = tuple(
v for v in ROM_FILE_CATEGORY_VALUES if v != "WALKTHROUGH"
)


def upgrade() -> None:
connection = op.get_bind()

# 1. Extend the rom_files.category enum with WALKTHROUGH.
if is_postgresql(connection):
# `ALTER TYPE ... ADD VALUE` must run outside a transaction in PostgreSQL.
with op.get_context().autocommit_block():
op.execute(
"ALTER TYPE romfilecategory ADD VALUE IF NOT EXISTS 'WALKTHROUGH'"
)
else:
with op.batch_alter_table("rom_files", schema=None) as batch_op:
batch_op.alter_column(
"category",
type_=sa.Enum(*ROM_FILE_CATEGORY_VALUES, name="romfilecategory"),
nullable=True,
)

# 2. Provenance sidecar for document-category files.
op.create_table(
"rom_file_doc_meta",
sa.Column("rom_file_id", sa.Integer(), nullable=False),
sa.Column("rom_id", sa.Integer(), nullable=False),
sa.Column(
"source",
sa.Enum("UPLOAD", "GAMEFAQS", "SCRAPER", name="docsource"),
nullable=False,
),
sa.Column("source_url", sa.Text(), nullable=True),
sa.Column("author", sa.String(length=512), nullable=True),
sa.Column("title", sa.String(length=512), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.ForeignKeyConstraint(["rom_file_id"], ["rom_files.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("rom_file_id"),
if_not_exists=True,
)
with op.batch_alter_table("rom_file_doc_meta", schema=None) as batch_op:
batch_op.create_index(
"idx_rom_file_doc_meta_rom_id",
["rom_id"],
unique=False,
if_not_exists=True,
)

# 3. Per-user reading progress for document-category files.
op.create_table(
"rom_file_user",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("rom_file_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("progress", sa.Float(), nullable=False, server_default=sa.text("0")),
sa.Column("last_page", sa.Integer(), nullable=True),
sa.Column("finished", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_read_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.ForeignKeyConstraint(["rom_file_id"], ["rom_files.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("rom_file_id", "user_id", name="unique_rom_file_user"),
if_not_exists=True,
)
with op.batch_alter_table("rom_file_user", schema=None) as batch_op:
batch_op.create_index(
"idx_rom_file_user",
["rom_file_id", "user_id"],
unique=False,
if_not_exists=True,
)


def downgrade() -> None:
connection = op.get_bind()

op.drop_table("rom_file_user", if_exists=True)
op.drop_table("rom_file_doc_meta", if_exists=True)

# PostgreSQL cannot drop an enum value, so leave WALKTHROUGH in place there.
# The docsource type is dropped with its table on PG; drop it explicitly in
# case create_table left it behind.
if is_postgresql(connection):
op.execute("DROP TYPE IF EXISTS docsource")
return

with op.batch_alter_table("rom_files", schema=None) as batch_op:
batch_op.alter_column(
"category",
type_=sa.Enum(*ROM_FILE_CATEGORY_VALUES_PRE, name="romfilecategory"),
nullable=True,
)
30 changes: 29 additions & 1 deletion backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@
from handler.metadata.ra_handler import RAMetadata
from handler.metadata.ss_handler import SSMetadata
from models.collection import Collection
from models.rom import Rom, RomArchiveMember, RomFile, RomFileCategory, RomUserStatus
from models.rom import (
DocSource,
Rom,
RomArchiveMember,
RomFile,
RomFileCategory,
RomUserStatus,
)

from .base import BaseModel, UTCDatetime

Expand Down Expand Up @@ -173,6 +180,26 @@ class TrackMetaSchema(BaseModel):
cover_path: str | None = None


class DocMetaSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

source: DocSource
source_url: str | None = None
author: str | None = None
title: str | None = None


class RomFileUserSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

rom_file_id: int
user_id: int
progress: float
last_page: int | None = None
finished: bool = False
last_read_at: UTCDatetime | None = None


class RomFileSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

Expand All @@ -194,6 +221,7 @@ class RomFileSchema(BaseModel):
archive_members: list[RomArchiveMember] | None
category: RomFileCategory | None
track_meta: TrackMetaSchema | None = None
doc_meta: DocMetaSchema | None = None

@model_validator(mode="after")
def default_category_for_non_nested(self) -> RomFileSchema:
Expand Down
2 changes: 2 additions & 0 deletions backend/endpoints/roms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
from .screenshot import router as screenshot_router
from .soundtrack import router as soundtrack_router
from .upload import router as upload_router
from .walkthrough import router as walkthrough_router

router = APIRouter(
prefix="/roms",
Expand All @@ -97,6 +98,7 @@
router.include_router(upload_router)
router.include_router(files_router)
router.include_router(manual_router)
router.include_router(walkthrough_router)
router.include_router(soundtrack_router)
router.include_router(screenshot_router)
router.include_router(notes_router)
Expand Down
124 changes: 117 additions & 7 deletions backend/endpoints/roms/files.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from datetime import datetime, timezone
from typing import Annotated

from anyio import Path
from fastapi import HTTPException
from fastapi import Body, HTTPException
from fastapi import Path as PathVar
from fastapi import Request, status
from fastapi.responses import Response
from starlette.responses import FileResponse

from config import DEV_MODE, DISABLE_DOWNLOAD_ENDPOINT_AUTH
from decorators.auth import protected_route
from endpoints.responses.rom import RomFileSchema
from endpoints.responses.rom import RomFileSchema, RomFileUserSchema
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from handler.auth.constants import Scope
from handler.auth.dependencies import assert_rom_visible
Expand All @@ -18,12 +19,13 @@
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.rom import RomFileCategory
from models.rom import DOCUMENT_CATEGORIES, RomFileCategory
from utils.audio_tags import guess_audio_media_type
from utils.media_types import (
guess_media_file_type,
is_allowed_document_file,
is_allowed_media_file,
is_html_document_file,
)
from utils.nginx import FileRedirectResponse
from utils.router import APIRouter
Expand Down Expand Up @@ -110,12 +112,12 @@ async def get_romfile_content(
if file.category == RomFileCategory.SOUNDTRACK:
media_type = guess_audio_media_type(file.file_name)
disposition = "inline"
elif file.category == RomFileCategory.MANUAL and is_allowed_document_file(
elif file.category in DOCUMENT_CATEGORIES and is_allowed_document_file(
file.file_name
):
# Only manuals are served inline as documents so the in-page viewer can
# render them; a game/extra file that happens to end in .pdf/.md still
# downloads.
# Only document-category files (manuals, walkthroughs) are served inline
# so the in-page viewer can render them; a game/extra file that happens
# to end in .pdf/.md/.txt still downloads.
media_type = guess_media_file_type(file.file_name)
disposition = "inline"
elif is_allowed_media_file(file.file_name):
Expand All @@ -129,6 +131,11 @@ async def get_romfile_content(
# keeps the browser from sniffing them into anything script-capable (e.g. a
# Markdown manual into HTML).
headers = {"X-Content-Type-Options": "nosniff"} if disposition == "inline" else {}
# HTML documents are sanitized on ingest, but serve them under a sandboxing
# CSP anyway so a crafted document can never run scripts or reach the
# session origin if it is opened directly.
if disposition == "inline" and is_html_document_file(file.file_name):
headers["Content-Security-Policy"] = "sandbox; default-src 'none'"

# Serve the file directly in development mode for emulatorjs
if DEV_MODE:
Expand Down Expand Up @@ -202,3 +209,106 @@ async def delete_rom_file(
)

return Response()


def _assert_document_file(
rom_id: int, file_id: int, request: Request
) -> RomFileCategory:
"""Resolve a document-category file, enforcing parent-rom visibility."""
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
assert_rom_visible(request, rom, not_found_detail="File not found")

rom_file = db_rom_handler.get_rom_file_by_id(file_id)
if (
not rom_file
or rom_file.rom_id != rom.id
or rom_file.category not in DOCUMENT_CATEGORIES
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document file not found",
)
return rom_file.category # type: ignore[return-value]


@protected_route(
router.get,
"/{rom_id}/files/{file_id}/progress",
[Scope.ROMS_READ],
responses={status.HTTP_404_NOT_FOUND: {}},
)
async def get_rom_file_progress(
request: Request,
rom_id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)],
) -> RomFileUserSchema:
"""Get the current user's reading progress for a document file."""

_assert_document_file(rom_id, file_id, request)

state = db_rom_handler.get_rom_file_user(
rom_file_id=file_id, user_id=request.user.id
)
if state:
return RomFileUserSchema.model_validate(state)

# No stored progress yet: return a zeroed default so the client has a shape.
return RomFileUserSchema(
rom_file_id=file_id,
user_id=request.user.id,
progress=0.0,
last_page=None,
finished=False,
last_read_at=None,
)


@protected_route(
router.put,
"/{rom_id}/files/{file_id}/progress",
[Scope.ROMS_READ],
responses={status.HTTP_404_NOT_FOUND: {}},
)
async def update_rom_file_progress(
request: Request,
rom_id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)],
body: Annotated[dict, Body()],
) -> RomFileUserSchema:
"""Upsert the current user's reading progress for a document file.

Reading progress is per-user state (like notes), so only ROMS_READ is
required, not ROMS_WRITE.
"""

_assert_document_file(rom_id, file_id, request)

values: dict = {"last_read_at": datetime.now(timezone.utc)}
if "progress" in body:
try:
values["progress"] = min(1.0, max(0.0, float(body["progress"])))
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="progress must be a number between 0 and 1",
) from exc
if "last_page" in body:
last_page = body["last_page"]
if last_page is not None:
try:
last_page = max(0, int(last_page))
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="last_page must be an integer",
) from exc
values["last_page"] = last_page
if "finished" in body:
values["finished"] = bool(body["finished"])

state = db_rom_handler.upsert_rom_file_user(
rom_file_id=file_id, user_id=request.user.id, values=values
)
return RomFileUserSchema.model_validate(state)
Loading
Loading