fix: the figures route handed out the answer side to anybody signed in
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 34s
Tests / e2e (push) Has been cancelled

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 15:29:29 +02:00
parent deb8ee7830
commit cdf4ab80b4
4 changed files with 61 additions and 5 deletions

View file

@ -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)

View file

@ -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(

View file

@ -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.

View file

@ -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. */