From 3c95231294845d03bdc337ab0f59276bf4650127 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 18 Jul 2026 19:41:15 -0400 Subject: [PATCH] feat: walkthrough documents with reading progress and GameFAQs import Unify manuals and walkthroughs on one document model instead of a separate walkthrough subsystem. Walkthroughs are RomFiles under a new WALKTHROUGH category with an optional RomFileDocMeta provenance sidecar, and reading progress is a per-user RomFileUser row keyed on rom_file_id so the same mechanism covers manuals and walkthroughs. Backend: - models: RomFileCategory.WALKTHROUGH, DocSource, RomFileDocMeta, RomFileUser; migration 0110 (enum value + two tables, cross-DB) - serve .txt/.html documents inline (HTML under a sandboxing CSP); add .txt to allowed manual files - endpoints: walkthrough upload / GameFAQs fetch / delete, and per-user reading-progress GET (ROMS_READ) / PUT (ROMS_USER_WRITE, like notes, so a kiosk guest capped to the read scopes cannot write) - last_page is range-checked, so an out-of-range value is a 400 rather than an integer column overflow - the guide body is read under the size cap instead of being buffered in full and truncated afterwards - GameFAQs import extracts text only (stored as .txt); no remote HTML is persisted or served. Host-allowlisted on top of the SSRF-safe client - a Cloudflare bot challenge is detected and reported as "open it in your browser and upload the text" instead of an opaque 403 - reading-progress upsert retries as an update on a unique-constraint race, so concurrent saves for one user/file can't 500 - a GameFAQs guide is removed from disk again if indexing it fails, rather than being left unreferenced - tests for CRUD, progress, the GameFAQs parser/URL validation, and hidden-rom masking on every walkthrough route Frontend (v2): - TextViewer (.txt/.html), useReadingProgress composable, WalkthroughSubtab (upload + add-from-GameFAQs-URL + delete + progress), wired into MediaTab - ManualSubtab renders .txt manuals; walkthrough category surfaced in FilesTab; regenerated types; i18n keys across all locales - a pending scroll position is committed before the viewer follows a new document, so switching files mid-debounce no longer drops it - WalkthroughSubtab gates upload, the GameFAQs URL field, and delete on useCan("rom.edit"), as ManualSubtab already does AI assistance: implemented with Claude Code (Opus 4.8). The data-model design, backend, migration, tests, and v2 UI were AI-authored under human direction. Alternative approach to #2892. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../alembic/versions/0110_walkthrough_docs.py | 159 ++++++++ backend/endpoints/responses/rom.py | 30 +- backend/endpoints/roms/__init__.py | 2 + backend/endpoints/roms/files.py | 134 ++++++- backend/endpoints/roms/walkthrough.py | 367 ++++++++++++++++++ backend/handler/database/roms_handler.py | 88 ++++- .../handler/filesystem/resources_handler.py | 2 +- backend/handler/walkthrough/__init__.py | 15 + backend/handler/walkthrough/gamefaqs.py | 207 ++++++++++ backend/models/rom.py | 91 +++++ .../endpoints/roms/test_file_progress.py | 149 +++++++ backend/tests/endpoints/roms/test_manual.py | 10 +- .../tests/endpoints/roms/test_walkthrough.py | 223 +++++++++++ .../endpoints/test_permissions_visibility.py | 22 ++ .../filesystem/test_resources_handler.py | 8 +- backend/tests/handler/walkthrough/__init__.py | 0 .../handler/walkthrough/test_gamefaqs.py | 154 ++++++++ backend/utils/media_types.py | 16 +- frontend/src/__generated__/index.ts | 3 + .../src/__generated__/models/DocMetaSchema.ts | 12 + .../src/__generated__/models/DocSource.ts | 8 + .../__generated__/models/RomFileCategory.ts | 2 +- .../src/__generated__/models/RomFileSchema.ts | 2 + .../__generated__/models/RomFileUserSchema.ts | 13 + frontend/src/locales/bg_BG/rom.json | 11 + frontend/src/locales/cs_CZ/rom.json | 11 + frontend/src/locales/de_DE/rom.json | 11 + frontend/src/locales/en_GB/rom.json | 11 + frontend/src/locales/en_US/rom.json | 11 + frontend/src/locales/es_ES/rom.json | 11 + frontend/src/locales/fr_FR/rom.json | 11 + frontend/src/locales/hu_HU/rom.json | 11 + frontend/src/locales/it_IT/rom.json | 11 + frontend/src/locales/ja_JP/rom.json | 11 + frontend/src/locales/ko_KR/rom.json | 11 + frontend/src/locales/pl_PL/rom.json | 11 + frontend/src/locales/pt_BR/rom.json | 11 + frontend/src/locales/ro_RO/rom.json | 11 + frontend/src/locales/ru_RU/rom.json | 11 + frontend/src/locales/tr_TR/rom.json | 11 + frontend/src/locales/zh_CN/rom.json | 11 + frontend/src/locales/zh_TW/rom.json | 11 + frontend/src/services/api/rom.ts | 88 +++++ .../GameDetails/FilesTab/FileRow.vue | 4 + .../GameDetails/FilesTab/FilesTab.vue | 6 + .../components/GameDetails/ManualSubtab.vue | 29 +- .../v2/components/GameDetails/MediaTab.vue | 15 + .../v2/components/GameDetails/TextViewer.vue | 241 ++++++++++++ .../GameDetails/WalkthroughSubtab.vue | 346 +++++++++++++++++ .../useReadingProgress/index.test.ts | 99 +++++ .../composables/useReadingProgress/index.ts | 101 +++++ 51 files changed, 2815 insertions(+), 29 deletions(-) create mode 100644 backend/alembic/versions/0110_walkthrough_docs.py create mode 100644 backend/endpoints/roms/walkthrough.py create mode 100644 backend/handler/walkthrough/__init__.py create mode 100644 backend/handler/walkthrough/gamefaqs.py create mode 100644 backend/tests/endpoints/roms/test_file_progress.py create mode 100644 backend/tests/endpoints/roms/test_walkthrough.py create mode 100644 backend/tests/handler/walkthrough/__init__.py create mode 100644 backend/tests/handler/walkthrough/test_gamefaqs.py create mode 100644 frontend/src/__generated__/models/DocMetaSchema.ts create mode 100644 frontend/src/__generated__/models/DocSource.ts create mode 100644 frontend/src/__generated__/models/RomFileUserSchema.ts create mode 100644 frontend/src/v2/components/GameDetails/TextViewer.vue create mode 100644 frontend/src/v2/components/GameDetails/WalkthroughSubtab.vue create mode 100644 frontend/src/v2/composables/useReadingProgress/index.test.ts create mode 100644 frontend/src/v2/composables/useReadingProgress/index.ts diff --git a/backend/alembic/versions/0110_walkthrough_docs.py b/backend/alembic/versions/0110_walkthrough_docs.py new file mode 100644 index 0000000000..0f07f51cc0 --- /dev/null +++ b/backend/alembic/versions/0110_walkthrough_docs.py @@ -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: 0110_walkthrough_docs +Revises: 0109_rom_files_category_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 = "0110_walkthrough_docs" +down_revision = "0109_rom_files_category_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, + ) diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 44383812a2..7fe61abb3b 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -26,7 +26,14 @@ from handler.metadata.ra_handler import RAMetadata from handler.metadata.ss_handler import SSMetadata from models.collection import Collection, SmartCollection -from models.rom import Rom, RomArchiveMember, RomFile, RomFileCategory, RomUserStatus +from models.rom import ( + DocSource, + Rom, + RomArchiveMember, + RomFile, + RomFileCategory, + RomUserStatus, +) from .base import BaseModel, UTCDatetime @@ -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) @@ -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: diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 6156a6d0c6..87b39ddb56 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -105,6 +105,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", @@ -113,6 +114,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) diff --git a/backend/endpoints/roms/files.py b/backend/endpoints/roms/files.py index 0d6b344ab6..30cbce3b8d 100644 --- a/backend/endpoints/roms/files.py +++ b/backend/endpoints/roms/files.py @@ -1,7 +1,8 @@ +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 @@ -9,7 +10,7 @@ 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_can, assert_rom_visible, get_permissions @@ -19,18 +20,22 @@ from logger.formatter import highlight as hl from logger.logger import log from models.permission import PermAction, PermEntity -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 router = APIRouter() +# Signed 32-bit INT ceiling, the narrowest `last_page` column across our DBs. +MAX_LAST_PAGE = 2**31 - 1 + @protected_route( router.get, @@ -111,12 +116,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): @@ -130,6 +135,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: @@ -209,3 +219,113 @@ 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_USER_WRITE], + 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. + + Self-service per-user state, so it takes ROMS_USER_WRITE rather than the + library-wide ROMS_WRITE, exactly as rom notes do. + """ + + _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 = 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 + # Bounded so an out-of-range value is a 400 rather than an integer + # column overflow at insert time. + if not 0 <= last_page <= MAX_LAST_PAGE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"last_page must be between 0 and {MAX_LAST_PAGE}", + ) + 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) diff --git a/backend/endpoints/roms/walkthrough.py b/backend/endpoints/roms/walkthrough.py new file mode 100644 index 0000000000..0c7adc0c03 --- /dev/null +++ b/backend/endpoints/roms/walkthrough.py @@ -0,0 +1,367 @@ +import contextlib +import os +import re +from typing import Annotated +from urllib.parse import unquote + +from fastapi import Body, Header, HTTPException +from fastapi import Path as PathVar +from fastapi import Request, status +from fastapi.responses import Response +from starlette.requests import ClientDisconnect +from streaming_form_data import StreamingFormDataParser +from streaming_form_data.targets import FileTarget, NullTarget + +from decorators.auth import protected_route +from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException +from exceptions.fs_exceptions import RomAlreadyExistsException +from handler.auth.constants import Scope +from handler.auth.dependencies import assert_rom_visible +from handler.database import db_rom_handler +from handler.filesystem import fs_rom_handler +from handler.rom_conversion import promote_single_file_to_folder +from handler.walkthrough import fetch_gamefaqs_guide, validate_gamefaqs_url +from handler.walkthrough.gamefaqs import GameFAQsFetchError +from logger.formatter import BLUE +from logger.formatter import highlight as hl +from logger.logger import log +from models.rom import DocSource, RomFile, RomFileCategory +from utils.media_types import ALLOWED_DOCUMENT_EXTENSIONS +from utils.router import APIRouter + +router = APIRouter() + +WALKTHROUGH_FOLDER = "walkthrough" + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def _is_allowed_walkthrough_file(file_name: str) -> bool: + _, ext = os.path.splitext(file_name) + return ext.lower() in ALLOWED_DOCUMENT_EXTENSIONS + + +def _slugify(value: str, fallback: str = "walkthrough") -> str: + slug = _SLUG_RE.sub("-", value.lower()).strip("-") + return (slug[:80] or fallback).strip("-") or fallback + + +def _decode_header(value: str | None) -> str | None: + """Percent-decode an optional header value, trimming to the DB column max.""" + if not value: + return None + decoded = unquote(value).strip() + return decoded[:512] or None + + +@protected_route( + router.post, + "/{id}/walkthroughs/files", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, + responses={ + status.HTTP_404_NOT_FOUND: {}, + status.HTTP_400_BAD_REQUEST: {}, + }, +) +async def add_rom_walkthrough_file( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + filename: Annotated[ + str, + Header( + description="The name of the file being uploaded.", + alias="x-upload-filename", + ), + ], + author: Annotated[ + str | None, + Header(description="Optional walkthrough author.", alias="x-doc-author"), + ] = None, + title: Annotated[ + str | None, + Header(description="Optional walkthrough title.", alias="x-doc-title"), + ] = None, +) -> Response: + """Upload a walkthrough document into the ROM's own walkthrough/ subfolder.""" + + rom = db_rom_handler.get_rom(id) + if not rom: + raise RomNotFoundInDatabaseException(id) + + assert_rom_visible(request, rom) + + if rom.has_simple_single_file: + try: + rom = await promote_single_file_to_folder(rom) + except RomAlreadyExistsException as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + + try: + safe_filename = fs_rom_handler._sanitize_filename(filename) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid upload filename: {exc}", + ) from exc + + if safe_filename != filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Upload filename must be a plain file name, not a path", + ) + + if not _is_allowed_walkthrough_file(safe_filename): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Unsupported walkthrough file type. Allowed: " + f"{', '.join(sorted(ALLOWED_DOCUMENT_EXTENSIONS))}" + ), + ) + + walkthrough_dir_rel = f"{rom.full_path}/{WALKTHROUGH_FOLDER}" + file_rel_path = f"{walkthrough_dir_rel}/{safe_filename}" + file_location = fs_rom_handler.validate_path(file_rel_path) + log.info(f"Uploading walkthrough file to {hl(str(file_location))}") + + await fs_rom_handler.make_directory(walkthrough_dir_rel) + + parser = StreamingFormDataParser(headers=request.headers) + parser.register("x-upload-platform", NullTarget()) + parser.register(safe_filename, FileTarget(str(file_location))) + + def cleanup_partial_file(): + if file_location.exists(): + file_location.unlink() + + try: + async for chunk in request.stream(): + parser.data_received(chunk) + except ClientDisconnect: + log.error("Client disconnected during upload") + cleanup_partial_file() + raise + except Exception as exc: + log.error("Error uploading walkthrough file", exc_info=exc) + cleanup_partial_file() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error uploading the walkthrough file", + ) from exc + + stat = os.stat(file_location) + existing = db_rom_handler.get_rom_file_by_path( + rom_id=rom.id, file_path=walkthrough_dir_rel, file_name=safe_filename + ) + if existing: + db_rom_handler.update_rom_file( + existing.id, + { + "file_size_bytes": stat.st_size, + "last_modified": stat.st_mtime, + "category": RomFileCategory.WALKTHROUGH, + "missing_from_fs": False, + }, + ) + rom_file_id = existing.id + else: + created = db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name=safe_filename, + file_path=walkthrough_dir_rel, + file_size_bytes=stat.st_size, + last_modified=stat.st_mtime, + category=RomFileCategory.WALKTHROUGH, + ) + ) + rom_file_id = created.id + + db_rom_handler.upsert_doc_meta( + rom_file_id=rom_file_id, + rom_id=rom.id, + values={ + "source": DocSource.UPLOAD, + "author": _decode_header(author), + "title": _decode_header(title), + }, + ) + + return Response(status_code=status.HTTP_201_CREATED) + + +@protected_route( + router.post, + "/{id}/walkthroughs/gamefaqs", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, + responses={ + status.HTTP_404_NOT_FOUND: {}, + status.HTTP_400_BAD_REQUEST: {}, + status.HTTP_502_BAD_GATEWAY: {}, + }, +) +async def add_rom_gamefaqs_walkthrough( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + body: Annotated[dict, Body()], +) -> Response: + """Fetch a GameFAQs text guide by URL and store it as a walkthrough. + + The remote page is reduced to plain text on ingest; no HTML is persisted. + """ + + rom = db_rom_handler.get_rom(id) + if not rom: + raise RomNotFoundInDatabaseException(id) + + assert_rom_visible(request, rom) + + url = (body.get("url") or "").strip() + try: + validate_gamefaqs_url(url) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + + try: + guide = await fetch_gamefaqs_guide(url) + except GameFAQsFetchError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + + if rom.has_simple_single_file: + try: + rom = await promote_single_file_to_folder(rom) + except RomAlreadyExistsException as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + + walkthrough_dir_rel = f"{rom.full_path}/{WALKTHROUGH_FOLDER}" + base_name = _slugify(guide["title"] or f"gamefaqs-{rom.id}") + file_name = f"{base_name}.txt" + + # Avoid clobbering an existing walkthrough with the same slug. + suffix = 1 + while db_rom_handler.get_rom_file_by_path( + rom_id=rom.id, file_path=walkthrough_dir_rel, file_name=file_name + ): + suffix += 1 + file_name = f"{base_name}-{suffix}.txt" + + try: + await fs_rom_handler.write_file( + file=guide["text"].encode("utf-8"), + path=walkthrough_dir_rel, + filename=file_name, + ) + except Exception as exc: + log.error("Error writing GameFAQs walkthrough", exc_info=exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error saving the walkthrough", + ) from exc + + file_location = fs_rom_handler.validate_path(f"{walkthrough_dir_rel}/{file_name}") + stat = os.stat(file_location) + # The guide is on disk but nothing references it yet, so drop it again if + # indexing fails rather than leaving an unreachable file behind. + try: + created = db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name=file_name, + file_path=walkthrough_dir_rel, + file_size_bytes=stat.st_size, + last_modified=stat.st_mtime, + category=RomFileCategory.WALKTHROUGH, + ) + ) + db_rom_handler.upsert_doc_meta( + rom_file_id=created.id, + rom_id=rom.id, + values={ + "source": DocSource.GAMEFAQS, + "source_url": url, + "author": guide["author"], + "title": guide["title"], + }, + ) + except Exception as exc: + log.error("Error indexing GameFAQs walkthrough", exc_info=exc) + with contextlib.suppress(OSError): + await fs_rom_handler.remove_file(f"{walkthrough_dir_rel}/{file_name}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error saving the walkthrough", + ) from exc + + log.info( + f"Saved GameFAQs walkthrough {hl(file_name)} for " + f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]" + ) + + return Response(status_code=status.HTTP_201_CREATED) + + +@protected_route( + router.delete, + "/{id}/walkthroughs/files/{file_id}", + [Scope.ROMS_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +async def delete_rom_walkthrough_file( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)], +) -> Response: + """Delete a single walkthrough file from a ROM's walkthrough/ subfolder.""" + + rom = db_rom_handler.get_rom(id) + if not rom: + raise RomNotFoundInDatabaseException(id) + + assert_rom_visible(request, rom) + + 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 != RomFileCategory.WALKTHROUGH + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Walkthrough file not found", + ) + + file_rel_path = rom_file.full_path + + try: + await fs_rom_handler.remove_file(file_rel_path) + except FileNotFoundError: + log.warning( + f"Walkthrough file {hl(file_rel_path)} not found on disk; " + f"removing DB row anyway" + ) + except Exception as exc: + log.error(f"Error deleting walkthrough file {hl(file_rel_path)}", exc_info=exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error deleting the walkthrough file", + ) from exc + + # doc_meta and rom_file_user rows cascade on the RomFile delete. + db_rom_handler.delete_rom_file(file_id) + + log.info( + f"Deleted walkthrough file {hl(rom_file.file_name)} from " + f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]" + ) + + return Response() diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index d3510200bc..f1cac18f0e 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -30,6 +30,7 @@ union, update, ) +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import ( Query, QueryableAttribute, @@ -59,6 +60,8 @@ RomFacets, RomFile, RomFileCategory, + RomFileDocMeta, + RomFileUser, RomMetadata, RomNote, RomUser, @@ -394,6 +397,7 @@ def wrapper(*args, **kwargs): selectinload(Rom.files).options( joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name), selectinload(RomFile.track_meta), + selectinload(RomFile.doc_meta), ), selectinload(Rom.sibling_roms).options( noload(Rom.platform), @@ -445,6 +449,7 @@ def wrapper(*args, **kwargs): selectinload(Rom.files).options( joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name), selectinload(RomFile.track_meta), + selectinload(RomFile.doc_meta), ), selectinload(Rom.sibling_roms).options( noload(Rom.platform), @@ -2223,7 +2228,7 @@ def get_rom_file_by_id( ) -> RomFile | None: return session.scalar( select(RomFile) - .options(selectinload(RomFile.track_meta)) + .options(selectinload(RomFile.track_meta), selectinload(RomFile.doc_meta)) .filter_by(id=id) .limit(1) ) @@ -2259,7 +2264,7 @@ def get_rom_file_by_path( ) -> RomFile | None: return session.scalar( select(RomFile) - .options(selectinload(RomFile.track_meta)) + .options(selectinload(RomFile.track_meta), selectinload(RomFile.doc_meta)) .filter_by(rom_id=rom_id, file_path=file_path, file_name=file_name) .limit(1) ) @@ -2275,7 +2280,9 @@ def get_rom_files_by_category( return ( session.scalars( select(RomFile) - .options(selectinload(RomFile.track_meta)) + .options( + selectinload(RomFile.track_meta), selectinload(RomFile.doc_meta) + ) .filter_by(rom_id=rom_id, category=category) .order_by(RomFile.file_name.asc()) ) @@ -2344,6 +2351,81 @@ def delete_track_meta( ) -> None: session.execute(delete(TrackMeta).where(TrackMeta.rom_file_id == rom_file_id)) + # ------------------------------------------------------- document metadata + + @begin_session + def upsert_doc_meta( + self, + rom_file_id: int, + rom_id: int, + values: dict, + session: Session = None, # type: ignore + ) -> RomFileDocMeta: + """Create or update the provenance sidecar for a document file.""" + existing = session.get(RomFileDocMeta, rom_file_id) + if existing: + for key, val in values.items(): + setattr(existing, key, val) + session.flush() + return existing + + doc = RomFileDocMeta(rom_file_id=rom_file_id, rom_id=rom_id, **values) + session.add(doc) + session.flush() + return doc + + # ------------------------------------------------ document reading progress + + @begin_session + def get_rom_file_user( + self, + rom_file_id: int, + user_id: int, + session: Session = None, # type: ignore + ) -> RomFileUser | None: + return session.scalar( + select(RomFileUser) + .filter_by(rom_file_id=rom_file_id, user_id=user_id) + .limit(1) + ) + + @begin_session + def upsert_rom_file_user( + self, + rom_file_id: int, + user_id: int, + values: dict, + session: Session = None, # type: ignore + ) -> RomFileUser: + """Create or update a user's reading progress for a document file.""" + select_state = ( + select(RomFileUser) + .filter_by(rom_file_id=rom_file_id, user_id=user_id) + .limit(1) + ) + existing = session.scalar(select_state) + + if existing is None: + try: + with session.begin_nested(): + state = RomFileUser( + rom_file_id=rom_file_id, user_id=user_id, **values + ) + session.add(state) + session.flush() + except IntegrityError: + # A concurrent save (e.g. a second tab) won the insert; update it. + existing = session.scalar(select_state) + if existing is None: + raise + else: + return state + + for key, val in values.items(): + setattr(existing, key, val) + session.flush() + return existing + # ----------------------------------------------------------------- music def _music_where( diff --git a/backend/handler/filesystem/resources_handler.py b/backend/handler/filesystem/resources_handler.py index 376a576d8c..064ab5171f 100644 --- a/backend/handler/filesystem/resources_handler.py +++ b/backend/handler/filesystem/resources_handler.py @@ -23,7 +23,7 @@ LOCAL_FILE_SCHEMES = ("file://", "launchbox-file://") -ALLOWED_MANUAL_EXTENSIONS = frozenset({".pdf", ".md"}) +ALLOWED_MANUAL_EXTENSIONS = frozenset({".pdf", ".md", ".txt"}) def _resolve_local_file_uri(uri: str) -> Path | None: diff --git a/backend/handler/walkthrough/__init__.py b/backend/handler/walkthrough/__init__.py new file mode 100644 index 0000000000..c872b5c876 --- /dev/null +++ b/backend/handler/walkthrough/__init__.py @@ -0,0 +1,15 @@ +from .gamefaqs import ( + GAMEFAQS_HOSTS, + ParsedGuide, + fetch_gamefaqs_guide, + parse_gamefaqs_guide, + validate_gamefaqs_url, +) + +__all__ = [ + "GAMEFAQS_HOSTS", + "ParsedGuide", + "fetch_gamefaqs_guide", + "parse_gamefaqs_guide", + "validate_gamefaqs_url", +] diff --git a/backend/handler/walkthrough/gamefaqs.py b/backend/handler/walkthrough/gamefaqs.py new file mode 100644 index 0000000000..098d9049a7 --- /dev/null +++ b/backend/handler/walkthrough/gamefaqs.py @@ -0,0 +1,207 @@ +"""Fetch GameFAQs text guides into the walkthrough document model. + +Design note: we extract plain text only and never store or serve remote HTML. +GameFAQs text guides live inside a `
` (or a `#faqtext` container), so
+pulling the text out sidesteps HTML sanitization entirely: nothing script
+capable is ever persisted. The outbound request also rides the shared,
+SSRF-protected httpx client, with an extra host allowlist below.
+"""
+
+from __future__ import annotations
+
+from html.parser import HTMLParser
+from typing import TypedDict
+from urllib.parse import urlparse
+
+import httpx
+
+from utils.context import ctx_httpx_client
+
+GAMEFAQS_HOSTS = frozenset(
+    {
+        "gamefaqs.gamespot.com",
+        "www.gamefaqs.com",
+        "gamefaqs.com",
+    }
+)
+
+# Cap the response we will read into memory (guides are text; 8 MiB is plenty).
+MAX_GUIDE_BYTES = 8 * 1024 * 1024
+
+_TITLE_SUFFIXES = (" - Guide and Walkthrough", " - FAQ", " - GameFAQs")
+
+
+class ParsedGuide(TypedDict):
+    title: str | None
+    author: str | None
+    text: str
+
+
+class GameFAQsFetchError(Exception):
+    """Raised when a GameFAQs guide cannot be fetched or parsed."""
+
+
+def validate_gamefaqs_url(url: str) -> str:
+    """Return the URL if it is a well-formed https GameFAQs URL, else raise."""
+    parsed = urlparse(url)
+    if parsed.scheme != "https":
+        raise ValueError("Walkthrough URL must use https")
+    host = (parsed.hostname or "").lower()
+    if host not in GAMEFAQS_HOSTS:
+        raise ValueError(
+            "Only GameFAQs guide URLs are supported "
+            f"(allowed hosts: {', '.join(sorted(GAMEFAQS_HOSTS))})"
+        )
+    return url
+
+
+class _GuideExtractor(HTMLParser):
+    """Pull the guide title, author, and body text out of a GameFAQs page.
+
+    Body text is captured from any `
` element or any element whose id or
+    class contains "faqtext". Meta tags supply the title/author when present.
+    """
+
+    def __init__(self) -> None:
+        super().__init__(convert_charrefs=True)
+        self.title: str | None = None
+        self.author: str | None = None
+        self._meta_title: str | None = None
+        self._capture_depth = 0
+        self._in_title = False
+        self._title_buf: list[str] = []
+        self._body_buf: list[str] = []
+
+    @staticmethod
+    def _is_capture_tag(tag: str, attrs: dict[str, str]) -> bool:
+        if tag == "pre":
+            return True
+        ident = f"{attrs.get('id', '')} {attrs.get('class', '')}".lower()
+        return "faqtext" in ident
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        attr_map = {k: (v or "") for k, v in attrs}
+
+        if tag == "title":
+            self._in_title = True
+        elif tag == "meta":
+            name = attr_map.get("name", "").lower()
+            prop = attr_map.get("property", "").lower()
+            content = attr_map.get("content", "").strip()
+            if content and name == "author" and not self.author:
+                self.author = content[:512]
+            if content and prop == "og:title" and not self._meta_title:
+                self._meta_title = content
+
+        if self._capture_depth > 0:
+            self._capture_depth += 1
+        elif self._is_capture_tag(tag, attr_map):
+            self._capture_depth = 1
+
+    def handle_endtag(self, tag: str) -> None:
+        if tag == "title":
+            self._in_title = False
+        if self._capture_depth > 0:
+            self._capture_depth -= 1
+
+    def handle_data(self, data: str) -> None:
+        if self._in_title:
+            self._title_buf.append(data)
+        if self._capture_depth > 0:
+            self._body_buf.append(data)
+
+    def finalize(self) -> ParsedGuide:
+        raw_title = self._meta_title or "".join(self._title_buf).strip() or None
+        title = _clean_title(raw_title)
+        text = "".join(self._body_buf).strip()
+        return ParsedGuide(title=title, author=self.author, text=text)
+
+
+def _clean_title(title: str | None) -> str | None:
+    if not title:
+        return None
+    cleaned = title.strip()
+    for suffix in _TITLE_SUFFIXES:
+        if cleaned.endswith(suffix):
+            cleaned = cleaned[: -len(suffix)].strip()
+    return cleaned[:512] or None
+
+
+def parse_gamefaqs_guide(html: str) -> ParsedGuide:
+    """Parse a GameFAQs guide HTML string into title/author/text.
+
+    Pure function (no I/O) so it can be unit-tested against saved fixtures.
+    """
+    extractor = _GuideExtractor()
+    extractor.feed(html)
+    extractor.close()
+    return extractor.finalize()
+
+
+def is_bot_challenge(response: httpx.Response) -> bool:
+    """Whether the response is an anti-bot interstitial rather than the guide.
+
+    Cloudflare marks a served challenge with `Cf-Mitigated`; some edges only
+    identify themselves in `Server` alongside the refusal status.
+    """
+    if response.status_code not in (403, 503):
+        return False
+    if "cf-mitigated" in response.headers:
+        return True
+    return "cloudflare" in response.headers.get("server", "").lower()
+
+
+async def fetch_gamefaqs_guide(url: str) -> ParsedGuide:
+    """Fetch and parse a GameFAQs guide. Raises GameFAQsFetchError on failure."""
+    validate_gamefaqs_url(url)
+
+    client = ctx_httpx_client.get()
+    request = client.build_request(
+        "GET",
+        url,
+        headers={
+            "User-Agent": "Mozilla/5.0 (compatible; RomM/1.0; +https://romm.app)",
+            "Accept": "text/html,application/xhtml+xml",
+        },
+        timeout=30.0,
+    )
+    try:
+        # Streamed so the size cap bounds what is read, rather than truncating a
+        # body already buffered in full.
+        response = await client.send(request, stream=True, follow_redirects=True)
+    except Exception as exc:
+        raise GameFAQsFetchError(f"Could not fetch guide: {exc}") from exc
+
+    try:
+        # A challenge page carries a 403 and challenge HTML, so it would
+        # otherwise surface as an opaque status error or an empty parse.
+        if is_bot_challenge(response):
+            raise GameFAQsFetchError(
+                "GameFAQs is blocking automated requests for this guide. Open "
+                "the guide in your browser, save it as a text file, and upload "
+                "it here instead."
+            )
+
+        chunks: list[bytes] = []
+        read = 0
+        try:
+            response.raise_for_status()
+            async for chunk in response.aiter_bytes():
+                chunks.append(chunk)
+                read += len(chunk)
+                if read >= MAX_GUIDE_BYTES:
+                    break
+        except Exception as exc:
+            raise GameFAQsFetchError(f"Could not fetch guide: {exc}") from exc
+    finally:
+        await response.aclose()
+
+    content = b"".join(chunks)[:MAX_GUIDE_BYTES]
+    guide = parse_gamefaqs_guide(content.decode("utf-8", errors="replace"))
+
+    if not guide["text"]:
+        raise GameFAQsFetchError(
+            "No guide text found at that URL. It may be a formatted guide or "
+            "an unsupported page."
+        )
+    return guide
diff --git a/backend/models/rom.py b/backend/models/rom.py
index 0258e7a4ca..b198db6227 100644
--- a/backend/models/rom.py
+++ b/backend/models/rom.py
@@ -96,6 +96,7 @@ class RomFileCategory(enum.StrEnum):
     DLC = "dlc"
     HACK = "hack"
     MANUAL = "manual"
+    WALKTHROUGH = "walkthrough"
     PATCH = "patch"
     UPDATE = "update"
     MOD = "mod"
@@ -107,6 +108,19 @@ class RomFileCategory(enum.StrEnum):
     SCREENSHOT = "screenshot"
 
 
+# Document-category files (manuals, walkthroughs) share one substrate: a
+# RomFile plus an optional RomFileDocMeta sidecar for provenance.
+DOCUMENT_CATEGORIES = frozenset({RomFileCategory.MANUAL, RomFileCategory.WALKTHROUGH})
+
+
+class DocSource(enum.StrEnum):
+    """Where a document (manual/walkthrough) came from."""
+
+    UPLOAD = "upload"  # User-uploaded file
+    GAMEFAQS = "gamefaqs"  # Fetched from a GameFAQs guide URL
+    SCRAPER = "scraper"  # Downloaded by a metadata provider
+
+
 class SiblingRom(BaseModel):
     __tablename__ = "sibling_roms"
 
@@ -173,6 +187,16 @@ class RomFile(BaseModel):
         uselist=False,
         cascade="all, delete-orphan",
     )
+    doc_meta: Mapped[RomFileDocMeta | None] = relationship(
+        back_populates="rom_file",
+        uselist=False,
+        cascade="all, delete-orphan",
+    )
+    user_states: Mapped[list[RomFileUser]] = relationship(
+        back_populates="rom_file",
+        cascade="all, delete-orphan",
+        lazy="raise",
+    )
 
     @cached_property
     def full_path(self) -> str:
@@ -276,6 +300,73 @@ class TrackMeta(BaseModel):
     rom_file: Mapped[RomFile] = relationship(back_populates="track_meta")
 
 
+# Max length for free-text document metadata (author / title).
+DOC_META_MAX_LENGTH = 512
+
+
+class RomFileDocMeta(BaseModel):
+    """Provenance sidecar for document-category files (manuals, walkthroughs).
+
+    Only doc-category RomFiles carry a row here, so these fields don't bloat
+    every rom_files row. Format is intentionally not stored: it is derived from
+    the file extension.
+    """
+
+    __tablename__ = "rom_file_doc_meta"
+
+    __table_args__ = (Index("idx_rom_file_doc_meta_rom_id", "rom_id"),)
+
+    rom_file_id: Mapped[int] = mapped_column(
+        ForeignKey("rom_files.id", ondelete="CASCADE"), primary_key=True
+    )
+    rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE"))
+    source: Mapped[DocSource] = mapped_column(
+        Enum(DocSource), default=DocSource.UPLOAD, nullable=False
+    )
+    source_url: Mapped[str | None] = mapped_column(Text, default=None)
+    author: Mapped[str | None] = mapped_column(
+        String(length=DOC_META_MAX_LENGTH), default=None
+    )
+    title: Mapped[str | None] = mapped_column(
+        String(length=DOC_META_MAX_LENGTH), default=None
+    )
+
+    rom_file: Mapped[RomFile] = relationship(back_populates="doc_meta")
+
+
+class RomFileUser(BaseModel):
+    """Per-user reading state for a document-category file.
+
+    Keyed on (rom_file_id, user_id) so one mechanism covers both manuals and
+    walkthroughs. `progress` is a 0.0-1.0 scroll fraction; `last_page` tracks
+    the page for paginated (PDF) documents.
+    """
+
+    __tablename__ = "rom_file_user"
+
+    __table_args__ = (
+        UniqueConstraint("rom_file_id", "user_id", name="unique_rom_file_user"),
+        Index("idx_rom_file_user", "rom_file_id", "user_id"),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+
+    rom_file_id: Mapped[int] = mapped_column(
+        ForeignKey("rom_files.id", ondelete="CASCADE")
+    )
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
+
+    progress: Mapped[float] = mapped_column(Float(), default=0.0, nullable=False)
+    last_page: Mapped[int | None] = mapped_column(Integer(), default=None)
+    finished: Mapped[bool] = mapped_column(default=False, nullable=False)
+    last_read_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
+
+    rom_file: Mapped[RomFile] = relationship(
+        lazy="joined", back_populates="user_states"
+    )
+    user: Mapped[User] = relationship(lazy="joined")
+
+
 class RomMetadata(BaseModel):
     __tablename__ = "roms_metadata"
 
diff --git a/backend/tests/endpoints/roms/test_file_progress.py b/backend/tests/endpoints/roms/test_file_progress.py
new file mode 100644
index 0000000000..e380facfcd
--- /dev/null
+++ b/backend/tests/endpoints/roms/test_file_progress.py
@@ -0,0 +1,149 @@
+from datetime import timedelta
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
+from handler.auth import oauth_handler
+from handler.auth.constants import Scope
+from handler.database import db_rom_handler
+from models.rom import Rom, RomFile, RomFileCategory
+from models.user import User
+
+
+def _auth(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def walkthrough_file(game_folder_rom: Rom) -> RomFile:
+    return db_rom_handler.add_rom_file(
+        RomFile(
+            rom_id=game_folder_rom.id,
+            file_name="guide.txt",
+            file_path=f"{game_folder_rom.full_path}/walkthrough",
+            file_size_bytes=10,
+            category=RomFileCategory.WALKTHROUGH,
+        )
+    )
+
+
+def test_get_progress_defaults_to_zero(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    response = client.get(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+    )
+    assert response.status_code == status.HTTP_200_OK
+    body = response.json()
+    assert body["progress"] == 0.0
+    assert body["finished"] is False
+
+
+def test_put_then_get_progress_roundtrip(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    put = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+        json={"progress": 0.42, "last_page": 3, "finished": False},
+    )
+    assert put.status_code == status.HTTP_200_OK
+    assert put.json()["progress"] == pytest.approx(0.42)
+    assert put.json()["last_page"] == 3
+
+    got = client.get(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+    )
+    assert got.json()["progress"] == pytest.approx(0.42)
+    assert got.json()["last_page"] == 3
+
+
+def test_put_progress_clamps_out_of_range(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    put = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+        json={"progress": 5.0},
+    )
+    assert put.status_code == status.HTTP_200_OK
+    assert put.json()["progress"] == 1.0
+
+
+def test_put_progress_rejects_oversized_last_page(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    """Out of range must be a 400, not an integer-column overflow at insert."""
+    put = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+        json={"last_page": 2**40},
+    )
+    assert put.status_code == status.HTTP_400_BAD_REQUEST
+
+
+def test_put_progress_requires_the_self_service_write_scope(
+    client: TestClient,
+    admin_user: User,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    """Writing progress needs ROMS_USER_WRITE, which a read-only token lacks.
+
+    Every signed-in user holds it (it is self-service, like notes), but the
+    kiosk guest is capped to the read scopes and so must not be able to write.
+    """
+    read_only = oauth_handler.create_access_token(
+        data={
+            "sub": admin_user.username,
+            "iss": "romm:oauth",
+            "scopes": str(Scope.ROMS_READ),
+        },
+        expires_delta=timedelta(seconds=OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS),
+    )
+
+    put = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(read_only),
+        json={"progress": 0.5},
+    )
+    assert put.status_code == status.HTTP_403_FORBIDDEN
+
+    # The read scope is still enough to read progress back.
+    got = client.get(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(read_only),
+    )
+    assert got.status_code == status.HTTP_200_OK
+
+
+def test_progress_rejects_non_document_file(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+):
+    game_file = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.GAME
+    )[0]
+    response = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{game_file.id}/progress",
+        headers=_auth(access_token),
+        json={"progress": 0.5},
+    )
+    assert response.status_code == status.HTTP_404_NOT_FOUND
diff --git a/backend/tests/endpoints/roms/test_manual.py b/backend/tests/endpoints/roms/test_manual.py
index 31e4888fe6..5300161e14 100644
--- a/backend/tests/endpoints/roms/test_manual.py
+++ b/backend/tests/endpoints/roms/test_manual.py
@@ -119,8 +119,10 @@ def test_upload_manual_to_resources_rejects_unsupported_extension(
 ):
     response = client.post(
         f"/api/roms/{rom.id}/manuals",
-        headers={**_auth(access_token), "x-upload-filename": "manual.txt"},
-        files={"manual.txt": ("manual.txt", b"not allowed", "text/plain")},
+        headers={**_auth(access_token), "x-upload-filename": "manual.exe"},
+        files={
+            "manual.exe": ("manual.exe", b"not allowed", "application/octet-stream")
+        },
     )
 
     assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -220,8 +222,8 @@ def test_upload_manual_to_folder_rejects_non_pdf(
 ):
     response = client.post(
         f"/api/roms/{game_folder_rom.id}/manuals/files",
-        headers={**_auth(access_token), "x-upload-filename": "manual.txt"},
-        files={"manual.txt": ("manual.txt", b"not a pdf", "text/plain")},
+        headers={**_auth(access_token), "x-upload-filename": "manual.exe"},
+        files={"manual.exe": ("manual.exe", b"not a pdf", "application/octet-stream")},
     )
 
     assert response.status_code == status.HTTP_400_BAD_REQUEST
diff --git a/backend/tests/endpoints/roms/test_walkthrough.py b/backend/tests/endpoints/roms/test_walkthrough.py
new file mode 100644
index 0000000000..3e5b0e6aac
--- /dev/null
+++ b/backend/tests/endpoints/roms/test_walkthrough.py
@@ -0,0 +1,223 @@
+from pathlib import Path
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from endpoints.roms import walkthrough as walkthrough_endpoint
+from handler.database import db_rom_handler
+from models.rom import DocSource, Rom, RomFile, RomFileCategory
+
+TXT_BYTES = b"Chrono Trigger walkthrough\n\nStep 1: leave home.\n"
+GAMEFAQS_URL = "https://gamefaqs.gamespot.com/snes/563538-chrono-trigger/faqs/1"
+
+
+def _auth(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def walkthrough_fs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+    """Mock fs_rom_handler so walkthrough writes land in tmp_path."""
+    folder_dir = tmp_path / "library"
+    folder_dir.mkdir()
+
+    def validate_path(path: str) -> Path:
+        return folder_dir / Path(path).name
+
+    async def remove_file(path: str) -> None:
+        target = folder_dir / Path(path).name
+        if target.exists():
+            target.unlink()
+        else:
+            raise FileNotFoundError(path)
+
+    async def write_file(file, path: str, filename: str) -> None:
+        (folder_dir / filename).write_bytes(file)
+
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler, "validate_path", validate_path
+    )
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler,
+        "make_directory",
+        AsyncMock(return_value=None),
+    )
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler,
+        "remove_file",
+        AsyncMock(side_effect=remove_file),
+    )
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler,
+        "write_file",
+        AsyncMock(side_effect=write_file),
+    )
+    return folder_dir
+
+
+# ---------- POST /api/roms/{id}/walkthroughs/files ----------
+
+
+def test_upload_walkthrough_file_success(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files",
+        headers={
+            **_auth(access_token),
+            "x-upload-filename": "guide.txt",
+            "x-doc-author": "John%20Doe",
+            "x-doc-title": "Full%20Walkthrough",
+        },
+        files={"guide.txt": ("guide.txt", TXT_BYTES, "text/plain")},
+    )
+
+    assert response.status_code == status.HTTP_201_CREATED
+
+    files = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.WALKTHROUGH
+    )
+    assert len(files) == 1
+    created = files[0]
+    assert created.file_name == "guide.txt"
+    assert created.doc_meta is not None
+    assert created.doc_meta.source == DocSource.UPLOAD
+    assert created.doc_meta.author == "John Doe"
+    assert created.doc_meta.title == "Full Walkthrough"
+
+
+def test_upload_walkthrough_rejects_unsupported_extension(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files",
+        headers={**_auth(access_token), "x-upload-filename": "guide.exe"},
+        files={"guide.exe": ("guide.exe", b"nope", "application/octet-stream")},
+    )
+
+    assert response.status_code == status.HTTP_400_BAD_REQUEST
+    assert "Unsupported walkthrough file type" in response.json()["detail"]
+
+
+def test_upload_walkthrough_rom_not_found(
+    client: TestClient,
+    access_token: str,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        "/api/roms/999999/walkthroughs/files",
+        headers={**_auth(access_token), "x-upload-filename": "guide.txt"},
+        files={"guide.txt": ("guide.txt", TXT_BYTES, "text/plain")},
+    )
+    assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+# ---------- DELETE /api/roms/{id}/walkthroughs/files/{file_id} ----------
+
+
+def test_delete_walkthrough_file(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    (walkthrough_fs / "guide.txt").write_bytes(TXT_BYTES)
+    rom_file = db_rom_handler.add_rom_file(
+        RomFile(
+            rom_id=game_folder_rom.id,
+            file_name="guide.txt",
+            file_path=f"{game_folder_rom.full_path}/walkthrough",
+            file_size_bytes=len(TXT_BYTES),
+            category=RomFileCategory.WALKTHROUGH,
+        )
+    )
+
+    response = client.delete(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files/{rom_file.id}",
+        headers=_auth(access_token),
+    )
+    assert response.status_code == status.HTTP_200_OK
+    assert db_rom_handler.get_rom_file_by_id(rom_file.id) is None
+
+
+def test_delete_walkthrough_rejects_non_walkthrough_file(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    game_file = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.GAME
+    )[0]
+
+    response = client.delete(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files/{game_file.id}",
+        headers=_auth(access_token),
+    )
+    assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+# ---------- POST /api/roms/{id}/walkthroughs/gamefaqs ----------
+
+
+def test_add_gamefaqs_walkthrough_success(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+    monkeypatch: pytest.MonkeyPatch,
+):
+    monkeypatch.setattr(
+        walkthrough_endpoint,
+        "fetch_gamefaqs_guide",
+        AsyncMock(
+            return_value={
+                "title": "Chrono Trigger FAQ",
+                "author": "Jane Roe",
+                "text": "This is the guide body.",
+            }
+        ),
+    )
+
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/gamefaqs",
+        headers=_auth(access_token),
+        json={"url": GAMEFAQS_URL},
+    )
+
+    assert response.status_code == status.HTTP_201_CREATED
+
+    files = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.WALKTHROUGH
+    )
+    assert len(files) == 1
+    created = files[0]
+    assert created.file_name == "chrono-trigger-faq.txt"
+    assert created.doc_meta.source == DocSource.GAMEFAQS
+    assert created.doc_meta.source_url == GAMEFAQS_URL
+    assert created.doc_meta.author == "Jane Roe"
+    assert (walkthrough_fs / "chrono-trigger-faq.txt").read_bytes() == (
+        b"This is the guide body."
+    )
+
+
+def test_add_gamefaqs_walkthrough_rejects_bad_host(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/gamefaqs",
+        headers=_auth(access_token),
+        json={"url": "https://evil.example.com/guide"},
+    )
+    assert response.status_code == status.HTTP_400_BAD_REQUEST
diff --git a/backend/tests/endpoints/test_permissions_visibility.py b/backend/tests/endpoints/test_permissions_visibility.py
index 9137826f59..e4e71236d7 100644
--- a/backend/tests/endpoints/test_permissions_visibility.py
+++ b/backend/tests/endpoints/test_permissions_visibility.py
@@ -12,6 +12,7 @@
 from fastapi import status
 
 from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
+from endpoints.roms import walkthrough as walkthrough_endpoints
 from handler.auth import oauth_handler
 from handler.database import db_rom_handler, db_user_handler
 from handler.database.base_handler import sync_session
@@ -161,6 +162,7 @@ def test_hidden_rom_patch_is_404_masked(client, viewer_user, rom, rom_file):
         ("/manuals", {"x-upload-filename": "manual.pdf"}),
         ("/manuals/files", {"x-upload-filename": "manual.pdf"}),
         ("/manuals/redownload", {}),
+        ("/walkthroughs/files", {"x-upload-filename": "guide.txt"}),
     ],
 )
 def test_hidden_rom_child_upload_routes_are_404_masked(
@@ -181,6 +183,7 @@ def test_hidden_rom_child_upload_routes_are_404_masked(
         ("/screenshots/{file_id}", RomFileCategory.SCREENSHOT),
         ("/soundtracks/{file_id}", RomFileCategory.SOUNDTRACK),
         ("/manuals/files/{file_id}", RomFileCategory.MANUAL),
+        ("/walkthroughs/files/{file_id}", RomFileCategory.WALKTHROUGH),
     ],
 )
 def test_hidden_rom_child_delete_routes_are_404_masked(
@@ -208,6 +211,25 @@ def test_hidden_rom_child_delete_routes_are_404_masked(
     assert db_rom_handler.get_rom_file_by_id(child.id) is not None
 
 
+def test_hidden_rom_gamefaqs_walkthrough_is_404_masked(
+    client, editor_user, rom, monkeypatch
+):
+    # The visibility gate must run before the outbound fetch, so a hidden rom
+    # can't be used to make the server reach GameFAQs.
+    def _unreachable(*_args, **_kwargs):
+        raise AssertionError("fetched a guide for a hidden rom")
+
+    monkeypatch.setattr(walkthrough_endpoints, "fetch_gamefaqs_guide", _unreachable)
+    _hide(PermEntity.ROMS, rom.id, editor_user.id)
+
+    resp = client.post(
+        f"/api/roms/{rom.id}/walkthroughs/gamefaqs",
+        headers=_auth(editor_user),
+        json={"url": "https://gamefaqs.gamespot.com/snes/1234-game/faqs/5678"},
+    )
+    assert resp.status_code == status.HTTP_404_NOT_FOUND
+
+
 def test_hidden_rom_manual_delete_is_404_masked(client, editor_user, rom, monkeypatch):
     # Without a manual on disk the route 404s before the visibility gate, so
     # force the existence check to pass.
diff --git a/backend/tests/handler/filesystem/test_resources_handler.py b/backend/tests/handler/filesystem/test_resources_handler.py
index e63b3c685b..d2407c718b 100644
--- a/backend/tests/handler/filesystem/test_resources_handler.py
+++ b/backend/tests/handler/filesystem/test_resources_handler.py
@@ -423,7 +423,7 @@ def test_get_manual_path_no_manual(self, handler: FSResourcesHandler, rom: Rom):
         result = handler._get_manual_path(rom)
         assert result is None
 
-    @pytest.mark.parametrize("ext", [".pdf", ".md"])
+    @pytest.mark.parametrize("ext", [".pdf", ".md", ".txt"])
     def test_manual_exists_finds_extension(
         self, handler: FSResourcesHandler, rom: Rom, tmp_path, ext: str
     ):
@@ -435,7 +435,7 @@ def test_manual_exists_finds_extension(
 
         assert handler.manual_exists(rom)
 
-    @pytest.mark.parametrize("ext", [".pdf", ".md"])
+    @pytest.mark.parametrize("ext", [".pdf", ".md", ".txt"])
     def test_get_manual_path_finds_extension(
         self, handler: FSResourcesHandler, rom: Rom, tmp_path, ext: str
     ):
@@ -448,11 +448,11 @@ def test_get_manual_path_finds_extension(
         result = handler._get_manual_path(rom)
         assert result == f"{rom.fs_resources_path}/manual/{rom.id}{ext}"
 
-    @pytest.mark.parametrize("ext", [".part", ".bak", ".tmp", ".txt"])
+    @pytest.mark.parametrize("ext", [".part", ".bak", ".tmp", ".exe"])
     def test_manual_exists_ignores_disallowed_extensions(
         self, handler: FSResourcesHandler, rom: Rom, tmp_path, ext: str
     ):
-        """Files that aren't PDF or Markdown must not be treated as manuals."""
+        """Files that aren't an allowed manual document must not count as manuals."""
         handler.base_path = tmp_path
         manual_dir = tmp_path / rom.fs_resources_path / "manual"
         manual_dir.mkdir(parents=True)
