Media Upload System
The Pergola media system provides schema-driven, semantically-grounded file uploads.
Every uploaded file gets a MediaAttachment entity in the Oxigraph RDF store;
thumbnails are served through a controlled proxy backed by picturium.
Architecture
Browser
│ POST /api/media/ (multipart)
▼
Pergola API (Hono)
├─► Writes file ──────────► pergola_media volume ◄──── picturium reads from here
└─► Upserts MediaAttachment ──► Oxigraph (SPARQL)
Browser
│ GET /api/media/:base64iri/thumb/:size
▼
Pergola API
├─ Loads MediaAttachment from Oxigraph (gets storedPath, mimeType)
├─ SVG? ──► streams file directly
└─ Other ──► buildPicturiumUrl() with HMAC token ──► picturium ──► stream back
Key design decisions:
- Picturium is not exposed externally — only the API can query it
- Only a fixed set of thumbnail sizes is allowed (
icon,small,medium,large,xlarge) - SVG files bypass picturium (served directly to avoid unwanted rasterisation)
- Uploads are optimistic: a file can be uploaded before the parent entity is created
- Orphaned files (no
attachedToreference) can be cleaned up by a scheduled SPARQL DELETE
API Endpoints
POST /api/media/
Upload a file. Accepts multipart/form-data.
| Field | Required | Description |
|---|---|---|
file |
yes | The file to upload |
altText |
no | Accessibility description |
copyright |
no | Copyright notice |
attachedTo |
no | IRI of the owning entity |
Supported MIME types: image/jpeg, image/png, image/webp, image/gif, image/svg+xml, image/avif, application/pdf
Response 201:
{
"@id": "https://data.semantic-desk.top/garden/Media/{uuid}",
"@type": "https://ontology.semantic-desk.top/garden#MediaAttachment",
"encodedIRI": "<base64url-encoded IRI>",
"name": "photo.jpg",
"storedPath": "uploads/{uuid}.jpg",
"mimeType": "image/jpeg",
"fileSize": 204800,
"uploadedAt": "2026-05-22T10:00:00.000Z"
}
Error responses: 400 missing file · 413 too large · 415 unsupported type
GET /api/media/:base64iri/thumb/:size
Fetch a thumbnail. The :base64iri is the encodedIRI field from the upload response
(or compute it as btoa(iri) with +→-, /→_, trailing = stripped).
| Size | Pixel width |
|---|---|
icon |
64 px |
small |
128 px |
medium |
320 px |
large |
640 px |
xlarge |
1 200 px |
The response is a real image (JPEG/WebP/AVIF chosen by picturium) with
Cache-Control: public, max-age=86400. SVG files are returned as-is with a
one-year immutable cache header.
DELETE /api/media/:base64iri
Removes the file from disk and the MediaAttachment entity from Oxigraph.
If the file is already gone from disk the SPARQL entity is still cleaned up.
Adding image upload to a form
Annotate any string field in a UISchema with options.customRenderer: "ImageUploadRenderer":
{
type: 'Control',
scope: '#/properties/coverImage',
options: {
customRenderer: 'ImageUploadRenderer',
displaySize: 'large', // thumbnail size shown in the form
maxSizeBytes: 5242880, // 5 MB client-side guard
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
allowSvg: false, // set true to also accept SVG
},
}
The field value stored in form data is the full semantic IRI of the MediaAttachment
entity. When the form is saved this IRI can be written as an RDF reference from
the parent entity.
Global API base configuration
The upload URL is injected globally via MediaConfigProvider — the renderer never
hard-codes a path:
// App root (already done inside GraviolaProvider):
<MediaConfigProvider mediaApiBase="/api/media">
<App />
</MediaConfigProvider>
The default value is VITE_MEDIA_API_URL env var if set, otherwise /api/media.
In Storybook, StoryProvider.tsx supplies the absolute dev URL
(https://dev01.pergola.kuenste.live/api/media) so stories upload to the real API.
UISchema options reference
| Option | Type | Default | Description |
|---|---|---|---|
displaySize |
"icon" \| "small" \| "medium" \| "large" \| "xlarge" |
"medium" |
Which thumbnail preset to show in the form |
maxSizeBytes |
number |
5242880 |
Client-side size guard (server also enforces 10 MB hard limit) |
allowedTypes |
string[] |
["image/jpeg","image/png","image/webp","image/gif"] |
Accepted MIME types shown to the browser |
allowSvg |
boolean |
false |
Include image/svg+xml in the accepted types |
apiBase |
string |
(from MediaConfigProvider) |
Per-field override — use when a specific field should upload to a different endpoint |
Data model — MediaAttachment
Every uploaded file is stored as a named RDF entity:
| Property | Type | Description |
|---|---|---|
@id |
IRI | https://data.semantic-desk.top/garden/Media/{uuid} |
@type |
IRI | https://ontology.semantic-desk.top/garden#MediaAttachment |
name |
string | Original filename (Graviola label) |
storedPath |
string | Relative path in the media volume, e.g. uploads/abc.jpg |
mimeType |
string | e.g. image/jpeg |
fileSize |
integer | Bytes |
uploadedAt |
datetime | ISO 8601 |
altText |
string? | Accessibility text |
copyright |
string? | Copyright notice |
width |
integer? | Pixel width (future: extracted on upload) |
height |
integer? | Pixel height (future: extracted on upload) |
attachedTo |
IRI ref? | Reference to the owning entity |
Picturium integration
Picturium is a Rust image server built on libvips.
It runs as an internal Docker service (internal-dev network only) and is never
reached directly by browsers.
The API generates an HMAC-SHA256 token for each request using the shared PICTURIUM_KEY
secret. This prevents arbitrary resize queries from reaching picturium.
Token computation (matches picturium's src/crypto.rs):
token = HMAC-SHA256(path + "?" + sorted_params_excluding_token, PICTURIUM_KEY)
The proxy module (watering-api/src/picturium/index.ts) is self-contained and has
no Hono or store dependencies — it can be extracted to a shared package later.
Extending to other file types
To support PDFs or other formats:
- Add the MIME type to
ALLOWED_MIME_TYPESinwatering-api/src/schema/media-attachment.ts - Add the extension mapping to
MIME_TO_EXT - Handle the new type in the thumbnail proxy route (e.g. PDF →
?thumb=p:1for first page) - Update the
allowedTypesdefault inuseImageUpload.tsif needed