TentoCMS
Api

Media

Serving media files and the image transformation parameters.

Version: 1.0 Base URL: /api/v1Authentication: Not required for public media serving

For uploading and managing media (admin), see the Media Admin API.


Overview

The public Media endpoint serves media assets — images, documents, and videos — over a fast, CDN-cached URL, with optional on-the-fly image transformations. Serving requires no authentication; isolation relies on the URL being unguessable (UUID filenames), not on server-side validation of the project ID against the requester. See Security Considerations for details.

Key Features:

  • Serve media publicly with image transformations
  • Cloudflare R2 storage backend
  • Cloudflare Image Resizing integration
  • 1-year immutable cache for optimal performance

To upload, organise, update, or delete media (all of which require an authenticated admin session), see the Media Admin API.


Table of Contents

  1. Authentication
  2. Public Endpoints
  3. Data Models
  4. Error Handling
  5. Rate Limiting
  6. Best Practices

Authentication

The public media serving endpoint (GET /media/:projectId/:filename) requires no authentication — anyone with the URL can retrieve the file. The project ID embedded in the URL is used as-is to build the tenant-prefixed R2 key, with no cross-check against an authenticated session or API key; isolation instead relies on UUID-based filenames being unguessable, which prevents URL enumeration. See Security Considerations for details.

For the rest of the public content API (fetching pages, collections, and their resolved media), requests are authenticated with an API key. See the Public REST API for details.

Admin media operations (upload, update, move, delete) use session-cookie authentication and are documented separately in the Media Admin API.


Public Endpoints

GET /media/:projectId/:filename

Serve a media file publicly with optional image transformations.

Authentication: Not required Rate Limit: no per-key in-Worker limit — this endpoint is unauthenticated, so there is no API key to key a limit on. It is covered only by the coarse per-IP Cloudflare WAF backstop (live since 2026-07-13); Cloudflare's CDN cache further reduces how often requests reach the origin. Figures: Limits & Errors. This matches the per-endpoint breakdown under Rate limiting below.

Path Parameters

ParameterTypeRequiredDescription
projectIdstringYesProject UUID; used as-is to build the R2 key (projectId/filename) — not cross-checked against any session or API key. See Security Considerations.
filenamestringYesGenerated filename (from R2 key)

Query Parameters (Image Transformations)