diff --git a/backend/tests/handler/walkthrough/__init__.py b/backend/tests/handler/walkthrough/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/tests/handler/walkthrough/test_gamefaqs.py b/backend/tests/handler/walkthrough/test_gamefaqs.py
new file mode 100644
index 0000000000..782e6ce429
--- /dev/null
+++ b/backend/tests/handler/walkthrough/test_gamefaqs.py
@@ -0,0 +1,154 @@
+import httpx
+import pytest
+
+from handler.walkthrough import (
+    fetch_gamefaqs_guide,
+    parse_gamefaqs_guide,
+    validate_gamefaqs_url,
+)
+from handler.walkthrough.gamefaqs import (
+    MAX_GUIDE_BYTES,
+    GameFAQsFetchError,
+    is_bot_challenge,
+)
+from utils.context import ctx_httpx_client
+
+
+def test_parse_extracts_title_author_and_pre_text():
+    html = (
+        ""
+        "Chrono Trigger - FAQ/Walkthrough - GameFAQs"
+        ''
+        ''
+        ""
+        '
Line 1\nLine 2 & more
' + "" + ) + guide = parse_gamefaqs_guide(html) + assert guide["title"] == "Chrono Trigger FAQ" + assert guide["author"] == "John Doe" + assert guide["text"] == "Line 1\nLine 2 & more" + + +def test_parse_falls_back_to_title_tag_and_strips_suffix(): + html = ( + "Some Guide - GameFAQs" + "
body
" + ) + guide = parse_gamefaqs_guide(html) + assert guide["title"] == "Some Guide" + assert guide["text"] == "body" + + +def test_parse_no_guide_text_returns_empty(): + guide = parse_gamefaqs_guide("

nothing here

") + assert guide["text"] == "" + + +@pytest.mark.parametrize( + "url", + [ + "http://gamefaqs.gamespot.com/x", # not https + "https://evil.example.com/x", # wrong host + "ftp://gamefaqs.com/x", # wrong scheme + "https://notgamefaqs.com/x", # lookalike host + ], +) +def test_validate_rejects_bad_urls(url): + with pytest.raises(ValueError): + validate_gamefaqs_url(url) + + +@pytest.mark.parametrize( + "url", + [ + "https://gamefaqs.gamespot.com/snes/563538-chrono-trigger/faqs/1", + "https://www.gamefaqs.com/snes/563538/faqs/1", + "https://gamefaqs.com/snes/563538/faqs/1", + ], +) +def test_validate_accepts_gamefaqs_urls(url): + assert validate_gamefaqs_url(url) == url + + +def _response( + status: int, headers: dict[str, str], content: bytes = b"" +) -> httpx.Response: + return httpx.Response( + status_code=status, + headers=headers, + content=content, + # raise_for_status() needs the originating request attached. + request=httpx.Request("GET", "https://gamefaqs.gamespot.com/x"), + ) + + +class _StreamingClient: + """Minimal stand-in for the shared httpx client's streaming interface.""" + + def __init__(self, response: httpx.Response) -> None: + self._response = response + + def build_request(self, *_args, **_kwargs): + return None + + async def send(self, *_args, **_kwargs): + return self._response + + +def test_detects_cloudflare_managed_challenge(): + # Headers as served by GameFAQs behind Cloudflare bot protection. + response = _response(403, {"cf-mitigated": "challenge", "server": "cloudflare"}) + assert is_bot_challenge(response) + + +@pytest.mark.parametrize("status", [403, 503]) +def test_detects_cloudflare_refusal_without_mitigation_header(status): + assert is_bot_challenge(_response(status, {"server": "cloudflare"})) + + +@pytest.mark.parametrize( + ("status", "headers"), + [ + (200, {"cf-mitigated": "challenge", "server": "cloudflare"}), # served + (403, {"server": "nginx"}), # a real permission denial + (404, {"server": "cloudflare"}), # guide is gone, not blocked + (500, {}), # upstream fault + ], +) +def test_other_responses_are_not_treated_as_challenges(status, headers): + assert not is_bot_challenge(_response(status, headers)) + + +async def test_fetch_stops_reading_at_the_size_cap(): + """The cap must bound what is read, not truncate an already-buffered body.""" + oversized = b"
" + b"x" * (MAX_GUIDE_BYTES * 2)
+    served = _response(200, {"content-type": "text/html"}, content=oversized)
+
+    token = ctx_httpx_client.set(_StreamingClient(served))  # type: ignore[arg-type]
+    try:
+        guide = await fetch_gamefaqs_guide(
+            "https://gamefaqs.gamespot.com/snes/563538-chrono-trigger/faqs/1"
+        )
+    finally:
+        ctx_httpx_client.reset(token)
+
+    # Body is capped, and the guide still parses out of the truncated HTML.
+    assert 0 < len(guide["text"]) <= MAX_GUIDE_BYTES
+
+
+async def test_fetch_explains_how_to_recover_from_a_bot_challenge():
+    """A challenge must surface as actionable advice, not an opaque 403."""
+    challenge = _response(403, {"cf-mitigated": "challenge", "server": "cloudflare"})
+
+    token = ctx_httpx_client.set(_StreamingClient(challenge))  # type: ignore[arg-type]
+    try:
+        with pytest.raises(GameFAQsFetchError) as exc:
+            await fetch_gamefaqs_guide(
+                "https://gamefaqs.gamespot.com/gba/471043-advance-wars/faqs/23604"
+            )
+    finally:
+        ctx_httpx_client.reset(token)
+
+    assert "blocking automated requests" in str(exc.value)
+    assert "upload" in str(exc.value)
diff --git a/backend/utils/media_types.py b/backend/utils/media_types.py
index 9e70cea288..d8f06bc19e 100644
--- a/backend/utils/media_types.py
+++ b/backend/utils/media_types.py
@@ -15,7 +15,11 @@
     {".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff", ".tif", ".bmp", ".avif"}
 )
 ALLOWED_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".ogv", ".mov", ".m4v"})
