Skip to content

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 at UPLOAD_DIR
  • s3 — 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.