Repository navigation
Store media assets with versioned metadata, folders and tags - #2543
Merged
Merged
Conversation
This was referenced Oct 19, 2026
# Conflicts: # CONTRIBUTING.md # src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs # src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs
This was referenced Oct 19, 2026
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
This was referenced Oct 19, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Createchecks 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 asproducts/drills. There are up to 32 lowercase tags, each kept once.MediaAsset,MediaAssetVersionandMediaBlob: 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__Storagenames before anything is written. A metadata update makes the next version with the same bytes. These uploads are refused:Media__MaxBytes(50 MB by default);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__MaxBytesof zero or less, or oflong.MaxValue, stops the host, as an unknown storage does.Infrastructure:
MediaAssets(latest and published pointers) andMediaAssetVersions, scoped to the site. ABEFORE UPDATEtrigger on both providers keeps versions unchanged, as for entry versions. Updates move the pointer only from the base version.media.uploadedandmedia.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, andIWhereUsed.ForMediaAsynclists 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__Storagechange without touching existing assets (MED-04).Choices worth a look:
text/html,application/xhtml+xmlandimage/svg+xmlare 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 sendX-Content-Type-Options: nosniffand a safeContent-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:
media.uploadednames the asset.media.updatedwith 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.Media__MaxBytesof 0, -1 andlong.MaxValuefails options validation and names the setting./hero/value.Downon both providers.The full suite ran locally against a
postgres:17container as well as SQLite. No new package and no new project reference.Checklist
v2