-ALLOWED_DOCUMENT_EXTENSIONS = frozenset({".pdf", ".md"})
+ALLOWED_DOCUMENT_EXTENSIONS = frozenset({".pdf", ".md", ".txt", ".html", ".htm"})
+
+# Document extensions that are HTML and therefore must be served under a
+# sandboxing CSP even after ingest-time sanitization (defense in depth).
+HTML_DOCUMENT_EXTENSIONS = frozenset({".html", ".htm"})
 
 # MIME types the stdlib mimetypes module guesses inconsistently (or not at
 # all) across platforms.
@@ -29,8 +33,18 @@
     ".mov": "video/quicktime",
     ".pdf": "application/pdf",
     ".md": "text/markdown",
+    ".txt": "text/plain",
+    ".html": "text/html",
+    ".htm": "text/html",
 }
 
+
+def is_html_document_file(file_name: str) -> bool:
+    """Whether a document is HTML and needs CSP sandboxing when served inline."""
+    ext = os.path.splitext(file_name)[1].lower()
+    return ext in HTML_DOCUMENT_EXTENSIONS
+
+
 # Image MIME types we trust to (a) accept as avatar uploads and (b) serve
 # inline from the raw asset endpoint. Maps each trusted MIME type to its
 # canonical file extension.
diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts
index 12762d7482..4fa93fb9f2 100644
--- a/frontend/src/__generated__/index.ts
+++ b/frontend/src/__generated__/index.ts
@@ -70,6 +70,8 @@ export type { DeviceHeartbeatPayload } from './models/DeviceHeartbeatPayload';
 export type { DeviceSchema } from './models/DeviceSchema';
 export type { DeviceSyncSchema } from './models/DeviceSyncSchema';
 export type { DeviceUpdatePayload } from './models/DeviceUpdatePayload';
