Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Media plugin

Where uploaded bytes go, which backend puts them there, and how they come back out.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Folders and assets for the Media Library, over a provider-agnostic storage seam.

  • Bytes never live in the database. An asset row stores storage_key and storage_provider.
  • One StorageProvider per deployment, constructed in plugins.ts. No registry, no resolver.

Install

npm install @orthacms/media-server @orthacms/media-admin
# plus exactly one backend
npm install @orthacms/media-provider-s3
BackendPackageidSigned URLs
Local disk@orthacms/media-provider-locallocalno
S3-compatible@orthacms/media-provider-s3s3yes
Azure Blob@orthacms/media-provider-azureazurewith a shared key
Google Cloud@orthacms/media-provider-gcsgcswith a key or IAM
Vercel Blob@orthacms/media-provider-vercel-blobvercel-blobno
In-memory@orthacms/media-provider-memorymemoryno — tests only
Your own—you chooseyou 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=300

Backend 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 id or no capabilities.
  • capabilities.directUrl: true without a directUrl() method.
  • MEDIA_DIRECT_SERVE=signed-url on a backend with directUrl: false.
  • verify() fails (wrong bucket, bad credentials, dead endpoint).
  • media_asset holds 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):

Example language
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, with X-Content-Type-Options: nosniff and a sandboxing Content-Security-Policy.
  • MEDIA_DIRECT_SERVE=signed-url: 302, Location: a short-lived backend URL, Cache-Control: private, no-store.
  • ?variant=thumb picks a derivative.
  • Missing, or in another workspace: 404 either 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-Disposition and Content-Type on the URL may declare it. A redirect drops the app’s own headers, and an uploaded .html served inline from a bucket is stored XSS.
  • S3 declares directUrl: true always. 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.

RoutePermissionWhat it does
GET /api/media/foldersmedia:readFolder tree, flat, with asset counts
POST /api/media/foldersmedia:createCreate a folder
PATCH /api/media/folders/:idmedia:updateRename
DELETE /api/media/folders/:idmedia:deleteDeletes the whole subtree, assets included
GET /api/media/assetsmedia:readfolderId, search, kind, sort, page, pageSize
POST /api/media/assetsmedia:createMultipart file, optional folderId, alt
POST /api/media/assets/:id/duplicatemedia:createCopies bytes and row
PATCH /api/media/assets/:idmedia:updateName, folder, tags, alt text, caption tracks
DELETE /api/media/assetsmedia:deleteBulk, body { "ids": [...] }
GET /api/media/assets/:id/rawmedia:readBytes; ?variant=
GET /api/insights/media/storage, /uploads?days=, /altmedia:readThe Insights figures
GET /api/insights/media/alt/missing?limit=media:readImages 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:

RoutePermissionNotes
POST /api/v1/media/assetsmedia:create (full token)?folderId=, ?alt=. Attributed to the token’s creator
GET /api/v1/media/assets/:id/rawmedia: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.