Repository navigation
Put media in change sets and add the media tools - #2546
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
# Conflicts: # tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs
# Conflicts: # tests/FluentCMS.IntegrationTests/Mcp/McpAgents.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
# Conflicts: # CONTRIBUTING.md # src/FluentCMS.Core/Builtin/Tools/ToolJson.cs # src/FluentCMS.Core/Media/IMediaLibrary.cs # src/FluentCMS.Core/Tools/ToolArgumentsReader.cs # src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs # src/FluentCMS.Infrastructure/Media/MediaLibrary.cs # tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs # tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs # tests/FluentCMS.Tests/Tools/McpCoverageTests.cs # tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs
pournasserian
added a commit
that referenced
this pull request
Oct 26, 2026
Closes #2548 Built on media in change sets (#2546) and the check engine on change sets, both merged. ## What This is the next F-MED (#2427) slice. The built-in `altText` check (#2535) now also checks media fields, so S2's image without alt text fails the check with a finding that names the field (CHK-01, MED-02). - **`AltTextCheckType`**: when the subject has a content type, each media field, and each item of a list of media, is checked against the version of its image that readers will see: - when the subject's change set holds the image, the live version once that set publishes what it proposes; - otherwise, the live version now. An image with no live version, one that only a change set proposes, fails with its own finding outside that set. Whatever version another set publishes later, nothing checks this content against it. An image whose version has no alt text is a finding. The finding points at the field (`/hero`, `/gallery/1`, or `/photo/value` on an entity), names the file, and says to set alt text with `update_media`. The asset's id is the excerpt. Media that is not an image, such as a PDF, needs no alt text. - **`IMediaLibrary.LiveVersionAsync(id, publishing)`**: the version readers see. That is the newest version a person wrote in the library after the published one, since library writes take effect at once; otherwise it is the published version. `publishing` names a version a set will publish, to ask what readers see once it has. A version that any change set has proposed counts only once its set publishes it. - **`ProposedMediaVersions`** (new table, with migrations for SQLite and PostgreSQL): every media version a change set proposes, kept when the set later moves on to a newer version. This is how the library tells a person's write from a set's. The version rows cannot record it themselves, since a trigger keeps them unchanged. The change set directory adds a row whenever it records a media operation or extends one. The migration fills the table from the operations stored today, and its `Down` drops the table. - **`CheckSubject.SetOperations`** (optional): the operations of the set the content goes live with, so a check can see what that set proposes. `ChangeSetChecks` passes the set's operations when it runs its checks, so the set is read once per run, not once per item. - **`check_draft`** takes an optional `change_set_id`. An agent can then check a draft against the media versions its set proposes, such as alt text it just added with `update_media`. It refuses a set the agent may not read and one that is published, rejected or reverted. - **`ChangeSet`**: a newer version of an asset the set already proposes moves its operation on, as for entries. So `update_media` after `upload_media` in one set, which is S2, extends the upload instead of being refused as a duplicate. A media duplicate now says which version to update from, as an entry's does. - **`MediaFields.Holds`** decides what counts as a media field. `MediaTools` and the check share it. ## Why The check already covered images in text, and the comment in it left assets of the library to F-MED. What goes live is what counts: - A published image without alt text fails even while a newer version with alt text waits in another set, or in a rejected one. That holds even when the set has moved on to a later version since. It passes once the page goes live in the set that adds the alt text. - A person who uploads or describes an image in the admin library changes it at once, without a change set. So their alt text counts on every page right away, including pages in a set that holds the image, and a later version a set proposes does not hide it. An asset the library does not hold is no finding. Entry writes already refuse such references, and check examples name made-up ids. Choices worth a look: - **The check needs `ProposedMediaVersions` to read the version that goes live.** A person's library write is live at once, while a set's version is live only when the set publishes it. The operation rows keep only the last version a set proposes. Without the table, a version the set moved past would pass for a library write, as the fourth review showed. Nothing else reads the table. - **A table of proposed versions, not a column on the version rows.** Versions are immutable by trigger, and when the library writes a version it does not yet know whether a set will propose it. The set directory does know, at the moment it records the operation. - **The migration's backfill knows only the versions sets propose now.** A version a set moved past before this migration is unknown, so it reads as a library write. v2 has no release yet, so this affects development data only. ## Tests Integration tests, each on SQLite and PostgreSQL: - A page whose hero and second gallery item name an image without alt text fails with findings at `/hero` and `/gallery/1`, naming the file. The gallery item with alt text passes. - A published image without alt text fails on an entity at `/photo/value`. Once a version with alt text is published, the page and the entity pass. An unknown asset passes. - Version 1 of an image is published without alt text, and version 2 with alt text waits in a set. A page fails with no set and with another set, and passes with the set that holds version 2. - An image that only change sets propose, whose latest version has alt text, fails with no set and with another set. It passes with the set that holds it, and fails again once that set is rejected. - A person's library writes count at once. An upload without alt text fails with the alt-text finding. Once the person describes it in the library, the page passes with no set and with another set. A later version without alt text that a set proposes fails only with that set. Content outside the set still passes while the set is pending, and after it is rejected. - A library write older than the published version no longer counts. - A PDF in a media field passes. - Over MCP, versions a set moved past: - An agent describes a published image without alt text and then captions it in the same set. It also uploads a new image with alt text and captions it there. - Outside the set, the published image still fails with the alt-text finding, and the new one fails as not published. Both hold while the set is pending and after it is rejected. - Inside the set, the published image passes. - Over MCP, a person describes in the library an image that an agent's set uploaded and attached. `check_draft` with the set turns from fail to pass. The set's own checks pass when it is submitted and published. Outside the set afterwards, the page passes on the person's version, while the published version stays 1. - Over MCP, S2 in one set: an agent uploads an image without alt text and attaches it in one session. `check_draft` fails at `/hero`. `update_media` adds alt text in the same session and extends the upload to version 2. `check_draft` then passes, and so does the set's own alt-text check when it is submitted. - Over MCP, an entity: a product's media fact fails at `/photo/value` and passes once `update_media` describes the photo in the same set. - Over MCP, `check_draft` refuses an unknown change set and a rejected one. - Over MCP, with a published image: 1. A published image is attached to a page in the agent's set. 2. `check_draft` with that `change_set_id` fails at `/hero`. 3. `update_media` adds alt text in the same set. 4. `check_draft` now passes. 5. Submitting the set runs its checks, and alt-text passes there too. Unit tests: - A media operation, like an entry's, moves on to a newer version from the same base. A stale media operation is a duplicate that names the version to update from. - The check never throws, whatever a media field holds (null, numbers, non-ids, objects, mixed lists, on pages and entity facts), and whatever the set names: no set, an upload, or a delete followed by a create of the same asset. The existing unit tests of the check, conformance included, pass unchanged, and so do the `check_draft` tests. The migration tests apply and revert the new migration on both providers. The full suite ran locally against a `postgres:17` container as well as SQLite. There is no new package or project reference. ## Review The media stack review found a High problem: the check read the asset's latest version rather than the one that goes live. That is fixed above. Its Low findings are fixed too: only images need alt text, and the acceptance is tested through `update_media`. A second review blocked again, for two reasons: - **High**: S2 could not finish in one set, because `update_media` after `upload_media` was refused as a duplicate. - **Medium**: an image never published and outside the set was still read at its latest version. Both are fixed above, together with its Low findings: `check_draft` validates `change_set_id`, the entity half is tested through `update_media`, and a stale comment is gone. A third review blocked on a **High** problem: an image a person uploads or describes in the admin library never goes through a change set. So it was never published, and content using it could never pass. The check now reads the live version, as described above. Its Low findings are fixed too: - the media duplicate message; - the set is read once per run; - a stale test comment is gone; - there is a never-throws test for media values; - the scope text of #2548 is updated. A fourth review blocked on two problems in that fix: - **High**: a version a set had moved past within the same set was taken for a library write. Its alt text then counted outside the set, while pending and after rejection. `ProposedMediaVersions` now keeps those versions. - **Medium**: inside its own set, the check ignored a person's later alt text in the library. The check now asks for the live version once the set publishes. A fifth review gave MERGE, with three Low findings: - a reverted upload stays live through a later library write: #2663; - a library save can carry a rejected set's metadata live, which is existing behaviour of the library view: #2664; - undoing and redoing the migration forgets the versions sets had moved past. That is the backfill limit described above, and v2 has no release yet. ## 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 #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).
ChangeSetDirectory):mediaoperation points at an asset of the site and at stored versions of it. Itschange_set.operation_addedevent carries its base version's row, as entries' do.ItemDiff.ForMediacompares 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.MediaAssets.PublishedVersionwith the same stale-base checks as entries; a delete unpublishes. Events:media.publishedandmedia.unpublished. Revert goes through the same publish.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_mediaandlist_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 likeupdate_entry. On an entity it writes the fact's value and keeps its metadata.MediaAssetVersionRow.ToVersion()is shared by the library and change sets.ToolSchemas.BooleanandToolArgumentsReader.OptionalFlagare new, andToolJsonrenders media. The MED rows ofMcpCoverageTestslist 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:
Tests
Integration, each on SQLite and PostgreSQL:
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 withmedia.unpublished.productwith create can add it.product-pageuploads 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_mediafinds the asset and says it has no alt text.update_mediaadds alt text, keeps the other metadata, and proposes the whole asset because it was never published.ChangeSetDirectoryTestsnow uses a rule operation.The full suite ran locally against a
postgres:17container 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_mediarefuses an upload overMedia__MaxBytesfrom the length of its base64, before decoding any of it, on HTTP and stdio alike.IMediaLibrary.MaxBytesgives the limit.check_draftis resolved.TestChangeSetTargets.ContentModel.On v2,
MediaToolsuses the sharedToolSchemas.FlagandToolArgumentsReader.Flag, and passes the asset toChangeSetTarget.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
v2