+export type { DocMetaSchema } from './models/DocMetaSchema';
+export type { DocSource } from './models/DocSource';
 export type { EarnedAchievement } from './models/EarnedAchievement';
 export type { EjsControls } from './models/EjsControls';
 export type { EjsControlsButton } from './models/EjsControlsButton';
@@ -139,6 +141,7 @@ export type { Role } from './models/Role';
 export type { RomArchiveMember } from './models/RomArchiveMember';
 export type { RomFileCategory } from './models/RomFileCategory';
 export type { RomFileSchema } from './models/RomFileSchema';
+export type { RomFileUserSchema } from './models/RomFileUserSchema';
 export type { RomFiltersDict } from './models/RomFiltersDict';
 export type { RomFlashpointMetadata } from './models/RomFlashpointMetadata';
 export type { RomGamelistMetadata } from './models/RomGamelistMetadata';
diff --git a/frontend/src/__generated__/models/DocMetaSchema.ts b/frontend/src/__generated__/models/DocMetaSchema.ts
new file mode 100644
index 0000000000..6f9ba850c1
--- /dev/null
+++ b/frontend/src/__generated__/models/DocMetaSchema.ts
@@ -0,0 +1,12 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { DocSource } from './DocSource';
+export type DocMetaSchema = {
+    source: DocSource;
+    source_url?: (string | null);
+    author?: (string | null);
+    title?: (string | null);
+};
+
diff --git a/frontend/src/__generated__/models/DocSource.ts b/frontend/src/__generated__/models/DocSource.ts
new file mode 100644
index 0000000000..85f338529e
--- /dev/null
+++ b/frontend/src/__generated__/models/DocSource.ts
@@ -0,0 +1,8 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Where a document (manual/walkthrough) came from.
+ */
+export type DocSource = 'upload' | 'gamefaqs' | 'scraper';
diff --git a/frontend/src/__generated__/models/RomFileCategory.ts b/frontend/src/__generated__/models/RomFileCategory.ts
index 8f3295fc30..10319cb436 100644
--- a/frontend/src/__generated__/models/RomFileCategory.ts
+++ b/frontend/src/__generated__/models/RomFileCategory.ts
@@ -2,4 +2,4 @@
 /* istanbul ignore file */
 /* tslint:disable */
 /* eslint-disable */
