diff --git a/.env.example b/.env.example index 8283a75..03b18f5 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,17 @@ # Runtime configuration FABLE_ENV=production -FABLE_HOST=0.0.0.0 +FABLE_BIND_ADDRESS=127.0.0.1 FABLE_PORT=8000 FABLE_DB_PATH=fable.sqlite3 -CORS_ORIGINS=["*"] -SECRET_KEY=change-this-in-production-to-a-secure-random-token +CORS_ORIGINS=[] +FABLE_MAX_REQUEST_BODY_BYTES=262144 +FABLE_AUTH_RATE_WINDOW_SECONDS=900 +FABLE_AUTH_REGISTER_LIMIT_PER_IP=5 +FABLE_AUTH_LOGIN_LIMIT_PER_IP=60 +FABLE_AUTH_LOGIN_LIMIT_PER_ACCOUNT_IP=10 + +# iOS Xcode Release build setting; replace with the deployed HTTPS API root. +FABLE_API_BASE_URL= # Docker bind-mount ownership; use the host user's IDs when they differ from 1000. FABLE_UID=1000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f19f3c..cef0573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Install backend dependencies run: pip install -r backend/requirements.txt pytest - name: Run backend tests - run: pytest backend/test_main.py -v + run: PYTHONPATH=.:backend pytest backend/test_main.py -v docker-build-verification: runs-on: ubuntu-latest @@ -30,3 +30,46 @@ jobs: uses: actions/checkout@v4 - name: Build backend image run: docker build -t fable-backend:test -f backend/Dockerfile . + + ios-verification: + runs-on: macos-15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Verify Release API URL guard + run: | + if CONFIGURATION=Release FABLE_API_BASE_URL=http://127.0.0.1:8000/api/v1 scripts/validate_ios_release_config.sh; then + echo "The Release URL guard accepted an HTTP loopback URL." + exit 1 + fi + if CONFIGURATION=Release FABLE_API_BASE_URL=https://127.1/api/v1 scripts/validate_ios_release_config.sh; then + echo "The Release URL guard accepted an abbreviated loopback address." + exit 1 + fi + if CONFIGURATION=Release FABLE_API_BASE_URL= scripts/validate_ios_release_config.sh; then + echo "The Release URL guard accepted a missing API URL." + exit 1 + fi + CONFIGURATION=Release FABLE_API_BASE_URL=https://api.example.com/api/v1 scripts/validate_ios_release_config.sh + - name: Install iOS Simulator runtime + run: xcodebuild -downloadPlatform iOS + - name: Build and test iOS app + run: >- + xcodebuild + -project frontend/FableApp.xcodeproj + -scheme FableApp + -destination 'platform=iOS Simulator,name=iPhone 16' + -derivedDataPath build/DerivedData + CODE_SIGNING_ALLOWED=NO + test + - name: Verify Release build + run: >- + xcodebuild + -project frontend/FableApp.xcodeproj + -scheme FableApp + -configuration Release + -destination 'generic/platform=iOS Simulator' + -derivedDataPath build/ReleaseDerivedData + FABLE_API_BASE_URL=https://api.example.com/api/v1 + CODE_SIGNING_ALLOWED=NO + build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7cda73 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# Project Instructions + +## Project + +Fable is a native iOS reading and writing app with a FastAPI backend. Keep the client usable with its locally persisted state when the backend is unavailable. + +## Tech Stack + +- iOS 17+ app built with SwiftUI; Xcode project: `frontend/FableApp.xcodeproj`. +- Swift language mode is 5.0 in the project build settings. +- Local client persistence uses SwiftData. HTTP requests use URLSession. +- Backend uses Python 3.11, FastAPI, Pydantic v2, and SQLite. +- API dependencies and supported minimum versions are listed in `backend/requirements.txt`. + +## Architecture + +- `FableApp.swift` installs the SwiftData model container; `ContentView.swift` coordinates authentication state and app tabs. +- Feature views live under `frontend/FableApp/Features/`. Keep views focused on presentation and route state changes or network work through the existing store, manager, or service. +- `StoryStore` coordinates catalog, reading, shelf, and local persistence state. `AuthManager` coordinates sessions; `KeychainStore` stores credentials. +- Keep API transport behind `StoryAPIServiceProtocol`; its implementation is `StoryAPIService`. +- Backend requests flow from `backend/api/v1/endpoints/` through Pydantic schemas and `backend/services/` to SQLite via `backend/core/database.py`. +- Keep Swift Codable models and Pydantic DTO aliases aligned with the camelCase JSON API contract. +- SQLite tables are initialized and compatibly extended in `backend/core/database.py`. Preserve existing data when changing schema. + +## Project Structure + +- `frontend/FableApp/App/` — app lifecycle and root navigation +- `frontend/FableApp/Core/` — shared theme, components, cache, and security +- `frontend/FableApp/Features/` — Auth, Library, Reader, Shelf, and Write +- `backend/api/v1/` — versioned API router and endpoints +- `backend/models/`, `backend/schemas/`, `backend/services/` — backend data and application layers +- `docs/` — architecture, plans, runbooks, and project documentation + +## Code Style + +- Follow the existing feature-based organization and local naming patterns. +- Swift types and files use PascalCase; Python modules and functions use `snake_case`. +- Use async/await for asynchronous client and provider I/O. +- Validate backend input through Pydantic request models. Derive ownership and other server-controlled values from the authenticated session. +- Keep account-scoped reads and writes filtered by the authenticated user in backend services. +- Do not place credentials or real secrets in source, fixtures, or documentation. + +## Verification and Common Tasks + +- Backend tests: `pytest backend/test_main.py -v` +- Start the backend from `backend/`: `uvicorn main:app --reload --host 127.0.0.1 --port 8000` +- Open the iOS project on macOS: `open frontend/FableApp.xcodeproj`; select the `FableApp` scheme and an iOS 17+ simulator in Xcode. +- GitHub Actions currently runs the backend test suite and builds the Docker image. The Swift `Features/*/Tests` files are in-app diagnostic helpers, not an Xcode XCTest target. + +## Documentation Notes + +Some older documentation describes strict MVC or lists a `Package.swift`; the current app root uses `StoryStore` and the repository has no Swift package manifest. Confirm implementation details in source before relying on older architecture diagrams or file inventories. + +## Git + +- Use a feature branch; do not push commits directly to `main`. +- Recent commit subjects use concise conventional prefixes such as `feat:`, `fix:`, and `docs:`. +- Keep commits focused and list the exact changed file paths when reporting them. diff --git a/backend/api/v1/endpoints/auth.py b/backend/api/v1/endpoints/auth.py index 5070730..eb3bb8e 100644 --- a/backend/api/v1/endpoints/auth.py +++ b/backend/api/v1/endpoints/auth.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Header, status +from fastapi import APIRouter, Header, Request, status from schemas import ( RegisterRequest, LoginRequest, @@ -10,15 +10,20 @@ ReadingStatsDTO, ) from services import auth_service, story_service, reading_stats +from services.auth_rate_limit import enforce_auth_rate_limit router = APIRouter() @router.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) -def register(req: RegisterRequest): +def register(req: RegisterRequest, request: Request): + client_host = request.client.host if request.client else "unknown" + enforce_auth_rate_limit("register", client_host) return auth_service.register_user(req) @router.post("/auth/login", response_model=AuthResponse) -def login(req: LoginRequest): +def login(req: LoginRequest, request: Request): + client_host = request.client.host if request.client else "unknown" + enforce_auth_rate_limit("login", client_host, req.email) return auth_service.login_user(req) @router.get("/auth/me", response_model=UserDTO) diff --git a/backend/api/v1/endpoints/gutenberg.py b/backend/api/v1/endpoints/gutenberg.py index 7e381fd..af1395c 100644 --- a/backend/api/v1/endpoints/gutenberg.py +++ b/backend/api/v1/endpoints/gutenberg.py @@ -8,24 +8,24 @@ @router.post("/stories/ingest/{gutenberg_id}", response_model=StoryDTO, status_code=status.HTTP_201_CREATED) async def ingest_gutenberg_book( - gutenberg_id: int, - genre: Optional[str] = Query(default="Folklore") + gutenberg_id: int = Path(ge=1, le=2_147_483_647), + genre: Optional[str] = Query(default="Folklore", max_length=50), ): return await gutenberg_service.ingest_gutenberg_book(gutenberg_id=gutenberg_id, genre=genre) @router.get("/public/gutenberg", response_model=list[StoryDTO]) async def get_gutenberg_stories( - topic: Optional[str] = Query(default="folklore", description="Topic or genre filter for Gutenberg"), - search: Optional[str] = Query(default=None, description="Search term across title and author") + topic: Optional[str] = Query(default="folklore", max_length=50, description="Topic or genre filter for Gutenberg"), + search: Optional[str] = Query(default=None, max_length=200, description="Search term across title and author"), ): return await gutenberg_service.get_gutenberg_stories(topic=topic, search=search) @router.get("/public/gutenberg/{gutenberg_id}", response_model=StoryDTO) -async def get_gutenberg_story(gutenberg_id: int = Path(ge=1)): +async def get_gutenberg_story(gutenberg_id: int = Path(ge=1, le=2_147_483_647)): return await gutenberg_service.get_gutenberg_story_by_id(gutenberg_id) @router.get("/public/gutenberg/{gutenberg_id}/chapters", response_model=list[ChapterDTO]) -async def get_gutenberg_chapters(gutenberg_id: int = Path(ge=1)): +async def get_gutenberg_chapters(gutenberg_id: int = Path(ge=1, le=2_147_483_647)): return await gutenberg_service.get_gutenberg_chapters(gutenberg_id=gutenberg_id) diff --git a/backend/api/v1/endpoints/stories.py b/backend/api/v1/endpoints/stories.py index ffc0ca1..24cb07f 100644 --- a/backend/api/v1/endpoints/stories.py +++ b/backend/api/v1/endpoints/stories.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional from uuid import UUID -from fastapi import APIRouter, Header, status +from fastapi import APIRouter, Header, Query, status from schemas import ( StoryDTO, @@ -17,11 +17,19 @@ @router.get("/stories", response_model=list[StoryDTO]) def get_stories( - genre: Optional[str] = None, - search: Optional[str] = None, - since: Optional[datetime] = None + genre: Optional[str] = Query(default=None, max_length=50), + search: Optional[str] = Query(default=None, max_length=200), + since: Optional[datetime] = None, + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0, le=1_000_000), ): - return story_service.get_stories(genre=genre, search=search, since=since) + return story_service.get_stories( + genre=genre, + search=search, + since=since, + limit=limit, + offset=offset, + ) @router.get("/genres", response_model=list[GenreDTO]) def get_genres(): diff --git a/backend/core/database.py b/backend/core/database.py index 5bfc540..f0b16af 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -2,6 +2,7 @@ import os DB_PATH = os.environ.get("FABLE_DB_PATH", "fable.sqlite3") +SCHEMA_VERSION = 1 LEGACY_DEMO_STORY_IDS = ( "66666666-6666-6666-6666-666666666666", "77777777-7777-7777-7777-777777777777", @@ -24,10 +25,23 @@ def get_db(): conn.row_factory = sqlite3.Row return conn + +def _add_column_if_missing(conn, table_name, column_name, column_definition): + existing_columns = { + row["name"] + for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall() + } + if column_name not in existing_columns: + conn.execute( + f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_definition}" + ) + + def init_db(): conn = get_db() - with conn: - conn.execute(""" + try: + with conn: + conn.execute(""" CREATE TABLE IF NOT EXISTS stories ( id TEXT PRIMARY KEY, title TEXT NOT NULL, @@ -61,8 +75,8 @@ def init_db(): provider_download_count INTEGER, owner_user_id TEXT ); - """) - conn.execute(""" + """) + conn.execute(""" CREATE TABLE IF NOT EXISTS chapters ( id TEXT PRIMARY KEY, story_id TEXT NOT NULL, @@ -74,23 +88,17 @@ def init_db(): page_urls TEXT DEFAULT '[]', FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE ); - """) - for col_name, col_def in ( - ("content_format", "TEXT DEFAULT 'PROSE'"), - ("source_provider", "TEXT DEFAULT 'FABLE_ORIGINAL'"), - ("provider_id", "TEXT"), - ("provider_download_count", "INTEGER"), - ("owner_user_id", "TEXT"), - ): - try: - conn.execute(f"ALTER TABLE stories ADD COLUMN {col_name} {col_def}") - except sqlite3.OperationalError: - pass - try: - conn.execute("ALTER TABLE chapters ADD COLUMN page_urls TEXT DEFAULT '[]'") - except sqlite3.OperationalError: - pass - conn.execute(""" + """) + for column_name, column_definition in ( + ("content_format", "TEXT DEFAULT 'PROSE'"), + ("source_provider", "TEXT DEFAULT 'FABLE_ORIGINAL'"), + ("provider_id", "TEXT"), + ("provider_download_count", "INTEGER"), + ("owner_user_id", "TEXT"), + ): + _add_column_if_missing(conn, "stories", column_name, column_definition) + _add_column_if_missing(conn, "chapters", "page_urls", "TEXT DEFAULT '[]'") + conn.execute(""" CREATE TABLE IF NOT EXISTS shelf_items ( device_id TEXT NOT NULL, story_id TEXT NOT NULL, @@ -100,9 +108,9 @@ def init_db(): updated_at_utc TEXT NOT NULL, PRIMARY KEY (device_id, story_id) ); - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_shelf_items_device ON shelf_items (device_id);") - conn.execute(""" + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_shelf_items_device ON shelf_items (device_id);") + conn.execute(""" CREATE TABLE IF NOT EXISTS account_shelf_items ( owner_user_id TEXT NOT NULL, device_id TEXT NOT NULL, @@ -113,10 +121,10 @@ def init_db(): updated_at_utc TEXT NOT NULL, PRIMARY KEY (owner_user_id, device_id, story_id) ); - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_account_shelf_owner_device ON account_shelf_items (owner_user_id, device_id);") - conn.execute("CREATE INDEX IF NOT EXISTS idx_account_shelf_story ON account_shelf_items (story_id);") - conn.execute(""" + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_shelf_owner_device ON account_shelf_items (owner_user_id, device_id);") + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_shelf_story ON account_shelf_items (story_id);") + conn.execute(""" CREATE TABLE IF NOT EXISTS reading_sessions ( owner_user_id TEXT NOT NULL, id TEXT NOT NULL, @@ -126,9 +134,9 @@ def init_db(): is_completed INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (owner_user_id, id) ); - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_sessions_owner_date ON reading_sessions (owner_user_id, read_at_utc);") - conn.execute(""" + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_sessions_owner_date ON reading_sessions (owner_user_id, read_at_utc);") + conn.execute(""" CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, @@ -141,17 +149,11 @@ def init_db(): created_at_utc TEXT NOT NULL, updated_at_utc TEXT NOT NULL ); - """) - for col_name, col_def in ( - ("handle", "TEXT NOT NULL DEFAULT ''"), - ("bio", "TEXT NOT NULL DEFAULT ''"), - ): - try: - conn.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_def}") - except sqlite3.OperationalError: - pass - conn.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);") - conn.execute(""" + """) + _add_column_if_missing(conn, "users", "handle", "TEXT NOT NULL DEFAULT ''") + _add_column_if_missing(conn, "users", "bio", "TEXT NOT NULL DEFAULT ''") + conn.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);") + conn.execute(""" CREATE TABLE IF NOT EXISTS user_sessions ( token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -159,19 +161,43 @@ def init_db(): expires_at_utc TEXT NOT NULL, revoked_at_utc TEXT ); - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions (user_id);") - conn.execute("CREATE INDEX IF NOT EXISTS idx_chapters_story_id ON chapters (story_id);") + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions (user_id);") + conn.execute("CREATE INDEX IF NOT EXISTS idx_chapters_story_id ON chapters (story_id);") + conn.execute(""" + CREATE TABLE IF NOT EXISTS auth_rate_limits ( + bucket_key TEXT PRIMARY KEY, + window_expires_at INTEGER NOT NULL, + request_count INTEGER NOT NULL + ); + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_auth_rate_limits_expiry ON auth_rate_limits (window_expires_at);") - # Remove only the exact IDs from the retired bundled demo catalog. - placeholders = ", ".join("?" for _ in LEGACY_DEMO_STORY_IDS) - conn.execute( - f"DELETE FROM chapters WHERE story_id IN ({placeholders})", - LEGACY_DEMO_STORY_IDS, - ) - conn.execute( - f"DELETE FROM stories WHERE id IN ({placeholders})", - LEGACY_DEMO_STORY_IDS, - ) + current_version = conn.execute("PRAGMA user_version").fetchone()[0] + if current_version > SCHEMA_VERSION: + raise RuntimeError( + f"Database schema version {current_version} is newer than supported version {SCHEMA_VERSION}." + ) - conn.close() + if current_version < 1: + placeholders = ", ".join("?" for _ in LEGACY_DEMO_STORY_IDS) + for table_name in ("shelf_items", "account_shelf_items"): + conn.execute( + f"DELETE FROM {table_name} WHERE story_id IN ({placeholders})", + LEGACY_DEMO_STORY_IDS, + ) + conn.execute( + f"DELETE FROM reading_sessions WHERE story_id IN ({placeholders})", + LEGACY_DEMO_STORY_IDS, + ) + conn.execute( + f"DELETE FROM chapters WHERE story_id IN ({placeholders})", + LEGACY_DEMO_STORY_IDS, + ) + conn.execute( + f"DELETE FROM stories WHERE id IN ({placeholders})", + LEGACY_DEMO_STORY_IDS, + ) + conn.execute("PRAGMA user_version = 1") + finally: + conn.close() diff --git a/backend/core/request_limits.py b/backend/core/request_limits.py new file mode 100644 index 0000000..dd32d98 --- /dev/null +++ b/backend/core/request_limits.py @@ -0,0 +1,66 @@ +import json + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +class RequestBodySizeLimitMiddleware: + def __init__(self, app: ASGIApp, max_bytes: int) -> None: + self.app = app + self.max_bytes = max_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("method") in {"GET", "HEAD", "OPTIONS"}: + await self.app(scope, receive, send) + return + + headers = dict(scope.get("headers", [])) + content_length = headers.get(b"content-length") + if content_length is not None: + try: + declared_length = int(content_length) + if declared_length < 0 or declared_length > self.max_bytes: + await self._reject(send) + return + except ValueError: + await self._reject(send) + return + + messages: list[Message] = [] + total_bytes = 0 + while True: + message = await receive() + if message["type"] == "http.disconnect": + messages.append(message) + break + if message["type"] != "http.request": + continue + total_bytes += len(message.get("body", b"")) + if total_bytes > self.max_bytes: + await self._reject(send) + return + messages.append(message) + if not message.get("more_body", False): + break + + message_index = 0 + + async def replay_receive() -> Message: + nonlocal message_index + if message_index < len(messages): + message = messages[message_index] + message_index += 1 + return message + return await receive() + + await self.app(scope, replay_receive, send) + + async def _reject(self, send: Send) -> None: + body = json.dumps({"detail": "Request body exceeds the configured size limit."}).encode() + await send( + { + "type": "http.response.start", + "status": 413, + "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())], + } + ) + await send({"type": "http.response.body", "body": body}) diff --git a/backend/core/settings.py b/backend/core/settings.py new file mode 100644 index 0000000..cd56978 --- /dev/null +++ b/backend/core/settings.py @@ -0,0 +1,57 @@ +import json +import os +from urllib.parse import urlsplit + + +def load_cors_origins() -> list[str]: + raw_origins = os.environ.get("CORS_ORIGINS", "[]") + try: + origins = json.loads(raw_origins) + except json.JSONDecodeError as error: + raise RuntimeError("CORS_ORIGINS must be a JSON array of origins.") from error + + if not isinstance(origins, list) or any(not isinstance(origin, str) for origin in origins): + raise RuntimeError("CORS_ORIGINS must be a JSON array of origin strings.") + + environment = os.environ.get("FABLE_ENV", "development").strip().lower() + development_environments = {"development", "dev", "local", "test"} + cleaned_origins = [origin.strip().rstrip("/") for origin in origins] + if "*" in cleaned_origins: + if len(cleaned_origins) != 1 or environment not in development_environments: + raise RuntimeError("Wildcard CORS is only allowed in development and test environments.") + return cleaned_origins + + for origin in cleaned_origins: + parsed = urlsplit(origin) + try: + parsed.port + has_invalid_port = False + except ValueError: + has_invalid_port = True + if ( + not origin + or parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username + or parsed.password + or has_invalid_port + or parsed.path + or parsed.query + or parsed.fragment + ): + raise RuntimeError(f"Invalid CORS origin: {origin!r}") + if environment not in development_environments and parsed.scheme != "https": + raise RuntimeError("Non-development CORS origins must use HTTPS.") + + return cleaned_origins + + +def request_body_limit_bytes() -> int: + raw_value = os.environ.get("FABLE_MAX_REQUEST_BODY_BYTES", "262144") + try: + value = int(raw_value) + except ValueError as error: + raise RuntimeError("FABLE_MAX_REQUEST_BODY_BYTES must be a positive integer.") from error + if value < 1024: + raise RuntimeError("FABLE_MAX_REQUEST_BODY_BYTES must be at least 1024.") + return value diff --git a/backend/main.py b/backend/main.py index cb3af38..ddf0c18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,9 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from core.request_limits import RequestBodySizeLimitMiddleware +from core.settings import load_cors_origins, request_body_limit_bytes from core.database import init_db, get_db, DB_PATH from api.v1.api import api_router @@ -19,12 +23,29 @@ async def lifespan(app: FastAPI): lifespan=lifespan ) + +@app.exception_handler(RequestValidationError) +async def sanitized_validation_error_handler( + _request: Request, + error: RequestValidationError, +) -> JSONResponse: + safe_errors = [ + { + "type": item["type"], + "loc": item["loc"], + "msg": item["msg"], + } + for item in error.errors() + ] + return JSONResponse(status_code=422, content={"detail": safe_errors}) + +app.add_middleware(RequestBodySizeLimitMiddleware, max_bytes=request_body_limit_bytes()) app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_origins=load_cors_origins(), + allow_credentials=False, + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], ) app.include_router(api_router, prefix="/api/v1") diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py index cce304e..65b55ca 100644 --- a/backend/schemas/schemas.py +++ b/backend/schemas/schemas.py @@ -92,12 +92,12 @@ class UpdateFeedDTO(BaseModel): class CreateStoryRequest(BaseModel): title: str = Field(..., min_length=1, max_length=120) - genre: str - chapter: Optional[str] = None - synopsis: str - content: str + genre: str = Field(..., min_length=1, max_length=50) + chapter: Optional[str] = Field(default=None, max_length=120) + synopsis: str = Field(..., max_length=2_000) + content: str = Field(..., max_length=50_000) read_time_minutes: int = Field(default=0, ge=0, alias="readTimeMinutes") - content_format: Optional[str] = Field(default="PROSE", alias="contentFormat") + content_format: Optional[str] = Field(default="PROSE", max_length=20, alias="contentFormat") model_config = { "populate_by_name": True, @@ -147,12 +147,12 @@ class UserDTO(BaseModel): } class LoginRequest(BaseModel): - email: str - password: str + email: str = Field(..., min_length=3, max_length=320) + password: str = Field(..., max_length=128) class RegisterRequest(BaseModel): - email: str - password: str = Field(..., min_length=6) + email: str = Field(..., min_length=3, max_length=320) + password: str = Field(..., min_length=12, max_length=128) name: str = Field(..., min_length=1, max_length=80) handle: str = Field(default="", max_length=40) diff --git a/backend/services/auth_rate_limit.py b/backend/services/auth_rate_limit.py new file mode 100644 index 0000000..7020208 --- /dev/null +++ b/backend/services/auth_rate_limit.py @@ -0,0 +1,94 @@ +import hashlib +import os +import time +from fastapi import HTTPException + +from core.database import get_db + + +def _positive_int(name: str, default: int) -> int: + try: + value = int(os.environ.get(name, str(default))) + except ValueError as error: + raise RuntimeError(f"{name} must be a positive integer.") from error + if value < 1: + raise RuntimeError(f"{name} must be a positive integer.") + return value + + +WINDOW_SECONDS = _positive_int("FABLE_AUTH_RATE_WINDOW_SECONDS", 900) +REGISTER_LIMIT_PER_IP = _positive_int("FABLE_AUTH_REGISTER_LIMIT_PER_IP", 5) +LOGIN_LIMIT_PER_IP = _positive_int("FABLE_AUTH_LOGIN_LIMIT_PER_IP", 60) +LOGIN_LIMIT_PER_ACCOUNT_IP = _positive_int("FABLE_AUTH_LOGIN_LIMIT_PER_ACCOUNT_IP", 10) + + +def _bucket_key(action: str, dimension: str, value: str) -> str: + digest = hashlib.sha256(f"{action}\x1f{dimension}\x1f{value}".encode("utf-8")).hexdigest() + return f"{action}:{dimension}:{digest}" + + +def enforce_auth_rate_limit(action: str, client_host: str, identity: str | None = None) -> None: + now = int(time.time()) + if action == "register": + buckets = [ + (_bucket_key(action, "ip", client_host), REGISTER_LIMIT_PER_IP), + ] + elif action == "login": + buckets = [ + (_bucket_key(action, "ip", client_host), LOGIN_LIMIT_PER_IP), + ] + if identity: + buckets.append( + ( + _bucket_key(action, "account-ip", f"{client_host}\x1f{identity.strip().casefold()}"), + LOGIN_LIMIT_PER_ACCOUNT_IP, + ) + ) + else: + raise ValueError(f"Unsupported rate-limited auth action: {action}") + + retry_after = 0 + connection = get_db() + try: + with connection: + connection.execute("BEGIN IMMEDIATE") + connection.execute( + "DELETE FROM auth_rate_limits WHERE window_expires_at <= ?", + (now,), + ) + for bucket_key, limit in buckets: + row = connection.execute( + "SELECT window_expires_at, request_count FROM auth_rate_limits WHERE bucket_key = ?", + (bucket_key,), + ).fetchone() + if row is None or row["window_expires_at"] <= now: + connection.execute( + """INSERT INTO auth_rate_limits + (bucket_key, window_expires_at, request_count) + VALUES (?, ?, 1) + ON CONFLICT(bucket_key) DO UPDATE SET + window_expires_at = excluded.window_expires_at, + request_count = 1""", + (bucket_key, now + WINDOW_SECONDS), + ) + continue + + count = row["request_count"] + 1 + connection.execute( + "UPDATE auth_rate_limits SET request_count = ? WHERE bucket_key = ?", + (min(count, limit + 1), bucket_key), + ) + if count > limit: + retry_after = max( + retry_after, + row["window_expires_at"] - now, + ) + finally: + connection.close() + + if retry_after > 0: + raise HTTPException( + status_code=429, + detail="Too many authentication attempts. Try again later.", + headers={"Retry-After": str(retry_after)}, + ) diff --git a/backend/services/story_service.py b/backend/services/story_service.py index e7ecd96..3c5990b 100644 --- a/backend/services/story_service.py +++ b/backend/services/story_service.py @@ -19,6 +19,12 @@ ) IDENTIFIER_NAMESPACE = UUID("b28f1a39-ff20-5d3c-9ff0-994df9324c22") +STORY_SUMMARY_COLUMNS = """id, title, author, genre, chapter, synopsis, '' AS content, + read_time_minutes, is_bookmarked, is_completed, created_at_utc, updated_at_utc, + cover_image_name, hero_image_name, cover_image_url, total_pages, current_page, + progress_percent, rating, saves_count, reads_count, is_tale_of_the_day, + is_recent_submission, is_curator_spotlight, badge_text, total_chapters, + content_format, source_provider, provider_id, provider_download_count, owner_user_id""" def _is_legacy_demo_image_url(value: str) -> bool: @@ -53,6 +59,7 @@ def row_to_story_dto( row: sqlite3.Row, include_chapters: bool = False, conn: Optional[sqlite3.Connection] = None, + include_content: bool = True, ) -> StoryDTO: story_id = UUID(row["id"]) chapters = None @@ -106,7 +113,7 @@ def row_to_story_dto( genre=row["genre"], chapter=row["chapter"], synopsis=row["synopsis"], - content=row["content"], + content=row["content"] if include_content else "", read_time_minutes=row["read_time_minutes"], is_bookmarked=False, is_completed=False, @@ -138,10 +145,12 @@ def get_stories( genre: Optional[str] = None, search: Optional[str] = None, since: Optional[datetime] = None, + limit: int = 50, + offset: int = 0, ) -> list[StoryDTO]: conn = get_db() try: - query = "SELECT * FROM stories WHERE 1=1" + query = f"SELECT {STORY_SUMMARY_COLUMNS} FROM stories WHERE 1=1" params: list[object] = [] if genre and genre.lower() != "all": query += " AND LOWER(genre) = LOWER(?)" @@ -153,7 +162,8 @@ def get_stories( if since: query += " AND updated_at_utc > ?" params.append(since.isoformat()) - query += " ORDER BY is_tale_of_the_day DESC, created_at_utc DESC" + query += " ORDER BY is_tale_of_the_day DESC, created_at_utc DESC, id ASC LIMIT ? OFFSET ?" + params.extend((limit, offset)) rows = conn.execute(query, params).fetchall() return [row_to_story_dto(row, conn=conn) for row in rows] finally: @@ -316,7 +326,7 @@ def get_user_stories(owner_user_id: UUID) -> list[StoryDTO]: conn = get_db() try: rows = conn.execute( - "SELECT * FROM stories WHERE owner_user_id = ? ORDER BY created_at_utc DESC", + f"SELECT {STORY_SUMMARY_COLUMNS} FROM stories WHERE owner_user_id = ? ORDER BY created_at_utc DESC", (str(owner_user_id),), ).fetchall() return [row_to_story_dto(row, conn=conn) for row in rows] @@ -444,22 +454,22 @@ def get_update_feed() -> UpdateFeedDTO: conn = get_db() try: totd_row = conn.execute( - """ - SELECT * FROM stories + f""" + SELECT {STORY_SUMMARY_COLUMNS} FROM stories WHERE is_tale_of_the_day = 1 ORDER BY created_at_utc DESC LIMIT 1 """ ).fetchone() curator_row = conn.execute( - """ - SELECT * FROM stories + f""" + SELECT {STORY_SUMMARY_COLUMNS} FROM stories WHERE is_curator_spotlight = 1 ORDER BY created_at_utc DESC LIMIT 1 """ ).fetchone() recent_rows = conn.execute( - """ - SELECT * FROM stories + f""" + SELECT {STORY_SUMMARY_COLUMNS} FROM stories WHERE is_recent_submission = 1 ORDER BY created_at_utc DESC LIMIT 10 """ diff --git a/backend/test_main.py b/backend/test_main.py index 0d11fa7..70fa7c8 100644 --- a/backend/test_main.py +++ b/backend/test_main.py @@ -84,6 +84,7 @@ def test_authenticated_story_listing_is_isolated_by_owner(): second_stories = client.get("/api/v1/auth/me/stories", headers=second_headers) assert [story["title"] for story in first_stories.json()] == ["First Story"] assert [story["title"] for story in second_stories.json()] == ["Second Story"] + assert first_stories.json()[0]["content"] == "" assert client.get("/api/v1/auth/me/stories").status_code == 401 def test_story_dto_camelcase_serialization_contract(): @@ -126,6 +127,11 @@ def test_get_story_chapters(): detail = client.get(f"/api/v1/stories/{story_id}") assert detail.status_code == 200 assert len(detail.json()["chapters"]) == 1 + assert "validated story API" in detail.json()["content"] + + catalog_item = client.get("/api/v1/stories").json()[0] + assert catalog_item["content"] == "" + assert catalog_item["chapters"] is None chapters = client.get(f"/api/v1/stories/{story_id}/chapters") assert chapters.status_code == 200 @@ -137,6 +143,59 @@ def test_get_story_chapters(): assert "validated story API" in chapter.json()["content"] +def test_story_catalog_paginates_and_returns_summaries(): + headers = auth_headers() + for index in range(3): + response = client.post("/api/v1/stories", headers=headers, json={ + "title": f"Paged Story {index}", + "genre": "Mystery", + "synopsis": "A short catalog summary.", + "content": "A long manuscript that should not be sent in the catalog response.", + }) + assert response.status_code == 201 + + first_page = client.get("/api/v1/stories?limit=2&offset=0") + second_page = client.get("/api/v1/stories?limit=2&offset=2") + + assert first_page.status_code == 200 + assert second_page.status_code == 200 + assert len(first_page.json()) == 2 + assert len(second_page.json()) == 1 + assert all(story["content"] == "" for story in first_page.json() + second_page.json()) + assert client.get("/api/v1/stories?limit=101").status_code == 422 + assert client.get("/api/v1/stories?offset=-1").status_code == 422 + assert client.get(f"/api/v1/stories?search={'x' * 201}").status_code == 422 + + +def test_create_story_rejects_oversized_fields(): + payload = { + "title": "Bounded Story", + "genre": "Mystery", + "synopsis": "A" * 2_001, + "content": "Valid content", + } + assert client.post("/api/v1/stories", json=payload).status_code == 422 + + payload["synopsis"] = "A valid synopsis" + payload["content"] = "B" * 50_001 + assert client.post("/api/v1/stories", json=payload).status_code == 422 + + payload["content"] = "Valid content" + payload["genre"] = "G" * 51 + assert client.post("/api/v1/stories", json=payload).status_code == 422 + + +def test_validation_errors_do_not_echo_submitted_password(): + password = "928374" + response = client.post("/api/v1/auth/register", json={ + "email": "reader@example.test", + "password": password, + "name": "Reader", + }) + assert response.status_code == 422 + assert password not in response.text + + def test_story_metrics_are_derived_from_device_shelf_state(): created = client.post("/api/v1/stories", headers=auth_headers(), json={ "title": "Metrics Story", @@ -270,13 +329,22 @@ async def mock_get(self, url, params=None, headers=None): def test_get_update_feed_endpoint(): + created = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "Update Feed Summary", + "genre": "Literary", + "synopsis": "Catalog only.", + "content": "Full content remains available through story details.", + }) + assert created.status_code == 201 + response = client.get("/api/v1/updates") assert response.status_code == 200 feed = response.json() assert feed["taleOfTheDay"] is None assert feed["curatorSpotlight"] is None - assert feed["recentSubmissions"] == [] - assert feed["totalStories"] == 0 + assert len(feed["recentSubmissions"]) == 1 + assert feed["recentSubmissions"][0]["content"] == "" + assert feed["totalStories"] == 1 def test_create_story(): @@ -567,6 +635,8 @@ async def unavailable(self, url, params=None, headers=None): response = client.get("/api/v1/public/gutenberg") assert response.status_code == 502 assert response.json()["detail"] == "Gutenberg catalog service is unavailable" + assert client.get(f"/api/v1/public/gutenberg?search={'x' * 201}").status_code == 422 + assert client.get("/api/v1/public/gutenberg/2147483648").status_code == 422 def test_register_and_login_auth_flow(): @@ -609,6 +679,73 @@ def test_register_and_login_auth_flow(): assert login_data["user"]["email"] == "reader@example.test" assert login_data["user"]["handle"] == "@test-reader" + +def test_registration_rate_limit_returns_retry_after(monkeypatch): + from services import auth_rate_limit + + monkeypatch.setattr(auth_rate_limit, "REGISTER_LIMIT_PER_IP", 1) + payload = { + "email": "first@example.test", + "password": "LongSecurePassword123!", + "name": "First Reader", + } + assert client.post("/api/v1/auth/register", json=payload).status_code == 201 + + payload["email"] = "second@example.test" + limited = client.post("/api/v1/auth/register", json=payload) + assert limited.status_code == 429 + assert int(limited.headers["retry-after"]) > 0 + assert "first@example.test" not in limited.text + + +def test_request_body_size_limit_rejects_oversized_request(): + oversized_body = b'{"payload":"' + (b"x" * 262_144) + b'"}' + response = client.post( + "/api/v1/auth/register", + content=oversized_body, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 413 + assert response.json() == {"detail": "Request body exceeds the configured size limit."} + + +def test_cors_configuration_is_validated_and_normalized(monkeypatch): + from core.settings import load_cors_origins + + monkeypatch.setenv("FABLE_ENV", "production") + monkeypatch.setenv("CORS_ORIGINS", '["https://reader.example.test/"]') + assert load_cors_origins() == ["https://reader.example.test"] + + monkeypatch.setenv("CORS_ORIGINS", '["*"]') + with pytest.raises(RuntimeError, match="Wildcard CORS"): + load_cors_origins() + + monkeypatch.setenv("FABLE_ENV", "staging") + with pytest.raises(RuntimeError, match="Wildcard CORS"): + load_cors_origins() + + monkeypatch.setenv("CORS_ORIGINS", '["https://reader.example.test:invalid"]') + with pytest.raises(RuntimeError, match="Invalid CORS origin"): + load_cors_origins() + + +def test_sqlite_online_backup_and_integrity_check(tmp_path): + import sqlite3 + from scripts.sqlite_backup import create_backup, verify_database + + source = tmp_path / "source.sqlite3" + backup = tmp_path / "backups" / "snapshot.sqlite3" + with sqlite3.connect(source) as connection: + connection.execute("CREATE TABLE entries (value TEXT NOT NULL)") + connection.execute("INSERT INTO entries (value) VALUES ('preserved')") + + create_backup(source, backup) + verify_database(backup) + + with sqlite3.connect(backup) as connection: + assert connection.execute("SELECT value FROM entries").fetchone()[0] == "preserved" + assert backup.stat().st_mode & 0o777 == 0o600 + def test_authenticated_profile_update_and_rejection(): response = client.post("/api/v1/auth/register", json={ "email": "profile@example.test", @@ -904,7 +1041,7 @@ async def mock_get(self, url, params=None, headers=None): -def test_init_db_removes_only_legacy_demo_stories_and_chapters(): +def test_init_db_migrates_legacy_demo_stories_and_shelf_references_once(): from core.database import LEGACY_DEMO_STORY_IDS, get_db connection = get_db() @@ -934,6 +1071,35 @@ def test_init_db_removes_only_legacy_demo_stories_and_chapters(): "Legacy chapter text", 3, "2026-01-01T00:00:00+00:00", ), ) + connection.execute( + """INSERT INTO shelf_items ( + device_id, story_id, reading_progress, is_bookmarked, + is_completed, updated_at_utc + ) VALUES (?, ?, ?, ?, ?, ?)""", + ( + "device-legacy", story_id, 0.25, 1, 0, + "2026-01-01T00:00:00+00:00", + ), + ) + connection.execute( + """INSERT INTO account_shelf_items ( + owner_user_id, device_id, story_id, reading_progress, + is_bookmarked, is_completed, updated_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + "owner-legacy", "device-legacy", story_id, 0.25, 1, 0, + "2026-01-01T00:00:00+00:00", + ), + ) + connection.execute( + """INSERT INTO reading_sessions ( + owner_user_id, id, story_id, seconds_read, read_at_utc, is_completed + ) VALUES (?, ?, ?, ?, ?, ?)""", + ( + "owner-legacy", f"reading-session-{index}", story_id, 60, + "2026-01-01T00:00:00+00:00", 0, + ), + ) authored_story_id = str(uuid4()) connection.execute( """ @@ -948,6 +1114,7 @@ def test_init_db_removes_only_legacy_demo_stories_and_chapters(): "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00", str(uuid4()), ), ) + connection.execute("PRAGMA user_version = 0") connection.commit() finally: connection.close() @@ -965,12 +1132,29 @@ def test_init_db_removes_only_legacy_demo_stories_and_chapters(): f"SELECT COUNT(*) FROM chapters WHERE story_id IN ({', '.join('?' for _ in LEGACY_DEMO_STORY_IDS)})", LEGACY_DEMO_STORY_IDS, ).fetchone()[0] + legacy_shelf_count = connection.execute( + f"SELECT COUNT(*) FROM shelf_items WHERE story_id IN ({', '.join('?' for _ in LEGACY_DEMO_STORY_IDS)})", + LEGACY_DEMO_STORY_IDS, + ).fetchone()[0] + legacy_account_shelf_count = connection.execute( + f"SELECT COUNT(*) FROM account_shelf_items WHERE story_id IN ({', '.join('?' for _ in LEGACY_DEMO_STORY_IDS)})", + LEGACY_DEMO_STORY_IDS, + ).fetchone()[0] + legacy_reading_session_count = connection.execute( + f"SELECT COUNT(*) FROM reading_sessions WHERE story_id IN ({', '.join('?' for _ in LEGACY_DEMO_STORY_IDS)})", + LEGACY_DEMO_STORY_IDS, + ).fetchone()[0] authored_story = connection.execute( "SELECT id FROM stories WHERE id = ?", (authored_story_id,) ).fetchone() + schema_version = connection.execute("PRAGMA user_version").fetchone()[0] finally: connection.close() assert legacy_story_count == 0 assert legacy_chapter_count == 0 + assert legacy_shelf_count == 0 + assert legacy_account_shelf_count == 0 + assert legacy_reading_session_count == 0 assert authored_story["id"] == authored_story_id + assert schema_version == 1 diff --git a/docker-compose.yml b/docker-compose.yml index e232532..08b0666 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: FABLE_UID: ${FABLE_UID:-1000} FABLE_GID: ${FABLE_GID:-1000} ports: - - "${FABLE_PORT:-8000}:8000" + - "${FABLE_BIND_ADDRESS:-127.0.0.1}:${FABLE_PORT:-8000}:8000" volumes: - ./data:/app/data env_file: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..2ecdadc --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,37 @@ +# Backend Operations + +## Deployment boundary + +The backend container serves plain HTTP on port 8000. In production, place it behind a TLS-terminating reverse proxy or managed ingress. Keep port 8000 private to that proxy. The checked-in Compose file binds it to loopback by default; set `FABLE_BIND_ADDRESS=0.0.0.0` only for a trusted development LAN. + +Set `FABLE_ENV=production` and provide the allowed browser origins in `CORS_ORIGINS` as a JSON array. Native iOS requests do not require a browser origin. Production startup rejects wildcard CORS origins. + +Authentication endpoints use SQLite-backed per-IP and per-account/IP limits. Defaults are 5 registrations and 60 login attempts per IP per 15 minutes, plus 10 login attempts per account/IP per window. Tune `FABLE_AUTH_RATE_WINDOW_SECONDS`, `FABLE_AUTH_REGISTER_LIMIT_PER_IP`, `FABLE_AUTH_LOGIN_LIMIT_PER_IP`, and `FABLE_AUTH_LOGIN_LIMIT_PER_ACCOUNT_IP` for the deployment. The limits use the ASGI client address; configure a trusted proxy so that address reflects the client IP without accepting untrusted forwarded headers. Request bodies default to 256 KiB and can be adjusted with `FABLE_MAX_REQUEST_BODY_BYTES`. + +For iOS Release builds, set the Xcode build setting `FABLE_API_BASE_URL` to the deployed HTTPS API root, for example `https://api.example.com/api/v1`, replacing the example host with the deployed host. Release builds fail when the value is missing, is not HTTPS, or uses a loopback, unspecified, or reserved local host. Debug builds retain the simulator URL. + +## SQLite backup + +Create an online, transactionally consistent backup with Python's SQLite backup API: + +```bash +python3 scripts/sqlite_backup.py backup data/fable.sqlite3 backups/fable-YYYY-MM-DD.sqlite3 +python3 scripts/sqlite_backup.py verify backups/fable-YYYY-MM-DD.sqlite3 +``` + +Use the actual path configured by `FABLE_DB_PATH` if it differs. Store backups outside the application data directory, restrict access to them, and copy them to durable storage. This repository does not configure scheduled off-host backups or retention; the hosting operator must provide those. + +## Restore + +1. Stop the backend so no process is writing to the database. +2. Verify the selected backup with `scripts/sqlite_backup.py verify`. +3. Move the current database file to a timestamped recovery copy. +4. Copy the verified backup into the configured `FABLE_DB_PATH` location. +5. Start the backend and check `/api/v1/health` plus a known account and story. +6. Keep the pre-restore copy until the application data has been confirmed. + +Database startup applies additive compatibility changes and versioned migrations transactionally. `PRAGMA user_version` records the applied schema version. Back up the database before deploying a new backend version. + +## CI and release configuration + +GitHub Actions runs backend tests, builds the Docker image, builds the iOS app, and runs the iOS XCTest target on an iOS Simulator. The CI HTTPS host is only a build validation value; it is not a production deployment endpoint. diff --git a/docs/final_milestone/MASTER_PROGRESS_LOG.md b/docs/final_milestone/MASTER_PROGRESS_LOG.md index f2bf6e2..831aa4a 100644 --- a/docs/final_milestone/MASTER_PROGRESS_LOG.md +++ b/docs/final_milestone/MASTER_PROGRESS_LOG.md @@ -9,7 +9,7 @@ ## Executive Summary & System Objectives -This progress log tracks the transition of **Fable** from an editorial prototype to a provider-backed, offline-first reading platform. The backend has 36 passing container tests covering content contracts, account isolation, live statistics, and persistent sessions. The iOS changes are implemented but cannot be compiled or run in this Fedora workspace because Swift and Xcode are unavailable. An approved startup migration removes the twelve exact legacy demo story IDs and their chapters from existing SQLite databases on the next backend start. +This progress log tracks the transition of **Fable** from an editorial prototype to a provider-backed, offline-first reading platform. The backend has 43 passing container tests covering content contracts, account isolation, live statistics, persistent sessions, request limits, versioned SQLite cleanup, and online backups. The iOS changes are implemented but cannot be compiled or run in this Fedora workspace because Swift and Xcode are unavailable; macOS CI is configured to build the Release app and run the XCTest target. The versioned startup migration removes the twelve exact legacy demo story IDs and their dependent shelf and reading-session records from existing SQLite databases. ### The 8 Core Architectural Domains @@ -21,11 +21,20 @@ This progress log tracks the transition of **Fable** from an editorial prototype | **4. UI Interactions & Voice Accessibility** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Implemented; device check pending** | Actions are wired across writing, profile, library, reader, shelf, and settings; `AVSpeechSynthesizer` uses installed OS voices. | | **5. Live Metadata & Pure User-State Isolation** | [`03_LIVE_METADATA_AND_USER_STATE_PURGING.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/03_LIVE_METADATA_AND_USER_STATE_PURGING.md) | **Startup cleanup migration committed** | Fresh installs have no bundled story catalog; provider and authored content uses live metadata and account-owned shelf/profile state. | | **6. Multi-Format & Manga Architecture** | [`05_MULTI_FORMAT_AND_MANGA_ARCHITECTURE.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/05_MULTI_FORMAT_AND_MANGA_ARCHITECTURE.md) | **Complete** | Polymorphic reader dispatch, continuous vertical Webtoon scroll, horizontal swipe paging, manga panel ingestion pipeline. | -| **7. Testing Suites & ADR Baseline** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Backend verified; iOS run pending** | 36/36 backend tests pass in Docker. Swift build, Swift tests, and device diagnostics require Xcode and remain unrun here. | +| **7. Testing Suites & ADR Baseline** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Backend verified; iOS CI configured** | 43/43 backend tests pass in an isolated container. The new iOS scheme, XCTest target, and Release build check are configured in macOS CI; they were not run locally because this workspace is Fedora without Xcode. | | **8. App Health Diagnostics & Anti-Overlap Invariants** | [`MASTER_PROGRESS_LOG.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/MASTER_PROGRESS_LOG.md) | **Implemented; device check pending** | Image boundary clipping, `SystemDiagnosticsSheet`, and `AppHealthTests` are in source; execution requires Xcode. | --- +## Production Readiness Improvements (October 2026) + +- Release iOS builds now require a configured HTTPS API URL and reject loopback, unspecified, and reserved local hosts. Debug builds keep the simulator API URL. +- API boundaries now enforce request-body and story-field size limits, sanitize validation errors, rate-limit registration and login, and validate CORS origins. +- SQLite startup changes are recorded with `PRAGMA user_version`; the one-time cleanup removes only the twelve retired demo IDs and their shelf, reading-session, chapter, and story rows. An online backup and integrity-check command plus restore guidance are available in [`OPERATIONS.md`](../OPERATIONS.md). +- Story catalog, account-story, and update-feed responses omit manuscript text. The public catalog supports bounded `limit`/`offset` pagination, and the iOS client fetches pages while story details and chapters retain full text. +- Backend verification: 43/43 tests pass in the cached Docker image with the workspace mounted read-only. Compose configuration, Python compilation, release URL guard cases, and Xcode scheme XML pass locally. Xcode build and XCTest execution remain pending a macOS runner. +- Implementation commits: `c631ee2` (backend and SQLite) and `4a327cc` (iOS release and CI). + ## Master Feature & Button Audit Matrix | Feature / UI Component | Original State (Midterm) | Final Milestone Target | Status | Implementation File(s) | diff --git a/frontend/FableApp.xcodeproj/project.pbxproj b/frontend/FableApp.xcodeproj/project.pbxproj index 263b9d6..1954a0f 100644 --- a/frontend/FableApp.xcodeproj/project.pbxproj +++ b/frontend/FableApp.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXFileReference section */ 83D429203059253E000C5567 /* FableApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FableApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A11C00000000000000000001 /* FableAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FableAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -16,6 +17,11 @@ path = FableApp; sourceTree = ""; }; + A11C00000000000000000002 /* FableAppTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FableAppTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +32,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11C00000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +46,7 @@ isa = PBXGroup; children = ( 83D429223059253E000C5567 /* FableApp */, + A11C00000000000000000002 /* FableAppTests */, 83D429213059253E000C5567 /* Products */, ); sourceTree = ""; @@ -41,6 +55,7 @@ isa = PBXGroup; children = ( 83D429203059253E000C5567 /* FableApp.app */, + A11C00000000000000000001 /* FableAppTests.xctest */, ); name = Products; sourceTree = ""; @@ -55,6 +70,7 @@ 83D4291C3059253E000C5567 /* Sources */, 83D4291D3059253E000C5567 /* Frameworks */, 83D4291E3059253E000C5567 /* Resources */, + A11C0000000000000000000C /* Validate Release API URL */, ); buildRules = ( ); @@ -69,6 +85,29 @@ productName = FableApp; productReference = 83D429203059253E000C5567 /* FableApp.app */; productType = "com.apple.product-type.application"; + }; + A11C00000000000000000006 /* FableAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A11C00000000000000000009 /* Build configuration list for PBXNativeTarget "FableAppTests" */; + buildPhases = ( + A11C00000000000000000004 /* Sources */, + A11C00000000000000000003 /* Frameworks */, + A11C00000000000000000005 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A11C00000000000000000008 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A11C00000000000000000002 /* FableAppTests */, + ); + name = FableAppTests; + packageProductDependencies = ( + ); + productName = FableAppTests; + productReference = A11C00000000000000000001 /* FableAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -80,9 +119,13 @@ LastSwiftUpdateCheck = 2660; LastUpgradeCheck = 2660; TargetAttributes = { - 83D4291F3059253E000C5567 = { - CreatedOnToolsVersion = 26.6; - }; + 83D4291F3059253E000C5567 = { + CreatedOnToolsVersion = 26.6; + }; + A11C00000000000000000006 = { + CreatedOnToolsVersion = 26.6; + TestTargetID = 83D4291F3059253E000C5567; + }; }; }; buildConfigurationList = 83D4291B3059253E000C5567 /* Build configuration list for PBXProject "FableApp" */; @@ -100,6 +143,7 @@ projectRoot = ""; targets = ( 83D4291F3059253E000C5567 /* FableApp */, + A11C00000000000000000006 /* FableAppTests */, ); }; /* End PBXProject section */ @@ -112,6 +156,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11C00000000000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,8 +173,51 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11C00000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + A11C0000000000000000000C /* Validate Release API URL */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/../scripts/validate_ios_release_config.sh", + ); + name = "Validate Release API URL"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "/bin/bash \"${SRCROOT}/../scripts/validate_ios_release_config.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + A11C00000000000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83D429183059253E000C5567 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 83D4291F3059253E000C5567; + remoteInfo = FableApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXTargetDependency section */ + A11C00000000000000000008 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 83D4291F3059253E000C5567 /* FableApp */; + targetProxy = A11C00000000000000000007 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 83D429293059253F000C5567 /* Debug */ = { isa = XCBuildConfiguration; @@ -288,10 +382,10 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - FABLE_API_BASE_URL = "http://127.0.0.1:8000/api/v1"; + FABLE_API_BASE_URL = ""; INFOPLIST_KEY_FABLE_API_BASE_URL = "$(FABLE_API_BASE_URL)"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = NO; - INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = NO; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -311,6 +405,40 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + A11C0000000000000000000A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = mseuf.edu.ph.FableAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FableApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FableApp"; + }; + name = Debug; + }; + A11C0000000000000000000B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = mseuf.edu.ph.FableAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FableApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FableApp"; }; name = Release; }; @@ -335,6 +463,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A11C00000000000000000009 /* Build configuration list for PBXNativeTarget "FableAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A11C0000000000000000000A /* Debug */, + A11C0000000000000000000B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 83D429183059253E000C5567 /* Project object */; diff --git a/frontend/FableApp.xcodeproj/xcshareddata/xcschemes/FableApp.xcscheme b/frontend/FableApp.xcodeproj/xcshareddata/xcschemes/FableApp.xcscheme new file mode 100644 index 0000000..ec2d8bc --- /dev/null +++ b/frontend/FableApp.xcodeproj/xcshareddata/xcschemes/FableApp.xcscheme @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/FableApp/Features/Auth/Services/AuthManager.swift b/frontend/FableApp/Features/Auth/Services/AuthManager.swift index e3e09a9..cb967b2 100644 --- a/frontend/FableApp/Features/Auth/Services/AuthManager.swift +++ b/frontend/FableApp/Features/Auth/Services/AuthManager.swift @@ -155,8 +155,8 @@ public final class AuthManager: ObservableObject { authErrorMessage = "Please provide a valid email." return false } - guard password.count >= 6 else { - authErrorMessage = "Password must be at least 6 characters." + guard password.count >= 12 else { + authErrorMessage = "Password must be at least 12 characters." return false } diff --git a/frontend/FableApp/Features/Auth/Tests/AuthTests.swift b/frontend/FableApp/Features/Auth/Tests/AuthTests.swift index 2cca2bf..66ce4ba 100644 --- a/frontend/FableApp/Features/Auth/Tests/AuthTests.swift +++ b/frontend/FableApp/Features/Auth/Tests/AuthTests.swift @@ -18,6 +18,7 @@ public struct AuthTests { let auth = AuthManager() auth.signOut() + let authViewModel = AuthViewModel.shared // Test 1: Invalid email format rejection let invalidEmailResult = await auth.signIn(email: "not-an-email", password: "password123") @@ -28,12 +29,22 @@ public struct AuthTests { assert(!shortPasswordResult && auth.authErrorMessage == "Password must be at least 6 characters.", "Enforce Minimum Password Length") // Test 3: Guest session generation - auth.continueAsGuest() - assert(auth.isAuthenticated && auth.isGuestMode && auth.currentSession?.name == "Guest", "Guest Session Generation") + authViewModel.continueAsGuest() + assert( + AuthManager.shared.isAuthenticated + && AuthManager.shared.isGuestMode + && authViewModel.currentUser?.name == "Guest", + "Guest Session Generation" + ) // Test 4: Sign-out state cleanup + authViewModel.logout() + assert( + !AuthManager.shared.isAuthenticated + && authViewModel.currentUser == nil, + "Sign Out State Cleanup" + ) auth.signOut() - assert(!auth.isAuthenticated && auth.currentSession == nil, "Sign Out State Cleanup") return (passed, total, failures) } diff --git a/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift b/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift index 5af19b7..1389c3c 100644 --- a/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift +++ b/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift @@ -142,8 +142,8 @@ public final class AuthViewModel: ObservableObject { errorMessage = "Please enter a valid email address." return false } - guard password.count >= 6 else { - errorMessage = "Password must be at least 6 characters." + guard password.count >= 12 else { + errorMessage = "Password must be at least 12 characters." return false } diff --git a/frontend/FableApp/Features/Auth/Views/SignUpView.swift b/frontend/FableApp/Features/Auth/Views/SignUpView.swift index 59c40ea..870bbbe 100644 --- a/frontend/FableApp/Features/Auth/Views/SignUpView.swift +++ b/frontend/FableApp/Features/Auth/Views/SignUpView.swift @@ -146,10 +146,10 @@ public struct SignUpView: View { .frame(width: 20) if isPasswordVisible { - TextField("Enter a password (min. 6 characters)", text: $password) + TextField("Enter a password (min. 12 characters)", text: $password) .font(.system(size: 15)) } else { - SecureField("Enter a password (min. 6 characters)", text: $password) + SecureField("Enter a password (min. 12 characters)", text: $password) .font(.system(size: 15)) } @@ -294,8 +294,8 @@ public struct SignUpView: View { return } - guard password.count >= 6 else { - validationError = "Password must be at least 6 characters." + guard password.count >= 12 else { + validationError = "Password must be at least 12 characters." return } diff --git a/frontend/FableApp/Features/Library/Services/StoryAPIService.swift b/frontend/FableApp/Features/Library/Services/StoryAPIService.swift index ec197f1..57e6a03 100644 --- a/frontend/FableApp/Features/Library/Services/StoryAPIService.swift +++ b/frontend/FableApp/Features/Library/Services/StoryAPIService.swift @@ -1,6 +1,12 @@ import Foundation public struct CreateStoryRequest: Codable { + public static let maximumTitleLength = 120 + public static let maximumGenreLength = 50 + public static let maximumChapterLength = 120 + public static let maximumSynopsisLength = 2_000 + public static let maximumContentLength = 50_000 + public let title: String public let genre: String public let chapter: String? @@ -233,9 +239,14 @@ public final class StoryAPIService: StoryAPIServiceProtocol { public init(baseURL: URL? = nil) { let infoBaseURL = Bundle.main.object(forInfoDictionaryKey: "FABLE_API_BASE_URL") as? String let configuredBaseURL = ProcessInfo.processInfo.environment["FABLE_API_BASE_URL"] ?? infoBaseURL +#if DEBUG + let fallbackBaseURL = URL(string: "http://127.0.0.1:8000/api/v1")! +#else + let fallbackBaseURL = URL(string: "https://api-configuration-required.invalid/api/v1")! +#endif self.baseURL = baseURL ?? configuredBaseURL.flatMap { URL(string: $0) } - ?? URL(string: "http://127.0.0.1:8000/api/v1")! + ?? fallbackBaseURL let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 5.0 self.session = URLSession(configuration: config) @@ -251,38 +262,50 @@ public final class StoryAPIService: StoryAPIServiceProtocol { } public func fetchStories(genre: String? = nil, search: String? = nil, since: Date? = nil) async throws -> [Story] { - var components = URLComponents(url: baseURL.appendingPathComponent("stories"), resolvingAgainstBaseURL: true)! - var queryItems: [URLQueryItem] = [] - if let genre, genre != "All" { queryItems.append(URLQueryItem(name: "genre", value: genre)) } - if let search, !search.isEmpty { queryItems.append(URLQueryItem(name: "search", value: search)) } - if let since { - let formatter = ISO8601DateFormatter() - queryItems.append(URLQueryItem(name: "since", value: formatter.string(from: since))) - } - if !queryItems.isEmpty { components.queryItems = queryItems } - - guard let targetURL = components.url else { throw URLError(.badURL) } - let (data, response) = try await session.data(from: targetURL) - guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } - let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - - // Supports both raw array and paged responses - if let directList = try? decoder.decode([Story].self, from: data) { - return directList - } - - struct PagedStoryWrapper: Codable { - let items: [Story] - } - if let wrapped = try? decoder.decode(PagedStoryWrapper.self, from: data) { - return wrapped.items + let pageSize = 50 + var offset = 0 + var stories: [Story] = [] + var storyIDs = Set() + + while true { + var components = URLComponents(url: baseURL.appendingPathComponent("stories"), resolvingAgainstBaseURL: true)! + var queryItems = [ + URLQueryItem(name: "limit", value: String(pageSize)), + URLQueryItem(name: "offset", value: String(offset)) + ] + if let genre, genre != "All" { queryItems.append(URLQueryItem(name: "genre", value: genre)) } + if let search, !search.isEmpty { queryItems.append(URLQueryItem(name: "search", value: search)) } + if let since { + let formatter = ISO8601DateFormatter() + queryItems.append(URLQueryItem(name: "since", value: formatter.string(from: since))) + } + components.queryItems = queryItems + + guard let targetURL = components.url else { throw URLError(.badURL) } + let (data, response) = try await session.data(from: targetURL) + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + let page: [Story] + if let directPage = try? decoder.decode([Story].self, from: data) { + page = directPage + } else { + struct PagedStoryWrapper: Decodable { + let items: [Story] + } + page = try decoder.decode(PagedStoryWrapper.self, from: data).items + } + + let newStories = page.filter { storyIDs.insert($0.id).inserted } + stories.append(contentsOf: newStories) + if page.count < pageSize || page.count > pageSize || newStories.isEmpty { + return stories + } + offset += page.count } - - return try decoder.decode([Story].self, from: data) } public func createStory(_ request: CreateStoryRequest, token: String) async throws -> Story { @@ -552,4 +575,3 @@ public final class StoryAPIService: StoryAPIServiceProtocol { return try decoder.decode(AuthUserDTO.self, from: data) } } - diff --git a/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift b/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift index ceb79f4..9221035 100644 --- a/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift +++ b/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift @@ -98,6 +98,26 @@ public final class StoryStore: ObservableObject { updatedAtUtc: .distantPast ) + private init( + readingProgress: Double, + isBookmarked: Bool, + isCompleted: Bool, + currentPage: Int, + totalPages: Int, + lastReadChapterId: String?, + lastReadChapterNumber: Int?, + updatedAtUtc: Date + ) { + self.readingProgress = readingProgress + self.isBookmarked = isBookmarked + self.isCompleted = isCompleted + self.currentPage = currentPage + self.totalPages = totalPages + self.lastReadChapterId = lastReadChapterId + self.lastReadChapterNumber = lastReadChapterNumber + self.updatedAtUtc = updatedAtUtc + } + init(story: Story, updatedAtUtc: Date = Date()) { self.readingProgress = Double(story.progressPercent) / 100.0 self.isBookmarked = story.isBookmarked @@ -264,10 +284,11 @@ public final class StoryStore: ObservableObject { publishErrorMessage = nil let title = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) let genre = draftGenre.trimmingCharacters(in: .whitespacesAndNewlines) + let chapter = draftChapter.trimmingCharacters(in: .whitespacesAndNewlines) let synopsis = draftSynopsis.trimmingCharacters(in: .whitespacesAndNewlines) let manuscript = draftManuscript.trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty, title.count <= 120 else { + guard !title.isEmpty, title.count <= CreateStoryRequest.maximumTitleLength else { publishErrorMessage = "Enter a title with 1 to 120 characters." return nil } @@ -275,14 +296,30 @@ public final class StoryStore: ObservableObject { publishErrorMessage = "Choose or enter a genre." return nil } + guard genre.count <= CreateStoryRequest.maximumGenreLength else { + publishErrorMessage = "Keep the genre to 50 characters or fewer." + return nil + } + guard chapter.count <= CreateStoryRequest.maximumChapterLength else { + publishErrorMessage = "Keep the chapter title to 120 characters or fewer." + return nil + } guard !synopsis.isEmpty else { publishErrorMessage = "Add a synopsis before publishing." return nil } + guard synopsis.count <= CreateStoryRequest.maximumSynopsisLength else { + publishErrorMessage = "Keep the synopsis to 2,000 characters or fewer." + return nil + } guard !manuscript.isEmpty else { publishErrorMessage = "Add manuscript text before publishing." return nil } + guard manuscript.count <= CreateStoryRequest.maximumContentLength else { + publishErrorMessage = "Keep the manuscript to 50,000 characters or fewer." + return nil + } guard let session = AuthManager.shared.currentSession, !session.isGuest, let token = KeychainStore.shared.readAccessToken() else { publishErrorMessage = "Sign in with an account to publish this story online. Your draft is saved on this device." @@ -294,7 +331,7 @@ public final class StoryStore: ObservableObject { let request = CreateStoryRequest( title: title, genre: genre, - chapter: draftChapter.trimmingCharacters(in: .whitespacesAndNewlines), + chapter: chapter, synopsis: synopsis, content: manuscript, readTimeMinutes: max(1, draftWordCount / 150) @@ -393,11 +430,6 @@ public final class StoryStore: ObservableObject { genre: entity.genreRaw, excerpt: entity.synopsis, paragraphs: [entity.content], - contentFormat: ContentFormat(rawValue: entity.contentFormatRaw ?? "PROSE") ?? .prose, - sourceProvider: provider, - providerId: entity.providerId, - providerDownloadCount: entity.providerDownloadCount, - chapters: PersistenceService.shared.cachedChapters(storyId: entity.id), coverImageName: nil, heroImageName: nil, coverImageUrl: validCoverImageURL(entity.coverImageUrl), @@ -409,6 +441,10 @@ public final class StoryStore: ObservableObject { isRecentSubmission: true, isSaved: entity.isBookmarked, isFinished: entity.isCompleted, + contentFormat: ContentFormat(rawValue: entity.contentFormatRaw ?? "PROSE") ?? .prose, + sourceProvider: provider, + providerId: entity.providerId, + chapters: PersistenceService.shared.cachedChapters(storyId: entity.id), lastReadChapterId: entity.lastReadChapterId, lastReadChapterNumber: entity.lastReadChapterNumber ) diff --git a/frontend/FableAppTests/StorySummaryDecodingTests.swift b/frontend/FableAppTests/StorySummaryDecodingTests.swift new file mode 100644 index 0000000..03c036b --- /dev/null +++ b/frontend/FableAppTests/StorySummaryDecodingTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import FableApp + +final class StorySummaryDecodingTests: XCTestCase { + func testCatalogSummaryCanOmitManuscriptContent() throws { + let payload = Data( + """ + { + "id": "4A04D708-498F-4D8C-9B6B-30F0FBB38C43", + "title": "Catalog Entry", + "author": "Fable Author", + "genre": "Folklore", + "synopsis": "A short catalog synopsis.", + "readTimeMinutes": 1, + "createdAtUtc": "2026-01-01T00:00:00Z" + } + """.utf8 + ) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let story = try decoder.decode(Story.self, from: payload) + + XCTAssertEqual(story.title, "Catalog Entry") + XCTAssertEqual(story.content, "") + } +} diff --git a/scripts/sqlite_backup.py b/scripts/sqlite_backup.py new file mode 100644 index 0000000..740b019 --- /dev/null +++ b/scripts/sqlite_backup.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Create and verify a consistent online backup of a SQLite database.""" + +from __future__ import annotations + +import argparse +import os +import sqlite3 +import sys +from pathlib import Path + + +def verify_database(path: Path) -> None: + if not path.is_file(): + raise FileNotFoundError(f"Database file does not exist: {path}") + with sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True) as connection: + result = connection.execute("PRAGMA integrity_check").fetchone()[0] + if result != "ok": + raise RuntimeError(f"SQLite integrity check failed for {path}: {result}") + + +def create_backup(source: Path, destination: Path) -> None: + source = source.resolve() + destination = destination.resolve() + if source == destination: + raise ValueError("Backup source and destination must be different files.") + if not source.is_file(): + raise FileNotFoundError(f"Database file does not exist: {source}") + + destination.parent.mkdir(parents=True, exist_ok=True) + file_descriptor = os.open( + destination, + os.O_CREAT | os.O_EXCL | os.O_WRONLY, + 0o600, + ) + os.close(file_descriptor) + try: + with sqlite3.connect(source) as source_connection: + with sqlite3.connect(destination) as destination_connection: + source_connection.backup(destination_connection) + verify_database(destination) + except Exception: + destination.unlink(missing_ok=True) + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + backup_parser = subparsers.add_parser("backup", help="Create an online database backup") + backup_parser.add_argument("source", type=Path) + backup_parser.add_argument("destination", type=Path) + + verify_parser = subparsers.add_parser("verify", help="Check a database backup") + verify_parser.add_argument("database", type=Path) + + arguments = parser.parse_args() + try: + if arguments.command == "backup": + create_backup(arguments.source, arguments.destination) + print(f"Backup created and verified: {arguments.destination}") + else: + verify_database(arguments.database) + print(f"Database integrity check passed: {arguments.database}") + except (OSError, sqlite3.Error, RuntimeError, ValueError) as error: + print(f"Database backup operation failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_ios_release_config.sh b/scripts/validate_ios_release_config.sh new file mode 100755 index 0000000..b0cfb69 --- /dev/null +++ b/scripts/validate_ios_release_config.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +if [[ "${CONFIGURATION:-}" != "Release" ]]; then + exit 0 +fi + +python3 - "${FABLE_API_BASE_URL:-}" <<'PY' +import ipaddress +import socket +import sys +from urllib.parse import urlsplit + +value = sys.argv[1] +try: + parsed = urlsplit(value) + parsed.port +except ValueError: + raise SystemExit("Release builds require a valid API URL.") +host = parsed.hostname + +if ( + parsed.scheme != "https" + or not host + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment +): + raise SystemExit("Release builds require FABLE_API_BASE_URL to be an HTTPS URL.") + +normalized_host = host.rstrip(".").lower() +if ( + normalized_host == "localhost" + or normalized_host.endswith((".localhost", ".local", ".test", ".invalid")) +): + raise SystemExit("Release builds cannot use a local or reserved API host.") + +try: + address = ipaddress.ip_address(normalized_host) +except ValueError: + try: + address = ipaddress.ip_address(socket.inet_aton(normalized_host)) + except (OSError, ValueError): + address = None + +if address and (address.is_loopback or address.is_unspecified): + raise SystemExit("Release builds cannot use a loopback or unspecified API address.") +PY