Only apply to image files (image/* MIME types). Non-images ignore transform params.

ParameterTypeDescriptionExample Values
widthnumberTarget width in pixels800, 1200
heightnumberTarget height in pixels600, 800
fitstringResize fit modecontain, cover, crop, scale-down
qualitynumberImage quality (1-100)80, 90
formatstringOutput formatwebp, avif, auto

Fit Modes:

  • contain: Preserve aspect ratio, image fits within dimensions (may have whitespace)
  • cover: Preserve aspect ratio, fills dimensions (may crop)
  • crop: Crop to exact dimensions
  • scale-down: Like contain but never upscale

Request Examples

Original image:

https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg

Thumbnail (200x200 cover crop):

https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg?width=200&height=200&fit=cover

Note: This does not preserve the original .jpg format. Supplying any transform parameter without an explicit format still re-encodes the output — the default output format is webp, not the source format. Pass &format=avif for AVIF, or see Fallback below for when the original bytes are served unchanged.

Responsive image (800px wide, WebP):

https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg?width=800&format=webp&quality=85

Hero image (1920px, scale-down, AVIF):

https://cms.example.com/api/v1/media/tenant-slug/abc123-def456.jpg?width=1920&fit=scale-down&format=avif

Response (200 OK)

Headers (untransformed / fallback path — no transform params, or a non-image file):

Content-Type: image/jpeg
Cache-Control: public, max-age=31536000
ETag: "<r2-object-etag>"

Headers (successfully transformed image): the Cloudflare Images binding's own response headers are passed through, with Cache-Control overridden to public, max-age=31536000. ETag is not guaranteed on this path — the binding does not necessarily emit one, so don't rely on it for conditional requests against transformed URLs. Only the untransformed/fallback response above is guaranteed to carry the R2 object's ETag.

Body: Binary image data (original or transformed)

Cloudflare Image Resizing

If transform params (width, height, fit, quality, format) are present on an image request:

  1. The Worker reads the file's bytes from R2 and passes them to the Cloudflare Images binding (env.IMAGES.input(...).transform(...).output(...)) — not by setting a cf: { image } option on the Response object. The code's own comment calls out that cf.image on a Response is a documented no-op; it only has an effect on a fetch() subrequest, which this route doesn't make.
  2. The binding transforms the image bytes directly and returns the result.
  3. Cloudflare's CDN caches the resulting response at edge locations (via the Cache-Control header above).
  4. Subsequent requests for the same transformed URL are served from cache.

Fallback: If the Images binding throws (e.g. unsupported input), the original, untransformed bytes are served instead (graceful degradation) — this response uses the plain R2 headers shown above, including ETag.

Caching Behavior

  • Cache-Control: public, max-age=31536000 (1 year)
  • Immutable content: Generated filenames (UUIDs) never change
  • Cache invalidation: Not needed (UUID ensures unique URLs)
  • Cloudflare CDN: Caches at edge locations globally

Error Responses

404 Not Found - Media doesn't exist or tenant mismatch

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Media not found"
  }
}

Note: No authentication required means anyone with the URL can access media. Isolation relies on the URL being unguessable (UUID filename), not on server-side validation of :projectId against the requester.

Security Considerations

  • Tenant Isolation: R2 key is projectId/filename — the URL's :projectId segment is used as-is to build the key, with no cross-check against an authenticated session
  • URL Enumeration: UUID filenames prevent guessing
  • No Directory Listing: R2 bucket not publicly browsable
  • Content-Type: Set correctly to prevent execution (e.g., SVG as image/svg+xml)

Data Models

Media shapes by context (read this first)

Media is represented two different ways depending on where you get it. Mixing them up is a common migration snag:

ContextShapeKey fieldsHas id?Notes
Inline media resolved inside page/collection contentTentoMedia (underscore-prefixed)_url, _filename, _mimeType, _width?, _height?, _altText?NoUse client.media.transformUrl(_url, …) for transforms — there's no id. _altText is omitted when unset; SVGs omit _width/_height. An empty image field can come back as "" (so a field is TentoMedia | "" | undefined).
GET /api/v1/media/:id (metadata) and admin list/getMedia (non-underscore)id, filename, originalFilename, mimeType, size, width?, height?, altText?, folder?, url, createdAt, updatedAtYesRender from the returned url (it points at the served bytes and carries any transform params); fetch it via client.media.getById(id). Note: /api/v1/media/:id returns JSON metadata, not image bytes — don't point an <img src> at it (or at getImageUrl(id)) directly.

So: in content, code against TentoMedia (_url, no id); from the media API, code against the Media object (id, url). Don't expect _altText/_width/_height to always be present on inline media.

Media

Complete media object returned by API endpoints.

interface Media {
  id: string                // UUID
  projectId: string          // Project UUID
  filename: string          // Generated filename (UUID + ext)
  originalFilename: string  // User's original filename
  mimeType: string          // MIME type (e.g., "image/jpeg")
  size: number              // File size in bytes
  r2Key: string             // R2 storage key (tenant/filename)
  altText?: string          // Alt text for images (max 500 chars)
  folder: string            // Folder path (empty string = root)
  metadata?: MediaMetadata  // Additional metadata
  createdBy: string         // User UUID who uploaded
  createdAt: string         // ISO 8601 timestamp
  updatedAt: string         // ISO 8601 timestamp
  deletedAt?: string | null // Soft delete timestamp
}

MediaMetadata

Optional metadata stored as JSON in D1.

interface MediaMetadata {
  width?: number       // Image width in pixels
  height?: number      // Image height in pixels
  duration?: number    // Video duration in seconds
  [key: string]: unknown  // Extensible for future metadata
}

Note: Current implementation does not extract image dimensions. Future enhancement to use Image Resizing format=json endpoint.

MediaUsage

Usage tracking response.

interface MediaUsage {
  pages: PageRef[]
}

interface PageRef {
  id: string      // Page UUID
  name: string    // Page name
  slug: string    // Page slug
}

Error Handling

Error Response Format

All error responses follow this structure:

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message"
  }
}

Error Codes

CodeHTTP StatusDescription
UNAUTHORIZED401Missing or invalid credentials
FORBIDDEN403Insufficient permissions
NOT_FOUND404Resource not found or wrong tenant
HAS_REFERENCES409Cannot delete media — still referenced by pages/collection items (bypass with ?force=true)
CONFLICT409A folder with the target name already exists (folder rename only — unrelated to media deletion)
VALIDATION_ERROR400Invalid input (file type, size, params)
INVALID_REQUEST400Malformed request (no file, invalid JSON)
RATE_LIMITED429Too many requests
INTERNAL_ERROR500Server error

Common Error Scenarios

Public media serving:

  • Unknown project ID or filename → 404 NOT_FOUND
  • Transform params on a non-image file → the file is served unchanged (transforms apply to images only)

Upload validation, permission, and delete-conflict scenarios belong to the session-authenticated admin endpoints — see the Media Admin API for those flows and client-side error-handling examples.


Rate Limiting

Rate limits differ by media endpoint:

  • Media metadata reads (GET /api/v1/media, GET /api/v1/media/:id, /transform) — the standard public read limit, in-Worker per API key and counted per cache miss, in line with the other public read endpoints. Figures: Limits & Errors.
  • Admin media endpoints (/api/v1/admin/media/*) — the admin API per-IP limit, enforced in the Worker; figure in Limits & Errors.
  • Raw media serving (GET /api/v1/media/:projectId/:filename, the file bytes) — no per-key in-worker limit. It's covered only by the coarse per-IP Cloudflare WAF backstop, live since 2026-07-13; figures in Limits & Errors. Cloudflare's CDN cache further reduces how often these requests reach the origin for repeat/transformed URLs.

A 429 response uses the RATE_LIMITED code. See Limits & Errors for the full limit table, rate-limit headers, and 429 response shape.


Best Practices

Image Transformation Best Practices

1. Use Predefined Sizes

Define common image sizes in your app:

const IMAGE_SIZES = {
  thumbnail: { width: 200, height: 200, fit: 'cover' },
  small: { width: 400, fit: 'scale-down' },
  medium: { width: 800, fit: 'scale-down' },
  large: { width: 1200, fit: 'scale-down' },
  hero: { width: 1920, fit: 'scale-down' },
}

function getImageUrl(media, size = 'medium') {
  const params = new URLSearchParams(IMAGE_SIZES[size])
  return `/api/v1/media/${media.r2Key}?${params}`
}

2. Responsive Images with srcset

Generate multiple sizes for responsive images:

<img
  src="/api/v1/media/tenant/image.jpg?width=800"
  srcset="
    /api/v1/media/tenant/image.jpg?width=320 320w,
    /api/v1/media/tenant/image.jpg?width=640 640w,
    /api/v1/media/tenant/image.jpg?width=960 960w,
    /api/v1/media/tenant/image.jpg?width=1280 1280w
  "
  sizes="(max-width: 640px) 100vw, 640px"
  alt="Hero image"
/>

3. Use WebP/AVIF with Fallback

Prefer modern formats with fallback:

<picture>
  <source
    srcset="/api/v1/media/tenant/image.jpg?width=800&format=avif"
    type="image/avif"
  />
  <source
    srcset="/api/v1/media/tenant/image.jpg?width=800&format=webp"
    type="image/webp"
  />
  <img src="/api/v1/media/tenant/image.jpg?width=800" alt="Hero" />
</picture>

Performance Best Practices

Lazy Load Thumbnails

Use native lazy loading for media grids:

<img
  src="/api/v1/media/tenant/image.jpg?width=200&height=200&fit=cover"
  loading="lazy"
  alt="Thumbnail"
/>

List/search, folder-caching, and pagination tips for the admin media endpoints live in the Media Admin API.


Summary

The Media API provides a comprehensive set of endpoints for managing media assets in a multi-tenant CMS:

  • Full media lifecycle covering upload, organise, serve, and delete (upload/manage endpoints are documented in the Media Admin API)
  • Strong tenant isolation enforced at storage (R2) and data (D1) layers
  • Usage tracking prevents accidental deletion of referenced media
  • Image transformations via Cloudflare Image Resizing for dynamic resizing
  • Soft delete pattern for recovery
  • Bulk operations with partial success handling
  • Public serving with 1-year cache for optimal performance

Key Considerations:

  • Validate files client-side and server-side (defense in depth)
  • Check usage before deletion (409 HAS_REFERENCES if still referenced by content)
  • Use image transforms for responsive, optimized images
  • Organize media with folders (2-3 levels max)
  • Respect rate limits (especially bulk upload)
  • SVG uploads are sanitized server-side to prevent XSS

For related guidance, see:

Copyright © 2026