Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/media-usage-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds coverage-aware media usage counts and read-only Used in details to the REST API and core client.
118 changes: 108 additions & 10 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,22 +178,23 @@ DELETE /_emdash/api/content/:collection/:id
### List Media

```http
GET /_emdash/api/media
GET /_emdash/api/media?includeUsage=1
```

#### Parameters

| Parameter | Type | Description |
| ---------- | -------- | ---------------------------- |
| `cursor` | `string` | Pagination cursor |
| `limit` | `number` | Items per page (default: 20) |
| `mimeType` | `string` | Filter by MIME type prefix |
| Parameter | Type | Description |
| -------------- | -------- | -------------------------------------------------------------- |
| `cursor` | `string` | Opaque pagination cursor |
| `limit` | `number` | Items per page, from 1 to 100 (default: 50) |
| `mimeType` | `string` | Filter by one or more comma-separated MIME types |
| `q` | `string` | Case-insensitive filename search |
| `includeUsage` | `1` | Include a coverage-aware `usage` summary on every returned item |

#### Response

```json
{
"success": true,
"data": {
"items": [
{
Expand All @@ -203,8 +204,15 @@ GET /_emdash/api/media
"size": 102400,
"width": 1920,
"height": 1080,
"url": "https://cdn.example.com/photo.jpg",
"createdAt": "2025-01-24T12:00:00Z"
"url": "/_emdash/api/media/file/uploads/photo.jpg",
"createdAt": "2025-01-24T12:00:00Z",
"usage": {
"count": 3,
"coverage": {
"scope": "all_content_collections",
"status": "complete"
}
}
}
],
"nextCursor": "eyJpZCI6..."
Expand All @@ -215,9 +223,99 @@ GET /_emdash/api/media
### Get Media

```http
GET /_emdash/api/media/:id
GET /_emdash/api/media/:id?includeUsage=1
```

`includeUsage` is optional on both list and get. Its only accepted value is `1`. When omitted,
the `usage` property is omitted and the server does not run usage queries.

### Usage Summaries

`usage.count` is the number of distinct active EmDash content rows or locales whose selected
current indexed source references the media item. Repeated references and multiple source
variants for the same content entry count once. Trashed content does not count.

A numeric count can reveal draft-like content. It is returned only when a session user has
`content:read_drafts`, or when an API token has `admin` scope and its associated user also has
that permission. Other media readers receive `usage.count: null`; this is a successful redacted
response, not an error.

Every requested summary includes aggregate coverage for all currently registered content
collections:

| Status | Meaning |
| ---------- | --------------------------------------------------------------------- |
| `complete` | Every registered collection has current, completed usage coverage |
| `never` | No registered collection has completed an initial usage repair |
| `running` | A usage repair is currently running |
| `partial` | Coverage is mixed or only part of the registered scope was indexed |
| `failed` | Coverage failed across the registered scope |
| `stale` | Indexed coverage is outdated |
| `unknown` | Stored coverage contains a state this version does not recognize |

Only `complete` supports a scoped complete-zero statement within the EmDash-managed fields
described below. Counts with any other status are indexed projections and may over-report or
under-report. Even complete results are advisory during concurrent writes; usage reads are not a
transactional lock and must not be used as a deletion guarantee.

### Get Media Usage Details

```http
GET /_emdash/api/media/:id/usage?limit=50&cursor=...
```

This endpoint requires `media:read` and `content:read_drafts`. Token-authenticated callers also
require `admin` scope; token scope does not bypass the associated user's permissions.

`limit` controls content entry groups per page, from 1 to 100 (default: 50). Pagination never
splits the sources or occurrences for one returned entry group.

```json
{
"data": {
"items": [
{
"collection": "posts",
"contentId": "01CONTENT...",
"title": "Launch notes",
"slug": "launch-notes",
"locale": "en",
"status": "published",
"scheduledAt": null,
"deletedAt": null,
"sources": [
{
"variant": "columns",
"occurrences": [
{
"fieldSlug": "hero",
"fieldPath": "hero",
"occurrenceIndex": 0,
"referenceType": "image_field"
}
]
}
]
}
],
"nextCursor": "eyJvcmRlclZhbHVlIjoicG9zdHMiLCJpZCI6IjAxLi4uIn0",
"coverage": {
"scope": "all_content_collections",
"status": "complete"
}
}
}
```

Authorized details include active and trashed entries. A non-null `deletedAt` identifies a
trashed entry. Sources are `columns` or `draft_overlay`; occurrences identify the supported field
and path without exposing internal index metadata.

Media usage covers local media references in top-level image and file fields, repeater image
fields, and Portable Text image blocks managed by EmDash content collections. It does not scan
custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or
provider-only assets.

### Create Media

```http
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/api/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { Permission, RoleLevel } from "@emdash-cms/auth";
import { hasPermission, canActOnOwn } from "@emdash-cms/auth";
import { hasPermission, canActOnOwn, hasScope } from "@emdash-cms/auth";

import { apiError } from "./error.js";

Expand All @@ -15,6 +15,15 @@ interface UserLike {
role: RoleLevel;
}

export function canReadMediaUsageCount(
user: UserLike | null | undefined,
tokenScopes: string[] | undefined,
): boolean {
return (
hasPermission(user, "content:read_drafts") && (!tokenScopes || hasScope(tokenScopes, "admin"))
);
}

/**
* Check if user has a permission. Returns a 401/403 Response if not, or null if authorized.
*
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const ErrorCode = {
MEDIA_CREATE_ERROR: "MEDIA_CREATE_ERROR",
MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR",
MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR",
MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR",
MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR",
NO_STORAGE: "NO_STORAGE",
NO_FILE: "NO_FILE",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,19 @@ export {
} from "./media.js";

export {
aggregateMediaUsageCoverageStatus,
handleMediaUsageDetails,
handleMediaUsageSummaries,
handleMediaUsageRepair,
toMediaUsageRepairResponse,
type MediaUsageCoverage,
type MediaUsageCoverageStatus,
type MediaUsageDetailsResponse,
type MediaUsageEntryDetail,
type MediaUsageOccurrenceDetail,
type MediaUsageRepairResponse,
type MediaUsageSourceDetail,
type MediaUsageSummary,
} from "./media-usage.js";

// Schema handlers
Expand Down
Loading
Loading