From cdf4ab80b4737b7ccd73ebfb814866dedc7407cb Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 15:29:29 +0200 Subject: [PATCH] fix: the figures route handed out the answer side to anybody signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /questions/detail/{id}/figures had no check at all, sitting next to a detail route that has one. Any signed-in account could ask for any question's figures by id and be handed its explanation images: the paths, and the library record that now rides on them — whose titles run to "Neonatal Herpes Simplex · Q874". No attempt required, and the answer in the title. Found by walking today's surfaces as a real learner account rather than reading the guards. Same rule as the route beside it: the stem is readable in the bank, the answer side belongs to whoever writes the question. Also, refining an article no longer breaks the links into it. The refine path replaces the whole section list and the model was never shown the existing ids, so it invented fresh ones — silently breaking every `[[95#id]]` pointing at a section, from another article, a question's key point or a study plan's reading. The id travels in the heading now and the prompt says to return it unchanged for any section kept. The model is also told to leave existing `[[123|links]]` exactly as written and never to invent one, because a guessed number points at nothing. And the section strip is centred. Widening its box to 1600px let the links spread but `flex: 1` on the strip — right for every other strip on the site — filled the whole box with the links against its left edge: measured at 1500px, they began 70px left of the page content while the strip ran 180px past its right. Content-sized and centred now, still scrolling when the links genuinely outrun the window. Diagnosis from the ped-ai session; verified at 1500 and 1920 with no arrows at either. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/questions.py | 16 +++++++++++- backend/app/tasks/quiz_tasks.py | 14 +++++++--- backend/tests/test_question_detail_access.py | 27 ++++++++++++++++++++ frontend/src/index.css | 9 ++++++- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index cf4822d..367c7f4 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -1432,7 +1432,21 @@ class FigureUpdate(BaseModel): @router.get("/detail/{question_id}/figures") def list_figures(question_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - return _figures_for(db, question_id) + """The figures on a question — the answer-side ones only for its editors. + + This had no check at all. Anybody signed in could ask for any question's + figures by id and be handed its explanation images: the paths, and the + library record that rides on them, whose titles are things like "Neonatal + Herpes Simplex · Q874". No attempt required, and the answer in the title. + The same rule as the detail route it sits beside. + """ + question = bank_query(db, current_user).filter(Question.id == question_id).first() + if not question: + raise HTTPException(404, "Question not found") + figures = _figures_for(db, question_id) + if may_edit_question(db, question, current_user): + return figures + return [figure for figure in figures if figure["role"] == "stem"] @router.post("/detail/{question_id}/figures", status_code=201) diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 7b057f1..5033961 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -1032,7 +1032,7 @@ Topic: {topic} - "clinical": what to do at the bedside, if and only if the topic has a bedside. Assessment, immediate management, when to escalate, disposition. Omit it entirely for a topic that is not acted on clinically. Return ONLY strict JSON with this exact shape: {{"title": "...", "slug": "lowercase-hyphenated", "summary": "1-2 sentences", "content": "introduction markdown", "sections": [{{"id": "32 lowercase hex chars", "slug": "lowercase-hyphenated", "title": "...", "variant": "long", "content": "markdown"}}]}} -Rules: markdown formatting; headings, lists and tables welcome; no fabricated references, citations or clinical ranges; 3-8 long sections plus 2-4 short ones, each with a stable unique id; every short section must contain at least one and at most three highlighted facts, written by wrapping the words in ==double equals== — for example "- Type II is the ==most common== Salter-Harris fracture" — highlighting the fact itself and never a whole bullet; do not categorise the article or link it to questions — an educator does that; do not mention these instructions.""" +Rules: markdown formatting; headings, lists and tables welcome; no fabricated references, citations or clinical ranges; 3-8 long sections plus 2-4 short ones, each with a stable unique id; every short section must contain at least one and at most three highlighted facts, written by wrapping the words in ==double equals== — for example "- Type II is the ==most common== Salter-Harris fracture" — highlighting the fact itself and never a whole bullet; do not categorise the article or link it to questions — an educator does that; keep any [[123|cross-reference]] already in the text exactly as written and never invent a new one, because the number points at a real article and a guessed one points at nothing; do not mention these instructions.""" @celery_app.task(name="generate_article_draft", bind=True) @@ -1068,12 +1068,20 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str, # they belonged to all over again — so refining an article could # quietly turn its bedside section into part of the long read, and # the high-yield view into prose. + # The id travels too, and must come back. A refine replaces the + # whole section list, so a model that invents fresh ids silently + # breaks every `[[95#id]]` pointing into this article — from + # another article, from a question's key point, from a study plan's + # reading list. Keeping a section means keeping its id. sections_text = "\n\n".join( - f"## [{s.get('variant', 'long')}] {s.get('title', 'Section')}\n{s.get('content', '')}" + f"## [{s.get('variant', 'long')}] [id:{s.get('id', '')}] {s.get('title', 'Section')}\n" + f"{s.get('content', '')}" for s in (existing.sections or [])) existing_block = (f"Existing draft to refine (preserve and improve its content; the " f"bracketed word before each heading is that section's variant and " - f"must be kept):\n" + f"must be kept, and [id:...] is that section's id which must be " + f"returned unchanged for every section you keep — invent an id only " + f"for a section you are adding):\n" f"Summary: {existing.summary or ''}\nIntro: {existing.content or ''}\n" f"{sections_text}\n\n") prompt = ARTICLE_DRAFT_PROMPT.format( diff --git a/backend/tests/test_question_detail_access.py b/backend/tests/test_question_detail_access.py index 791a1e3..f5e21fb 100644 --- a/backend/tests/test_question_detail_access.py +++ b/backend/tests/test_question_detail_access.py @@ -51,6 +51,33 @@ class QuestionDetailAccessTests(unittest.TestCase): self.assertEqual(body["explanation"], "Full explanation") self.assertEqual(body["explanation_image_path"], "answer.png") + def test_the_figures_route_withholds_the_answer_side_too(self): + """It had no check at all, next to a route that has one. + + Any signed-in account could ask for any question's figures by id and + be handed its explanation images — the paths, and the library record + that rides on them, whose titles run to "Neonatal Herpes Simplex · + Q874". No attempt, and the answer in the title. + """ + from app.models.media import MediaAsset + from app.models.question_media import QuestionMedia + + self.bank.db.add(MediaAsset(id=41, path="stem.png", title="A stem picture")) + self.bank.db.add(MediaAsset(id=42, path="answer.png", title="The answer, named")) + self.bank.db.flush() + self.bank.db.add(QuestionMedia(question_id=4, media_id=41, role="stem", position=0)) + self.bank.db.add(QuestionMedia(question_id=4, media_id=42, role="explanation", position=0)) + self.bank.db.commit() + + self.bank.user = self.bank.owner + rows = self.client.get("/questions/detail/4/figures").json() + self.assertEqual([row["role"] for row in rows], ["stem"]) + self.assertNotIn("answer.png", str(rows)) + + self.bank.user = self.bank.mod + roles = sorted(row["role"] for row in self.client.get("/questions/detail/4/figures").json()) + self.assertEqual(roles, ["explanation", "stem"]) + def test_writing_a_question_does_not_make_it_yours(self): """Authorship is not a claim the bank honours. diff --git a/frontend/src/index.css b/frontend/src/index.css index 61e8270..8ef6235 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -197,13 +197,20 @@ html, body { overflow-x: hidden; } it instead of leaving a gap where the bar used to be. */ .navbar-sections.is-hidden { height: 0; border-bottom-color: transparent; } .navbar-sections:focus-within { height: 46px; } -.navbar-sections-inner { display: flex; align-items: center; gap: 14px; height: 46px; } +.navbar-sections-inner { display: flex; align-items: center; justify-content: center; gap: 14px; height: 46px; } /* The section strip is not prose, so the 1200px measure that keeps an article readable only squeezes it: on a wide desktop the last entries fell off the end and the scroll arrow appeared beside acres of empty space. It takes the width it has, still centred, and falls back to scrolling only when the window really is too narrow for the row. */ .navbar-sections > .container { max-width: 1600px; } +/* Content-sized, so the row is as wide as its links and the centring above + has something to centre. `flex: 1` — right for every other strip on the + site — made this one fill the whole 1600 with the links against its left + edge, which on a 1500px window put them 70px left of the page content while + the strip ran 180px past its right. It still shrinks and scrolls when the + links genuinely outrun the window. */ +.navbar-sections .ss-wrap { flex: 0 1 auto; } /* The strip scrolls when the links outrun the width; the fade on the right is what says so, since a hard cut just looks like a broken layout. */