Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
769597d
Store media assets with versioned metadata, folders and tags
pournasserian Oct 19, 2026
7232e12
Stop reading an upload one byte past the limit
pournasserian Oct 19, 2026
53368ac
Put media in change sets and add the media tools
pournasserian Oct 19, 2026
ed49ec0
Name only media fields when a field is not one
pournasserian Oct 19, 2026
f0aed6f
Check alt text of the assets media fields name
pournasserian Oct 19, 2026
e619695
Soft delete and restore media assets
pournasserian Oct 19, 2026
110a5e4
Merge remote-tracking branch 'origin/v2' into 2542-media-assets
pournasserian Oct 19, 2026
6b95164
Merge branch '2542-media-assets' into 2544-media-change-sets
pournasserian Oct 19, 2026
3ef7658
Merge branch '2544-media-change-sets' into 2548-media-alt-text
pournasserian Oct 19, 2026
99cac20
Merge branch '2548-media-alt-text' into 2553-media-soft-delete
pournasserian Oct 19, 2026
e1faec2
Regenerate the media asset deletion migrations on top of v2
pournasserian Oct 19, 2026
e8f2efe
Say that the retention window covers media assets
pournasserian Oct 19, 2026
4f62696
Merge remote-tracking branch 'origin/v2' into 2542-media-assets
pournasserian Oct 19, 2026
3782b3e
Accept only allowed media types whose bytes match them, and validate …
pournasserian Oct 19, 2026
2eff3fd
Merge branch '2542-media-assets' into 2544-media-change-sets
pournasserian Oct 19, 2026
43d4160
Refuse an oversized upload before decoding it
pournasserian Oct 19, 2026
fad2ed1
Merge branch '2544-media-change-sets' into 2548-media-alt-text
pournasserian Oct 19, 2026
da00d21
Check the alt text of the version that goes live, and only for images
pournasserian Oct 19, 2026
55113c5
Merge branch '2548-media-alt-text' into 2553-media-soft-delete
pournasserian Oct 19, 2026
3ceaa12
Refuse control characters in alt text and caption, and match folders …
pournasserian Oct 19, 2026
af8eff8
Merge remote-tracking branch 'origin/v2' into 2542-media-assets
pournasserian Oct 19, 2026
f045838
Merge remote-tracking branch 'origin/2542-media-assets' into 2544-med…
pournasserian Oct 19, 2026
ecdba9b
Merge remote-tracking branch 'origin/v2' into 2544-media-change-sets
pournasserian Oct 19, 2026
f68a3cb
Leave the content model's own change set out of the count
pournasserian Oct 19, 2026
8f96312
Merge remote-tracking branch 'origin/v2' into 2544-media-change-sets
pournasserian Oct 19, 2026
cb71531
Merge remote-tracking branch 'origin/2544-media-change-sets' into 254…
pournasserian Oct 19, 2026
474f385
Merge remote-tracking branch 'origin/v2' into 2548-media-alt-text
pournasserian Oct 19, 2026
b6b307f
Let a set's own checks read the media versions it proposes
pournasserian Oct 19, 2026
dfcd409
Merge branch '2548-media-alt-text' into 2553-media-soft-delete
pournasserian Oct 19, 2026
0bf5796
Regenerate the media asset deletion migrations on top of v2
pournasserian Oct 19, 2026
f06478b
Let update_media extend an upload in the same set, and fail an image …
pournasserian Oct 19, 2026
00eeca2
Merge remote-tracking branch 'origin/v2' into 2548-media-alt-text
pournasserian Oct 19, 2026
57104e4
Merge branch '2548-media-alt-text' into 2553-media-soft-delete
pournasserian Oct 19, 2026
0159e72
Merge remote-tracking branch 'origin/v2' into 2553-media-soft-delete
pournasserian Oct 26, 2026
c4de733
Merge remote-tracking branch 'origin/v2' into 2553-media-soft-delete
pournasserian Oct 26, 2026
7855740
Test that a deleted asset has no live version
pournasserian Oct 26, 2026
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `F

The browser tests drive the admin in headless Chromium through Playwright: keyboard-only review on the desk and automated WCAG 2.1 AA checks with axe-core. They run when `FLUENTCMS_TEST_BROWSER` is set. Install the browser once after a build with `pwsh tests/FluentCMS.IntegrationTests/bin/Debug/net10.0/playwright.ps1 install chromium`; CI installs it and sets the variable.

To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Delivery__PreviewTokenLifetime` (how long a preview token works unless it is revoked or its change set ends, `7.00:00:00` by default), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` (the reverse proxies, by address such as `10.0.0.5` or network such as `10.0.0.0/8`, whose `X-Forwarded-For` names the client that rate limits count, read one hop deep; with none, the header is ignored), `Media__Storage` (the storage new uploads go to, `local` by default), `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) and `Media__MaxBytes` (the largest upload, 50 MB by default; over MCP on HTTP, the server's 30 MB request limit holds an upload to about 22 MB, since base64 grows a file by a third), and from an optional `appsettings.json` next to the binary.
To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry or media asset can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Delivery__PreviewTokenLifetime` (how long a preview token works unless it is revoked or its change set ends, `7.00:00:00` by default), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` (the reverse proxies, by address such as `10.0.0.5` or network such as `10.0.0.0/8`, whose `X-Forwarded-For` names the client that rate limits count, read one hop deep; with none, the header is ignored), `Media__Storage` (the storage new uploads go to, `local` by default), `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) and `Media__MaxBytes` (the largest upload, 50 MB by default; over MCP on HTTP, the server's 30 MB request limit holds an upload to about 22 MB, since base64 grows a file by a third), and from an optional `appsettings.json` next to the binary.

Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. Agents follow review with the read-only desk tools: `get_desk_counts`, `list_desk_queue` and `get_change_set_review`, which see only the change sets the agent opened.

Expand Down
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/Media/IMediaLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ public interface IMediaLibrary

// The bytes of the latest version, or of the version named. The caller disposes the stream.
Task<Result<MediaContent>> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default);

// A soft delete: versions, bytes and pointers stay, so restore brings the asset back as it was (CNT-06).
Task<Result> DeleteAsync(Guid id, int baseVersion, CancellationToken cancellationToken = default);

// Only within the retention window after the delete (REL-03).
Task<Result<MediaAsset>> RestoreAsync(Guid id, CancellationToken cancellationToken = default);

// Most recently deleted first.
Task<IReadOnlyList<DeletedAsset>> ListDeletedAsync(CancellationToken cancellationToken = default);
}

public sealed record NewAsset(Stream Content, string ContentType, AssetMetadata Metadata);
Expand All @@ -57,6 +66,9 @@ public sealed record MediaAssetSummary(Guid Id, int LatestVersion, int? Publishe

public sealed record MediaContent(Stream Content, string ContentType, string FileName, long Length);

// A soft-deleted asset, which restore brings back with its versions and bytes until RestorableUntil.
public sealed record DeletedAsset(Guid Id, string FileName, int LatestVersion, DateTimeOffset DeletedAt, DateTimeOffset RestorableUntil);

public static class MediaErrorCodes
{
public const string FileNameInvalid = "file_name_invalid";
Expand All @@ -73,4 +85,6 @@ public static class MediaErrorCodes
public const string VersionConflict = "version_conflict";
public const string ContentMissing = "media_content_missing";
public const string FieldNotMedia = "field_not_media";
public const string AssetNotDeleted = "asset_not_deleted";
public const string RetentionExpired = "retention_expired";
}
20 changes: 16 additions & 4 deletions src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ private async Task<bool> MovePublishedVersionAsync(ChangeOperation operation, bo
{
// A delete published from here unpublishes the asset; its versions and its bytes stay.
return await context.MediaAssets
.Where(asset => asset.Id == operation.ItemId && asset.PublishedVersion == operation.BaseVersion)
.Where(asset => asset.Id == operation.ItemId && asset.PublishedVersion == operation.BaseVersion && asset.DeletedAt == null)
.ExecuteUpdateAsync(setters => setters.SetProperty(asset => asset.PublishedVersion, operation.ProposedVersion), cancellationToken) == 1;
}

Expand Down Expand Up @@ -1252,11 +1252,23 @@ private async Task<Dictionary<Guid, PublishedEntry>> PublishedEntriesAsync(IRead
// operation on media.
private async Task<Result<Guid?>> ResolveMediaAsync(ChangeOperation operation, CancellationToken cancellationToken)
{
if (!await context.MediaAssets.AnyAsync(asset => asset.Id == operation.ItemId && asset.SiteId == SiteId, cancellationToken))
var asset = await context.MediaAssets
.AsNoTracking()
.Where(row => row.Id == operation.ItemId && row.SiteId == SiteId)
.Select(row => new { row.DeletedAt })
.SingleOrDefaultAsync(cancellationToken);
if (asset is null)
{
return Result<Guid?>.Failure(ChangeSetErrorCodes.ItemNotFound, $"There is no media asset {operation.ItemId} in this site.");
}

if (asset.DeletedAt is not null)
{
return Result<Guid?>.Failure(
ChangeSetErrorCodes.ItemNotFound,
$"Media asset {operation.ItemId} is deleted; restore it before a change set points at it.");
}

var allowed = await permissions.CheckMediaAsync(AgentOperationOf(operation.Kind), cancellationToken);
if (!allowed.IsSuccess)
{
Expand Down Expand Up @@ -1330,7 +1342,7 @@ private async Task<Dictionary<Guid, PublishedEntry>> PublishedMediaAsync(IReadOn
var assets = await context.MediaAssets
.AsNoTracking()
.Where(asset => asset.SiteId == SiteId && ids.Contains(asset.Id))
.Select(asset => new { asset.Id, asset.PublishedVersion })
.Select(asset => new { asset.Id, asset.PublishedVersion, Deleted = asset.DeletedAt != null })
.ToListAsync(cancellationToken);
var versionIds = (await context.MediaAssetVersions
.AsNoTracking()
Expand All @@ -1341,7 +1353,7 @@ private async Task<Dictionary<Guid, PublishedEntry>> PublishedMediaAsync(IReadOn

return assets.ToDictionary(
asset => asset.Id,
asset => new PublishedEntry(asset.PublishedVersion, Deleted: false, versionIds[asset.Id].ToDictionary(version => version.Version, version => version.Id)));
asset => new PublishedEntry(asset.PublishedVersion, asset.Deleted, versionIds[asset.Id].ToDictionary(version => version.Version, version => version.Id)));
}

private static string Capitalize(string text)
Expand Down
2 changes: 1 addition & 1 deletion src/FluentCMS.Infrastructure/Content/EntryDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ join type in context.ContentTypes.AsNoTracking() on entry.ContentTypeId equals t
var ids = media.Select(reference => reference.TargetId).Distinct().ToList();
var assets = await context.MediaAssets
.AsNoTracking()
.Where(asset => asset.SiteId == SiteId && ids.Contains(asset.Id))
.Where(asset => asset.SiteId == SiteId && asset.DeletedAt == null && ids.Contains(asset.Id))
.Select(asset => asset.Id)
.ToListAsync(cancellationToken);

Expand Down
3 changes: 3 additions & 0 deletions src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public sealed class MediaAssetRow
public int LatestVersion { get; set; }

public int? PublishedVersion { get; set; }

// Set by a soft delete and cleared by a restore; every read leaves a deleted asset out (CNT-06).
public DateTimeOffset? DeletedAt { get; set; }
}

// A version of an asset that a change set proposed, kept when the set moves on to a newer version, so a version no set ever proposed
Expand Down
127 changes: 123 additions & 4 deletions src/FluentCMS.Infrastructure/Media/MediaLibrary.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using FluentCMS.Core.Agents;
using FluentCMS.Core.Audit;
using FluentCMS.Core.Media;
using FluentCMS.Core.Persistence;
using FluentCMS.Core.Results;
using FluentCMS.Core.Sites;
using FluentCMS.Infrastructure.Content;
using FluentCMS.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
Expand All @@ -15,8 +17,10 @@ internal sealed class MediaLibrary(
FluentCmsDbContext context,
IUnitOfWork unitOfWork,
IAuditLog auditLog,
IAgentPermissions permissions,
MediaStorageRegistry storages,
IOptions<MediaOptions> options,
IOptions<ContentOptions> contentOptions,
TimeProvider clock) : IMediaLibrary
{
private const string SubjectType = "media";
Expand Down Expand Up @@ -127,7 +131,7 @@ public async Task<Result<MediaAsset>> UpdateAsync(Guid id, int baseVersion, Asse

// Moves the pointer only from the version the caller read; a concurrent update that committed first leaves nothing to move.
var moved = await context.MediaAssets
.Where(asset => asset.Id == id && asset.LatestVersion == baseVersion)
.Where(asset => asset.Id == id && asset.LatestVersion == baseVersion && asset.DeletedAt == null)
.ExecuteUpdateAsync(setters => setters.SetProperty(asset => asset.LatestVersion, version.Version), cancellationToken);
if (moved == 0)
{
Expand Down Expand Up @@ -307,13 +311,121 @@ public async Task<Result<MediaContent>> OpenAsync(Guid id, int? version = null,
: Result<MediaContent>.Success(new MediaContent(content, stored.ContentType, stored.FileName, stored.Length));
}

public async Task<Result> DeleteAsync(Guid id, int baseVersion, CancellationToken cancellationToken = default)
{
var row = await FindAsync(id, cancellationToken);
if (row is null)
{
return Result.Failure([NotFound(id)]);
}

var allowed = await permissions.CheckMediaAsync(AgentOperation.Delete, cancellationToken);
if (!allowed.IsSuccess)
{
return allowed;
}

if (row.LatestVersion != baseVersion)
{
return Result.Failure([Conflict(id, baseVersion)]);
}

var latest = await VersionRowAsync(id, baseVersion, cancellationToken)
?? throw new InvalidOperationException($"Media asset {id} points at version {baseVersion}, which is not stored.");

await using var scope = await unitOfWork.BeginAsync(cancellationToken);

// Only from the version the caller read, and only once: a concurrent update or delete that committed first wins.
var deletedAt = clock.GetUtcNow();
var deleted = await context.MediaAssets
.Where(asset => asset.Id == id && asset.LatestVersion == baseVersion && asset.DeletedAt == null)
.ExecuteUpdateAsync(setters => setters.SetProperty(asset => asset.DeletedAt, deletedAt), cancellationToken);
if (deleted == 0)
{
return Result.Failure([Conflict(id, baseVersion)]);
}

await auditLog.AppendAsync(
new NewAuditEvent("media.deleted") { SubjectType = SubjectType, SubjectId = id, PreviousVersionId = latest.Id },
cancellationToken);
await scope.CommitAsync(cancellationToken);

return Result.Success();
}

public async Task<Result<MediaAsset>> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
{
var row = await FindAsync(id, cancellationToken, includeDeleted: true);
if (row is null)
{
return Result<MediaAsset>.Failure([NotFound(id)]);
}

// Restoring undoes a delete, so it takes the same operation.
var allowed = await permissions.CheckMediaAsync(AgentOperation.Delete, cancellationToken);
if (!allowed.IsSuccess)
{
return Result<MediaAsset>.Failure(allowed.Errors);
}

if (row.DeletedAt is not { } deletedAt)
{
return Result<MediaAsset>.Failure([NotDeleted(id)]);
}

var until = deletedAt + contentOptions.Value.DeletedRetention;
if (clock.GetUtcNow() >= until)
{
return Result<MediaAsset>.Failure(
MediaErrorCodes.RetentionExpired,
$"Media asset {id} was deleted at {deletedAt:O} and could be restored until {until:O}; that window has closed.");
}

var latest = await VersionRowAsync(id, row.LatestVersion, cancellationToken)
?? throw new InvalidOperationException($"Media asset {id} points at version {row.LatestVersion}, which is not stored.");

await using var scope = await unitOfWork.BeginAsync(cancellationToken);

// A concurrent restore that committed first leaves nothing to restore.
var restored = await context.MediaAssets
.Where(asset => asset.Id == id && asset.DeletedAt != null)
.ExecuteUpdateAsync(setters => setters.SetProperty(asset => asset.DeletedAt, (DateTimeOffset?)null), cancellationToken);
if (restored == 0)
{
return Result<MediaAsset>.Failure([NotDeleted(id)]);
}

await auditLog.AppendAsync(new NewAuditEvent("media.restored") { SubjectType = SubjectType, SubjectId = id }, cancellationToken);
await scope.CommitAsync(cancellationToken);

return Result<MediaAsset>.Success(new MediaAsset(id, row.SiteId, row.LatestVersion, row.PublishedVersion, latest.ToVersion()));
}

public async Task<IReadOnlyList<DeletedAsset>> ListDeletedAsync(CancellationToken cancellationToken = default)
{
var rows = await (
from asset in context.MediaAssets.AsNoTracking()
join version in context.MediaAssetVersions.AsNoTracking()
on new { AssetId = asset.Id, Version = asset.LatestVersion } equals new { version.AssetId, version.Version }
where asset.SiteId == SiteId && asset.DeletedAt != null
select new { asset.Id, version.FileName, asset.LatestVersion, asset.DeletedAt })
.ToListAsync(cancellationToken);

// Ordered here, because SQLite cannot order by the stored offsets.
return rows
.Select(row => new DeletedAsset(row.Id, row.FileName, row.LatestVersion, row.DeletedAt!.Value, row.DeletedAt.Value + contentOptions.Value.DeletedRetention))
.OrderByDescending(asset => asset.DeletedAt)
.ThenBy(asset => asset.Id)
.ToList();
}

// The latest version of every asset of the site, which lists and folders read.
private IQueryable<MediaAssetVersionRow> LatestVersions()
{
return
from version in context.MediaAssetVersions.AsNoTracking()
join asset in context.MediaAssets.AsNoTracking() on version.AssetId equals asset.Id
where asset.SiteId == SiteId && version.Version == asset.LatestVersion
where asset.SiteId == SiteId && asset.DeletedAt == null && version.Version == asset.LatestVersion
select version;
}

Expand All @@ -324,9 +436,11 @@ private IMediaStorage Storage()
?? throw new InvalidOperationException($"Media__Storage is '{options.Value.Storage}', which is no registered media storage.");
}

private Task<MediaAssetRow?> FindAsync(Guid id, CancellationToken cancellationToken)
private Task<MediaAssetRow?> FindAsync(Guid id, CancellationToken cancellationToken, bool includeDeleted = false)
{
return context.MediaAssets.AsNoTracking().SingleOrDefaultAsync(asset => asset.Id == id && asset.SiteId == SiteId, cancellationToken);
return context.MediaAssets
.AsNoTracking()
.SingleOrDefaultAsync(asset => asset.Id == id && asset.SiteId == SiteId && (includeDeleted || asset.DeletedAt == null), cancellationToken);
}

private Task<MediaAssetVersionRow?> VersionRowAsync(Guid id, int version, CancellationToken cancellationToken)
Expand Down Expand Up @@ -370,6 +484,11 @@ private static Error NotFound(Guid id)
return new Error(MediaErrorCodes.AssetNotFound, $"There is no media asset {id} in this site.");
}

private static Error NotDeleted(Guid id)
{
return new Error(MediaErrorCodes.AssetNotDeleted, $"Media asset {id} is not deleted, so there is nothing to restore.");
}

private static Error Conflict(Guid id, int baseVersion)
{
return new Error(
Expand Down
Loading
Loading