diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 570cbe376..ca1dda62d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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), `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), 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 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), `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. diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs index 9234caf12..1f1ee952a 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -221,6 +221,25 @@ public async Task CheckContentAsync( + "credential that includes them."); } + public async Task CheckMediaAsync(AgentOperation operation, CancellationToken cancellationToken = default) + { + var agent = await AgentAsync(cancellationToken); + if (!agent.IsSuccess) + { + return Result.Failure(agent.Errors); + } + + if (agent.Value is not { } credential || credential.Scope.AllowsOperation(operation)) + { + return Result.Success(); + } + + return Result.Failure( + AgentPermissionErrorCodes.ScopeDenied, + $"The credential '{credential.DisplayName}' may not {Verb(operation)} media: its scope allows " + + $"{string.Join(", ", credential.Scope.Operations.Select(Verb))} only. Ask an administrator for a credential that allows {Verb(operation)}."); + } + public async Task CheckDefinitionAsync(string contentType, AgentOperation operation, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(contentType); diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs index 1b390e42a..45f4af8a1 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -32,6 +32,9 @@ Task CheckContentAsync( IReadOnlyCollection? fields = null, CancellationToken cancellationToken = default); + // Media is not a content type, so a scope allows it by its sites and operations alone, whatever types it lists (MED-03, MCP-05). + Task CheckMediaAsync(AgentOperation operation, CancellationToken cancellationToken = default); + // A content type's own definition, which covers every field, so a scope that narrows the type to some of its fields is refused. Task CheckDefinitionAsync(string contentType, AgentOperation operation, CancellationToken cancellationToken = default); diff --git a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs new file mode 100644 index 000000000..d19228a6f --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs @@ -0,0 +1,442 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Media; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// Media (MED-01 to MED-03): agents upload assets with their alt text and caption, describe them, read and list them, and attach them +// to fields. Every write goes into a change set a person reviews. +public sealed class MediaTools : IToolProvider +{ + private const string FolderExample = "products/drills"; + private const string TagExample = "product-shot"; + private const string AltTextExample = "A cordless drill on a workbench."; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "upload_media", + "Upload a media asset", + "Uploads a file, given as base64, with its content type and metadata: it stores the bytes and version 1, unpublished, and " + + "adds the create to a change set in the same call. Give alt text for every image: it describes the image to people " + + "who cannot see it, and a check fails pages that show an image without it. Attach it to a field with attach_media.", + ToolSchemas.Object([ + ToolSchemas.String("content_base64", "The file's bytes in base64.", "iVBORw0KGgo="), + ToolSchemas.String("content_type", "The file's media type.", "image/png"), + ToolSchemas.String("file_name", "The file's name, without folders.", "drill.png"), + .. MetadataArguments, + .. ChangeSetTarget.Arguments]), + readOnly: false, + UploadAsync), + new AgentTool( + "update_media", + "Propose new metadata for a media asset", + "Proposes new metadata for an asset from the version you read, given as base_version: it stores the next version with the " + + "same bytes and adds the update to a change set in the same call. Each property you leave out keeps its value.", + ToolSchemas.Object([ + IdArgument, + ToolSchemas.PositiveInteger("base_version", "The latest version you read, as get_media returned it.", 1), + ToolSchemas.String("file_name", "The file's new name.", "drill.png", required: false), + .. MetadataArguments, + .. ChangeSetTarget.Arguments]), + readOnly: false, + UpdateAsync), + new AgentTool( + "get_media", + "Get a media asset", + "Returns one asset with its metadata, content type, size and hash, for its latest version or the version you name, " + + "and its latest and published versions.", + ToolSchemas.Object(IdArgument, ToolSchemas.PositiveInteger("version", "A version to read instead of the latest.", 1, required: false)), + readOnly: true, + GetAsync), + new AgentTool( + "list_media", + "List media assets", + "Lists the assets in a folder, with or without its subfolders, or with a tag, oldest first, each with its file name, " + + "type, size, folder, tags and whether it has alt text. Without arguments it lists every asset.", + ToolSchemas.Object( + ToolSchemas.String("folder", "A folder, such as products/drills; / is the root.", FolderExample, required: false), + ToolSchemas.Flag("include_subfolders", "With folder: also list the assets in its subfolders."), + ToolSchemas.String("tag", "Only assets with this tag.", TagExample, required: false)), + readOnly: true, + ListAsync), + new AgentTool( + "attach_media", + "Propose attaching media to an entry", + "Proposes showing an asset in a media field of an entry, from the version of the entry you read as base_version: a single " + + "media field is set to the asset, and a list of media gets it added. It stores the entry's next version and adds the " + + "change to a change set in the same call.", + ToolSchemas.Object([ + ToolSchemas.Uuid("entry_id", "The entry, as list_entries or create_entry returned it."), + ToolSchemas.PositiveInteger("base_version", "The entry's latest version you read, as get_entry returned it.", 1), + ToolSchemas.String("field", "The entry's media field, such as hero.", "hero"), + ToolSchemas.Uuid("asset_id", "The asset to show, as upload_media or list_media returned it."), + .. ChangeSetTarget.Arguments]), + readOnly: false, + AttachAsync), + ]; + + private static ToolArgument IdArgument { get; } = ToolSchemas.Uuid("id", "The asset's id, as upload_media or list_media returned it."); + + private static ToolArgument[] MetadataArguments { get; } = + [ + ToolSchemas.String("folder", "The folder it sits in: lowercase words joined by hyphens, with slashes between levels.", FolderExample, required: false), + ToolSchemas.Strings("tags", "Tags of lowercase words joined by hyphens.", TagExample, required: false), + ToolSchemas.String("alt_text", "What the image shows, for people who cannot see it.", AltTextExample, required: false), + ToolSchemas.String("caption", "A caption pages show with it.", "Our best-selling drill.", required: false), + ]; + + private static async Task UploadAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var content = arguments.RequiredString("content_base64", "iVBORw0KGgo="); + var contentType = arguments.RequiredString("content_type", "image/png"); + var fileName = arguments.RequiredString("file_name", "drill.png"); + var folder = arguments.OptionalString("folder", FolderExample); + var tags = arguments.OptionalStrings("tags", TagExample); + var altText = arguments.OptionalString("alt_text", AltTextExample); + var caption = arguments.OptionalString("caption", "Our best-selling drill."); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + + var bytes = content is null ? null : Base64(content, call.Services.GetRequiredService().MaxBytes, errors); + var metadata = fileName is null ? null : AssetMetadata.Create(fileName, folder, tags, altText, caption); + errors.AddRange(metadata?.Errors ?? []); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + return await ProposeAsync( + call, + target!, + itemId: null, + AgentOperation.Create, + library => library.UploadAsync(new NewAsset(new MemoryStream(bytes!), contentType!, metadata!.Value), cancellationToken), + asset => ChangeOperation.Create(OperationKind.Create, ItemKind.Media, asset.Id, null, asset.LatestVersion), + uploaded: true, + cancellationToken); + } + + private static async Task UpdateAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var baseVersion = arguments.RequiredPositiveInteger("base_version"); + var fileName = arguments.OptionalString("file_name", "drill.png"); + var folder = arguments.OptionalString("folder", FolderExample); + var tags = arguments.OptionalStrings("tags", TagExample); + var altText = arguments.OptionalString("alt_text", AltTextExample); + var caption = arguments.OptionalString("caption", "Our best-selling drill."); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + return await ProposeAsync( + call, + target!, + id, + AgentOperation.Update, + async library => + { + var read = await library.GetVersionAsync(id!.Value, baseVersion!.Value, cancellationToken); + if (!read.IsSuccess) + { + return Result.Failure(read.Errors); + } + + var current = read.Value.Metadata; + var metadata = AssetMetadata.Create( + fileName ?? current.FileName, + folder ?? current.Folder, + tags ?? current.Tags, + altText ?? current.AltText, + caption ?? current.Caption); + + return metadata.IsSuccess + ? await library.UpdateAsync(id.Value, baseVersion.Value, metadata.Value, cancellationToken) + : Result.Failure(metadata.Errors); + }, + asset => asset.PublishedVersion is { } published + ? ChangeOperation.Create(OperationKind.Update, ItemKind.Media, asset.Id, published, asset.LatestVersion) + : ChangeOperation.Create(OperationKind.Create, ItemKind.Media, asset.Id, null, asset.LatestVersion), + uploaded: false, + cancellationToken); + } + + private static async Task GetAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var version = arguments.OptionalPositiveInteger("version"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var readable = await call.Services.GetRequiredService().CheckMediaAsync(AgentOperation.Read, cancellationToken); + if (!readable.IsSuccess) + { + return ToolResult.Failure(readable.Errors); + } + + var library = call.Services.GetRequiredService(); + var asset = await library.GetAsync(id!.Value, cancellationToken); + if (!asset.IsSuccess || version is not { } number) + { + return ToolResult.From(asset, read => ToolJson.Media(read), Hint(asset.Errors)); + } + + var stored = await library.GetVersionAsync(id.Value, number, cancellationToken); + return ToolResult.From(stored, read => ToolJson.Media(asset.Value, read), "Leave out the version to read the latest."); + } + + private static async Task ListAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var folder = arguments.OptionalString("folder", FolderExample); + var subfolders = arguments.Flag("include_subfolders"); + var tag = arguments.OptionalString("tag", TagExample); + + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var readable = await call.Services.GetRequiredService().CheckMediaAsync(AgentOperation.Read, cancellationToken); + if (!readable.IsSuccess) + { + return ToolResult.Failure(readable.Errors); + } + + var assets = await call.Services.GetRequiredService().ListAsync(new MediaQuery(folder, subfolders, tag), cancellationToken); + return ToolResult.Success(new JsonObject { ["media"] = new JsonArray([.. assets.Select(ToolJson.MediaSummary)]) }); + } + + private static async Task AttachAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var entryId = arguments.RequiredGuid("entry_id"); + var baseVersion = arguments.RequiredPositiveInteger("base_version"); + var field = arguments.RequiredString("field", "hero"); + var assetId = arguments.RequiredGuid("asset_id"); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + return await ProposedEntryWrite.RunAsync( + call, + target!, + entries => AttachToEntryAsync(call.Services, entries, entryId!.Value, baseVersion!.Value, field!, assetId!.Value, cancellationToken), + ProposedEntryWrite.Update, + cancellationToken); + } + + // Sets a single media field, or adds to a list of media, in a copy of the version the agent read; on an entity the value sits + // inside the field's fact, whose metadata stays. + private static async Task> AttachToEntryAsync( + IServiceProvider services, + IEntryDirectory entries, + Guid entryId, + int baseVersion, + string field, + Guid assetId, + CancellationToken cancellationToken) + { + var entry = await entries.GetAsync(entryId, cancellationToken); + if (!entry.IsSuccess) + { + return entry; + } + + var type = await services.GetRequiredService().GetAsync(entry.Value.ContentType, cancellationToken); + if (!type.IsSuccess) + { + return Result.Failure(type.Errors); + } + + var definition = type.Value.Definition; + var media = definition.Fields.FirstOrDefault(candidate => candidate.Name == field); + if (media is null || !IsMediaField(media)) + { + return Result.Failure( + MediaErrorCodes.FieldNotMedia, + $"'{field}' is not a media field of '{entry.Value.ContentType}'; its media fields are " + + string.Join(", ", definition.Fields.Where(IsMediaField).Select(candidate => candidate.Name)) + + "."); + } + + var isList = media.Type == "list"; + + var read = await entries.GetVersionAsync(entryId, baseVersion, cancellationToken); + if (!read.IsSuccess) + { + return Result.Failure(read.Errors); + } + + var fields = JsonNode.Parse(read.Value.Fields.GetRawText())!.AsObject(); + var holder = definition.IsEntity + ? fields[field] as JsonObject ?? new JsonObject + { + [FactMetadata.Value] = null, + [FactMetadata.Source] = null, + [FactMetadata.Owner] = null, + [FactMetadata.ValidFrom] = null, + [FactMetadata.ValidUntil] = null, + [FactMetadata.LastVerified] = null, + } + : fields; + var key = definition.IsEntity ? FactMetadata.Value : field; + var asset = assetId.ToString(); + + if (isList) + { + var list = holder[key] as JsonArray ?? []; + if (!list.Any(item => item is JsonValue value && value.TryGetValue(out var existing) && Guid.TryParse(existing, out var parsed) && parsed == assetId)) + { + list.Add(asset); + } + + holder[key] = list.DeepClone(); + } + else + { + holder[key] = asset; + } + + if (definition.IsEntity) + { + fields[field] = holder.DeepClone(); + } + + return await entries.UpdateAsync(entryId, baseVersion, JsonSerializer.SerializeToElement(fields), cancellationToken); + } + + // A single media field or a list of media. + private static bool IsMediaField(FieldDefinition field) + { + return field.Type == "media" + || (field.Type == "list" && field.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media"); + } + + // The version an agent writes and its operation commit together or not at all; uploaded bytes that end up in no asset are deleted + // again, so a refused upload leaves nothing in the storage. + private static async Task ProposeAsync( + ToolInvocation call, + ChangeSetTarget target, + Guid? itemId, + AgentOperation agentOperation, + Func>> write, + Func> operation, + bool uploaded, + CancellationToken cancellationToken) + { + var services = call.Services; + var ready = await target.CheckAsync(services, itemId, cancellationToken); + if (!ready.IsSuccess) + { + return ToolResult.Failure(ready.Errors); + } + + var allowed = await services.GetRequiredService().CheckMediaAsync(agentOperation, cancellationToken); + if (!allowed.IsSuccess) + { + return ToolResult.Failure(allowed.Errors); + } + + MediaAsset? written = null; + var committed = false; + try + { + await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); + + var asset = await write(services.GetRequiredService()); + if (!asset.IsSuccess) + { + return ToolResult.Failure(asset.Errors, Hint(asset.Errors)); + } + + written = asset.Value; + var proposed = operation(asset.Value); + if (!proposed.IsSuccess) + { + return ToolResult.Failure(proposed.Errors); + } + + var set = await target.AddAsync(services.GetRequiredService(), proposed.Value, cancellationToken); + if (!set.IsSuccess) + { + return ToolResult.Failure(set.Errors); + } + + await scope.CommitAsync(cancellationToken); + committed = true; + + return ToolResult.Success(new JsonObject + { + ["media"] = ToolJson.Media(asset.Value), + ["changeSet"] = ToolJson.ChangeSet(set.Value), + ["next"] = $"The {ToolJson.Name(proposed.Value.Kind)} is in change set {set.Value.Id}. Attach the asset to a field with " + + "attach_media, and call submit_change_set when the set is complete.", + }); + } + finally + { + if (uploaded && !committed && written is { } orphan + && services.GetRequiredService().Find(orphan.Latest.Blob.Storage) is { } storage) + { + await storage.DeleteAsync(orphan.Latest.Blob.Key, CancellationToken.None); + } + } + } + + // The decoded length follows from the text's length, so an oversized file is refused before anything is decoded. + private static byte[]? Base64(string content, long maxBytes, List errors) + { + var characters = content.Count(character => !char.IsWhiteSpace(character)); + var padding = content.TrimEnd().EndsWith("==", StringComparison.Ordinal) ? 2 : content.TrimEnd().EndsWith('=') ? 1 : 0; + if ((long)characters / 4 * 3 - padding > maxBytes) + { + errors.Add(new Error(MediaErrorCodes.MediaTooLarge, $"The file has more than {maxBytes} bytes, and an upload may have at most {maxBytes}.")); + return null; + } + + try + { + return Convert.FromBase64String(content); + } + catch (FormatException) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, "'content_base64' must be the file's bytes in base64.")); + return null; + } + } + + private static string? Hint(IReadOnlyList errors) + { + return errors.FirstOrDefault()?.Code switch + { + MediaErrorCodes.VersionConflict => "Call get_media for the latest version and its metadata, then update from that version.", + MediaErrorCodes.AssetNotFound => "Call list_media for the ids of the site's assets.", + MediaErrorCodes.MediaTooLarge => "Upload a smaller file; the administrator sets the limit with Media__MaxBytes.", + _ => null, + }; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs index 85806c136..2d8fd65b3 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -6,6 +6,7 @@ using FluentCMS.Core.Entities; using FluentCMS.Core.Identity; using FluentCMS.Core.Jobs; +using FluentCMS.Core.Media; using FluentCMS.Core.Schema; namespace FluentCMS.Core.Builtin.Tools; @@ -294,6 +295,43 @@ public static JsonObject BlastRadius(BlastRadius radius) }; } + public static JsonObject Media(MediaAsset asset, MediaAssetVersion? version = null) + { + var shown = version ?? asset.Latest; + + return new JsonObject + { + ["id"] = asset.Id.ToString(), + ["latestVersion"] = asset.LatestVersion, + ["publishedVersion"] = asset.PublishedVersion, + ["version"] = shown.Version, + ["contentType"] = shown.Blob.ContentType, + ["length"] = shown.Blob.Length, + ["sha256"] = shown.Blob.Sha256, + ["fileName"] = shown.Metadata.FileName, + ["folder"] = shown.Metadata.Folder, + ["tags"] = new JsonArray([.. shown.Metadata.Tags.Select(tag => JsonValue.Create(tag))]), + ["altText"] = shown.Metadata.AltText, + ["caption"] = shown.Metadata.Caption, + }; + } + + public static JsonObject MediaSummary(MediaAssetSummary asset) + { + return new JsonObject + { + ["id"] = asset.Id.ToString(), + ["latestVersion"] = asset.LatestVersion, + ["publishedVersion"] = asset.PublishedVersion, + ["fileName"] = asset.FileName, + ["contentType"] = asset.ContentType, + ["length"] = asset.Length, + ["folder"] = asset.Folder, + ["tags"] = new JsonArray([.. asset.Tags.Select(tag => JsonValue.Create(tag))]), + ["hasAltText"] = asset.HasAltText, + }; + } + private static JsonObject Relation(EntityRelation relation) { return new JsonObject diff --git a/src/FluentCMS.Core/ChangeSets/ItemDiff.cs b/src/FluentCMS.Core/ChangeSets/ItemDiff.cs index 4ae0fa130..ed64fd910 100644 --- a/src/FluentCMS.Core/ChangeSets/ItemDiff.cs +++ b/src/FluentCMS.Core/ChangeSets/ItemDiff.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Text.Json; +using FluentCMS.Core.Media; using FluentCMS.Core.Schema; namespace FluentCMS.Core.ChangeSets; @@ -75,6 +76,64 @@ public static ItemDiff ForEntry( return new ItemDiff(operation, [], ValueDiff.Compare(EntryValues(baseFields, order), EntryValues(proposedFields, order))); } + // The base version is absent for a create and the proposed one for a delete, as the operation's versions say. The metadata + // people and agents write are fields; the bytes, which a metadata change keeps, are properties. + public static ItemDiff ForMedia(ChangeOperation operation, MediaAssetVersion? baseVersion, MediaAssetVersion? proposedVersion) + { + ArgumentNullException.ThrowIfNull(operation); + + if (operation.ItemKind != ItemKind.Media) + { + throw new ArgumentException($"A media diff is for a media operation, and this one is on a {operation.ItemKind} item.", nameof(operation)); + } + + if ((operation.BaseVersion is null) != (baseVersion is null) || (operation.ProposedVersion is null) != (proposedVersion is null)) + { + throw new ArgumentException("A media diff takes a version for each version the operation names, and only for those."); + } + + return new ItemDiff( + operation, + ValueDiff.Compare(MediaProperties(baseVersion), MediaProperties(proposedVersion)), + ValueDiff.Compare(MediaFields(baseVersion), MediaFields(proposedVersion))); + } + + private static IEnumerable> MediaProperties(MediaAssetVersion? version) + { + return version is null + ? [] + : + [ + new("contentType", JsonSerializer.SerializeToElement(version.Blob.ContentType)), + new("length", JsonSerializer.SerializeToElement(version.Blob.Length)), + new("sha256", JsonSerializer.SerializeToElement(version.Blob.Sha256)), + ]; + } + + // Alt text and a caption that are absent are absent here too, so adding one reads as added. + private static IEnumerable> MediaFields(MediaAssetVersion? version) + { + if (version is null) + { + yield break; + } + + var metadata = version.Metadata; + yield return new("fileName", JsonSerializer.SerializeToElement(metadata.FileName)); + yield return new("folder", JsonSerializer.SerializeToElement(metadata.Folder)); + yield return new("tags", JsonSerializer.SerializeToElement(metadata.Tags)); + + if (metadata.AltText is { } altText) + { + yield return new("altText", JsonSerializer.SerializeToElement(altText)); + } + + if (metadata.Caption is { } caption) + { + yield return new("caption", JsonSerializer.SerializeToElement(caption)); + } + } + // A field the definition no longer has goes last, by name. private static IEnumerable> EntryValues(JsonElement? fields, Dictionary order) { diff --git a/src/FluentCMS.Core/Media/IMediaLibrary.cs b/src/FluentCMS.Core/Media/IMediaLibrary.cs index 0087add71..d32472a5b 100644 --- a/src/FluentCMS.Core/Media/IMediaLibrary.cs +++ b/src/FluentCMS.Core/Media/IMediaLibrary.cs @@ -6,6 +6,9 @@ namespace FluentCMS.Core.Media; // afterwards, and appends its audit event in the same unit of work (CNT-02, AUD-N1). public interface IMediaLibrary { + // The largest upload accepted, in bytes, so a caller can refuse a larger one before reading it. + long MaxBytes { get; } + // The bytes go to the configured storage before anything is written; a refused upload deletes them again. Task> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default); @@ -57,4 +60,5 @@ public static class MediaErrorCodes public const string VersionNotFound = "version_not_found"; public const string VersionConflict = "version_conflict"; public const string ContentMissing = "media_content_missing"; + public const string FieldNotMedia = "field_not_media"; } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index fda8df643..960ef1153 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -4,10 +4,12 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; using FluentCMS.Core.Identity; +using FluentCMS.Core.Media; using FluentCMS.Core.Persistence; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Media; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -60,6 +62,7 @@ public async Task> DiffAsync(Guid id, CancellationToken ca var set = stored.ChangeSet; var definitions = await DefinitionsAsync(set.Operations, cancellationToken); var entries = await EntryDiffsAsync(set, cancellationToken); + var media = await MediaDiffsAsync(set, cancellationToken); var items = set.Operations .Select((operation, position) => operation.ItemKind switch @@ -69,6 +72,7 @@ public async Task> DiffAsync(Guid id, CancellationToken ca Definition(definitions, set.Id, operation.ItemId, operation.BaseVersion), Definition(definitions, set.Id, operation.ItemId, operation.ProposedVersion)), ItemKind.Entry or ItemKind.Entity => entries[position], + ItemKind.Media => media[position], _ => throw new InvalidOperationException( $"Change set {set.Id} holds an operation on a {ItemSubjectType(operation.ItemKind)} item, which no diff covers yet."), }) @@ -672,12 +676,17 @@ private async Task UpdateAsync(int revision, ChangeSet set, CancellationTo return await ResolveEntryAsync(operation, cancellationToken); } + if (operation.ItemKind == ItemKind.Media) + { + return await ResolveMediaAsync(operation, cancellationToken); + } + if (operation.ItemKind != ItemKind.Schema) { return Result.Failure( ChangeSetErrorCodes.ItemKindUnavailable, $"There are no stored {ItemSubjectType(operation.ItemKind)} items yet, so a change set cannot point at one; " - + "only schema, entry and entity operations can be added."); + + "only schema, entry, entity and media operations can be added."); } var slug = await context.ContentTypes @@ -769,13 +778,14 @@ private async Task UpdateAsync(int revision, ChangeSet set, CancellationTo // operation's base version, and a create's item not at all, so a publish never replaces a version its set has not seen. private async Task>> PublishableItemsAsync(ChangeSet set, CancellationToken cancellationToken) { - if (set.Operations.FirstOrDefault(operation => operation.ItemKind is not (ItemKind.Schema or ItemKind.Entry or ItemKind.Entity)) is { } other) + if (set.Operations.FirstOrDefault(operation => operation.ItemKind is not (ItemKind.Schema or ItemKind.Entry or ItemKind.Entity or ItemKind.Media)) is { } other) { throw new InvalidOperationException( $"Change set {set.Id} holds an operation on a {ItemSubjectType(other.ItemKind)} item, which nothing publishes yet."); } var entries = await PublishedEntriesAsync(set.Operations, cancellationToken); + var media = await PublishedMediaAsync(set.Operations, cancellationToken); var ids = set.Operations.Select(operation => operation.ItemId).ToList(); var bases = set.Operations.Select(operation => operation.BaseVersion).OfType().Distinct().ToList(); @@ -795,9 +805,9 @@ private async Task>> PublishableItemsAsync(ChangeSe var errors = new List(); foreach (var operation in set.Operations) { - if (operation.ItemKind is ItemKind.Entry or ItemKind.Entity) + if (operation.ItemKind is ItemKind.Entry or ItemKind.Entity or ItemKind.Media) { - if (PublishableEntry(set, operation, entries, errors) is { } item) + if (PublishableEntry(set, operation, operation.ItemKind == ItemKind.Media ? media : entries, errors) is { } item) { items.Add(item); } @@ -827,6 +837,14 @@ private async Task>> PublishableItemsAsync(ChangeSe // Moves only from the base version, so a publish over the same item that committed first leaves nothing to move. private async Task MovePublishedVersionAsync(ChangeOperation operation, bool revert, CancellationToken cancellationToken) { + if (operation.ItemKind == ItemKind.Media) + { + // 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) + .ExecuteUpdateAsync(setters => setters.SetProperty(asset => asset.PublishedVersion, operation.ProposedVersion), cancellationToken) == 1; + } + if (operation.ItemKind is ItemKind.Entry or ItemKind.Entity) { // A delete published from here unpublishes the entry; its versions and its draft stay. The state moves with the pointer, @@ -1024,6 +1042,102 @@ private async Task> PublishedEntriesAsync(IRead return new PublishableItem(operation, name, operation.BaseVersion is { } baseVersion ? entry.VersionIds[baseVersion] : null); } + // A media operation points at an asset of the site and at stored versions of it (MED-03); an agent needs its scope to allow the + // operation on media. + private async Task> ResolveMediaAsync(ChangeOperation operation, CancellationToken cancellationToken) + { + if (!await context.MediaAssets.AnyAsync(asset => asset.Id == operation.ItemId && asset.SiteId == SiteId, cancellationToken)) + { + return Result.Failure(ChangeSetErrorCodes.ItemNotFound, $"There is no media asset {operation.ItemId} in this site."); + } + + var allowed = await permissions.CheckMediaAsync(AgentOperationOf(operation.Kind), cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var wanted = new[] { operation.BaseVersion, operation.ProposedVersion }.OfType().ToList(); + var stored = await context.MediaAssetVersions + .AsNoTracking() + .Where(version => version.AssetId == operation.ItemId && wanted.Contains(version.Version)) + .Select(version => new { version.Version, version.Id }) + .ToListAsync(cancellationToken); + + var missing = wanted.Except(stored.Select(version => version.Version)).ToList(); + if (missing.Count > 0) + { + return Result.Failure(missing.Select(version => new Error( + ChangeSetErrorCodes.VersionNotFound, + $"Media asset {operation.ItemId} has no version {version}."))); + } + + return Result.Success(stored.SingleOrDefault(version => version.Version == operation.BaseVersion)?.Id); + } + + // The diff of every media operation, by its position in the set, from all the versions they name in one query. + private async Task> MediaDiffsAsync(ChangeSet set, CancellationToken cancellationToken) + { + var operations = set.Operations + .Select((operation, position) => (Operation: operation, Position: position)) + .Where(item => item.Operation.ItemKind == ItemKind.Media) + .ToList(); + if (operations.Count == 0) + { + return []; + } + + var ids = operations.Select(item => item.Operation.ItemId).Distinct().ToList(); + var numbers = operations.SelectMany(item => new[] { item.Operation.BaseVersion, item.Operation.ProposedVersion }).OfType().Distinct().ToList(); + var rows = await context.MediaAssetVersions + .AsNoTracking() + .Where(version => ids.Contains(version.AssetId) && numbers.Contains(version.Version)) + .ToListAsync(cancellationToken); + var versions = rows.ToDictionary(row => (row.AssetId, row.Version), row => row.ToVersion()); + + // Versions are never changed and an operation is checked against them when it is added, so a missing one is a broken store. + MediaAssetVersion? Stored(Guid assetId, int? number) + { + return number is { } version + ? versions.GetValueOrDefault((assetId, version)) + ?? throw new InvalidOperationException($"Change set {set.Id} points at version {version} of media asset {assetId}, which is not stored.") + : null; + } + + return operations.ToDictionary( + item => item.Position, + item => ItemDiff.ForMedia(item.Operation, Stored(item.Operation.ItemId, item.Operation.BaseVersion), Stored(item.Operation.ItemId, item.Operation.ProposedVersion))); + } + + // Each asset the operations name with its published version and the rows of their base versions. + private async Task> PublishedMediaAsync(IReadOnlyList operations, CancellationToken cancellationToken) + { + var mediaOperations = operations.Where(operation => operation.ItemKind == ItemKind.Media).ToList(); + if (mediaOperations.Count == 0) + { + return []; + } + + var ids = mediaOperations.Select(operation => operation.ItemId).Distinct().ToList(); + var bases = mediaOperations.Select(operation => operation.BaseVersion).OfType().Distinct().ToList(); + + var assets = await context.MediaAssets + .AsNoTracking() + .Where(asset => asset.SiteId == SiteId && ids.Contains(asset.Id)) + .Select(asset => new { asset.Id, asset.PublishedVersion }) + .ToListAsync(cancellationToken); + var versionIds = (await context.MediaAssetVersions + .AsNoTracking() + .Where(version => ids.Contains(version.AssetId) && bases.Contains(version.Version)) + .Select(version => new { version.AssetId, version.Version, version.Id }) + .ToListAsync(cancellationToken)) + .ToLookup(version => version.AssetId); + + return assets.ToDictionary( + asset => asset.Id, + asset => new PublishedEntry(asset.PublishedVersion, Deleted: false, versionIds[asset.Id].ToDictionary(version => version.Version, version => version.Id))); + } + private static string Capitalize(string text) { return char.ToUpperInvariant(text[0]) + text[1..]; diff --git a/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs index e9353360e..6b28fac2f 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs @@ -1,3 +1,5 @@ +using FluentCMS.Core.Media; + namespace FluentCMS.Infrastructure.Media; // An asset and its pointers to the latest version and the published one; each version is its own MediaAssetVersionRow. @@ -42,4 +44,14 @@ public sealed class MediaAssetVersionRow public string? Caption { get; set; } public DateTimeOffset CreatedAt { get; set; } + + internal MediaAssetVersion ToVersion() + { + return new MediaAssetVersion( + AssetId, + Version, + new MediaBlob(ContentType, Length, Sha256, Storage, StorageKey), + new AssetMetadata(FileName, Folder, Tags, AltText, Caption), + CreatedAt); + } } diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index c25416fe3..0828dd90e 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -24,6 +24,8 @@ internal sealed class MediaLibrary( // Until multi-site (MST-01), every asset belongs to the one site (SCH-07). private static readonly Guid SiteId = Site.Default.Id; + public long MaxBytes => options.Value.MaxBytes; + public async Task> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(asset); @@ -119,7 +121,7 @@ public async Task> UpdateAsync(Guid id, int baseVersion, Asse var baseRow = await VersionRowAsync(id, baseVersion, cancellationToken) ?? throw new InvalidOperationException($"Media asset {id} points at version {baseVersion}, which is not stored."); - var version = ToVersion(baseRow) with { Version = baseVersion + 1, Metadata = checkedMetadata!, CreatedAt = clock.GetUtcNow() }; + var version = baseRow.ToVersion() with { Version = baseVersion + 1, Metadata = checkedMetadata!, CreatedAt = clock.GetUtcNow() }; await using var scope = await unitOfWork.BeginAsync(cancellationToken); @@ -160,7 +162,7 @@ public async Task> GetAsync(Guid id, CancellationToken cancel var latest = await VersionRowAsync(id, row.LatestVersion, cancellationToken) ?? throw new InvalidOperationException($"Media asset {id} points at version {row.LatestVersion}, which is not stored."); - return Result.Success(new MediaAsset(id, row.SiteId, row.LatestVersion, row.PublishedVersion, ToVersion(latest))); + return Result.Success(new MediaAsset(id, row.SiteId, row.LatestVersion, row.PublishedVersion, latest.ToVersion())); } public async Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) @@ -176,7 +178,7 @@ public async Task> GetVersionAsync(Guid id, int versio ? Result.Failure( MediaErrorCodes.VersionNotFound, $"Media asset {id} has no version {version}; its latest version is {row.LatestVersion}.") - : Result.Success(ToVersion(stored)); + : Result.Success(stored.ToVersion()); } public async Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default) @@ -310,16 +312,6 @@ private static MediaAssetVersionRow VersionRow(MediaAssetVersion version) }; } - private static MediaAssetVersion ToVersion(MediaAssetVersionRow row) - { - return new MediaAssetVersion( - row.AssetId, - row.Version, - new MediaBlob(row.ContentType, row.Length, row.Sha256, row.Storage, row.StorageKey), - new AssetMetadata(row.FileName, row.Folder, row.Tags, row.AltText, row.Caption), - row.CreatedAt); - } - private static Error NotFound(Guid id) { return new Error(MediaErrorCodes.AssetNotFound, $"There is no media asset {id} in this site."); diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs index f0431b35f..b342d74e4 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs @@ -115,7 +115,7 @@ public async Task An_operation_that_does_not_point_at_stored_versions_fails_with ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, category.Id, 3, 5).Value, [ChangeSetErrorCodes.VersionNotFound, ChangeSetErrorCodes.VersionNotFound] ), - (ChangeOperation.Create(OperationKind.Update, ItemKind.Media, Guid.NewGuid(), 1, 2).Value, [ChangeSetErrorCodes.ItemKindUnavailable]), + (ChangeOperation.Create(OperationKind.Update, ItemKind.Rule, Guid.NewGuid(), 1, 2).Value, [ChangeSetErrorCodes.ItemKindUnavailable]), (ChangeOperation.Create(OperationKind.Delete, ItemKind.Schema, product.Id, 2, null).Value, [ChangeSetErrorCodes.ItemDuplicate]), ]; diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs new file mode 100644 index 000000000..13bb8e1cf --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs @@ -0,0 +1,129 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Media; +using FluentCMS.Core.Persistence; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Media; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; +using static FluentCMS.IntegrationTests.ChangeSets.EntryChangeSetTests; + +namespace FluentCMS.IntegrationTests.ChangeSets; + +public sealed class MediaChangeSetTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Media_operations_are_diffed_published_and_reverted(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var asset = await UploadAsync(scenario, Metadata(null)); + + var created = await scenario.OpenAsync(Operation(OperationKind.Create, ItemKind.Media, asset, null, 1)); + var createDiff = Assert.Single((await scenario.RunAsync(directory => directory.DiffAsync(created.Id))).Value.Items); + await scenario.PublishAsync(created.Id); + + Assert.Equal(["contentType", "length", "sha256"], createDiff.Properties.Select(property => property.Name)); + Assert.Equal(["fileName", "folder", "tags"], createDiff.Fields.Select(field => field.Name)); + Assert.Equal(1, await PublishedMediaVersionAsync(scenario, asset)); + var published = Assert.Single(await scenario.ChangeSetEventsAsync(created.Id), row => row.Operation == "media.published"); + Assert.Equal(("media", asset), (published.SubjectType, published.SubjectId!.Value)); + + await UpdateAsync(scenario, asset, 1, Metadata("A cordless drill on a bench.")); + var update = await scenario.PublishAsync(Operation(OperationKind.Update, ItemKind.Media, asset, 1, 2)); + var updateDiff = Assert.Single((await scenario.RunAsync(directory => directory.DiffAsync(update.Id))).Value.Items); + + Assert.Empty(updateDiff.Properties); + var altText = Assert.Single(updateDiff.Fields); + Assert.Equal(("altText", ValueChange.Added, "A cordless drill on a bench."), (altText.Name, altText.Change, altText.After!.Value.GetString())); + Assert.Equal(2, await PublishedMediaVersionAsync(scenario, asset)); + + var revert = (await scenario.RunAsync(directory => directory.RevertAsync(update.Id, "The alt text was wrong."))).Value; + await scenario.PublishAsync(revert.Id); + + Assert.Equal(1, await PublishedMediaVersionAsync(scenario, asset)); + Assert.Equal(ChangeSetState.Reverted, (await scenario.ChangeSetRowAsync(update.Id)).State); + + var delete = await scenario.PublishAsync(Operation(OperationKind.Delete, ItemKind.Media, asset, 1, null)); + Assert.Null(await PublishedMediaVersionAsync(scenario, asset)); + Assert.Single(await scenario.ChangeSetEventsAsync(delete.Id), row => row.Operation == "media.unpublished"); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_stale_base_or_a_failure_mid_publish_moves_nothing(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var asset = await UploadAsync(scenario, Metadata(null)); + var winner = await scenario.ApproveAsync(Operation(OperationKind.Create, ItemKind.Media, asset, null, 1)); + var loser = await scenario.ApproveAsync(Operation(OperationKind.Create, ItemKind.Media, asset, null, 1)); + + for (var append = 1; append <= 2; append++) + { + scenario.FailOnAppend(append); + await Assert.ThrowsAsync(() => scenario.RunAsync(directory => directory.PublishAsync(winner.Id))); + Assert.Null(await PublishedMediaVersionAsync(scenario, asset)); + } + + Assert.True((await scenario.RunAsync(directory => directory.PublishAsync(winner.Id))).IsSuccess); + var stale = await scenario.RunAsync(directory => directory.PublishAsync(loser.Id)); + + Assert.Equal(ChangeSetErrorCodes.BaseVersionStale, Assert.Single(stale.Errors).Code); + Assert.Equal(1, await PublishedMediaVersionAsync(scenario, asset)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Media_operations_point_at_stored_versions_and_need_the_agents_operation(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var asset = await UploadAsync(scenario, Metadata(null)); + var set = await scenario.OpenAsync(); + + var unknown = await scenario.RunAsync(directory => directory.AddOperationAsync(set.Id, Operation(OperationKind.Create, ItemKind.Media, Guid.NewGuid(), null, 1))); + var missingVersion = await scenario.RunAsync(directory => directory.AddOperationAsync(set.Id, Operation(OperationKind.Update, ItemKind.Media, asset, 1, 2))); + Assert.Equal(ChangeSetErrorCodes.ItemNotFound, Assert.Single(unknown.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.VersionNotFound, Assert.Single(missingVersion.Errors).Code); + + await scenario.UpdateAgentAsync(row => + { + row.Types = ["product"]; + row.Operations = [AgentOperation.Read, AgentOperation.Update]; + }); + scenario.Initiator = Agent; + var agentSet = await scenario.OpenAsync(); + + var refused = await scenario.RunAsync(directory => directory.AddOperationAsync(agentSet.Id, Operation(OperationKind.Create, ItemKind.Media, asset, null, 1))); + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Assert.Single(refused.Errors).Code); + + await scenario.UpdateAgentAsync(row => row.Operations = [AgentOperation.Read, AgentOperation.Create]); + Assert.True((await scenario.RunAsync(directory => directory.AddOperationAsync(agentSet.Id, Operation(OperationKind.Create, ItemKind.Media, asset, null, 1)))).IsSuccess); + } + + private static AssetMetadata Metadata(string? altText) + { + return AssetMetadata.Create("drill.jpg", "products", ["product-shot"], altText, null).Value; + } + + private static async Task UploadAsync(ChangeSetScenario scenario, AssetMetadata metadata) + { + await using var scope = scenario.Services.CreateAsyncScope(); + var asset = await scope.ServiceProvider.GetRequiredService().UploadAsync(new NewAsset(new MemoryStream(MediaFiles.Jpeg()), "image/jpeg", metadata)); + return asset.Value.Id; + } + + private static async Task UpdateAsync(ChangeSetScenario scenario, Guid asset, int baseVersion, AssetMetadata metadata) + { + await using var scope = scenario.Services.CreateAsyncScope(); + Assert.True((await scope.ServiceProvider.GetRequiredService().UpdateAsync(asset, baseVersion, metadata)).IsSuccess); + } + + private static Task PublishedMediaVersionAsync(ChangeSetScenario scenario, Guid asset) + { + return scenario.QueryAsync(context => context.MediaAssets.AsNoTracking().Where(row => row.Id == asset).Select(row => row.PublishedVersion).SingleAsync()); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index c09c1db96..2f5222638 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -21,11 +21,12 @@ internal static class McpAgents // The built-in tools every server lists, sorted by name. public static readonly string[] BuiltInTools = [ - "check_draft", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", "get_change_set_checks", - "get_change_set_diff", "get_change_set_review", "get_content_type", "get_desk_counts", "get_entity_facts", "get_entity_relations", - "get_entry", "get_entry_history", "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", - "list_desk_queue", "list_entries", "list_stale_facts", "list_unsourced_facts", "open_change_set", "relate_entities", - "reopen_change_set", "revise_content_type", "search_entries", "set_entity_fact", "submit_change_set", "update_entry", + "attach_media", "check_draft", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", + "get_change_set_checks", "get_change_set_diff", "get_change_set_review", "get_content_type", "get_desk_counts", + "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", "get_media", "get_resolved_entry", + "get_schema_document", "get_where_used", "list_content_types", "list_desk_queue", "list_entries", "list_media", + "list_stale_facts", "list_unsourced_facts", "open_change_set", "relate_entities", "reopen_change_set", "revise_content_type", + "search_entries", "set_entity_fact", "submit_change_set", "update_entry", "update_media", "upload_media", ]; public static async Task ToolNamesAsync(McpClient client) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs new file mode 100644 index 000000000..77389a3fb --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -0,0 +1,205 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Media; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class MediaToolsTests +{ + private static readonly string Image = Convert.ToBase64String([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4]); + + // S2 in the F-MED acceptance, as far as media goes: the image and the page that shows it arrive in one set. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_uploads_an_image_and_attaches_it_to_a_page_in_one_set(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, types: ["product-page"]); + var page = await PageAsync(agent); + + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["folder"] = "products/drills", + ["tags"] = new[] { "product-shot" }, + ["session_id"] = "launch", + ["rationale"] = "Show the drill on its page.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + var attached = await agent.CallAsync("attach_media", new() + { + ["entry_id"] = page.ToString(), + ["base_version"] = 1, + ["field"] = "hero", + ["asset_id"] = assetId, + ["session_id"] = "launch", + ["rationale"] = "Ignored once the set is open.", + }); + + var setId = (string)attached["changeSet"]!["id"]!; + Assert.Equal(setId, (string?)uploaded["changeSet"]!["id"]); + Assert.Equal( + [("create", "media"), ("update", "entry")], + attached["changeSet"]!["operations"]!.AsArray().Select(operation => ((string)operation!["kind"]!, (string)operation["itemKind"]!))); + Assert.Equal(assetId, (string?)attached["entry"]!["fields"]!["hero"]); + Assert.Null((string?)uploaded["media"]!["altText"]); + + await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + await agent.AsPersonAsync(directory => directory.TransitionAsync(Guid.Parse(setId), ChangeSetState.Approved)); + var published = await agent.AsPersonAsync(directory => directory.PublishAsync(Guid.Parse(setId))); + + Assert.Equal(agent.Credential.Initiator, published.Initiator); + Assert.Equal(1, await agent.QueryAsync(context => context.MediaAssets.Select(row => row.PublishedVersion).SingleAsync())); + Assert.Equal(2, await agent.QueryAsync(context => context.Entries.Where(row => row.Id == page).Select(row => row.PublishedVersion).SingleAsync())); + + var listed = await agent.CallAsync("list_media", new() { ["folder"] = "products", ["include_subfolders"] = true }); + Assert.Equal(assetId, (string?)Assert.Single(listed["media"]!.AsArray())!["id"]); + Assert.False((bool)listed["media"]![0]!["hasAltText"]!); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Alt_text_added_later_is_in_the_proposed_version_and_the_other_metadata_stays(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["tags"] = new[] { "product-shot" }, + ["session_id"] = "upload", + ["rationale"] = "Upload.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + + var described = await agent.CallAsync("update_media", new() + { + ["id"] = assetId, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "describe", + ["rationale"] = "Describe the drill for people who cannot see it.", + }); + var diff = await agent.CallAsync("get_change_set_diff", new() { ["id"] = (string)described["changeSet"]!["id"]! }); + var media = await agent.CallAsync("get_media", new() { ["id"] = assetId }); + + var operation = Assert.Single(described["changeSet"]!["operations"]!.AsArray())!; + Assert.Equal(("create", 2), ((string)operation["kind"]!, (int)operation["proposedVersion"]!)); + // Never published, so the set proposes the whole asset, now with its alt text. + var altText = Assert.Single(diff["items"]![0]!["fields"]!.AsArray(), field => (string?)field!["name"] == "altText")!; + Assert.Equal(("added", "A cordless drill on a workbench."), ((string)altText["change"]!, (string)altText["after"]!)); + Assert.Equal(("drill.png", "product-shot"), ((string)media["fileName"]!, (string)media["tags"]![0]!)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_upload_over_the_limit_or_unlike_its_type_is_refused_and_stores_nothing(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: new Dictionary { ["Media:MaxBytes"] = "8" }); + + var large = await agent.FailAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "upload", + ["rationale"] = "Upload.", + }); + var disguised = await agent.FailAsync("upload_media", new() + { + ["content_base64"] = Convert.ToBase64String(""u8), + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "upload", + ["rationale"] = "Upload.", + }); + + Assert.Equal(MediaErrorCodes.MediaTooLarge, Code(large)); + Assert.Equal(MediaErrorCodes.ContentMismatch, Code(disguised)); + Assert.Equal(0, await agent.QueryAsync(context => context.MediaAssets.CountAsync())); + Assert.Equal(0, await agent.QueryAsync(context => context.ChangeSets.CountAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Refused_uploads_and_attachments_say_why_and_store_nothing(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, operations: [AgentOperation.Read, AgentOperation.Update]); + var page = await PageAsync(agent); + + var noCreate = await agent.FailAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "upload", + ["rationale"] = "Upload.", + }); + var notBase64 = await agent.FailAsync("upload_media", new() + { + ["content_base64"] = "not base64!", + ["content_type"] = "image/png", + ["file_name"] = "a/b.png", + ["session_id"] = "upload", + ["rationale"] = "Upload.", + }); + var notMedia = await agent.FailAsync("attach_media", new() + { + ["entry_id"] = page.ToString(), + ["base_version"] = 1, + ["field"] = "headline", + ["asset_id"] = Guid.NewGuid().ToString(), + ["session_id"] = "attach", + ["rationale"] = "Attach.", + }); + var missingAsset = await agent.FailAsync("attach_media", new() + { + ["entry_id"] = page.ToString(), + ["base_version"] = 1, + ["field"] = "hero", + ["asset_id"] = Guid.NewGuid().ToString(), + ["session_id"] = "attach", + ["rationale"] = "Attach.", + }); + + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(noCreate)); + Assert.Equal(["argument_invalid", "file_name_invalid"], notBase64["errors"]!.AsArray().Select(error => (string)error!["code"]!)); + Assert.Equal("field_not_media", Code(notMedia)); + Assert.Equal(ContentErrorCodes.ReferenceNotFound, Code(missingAsset)); + Assert.Equal(0, await agent.QueryAsync(context => context.MediaAssets.CountAsync())); + Assert.Equal(0, await agent.QueryAsync(context => context.ChangeSets.CountAsync(row => row.SessionId != TestChangeSetTargets.ContentModelSession))); + } + + // A published product page with a headline and an empty hero image, defined and written by a person. + private static async Task PageAsync(ConnectedAgent agent) + { + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType( + "Product page", + "product-page", + new ContentTypeDefinition([new FieldDefinition("headline", "text"), new FieldDefinition("hero", "media")])), + TestChangeSetTargets.ContentModel)); + var page = await agent.AsPersonAsync(entries => + entries.CreateAsync(new NewEntry("product-page", JsonSerializer.Deserialize("""{"headline": "The Widget"}""")))); + await agent.QueryAsync(context => context.Entries + .Where(row => row.Id == page.Id) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, 1))); + return page.Id; + } + + private static string? Code(JsonObject failure) + { + return (string?)failure["errors"]![0]!["code"]; + } +} diff --git a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs index 36ba19673..cd2db4fac 100644 --- a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs +++ b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs @@ -143,6 +143,8 @@ private static AdminTestContext Context(FakeLibrary library) private sealed class FakeLibrary : IMediaLibrary { + public long MaxBytes => 50 * 1024 * 1024; + public MediaAssetSummary Drill { get; } = new(Guid.CreateVersion7(), 2, 1, "drill.jpg", "image/jpeg", 2048, "products/drills", ["hero", "product-shot"], true); public MediaAssetSummary NoAlt { get; } = new(Guid.CreateVersion7(), 1, null, "saw.png", "image/png", 512, "products", [], false); diff --git a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs index d9fb82d69..6fdd3c4e7 100644 --- a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs +++ b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs @@ -151,6 +151,8 @@ public Task> GetAsync(Guid id, CancellationToken cancellationToken private sealed class FakeMedia : IMediaLibrary { + public long MaxBytes => 50 * 1024 * 1024; + public Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default) { IReadOnlyList assets = diff --git a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index 0d3efdb9d..23425d0ae 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -36,7 +36,9 @@ public sealed class McpCoverageTests new("CHK", "Read the check results of a change set", ["get_change_set_checks"]), new("DSK", "Read the review desk's queues and counts, read-only", ["get_desk_counts", "list_desk_queue"]), new("DSK", "Read a change set's review status and decisions, read-only", ["get_change_set_review"]), - Missing("MED", "Upload and describe media", "#2427"), + new("MED", "Upload and describe media", ["upload_media", "update_media"]), + new("MED", "Read and list media", ["get_media", "list_media"]), + new("MED", "Attach media to a field", ["attach_media"]), new("Search", "Search entries and entities by their words", ["search_entries"]), Missing("MCP-08", "Bulk operations that return a change set id and report progress", "#2423"), ]; diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index cc9027110..d91370030 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -125,6 +125,11 @@ public sealed class EntityToolsConformanceTests : ToolProviderConformanceTests protected override object Provider { get; } = new EntityTools(); } +public sealed class MediaToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new MediaTools(); +} + public sealed class SearchToolsConformanceTests : ToolProviderConformanceTests { protected override object Provider { get; } = new SearchTools();