Skip to content

Store media assets with versioned metadata, folders and tags - #2543

Merged
pournasserian merged 7 commits into
v2from
2542-media-assets
Oct 19, 2026
Merged

pournasserian merged 7 commits into
v2from
2542-media-assets

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #2542

What

The next F-MED (#2427) slice: media assets with versioned metadata, including folders, tags, alt text and caption, and their use through the reference index (MED-01, MED-02, CNT-05). It builds on the storage from #2541.

  • Core (FluentCMS.Core.Media):

    • AssetMetadata.Create checks and normalizes the file name, folder, tags, alt text and caption, reporting every problem at once by field. Folders are slug segments joined by slashes, such as products/drills. There are up to 32 lowercase tags, each kept once.
    • MediaAsset, MediaAssetVersion and MediaBlob: the bytes a version points at, the storage that holds them, its key, length and SHA-256.
    • IMediaLibrary: upload, update metadata from a base version, read an asset or a version, list by folder (with or without subfolders) and tag, list folders in use, and open the bytes.
  • Uploads: the bytes go to the storage Media__Storage names before anything is written. A metadata update makes the next version with the same bytes. These uploads are refused:

    • an empty upload;
    • one over Media__MaxBytes (50 MB by default);
    • one whose media type is not on the allow-list: JPEG, PNG, GIF, WebP, PDF, MP4 and WebM;
    • one whose first bytes do not carry the declared type's signature (media_content_mismatch).

    The type and the signature are checked before any byte reaches the storage. A refusal or a failed write deletes any bytes already saved.

  • Startup: a Media__MaxBytes of zero or less, or of long.MaxValue, stops the host, as an unknown storage does.

  • Infrastructure: MediaAssets (latest and published pointers) and MediaAssetVersions, scoped to the site. A BEFORE UPDATE trigger on both providers keeps versions unchanged, as for entry versions. Updates move the pointer only from the base version. media.uploaded and media.updated (with the base version's row as previous version) are appended in the same unit of work. Migrations for SQLite and PostgreSQL.

  • Usage: an entry that references a missing media asset now fails with reference_not_found, and IWhereUsed.ForMediaAsync lists the entries that show an asset (CNT-05).

  • Tests: each temporary database now carries its own Media:LocalDisk:Root, so no test writes into the build output.

Why

MED-01 and MED-02 need assets people and agents can organize and describe, and the next slice puts metadata changes into change sets. They get versions with base and proposed numbers, like entries, from the start. Storing the storage name with each version is what lets Media__Storage change without touching existing assets (MED-04).

Choices worth a look:

  • Bytes first, rows second: storages are not transactional. Saving first and deleting on any refusal or failure keeps the database from pointing at missing bytes. A crash between the two can leave unreferenced bytes behind, never a broken asset.
  • Folders are a path on the asset, not a table: listing the folders in use is a distinct query, and moving an asset is a metadata update. Empty folders do not exist.
  • Agent scopes for media come with the tools in the next slice, together with media operations in change sets.
  • No type a browser runs as a document. text/html, application/xhtml+xml and image/svg+xml are refused, and SVG stays out until it can be sanitized. Each allowed type's signature is checked, so a declared type cannot lie. Whoever serves the bytes later (F-API) must also send X-Content-Type-Options: nosniff and a safe Content-Disposition.

Tests

Unit (Core):

  • AssetMetadata: normalization (trim, slashes around the folder, lowercase and once-only tags); every problem at once; file names with slashes, dots or control characters; more than 32 tags.
  • MediaTypes: the allow-list refuses HTML, XHTML and SVG, case variants and parameters. Each allowed type matches its signature, and HTML, a JPEG declared as PNG, a RIFF that is not WebP and a truncated MP4 do not.

Integration, each on SQLite and PostgreSQL:

  • An upload stores the bytes in the local storage and version 1 with its metadata and blob, the bytes read back, and media.uploaded names the asset.
  • A metadata update stores version 2 with the same bytes and media.updated with the first version's row; a stale base and invalid metadata (by field) store nothing; version 1 still reads with its own metadata and bytes.
  • Empty, oversized and mistyped uploads are refused, and leave no asset and no bytes.
  • HTML and SVG uploads are refused as types, and HTML declared as JPEG and a JPEG declared as PNG are refused as mismatches. None leaves an asset or bytes.
  • Media__MaxBytes of 0, -1 and long.MaxValue fails options validation and names the setting.
  • Lists by folder, with and without subfolders, by root, by tag and both; folders in use list once each.
  • A product entity that references a missing asset is refused; one that references an uploaded asset appears in where used for it at /hero/value.
  • An update of a version row fails; unknown assets fail to read, open and update.
  • The newest migration's Down on both providers.

The full suite ran locally against a postgres:17 container as well as SQLite. No new package and no new project reference.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:media Media library (MED) labels Oct 19, 2026
# Conflicts:
#	CONTRIBUTING.md
#	src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs
#	src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs
@pournasserian
pournasserian merged commit a58d620 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2542-media-assets branch October 19, 2026 05:10
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2595

## What

The media library screen of F-MED (#2427), MED-01's UI: upload, browse,
organize and where used. It runs on the merged `IMediaLibrary` (#2543)
and `IWhereUsed.ForMediaAsync` (#2494), and uses the desk's layout and
conventions (#2422).

- **Host** (`Media/MediaEndpoints.cs`, next to the audit export
endpoint):
- `POST /media/upload` takes a multipart form with the anti-forgery
token. It calls `UploadAsync` and redirects to the new asset with
`?uploaded=true`.
- A refused upload redirects back to `/media` with the error codes and
the typed fields, as the audit export does.
- The request size limit is `Media__MaxBytes` plus 1 MB for the form, so
the library's own limit decides first.
- A form with only a file binds no fields, and the endpoint treats that
as empty metadata.
- `GET /media/{id}/file?version=n` streams the bytes to a signed-in
person. Images are inline for the previews, and every other type is an
attachment. Each response has `X-Content-Type-Options: nosniff` and
`Cache-Control: private, no-store`. The library already refuses types a
browser runs as a document (`MediaTypes`).
- **Admin**:
- `MediaUploadForm`, `MediaMetadataForm` and their shared
`MediaMetadataFields`. Tags are typed comma-separated, and a blank alt
text or caption means none.
  - **`/media`**:
    - Folder links with subfolders, and tag links.
- An upload card that shows each refused field's problem next to it and
keeps what was typed.
- The assets newest first. Each row has a decorative image preview, the
file name linked to the asset, type, folder, tags, size, Published, and
No alt text for an image without it (MED-02). The rows rove like the
desk's.
  - **`/media/{id}`**:
- The preview with the asset's alt text, its version and whether it is
published, and a download link.
- A details form that saves the next version from the version the page
showed. A form from an older version is refused with the current details
shown.
- Where used: each entry version that references the asset, with its
paths.
  - A Media link in the header.
- **Accessibility**: the axe WCAG 2.1 AA check from #2582 now covers
`/media` and an asset page in both directions and themes.

## Why

- **Endpoints for files, pages for everything else.** A Razor page
cannot read a multipart upload or answer with bytes, so the host takes
those two requests, as it does for the audit export. The pages stay
static and post ordinary forms.
- **Upload calls the library directly, for now.** #2546 puts uploads and
metadata changes into change sets with the uploader as initiator. Until
it lands, the screen does what the merged library offers. When it lands,
the endpoint's `UploadAsync` and the page's `UpdateAsync` pass the
person's change set target, like the other writes.
- **Previews are the image itself, scaled by CSS.** Server-side
thumbnails are image processing, which F-MED leaves out of M1.
- **No paging yet.** `IMediaLibrary.ListAsync` returns every match. The
table is fine at M1's scale, and paging belongs in the library's query
once one is needed.
- **Not in this PR**: change set operations for uploads (#2546), soft
delete and restore (#2553), and attaching an asset to a field from the
library (generated forms, F-FRM #2426).

## Tests

bUnit (`MediaViewsTests`):
- The library lists newest first, with previews only for images,
decorative preview alt text, No alt text and Published badges, folder
and tag links, the current filter and roving rows. A folder in the
address becomes the query.
- The upload form posts multipart to the endpoint and accepts the
allowed types. A refused upload shows its problems next to the fields
and keeps the typed values.
- The asset page shows the preview with its alt text, the details form
at the latest version and where it is used. An image without alt text
says so.
- Tags split on commas, a refused upload returns its codes and fields,
and blank texts mean none. Logical utilities only.

Integration, host:
- An upload lands on the asset. Its bytes come back with the declared
type, inline and `nosniff`, and an unknown asset's file is 404.
- A bad folder, bytes that do not match the declared type, and a post
with no file are each sent back with their codes and shown next to their
fields, and nothing is stored.
- The library filters by folder, subfolders and tag, newest first, and
marks the one image without alt text.
- Saving details stores version 2, a form from version 1 is refused with
the current details, and an invalid tag is reported.
- Where used lists the product page that references the asset, and an
unused asset says so.
- Anonymous requests to the pages and the file go to sign-in, and an
upload without the anti-forgery token answers 400 and stores nothing.

Browser: the axe check passes on `/media` and an asset page.

`dotnet build -warnaserror` and the full suite with the browser tests
pass locally against a `postgres:17` container as well as SQLite. No new
package and no new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2621

## What

The third slice of F-FRM (#2426): pickers for reference and media fields
(FRM-01), backed by search (`ISearch`, #2566, #2606) and the media
library (#2543).

- **`PickerField`** (`Components/Forms`) is an interactive island inside
the static generated form. `FieldWidget` renders it for reference and
media fields.
- Over the circuit it shows the current choice, a Clear button and a
search box. The choice is a hidden input named `Form.Values[field]`, so
the form posts and saves exactly as before.
- **Entries**: as the person types, it searches the reference's target
type (at most 10 direct hits). Each hit shows its first text value, or
an entity's first text fact, with its state and a short id.
- **Media**: it filters the library by file name or folder, newest
first, with a preview for images.
- The matches are a `listbox` of buttons that rove with the module from
#2585. ArrowDown moves from the search box into the list, the arrows
move through it, Enter chooses and Escape clears the matches.
- Before the circuit attaches, or when it is lost, the prerendered
island is the plain select from #2613, so a form posted then still
saves.
- **`admin.js`**: a `data-ui="rove-input"` box moves focus to the first
item of the list its `aria-controls` names on ArrowDown.

## Why

- **An island, not a page in interactive mode.** Only the search needs
the circuit. The form, its other fields and the save stay static, so the
save path from #2613 does not change, and a lost circuit costs only the
search.
- **A hidden input in the island.** The static form collects it with the
other fields, and no server state has to survive between the choice and
the save.
- **`FieldOption[]` rather than an interface.** An island's parameters
cross into the circuit as JSON. With `IReadOnlyList<FieldOption>`, the
browser refused the root components with "The list of component
operations is not valid". The parameter says why in a comment.
- **Not in this PR**: field filters in the picker (`SearchQuery.Fields`
exists for it once a field's settings name a filter), list-of-reference
widgets, and rule hints (FRM-03, needs F-RUL #2419).

## Tests

- bUnit (`PickerFieldTests`):
  - Before the circuit, a picker is the select with the stored choice.
- An entry picker searches its target type and names hits by their first
text fact. It wires the listbox to the search box and the rove module,
and choosing posts the id and shows the name. Clear empties the choice.
  - A search with no match says so, and Escape clears the matches.
- A media picker finds an asset by file name, case-insensitively, with
its preview, and posts its id.
- `GeneratedFormTests` renders the reference and media widgets before
the circuit, as selects.
- Browser (`FormPickerTests`), keyboard alone: type the title, Tab to
the picker, type part of a product's name, press ArrowDown and Enter,
then write the rationale and save. The stored entry references the
product.

`dotnet build -warnaserror`, the unit and bUnit suite, the browser tests
and the SQLite integration suite pass locally. The local PostgreSQL run
on a loaded machine had nine failures in untouched classes, the same
pattern as the last three PRs. CI runs the full PostgreSQL suite. No new
package and no new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2544

Built on the media assets of #2543 and the schema change sets of #2520,
both merged.

## What

The next F-MED (#2427) slice: media in change sets, and the tools agents
use to upload, describe, read, list and attach media (MED-03, CHG-01).

- **Change sets** (`ChangeSetDirectory`):
- A `media` operation points at an asset of the site and at stored
versions of it. Its `change_set.operation_added` event carries its base
version's row, as entries' do.
- `ItemDiff.ForMedia` compares metadata as fields (file name, folder,
tags, alt text, caption) and the bytes as properties (content type,
length, SHA-256), so a metadata change shows no property.
- Publish moves `MediaAssets.PublishedVersion` with the same stale-base
checks as entries; a delete unpublishes. Events: `media.published` and
`media.unpublished`. Revert goes through the same publish.
- **Agent permissions**: `IAgentPermissions.CheckMediaAsync(operation)`.
Media is not a content type, so a scope allows it by its sites and
operations alone, whatever types it lists (MCP-05). Change sets check it
when a media operation is added.
- **`MediaTools`** (`FluentCMS.Core.Builtin.Tools`):
- `upload_media`: base64 bytes, content type and metadata. It stores
version 1 and adds the create to a set in the same call. Bytes that end
up in no committed asset are deleted again.
- `update_media`: new metadata from a base version; omitted properties
keep their values.
- `get_media` and `list_media`: by folder, optionally with subfolders,
and by tag.
- `attach_media`: sets a single media field, or adds to a list of media,
in the entry's next version, and proposes it like `update_entry`. On an
entity it writes the fact's value and keeps its metadata.
- **Supporting changes**: `MediaAssetVersionRow.ToVersion()` is shared
by the library and change sets. `ToolSchemas.Boolean` and
`ToolArgumentsReader.OptionalFlag` are new, and `ToolJson` renders
media. The MED rows of `McpCoverageTests` list the tools.

## Why

CHG-01 puts every write into a change set, and F-MED's acceptance has
uploads and metadata changes in the uploader's set with the uploader as
initiator. Media versions already carry base and proposed numbers
(#2542), so they publish and revert like entries. That lets one set hold
a page and the image it now shows, and publish them atomically (CHG-05).

Choices worth a look:

- **Scope by operation, not type**: a credential that lists content
types can still upload media when its operations allow it, since an
image belongs to no type. Narrowing media further would need a scope
field for it.
- **An upload proposes a create, and so does a metadata update before
the first publish**, as for entries. A second write to one asset in one
set is refused as a duplicate, as for entries and content types.
- **Bytes are cleaned up when the set refuses the operation**: the
storage is not transactional, so the tool deletes what it uploaded when
its unit of work does not commit.

## Tests

Integration, each on SQLite and PostgreSQL:
- A media create diffs as three properties and three fields and
publishes with `media.published`. An alt text update diffs as one added
field and no property, publishes, and its revert puts version 1 back and
marks it reverted. A delete unpublishes with `media.unpublished`.
- Two sets creating one asset: a failure at each audit append of the
first publish moves nothing, it then publishes, and the second is stale.
- An unknown asset and a missing version fail. An agent without the
create operation is refused a media create, and one scoped to `product`
with create can add it.
- Over MCP:
- S2's media part: an agent scoped to `product-page` uploads an image
without alt text and attaches it to a published page in one session's
set. The set holds the media create and the entry update with the agent
as initiator, and a person publishes both.
  - `list_media` finds the asset and says it has no alt text.
- `update_media` adds alt text, keeps the other metadata, and proposes
the whole asset because it was never published.
- Refused: an upload without the create operation, bad base64 together
with a bad file name, attaching to a field that is not media, and
attaching an asset that does not exist. None of them stores anything.
- The "kind not available yet" case in `ChangeSetDirectoryTests` now
uses a rule operation.

The full suite ran locally against a `postgres:17` container as well as
SQLite. No migration, no new package and no new project reference.

## Review

The media stack was reviewed before merging. Its findings for this PR
are fixed:
- `upload_media` refuses an upload over `Media__MaxBytes` from the
length of its base64, before decoding any of it, on HTTP and stdio
alike. `IMediaLibrary.MaxBytes` gives the limit.
- CONTRIBUTING says that MCP over HTTP holds an upload to about 22 MB,
because of the server's 30 MB request limit.
- The tool list conflict with `check_draft` is resolved.
- The test's content type is now created through
`TestChangeSetTargets.ContentModel`.

On v2, `MediaTools` uses the shared `ToolSchemas.Flag` and
`ToolArgumentsReader.Flag`, and passes the asset to
`ChangeSetTarget.CheckAsync`, so an update to an asset the set already
holds takes no extra room under the cap. New test: an upload over the
limit and a file unlike its declared type are refused through the tool
and store nothing.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 26, 2026
Closes #2553

Built on media storage (#2543), media in change sets (#2546) and the alt
text check (#2549), all merged.

## What

The last slice of F-MED (#2427): assets are soft deleted and can be
restored within the retention window, as entries are (#2502, CNT-06,
REL-03).

- **Core** (`IMediaLibrary`):
  - `DeleteAsync(id, baseVersion)` and `RestoreAsync(id)`.
- `ListDeletedAsync()`, which returns each `DeletedAsset` with when it
was deleted and until when it can be restored, most recent first.
  - New codes `asset_not_deleted` and `retention_expired`.
- **Gone from every read**: a deleted asset is not found by get,
versions, the live version, open or metadata updates, and it leaves
lists and folders. An entry write that references it fails with
`reference_not_found`. A change set operation pointing at it cannot be
added, and a set that already holds one fails to publish until the asset
is restored, as for deleted entries.
- **Restore** clears the deletion while `DeletedAt +
Content__DeletedRetention` is still in the future. Versions, bytes and
the published pointer are never touched, so the asset comes back exactly
as it was.
- **Permissions**: delete and restore take the delete operation of an
agent's scope (MCP-05).
- **Infrastructure**:
  - New column `MediaAssets.DeletedAt`.
- Delete and restore are conditional updates, so concurrent calls get
`version_conflict` or `asset_not_deleted` instead of both succeeding.
- Events `media.deleted` (with the latest version's row as previous
version) and `media.restored`, appended in the same unit of work.
  - Migrations for SQLite and PostgreSQL.

## Why

CNT-06 and REL-03 require that nothing is hard-deleted inside the
window. Media follows the entry design so that a person restores both
the same way.

Choices worth a look:

- **No purge, and no tool**, as for entries. A purge needs a decision on
what it removes, then a job. Deleting stays a person's action.
`delete_entry` and media delete operations in change sets only
unpublish.
- **The `where_used` tool still lists the pages that show a deleted
asset**, so an agent can say what the delete affects. The admin asset
page shows a deleted asset as not found, like every other read.
- **The media library screen is not in this epic's PRs**: it depends on
the desk's layout (F-DSK).

## Tests

Integration tests, each on SQLite and PostgreSQL:

- **Deleted asset is gone**: it is not found by get, version, open or
update. It leaves the list and the folders. A product whose hero names
it fails with `reference_not_found`. `media.deleted` names it and its
version row.
- **Restore inside the window**: a restore one minute before the window
closes brings back version 2 with its alt text, published version 1 and
its bytes. It empties the deleted list and appends `media.restored`.
- **Restore after the window**: at the end of the window, restore fails
with `retention_expired` and appends nothing.
- **Failures**: each of these fails with its own code:
  - a stale base version;
  - restoring an asset that is not deleted;
  - unknown assets;
  - a second delete.
- **Deleted list**: it is ordered by deletion time and says until when
each asset can be restored.
- **Approved set**: a set whose media create was approved fails to
publish once the asset is deleted, and a new set cannot add it. After a
restore, the set publishes.
- **Agent scope**: an agent without the delete operation is refused
delete and restore, and one with it can delete.

The full suite ran locally against a `postgres:17` container as well as
SQLite. The migration was regenerated on top of `ProposedMediaVersions`
(#2549). The migration tests apply and revert it on SQLite and
PostgreSQL. No new package and no new project reference.

## Review

An independent review gave MERGE, with Low findings:
- content that shows a deleted image passes the alt text check and can
publish, as content that references a deleted entry can today: #2665;
- media permissions let an engine write assets, where content
permissions refuse one; this already exists on v2: #2666;
- the live version of a deleted asset was untested; the deleted-asset
test now covers it, and this description is updated.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:media Media library (MED) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store media assets with versioned metadata, folders and tags

1 participant