Folders and assets for the Media Library, over a provider-agnostic storage seam.
- Bytes never live in the database. An asset row stores
storage_keyandstorage_provider. - One
StorageProviderper deployment, constructed inplugins.ts. No registry, no resolver.
Install
npm install @orthacms/media-server @orthacms/media-admin
# plus exactly one backend
npm install @orthacms/media-provider-s3| Backend | Package | id | Signed URLs |
|---|---|---|---|
| Local disk | @orthacms/media-provider-local | local | no |
| S3-compatible | @orthacms/media-provider-s3 | s3 | yes |
| Azure Blob | @orthacms/media-provider-azure | azure | with a shared key |
| Google Cloud | @orthacms/media-provider-gcs | gcs | with a key or IAM |
| Vercel Blob | @orthacms/media-provider-vercel-blob | vercel-blob | no |
| In-memory | @orthacms/media-provider-memory | memory | no — tests only |
| Your own | — | you choose | you declare |
create-orthacms-app asks which backend to use and generates
config/media-storage.ts for it.
Configure
Plugin-wide variables, read by both the reference server and a generated app:
# .env
# upload cap in bytes (50 MB). Exactly this many is accepted; one more is a 413.
MEDIA_MAX_UPLOAD_BYTES=52428800
# "signed-url" redirects downloads to the backend; anything else = proxy (default)
MEDIA_DIRECT_SERVE=off
MEDIA_DIRECT_SERVE_TTL_SECONDS=300Backend variables are on each backend’s page. The reference server,
apps/server/config/media.ts:
import type { MediaPluginConfig } from '@orthacms/media-server';
import type { LocalStorageConfig } from '@orthacms/media-provider-local';
import { readEnv, readPositiveInt } from '@orthacms/utils-server';
export interface OrthaCmsMediaConfig extends MediaPluginConfig {
storage: LocalStorageConfig; // swap with the factory, e.g. S3StorageConfig
}
export function mediaConfig(): OrthaCmsMediaConfig {
return {
storage: { rootDir: readEnv('MEDIA_LOCAL_ROOT') ?? './.storage/media' },
directServe: readEnv('MEDIA_DIRECT_SERVE') === 'signed-url' ? 'signed-url' : 'off',
directServeTtlSeconds: readPositiveInt('MEDIA_DIRECT_SERVE_TTL_SECONDS', 300),
maxUploadBytes: readPositiveInt('MEDIA_MAX_UPLOAD_BYTES', 52_428_800)
};
}A generated app splits storage into config/media-storage.ts, one body per
backend.
Register
// apps/server/src/plugins.ts — after WorkspacesPlugin and IdentityPlugin
import { MediaServerPlugin } from '@orthacms/media-server';
import { createS3StorageProvider } from '@orthacms/media-provider-s3';
MediaServerPlugin({
provider: createS3StorageProvider(config.plugins.media.storage),
config: config.plugins.media
});// apps/admin/src/plugins.ts
import { MediaPlugin } from '@orthacms/media-admin';
MediaPlugin();The admin half adds the workspace’s Media Library and the control a
media field renders as. Since 0.12.0 that control sits inline in the entry
editor’s one Content form, in the schema’s order, through content’s
ENTRY_FIELD_CONTROL_SLOT, instead of on a tab of its own. Files picked there
are still uploaded on Save, not before.
Switching backend = swapping the factory, the storage type, and the env vars.
Boot refusals:
- A provider with no
idor nocapabilities. capabilities.directUrl: truewithout adirectUrl()method.MEDIA_DIRECT_SERVE=signed-urlon a backend withdirectUrl: false.verify()fails (wrong bucket, bad credentials, dead endpoint).media_assetholds rows written by a different provider id:
Error: The media library holds assets written by a different storage provider: "local" (42 assets). This deployment is configured with "s3", which cannot read them — their bytes live in the other backend. …A missing media_asset table is skipped, so a fresh database boots before
migrations.
Switching backend strands existing assets
Move the blobs and rewrite media_asset.storage_provider yourself before the
new process will start. No migration tool ships (ADR-0012).
Verify
Upload one file and read it back. With an API token (full scope):
curl -s 'http://localhost:3000/api/v1/media/assets?alt=Test%20image' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f6c9a8e-0000-4000-8000-000000000001' \
-F file=@./kingfisher.jpg const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/jpeg' }), 'kingfisher.jpg');
const response = await fetch('http://localhost:3000/api/v1/media/assets?alt=Test%20image', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'X-Workspace-Id': workspaceId
},
body: form
});
const asset = await response.json(); {
"id": "b1e0c6d2-…",
"name": "kingfisher.jpg",
"folderId": null,
"kind": "image",
"mimeType": "image/jpeg",
"size": 184223,
"url": "/api/media/assets/b1e0c6d2-…/raw",
"variants": ["thumb", "preview"],
"width": 1600,
"height": 1067,
"alt": "Test image",
"…": "…"
} X-Workspace-Id is optional when the token covers exactly one workspace.
Download it:
curl -si http://localhost:3000/api/v1/media/assets/b1e0c6d2-…/raw \
-H 'Authorization: Bearer orthacms_…' | head -12- Proxied (default):
200, withX-Content-Type-Options: nosniffand a sandboxingContent-Security-Policy. MEDIA_DIRECT_SERVE=signed-url:302,Location:a short-lived backend URL,Cache-Control: private, no-store.?variant=thumbpicks a derivative.- Missing, or in another workspace:
404either way.
Then check the object landed where you expect. Keys are
[keyPrefix/]<workspaceId>/<assetId>/<file-name>, derivatives under
…/<assetId>/variants/.
Direct serve
directServe: 'signed-url' answers a download with a 302 to the backend,
instead of streaming bytes through Nest.
- It runs after authorization, never instead of it.
- Only a backend that pins
Content-DispositionandContent-Typeon the URL may declare it. A redirect drops the app’s own headers, and an uploaded.htmlserved inline from a bucket is stored XSS. - S3 declares
directUrl: truealways. Azure and GCS compute it from how they authenticated.
Routes
Session routes under /api/media need X-Workspace-Id. State-changing ones
also check Origin.
| Route | Permission | What it does |
|---|---|---|
GET /api/media/folders | media:read | Folder tree, flat, with asset counts |
POST /api/media/folders | media:create | Create a folder |
PATCH /api/media/folders/:id | media:update | Rename |
DELETE /api/media/folders/:id | media:delete | Deletes the whole subtree, assets included |
GET /api/media/assets | media:read | folderId, search, kind, sort, page, pageSize |
POST /api/media/assets | media:create | Multipart file, optional folderId, alt |
POST /api/media/assets/:id/duplicate | media:create | Copies bytes and row |
PATCH /api/media/assets/:id | media:update | Name, folder, tags, alt text, caption tracks |
DELETE /api/media/assets | media:delete | Bulk, body { "ids": [...] } |
GET /api/media/assets/:id/raw | media:read | Bytes; ?variant= |
GET /api/insights/media/storage, /uploads?days=, /alt | media:read | The Insights figures |
GET /api/insights/media/alt/missing?limit= | media:read | Images with no alt text, oldest upload first (20 by default, at most 50) |
On Insights, since 0.13.0, media adds the Media storage tile on Overview, Storage and Uploads on Localisation & media, and Images missing alt text as a line under Needs attention, whose panel lists the images behind it.
The session raw route needs no X-Workspace-Id (an <img src> cannot send
it). It checks membership of the asset’s own workspace; a non-member gets 404.
Bearer-token routes for the public API:
| Route | Permission | Notes |
|---|---|---|
POST /api/v1/media/assets | media:create (full token) | ?folderId=, ?alt=. Attributed to the token’s creator |
GET /api/v1/media/assets/:id/raw | media:read (either scope) | ?variant= |
Rename, move and delete are session-only.
Downloads are hostile bytes
The stored MIME type is the uploader’s claim. Every download carries nosniff
and a sandboxing CSP. inline only for raster images, audio, video, PDF and
text/plain. HTML, XML, SVG and anything unknown are attachment.
Backups
Blobs are not in the database. Back up the storage backend alongside it, to a consistent point.