-export type RomFileCategory = 'game' | 'dlc' | 'hack' | 'manual' | 'patch' | 'update' | 'mod' | 'demo' | 'translation' | 'prototype' | 'cheat' | 'soundtrack' | 'screenshot';
+export type RomFileCategory = 'game' | 'dlc' | 'hack' | 'manual' | 'walkthrough' | 'patch' | 'update' | 'mod' | 'demo' | 'translation' | 'prototype' | 'cheat' | 'soundtrack' | 'screenshot';
diff --git a/frontend/src/__generated__/models/RomFileSchema.ts b/frontend/src/__generated__/models/RomFileSchema.ts
index 5d672771c7..449120e09e 100644
--- a/frontend/src/__generated__/models/RomFileSchema.ts
+++ b/frontend/src/__generated__/models/RomFileSchema.ts
@@ -2,6 +2,7 @@
 /* istanbul ignore file */
 /* tslint:disable */
 /* eslint-disable */
+import type { DocMetaSchema } from './DocMetaSchema';
 import type { RomArchiveMember } from './RomArchiveMember';
 import type { RomFileCategory } from './RomFileCategory';
 import type { TrackMetaSchema } from './TrackMetaSchema';
@@ -24,5 +25,6 @@ export type RomFileSchema = {
     archive_members: (Array | null);
     category: (RomFileCategory | null);
     track_meta?: (TrackMetaSchema | null);
+    doc_meta?: (DocMetaSchema | null);
 };
 
