Figures¶
A figure is a picture hung on one section of one article. The picture itself is a media asset that lives in an image library and may be hung on several articles at once.
The three ways one arrives¶
| Route | Is |
|---|---|
POST /articles/{id}/figures |
A rendered diagram. A spec goes in, an SVG comes out. |
POST /articles/{id}/figures/image |
An uploaded still or short clip. |
POST /articles/{id}/figures/existing |
An asset already in the library, placed on a second section — the same pathway diagram in both the long and the clinical view — with no second upload and no duplicate row. |
All three end in the same place: a markdown image line written into the section, a link row recorded, a revision snapshotted, the link rows reconciled against the actual markdown, and the article reindexed.
Rendered diagrams are checked, not trusted¶
A generated SVG is parsed and run against an allow-list before it is stored.
Refused: <script>, <foreignObject>, <iframe>, <image>, <use>, any
event handler attribute, and any url() or remote reference that is not a
fragment. A viewBox is required; a missing xmlns is injected. Maximum
60,000 bytes.
Uploads¶
| Rule | Value |
|---|---|
| Still images | PNG, JPEG, WebP — up to 8 MiB |
| Clips | MP4, WebM, silent — up to 25 MiB |
| Type | Sniffed from the file's magic bytes. The client's content-type is never trusted. |
| Caption | Required. It becomes the alt text. |
| Credit | Required when the licence is one that demands attribution — cc by, cc-by, ccby, gfdl, attribution. Without it the upload is refused. |
| A section already holding a figure | Refuses a second one unless replace=true. |
A no-derivatives licence is recorded and blocks the image-overlay tool.
What an asset records¶
path, title, caption, alt_text, source (the credit line),
source_url, license, overlay (vector shapes drawn on top), kind
(image or video), modality, spec (the diagram spec it was rendered
from, so it can be re-rendered), library_id, storage, byte_size,
category_id, deleted_at.
Modality is recorded by a person, never inferred from where the file came from:
radiograph · ultrasound · ct · mri · fluoroscopy · nuclear ·
photograph · histology · illustration · diagram · chart
Licence and credit live on the asset, not in the prose, so the same figure used in three articles is cited the same way in all three.
The slideshow¶
Opening a figure full-screen fetches the asset and a gallery: every figure hanging on the same article, ordered by where each appears in the markdown — reading order, not the order they were attached. With fewer than two figures there is no gallery and the viewer is a single image.
With two or more, the viewer becomes a slideshow: previous and next, arrow keys, swipe, and a collapsible thumbnail filmstrip along the foot. It opens positioned on whichever figure was clicked.
Clips play silently, looped, without controls inline; the viewer gives them
transport controls. With prefers-reduced-motion set, autoplay is off and a
static first frame is shown with manual controls instead.
The description panel — title, modality, where in the article it sits, caption, source and licence — is collapsible. A star saves the open figure to one of the learner's collections.
Where the bytes are¶
Two backends behind one interface, chosen by STORAGE_BACKEND:
local— the container volume atUPLOAD_DIRs3— MinIO or any S3-compatible service, path-style addressing
A stored value is always a key, never a URL: a row that hardcodes
http://minio:9000/… breaks the moment the backend changes. With S3 active,
reads try the bucket first and fall back to the local volume for objects
not yet migrated, which is how existing uploads keep working without a
migration pass.
A URL is formed as /uploads/{key} and served by a route that authenticates —
a bearer token, or a pedshub_media cookie for a native <img> or <video>
tag that cannot send an Authorization header — and authorises per file.
?w=256 and ?w=640 return a WebP derivative, made once and cached under
thumbs/{width}/{key} in the same bucket, never upscaled.
Caching is private, no-store for originals and private, max-age=604800,
immutable for derivatives. Never a shared or CDN cache — that is how one
learner is served another's private figure.
The bin¶
See What can be undone
for the full rule. In short: a grant holder unlinks, a moderator can bin, the
bin holds for 30 days, and only the daily purge_trashed_media job removes
bytes.
Restoring within the window re-appends the figure line to every section it used to be in — at the end of the section, not necessarily where it was, since the section may have been rewritten meanwhile.