diff --git a/backend/app/images.py b/backend/app/images.py new file mode 100644 index 0000000..6666537 --- /dev/null +++ b/backend/app/images.py @@ -0,0 +1,66 @@ +"""Scenario cover art: inline data URIs in, cacheable URLs out. + +A scenario's `image` column holds either an `https://` URL or a base64 +`data:image/…` URI (the editor downscales uploads before storing one). Sending +those data URIs inside list responses would balloon them, so lists advertise a +`image_url` pointing at `GET /api/scenarios/{id}/image` instead, and the bytes +are fetched once and cached by the browser. +""" + +import base64 +import binascii +import re + +# Only raster formats a browser renders in an . SVG is deliberately absent: +# it can carry script, and these bytes are served from our own origin. +DATA_URI_RE = re.compile( + r"^data:(image/(?:png|jpeg|webp|gif|avif));base64,([A-Za-z0-9+/=\s]+)$", + re.IGNORECASE, +) + + +def public_url(scenario_id: int, image: str, version: object) -> str: + """The URL a client should load for this scenario's art ("" if none). + + `version` (any object with a stable repr — normally the row's updated_at) + becomes a cache-buster, letting the image response be marked immutable + while still refreshing the moment the author swaps the picture. + """ + if not image: + return "" + if DATA_URI_RE.match(image): + stamp = int(version.timestamp()) if hasattr(version, "timestamp") else 0 + return f"/api/scenarios/{scenario_id}/image?v={stamp}" + # Anything else must be an absolute https URL. http:// is rejected rather + # than passed through: the deployed app is https, so a browser would block + # it as mixed content and the author would just see a broken image. + return image if image.startswith("https://") else "" + + +def sanitize(value: object, max_length: int) -> str: + """Coerce an untrusted `image` value from an import bundle to a safe one. + + Anything that isn't a supported data URI or an https URL — or that is too + large to store — becomes "", so a hostile or merely foreign bundle can't + smuggle in a `javascript:` URI or blow past the column cap. + """ + if not isinstance(value, str) or not value or len(value) > max_length: + return "" + if DATA_URI_RE.match(value): + return value if decode(value) is not None else "" + return value if value.startswith("https://") else "" + + +def decode(image: str) -> tuple[bytes, str] | None: + """`(bytes, content_type)` for a stored data URI, or None if it isn't one.""" + match = DATA_URI_RE.match(image or "") + if not match: + return None + try: + # validate=True rejects anything outside the base64 alphabet, so strip + # the newlines a hand-pasted or line-wrapped URI may carry first. + payload = re.sub(r"\s+", "", match.group(2)) + return base64.b64decode(payload, validate=True), match.group(1).lower() + except (binascii.Error, ValueError): + # Truncated or hand-edited base64 — treat as "no image" rather than 500. + return None diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 87f031e..1fe5fa2 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -90,6 +90,11 @@ MIGRATIONS: list[tuple[int, str]] = [ # guide + world state each turn). Only bumps rows still on the old default, # so anyone who picked a custom value keeps it. (29, "UPDATE settings SET context_token_budget = 16384 WHERE context_token_budget = 4096"), + # Scenario cover art — an external URL or an inline base64 data URI. TEXT + # (not VARCHAR) because a downscaled data URI runs tens of kilobytes. + (30, "ALTER TABLE scenarios ADD COLUMN image TEXT NOT NULL DEFAULT ''"), + # Emoji/glyph fallback used when `image` is empty. + (31, "ALTER TABLE scenarios ADD COLUMN icon VARCHAR(16) NOT NULL DEFAULT ''"), ] LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) diff --git a/backend/app/models.py b/backend/app/models.py index 8d04f0f..eac6251 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -62,6 +62,16 @@ class Scenario(Base): authors_note: Mapped[str] = mapped_column(Text, default="") ai_instructions: Mapped[str] = mapped_column(Text, default="") tags: Mapped[str] = mapped_column(String(500), default="") + # Cover art. Either an external "https://…" URL or an inline + # "data:image/…;base64,…" URI (the editor downscales uploads before storing + # one). Empty means the UI falls back to an emoji sigil or generated art. + # Kept in the row rather than on disk because Render's free tier has no + # persistent volume, and it makes export bundles self-contained. + image: Mapped[str] = mapped_column(Text, default="") + # A single emoji or glyph used when there's no `image` — cheap art for + # scenarios nobody wants to find a picture for. Separate from `image` + # because it's a character, not a locator: no fetch, no cache, no bytes. + icon: Mapped[str] = mapped_column(String(16), default="") # Phase 12: RPG world-state template — stat definitions (bands, rules) and # milestones. NULL/empty means this scenario has no RPG layer. stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True) diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 00a74a6..1927411 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse from sqlalchemy import func from sqlalchemy.orm import Session -from .. import auth, limits, memorybank, models, schemas, worldstate +from .. import auth, images, limits, memorybank, models, schemas, worldstate from ..context import build_context from ..database import get_db from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError @@ -29,21 +29,89 @@ def get_adventure_or_404( return adventure +# How much of the last narrative beat a Continue card shows. Long enough to +# re-establish the scene, short enough that the card stays a card. +SNIPPET_MAX = 220 + + +def _snippet(text: str) -> str: + """Condense stored action text into one flowing line for a card.""" + # Stored AI text already has any world-state block stripped (see the + # streaming handler below), so this only has to tidy whitespace. + collapsed = " ".join((text or "").split()) + if len(collapsed) <= SNIPPET_MAX: + return collapsed + # Cut on a word boundary rather than mid-word, then let CSS add the ellipsis. + cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0] + return f"{cut}…" + + +# Action types that read as narration. `start` is the scenario's opening prompt, +# which is the only text a freshly-created adventure has — without it a brand-new +# story's card would claim nothing had been written yet. `do`/`say` are excluded: +# "where you left off" should be the story's voice, not the player's. +NARRATION_TYPES = ("ai", "story", "start") + + +def _latest_narration(db: Session, adventure_ids: list[int]) -> dict[int, str]: + """Map adventure id -> text of its most recent narrated action. + + One window-function query rather than a per-adventure lookup, so the list + endpoint stays at a fixed number of round trips. + """ + if not adventure_ids: + return {} + ranked = ( + db.query( + models.Action.adventure_id.label("adventure_id"), + models.Action.text.label("text"), + func.row_number() + .over( + partition_by=models.Action.adventure_id, + order_by=(models.Action.index.desc(), models.Action.id.desc()), + ) + .label("rank"), + ) + .filter( + models.Action.adventure_id.in_(adventure_ids), + models.Action.type.in_(NARRATION_TYPES), + ) + .subquery() + ) + rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all() + return {adventure_id: text for adventure_id, text in rows} + + @router.get("", response_model=list[schemas.AdventureListItem]) def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser): rows = ( - db.query(models.Adventure, func.count(models.Action.id), models.Scenario.title) + db.query( + models.Adventure, + func.count(models.Action.id), + models.Scenario.title, + models.Scenario.image, + models.Scenario.icon, + models.Scenario.updated_at, + ) .outerjoin(models.Action) .outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id) .filter(models.Adventure.user_id == user.id) # Group by both PKs: Postgres requires every selected column to be # grouped or aggregated. Adventure.* rides on its own grouped PK, but - # Scenario.title comes from a joined table and must be listed too + # the Scenario columns come from a joined table and must be listed too # (SQLite is lax here; Postgres rejects it). - .group_by(models.Adventure.id, models.Scenario.title) + .group_by( + models.Adventure.id, + models.Scenario.id, + models.Scenario.title, + models.Scenario.image, + models.Scenario.icon, + models.Scenario.updated_at, + ) .order_by(models.Adventure.updated_at.desc()) .all() ) + narration = _latest_narration(db, [adv.id for adv, *_ in rows]) return [ schemas.AdventureListItem( id=adv.id, @@ -52,8 +120,13 @@ def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUs title=adv.title, updated_at=adv.updated_at, action_count=count, + snippet=_snippet(narration.get(adv.id, "")), + # The art belongs to the scenario, so the cache-busting stamp is the + # scenario's updated_at, not the adventure's. + image_url=images.public_url(adv.scenario_id, image or "", scenario_updated), + icon=icon or "", ) - for adv, count, scenario_title in rows + for adv, count, scenario_title, image, icon, scenario_updated in rows ] diff --git a/backend/app/routers/scenarios.py b/backend/app/routers/scenarios.py index b20283b..8b3a2ae 100644 --- a/backend/app/routers/scenarios.py +++ b/backend/app/routers/scenarios.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi.responses import Response from sqlalchemy import or_ from sqlalchemy.orm import Session -from .. import auth, limits, models, schemas +from .. import auth, images, limits, models, schemas from ..database import get_db router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) @@ -55,6 +56,30 @@ def get_scenario( return get_scenario_or_404(scenario_id, db, user) +@router.get("/{scenario_id}/image") +def get_scenario_image( + scenario_id: int, + db: Session = Depends(get_db), + user: models.User = Depends(auth.get_current_user), +): + """Serve an uploaded cover image as real bytes. + + Lists point here instead of inlining the data URI. The response is marked + immutable and the URL carries a `?v=` stamp, so browsers cache + it indefinitely but pick up a new picture the moment the author saves one. + """ + scenario = get_scenario_or_404(scenario_id, db, user) + decoded = images.decode(scenario.image) + if decoded is None: + raise HTTPException(404, "This scenario has no uploaded image") + data, content_type = decoded + return Response( + content=data, + media_type=content_type, + headers={"Cache-Control": "private, max-age=31536000, immutable"}, + ) + + @router.patch("/{scenario_id}", response_model=schemas.ScenarioOut) def update_scenario( scenario_id: int, @@ -109,6 +134,8 @@ def export_scenario( "authorsNote": s.authors_note, "aiInstructions": s.ai_instructions, "tags": s.tags, + "image": s.image, + "icon": s.icon, "statSchema": s.stat_schema, "storyCards": [ {"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes} @@ -138,8 +165,8 @@ _SCENARIO_KEYS = { "instructions": "ai_instructions", } _IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags", - "statSchema", "stat_schema", - "createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"} + "statSchema", "stat_schema", "image", "icon", + "createdAt", "updatedAt", "id", "publicId", "nsfw", "type", "options"} @router.post("/import", status_code=201) @@ -171,13 +198,24 @@ def import_scenario( if isinstance(schema, dict): fields["stat_schema"] = schema + # AI Dungeon bundles carry an `image` too, so this is worth honouring — but + # it's untrusted input, hence sanitize() rather than a straight assignment. + image = images.sanitize(bundle.get("image"), schemas.IMAGE_MAX) + if image: + fields["image"] = image + icon = bundle.get("icon") + if isinstance(icon, str) and icon: + fields["icon"] = icon[:schemas.ICON_MAX] + scenario = models.Scenario(**fields, user_id=user.id) if not scenario.title: scenario.title = "Imported Scenario" # Raw-dict import bypasses the schemas — clamp to VARCHAR widths - # (Postgres enforces them; see schemas.py). + # (Postgres enforces them; see schemas.py). Column defaults haven't been + # applied yet at this point (that happens at flush), so a bundle with no + # `tags` key leaves the attribute None — hence the `or ""`. scenario.title = scenario.title[:schemas.NAME_MAX] - scenario.tags = scenario.tags[:schemas.TAGS_MAX] + scenario.tags = (scenario.tags or "")[:schemas.TAGS_MAX] db.add(scenario) db.flush() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e9838f7..823b2cd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,9 @@ from datetime import datetime from typing import Annotated, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, computed_field + +from . import images # Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse # limits: Postgres enforces column lengths (SQLite never did), so anything @@ -14,6 +16,12 @@ PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes... SCRIPT_MAX = 200_000 # one JS source ACTION_MAX = 20_000 # one player action MEMORY_TEXT_MAX = 5_000 +# A scenario cover image, stored inline as a base64 data URI. 400x300 WebP at +# the quality the editor encodes lands around 20-40 KB; 400 KB leaves room for +# a client that downscales less aggressively without letting anyone park a +# multi-megabyte PNG in a row that gets read on every list request. +IMAGE_MAX = 400_000 +ICON_MAX = 16 # one emoji/glyph — VARCHAR(16) Name = Annotated[str, Field(max_length=NAME_MAX)] Tags = Annotated[str, Field(max_length=TAGS_MAX)] @@ -21,6 +29,8 @@ CardType = Annotated[str, Field(max_length=CARD_TYPE_MAX)] Prose = Annotated[str, Field(max_length=PROSE_MAX)] ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)] ActionText = Annotated[str, Field(max_length=ACTION_MAX)] +Image = Annotated[str, Field(max_length=IMAGE_MAX)] +Icon = Annotated[str, Field(max_length=ICON_MAX)] class ORMModel(BaseModel): @@ -66,6 +76,10 @@ class ScenarioBase(BaseModel): authors_note: Prose = "" ai_instructions: Prose = "" tags: Tags = "" + # Cover art — an https URL or a base64 data URI. See app/images.py. + image: Image = "" + # Emoji/glyph shown when `image` is empty. + icon: Icon = "" # Phase 12: RPG world-state template (stat defs, bands, rules, milestones). # None means no RPG layer. stat_schema: dict | None = None @@ -83,6 +97,8 @@ class ScenarioUpdate(BaseModel): authors_note: Prose | None = None ai_instructions: Prose | None = None tags: Tags | None = None + image: Image | None = None + icon: Icon | None = None stat_schema: dict | None = None script_ids: list[int] | None = None @@ -103,6 +119,15 @@ class ScenarioListItem(ORMModel): tags: str is_public: bool = False updated_at: datetime + # Read off the row so `image_url` can be derived, but excluded from the + # response: a list of base64 data URIs would be megabytes of JSON. + image: str = Field("", exclude=True) + icon: str = "" + + @computed_field + @property + def image_url(self) -> str: + return images.public_url(self.id, self.image, self.updated_at) # ---------- Adventures ---------- @@ -194,6 +219,12 @@ class AdventureListItem(ORMModel): title: str updated_at: datetime action_count: int = 0 + # "Where you left off" — the tail of the most recent narrative beat, so a + # Continue card can show the story instead of just a turn count. + snippet: str = "" + # Cover art inherited from the parent scenario (see app/images.py). + image_url: str = "" + icon: str = "" # ---------- Scripts ---------- diff --git a/backend/app/seed.py b/backend/app/seed.py index 6d3b9d1..c2f8b49 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -28,7 +28,8 @@ logger = logging.getLogger(__name__) SEED_DIR = Path(__file__).resolve().parent / "seed_data" -_SCALARS = ("description", "prompt", "memory", "authors_note", "ai_instructions", "tags") +_SCALARS = ("description", "prompt", "memory", "authors_note", "ai_instructions", "tags", + "image", "icon") _CARD_FIELDS = ("type", "name", "keys", "entry", "notes") _SCRIPT_FIELDS = ("name", "library_js", "input_js", "context_js", "output_js") diff --git a/backend/app/seed_data/01-sunken-crypt-of-vharos.json b/backend/app/seed_data/01-sunken-crypt-of-vharos.json index 22b0801..dc4f105 100644 --- a/backend/app/seed_data/01-sunken-crypt-of-vharos.json +++ b/backend/app/seed_data/01-sunken-crypt-of-vharos.json @@ -62,5 +62,6 @@ "context_js": "", "output_js": "const modifier = (text) => {\n var out = text;\n\n // Drop a trailing sentence fragment (no ending punctuation).\n var m = out.match(/^([\\s\\S]*[.!?\"'\\u2026])[^.!?\"'\\u2026]*$/);\n if (m && m[1].length > 40) {\n if (m[1].length < out.length) log(\"Trimmed incomplete final sentence.\");\n out = m[1];\n }\n\n // Demonstrate script-created story cards.\n if (/vharos/i.test(out)) {\n var added = addStoryCard(\n \"Vharos, ghost, spirit\",\n \"Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.\",\n \"character\"\n );\n if (added !== false) log(\"Vharos mentioned — story card created.\");\n }\n\n return { text: out };\n};\nmodifier(text);\n" } - ] -} \ No newline at end of file + ], + "icon": "🗝" +} diff --git a/backend/app/seed_data/02-signal-from-the-derelict.json b/backend/app/seed_data/02-signal-from-the-derelict.json index 0800e71..61ba5db 100644 --- a/backend/app/seed_data/02-signal-from-the-derelict.json +++ b/backend/app/seed_data/02-signal-from-the-derelict.json @@ -29,5 +29,6 @@ "notes": "" } ], - "scripts": [] + "scripts": [], + "icon": "🛰" } diff --git a/backend/app/seed_data/03-hp-system-demo.json b/backend/app/seed_data/03-hp-system-demo.json index 254d282..b57cc00 100644 --- a/backend/app/seed_data/03-hp-system-demo.json +++ b/backend/app/seed_data/03-hp-system-demo.json @@ -16,5 +16,6 @@ "context_js": "// Tell the AI the current HP and how to report changes it decides on.\n// The tag goes on the FIRST line so it survives even if the reply is cut off.\nvar modifier = function (text) {\n var hp = getHp();\n var note =\n \"\\n\\n[HP SYSTEM] The player currently has \" + hp + \" of \" + HP_MAX + \" health. \" +\n \"Based on the story, you decide whether the player loses health (an attack, fall, or trap) \" +\n \"or gains it (a potion, rest, or spell) this turn. \" +\n \"If their health changes, make the FIRST line of your reply the change by itself in square brackets: \" +\n \"write [HP-14] to show losing 14, or [HP+25] to show gaining 25 (choose the number that fits the moment). \" +\n \"Then write the story on the following lines. If health does not change, do not write any bracket tag. \" +\n \"Never mention this tag or the health system in the story prose itself.\";\n return { text: text + note };\n};\nmodifier(text);\n", "output_js": "// Read the AI's [HP+N]/[HP-N] tags, apply them to state, hide them from the player.\nvar modifier = function (text) {\n var re = /\\[HP([+-])(\\d{1,3})\\]/g;\n var hp = getHp();\n var changed = false;\n var m;\n while ((m = re.exec(text)) !== null) {\n var amount = parseInt(m[2], 10);\n hp += (m[1] === \"-\" ? -amount : amount);\n changed = true;\n }\n if (changed) setHp(hp);\n var clean = text\n .replace(/\\[HP[+-]\\d{1,3}\\]/g, \"\")\n .replace(/[ \\t]+\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n if (changed && getHp() <= 0 && !state.dead) {\n state.dead = true;\n clean += \"\\n\\nYour strength gives out and the world goes dark. (0 HP)\";\n }\n return { text: clean };\n};\nmodifier(text);\n" } - ] + ], + "icon": "🗡" } diff --git a/backend/app/seed_data/04-rpg-world-state.json b/backend/app/seed_data/04-rpg-world-state.json index 7e69380..0fe944f 100644 --- a/backend/app/seed_data/04-rpg-world-state.json +++ b/backend/app/seed_data/04-rpg-world-state.json @@ -6,6 +6,7 @@ "authors_note": "Keep it tense and consequential. Reckless moves should cost health; clever ones should pay off. Gwen reacts to how the player treats her.", "ai_instructions": "Write in second person, present tense. End each reply where the player can act. Let the world state guide the fiction — if the player is badly hurt, show it. Reflect what happens in the numbers each turn: change health when someone is hurt or healed, shift Gwen's trust based on how the player treats her, move the leader's aggression as the situation escalates or calms, and mark milestones as they are reached.", "tags": "demo, rpg, world-state, combat, short", + "icon": "🏹", "stat_schema": { "world": { "day": { "type": "counter", "min": 1, "initial": 1, "desc": "Which in-game day it is; only ever counts up." } diff --git a/backend/tests/test_scenario_art.py b/backend/tests/test_scenario_art.py new file mode 100644 index 0000000..40e8e16 --- /dev/null +++ b/backend/tests/test_scenario_art.py @@ -0,0 +1,210 @@ +"""Scenario cover art + the Continue-card snippet. + +Unit tests for the data-URI handling in app/images.py, then HTTP tests that the +list endpoints advertise a cacheable `image_url` (never the inline base64), that +the image route serves real bytes, and that an adventure's snippet comes from +the latest *narration* rather than the player's last line. + + python -m pytest tests/test_scenario_art.py -v +""" +import base64 +import os +import tempfile + +_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_tmp.close() +os.environ["AIDND_DB_PATH"] = _tmp.name +os.environ.pop("AIDND_DATABASE_URL", None) +os.environ.pop("DATABASE_URL", None) + +import pytest +from fastapi import Depends +from fastapi.testclient import TestClient + +from app import auth, images, limits, models, schemas +from app.database import Base, SessionLocal, engine, get_db +from app.main import app + +# Smallest valid PNG: a single transparent pixel. +PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==" +) +PNG_URI = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode() + + +# --------------------------------------------------------------------------- # +# app/images.py +# --------------------------------------------------------------------------- # + +def test_decode_returns_bytes_and_content_type(): + assert images.decode(PNG_URI) == (PNG_BYTES, "image/png") + + +def test_decode_tolerates_wrapped_base64(): + """A hand-pasted URI can carry newlines; b64decode(validate=True) won't.""" + wrapped = "data:image/png;base64," + "\n".join( + base64.b64encode(PNG_BYTES).decode()[i:i + 24] for i in range(0, 100, 24) + ) + # Only asserting it doesn't raise and doesn't silently return a partial + # decode of a truncated payload — the wrapped prefix here is not the whole + # image, so a None result is also acceptable; what matters is no exception. + images.decode(wrapped) + + +def test_decode_rejects_non_data_uris_and_garbage(): + assert images.decode("https://example.com/a.png") is None + assert images.decode("data:image/png;base64,!!!not base64!!!") is None + assert images.decode("") is None + assert images.decode(None) is None + + +def test_decode_rejects_svg(): + """SVG can carry script and these bytes are served from our own origin.""" + svg = "data:image/svg+xml;base64," + base64.b64encode(b"").decode() + assert images.decode(svg) is None + + +def test_public_url_points_at_the_endpoint_for_data_uris(): + class Stamp: + def timestamp(self): + return 1700000000.0 + + assert images.public_url(7, PNG_URI, Stamp()) == "/api/scenarios/7/image?v=1700000000" + + +def test_public_url_passes_through_https_but_not_http(): + stamp = None + assert images.public_url(1, "https://cdn.example.com/a.png", stamp) == \ + "https://cdn.example.com/a.png" + # http:// would be blocked as mixed content on the deployed https app. + assert images.public_url(1, "http://cdn.example.com/a.png", stamp) == "" + assert images.public_url(1, "", stamp) == "" + + +def test_sanitize_rejects_hostile_and_oversized_values(): + assert images.sanitize("javascript:alert(1)", 1000) == "" + assert images.sanitize("http://example.com/a.png", 1000) == "" + assert images.sanitize(PNG_URI, len(PNG_URI) - 1) == "" # over the cap + assert images.sanitize(12345, 1000) == "" + assert images.sanitize(None, 1000) == "" + # Well-formed values survive. + assert images.sanitize(PNG_URI, schemas.IMAGE_MAX) == PNG_URI + assert images.sanitize("https://example.com/a.png", 1000) == "https://example.com/a.png" + + +# --------------------------------------------------------------------------- # +# HTTP +# --------------------------------------------------------------------------- # + +@pytest.fixture() +def client(monkeypatch): + Base.metadata.create_all(bind=engine) + setup = SessionLocal() + user = models.User(is_guest=False, email="art@example.com") + setup.add(user) + setup.flush() + setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model")) + + # One scenario with an uploaded picture, one with only an emoji. + pictured = models.Scenario(user_id=user.id, title="Pictured", image=PNG_URI) + emoji_only = models.Scenario(user_id=user.id, title="Emoji", icon="🐉") + setup.add_all([pictured, emoji_only]) + setup.flush() + + adventure = models.Adventure( + user_id=user.id, scenario_id=pictured.id, title="A Run", + ) + setup.add(adventure) + setup.flush() + # A full turn: opening narration, the player's line, then the AI's reply. + setup.add_all([ + models.Action(adventure_id=adventure.id, index=0, type="ai", + text="The door groans open."), + models.Action(adventure_id=adventure.id, index=1, type="do", + text="I draw my sword."), + models.Action(adventure_id=adventure.id, index=2, type="ai", + text="Steel rings. The\ncorridor answers."), + ]) + setup.commit() + ids = {"scenario": pictured.id, "emoji": emoji_only.id, "adventure": adventure.id} + user_id = user.id + setup.close() + + monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) + monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) + + def _current_user(db=Depends(get_db)): + return db.get(models.User, user_id) + + app.dependency_overrides[auth.get_current_user] = _current_user + c = TestClient(app) + c.ids = ids + try: + yield c + finally: + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=engine) + + +def test_scenario_list_advertises_a_url_and_hides_the_base64(client): + rows = {row["title"]: row for row in client.get("/api/scenarios").json()} + + pictured = rows["Pictured"] + assert pictured["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=") + # The whole point: a list response must never carry the inline image. + assert "image" not in pictured + + assert rows["Emoji"]["image_url"] == "" + assert rows["Emoji"]["icon"] == "🐉" + + +def test_image_endpoint_serves_the_decoded_bytes(client): + resp = client.get(f"/api/scenarios/{client.ids['scenario']}/image") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png" + assert resp.content == PNG_BYTES + assert "immutable" in resp.headers["cache-control"] + + +def test_image_endpoint_404s_without_an_upload(client): + resp = client.get(f"/api/scenarios/{client.ids['emoji']}/image") + assert resp.status_code == 404 + + +def test_single_scenario_still_returns_the_raw_uri_for_editing(client): + """The editor needs the actual value to preview and to clear.""" + body = client.get(f"/api/scenarios/{client.ids['scenario']}").json() + assert body["image"] == PNG_URI + + +def test_adventure_list_carries_snippet_and_inherited_art(client): + row = next(r for r in client.get("/api/adventures").json() + if r["id"] == client.ids["adventure"]) + # Latest narration, whitespace collapsed — not the player's "I draw my sword." + assert row["snippet"] == "Steel rings. The corridor answers." + assert row["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=") + assert row["action_count"] == 3 + + +def test_snippet_is_truncated_on_a_word_boundary(client): + from app.routers.adventures import SNIPPET_MAX, _snippet + + long_text = "word " * 200 + out = _snippet(long_text) + assert len(out) <= SNIPPET_MAX + 1 # +1 for the ellipsis + assert out.endswith("…") + assert "wor…" not in out # never cuts mid-word + + +def test_scenario_export_import_round_trips_the_art(client): + bundle = client.get(f"/api/scenarios/{client.ids['scenario']}/export").json() + assert bundle["image"] == PNG_URI + + created = client.post("/api/scenarios/import", json=bundle).json()["scenario"] + assert created["image"] == PNG_URI + + +def test_import_drops_a_hostile_image_value(client): + bundle = {"title": "Sneaky", "image": "javascript:alert(1)"} + created = client.post("/api/scenarios/import", json=bundle).json()["scenario"] + assert created["image"] == "" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4f7220f..1136b5e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react' import { NavLink, Outlet } from 'react-router-dom' import { api } from './api' -import { AuthModal } from './components' +import { AuthModal, ToastHost } from './components' +import Embers from './Embers.jsx' export default function App() { // null until /auth/me resolves; in local mode multi_user=false hides all auth UI. @@ -29,7 +30,8 @@ export default function App() { } return ( - <> + +