diff --git a/frontend/src/__generated__/models/RomFileUserSchema.ts b/frontend/src/__generated__/models/RomFileUserSchema.ts
new file mode 100644
index 0000000000..a5a586f552
--- /dev/null
+++ b/frontend/src/__generated__/models/RomFileUserSchema.ts
@@ -0,0 +1,13 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type RomFileUserSchema = {
+    rom_file_id: number;
+    user_id: number;
+    progress: number;
+    last_page?: (number | null);
+    finished?: boolean;
+    last_read_at?: (string | null);
+};
+
diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json
index 8142f163ba..7c30874dc0 100644
--- a/frontend/src/locales/bg_BG/rom.json
+++ b/frontend/src/locales/bg_BG/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Добави бележка",
   "add-to-collection": "Добави в колекция",
   "add-to-favorites": "Добави в любими",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Добавяне на {n} ROM-а в колекция",
   "additional-content": "Допълнително съдържание",
   "additional-details": "Допълнителни детайли",
@@ -47,6 +48,7 @@
   "category-prototype": "Прототип",
   "category-translation": "Превод",
   "category-update": "Обновление",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Диск {n}",
   "chip-track-n": "Песен {n}",
   "clear-all": "Изчисти всички",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Качи файлове на саундтрака",
   "upload-states": "Качи бързи записи",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Потвърждение",
   "verified-rom": "Потвърден ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} версии",
   "volume-mute": "Заглуши",
   "volume-unmute": "Включи звука",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube видео ID"
 }
diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json
index 0bc0bb6a6f..c11c003e7d 100644
--- a/frontend/src/locales/cs_CZ/rom.json
+++ b/frontend/src/locales/cs_CZ/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Přidat poznámku",
   "add-to-collection": "Přidat do kolekce",
   "add-to-favorites": "Přidat mezi oblíbené",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Přidávání {n} ROM do kolekce",
   "additional-content": "Další obsah",
   "additional-details": "Další detaily",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototyp",
   "category-translation": "Překlad",
   "category-update": "Aktualizace",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disk {n}",
   "chip-track-n": "Skladba {n}",
   "clear-all": "Vymazat vše",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Nahrát soubory soundtracku",
   "upload-states": "Nahrát stavy",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Ověření",
   "verified-rom": "Ověřená ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} verzí",
   "volume-mute": "Ztlumit",
   "volume-unmute": "Zrušit ztlumení",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "ID YouTube videa"
 }
diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json
index 73a1e6b2be..5d96fb2614 100644
--- a/frontend/src/locales/de_DE/rom.json
+++ b/frontend/src/locales/de_DE/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Notiz hinzufügen",
   "add-to-collection": "Zu Sammlung hinzufügen",
   "add-to-favorites": "Zu Favoriten hinzufügen",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Füge {n} ROMs zu Sammlung hinzu",
   "additional-content": "Zustätliche Inhalte",
   "additional-details": "Zusätzliche Details",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototyp",
   "category-translation": "Übersetzung",
   "category-update": "Update",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disc {n}",
   "chip-track-n": "Track {n}",
   "clear-all": "Alle löschen",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Soundtrack-Dateien hochladen",
   "upload-states": "Zustände hochladen",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Verifizierung",
   "verified-rom": "Verifizierte ROM",
   "verified-rom-hint": "Hash mit ROM-Datenbank abgeglichen",
   "versions-count": "{n} Versionen",
   "volume-mute": "Stummschalten",
   "volume-unmute": "Stummschaltung aufheben",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube-Video-ID"
 }
diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json
index 52e0c5ad66..32bf9394a8 100644
--- a/frontend/src/locales/en_GB/rom.json
+++ b/frontend/src/locales/en_GB/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Add Note",
   "add-to-collection": "Add to collection",
   "add-to-favorites": "Add to favourites",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Adding {n} ROMs to collection",
   "additional-content": "Additional content",
   "additional-details": "Additional details",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototype",
   "category-translation": "Translation",
   "category-update": "Update",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disc {n}",
   "chip-track-n": "Track {n}",
   "clear-all": "Clear all",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Upload soundtrack files",
   "upload-states": "Upload states",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Verification",
   "verified-rom": "Verified ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} versions",
   "volume-mute": "Mute",
   "volume-unmute": "Unmute",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube video ID"
 }
diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json
index aae1cd4ff8..1215e09b4e 100644
--- a/frontend/src/locales/en_US/rom.json
+++ b/frontend/src/locales/en_US/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Add Note",
   "add-to-collection": "Add to collection",
   "add-to-favorites": "Add to favorites",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Adding {n} ROMs to collection",
   "additional-content": "Additional content",
   "additional-details": "Additional details",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototype",
   "category-translation": "Translation",
   "category-update": "Update",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disc {n}",
   "chip-track-n": "Track {n}",
   "clear-all": "Clear all",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Upload soundtrack files",
   "upload-states": "Upload states",
   "upload-tracks": "Upload tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Verification",
   "verified-rom": "Verified ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} versions",
   "volume-mute": "Mute",
   "volume-unmute": "Unmute",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube video ID"
 }
diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json
index 9f33303849..58da2552df 100644
--- a/frontend/src/locales/es_ES/rom.json
+++ b/frontend/src/locales/es_ES/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Agregar Nota",
   "add-to-collection": "Añadir a la colección",
   "add-to-favorites": "Añadir a favoritos",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Añadiendo {n} ROMs a la colección",
   "additional-content": "Contenido adicional",
   "additional-details": "Detalles adicionales",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototipo",
   "category-translation": "Traducción",
   "category-update": "Actualización",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disco {n}",
   "chip-track-n": "Pista {n}",
   "clear-all": "Limpiar todo",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Subir bandas sonoras",
   "upload-states": "Subir estados",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Verificación",
   "verified-rom": "ROM verificada",
   "verified-rom-hint": "Hash verificado contra una base de datos de ROM",
   "versions-count": "{n} versiones",
   "volume-mute": "Silenciar",
   "volume-unmute": "Quitar silencio",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Tu progreso",
   "youtube-video-id": "ID de vídeo de YouTube"
 }
diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json
index 27c3a0dad6..4cac34ea30 100644
--- a/frontend/src/locales/fr_FR/rom.json
+++ b/frontend/src/locales/fr_FR/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Ajouter une Note",
   "add-to-collection": "Ajouter à la collection",
   "add-to-favorites": "Ajouter aux favoris",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Ajout de {n} ROMs à la collection",
   "additional-content": "Contenu supplémentaire",
   "additional-details": "Détails supplémentaires",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototype",
   "category-translation": "Traduction",
   "category-update": "Mise à jour",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disque {n}",
   "chip-track-n": "Piste {n}",
   "clear-all": "Tout effacer",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Téléverser les fichiers de la bande son",
   "upload-states": "Téléverser des états",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Vérification",
   "verified-rom": "ROM vérifiée",
   "verified-rom-hint": "Empreinte vérifiée dans une base de données de ROM",
   "versions-count": "{n} versions",
   "volume-mute": "Couper le son",
   "volume-unmute": "Activer le son",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "ID vidéo YouTube"
 }
diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json
index 84b218230c..10bf72f8f6 100644
--- a/frontend/src/locales/hu_HU/rom.json
+++ b/frontend/src/locales/hu_HU/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Megjegyzés hozzáadása",
   "add-to-collection": "Hozzáadás a Gyűjteményhez",
   "add-to-favorites": "Hozzáadás a Kedvencekhez",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "{n} ROM-ok hozzáadva a gyűjteményhez",
   "additional-content": "További tartalom",
   "additional-details": "További részletek",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototípus",
   "category-translation": "Fordítás",
   "category-update": "Frissítés",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "{n}. lemez",
   "chip-track-n": "{n}. szám",
   "clear-all": "Összes törlése",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Zenei fájlok feltöltése",
   "upload-states": "Állások feltöltése",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Ellenőrzés",
   "verified-rom": "Ellenőrzött ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} verzió",
   "volume-mute": "Némítás",
   "volume-unmute": "Némítás feloldása",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube videó azonosító"
 }
diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json
index 1a295b79ce..1e923094ed 100644
--- a/frontend/src/locales/it_IT/rom.json
+++ b/frontend/src/locales/it_IT/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Aggiungi Nota",
   "add-to-collection": "Aggiungi alla collezione",
   "add-to-favorites": "Aggiungi ai preferiti",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Aggiungendo {n} ROM alla collezione",
   "additional-content": "Contenuti aggiuntivi",
   "additional-details": "Dettagli aggiuntivi",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototipo",
   "category-translation": "Traduzione",
   "category-update": "Aggiornamento",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disco {n}",
   "chip-track-n": "Traccia {n}",
   "clear-all": "Pulisci tutto",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Carica file colonna sonora",
   "upload-states": "Carica stati",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Verifica",
   "verified-rom": "ROM verificata",
   "verified-rom-hint": "Hash verificato su un database di ROM",
   "versions-count": "{n} versioni",
   "volume-mute": "Disattiva audio",
   "volume-unmute": "Riattiva audio",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "ID video YouTube"
 }
diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json
index 0f598ccc29..bf5c96917d 100644
--- a/frontend/src/locales/ja_JP/rom.json
+++ b/frontend/src/locales/ja_JP/rom.json
@@ -18,6 +18,7 @@
   "add-note": "ノートを追加",
   "add-to-collection": "コレクションに追加",
   "add-to-favorites": "お気に入りに追加",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "コレクションにROM {n} を追加しています",
   "additional-content": "追加コンテンツ",
   "additional-details": "追加の詳細",
@@ -47,6 +48,7 @@
   "category-prototype": "プロトタイプ",
   "category-translation": "翻訳",
   "category-update": "アップデート",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "ディスク{n}",
   "chip-track-n": "トラック{n}",
   "clear-all": "すべてクリア",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "サウンドトラックファイルをアップロード",
   "upload-states": "ステートセーブをアップロード",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "検証",
   "verified-rom": "確認済みROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n}件のバージョン",
   "volume-mute": "ミュート",
   "volume-unmute": "ミュート解除",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube動画ID"
 }
diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json
index 04b409f447..690046e4b3 100644
--- a/frontend/src/locales/ko_KR/rom.json
+++ b/frontend/src/locales/ko_KR/rom.json
@@ -18,6 +18,7 @@
   "add-note": "노트 추가",
   "add-to-collection": "모음집에 추가",
   "add-to-favorites": "즐겨찾기에 추가",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "{n} 개의 롬을 모음집에 추가합니다",
   "additional-content": "추가 컨텐츠",
   "additional-details": "추가 세부 정보",
@@ -47,6 +48,7 @@
   "category-prototype": "프로토타입",
   "category-translation": "번역",
   "category-update": "업데이트",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "디스크 {n}",
   "chip-track-n": "트랙 {n}",
   "clear-all": "모두 지우기",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "사운드트랙 파일 업로드",
   "upload-states": "상태 업로드",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "검증",
   "verified-rom": "검증된 ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n}개 버전",
   "volume-mute": "음소거",
   "volume-unmute": "음소거 해제",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube 동영상 ID"
 }
diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json
index 8f5247ba09..c177e38a55 100644
--- a/frontend/src/locales/pl_PL/rom.json
+++ b/frontend/src/locales/pl_PL/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Dodaj Notatkę",
   "add-to-collection": "Dodaj do kolekcji",
   "add-to-favorites": "Dodaj do ulubionych",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Dodawanie {n} ROM-ów do kolekcji",
   "additional-content": "Dodatkowa zawartość",
   "additional-details": "Dodatkowe szczegóły",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototyp",
   "category-translation": "Tłumaczenie",
   "category-update": "Aktualizacja",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Płyta {n}",
   "chip-track-n": "Utwór {n}",
   "clear-all": "Wyczyść wszystko",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Prześlij pliki ścieżki dźwiękowej",
   "upload-states": "Prześlij stany",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Weryfikacja",
   "verified-rom": "Zweryfikowany ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} wersji",
   "volume-mute": "Wycisz",
   "volume-unmute": "Wyłącz wyciszenie",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "ID filmu YouTube"
 }
diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json
index 62d49b5ef6..25fa998378 100644
--- a/frontend/src/locales/pt_BR/rom.json
+++ b/frontend/src/locales/pt_BR/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Adicionar Nota",
   "add-to-collection": "Adicionar à coleção",
   "add-to-favorites": "Adicionar aos favoritos",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Adicionando {n} ROMs à coleção",
   "additional-content": "Conteúdo adicional",
   "additional-details": "Detalhes adicionais",
@@ -47,6 +48,7 @@
   "category-prototype": "Protótipo",
   "category-translation": "Tradução",
   "category-update": "Atualização",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Disco {n}",
   "chip-track-n": "Faixa {n}",
   "clear-all": "Limpar tudo",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Enviar arquivos de trilha sonora",
   "upload-states": "Enviar states",
   "upload-tracks": "Enviar faixas",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Verificação",
   "verified-rom": "ROM verificada",
   "verified-rom-hint": "Hash verificado em banco de dados de ROM",
   "versions-count": "{n} versões",
   "volume-mute": "Silenciar",
   "volume-unmute": "Reativar som",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Seu progresso",
   "youtube-video-id": "ID do vídeo do YouTube"
 }
diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json
index 988fbb1bd4..8ad898028a 100644
--- a/frontend/src/locales/ro_RO/rom.json
+++ b/frontend/src/locales/ro_RO/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Adaugă Notație",
   "add-to-collection": "Adaugă la colecție",
   "add-to-favorites": "Adaugă la favorite",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Adăugare a {n} ROM-urilor la colecție",
   "additional-content": "Conținut suplimentar",
   "additional-details": "Detalii suplimentare",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototip",
   "category-translation": "Traducere",
   "category-update": "Update",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Discul {n}",
   "chip-track-n": "Piesa {n}",
   "clear-all": "Șterge tot",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Încarcă fișiere de coloană sonoră",
   "upload-states": "Încarcă stări",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Verificare",
   "verified-rom": "ROM verificat",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} versiuni",
   "volume-mute": "Mut",
   "volume-unmute": "Activează sunetul",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "ID video YouTube"
 }
diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json
index 3f29cab418..9fb53890f6 100644
--- a/frontend/src/locales/ru_RU/rom.json
+++ b/frontend/src/locales/ru_RU/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Добавить Заметку",
   "add-to-collection": "Добавить в коллекцию",
   "add-to-favorites": "Добавить в избранное",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "Добавление {n} ромов в коллекцию",
   "additional-content": "Дополнительный контент",
   "additional-details": "Дополнительные сведения",
@@ -47,6 +48,7 @@
   "category-prototype": "Прототип",
   "category-translation": "Перевод",
   "category-update": "Обновление",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "Диск {n}",
   "chip-track-n": "Трек {n}",
   "clear-all": "Очистить всё",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Загрузить файлы саундтрека",
   "upload-states": "Загрузить состояния",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Проверка",
   "verified-rom": "Проверенный ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} версий",
   "volume-mute": "Отключить звук",
   "volume-unmute": "Включить звук",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "ID видео YouTube"
 }
diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json
index a713f8e7df..26fe36d99e 100644
--- a/frontend/src/locales/tr_TR/rom.json
+++ b/frontend/src/locales/tr_TR/rom.json
@@ -18,6 +18,7 @@
   "add-note": "Not ekle",
   "add-to-collection": "Koleksiyona ekle",
   "add-to-favorites": "Favorilere ekle",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "{n} ROM koleksiyona ekleniyor",
   "additional-content": "Ek içerik",
   "additional-details": "Ek ayrıntılar",
@@ -47,6 +48,7 @@
   "category-prototype": "Prototip",
   "category-translation": "Çeviri",
   "category-update": "Güncelleme",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "{n}. Disk",
   "chip-track-n": "{n}. Parça",
   "clear-all": "Tümünü temizle",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "Müzik paketi dosyaları yükle",
   "upload-states": "Durum kaydı yükle",
   "upload-tracks": "Parça yükle",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "Doğrulama",
   "verified-rom": "Doğrulanmış ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} sürüm",
   "volume-mute": "Sesi kapat",
   "volume-unmute": "Sesi aç",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube video ID"
 }
diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json
index ba2d086cec..5e59b21c9d 100644
--- a/frontend/src/locales/zh_CN/rom.json
+++ b/frontend/src/locales/zh_CN/rom.json
@@ -18,6 +18,7 @@
   "add-note": "添加笔记",
   "add-to-collection": "添加至收藏",
   "add-to-favorites": "添加至喜好",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "添加 {n} ROMs 至收藏",
   "additional-content": "附加内容",
   "additional-details": "其他详情",
@@ -47,6 +48,7 @@
   "category-prototype": "原型",
   "category-translation": "翻译",
   "category-update": "更新",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "碟片 {n}",
   "chip-track-n": "曲目 {n}",
   "clear-all": "全部清除",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "上传原声带文件",
   "upload-states": "上传状态",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "验证",
   "verified-rom": "已验证 ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} 个版本",
   "volume-mute": "静音",
   "volume-unmute": "取消静音",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube 视频 ID"
 }
diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json
index 507578c21d..9854e08fad 100644
--- a/frontend/src/locales/zh_TW/rom.json
+++ b/frontend/src/locales/zh_TW/rom.json
@@ -18,6 +18,7 @@
   "add-note": "新增筆記",
   "add-to-collection": "加入收藏庫",
   "add-to-favorites": "加入喜好",
+  "add-walkthrough-from-url": "Add from GameFAQs URL",
   "adding-to-collection": "加入 {n} 個 ROM 至收藏庫",
   "additional-content": "附加内容",
   "additional-details": "其他詳細資訊",
@@ -47,6 +48,7 @@
   "category-prototype": "原型",
   "category-translation": "翻譯",
   "category-update": "更新",
+  "category-walkthrough": "Walkthrough",
   "chip-disc-n": "光碟 {n}",
   "chip-track-n": "音軌 {n}",
   "clear-all": "全部清除",
@@ -456,12 +458,21 @@
   "upload-soundtrack-files": "上傳原聲帶檔案",
   "upload-states": "上傳即時存檔",
   "upload-tracks": "Add tracks",
+  "upload-walkthrough": "Upload walkthrough",
   "verification": "驗證",
   "verified-rom": "已驗證 ROM",
   "verified-rom-hint": "Hash verified against ROM database",
   "versions-count": "{n} 個版本",
   "volume-mute": "靜音",
   "volume-unmute": "取消靜音",
+  "walkthrough": "Walkthrough",
+  "walkthrough-add-failed": "Could not add walkthrough: {error}",
+  "walkthrough-added": "Walkthrough added",
+  "walkthrough-empty": "No walkthroughs yet",
+  "walkthrough-remove-failed": "Failed to remove walkthrough: {error}",
+  "walkthrough-removed": "Walkthrough removed",
+  "walkthrough-url-label": "GameFAQs guide URL",
+  "walkthroughs": "Walkthroughs",
   "your-progress": "Your progress",
   "youtube-video-id": "YouTube 影片 ID"
 }
diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts
index 895f9765f8..5453041f22 100644
--- a/frontend/src/services/api/rom.ts
+++ b/frontend/src/services/api/rom.ts
@@ -8,6 +8,7 @@ import type {
   ManualMetadata,
   RomUserData,
   RomUserSchema,
+  RomFileUserSchema,
   SearchRomSchema,
   SimpleRomSchema,
   SoundtrackTrackMetaSchema,
@@ -836,6 +837,88 @@ async function deleteRomFile({
   return api.delete(`/roms/${romId}/files/${fileId}`);
 }
 
+async function uploadWalkthroughFiles({
+  romId,
+  filesToUpload,
+}: {
+  romId: number;
+  filesToUpload: File[];
+}) {
+  const uploadStore = storeUpload();
+
+  const promises = filesToUpload.map((file) => {
+    const formData = new FormData();
+    formData.append(file.name, file);
+
+    uploadStore.start(file.name);
+    return new Promise((resolve, reject) => {
+      api
+        .post(`/roms/${romId}/walkthroughs/files`, formData, {
+          headers: {
+            "Content-Type": "multipart/form-data",
+            "X-Upload-Filename": file.name,
+          },
+          params: {},
+          onUploadProgress: (progressEvent: AxiosProgressEvent) => {
+            uploadStore.update(file.name, progressEvent);
+          },
+        })
+        .then(resolve)
+        .catch((error) => {
+          uploadStore.fail(file.name, error.response?.data?.detail);
+          reject(error);
+        });
+    });
+  });
+
+  return Promise.allSettled(promises);
+}
+
+async function deleteWalkthroughFile({
+  romId,
+  fileId,
+}: {
+  romId: number;
+  fileId: number;
+}) {
+  return api.delete(`/roms/${romId}/walkthroughs/files/${fileId}`);
+}
+
+async function addGamefaqsWalkthrough({
+  romId,
+  url,
+}: {
+  romId: number;
+  url: string;
+}) {
+  return api.post(`/roms/${romId}/walkthroughs/gamefaqs`, { url });
+}
+
+async function getFileProgress({
+  romId,
+  fileId,
+}: {
+  romId: number;
+  fileId: number;
+}) {
+  return api.get(`/roms/${romId}/files/${fileId}/progress`);
+}
+
+async function updateFileProgress({
+  romId,
+  fileId,
+  data,
+}: {
+  romId: number;
+  fileId: number;
+  data: { progress?: number; last_page?: number | null; finished?: boolean };
+}) {
+  return api.put(
+    `/roms/${romId}/files/${fileId}/progress`,
+    data,
+  );
+}
+
 async function updateUserRomProps({
   romId,
   data,
@@ -960,6 +1043,11 @@ export default {
   uploadManualFiles,
   deleteManualFile,
   deleteRomFile,
+  uploadWalkthroughFiles,
+  deleteWalkthroughFile,
+  addGamefaqsWalkthrough,
+  getFileProgress,
+  updateFileProgress,
   uploadSoundtracks,
   removeSoundtrack,
   getSoundtrackMetadata,
diff --git a/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue b/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue
index a857631564..a7e70a380c 100644
--- a/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue
+++ b/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue
@@ -67,6 +67,10 @@ const CATEGORY_META = computed<
     label: t("rom.manual"),
     icon: "mdi-book-open-page-variant-outline",
   },
+  walkthrough: {
+    label: t("rom.walkthrough"),
+    icon: "mdi-map-legend",
+  },
   soundtrack: {
     label: t("rom.soundtrack"),
     icon: "mdi-music-note-outline",
diff --git a/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue b/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue
index 2d46bbfe7b..be9744fe64 100644
--- a/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue
+++ b/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue
@@ -109,6 +109,10 @@ const CATEGORY_META = computed<
     label: t("rom.manual"),
     icon: "mdi-book-open-page-variant-outline",
   },
+  walkthrough: {
+    label: t("rom.walkthrough"),
+    icon: "mdi-map-legend",
+  },
   soundtrack: {
     label: t("rom.soundtrack"),
     icon: "mdi-music-note-outline",
@@ -155,6 +159,8 @@ const FOLDER_META = computed>(() => {
     cheats: c.cheat,
     manual: c.manual,
     manuals: c.manual,
+    walkthrough: c.walkthrough,
+    walkthroughs: c.walkthrough,
     soundtrack: c.soundtrack,
     soundtracks: c.soundtrack,
     // Non-category folders that conventionally appear in ROM directories.
diff --git a/frontend/src/v2/components/GameDetails/ManualSubtab.vue b/frontend/src/v2/components/GameDetails/ManualSubtab.vue
index a301f7a9d0..693020ce60 100644
--- a/frontend/src/v2/components/GameDetails/ManualSubtab.vue
+++ b/frontend/src/v2/components/GameDetails/ManualSubtab.vue
@@ -27,6 +27,9 @@ const PdfViewer = defineAsyncComponent(
 const MarkdownViewer = defineAsyncComponent(
   () => import("@/v2/components/GameDetails/MarkdownViewer.vue"),
 );
+const TextViewer = defineAsyncComponent(
+  () => import("@/v2/components/GameDetails/TextViewer.vue"),
+);
 
 function errorMessage(err: unknown): string {
   if (axios.isAxiosError(err)) {
@@ -55,11 +58,16 @@ type ManualEntry = {
   label: string;
   url: string;
   isPrimary: boolean;
-  // Manuals can be PDF or Markdown; the viewer is picked by extension.
-  kind: "pdf" | "md";
+  // Manuals can be PDF, Markdown, or plain text; the viewer is picked by
+  // extension.
+  kind: "pdf" | "md" | "text";
 };
 
-const isMarkdown = (name: string) => /\.md$/i.test(name);
+const kindFor = (name: string): ManualEntry["kind"] => {
+  if (/\.md$/i.test(name)) return "md";
+  if (/\.(txt|html?|htm)$/i.test(name)) return "text";
+  return "pdf";
+};
 
 const manualEntries = computed(() => {
   const entries: ManualEntry[] = [];
@@ -70,7 +78,7 @@ const manualEntries = computed(() => {
       label: t("rom.scraped-manual"),
       url: `${FRONTEND_RESOURCES_PATH}/${props.rom.path_manual}?v=${cacheBust}`,
       isPrimary: true,
-      kind: isMarkdown(props.rom.path_manual) ? "md" : "pdf",
+      kind: kindFor(props.rom.path_manual),
     });
   }
   for (const file of props.rom.files ?? []) {
@@ -82,7 +90,7 @@ const manualEntries = computed(() => {
           file.file_name,
         )}?v=${cacheBust}`,
         isPrimary: false,
-        kind: isMarkdown(file.file_name) ? "md" : "pdf",
+        kind: kindFor(file.file_name),
       });
     }
   }
@@ -219,7 +227,7 @@ function requestDeleteManual() {
       :hint="t('common.dropzone-hint')"
       :active-title="t('common.dropzone-drag-over')"
       :input-label="t('rom.upload-manual')"
-      accept="application/pdf,.md"
+      accept="application/pdf,.md,.txt"
       multiple
       @files="handleManualFiles"
     >
@@ -244,7 +252,7 @@ function requestDeleteManual() {
       class="r-v2-manual__fill"
       :release-label="t('common.dropzone-drag-over')"
       :input-label="t('rom.upload-manual')"
-      accept="application/pdf,.md"
+      accept="application/pdf,.md,.txt"
       multiple
       @files="handleManualFiles"
     >
@@ -259,6 +267,13 @@ function requestDeleteManual() {
           @delete="requestDeleteManual"
           @redownload="redownloadManual"
         />
+        
          import("@/v2/components/GameDetails/ManualSubtab.vue"),
 );
+const WalkthroughSubtab = defineAsyncComponent(
+  () => import("@/v2/components/GameDetails/WalkthroughSubtab.vue"),
+);
 const SoundtrackPanel = defineAsyncComponent(
   () => import("@/v2/components/GameDetails/SoundtrackPanel.vue"),
 );
@@ -63,6 +66,7 @@ const canEdit = useCan("rom.edit");
 // player is visible here and hide itself to avoid duplication.
 const validSubtabs = [
   "manual",
+  "walkthrough",
   "screenshots",
   "artwork",
   "soundtrack",
@@ -134,6 +138,11 @@ const subtabDefs = computed(() => [
     label: t("rom.manual"),
     icon: "mdi-book-open-page-variant-outline",
   },
+  {
+    id: "walkthrough",
+    label: t("rom.walkthrough"),
+    icon: "mdi-map-legend",
+  },
   {
     id: "screenshots",
     label: t("rom.screenshots"),
@@ -275,6 +284,12 @@ async function deleteSoundtrack(fileId: number) {
         
       
 
+      
+      
+ +
+
diff --git a/frontend/src/v2/components/GameDetails/TextViewer.vue b/frontend/src/v2/components/GameDetails/TextViewer.vue new file mode 100644 index 0000000000..b28476befd --- /dev/null +++ b/frontend/src/v2/components/GameDetails/TextViewer.vue @@ -0,0 +1,241 @@ + + +