From 769597d861eeccf84a3a7753dd60bfb252855213 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:48:20 -0400 Subject: [PATCH 01/17] Store media assets with versioned metadata, folders and tags --- CONTRIBUTING.md | 2 +- src/FluentCMS.Core/Media/AssetMetadata.cs | 79 ++ src/FluentCMS.Core/Media/IMediaLibrary.cs | 59 + .../Content/EntryDirectory.cs | 69 +- ...frastructureServiceCollectionExtensions.cs | 1 + .../Media/MediaAssetConfigurations.cs | 56 + .../Media/MediaAssetRows.cs | 45 + .../Media/MediaLibrary.cs | 314 +++++ .../Media/MediaOptions.cs | 3 + .../Persistence/FluentCmsDbContext.cs | 7 + .../20261019014210_MediaAssets.Designer.cs | 1213 +++++++++++++++++ .../PostgreSql/20261019014210_MediaAssets.cs | 110 ++ ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 120 ++ .../20261019014200_MediaAssets.Designer.cs | 1203 ++++++++++++++++ .../Sqlite/20261019014200_MediaAssets.cs | 103 ++ .../SqliteFluentCmsDbContextModelSnapshot.cs | 120 ++ .../Content/WhereUsedTests.cs | 11 +- .../Media/MediaLibraryTests.cs | 172 +++ .../TemporaryDatabase.cs | 10 +- .../Media/AssetMetadataTests.cs | 67 + 20 files changed, 3734 insertions(+), 30 deletions(-) create mode 100644 src/FluentCMS.Core/Media/AssetMetadata.cs create mode 100644 src/FluentCMS.Core/Media/IMediaLibrary.cs create mode 100644 src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs create mode 100644 src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs create mode 100644 src/FluentCMS.Infrastructure/Media/MediaLibrary.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.cs create mode 100644 tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs create mode 100644 tests/FluentCMS.Tests/Media/AssetMetadataTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db7ada3da..5a4c1aa30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ The admin stylesheet is compiled by the Tailwind CSS standalone CLI (ADR-0011). Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `FLUENTCMS_TEST_POSTGRES` to a connection string for an empty database, for example `Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres`. CI does this with a service container. Without the variable those tests are skipped. -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`, `Media__Storage` (the storage new uploads go to, `local` by default) and `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) (how long a deleted entry can be restored, `30.00:00:00` 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`, `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) (how long a deleted entry can be restored, `30.00:00:00` by default), 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. diff --git a/src/FluentCMS.Core/Media/AssetMetadata.cs b/src/FluentCMS.Core/Media/AssetMetadata.cs new file mode 100644 index 000000000..c071de52b --- /dev/null +++ b/src/FluentCMS.Core/Media/AssetMetadata.cs @@ -0,0 +1,79 @@ +using System.Text.RegularExpressions; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; + +namespace FluentCMS.Core.Media; + +// What people and agents say about an asset (MED-01, MED-02): its file name, the folder it sits in, its tags, and the alt text and +// caption pages show with it. Folder is "" for the root or slug segments joined by slashes, such as "products/drills". +public sealed partial record AssetMetadata(string FileName, string Folder, IReadOnlyList Tags, string? AltText, string? Caption) +{ + public const int MaxFileNameLength = 255; + public const int MaxFolderLength = 512; + public const int MaxTags = 32; + public const int MaxAltTextLength = 1000; + public const int MaxCaptionLength = 2000; + + // Every problem at once, each naming its field (UX-03). The metadata returned is the one to store: trimmed, with tags in lowercase, + // each once, in the order given. + public static Result Create(string fileName, string? folder, IEnumerable? tags, string? altText, string? caption) + { + var errors = new List(); + var name = fileName?.Trim() ?? string.Empty; + var path = folder?.Trim().Trim('/') ?? string.Empty; + var tagList = (tags ?? []).Select(tag => tag?.Trim().ToLowerInvariant() ?? string.Empty).Distinct(StringComparer.Ordinal).ToList(); + + if (name.Length is 0 or > MaxFileNameLength || name is "." or ".." || name.Any(character => character is '/' or '\\' || char.IsControl(character))) + { + errors.Add(new Error( + MediaErrorCodes.FileNameInvalid, + $"fileName: the file name must not be blank, must be at most {MaxFileNameLength} characters and must not contain slashes, such as 'drill.jpg'.")); + } + + if (path.Length > MaxFolderLength || (path.Length > 0 && !path.Split('/').All(SchemaNames.IsSlug))) + { + errors.Add(new Error( + MediaErrorCodes.FolderInvalid, + $"folder: a folder is lowercase words joined by hyphens, with slashes between levels, such as 'products/drills', at most {MaxFolderLength} characters; leave it out for the root.")); + } + + if (tagList.Count > MaxTags || !tagList.All(SchemaNames.IsSlug)) + { + errors.Add(new Error( + MediaErrorCodes.TagInvalid, + $"tags: give at most {MaxTags} tags, each lowercase words joined by hyphens, such as 'product-shot'.")); + } + + if (!IsValidText(altText, MaxAltTextLength)) + { + errors.Add(new Error( + MediaErrorCodes.AltTextInvalid, + $"altText: alt text describes the image for people who cannot see it; it must not be blank and must be at most {MaxAltTextLength} characters. Leave it out instead.")); + } + + if (!IsValidText(caption, MaxCaptionLength)) + { + errors.Add(new Error( + MediaErrorCodes.CaptionInvalid, + $"caption: a caption must not be blank and must be at most {MaxCaptionLength} characters; leave it out instead.")); + } + + return errors.Count == 0 + ? Result.Success(new AssetMetadata(name, path, tagList, altText?.Trim(), caption?.Trim())) + : Result.Failure(errors); + } + + // "image/jpeg" and the like, as uploads name their content. + public static bool IsContentType(string? value) + { + return value is { Length: > 0 and <= 127 } && ContentTypePattern().IsMatch(value); + } + + private static bool IsValidText(string? text, int maxLength) + { + return text is null || (!string.IsNullOrWhiteSpace(text) && text.Trim().Length <= maxLength); + } + + [GeneratedRegex(@"^[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\z", RegexOptions.CultureInvariant)] + private static partial Regex ContentTypePattern(); +} diff --git a/src/FluentCMS.Core/Media/IMediaLibrary.cs b/src/FluentCMS.Core/Media/IMediaLibrary.cs new file mode 100644 index 000000000..d77e72762 --- /dev/null +++ b/src/FluentCMS.Core/Media/IMediaLibrary.cs @@ -0,0 +1,59 @@ +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Media; + +// The media assets of the site (MED-01, MED-02). Every upload and every metadata change stores a new version that is never changed +// afterwards, and appends its audit event in the same unit of work (CNT-02, AUD-N1). +public interface IMediaLibrary +{ + // The bytes go to the configured storage before anything is written; a refused upload deletes them again. + Task> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default); + + // The caller names the version it read; the new version keeps that version's bytes. + Task> UpdateAsync(Guid id, int baseVersion, AssetMetadata metadata, CancellationToken cancellationToken = default); + + Task> GetAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default); + + // By the latest versions' metadata; oldest asset first. + Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default); + + // The folders the latest versions sit in, each once, in order; the root is "". + Task> FoldersAsync(CancellationToken cancellationToken = default); + + // The bytes of the latest version, or of the version named. The caller disposes the stream. + Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default); +} + +public sealed record NewAsset(Stream Content, string ContentType, AssetMetadata Metadata); + +public sealed record MediaQuery(string? Folder = null, bool IncludeSubfolders = false, string? Tag = null); + +// An asset with its pointers and the latest version. +public sealed record MediaAsset(Guid Id, Guid SiteId, int LatestVersion, int? PublishedVersion, MediaAssetVersion Latest); + +// One version: the bytes it points at, as the storage that holds them knows them, and the metadata. +public sealed record MediaAssetVersion(Guid AssetId, int Version, MediaBlob Blob, AssetMetadata Metadata, DateTimeOffset CreatedAt); + +public sealed record MediaBlob(string ContentType, long Length, string Sha256, string Storage, string Key); + +public sealed record MediaAssetSummary(Guid Id, int LatestVersion, int? PublishedVersion, string FileName, string ContentType, long Length, string Folder, IReadOnlyList Tags, bool HasAltText); + +public sealed record MediaContent(Stream Content, string ContentType, string FileName, long Length); + +public static class MediaErrorCodes +{ + public const string FileNameInvalid = "file_name_invalid"; + public const string FolderInvalid = "folder_invalid"; + public const string TagInvalid = "tag_invalid"; + public const string AltTextInvalid = "alt_text_invalid"; + public const string CaptionInvalid = "caption_invalid"; + public const string ContentTypeInvalid = "content_type_invalid"; + public const string MediaEmpty = "media_empty"; + public const string MediaTooLarge = "media_too_large"; + public const string AssetNotFound = "asset_not_found"; + public const string VersionNotFound = "version_not_found"; + public const string VersionConflict = "version_conflict"; + public const string ContentMissing = "media_content_missing"; +} diff --git a/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs b/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs index ea100b81d..92c2b1723 100644 --- a/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs +++ b/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs @@ -400,42 +400,57 @@ join type in context.ContentTypes.AsNoTracking() on entry.ContentTypeId equals t .SingleOrDefaultAsync(cancellationToken); } - // Every entry a version points at must exist in the site and be of the type its field names (CNT-03). Media assets are checked - // once the library exists (F-MED). + // Every entry a version points at must exist in the site and be of the type its field names, and every media asset must exist + // (CNT-03, MED-01). private async Task> ReferenceErrorsAsync(IReadOnlyList references, CancellationToken cancellationToken) { + var errors = new List(); var entries = references.Where(reference => reference.Kind == ReferenceKind.Entry).ToList(); - if (entries.Count == 0) + if (entries.Count > 0) { - return []; - } + var ids = entries.Select(reference => reference.TargetId).Distinct().ToList(); + var types = await ( + from entry in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on entry.ContentTypeId equals type.Id + where entry.SiteId == SiteId && ids.Contains(entry.Id) && entry.DeletedAt == null + select new { entry.Id, type.Slug }) + .ToDictionaryAsync(found => found.Id, found => found.Slug, cancellationToken); - var ids = entries.Select(reference => reference.TargetId).Distinct().ToList(); - var types = await ( - from entry in context.Entries.AsNoTracking() - join type in context.ContentTypes.AsNoTracking() on entry.ContentTypeId equals type.Id - where entry.SiteId == SiteId && ids.Contains(entry.Id) && entry.DeletedAt == null - select new { entry.Id, type.Slug }) - .ToDictionaryAsync(found => found.Id, found => found.Slug, cancellationToken); + foreach (var reference in entries) + { + var field = reference.Pointer.Split('/')[1]; + var wanted = reference.Target!.Type; + + if (!types.TryGetValue(reference.TargetId, out var slug)) + { + errors.Add(new Error( + ContentErrorCodes.ReferenceNotFound, + $"Field '{field}': {reference.Pointer} points at {reference.TargetId}, which is not an entry in this site.")); + } + else if (slug != wanted) + { + errors.Add(new Error( + ContentErrorCodes.ReferenceTypeMismatch, + $"Field '{field}': {reference.Pointer} points at {reference.TargetId}, which is a '{slug}', but the field references '{wanted}'.")); + } + } + } - var errors = new List(); - foreach (var reference in entries) + var media = references.Where(reference => reference.Kind == ReferenceKind.Media).ToList(); + if (media.Count > 0) { - var field = reference.Pointer.Split('/')[1]; - var wanted = reference.Target!.Type; + var ids = media.Select(reference => reference.TargetId).Distinct().ToList(); + var assets = await context.MediaAssets + .AsNoTracking() + .Where(asset => asset.SiteId == SiteId && ids.Contains(asset.Id)) + .Select(asset => asset.Id) + .ToListAsync(cancellationToken); - if (!types.TryGetValue(reference.TargetId, out var slug)) - { - errors.Add(new Error( + errors.AddRange(media + .Where(reference => !assets.Contains(reference.TargetId)) + .Select(reference => new Error( ContentErrorCodes.ReferenceNotFound, - $"Field '{field}': {reference.Pointer} points at {reference.TargetId}, which is not an entry in this site.")); - } - else if (slug != wanted) - { - errors.Add(new Error( - ContentErrorCodes.ReferenceTypeMismatch, - $"Field '{field}': {reference.Pointer} points at {reference.TargetId}, which is a '{slug}', but the field references '{wanted}'.")); - } + $"Field '{reference.Pointer.Split('/')[1]}': {reference.Pointer} points at {reference.TargetId}, which is not a media asset in this site."))); } return errors; diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 99f4e4d3b..3ea20e305 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -102,6 +102,7 @@ private static void AddMedia(IServiceCollection services, IConfiguration configu services.AddFluentCmsExtension(); services.AddOptions().Bind(configuration.GetSection(MediaOptions.SectionName)).ValidateOnStart(); services.AddSingleton, MediaOptionsValidation>(); + services.AddScoped(); } private static void AddContent(IServiceCollection services, IConfiguration configuration) diff --git a/src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs b/src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs new file mode 100644 index 000000000..977f804f1 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs @@ -0,0 +1,56 @@ +using FluentCMS.Core.Media; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Media; + +internal sealed class MediaAssetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("MediaAssets", table => + { + table.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + table.HasCheckConstraint( + "CK_MediaAssets_PublishedVersion", + "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + row.HasKey(asset => asset.Id); + row.Property(asset => asset.Id).ValueGeneratedNever(); + + row.HasOne().WithMany().HasForeignKey(asset => asset.SiteId).OnDelete(DeleteBehavior.Restrict); + } +} + +internal sealed class MediaAssetVersionConfiguration : IEntityTypeConfiguration +{ + // Created by the MediaAssets migration on each provider. A migration that rebuilds the table on SQLite must create it again. + public const string NoUpdateTrigger = "TR_MediaAssetVersions_NoUpdate"; + + public void Configure(EntityTypeBuilder row) + { + row.ToTable("MediaAssetVersions", table => + { + table.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + table.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + table.HasTrigger(NoUpdateTrigger); + }); + row.HasKey(version => version.Id); + row.Property(version => version.Id).ValueGeneratedNever(); + row.Property(version => version.FileName).HasMaxLength(AssetMetadata.MaxFileNameLength).IsRequired(); + row.Property(version => version.ContentType).HasMaxLength(127).IsRequired(); + row.Property(version => version.Sha256).HasMaxLength(64).IsRequired(); + row.Property(version => version.Storage).HasMaxLength(SchemaNames.MaxLength).IsRequired(); + row.Property(version => version.StorageKey).HasMaxLength(256).IsRequired(); + row.Property(version => version.Folder).HasMaxLength(AssetMetadata.MaxFolderLength).IsRequired(); + row.Property(version => version.AltText).HasMaxLength(AssetMetadata.MaxAltTextLength); + row.Property(version => version.Caption).HasMaxLength(AssetMetadata.MaxCaptionLength); + + row.HasOne().WithMany().HasForeignKey(version => version.AssetId).OnDelete(DeleteBehavior.Restrict); + + row.HasIndex(version => new { version.AssetId, version.Version }).IsUnique(); + row.HasIndex(version => version.Folder); + } +} diff --git a/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs new file mode 100644 index 000000000..e9353360e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs @@ -0,0 +1,45 @@ +namespace FluentCMS.Infrastructure.Media; + +// An asset and its pointers to the latest version and the published one; each version is its own MediaAssetVersionRow. +public sealed class MediaAssetRow +{ + public Guid Id { get; set; } + + public Guid SiteId { get; set; } + + public int LatestVersion { get; set; } + + public int? PublishedVersion { get; set; } +} + +// One version of an asset's metadata with the bytes it points at; inserted once and never changed, which a trigger enforces. +public sealed class MediaAssetVersionRow +{ + public Guid Id { get; set; } + + public Guid AssetId { get; set; } + + public int Version { get; set; } + + public string FileName { get; set; } = string.Empty; + + public string ContentType { get; set; } = string.Empty; + + public long Length { get; set; } + + public string Sha256 { get; set; } = string.Empty; + + public string Storage { get; set; } = string.Empty; + + public string StorageKey { get; set; } = string.Empty; + + public string Folder { get; set; } = string.Empty; + + public List Tags { get; set; } = []; + + public string? AltText { get; set; } + + public string? Caption { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs new file mode 100644 index 000000000..3fc6ee23f --- /dev/null +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -0,0 +1,314 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Media; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FluentCMS.Infrastructure.Media; + +// Expected failures are found before the unit of work begins, so they leave a caller's outer unit of work able to commit. Reads +// never track rows, so a long-lived context always sees what is stored. +internal sealed class MediaLibrary( + FluentCmsDbContext context, + IUnitOfWork unitOfWork, + IAuditLog auditLog, + MediaStorageRegistry storages, + IOptions options, + TimeProvider clock) : IMediaLibrary +{ + private const string SubjectType = "media"; + + // Until multi-site (MST-01), every asset belongs to the one site (SCH-07). + private static readonly Guid SiteId = Site.Default.Id; + + public async Task> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(asset); + + var errors = new List(); + var metadata = Revalidate(asset.Metadata, errors); + var contentType = asset.ContentType?.Trim().ToLowerInvariant(); + if (!AssetMetadata.IsContentType(contentType)) + { + errors.Add(new Error(MediaErrorCodes.ContentTypeInvalid, $"contentType: '{asset.ContentType}' is not a media type such as 'image/jpeg'.")); + } + + if (errors.Count > 0) + { + return Result.Failure(errors); + } + + var storage = Storage(); + var stored = await storage.SaveAsync(asset.Content, cancellationToken); + var limit = options.Value.MaxBytes; + if (stored.Length == 0 || stored.Length > limit) + { + await storage.DeleteAsync(stored.Key, cancellationToken); + return stored.Length == 0 + ? Result.Failure(MediaErrorCodes.MediaEmpty, "The file is empty; upload the bytes of the asset.") + : Result.Failure(MediaErrorCodes.MediaTooLarge, $"The file has {stored.Length} bytes, and an upload may have at most {limit}."); + } + + var now = clock.GetUtcNow(); + var id = Guid.CreateVersion7(now); + var version = new MediaAssetVersion(id, 1, new MediaBlob(contentType!, stored.Length, stored.Sha256, storage.Name, stored.Key), metadata!, now); + + try + { + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + context.MediaAssets.Add(new MediaAssetRow { Id = id, SiteId = SiteId, LatestVersion = 1 }); + context.MediaAssetVersions.Add(VersionRow(version)); + await auditLog.AppendAsync(new NewAuditEvent("media.uploaded") { SubjectType = SubjectType, SubjectId = id }, cancellationToken); + + await scope.CommitAsync(cancellationToken); + } + catch + { + // Bytes without an asset row are never read; deleting them keeps the storage from filling with them. + await storage.DeleteAsync(stored.Key, CancellationToken.None); + throw; + } + + return Result.Success(new MediaAsset(id, SiteId, 1, null, version)); + } + + public async Task> UpdateAsync(Guid id, int baseVersion, AssetMetadata metadata, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(metadata); + + var row = await FindAsync(id, cancellationToken); + if (row is null) + { + return Result.Failure([NotFound(id)]); + } + + if (row.LatestVersion != baseVersion) + { + return Result.Failure([Conflict(id, baseVersion)]); + } + + var errors = new List(); + var checkedMetadata = Revalidate(metadata, errors); + if (errors.Count > 0) + { + return Result.Failure(errors); + } + + 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() }; + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + // 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) + .ExecuteUpdateAsync(setters => setters.SetProperty(asset => asset.LatestVersion, version.Version), cancellationToken); + if (moved == 0) + { + return Result.Failure([Conflict(id, baseVersion)]); + } + + context.MediaAssetVersions.Add(VersionRow(version)); + await auditLog.AppendAsync( + new NewAuditEvent("media.updated") { SubjectType = SubjectType, SubjectId = id, PreviousVersionId = baseRow.Id }, + cancellationToken); + + try + { + await scope.CommitAsync(cancellationToken); + } + catch (DbUpdateException exception) when (DatabaseErrors.IsUniqueViolation(exception)) + { + return Result.Failure([Conflict(id, baseVersion)]); + } + + return Result.Success(new MediaAsset(id, row.SiteId, version.Version, row.PublishedVersion, version)); + } + + public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var row = await FindAsync(id, cancellationToken); + if (row is null) + { + return Result.Failure([NotFound(id)]); + } + + 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))); + } + + public async Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) + { + var row = await FindAsync(id, cancellationToken); + if (row is null) + { + return Result.Failure([NotFound(id)]); + } + + var stored = await VersionRowAsync(id, version, cancellationToken); + return stored is null + ? Result.Failure( + MediaErrorCodes.VersionNotFound, + $"Media asset {id} has no version {version}; its latest version is {row.LatestVersion}.") + : Result.Success(ToVersion(stored)); + } + + public async Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var latest = LatestVersions(); + if (query.Folder is { } folder) + { + var path = folder.Trim().Trim('/'); + var prefix = path + "/"; + latest = query.IncludeSubfolders + ? latest.Where(version => path.Length == 0 || version.Folder == path || version.Folder.StartsWith(prefix)) + : latest.Where(version => version.Folder == path); + } + + if (query.Tag is { } tag) + { + var wanted = tag.Trim().ToLowerInvariant(); + latest = latest.Where(version => version.Tags.Contains(wanted)); + } + + var rows = await ( + from version in latest + join asset in context.MediaAssets.AsNoTracking() on version.AssetId equals asset.Id + orderby asset.Id + select new { asset.PublishedVersion, Version = version }) + .ToListAsync(cancellationToken); + + return rows + .Select(row => new MediaAssetSummary( + row.Version.AssetId, + row.Version.Version, + row.PublishedVersion, + row.Version.FileName, + row.Version.ContentType, + row.Version.Length, + row.Version.Folder, + row.Version.Tags, + row.Version.AltText != null)) + .ToList(); + } + + public async Task> FoldersAsync(CancellationToken cancellationToken = default) + { + var folders = await LatestVersions().Select(version => version.Folder).Distinct().ToListAsync(cancellationToken); + return folders.Order(StringComparer.Ordinal).ToList(); + } + + public async Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default) + { + var row = await FindAsync(id, cancellationToken); + if (row is null) + { + return Result.Failure([NotFound(id)]); + } + + var number = version ?? row.LatestVersion; + var stored = await VersionRowAsync(id, number, cancellationToken); + if (stored is null) + { + return Result.Failure( + MediaErrorCodes.VersionNotFound, + $"Media asset {id} has no version {number}; its latest version is {row.LatestVersion}."); + } + + var content = storages.Find(stored.Storage) is { } storage ? await storage.OpenReadAsync(stored.StorageKey, cancellationToken) : null; + return content is null + ? Result.Failure( + MediaErrorCodes.ContentMissing, + $"The bytes of media asset {id} are not in the storage '{stored.Storage}'; it may no longer be registered or may have lost them.") + : Result.Success(new MediaContent(content, stored.ContentType, stored.FileName, stored.Length)); + } + + // The latest version of every asset of the site, which lists and folders read. + private IQueryable 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 + select version; + } + + private IMediaStorage Storage() + { + // The host checks the name at startup (MediaOptionsValidation), so a missing storage here is a misconfigured service provider. + return storages.Find(options.Value.Storage) + ?? throw new InvalidOperationException($"Media__Storage is '{options.Value.Storage}', which is no registered media storage."); + } + + private Task FindAsync(Guid id, CancellationToken cancellationToken) + { + return context.MediaAssets.AsNoTracking().SingleOrDefaultAsync(asset => asset.Id == id && asset.SiteId == SiteId, cancellationToken); + } + + private Task VersionRowAsync(Guid id, int version, CancellationToken cancellationToken) + { + return context.MediaAssetVersions.AsNoTracking().SingleOrDefaultAsync(row => row.AssetId == id && row.Version == version, cancellationToken); + } + + // Metadata built outside AssetMetadata.Create is checked again, so the stored form is always the normalized one. + private static AssetMetadata? Revalidate(AssetMetadata metadata, List errors) + { + ArgumentNullException.ThrowIfNull(metadata); + + var created = AssetMetadata.Create(metadata.FileName, metadata.Folder, metadata.Tags, metadata.AltText, metadata.Caption); + errors.AddRange(created.Errors); + return created.IsSuccess ? created.Value : null; + } + + private static MediaAssetVersionRow VersionRow(MediaAssetVersion version) + { + return new MediaAssetVersionRow + { + Id = Guid.CreateVersion7(version.CreatedAt), + AssetId = version.AssetId, + Version = version.Version, + FileName = version.Metadata.FileName, + ContentType = version.Blob.ContentType, + Length = version.Blob.Length, + Sha256 = version.Blob.Sha256, + Storage = version.Blob.Storage, + StorageKey = version.Blob.Key, + Folder = version.Metadata.Folder, + Tags = [.. version.Metadata.Tags], + AltText = version.Metadata.AltText, + Caption = version.Metadata.Caption, + CreatedAt = version.CreatedAt, + }; + } + + 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."); + } + + private static Error Conflict(Guid id, int baseVersion) + { + return new Error( + MediaErrorCodes.VersionConflict, + $"Version {baseVersion} is not the latest version of media asset {id}; read it again and update the latest version."); + } +} diff --git a/src/FluentCMS.Infrastructure/Media/MediaOptions.cs b/src/FluentCMS.Infrastructure/Media/MediaOptions.cs index 1b68cc014..faa48a638 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaOptions.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaOptions.cs @@ -10,6 +10,9 @@ public sealed class MediaOptions // The storage new uploads go to (MED-04). Assets keep the name of the storage that holds their bytes, so changing it moves new // uploads only. public string Storage { get; set; } = LocalDiskMediaStorage.StorageName; + + // The largest upload accepted, in bytes. + public long MaxBytes { get; set; } = 50 * 1024 * 1024; } // Its own options, bound to Media:LocalDisk, so the storage does not depend on the choice of storage that is checked against it. diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index 9948560a7..0c948ec64 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -4,6 +4,7 @@ using FluentCMS.Infrastructure.Checks; using FluentCMS.Infrastructure.Content; using FluentCMS.Infrastructure.Entities; +using FluentCMS.Infrastructure.Media; using FluentCMS.Infrastructure.Jobs; using FluentCMS.Infrastructure.Schema; using FluentCMS.Infrastructure.Sites; @@ -40,6 +41,10 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet EntityFacts => Set(); + public DbSet MediaAssets => Set(); + + public DbSet MediaAssetVersions => Set(); + public DbSet Checks => Set(); public DbSet CheckVersions => Set(); @@ -62,6 +67,8 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new EntryVersionConfiguration()); builder.ApplyConfiguration(new EntryReferenceConfiguration()); builder.ApplyConfiguration(new EntityFactConfiguration()); + builder.ApplyConfiguration(new MediaAssetConfiguration()); + builder.ApplyConfiguration(new MediaAssetVersionConfiguration()); builder.ApplyConfiguration(new CheckConfiguration()); builder.ApplyConfiguration(new CheckVersionConfiguration()); builder.ApplyConfiguration(new JobConfiguration()); diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.Designer.cs new file mode 100644 index 000000000..183bb92ee --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.Designer.cs @@ -0,0 +1,1213 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019014210_MediaAssets")] + partial class MediaAssets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.cs new file mode 100644 index 000000000..509dc6933 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019014210_MediaAssets.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class MediaAssets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MediaAssets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SiteId = table.Column(type: "uuid", nullable: false), + LatestVersion = table.Column(type: "integer", nullable: false), + PublishedVersion = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaAssets", x => x.Id); + table.CheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + table.CheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + table.ForeignKey( + name: "FK_MediaAssets_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "MediaAssetVersions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AssetId = table.Column(type: "uuid", nullable: false), + Version = table.Column(type: "integer", nullable: false), + FileName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + ContentType = table.Column(type: "character varying(127)", maxLength: 127, nullable: false), + Length = table.Column(type: "bigint", nullable: false), + Sha256 = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Storage = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + StorageKey = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Folder = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Tags = table.Column>(type: "text[]", nullable: false), + AltText = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + Caption = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaAssetVersions", x => x.Id); + table.CheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + table.CheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_MediaAssetVersions_MediaAssets_AssetId", + column: x => x.AssetId, + principalTable: "MediaAssets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_MediaAssets_SiteId", + table: "MediaAssets", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaAssetVersions_AssetId_Version", + table: "MediaAssetVersions", + columns: new[] { "AssetId", "Version" }, + unique: true); + + // Versions are never overwritten (CNT-02). A trigger binds every role, the table's owner included. + migrationBuilder.Sql(""" + CREATE FUNCTION "MediaAssetVersions_RefuseUpdate"() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + RAISE EXCEPTION 'Media asset versions are never overwritten (CNT-02).'; + END; + $$; + + CREATE TRIGGER "TR_MediaAssetVersions_NoUpdate" BEFORE UPDATE ON "MediaAssetVersions" + FOR EACH ROW EXECUTE FUNCTION "MediaAssetVersions_RefuseUpdate"(); + """); + + migrationBuilder.CreateIndex( + name: "IX_MediaAssetVersions_Folder", + table: "MediaAssetVersions", + column: "Folder"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MediaAssetVersions"); + + migrationBuilder.DropTable( + name: "MediaAssets"); + + migrationBuilder.Sql("""DROP FUNCTION "MediaAssetVersions_RefuseUpdate"();"""); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index a41b339b5..1f535d86d 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -636,6 +636,108 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => { b.Property("Id") @@ -1016,6 +1118,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.Designer.cs new file mode 100644 index 000000000..9a5608c3d --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.Designer.cs @@ -0,0 +1,1203 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019014200_MediaAssets")] + partial class MediaAssets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.cs new file mode 100644 index 000000000..e5acd3bc1 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019014200_MediaAssets.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class MediaAssets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MediaAssets", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SiteId = table.Column(type: "TEXT", nullable: false), + LatestVersion = table.Column(type: "INTEGER", nullable: false), + PublishedVersion = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaAssets", x => x.Id); + table.CheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + table.CheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + table.ForeignKey( + name: "FK_MediaAssets_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "MediaAssetVersions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AssetId = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false), + FileName = table.Column(type: "TEXT", maxLength: 255, nullable: false), + ContentType = table.Column(type: "TEXT", maxLength: 127, nullable: false), + Length = table.Column(type: "INTEGER", nullable: false), + Sha256 = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Storage = table.Column(type: "TEXT", maxLength: 64, nullable: false), + StorageKey = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Folder = table.Column(type: "TEXT", maxLength: 512, nullable: false), + Tags = table.Column(type: "TEXT", nullable: false), + AltText = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + Caption = table.Column(type: "TEXT", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaAssetVersions", x => x.Id); + table.CheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + table.CheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_MediaAssetVersions_MediaAssets_AssetId", + column: x => x.AssetId, + principalTable: "MediaAssets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_MediaAssets_SiteId", + table: "MediaAssets", + column: "SiteId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaAssetVersions_AssetId_Version", + table: "MediaAssetVersions", + columns: new[] { "AssetId", "Version" }, + unique: true); + + // Versions are never overwritten (CNT-02); dropping the table drops the trigger with it. + migrationBuilder.Sql(""" + CREATE TRIGGER "TR_MediaAssetVersions_NoUpdate" BEFORE UPDATE ON "MediaAssetVersions" + BEGIN + SELECT RAISE(ABORT, 'Media asset versions are never overwritten (CNT-02).'); + END; + """); + + migrationBuilder.CreateIndex( + name: "IX_MediaAssetVersions_Folder", + table: "MediaAssetVersions", + column: "Folder"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MediaAssetVersions"); + + migrationBuilder.DropTable( + name: "MediaAssets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 5594238fc..35cc7a3cc 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -630,6 +630,108 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => { b.Property("Id") @@ -1006,6 +1108,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) diff --git a/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs b/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs index 01e19ff28..d27726322 100644 --- a/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs +++ b/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs @@ -1,4 +1,5 @@ using FluentCMS.Core.Content; +using FluentCMS.Core.Media; using FluentCMS.Core.Results; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -99,7 +100,7 @@ public async Task References_inside_facts_and_to_media_are_indexed_with_their_pa { await using var scenario = await CreateAsync(provider); var category = (await scenario.CreateAsync("category", $$"""{"name": {{Fact("\"Tools\"")}}}""")).Value.Id; - var hero = Guid.CreateVersion7(); + var hero = await HeroAsync(scenario); var product = (await scenario.CreateAsync("product", $$""" {"name": {{Fact("\"Widget\"")}}, "price": {{Fact("10")}}, "category": {{Fact($"\"{category}\"")}}, "hero": {{Fact($"\"{hero}\"")}}} @@ -137,6 +138,14 @@ public async Task Unknown_entries_entries_that_are_not_entities_and_unknown_fiel Assert.Empty((await RunResultAsync(scenario, whereUsed => whereUsed.ForEntryAsync(post))).Value); } + private static async Task HeroAsync(EntryScenario scenario) + { + await using var scope = scenario.Services.CreateAsyncScope(); + var metadata = AssetMetadata.Create("drill.jpg", null, null, "A cordless drill.", null).Value; + var asset = await scope.ServiceProvider.GetRequiredService().UploadAsync(new NewAsset(new MemoryStream([1, 2, 3]), "image/jpeg", metadata)); + return asset.Value.Id; + } + private static async Task CreateAsync(string provider) { var scenario = await EntryScenario.CreateAsync(provider); diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs new file mode 100644 index 000000000..27f9d7ab4 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs @@ -0,0 +1,172 @@ +using System.Security.Cryptography; +using FluentCMS.Core.Content; +using FluentCMS.Core.Media; +using FluentCMS.Core.Results; +using FluentCMS.Infrastructure.Media; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using static FluentCMS.IntegrationTests.Content.EntryScenario; + +namespace FluentCMS.IntegrationTests.Media; + +public sealed class MediaLibraryTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_upload_stores_the_bytes_in_the_configured_storage_and_version_one(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var bytes = RandomNumberGenerator.GetBytes(2048); + + var asset = (await UploadAsync(scenario, bytes, Metadata("drill.jpg", "products/drills", ["product-shot"], "A cordless drill."))).Value; + var read = (await LibraryAsync(scenario, library => library.GetAsync(asset.Id))).Value; + + Assert.Equal((1, (int?)null), (read.LatestVersion, read.PublishedVersion)); + Assert.Equal(new MediaBlob("image/jpeg", 2048, Convert.ToHexStringLower(SHA256.HashData(bytes)), "local", asset.Latest.Blob.Key), read.Latest.Blob); + Assert.Equal(("drill.jpg", "products/drills", "A cordless drill."), (read.Latest.Metadata.FileName, read.Latest.Metadata.Folder, read.Latest.Metadata.AltText)); + Assert.Equal(["product-shot"], read.Latest.Metadata.Tags); + Assert.Equal(bytes, await ContentAsync(scenario, asset.Id)); + + var uploaded = Assert.Single(await scenario.Schema.QueryAsync(context => context.AuditEvents.Where(row => row.SubjectType == "media").ToListAsync())); + Assert.Equal(("media.uploaded", asset.Id), (uploaded.Operation, uploaded.SubjectId!.Value)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_metadata_update_stores_the_next_version_with_the_same_bytes(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var bytes = RandomNumberGenerator.GetBytes(64); + var asset = (await UploadAsync(scenario, bytes, Metadata("drill.jpg"))).Value; + + var updated = (await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, Metadata("drill.jpg", "products", ["hero"], "A drill on a bench.", "Our best seller.")))).Value; + var stale = await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, Metadata("other.jpg"))); + var invalid = await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 2, new AssetMetadata("a/b.jpg", "Not Valid", [], " ", null))); + + Assert.Equal(2, updated.LatestVersion); + Assert.Equal(asset.Latest.Blob, updated.Latest.Blob); + Assert.Equal("Our best seller.", updated.Latest.Metadata.Caption); + Assert.Equal(MediaErrorCodes.VersionConflict, Assert.Single(stale.Errors).Code); + Assert.Equal([MediaErrorCodes.FileNameInvalid, MediaErrorCodes.FolderInvalid, MediaErrorCodes.AltTextInvalid], invalid.Errors.Select(error => error.Code)); + Assert.Null((await LibraryAsync(scenario, library => library.GetVersionAsync(asset.Id, 1))).Value.Metadata.AltText); + Assert.Equal(bytes, await ContentAsync(scenario, asset.Id, 1)); + Assert.Equal(2, await scenario.Schema.QueryAsync(context => context.MediaAssetVersions.CountAsync())); + + var updatedEvent = Assert.Single(await scenario.Schema.QueryAsync(context => context.AuditEvents.Where(row => row.Operation == "media.updated").ToListAsync())); + var first = await scenario.Schema.QueryAsync(context => context.MediaAssetVersions.SingleAsync(row => row.Version == 1)); + Assert.Equal(first.Id, updatedEvent.PreviousVersionId); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Empty_oversized_and_mistyped_uploads_are_refused_and_leave_no_bytes(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider, services => services.PostConfigure(media => media.MaxBytes = 100)); + + var empty = await UploadAsync(scenario, [], Metadata("empty.png")); + var large = await UploadAsync(scenario, new byte[101], Metadata("large.png")); + var mistyped = await UploadAsync(scenario, new byte[10], Metadata("text.png"), contentType: "png"); + + Assert.Equal(MediaErrorCodes.MediaEmpty, Assert.Single(empty.Errors).Code); + Assert.Equal(MediaErrorCodes.MediaTooLarge, Assert.Single(large.Errors).Code); + Assert.Equal(MediaErrorCodes.ContentTypeInvalid, Assert.Single(mistyped.Errors).Code); + Assert.Equal(0, await scenario.Schema.QueryAsync(context => context.MediaAssets.CountAsync())); + + var root = scenario.Services.GetRequiredService>().Value.Root; + Assert.Empty(Directory.Exists(root) ? Directory.GetFiles(root, "*", SearchOption.AllDirectories) : []); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Assets_list_by_folder_and_tag(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var root = (await UploadAsync(scenario, [1], Metadata("logo.png", tags: ["brand"]))).Value.Id; + var drills = (await UploadAsync(scenario, [2], Metadata("drill.png", "products/drills", ["product-shot"]))).Value.Id; + var products = (await UploadAsync(scenario, [3], Metadata("range.png", "products", ["product-shot", "brand"]))).Value.Id; + + async Task> ListAsync(MediaQuery query) => + (await LibraryAsync(scenario, library => library.ListAsync(query))).Select(asset => asset.Id).ToList(); + + Assert.Equal(new[] { root, drills, products }.Order(), await ListAsync(new MediaQuery())); + Assert.Equal([products], await ListAsync(new MediaQuery("products"))); + Assert.Equal(new[] { drills, products }.Order(), await ListAsync(new MediaQuery("products", IncludeSubfolders: true))); + Assert.Equal([root], await ListAsync(new MediaQuery(""))); + Assert.Equal(new[] { root, products }.Order(), await ListAsync(new MediaQuery(Tag: "brand"))); + Assert.Equal([drills], await ListAsync(new MediaQuery("products/drills", Tag: "product-shot"))); + Assert.Equal(["", "products", "products/drills"], await LibraryAsync(scenario, library => library.FoldersAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_entry_must_reference_an_existing_asset_and_then_uses_it(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + await scenario.Schema.CreateReferenceModelAsync(); + var hero = (await UploadAsync(scenario, [1, 2, 3], Metadata("drill.jpg"))).Value.Id; + var missing = Guid.CreateVersion7(); + + var refused = await scenario.CreateAsync("product", $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact("99")}}, "hero": {{Fact($"\"{missing}\"")}}}"""); + var product = (await scenario.CreateAsync("product", $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact("99")}}, "hero": {{Fact($"\"{hero}\"")}}}""")).Value.Id; + + Assert.Equal(ContentErrorCodes.ReferenceNotFound, Assert.Single(refused.Errors).Code); + Assert.Contains("not a media asset", refused.Errors[0].Message); + await using var scope = scenario.Services.CreateAsyncScope(); + var usage = Assert.Single(await scope.ServiceProvider.GetRequiredService().ForMediaAsync(hero)); + Assert.Equal((product, "/hero/value"), (usage.EntryId, Assert.Single(usage.Paths))); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_stored_version_cannot_be_overwritten_and_unknown_assets_fail(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + await UploadAsync(scenario, [1], Metadata("drill.jpg")); + var unknown = Guid.CreateVersion7(); + + await scenario.Schema.QueryAsync(async context => + { + await Assert.ThrowsAnyAsync(() => context.MediaAssetVersions.ExecuteUpdateAsync(setters => setters.SetProperty(row => row.AltText, "Tampered"))); + return 0; + }); + + Assert.Null((await scenario.Schema.QueryAsync(context => context.MediaAssetVersions.SingleAsync())).AltText); + Assert.Equal(MediaErrorCodes.AssetNotFound, Assert.Single((await LibraryAsync(scenario, library => library.GetAsync(unknown))).Errors).Code); + Assert.Equal(MediaErrorCodes.AssetNotFound, Assert.Single((await LibraryAsync(scenario, library => library.OpenAsync(unknown))).Errors).Code); + Assert.Equal(MediaErrorCodes.AssetNotFound, Assert.Single((await LibraryAsync(scenario, library => library.UpdateAsync(unknown, 1, Metadata("x.png")))).Errors).Code); + } + + private static AssetMetadata Metadata(string fileName, string? folder = null, string[]? tags = null, string? altText = null, string? caption = null) + { + return AssetMetadata.Create(fileName, folder, tags, altText, caption).Value; + } + + private static Task> UploadAsync(EntryScenario scenario, byte[] bytes, AssetMetadata metadata, string contentType = "image/jpeg") + { + return LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream(bytes), contentType, metadata))); + } + + private static async Task ContentAsync(EntryScenario scenario, Guid asset, int? version = null) + { + var content = (await LibraryAsync(scenario, library => library.OpenAsync(asset, version))).Value; + await using var stream = content.Content; + using var copy = new MemoryStream(); + await stream.CopyToAsync(copy); + return copy.ToArray(); + } + + private static async Task LibraryAsync(EntryScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs b/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs index 0fafe5c29..8658e9fb3 100644 --- a/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs +++ b/tests/FluentCMS.IntegrationTests/TemporaryDatabase.cs @@ -10,9 +10,12 @@ internal sealed class TemporaryDatabase : IAsyncDisposable private readonly NpgsqlConnectionStringBuilder? _server; private readonly string? _postgresName; + // Media bytes go to a directory of the database's own, so no test writes into the build output. + private readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"fluentcms-media-{Guid.NewGuid():N}"); + private TemporaryDatabase(Dictionary configuration, TemporarySqliteDatabase? sqlite, NpgsqlConnectionStringBuilder? server, string? postgresName) { - Configuration = configuration; + Configuration = new Dictionary(configuration) { ["Media:LocalDisk:Root"] = _mediaRoot }; _sqlite = sqlite; _server = server; _postgresName = postgresName; @@ -40,6 +43,11 @@ public async ValueTask DisposeAsync() { _sqlite?.Dispose(); + if (Directory.Exists(_mediaRoot)) + { + Directory.Delete(_mediaRoot, recursive: true); + } + if (_server is not null) { await ExecuteAsync(_server, $"DROP DATABASE IF EXISTS \"{_postgresName}\" WITH (FORCE)"); diff --git a/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs b/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs new file mode 100644 index 000000000..f6f0f4b44 --- /dev/null +++ b/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs @@ -0,0 +1,67 @@ +using FluentCMS.Core.Media; + +namespace FluentCMS.Tests.Media; + +public sealed class AssetMetadataTests +{ + [Fact] + public void Metadata_is_stored_trimmed_with_each_tag_once_in_lowercase() + { + var metadata = AssetMetadata.Create(" drill.jpg ", "/products/drills/", ["Product-Shot", "hero", "product-shot"], " A cordless drill. ", null).Value; + + Assert.Equal("drill.jpg", metadata.FileName); + Assert.Equal("products/drills", metadata.Folder); + Assert.Equal(["product-shot", "hero"], metadata.Tags); + Assert.Equal("A cordless drill.", metadata.AltText); + Assert.Null(metadata.Caption); + } + + [Fact] + public void Every_problem_comes_back_at_once_and_names_its_field() + { + var errors = AssetMetadata.Create("../drill.jpg", "Products/Drills", ["not a tag"], " ", new string('x', 2001)).Errors; + + Assert.Equal( + [ + MediaErrorCodes.FileNameInvalid, + MediaErrorCodes.FolderInvalid, + MediaErrorCodes.TagInvalid, + MediaErrorCodes.AltTextInvalid, + MediaErrorCodes.CaptionInvalid, + ], + errors.Select(error => error.Code)); + Assert.All(errors, error => Assert.Contains(':', error.Message)); + } + + [Theory] + [InlineData("")] + [InlineData(".")] + [InlineData("..")] + [InlineData("a\\b.png")] + [InlineData("tab\t.png")] + public void A_file_name_is_one_name_without_slashes(string fileName) + { + Assert.Equal(MediaErrorCodes.FileNameInvalid, Assert.Single(AssetMetadata.Create(fileName, null, null, null, null).Errors).Code); + } + + [Fact] + public void An_asset_has_at_most_32_tags() + { + var tags = Enumerable.Range(0, 33).Select(index => $"tag-{index}"); + + Assert.Equal(MediaErrorCodes.TagInvalid, Assert.Single(AssetMetadata.Create("a.png", null, tags, null, null).Errors).Code); + } + + [Theory] + [InlineData("image/jpeg", true)] + [InlineData("image/svg+xml", true)] + [InlineData("application/vnd.ms-excel", true)] + [InlineData("image", false)] + [InlineData("Image/JPEG", false)] + [InlineData("image/jpeg; charset=x", false)] + [InlineData("", false)] + public void A_content_type_is_a_media_type(string contentType, bool valid) + { + Assert.Equal(valid, AssetMetadata.IsContentType(contentType)); + } +} From 7232e12f5347fe6e14641f7d91e1412c5c043c23 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:49:26 -0400 Subject: [PATCH 02/17] Stop reading an upload one byte past the limit --- .../Media/CappedStream.cs | 65 +++++++++++++++++++ .../Media/MediaLibrary.cs | 6 +- .../Media/MediaLibraryTests.cs | 3 +- 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 src/FluentCMS.Infrastructure/Media/CappedStream.cs diff --git a/src/FluentCMS.Infrastructure/Media/CappedStream.cs b/src/FluentCMS.Infrastructure/Media/CappedStream.cs new file mode 100644 index 000000000..73cbc93b4 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Media/CappedStream.cs @@ -0,0 +1,65 @@ +namespace FluentCMS.Infrastructure.Media; + +// Reads from another stream until the cap is reached, then reports its end, so a reader never takes more than the cap. +internal sealed class CappedStream(Stream inner, long cap) : Stream +{ + private long _remaining = cap; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + if (_remaining <= 0) + { + return 0; + } + + var read = inner.Read(buffer[..(int)Math.Min(buffer.Length, _remaining)]); + _remaining -= read; + return read; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_remaining <= 0) + { + return 0; + } + + var read = await inner.ReadAsync(buffer[..(int)Math.Min(buffer.Length, _remaining)], cancellationToken); + _remaining -= read; + return read; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index 3fc6ee23f..3640e6535 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -42,14 +42,16 @@ public async Task> UploadAsync(NewAsset asset, CancellationTo } var storage = Storage(); - var stored = await storage.SaveAsync(asset.Content, cancellationToken); var limit = options.Value.MaxBytes; + + // The storage sees at most one byte past the limit, so an oversized upload costs no more than the limit to refuse. + var stored = await storage.SaveAsync(new CappedStream(asset.Content, limit + 1), cancellationToken); if (stored.Length == 0 || stored.Length > limit) { await storage.DeleteAsync(stored.Key, cancellationToken); return stored.Length == 0 ? Result.Failure(MediaErrorCodes.MediaEmpty, "The file is empty; upload the bytes of the asset.") - : Result.Failure(MediaErrorCodes.MediaTooLarge, $"The file has {stored.Length} bytes, and an upload may have at most {limit}."); + : Result.Failure(MediaErrorCodes.MediaTooLarge, $"The file has more than {limit} bytes, and an upload may have at most {limit}."); } var now = clock.GetUtcNow(); diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs index 27f9d7ab4..958ba0159 100644 --- a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs @@ -70,11 +70,12 @@ public async Task Empty_oversized_and_mistyped_uploads_are_refused_and_leave_no_ await using var scenario = await EntryScenario.CreateAsync(provider, services => services.PostConfigure(media => media.MaxBytes = 100)); var empty = await UploadAsync(scenario, [], Metadata("empty.png")); - var large = await UploadAsync(scenario, new byte[101], Metadata("large.png")); + var large = await UploadAsync(scenario, new byte[100_000], Metadata("large.png")); var mistyped = await UploadAsync(scenario, new byte[10], Metadata("text.png"), contentType: "png"); Assert.Equal(MediaErrorCodes.MediaEmpty, Assert.Single(empty.Errors).Code); Assert.Equal(MediaErrorCodes.MediaTooLarge, Assert.Single(large.Errors).Code); + Assert.Equal("The file has more than 100 bytes, and an upload may have at most 100.", large.Errors[0].Message); Assert.Equal(MediaErrorCodes.ContentTypeInvalid, Assert.Single(mistyped.Errors).Code); Assert.Equal(0, await scenario.Schema.QueryAsync(context => context.MediaAssets.CountAsync())); From 53368ac3bf476de3d782b8cd194455a20fe4322c Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 22:00:39 -0400 Subject: [PATCH 03/17] Put media in change sets and add the media tools --- src/FluentCMS.Core/Agents/AgentPermissions.cs | 19 + .../Agents/IAgentPermissions.cs | 3 + .../Builtin/Tools/MediaTools.cs | 422 ++++++++++++++++++ src/FluentCMS.Core/Builtin/Tools/ToolJson.cs | 38 ++ src/FluentCMS.Core/ChangeSets/ItemDiff.cs | 59 +++ src/FluentCMS.Core/Media/IMediaLibrary.cs | 1 + .../Tools/ToolArgumentsReader.cs | 16 + src/FluentCMS.Core/Tools/ToolSchemas.cs | 5 + .../ChangeSets/ChangeSetDirectory.cs | 122 ++++- .../Media/MediaAssetRows.cs | 12 + .../Media/MediaLibrary.cs | 16 +- .../ChangeSets/ChangeSetDirectoryTests.cs | 2 +- .../ChangeSets/MediaChangeSetTests.cs | 128 ++++++ .../Mcp/McpAgents.cs | 9 +- .../Mcp/MediaToolsTests.cs | 173 +++++++ .../FluentCMS.Tests/Tools/McpCoverageTests.cs | 4 +- .../Tools/ToolProviderConformanceTests.cs | 5 + 17 files changed, 1011 insertions(+), 23 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/Tools/MediaTools.cs create mode 100644 tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs index 435c75ac6..bb1697a53 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -162,6 +162,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 14b891cfa..9942c143b 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -26,6 +26,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..3d4aeae72 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs @@ -0,0 +1,422 @@ +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.Boolean("include_subfolders", "With folder: also list the assets in its subfolders.", required: false), + 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, 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!, + 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!, + 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.OptionalFlag("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 ?? false, 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); + var isList = media is { Type: "list" } && media.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media"; + if (media is null || (media.Type != "media" && !isList)) + { + return Result.Failure( + MediaErrorCodes.FieldNotMedia, + $"'{field}' is not a media field of '{entry.Value.ContentType}'; its media fields are " + + string.Join(", ", definition.Fields.Where(candidate => candidate.Type == "media" || candidate.Type == "list").Select(candidate => candidate.Name)) + + "."); + } + + 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); + } + + // 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, + AgentOperation agentOperation, + Func>> write, + Func> operation, + bool uploaded, + CancellationToken cancellationToken) + { + var services = call.Services; + var ready = await target.CheckAsync(services, 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); + } + } + } + + private static byte[]? Base64(string content, List errors) + { + 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 4dd7d5ceb..f7ff98c14 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -4,6 +4,7 @@ using FluentCMS.Core.Content; using FluentCMS.Core.Entities; using FluentCMS.Core.Identity; +using FluentCMS.Core.Media; using FluentCMS.Core.Schema; namespace FluentCMS.Core.Builtin.Tools; @@ -219,6 +220,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 d77e72762..3d99e8194 100644 --- a/src/FluentCMS.Core/Media/IMediaLibrary.cs +++ b/src/FluentCMS.Core/Media/IMediaLibrary.cs @@ -56,4 +56,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.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs index e544c706f..3df238949 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -94,6 +94,22 @@ public ToolArgumentsReader(JsonElement arguments) return null; } + public bool? OptionalFlag(string name) + { + if (Read(name) is not { } value) + { + return null; + } + + if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return value.GetBoolean(); + } + + Invalid(name, "must be true or false"); + return null; + } + public Guid? OptionalGuid(string name) { return Read(name) is null ? null : GuidOf(name); diff --git a/src/FluentCMS.Core/Tools/ToolSchemas.cs b/src/FluentCMS.Core/Tools/ToolSchemas.cs index 648e5f76c..4faaa37d6 100644 --- a/src/FluentCMS.Core/Tools/ToolSchemas.cs +++ b/src/FluentCMS.Core/Tools/ToolSchemas.cs @@ -68,6 +68,11 @@ public static ToolArgument Json(string name, string description, JsonObject sche return new ToolArgument(name, description, required, copy); } + public static ToolArgument Boolean(string name, string description, bool required = true) + { + return new ToolArgument(name, description, required, new JsonObject { ["type"] = "boolean", ["examples"] = new JsonArray(true) }); + } + public static ToolArgument PositiveInteger(string name, string description, int example, bool required = true) { return new ToolArgument(name, description, required, new JsonObject { ["type"] = "integer", ["minimum"] = 1, ["examples"] = new JsonArray(example) }); diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 070269234..78658ee80 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; @@ -48,6 +50,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 @@ -57,6 +60,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."), }) @@ -470,12 +474,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 @@ -567,13 +576,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(); @@ -593,9 +603,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); } @@ -625,6 +635,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, @@ -822,6 +840,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 3640e6535..b911c97a4 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -102,7 +102,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); @@ -143,7 +143,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) @@ -159,7 +159,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) @@ -292,16 +292,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..7a2e162f2 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs @@ -0,0 +1,128 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Media; +using FluentCMS.Core.Persistence; +using FluentCMS.Infrastructure.Persistence; +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([1, 2, 3]), "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 10f2e84f9..30233b7be 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -21,10 +21,11 @@ internal static class McpAgents // The built-in tools every server lists, sorted by name. public static readonly string[] BuiltInTools = [ - "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", "get_change_set_diff", - "get_content_type", "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", "get_resolved_entry", - "get_schema_document", "get_where_used", "list_content_types", "list_entries", "list_unsourced_facts", "open_change_set", - "relate_entities", "reopen_change_set", "revise_content_type", "set_entity_fact", "submit_change_set", "update_entry", + "attach_media", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", "get_change_set_diff", + "get_content_type", "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_entries", "list_media", + "list_unsourced_facts", "open_change_set", "relate_entities", "reopen_change_set", "revise_content_type", "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..dde87424f --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +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, 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 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())); + } + + // 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")])))); + 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/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index 9a1fc7dc8..fb554de76 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -34,7 +34,9 @@ public sealed class McpCoverageTests Missing("RUL", "Read and propose rules", "#2419"), Missing("CHK", "Read the check results of a change set", "#2420"), Missing("DSK", "Read review decisions, read-only", "#2422"), - 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"]), Missing("Search", "Search entries and entities", "#2430"), 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 58050868f..251ca3466 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -119,3 +119,8 @@ public sealed class EntityToolsConformanceTests : ToolProviderConformanceTests { protected override object Provider { get; } = new EntityTools(); } + +public sealed class MediaToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new MediaTools(); +} From ed49ec025e68b7adc1c354943fc4ce92e0e305cf Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 22:01:40 -0400 Subject: [PATCH 04/17] Name only media fields when a field is not one --- src/FluentCMS.Core/Builtin/Tools/MediaTools.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs index 3d4aeae72..b8814c29e 100644 --- a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs @@ -272,16 +272,17 @@ private static async Task> AttachToEntryAsync( var definition = type.Value.Definition; var media = definition.Fields.FirstOrDefault(candidate => candidate.Name == field); - var isList = media is { Type: "list" } && media.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media"; - if (media is null || (media.Type != "media" && !isList)) + 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(candidate => candidate.Type == "media" || candidate.Type == "list").Select(candidate => candidate.Name)) + + 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) { @@ -326,6 +327,13 @@ private static async Task> AttachToEntryAsync( 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( From f0aed6f77a523d1bedd8531bbcebe4a1ba8d001b Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 22:06:18 -0400 Subject: [PATCH 05/17] Check alt text of the assets media fields name --- .../Builtin/Checks/AltTextCheckType.cs | 41 +++++++++- .../Builtin/Tools/MediaTools.cs | 10 +-- src/FluentCMS.Core/Media/MediaFields.cs | 13 +++ .../Media/AltTextCheckTests.cs | 82 +++++++++++++++++++ 4 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 src/FluentCMS.Core/Media/MediaFields.cs create mode 100644 tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs diff --git a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs index ba8565503..275f675dd 100644 --- a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs +++ b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs @@ -1,12 +1,15 @@ using System.Text.Json; using System.Text.RegularExpressions; using FluentCMS.Core.Checks; +using FluentCMS.Core.Media; using FluentCMS.Core.Results; +using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.Core.Builtin.Checks; // Every image in the text has alt text that describes it (CHK-01): Markdown images, inline or by reference, and HTML img elements. -// Alt text of assets in the media library is checked there (F-MED). +// A media field passes when the asset it names has alt text in its latest version, the one a change proposes; an asset the +// library does not hold is left to the reference checks of entries. internal sealed class AltTextCheckType : ICheckType { private const RegexOptions Linear = RegexOptions.NonBacktracking | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; @@ -29,7 +32,7 @@ public IReadOnlyList ValidateSettings(JsonElement settings) return new CheckSettingsReader(Name, settings).Errors(); } - public Task EvaluateAsync(CheckInput input, CancellationToken cancellationToken) + public async Task EvaluateAsync(CheckInput input, CancellationToken cancellationToken) { var findings = new List(); @@ -46,7 +49,39 @@ public Task EvaluateAsync(CheckInput input, CancellationToken canc image.Value))); } - return Task.FromResult(findings.Count == 0 ? CheckOutcome.Pass() : CheckOutcome.Fail(findings)); + findings.AddRange(await MediaFindingsAsync(input, cancellationToken)); + + return findings.Count == 0 ? CheckOutcome.Pass() : CheckOutcome.Fail(findings); + } + + private static async Task> MediaFindingsAsync(CheckInput input, CancellationToken cancellationToken) + { + var findings = new List(); + if (input.Subject.Definition is not { } definition || input.Services.GetService() is not { } library) + { + return findings; + } + + foreach (var (pointer, value) in CheckContent.FieldValues(input.Subject)) + { + var field = definition.Fields.FirstOrDefault(candidate => candidate.Name == CheckContent.FieldOf(pointer)); + if (field is null + || !MediaFields.Holds(field) + || value.ValueKind != JsonValueKind.String + || !Guid.TryParse(value.GetString(), out var id) + || await library.GetAsync(id, cancellationToken) is not { IsSuccess: true } asset + || !string.IsNullOrWhiteSpace(asset.Value.Latest.Metadata.AltText)) + { + continue; + } + + findings.Add(new CheckFinding( + $"Add alt text to the image '{asset.Value.Latest.Metadata.FileName}' in {field.Name} that says what it shows; set it with update_media.", + pointer, + id.ToString())); + } + + return findings; } // The value of the element's alt attribute, read attribute by attribute so a quoted value or a data-alt is not taken for it. diff --git a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs index b8814c29e..87a9ccfc0 100644 --- a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs @@ -272,12 +272,12 @@ private static async Task> AttachToEntryAsync( var definition = type.Value.Definition; var media = definition.Fields.FirstOrDefault(candidate => candidate.Name == field); - if (media is null || !IsMediaField(media)) + if (media is null || !MediaFields.Holds(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)) + + string.Join(", ", definition.Fields.Where(MediaFields.Holds).Select(candidate => candidate.Name)) + "."); } @@ -328,12 +328,6 @@ private static async Task> AttachToEntryAsync( } // 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( diff --git a/src/FluentCMS.Core/Media/MediaFields.cs b/src/FluentCMS.Core/Media/MediaFields.cs new file mode 100644 index 000000000..525506081 --- /dev/null +++ b/src/FluentCMS.Core/Media/MediaFields.cs @@ -0,0 +1,13 @@ +using FluentCMS.Core.Schema; + +namespace FluentCMS.Core.Media; + +internal static class MediaFields +{ + // A field that holds an asset of the media library, or a list of them. + public static bool Holds(FieldDefinition field) + { + return field.Type == "media" + || (field.Type == "list" && field.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media"); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs new file mode 100644 index 000000000..809680d8e --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Media; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Content; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Media; + +public sealed class AltTextCheckTests +{ + private static readonly ContentTypeDefinition ProductPage = new( + [ + new FieldDefinition("headline", "text"), + new FieldDefinition("hero", "media"), + new FieldDefinition("gallery", "list", settings: JsonSerializer.Deserialize("""{"itemType": "media"}""")), + ]); + + private static readonly ContentTypeDefinition Product = new([new FieldDefinition("photo", "media")], isEntity: true); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_media_field_naming_an_asset_without_alt_text_fails_and_names_the_field(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var bare = await UploadAsync(scenario, "drill.jpg", altText: null); + var described = await UploadAsync(scenario, "saw.jpg", altText: "A circular saw on a bench."); + + var outcome = await EvaluateAsync(scenario, ProductPage, $$"""{"headline": "Drills", "hero": "{{bare.Id}}", "gallery": ["{{described.Id}}", "{{bare.Id}}"]}"""); + + Assert.Equal(CheckVerdict.Fail, outcome.Verdict); + Assert.Equal(["/hero", "/gallery/1"], outcome.Findings.Select(finding => finding.Pointer)); + Assert.Equal( + "Add alt text to the image 'drill.jpg' in hero that says what it shows; set it with update_media.", + outcome.Findings[0].Message); + Assert.Equal(bare.Id.ToString(), outcome.Findings[0].Excerpt); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Alt_text_in_the_assets_latest_version_passes_on_pages_and_entities(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await UploadAsync(scenario, "drill.jpg", altText: null); + var entity = $$"""{"photo": {"value": "{{asset.Id}}", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} }"""; + + var before = await EvaluateAsync(scenario, Product, entity); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + var page = await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{asset.Id}}"}"""); + var entityAfter = await EvaluateAsync(scenario, Product, entity); + var unknown = await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{Guid.NewGuid()}}"}"""); + + Assert.Equal("/photo/value", Assert.Single(before.Findings).Pointer); + Assert.Equal( + [CheckVerdict.Pass, CheckVerdict.Pass, CheckVerdict.Pass], + new[] { page, entityAfter, unknown }.Select(outcome => outcome.Verdict)); + } + + private static async Task UploadAsync(EntryScenario scenario, string fileName, string? altText) + { + var metadata = AssetMetadata.Create(fileName, null, [], altText, null).Value; + return (await LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream([1, 2, 3]), "image/jpeg", metadata)))).Value; + } + + private static async Task EvaluateAsync(EntryScenario scenario, ContentTypeDefinition definition, string fields) + { + await using var scope = scenario.Services.CreateAsyncScope(); + var subject = new CheckSubject(JsonSerializer.Deserialize(fields), [], "product-page", definition); + var input = new CheckInput(JsonSerializer.Deserialize("{}"), subject, scope.ServiceProvider); + + return await scope.ServiceProvider.GetRequiredService().EvaluateAsync("altText", input); + } + + private static async Task LibraryAsync(EntryScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } +} From e619695f116dd95d6ba71645c9b3020d487bd12d Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 22:21:09 -0400 Subject: [PATCH 06/17] Soft delete and restore media assets --- src/FluentCMS.Core/Media/IMediaLibrary.cs | 14 + .../ChangeSets/ChangeSetDirectory.cs | 20 +- .../Content/EntryDirectory.cs | 2 +- .../Media/MediaAssetRows.cs | 3 + .../Media/MediaLibrary.cs | 127 +- ...61019022051_MediaAssetDeletion.Designer.cs | 1216 +++++++++++++++++ .../20261019022051_MediaAssetDeletion.cs | 29 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 3 + ...61019022041_MediaAssetDeletion.Designer.cs | 1206 ++++++++++++++++ .../20261019022041_MediaAssetDeletion.cs | 29 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 3 + .../ChangeSets/MediaChangeSetTests.cs | 51 + .../Media/MediaSoftDeleteTests.cs | 170 +++ 13 files changed, 2864 insertions(+), 9 deletions(-) create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.cs create mode 100644 tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs diff --git a/src/FluentCMS.Core/Media/IMediaLibrary.cs b/src/FluentCMS.Core/Media/IMediaLibrary.cs index 3d99e8194..8d6ba2aef 100644 --- a/src/FluentCMS.Core/Media/IMediaLibrary.cs +++ b/src/FluentCMS.Core/Media/IMediaLibrary.cs @@ -24,6 +24,15 @@ public interface IMediaLibrary // The bytes of the latest version, or of the version named. The caller disposes the stream. Task> 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 DeleteAsync(Guid id, int baseVersion, CancellationToken cancellationToken = default); + + // Only within the retention window after the delete (REL-03). + Task> RestoreAsync(Guid id, CancellationToken cancellationToken = default); + + // Most recently deleted first. + Task> ListDeletedAsync(CancellationToken cancellationToken = default); } public sealed record NewAsset(Stream Content, string ContentType, AssetMetadata Metadata); @@ -42,6 +51,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"; @@ -57,4 +69,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"; } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 78658ee80..8e94bc661 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -639,7 +639,7 @@ private async Task 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; } @@ -844,11 +844,23 @@ private async Task> PublishedEntriesAsync(IRead // 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)) + 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.Failure(ChangeSetErrorCodes.ItemNotFound, $"There is no media asset {operation.ItemId} in this site."); } + if (asset.DeletedAt is not null) + { + return Result.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) { @@ -922,7 +934,7 @@ private async Task> 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() @@ -933,7 +945,7 @@ private async Task> 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) diff --git a/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs b/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs index 92c2b1723..9eb7a8cf3 100644 --- a/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs +++ b/src/FluentCMS.Infrastructure/Content/EntryDirectory.cs @@ -442,7 +442,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); diff --git a/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs index 6b28fac2f..f6f440352 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs @@ -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; } } // One version of an asset's metadata with the bytes it points at; inserted once and never changed, which a trigger enforces. diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index b911c97a4..f1f2ed8d8 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -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; @@ -15,8 +17,10 @@ internal sealed class MediaLibrary( FluentCmsDbContext context, IUnitOfWork unitOfWork, IAuditLog auditLog, + IAgentPermissions permissions, MediaStorageRegistry storages, IOptions options, + IOptions contentOptions, TimeProvider clock) : IMediaLibrary { private const string SubjectType = "media"; @@ -108,7 +112,7 @@ public async Task> 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) { @@ -234,13 +238,121 @@ public async Task> OpenAsync(Guid id, int? version = null, : Result.Success(new MediaContent(content, stored.ContentType, stored.FileName, stored.Length)); } + public async Task 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> RestoreAsync(Guid id, CancellationToken cancellationToken = default) + { + var row = await FindAsync(id, cancellationToken, includeDeleted: true); + if (row is null) + { + return Result.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.Failure(allowed.Errors); + } + + if (row.DeletedAt is not { } deletedAt) + { + return Result.Failure([NotDeleted(id)]); + } + + var until = deletedAt + contentOptions.Value.DeletedRetention; + if (clock.GetUtcNow() >= until) + { + return Result.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.Failure([NotDeleted(id)]); + } + + await auditLog.AppendAsync(new NewAuditEvent("media.restored") { SubjectType = SubjectType, SubjectId = id }, cancellationToken); + await scope.CommitAsync(cancellationToken); + + return Result.Success(new MediaAsset(id, row.SiteId, row.LatestVersion, row.PublishedVersion, latest.ToVersion())); + } + + public async Task> 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 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; } @@ -251,9 +363,11 @@ private IMediaStorage Storage() ?? throw new InvalidOperationException($"Media__Storage is '{options.Value.Storage}', which is no registered media storage."); } - private Task FindAsync(Guid id, CancellationToken cancellationToken) + private Task 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 VersionRowAsync(Guid id, int version, CancellationToken cancellationToken) @@ -297,6 +411,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( diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.Designer.cs new file mode 100644 index 000000000..4298dc516 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.Designer.cs @@ -0,0 +1,1216 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019022051_MediaAssetDeletion")] + partial class MediaAssetDeletion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.cs new file mode 100644 index 000000000..e92535922 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class MediaAssetDeletion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "MediaAssets", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "MediaAssets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 1f535d86d..c4e9608e8 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -641,6 +641,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + b.Property("LatestVersion") .HasColumnType("integer"); diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.Designer.cs new file mode 100644 index 000000000..1705ffdc4 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.Designer.cs @@ -0,0 +1,1206 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019022041_MediaAssetDeletion")] + partial class MediaAssetDeletion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.cs new file mode 100644 index 000000000..1812912a1 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class MediaAssetDeletion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "MediaAssets", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "MediaAssets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 35cc7a3cc..e3ab3b6f9 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -635,6 +635,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("TEXT"); + b.Property("DeletedAt") + .HasColumnType("TEXT"); + b.Property("LatestVersion") .HasColumnType("INTEGER"); diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs index 7a2e162f2..7cfc3615e 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs @@ -103,6 +103,57 @@ await scenario.UpdateAgentAsync(row => Assert.True((await scenario.RunAsync(directory => directory.AddOperationAsync(agentSet.Id, Operation(OperationKind.Create, ItemKind.Media, asset, null, 1)))).IsSuccess); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_deleted_asset_fails_the_publish_of_its_set_and_joins_no_other(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var asset = await UploadAsync(scenario, Metadata(null)); + var approved = await scenario.ApproveAsync(Operation(OperationKind.Create, ItemKind.Media, asset, null, 1)); + + Assert.True((await LibraryAsync(scenario, library => library.DeleteAsync(asset, 1))).IsSuccess); + var publish = await scenario.RunAsync(directory => directory.PublishAsync(approved.Id)); + var set = await scenario.OpenAsync(); + var added = await scenario.RunAsync(directory => directory.AddOperationAsync(set.Id, Operation(OperationKind.Create, ItemKind.Media, asset, null, 1))); + + Assert.Equal(ChangeSetErrorCodes.ItemNotFound, Assert.Single(publish.Errors).Code); + Assert.Contains("restore it first", publish.Errors[0].Message); + Assert.Null(await PublishedMediaVersionAsync(scenario, asset)); + Assert.Equal(ChangeSetErrorCodes.ItemNotFound, Assert.Single(added.Errors).Code); + + Assert.True((await LibraryAsync(scenario, library => library.RestoreAsync(asset))).IsSuccess); + Assert.True((await scenario.RunAsync(directory => directory.PublishAsync(approved.Id))).IsSuccess); + Assert.Equal(1, await PublishedMediaVersionAsync(scenario, asset)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_deletes_and_restores_media_only_with_the_delete_operation(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var asset = await UploadAsync(scenario, Metadata(null)); + await scenario.UpdateAgentAsync(row => row.Operations = [AgentOperation.Read, AgentOperation.Create, AgentOperation.Update]); + scenario.Initiator = Agent; + + var refused = await LibraryAsync(scenario, library => library.DeleteAsync(asset, 1)); + await scenario.UpdateAgentAsync(row => row.Operations = [AgentOperation.Read, AgentOperation.Delete]); + var deleted = await LibraryAsync(scenario, library => library.DeleteAsync(asset, 1)); + await scenario.UpdateAgentAsync(row => row.Operations = [AgentOperation.Read]); + var refusedRestore = await LibraryAsync(scenario, library => library.RestoreAsync(asset)); + + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Assert.Single(refused.Errors).Code); + Assert.True(deleted.IsSuccess); + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Assert.Single(refusedRestore.Errors).Code); + } + + private static async Task LibraryAsync(ChangeSetScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + private static AssetMetadata Metadata(string? altText) { return AssetMetadata.Create("drill.jpg", "products", ["product-shot"], altText, null).Value; diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs new file mode 100644 index 000000000..bd33da9f8 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs @@ -0,0 +1,170 @@ +using FluentCMS.Core.Content; +using FluentCMS.Core.Media; +using FluentCMS.Core.Results; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.Content.EntryScenario; + +namespace FluentCMS.IntegrationTests.Media; + +public sealed class MediaSoftDeleteTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 18, 9, 0, 0, TimeSpan.Zero); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_deleted_asset_is_gone_from_every_read_and_cannot_be_referenced(string provider) + { + await using var scenario = await CreateAsync(provider, new ManualClock(Start)); + var drill = await UploadAsync(scenario, "drill.jpg", "products"); + var kept = await UploadAsync(scenario, "news.jpg", "blog"); + + Assert.True((await LibraryAsync(scenario, library => library.DeleteAsync(drill, 1))).IsSuccess); + + Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.GetAsync(drill)))); + Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.GetVersionAsync(drill, 1)))); + Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.OpenAsync(drill)))); + Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.UpdateAsync(drill, 1, Metadata("drill.jpg", "products"))))); + Assert.Equal([kept], (await LibraryAsync(scenario, library => library.ListAsync(new MediaQuery()))).Select(asset => asset.Id)); + Assert.Equal(["blog"], await LibraryAsync(scenario, library => library.FoldersAsync())); + + var product = await scenario.CreateAsync("product", $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact("99")}}, "hero": {{Fact($"\"{drill}\"")}}}"""); + Assert.Equal(ContentErrorCodes.ReferenceNotFound, Assert.Single(product.Errors).Code); + + var deleted = Assert.Single(await scenario.Schema.QueryAsync(context => context.AuditEvents.Where(row => row.Operation == "media.deleted").ToListAsync())); + var version = await scenario.Schema.QueryAsync(context => context.MediaAssetVersions.SingleAsync(row => row.AssetId == drill)); + Assert.Equal((drill, version.Id), (deleted.SubjectId!.Value, deleted.PreviousVersionId!.Value)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Restore_within_the_window_brings_the_asset_back_as_it_was(string provider) + { + var clock = new ManualClock(Start); + await using var scenario = await CreateAsync(provider, clock); + var drill = await UploadAsync(scenario, "drill.jpg", "products"); + await LibraryAsync(scenario, library => library.UpdateAsync(drill, 1, Metadata("drill.jpg", "products", "A cordless drill."))); + await scenario.Schema.QueryAsync(context => context.MediaAssets + .Where(row => row.Id == drill) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, 1))); + await LibraryAsync(scenario, library => library.DeleteAsync(drill, 2)); + + clock.Now = Start.AddDays(30).AddMinutes(-1); + var restored = (await LibraryAsync(scenario, library => library.RestoreAsync(drill))).Value; + + Assert.Equal((2, (int?)1, "A cordless drill."), (restored.LatestVersion, restored.PublishedVersion, restored.Latest.Metadata.AltText)); + Assert.Equal([1, 2, 3], await ContentAsync(scenario, drill, 1)); + Assert.Equal([drill], (await LibraryAsync(scenario, library => library.ListAsync(new MediaQuery()))).Select(asset => asset.Id)); + Assert.Empty(await LibraryAsync(scenario, library => library.ListDeletedAsync())); + var restoredEvent = Assert.Single(await scenario.Schema.QueryAsync(context => context.AuditEvents.Where(row => row.Operation == "media.restored").ToListAsync())); + Assert.Equal(drill, restoredEvent.SubjectId); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task After_the_window_restore_fails_and_changes_nothing(string provider) + { + var clock = new ManualClock(Start); + await using var scenario = await CreateAsync(provider, clock); + var drill = await UploadAsync(scenario, "drill.jpg", "products"); + await LibraryAsync(scenario, library => library.DeleteAsync(drill, 1)); + var events = await scenario.Schema.QueryAsync(context => context.AuditEvents.CountAsync()); + + clock.Now = Start.AddDays(30); + var restore = await LibraryAsync(scenario, library => library.RestoreAsync(drill)); + + Assert.Equal(MediaErrorCodes.RetentionExpired, Code(restore)); + Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.GetAsync(drill)))); + Assert.Equal(events, await scenario.Schema.QueryAsync(context => context.AuditEvents.CountAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Deletes_and_restores_that_do_not_fit_the_assets_state_fail(string provider) + { + await using var scenario = await CreateAsync(provider, new ManualClock(Start)); + var drill = await UploadAsync(scenario, "drill.jpg", "products"); + await LibraryAsync(scenario, library => library.UpdateAsync(drill, 1, Metadata("drill.jpg", "tools"))); + + var stale = await LibraryAsync(scenario, library => library.DeleteAsync(drill, 1)); + var notDeleted = await LibraryAsync(scenario, library => library.RestoreAsync(drill)); + var unknownDelete = await LibraryAsync(scenario, library => library.DeleteAsync(Guid.CreateVersion7(), 1)); + var unknownRestore = await LibraryAsync(scenario, library => library.RestoreAsync(Guid.CreateVersion7())); + Assert.True((await LibraryAsync(scenario, library => library.DeleteAsync(drill, 2))).IsSuccess); + var twice = await LibraryAsync(scenario, library => library.DeleteAsync(drill, 2)); + + Assert.Equal(MediaErrorCodes.VersionConflict, Assert.Single(stale.Errors).Code); + Assert.Equal(MediaErrorCodes.AssetNotDeleted, Code(notDeleted)); + Assert.Equal(MediaErrorCodes.AssetNotFound, Assert.Single(unknownDelete.Errors).Code); + Assert.Equal(MediaErrorCodes.AssetNotFound, Code(unknownRestore)); + Assert.Equal(MediaErrorCodes.AssetNotFound, Assert.Single(twice.Errors).Code); + Assert.Single(await scenario.Schema.QueryAsync(context => context.AuditEvents.Where(row => row.Operation == "media.deleted").ToListAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_deleted_list_says_when_each_asset_was_deleted_and_until_when_it_can_be_restored(string provider) + { + var clock = new ManualClock(Start); + await using var scenario = await CreateAsync(provider, clock); + var first = await UploadAsync(scenario, "first.jpg", ""); + var second = await UploadAsync(scenario, "second.jpg", ""); + await UploadAsync(scenario, "kept.jpg", ""); + + await LibraryAsync(scenario, library => library.DeleteAsync(first, 1)); + clock.Now = Start.AddHours(1); + await LibraryAsync(scenario, library => library.DeleteAsync(second, 1)); + + Assert.Equal( + [ + new DeletedAsset(second, "second.jpg", 1, Start.AddHours(1), Start.AddHours(1).AddDays(30)), + new DeletedAsset(first, "first.jpg", 1, Start, Start.AddDays(30)), + ], + await LibraryAsync(scenario, library => library.ListDeletedAsync())); + } + + private static async Task CreateAsync(string provider, ManualClock clock) + { + var scenario = await EntryScenario.CreateAsync(provider, services => services.AddSingleton(clock)); + await scenario.Schema.CreateReferenceModelAsync(); + return scenario; + } + + private static AssetMetadata Metadata(string fileName, string folder, string? altText = null) + { + return AssetMetadata.Create(fileName, folder, [], altText, null).Value; + } + + private static async Task UploadAsync(EntryScenario scenario, string fileName, string folder) + { + var asset = await LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream([1, 2, 3]), "image/jpeg", Metadata(fileName, folder)))); + return asset.Value.Id; + } + + private static async Task ContentAsync(EntryScenario scenario, Guid asset, int version) + { + var content = (await LibraryAsync(scenario, library => library.OpenAsync(asset, version))).Value; + await using var stream = content.Content; + using var copy = new MemoryStream(); + await stream.CopyToAsync(copy); + return copy.ToArray(); + } + + private static string Code(Result result) + { + return Assert.Single(result.Errors).Code; + } + + private static async Task LibraryAsync(EntryScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } +} From e1faec2565a2b871510b9b26b74071512707114e Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 22:45:19 -0400 Subject: [PATCH 07/17] Regenerate the media asset deletion migrations on top of v2 --- ...1019024515_MediaAssetDeletion.Designer.cs} | 98 ++++++++++++++++++- ...s => 20261019024515_MediaAssetDeletion.cs} | 0 ...1019024500_MediaAssetDeletion.Designer.cs} | 98 ++++++++++++++++++- ...s => 20261019024500_MediaAssetDeletion.cs} | 0 4 files changed, 194 insertions(+), 2 deletions(-) rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019022051_MediaAssetDeletion.Designer.cs => 20261019024515_MediaAssetDeletion.Designer.cs} (92%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019022051_MediaAssetDeletion.cs => 20261019024515_MediaAssetDeletion.cs} (100%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019022041_MediaAssetDeletion.Designer.cs => 20261019024500_MediaAssetDeletion.Designer.cs} (92%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019022041_MediaAssetDeletion.cs => 20261019024500_MediaAssetDeletion.cs} (100%) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019024515_MediaAssetDeletion.Designer.cs similarity index 92% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019024515_MediaAssetDeletion.Designer.cs index 4298dc516..7b4f43176 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019024515_MediaAssetDeletion.Designer.cs @@ -13,7 +13,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlFluentCmsDbContext))] - [Migration("20261019022051_MediaAssetDeletion")] + [Migration("20261019024515_MediaAssetDeletion")] partial class MediaAssetDeletion { /// @@ -744,6 +744,94 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => { b.Property("Id") @@ -1142,6 +1230,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019024515_MediaAssetDeletion.cs similarity index 100% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019022051_MediaAssetDeletion.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019024515_MediaAssetDeletion.cs diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019024500_MediaAssetDeletion.Designer.cs similarity index 92% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019024500_MediaAssetDeletion.Designer.cs index 1705ffdc4..33d636a43 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019024500_MediaAssetDeletion.Designer.cs @@ -11,7 +11,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite { [DbContext(typeof(SqliteFluentCmsDbContext))] - [Migration("20261019022041_MediaAssetDeletion")] + [Migration("20261019024500_MediaAssetDeletion")] partial class MediaAssetDeletion { /// @@ -738,6 +738,94 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => { b.Property("Id") @@ -1132,6 +1220,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019024500_MediaAssetDeletion.cs similarity index 100% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019022041_MediaAssetDeletion.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019024500_MediaAssetDeletion.cs From e8f2efe8e5c34e2968c2a4111712b1a0c3725ff4 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 22:45:30 -0400 Subject: [PATCH 08/17] Say that the retention window covers media assets --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa8d7d6f4..015306b41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ The admin stylesheet is compiled by the Tailwind CSS standalone CLI (ADR-0011). Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `FLUENTCMS_TEST_POSTGRES` to a connection string for an empty database, for example `Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres`. CI does this with a service container. Without the variable those tests are skipped. -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), `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 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), `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. 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. From 3782b3eb825b6f0730d1c98d85f5f85dddd5c2bd Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:37:32 -0400 Subject: [PATCH 09/17] Accept only allowed media types whose bytes match them, and validate the upload limit --- src/FluentCMS.Core/Media/AssetMetadata.cs | 12 +--- src/FluentCMS.Core/Media/IMediaLibrary.cs | 1 + src/FluentCMS.Core/Media/MediaTypes.cs | 34 +++++++++++ .../Media/MediaLibrary.cs | 23 +++++++- .../Media/MediaOptions.cs | 19 ++++-- .../Media/PrefixedStream.cs | 59 +++++++++++++++++++ .../Content/WhereUsedTests.cs | 3 +- .../Media/LocalDiskMediaStorageTests.cs | 17 ++++++ .../Media/MediaFiles.cs | 16 +++++ .../Media/MediaLibraryTests.cs | 40 ++++++++++--- .../Media/AssetMetadataTests.cs | 13 ---- .../FluentCMS.Tests/Media/MediaTypesTests.cs | 46 +++++++++++++++ 12 files changed, 242 insertions(+), 41 deletions(-) create mode 100644 src/FluentCMS.Core/Media/MediaTypes.cs create mode 100644 src/FluentCMS.Infrastructure/Media/PrefixedStream.cs create mode 100644 tests/FluentCMS.IntegrationTests/Media/MediaFiles.cs create mode 100644 tests/FluentCMS.Tests/Media/MediaTypesTests.cs diff --git a/src/FluentCMS.Core/Media/AssetMetadata.cs b/src/FluentCMS.Core/Media/AssetMetadata.cs index c071de52b..a00f31140 100644 --- a/src/FluentCMS.Core/Media/AssetMetadata.cs +++ b/src/FluentCMS.Core/Media/AssetMetadata.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -6,7 +5,7 @@ namespace FluentCMS.Core.Media; // What people and agents say about an asset (MED-01, MED-02): its file name, the folder it sits in, its tags, and the alt text and // caption pages show with it. Folder is "" for the root or slug segments joined by slashes, such as "products/drills". -public sealed partial record AssetMetadata(string FileName, string Folder, IReadOnlyList Tags, string? AltText, string? Caption) +public sealed record AssetMetadata(string FileName, string Folder, IReadOnlyList Tags, string? AltText, string? Caption) { public const int MaxFileNameLength = 255; public const int MaxFolderLength = 512; @@ -63,17 +62,8 @@ public static Result Create(string fileName, string? folder, IEnu : Result.Failure(errors); } - // "image/jpeg" and the like, as uploads name their content. - public static bool IsContentType(string? value) - { - return value is { Length: > 0 and <= 127 } && ContentTypePattern().IsMatch(value); - } - private static bool IsValidText(string? text, int maxLength) { return text is null || (!string.IsNullOrWhiteSpace(text) && text.Trim().Length <= maxLength); } - - [GeneratedRegex(@"^[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\z", RegexOptions.CultureInvariant)] - private static partial Regex ContentTypePattern(); } diff --git a/src/FluentCMS.Core/Media/IMediaLibrary.cs b/src/FluentCMS.Core/Media/IMediaLibrary.cs index d77e72762..0087add71 100644 --- a/src/FluentCMS.Core/Media/IMediaLibrary.cs +++ b/src/FluentCMS.Core/Media/IMediaLibrary.cs @@ -52,6 +52,7 @@ public static class MediaErrorCodes public const string ContentTypeInvalid = "content_type_invalid"; public const string MediaEmpty = "media_empty"; public const string MediaTooLarge = "media_too_large"; + public const string ContentMismatch = "media_content_mismatch"; public const string AssetNotFound = "asset_not_found"; public const string VersionNotFound = "version_not_found"; public const string VersionConflict = "version_conflict"; diff --git a/src/FluentCMS.Core/Media/MediaTypes.cs b/src/FluentCMS.Core/Media/MediaTypes.cs new file mode 100644 index 000000000..55f4817b1 --- /dev/null +++ b/src/FluentCMS.Core/Media/MediaTypes.cs @@ -0,0 +1,34 @@ +namespace FluentCMS.Core.Media; + +// The media types an upload may declare (MED-01). Types a browser runs as a document, such as text/html, application/xhtml+xml +// and image/svg+xml, are left out, so stored bytes cannot become stored XSS once they are served. Each allowed type is checked +// against the file's first bytes, so a declared type cannot lie about the content. +public static class MediaTypes +{ + // Enough of the file's start for every signature below. + public const int SignatureLength = 16; + + public static IReadOnlyList Allowed { get; } = + ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "video/mp4", "video/webm"]; + + public static bool IsAllowed(string? type) + { + return type is not null && Allowed.Contains(type, StringComparer.Ordinal); + } + + // The start of the file, up to SignatureLength bytes, carries the signature of the type declared. + public static bool Matches(string type, ReadOnlySpan head) + { + return type switch + { + "image/jpeg" => head.StartsWith((ReadOnlySpan)[0xFF, 0xD8, 0xFF]), + "image/png" => head.StartsWith((ReadOnlySpan)[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + "image/gif" => head.StartsWith("GIF87a"u8) || head.StartsWith("GIF89a"u8), + "image/webp" => head.Length >= 12 && head.StartsWith("RIFF"u8) && head[8..12].SequenceEqual("WEBP"u8), + "application/pdf" => head.StartsWith("%PDF-"u8), + "video/mp4" => head.Length >= 8 && head[4..8].SequenceEqual("ftyp"u8), + "video/webm" => head.StartsWith((ReadOnlySpan)[0x1A, 0x45, 0xDF, 0xA3]), + _ => false, + }; + } +} diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index 3640e6535..65fea9d1a 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -31,9 +31,11 @@ public async Task> UploadAsync(NewAsset asset, CancellationTo var errors = new List(); var metadata = Revalidate(asset.Metadata, errors); var contentType = asset.ContentType?.Trim().ToLowerInvariant(); - if (!AssetMetadata.IsContentType(contentType)) + if (!MediaTypes.IsAllowed(contentType)) { - errors.Add(new Error(MediaErrorCodes.ContentTypeInvalid, $"contentType: '{asset.ContentType}' is not a media type such as 'image/jpeg'.")); + errors.Add(new Error( + MediaErrorCodes.ContentTypeInvalid, + $"contentType: '{asset.ContentType}' is not a media type uploads accept; use one of {string.Join(", ", MediaTypes.Allowed)}.")); } if (errors.Count > 0) @@ -41,11 +43,26 @@ public async Task> UploadAsync(NewAsset asset, CancellationTo return Result.Failure(errors); } + // The file's start is read first, so a file that is not what it claims to be never reaches the storage. + var head = new byte[MediaTypes.SignatureLength]; + var headLength = await asset.Content.ReadAtLeastAsync(head, head.Length, throwOnEndOfStream: false, cancellationToken); + if (headLength == 0) + { + return Result.Failure(MediaErrorCodes.MediaEmpty, "The file is empty; upload the bytes of the asset."); + } + + if (!MediaTypes.Matches(contentType!, head.AsSpan(0, headLength))) + { + return Result.Failure( + MediaErrorCodes.ContentMismatch, + $"The file does not start like {contentType}; upload it with the media type of its content."); + } + var storage = Storage(); var limit = options.Value.MaxBytes; // The storage sees at most one byte past the limit, so an oversized upload costs no more than the limit to refuse. - var stored = await storage.SaveAsync(new CappedStream(asset.Content, limit + 1), cancellationToken); + var stored = await storage.SaveAsync(new CappedStream(new PrefixedStream(head.AsMemory(0, headLength), asset.Content), limit + 1), cancellationToken); if (stored.Length == 0 || stored.Length > limit) { await storage.DeleteAsync(stored.Key, cancellationToken); diff --git a/src/FluentCMS.Infrastructure/Media/MediaOptions.cs b/src/FluentCMS.Infrastructure/Media/MediaOptions.cs index faa48a638..6fc863efa 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaOptions.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaOptions.cs @@ -24,14 +24,23 @@ public sealed class LocalDiskOptions public string Root { get; set; } = "media"; } -// An unknown storage name stops the host at startup instead of failing the first upload. +// An unknown storage or a limit no upload can meet stops the host at startup instead of failing the first upload. The limit stays +// below long.MaxValue, because an upload is read to one byte past it. internal sealed class MediaOptionsValidation(MediaStorageRegistry storages) : IValidateOptions { public ValidateOptionsResult Validate(string? name, MediaOptions options) { - return storages.Find(options.Storage ?? string.Empty) is not null - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail( - $"Media__Storage is '{options.Storage}', which is no registered media storage; the known storages are {string.Join(", ", storages.Names)}."); + var failures = new List(); + if (storages.Find(options.Storage ?? string.Empty) is null) + { + failures.Add($"Media__Storage is '{options.Storage}', which is no registered media storage; the known storages are {string.Join(", ", storages.Names)}."); + } + + if (options.MaxBytes is <= 0 or long.MaxValue) + { + failures.Add($"Media__MaxBytes is {options.MaxBytes}; it must be from 1 to {long.MaxValue - 1} bytes."); + } + + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); } } diff --git a/src/FluentCMS.Infrastructure/Media/PrefixedStream.cs b/src/FluentCMS.Infrastructure/Media/PrefixedStream.cs new file mode 100644 index 000000000..d02ccd442 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Media/PrefixedStream.cs @@ -0,0 +1,59 @@ +namespace FluentCMS.Infrastructure.Media; + +// Gives back bytes already read from a stream, then the rest of it, so a caller can look at a file's start before passing it on. +internal sealed class PrefixedStream(ReadOnlyMemory prefix, Stream inner) : Stream +{ + private ReadOnlyMemory _prefix = prefix; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + return _prefix.IsEmpty ? inner.Read(buffer) : TakePrefix(buffer); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _prefix.IsEmpty ? inner.ReadAsync(buffer, cancellationToken) : ValueTask.FromResult(TakePrefix(buffer.Span)); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + private int TakePrefix(Span buffer) + { + var count = Math.Min(buffer.Length, _prefix.Length); + _prefix.Span[..count].CopyTo(buffer); + _prefix = _prefix[count..]; + return count; + } +} diff --git a/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs b/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs index d27726322..e266760cc 100644 --- a/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs +++ b/tests/FluentCMS.IntegrationTests/Content/WhereUsedTests.cs @@ -2,6 +2,7 @@ using FluentCMS.Core.Media; using FluentCMS.Core.Results; using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Media; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using static FluentCMS.IntegrationTests.Content.EntryScenario; @@ -142,7 +143,7 @@ private static async Task HeroAsync(EntryScenario scenario) { await using var scope = scenario.Services.CreateAsyncScope(); var metadata = AssetMetadata.Create("drill.jpg", null, null, "A cordless drill.", null).Value; - var asset = await scope.ServiceProvider.GetRequiredService().UploadAsync(new NewAsset(new MemoryStream([1, 2, 3]), "image/jpeg", metadata)); + var asset = await scope.ServiceProvider.GetRequiredService().UploadAsync(new NewAsset(new MemoryStream(MediaFiles.Jpeg()), "image/jpeg", metadata)); return asset.Value.Id; } diff --git a/tests/FluentCMS.IntegrationTests/Media/LocalDiskMediaStorageTests.cs b/tests/FluentCMS.IntegrationTests/Media/LocalDiskMediaStorageTests.cs index 2bbcddd7a..741a1a9fa 100644 --- a/tests/FluentCMS.IntegrationTests/Media/LocalDiskMediaStorageTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/LocalDiskMediaStorageTests.cs @@ -50,6 +50,23 @@ public void The_configured_storage_must_be_registered() Assert.Equal("local", services.GetRequiredService().Names.Single()); } + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("9223372036854775807")] + public void The_upload_limit_must_be_one_an_upload_can_meet(string maxBytes) + { + using var services = TestServices.Build(new Dictionary + { + ["Database:ConnectionString"] = "Data Source=unused.db", + ["Media:MaxBytes"] = maxBytes, + }); + + var exception = Assert.Throws(() => services.GetRequiredService>().Value); + + Assert.Contains($"Media__MaxBytes is {maxBytes}", exception.Message); + } + [Fact] public async Task An_unknown_storage_stops_the_host_from_starting() { diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaFiles.cs b/tests/FluentCMS.IntegrationTests/Media/MediaFiles.cs new file mode 100644 index 000000000..729f91f55 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Media/MediaFiles.cs @@ -0,0 +1,16 @@ +using System.Security.Cryptography; + +namespace FluentCMS.IntegrationTests.Media; + +// Uploads are checked against the signature of their declared type, so test files start like the type they claim. +internal static class MediaFiles +{ + public static byte[] Jpeg(int length = 16) + { + var bytes = RandomNumberGenerator.GetBytes(Math.Max(length, 3)); + bytes[0] = 0xFF; + bytes[1] = 0xD8; + bytes[2] = 0xFF; + return bytes; + } +} diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs index 958ba0159..6b1178f77 100644 --- a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs @@ -20,7 +20,7 @@ public sealed class MediaLibraryTests public async Task An_upload_stores_the_bytes_in_the_configured_storage_and_version_one(string provider) { await using var scenario = await EntryScenario.CreateAsync(provider); - var bytes = RandomNumberGenerator.GetBytes(2048); + var bytes = MediaFiles.Jpeg(2048); var asset = (await UploadAsync(scenario, bytes, Metadata("drill.jpg", "products/drills", ["product-shot"], "A cordless drill."))).Value; var read = (await LibraryAsync(scenario, library => library.GetAsync(asset.Id))).Value; @@ -41,7 +41,7 @@ public async Task An_upload_stores_the_bytes_in_the_configured_storage_and_versi public async Task A_metadata_update_stores_the_next_version_with_the_same_bytes(string provider) { await using var scenario = await EntryScenario.CreateAsync(provider); - var bytes = RandomNumberGenerator.GetBytes(64); + var bytes = MediaFiles.Jpeg(64); var asset = (await UploadAsync(scenario, bytes, Metadata("drill.jpg"))).Value; var updated = (await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, Metadata("drill.jpg", "products", ["hero"], "A drill on a bench.", "Our best seller.")))).Value; @@ -70,7 +70,7 @@ public async Task Empty_oversized_and_mistyped_uploads_are_refused_and_leave_no_ await using var scenario = await EntryScenario.CreateAsync(provider, services => services.PostConfigure(media => media.MaxBytes = 100)); var empty = await UploadAsync(scenario, [], Metadata("empty.png")); - var large = await UploadAsync(scenario, new byte[100_000], Metadata("large.png")); + var large = await UploadAsync(scenario, MediaFiles.Jpeg(100_000), Metadata("large.jpg")); var mistyped = await UploadAsync(scenario, new byte[10], Metadata("text.png"), contentType: "png"); Assert.Equal(MediaErrorCodes.MediaEmpty, Assert.Single(empty.Errors).Code); @@ -83,15 +83,39 @@ public async Task Empty_oversized_and_mistyped_uploads_are_refused_and_leave_no_ Assert.Empty(Directory.Exists(root) ? Directory.GetFiles(root, "*", SearchOption.AllDirectories) : []); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Documents_a_browser_runs_and_files_that_lie_about_their_type_are_refused_and_leave_no_bytes(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var page = ""u8.ToArray(); + + var html = await UploadAsync(scenario, page, Metadata("page.html"), contentType: "text/html"); + var svg = await UploadAsync(scenario, ""u8.ToArray(), Metadata("logo.svg"), contentType: "image/svg+xml"); + var disguised = await UploadAsync(scenario, page, Metadata("photo.jpg")); + var wrongImage = await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("photo.png"), contentType: "image/png"); + + Assert.Equal(MediaErrorCodes.ContentTypeInvalid, Assert.Single(html.Errors).Code); + Assert.Contains("image/jpeg", html.Errors[0].Message); + Assert.Equal(MediaErrorCodes.ContentTypeInvalid, Assert.Single(svg.Errors).Code); + Assert.Equal(MediaErrorCodes.ContentMismatch, Assert.Single(disguised.Errors).Code); + Assert.Equal(MediaErrorCodes.ContentMismatch, Assert.Single(wrongImage.Errors).Code); + Assert.Equal(0, await scenario.Schema.QueryAsync(context => context.MediaAssets.CountAsync())); + + var root = scenario.Services.GetRequiredService>().Value.Root; + Assert.Empty(Directory.Exists(root) ? Directory.GetFiles(root, "*", SearchOption.AllDirectories) : []); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] public async Task Assets_list_by_folder_and_tag(string provider) { await using var scenario = await EntryScenario.CreateAsync(provider); - var root = (await UploadAsync(scenario, [1], Metadata("logo.png", tags: ["brand"]))).Value.Id; - var drills = (await UploadAsync(scenario, [2], Metadata("drill.png", "products/drills", ["product-shot"]))).Value.Id; - var products = (await UploadAsync(scenario, [3], Metadata("range.png", "products", ["product-shot", "brand"]))).Value.Id; + var root = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("logo.png", tags: ["brand"]))).Value.Id; + var drills = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("drill.png", "products/drills", ["product-shot"]))).Value.Id; + var products = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("range.png", "products", ["product-shot", "brand"]))).Value.Id; async Task> ListAsync(MediaQuery query) => (await LibraryAsync(scenario, library => library.ListAsync(query))).Select(asset => asset.Id).ToList(); @@ -112,7 +136,7 @@ public async Task An_entry_must_reference_an_existing_asset_and_then_uses_it(str { await using var scenario = await EntryScenario.CreateAsync(provider); await scenario.Schema.CreateReferenceModelAsync(); - var hero = (await UploadAsync(scenario, [1, 2, 3], Metadata("drill.jpg"))).Value.Id; + var hero = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("drill.jpg"))).Value.Id; var missing = Guid.CreateVersion7(); var refused = await scenario.CreateAsync("product", $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact("99")}}, "hero": {{Fact($"\"{missing}\"")}}}"""); @@ -131,7 +155,7 @@ public async Task An_entry_must_reference_an_existing_asset_and_then_uses_it(str public async Task A_stored_version_cannot_be_overwritten_and_unknown_assets_fail(string provider) { await using var scenario = await EntryScenario.CreateAsync(provider); - await UploadAsync(scenario, [1], Metadata("drill.jpg")); + await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("drill.jpg")); var unknown = Guid.CreateVersion7(); await scenario.Schema.QueryAsync(async context => diff --git a/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs b/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs index f6f0f4b44..94087cd25 100644 --- a/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs +++ b/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs @@ -51,17 +51,4 @@ public void An_asset_has_at_most_32_tags() Assert.Equal(MediaErrorCodes.TagInvalid, Assert.Single(AssetMetadata.Create("a.png", null, tags, null, null).Errors).Code); } - - [Theory] - [InlineData("image/jpeg", true)] - [InlineData("image/svg+xml", true)] - [InlineData("application/vnd.ms-excel", true)] - [InlineData("image", false)] - [InlineData("Image/JPEG", false)] - [InlineData("image/jpeg; charset=x", false)] - [InlineData("", false)] - public void A_content_type_is_a_media_type(string contentType, bool valid) - { - Assert.Equal(valid, AssetMetadata.IsContentType(contentType)); - } } diff --git a/tests/FluentCMS.Tests/Media/MediaTypesTests.cs b/tests/FluentCMS.Tests/Media/MediaTypesTests.cs new file mode 100644 index 000000000..592e09a78 --- /dev/null +++ b/tests/FluentCMS.Tests/Media/MediaTypesTests.cs @@ -0,0 +1,46 @@ +using FluentCMS.Core.Media; + +namespace FluentCMS.Tests.Media; + +public sealed class MediaTypesTests +{ + [Theory] + [InlineData("image/jpeg", true)] + [InlineData("application/pdf", true)] + [InlineData("video/webm", true)] + [InlineData("text/html", false)] + [InlineData("application/xhtml+xml", false)] + [InlineData("image/svg+xml", false)] + [InlineData("Image/JPEG", false)] + [InlineData("image/jpeg; charset=x", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void Only_types_a_browser_does_not_run_as_a_document_are_allowed(string? type, bool allowed) + { + Assert.Equal(allowed, MediaTypes.IsAllowed(type)); + } + + [Theory] + [InlineData("image/jpeg", "FFD8FFE000104A464946")] + [InlineData("image/png", "89504E470D0A1A0A0000000D")] + [InlineData("image/gif", "474946383961")] + [InlineData("image/webp", "52494646240000005745425056503820")] + [InlineData("application/pdf", "255044462D312E37")] + [InlineData("video/mp4", "0000001866747970")] + [InlineData("video/webm", "1A45DFA3")] + public void Each_allowed_type_matches_its_signature(string type, string head) + { + Assert.True(MediaTypes.Matches(type, Convert.FromHexString(head))); + } + + [Theory] + [InlineData("image/jpeg", "3C68746D6C3E")] + [InlineData("image/png", "FFD8FFE0")] + [InlineData("image/webp", "52494646240000004156492020")] + [InlineData("video/mp4", "000000")] + [InlineData("text/html", "3C68746D6C3E")] + public void A_file_that_does_not_start_like_its_type_does_not_match(string type, string head) + { + Assert.False(MediaTypes.Matches(type, Convert.FromHexString(head))); + } +} From 43d4160a74d666b9d0c760a04f49995d75ea9f5c Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:40:00 -0400 Subject: [PATCH 10/17] Refuse an oversized upload before decoding it --- CONTRIBUTING.md | 2 +- .../Builtin/Tools/MediaTools.cs | 13 ++++++-- src/FluentCMS.Core/Media/IMediaLibrary.cs | 3 ++ .../Media/MediaLibrary.cs | 2 ++ .../ChangeSets/MediaChangeSetTests.cs | 3 +- .../Mcp/ConnectedAgent.cs | 11 +++++-- .../Mcp/MediaToolsTests.cs | 33 ++++++++++++++++++- 7 files changed, 60 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d7c340f9..3b5a6a6d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ The admin stylesheet is compiled by the Tailwind CSS standalone CLI (ADR-0011). Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `FLUENTCMS_TEST_POSTGRES` to a connection string for an empty database, for example `Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres`. CI does this with a service container. Without the variable those tests are skipped. -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), `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), `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. diff --git a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs index b8814c29e..fa67505fc 100644 --- a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs @@ -108,7 +108,7 @@ private static async Task UploadAsync(ToolInvocation call, Cancellat var target = ChangeSetTarget.Read(arguments, errors); errors.InsertRange(0, arguments.Errors()); - var bytes = content is null ? null : Base64(content, 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) @@ -404,8 +404,17 @@ private static async Task ProposeAsync( } } - private static byte[]? Base64(string content, List errors) + // 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); diff --git a/src/FluentCMS.Core/Media/IMediaLibrary.cs b/src/FluentCMS.Core/Media/IMediaLibrary.cs index 6a3fbecd9..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); diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index a8e9bc775..4c474f841 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); diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs index 7a2e162f2..13bb8e1cf 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/MediaChangeSetTests.cs @@ -3,6 +3,7 @@ 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; @@ -111,7 +112,7 @@ private static AssetMetadata Metadata(string? altText) 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([1, 2, 3]), "image/jpeg", metadata)); + var asset = await scope.ServiceProvider.GetRequiredService().UploadAsync(new NewAsset(new MemoryStream(MediaFiles.Jpeg()), "image/jpeg", metadata)); return asset.Value.Id; } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs index 6c322813d..d51562668 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs @@ -25,11 +25,18 @@ public static async Task StartAsync( string provider, int itemsPerChangeSet = 200, IReadOnlyList? types = null, - AgentOperation[]? operations = null) + AgentOperation[]? operations = null, + IReadOnlyDictionary? settings = null) { var install = new TemporaryInstall(); var database = await TemporaryDatabase.CreateAsync(provider); - var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var configuration = new Dictionary(database.Configuration); + foreach (var (key, value) in settings ?? new Dictionary()) + { + configuration[key] = value; + } + + var host = await AdminHost.StartAsync(install, settings: configuration); var administrator = await host.CreateFirstAdministratorAsync(); var issued = await McpAgents.IssueAsync(host, administrator, new NewAgentCredential( "schema-agent", diff --git a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs index dde87424f..e1d617696 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -3,6 +3,7 @@ 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; @@ -11,7 +12,7 @@ namespace FluentCMS.IntegrationTests.Mcp; public sealed class MediaToolsTests { - private static readonly string Image = Convert.ToBase64String([0x89, 0x50, 0x4E, 0x47, 1, 2, 3, 4]); + 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] @@ -100,6 +101,36 @@ public async Task Alt_text_added_later_is_in_the_proposed_version_and_the_other_ 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] From da00d213f718712fc9ba3dcf761e0accf0f8e819 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:45:46 -0400 Subject: [PATCH 11/17] Check the alt text of the version that goes live, and only for images --- .../Builtin/Checks/AltTextCheckType.cs | 53 +++++++++++-- .../Builtin/Tools/CheckTools.cs | 9 ++- src/FluentCMS.Core/Checks/CheckInput.cs | 6 +- .../Mcp/MediaToolsTests.cs | 75 +++++++++++++++++++ .../Media/AltTextCheckTests.cs | 48 +++++++++++- 5 files changed, 178 insertions(+), 13 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs index 275f675dd..ac1bdcc6e 100644 --- a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs +++ b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.RegularExpressions; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Media; using FluentCMS.Core.Results; @@ -8,8 +9,8 @@ namespace FluentCMS.Core.Builtin.Checks; // Every image in the text has alt text that describes it (CHK-01): Markdown images, inline or by reference, and HTML img elements. -// A media field passes when the asset it names has alt text in its latest version, the one a change proposes; an asset the -// library does not hold is left to the reference checks of entries. +// A media field that names an image passes when the version readers will see has alt text; other media, such as a PDF, needs +// none. An asset the library does not hold is left to the reference checks of entries. internal sealed class AltTextCheckType : ICheckType { private const RegexOptions Linear = RegexOptions.NonBacktracking | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; @@ -62,6 +63,7 @@ private static async Task> MediaFindingsAsync(CheckInput inpu return findings; } + var proposals = await ProposalsAsync(input, cancellationToken); foreach (var (pointer, value) in CheckContent.FieldValues(input.Subject)) { var field = definition.Fields.FirstOrDefault(candidate => candidate.Name == CheckContent.FieldOf(pointer)); @@ -69,14 +71,15 @@ private static async Task> MediaFindingsAsync(CheckInput inpu || !MediaFields.Holds(field) || value.ValueKind != JsonValueKind.String || !Guid.TryParse(value.GetString(), out var id) - || await library.GetAsync(id, cancellationToken) is not { IsSuccess: true } asset - || !string.IsNullOrWhiteSpace(asset.Value.Latest.Metadata.AltText)) + || await ShownAsync(library, id, proposals, cancellationToken) is not { } shown + || !shown.Blob.ContentType.StartsWith("image/", StringComparison.Ordinal) + || !string.IsNullOrWhiteSpace(shown.Metadata.AltText)) { continue; } findings.Add(new CheckFinding( - $"Add alt text to the image '{asset.Value.Latest.Metadata.FileName}' in {field.Name} that says what it shows; set it with update_media.", + $"Add alt text to the image '{shown.Metadata.FileName}' in {field.Name} that says what it shows; set it with update_media.", pointer, id.ToString())); } @@ -84,6 +87,46 @@ private static async Task> MediaFindingsAsync(CheckInput inpu return findings; } + // The version of an asset that readers will see once the content goes live (MED-02): the one the subject's change set proposes + // when it holds the asset, else the published one. An asset never published and outside the set is read at its latest version, + // the best guess at what will go live with it. Null when the asset is unknown or the set unpublishes it. + private static async Task ShownAsync( + IMediaLibrary library, + Guid id, + IReadOnlyDictionary proposals, + CancellationToken cancellationToken) + { + if (await library.GetAsync(id, cancellationToken) is not { IsSuccess: true } asset) + { + return null; + } + + var version = proposals.TryGetValue(id, out var proposed) ? proposed : asset.Value.PublishedVersion ?? asset.Value.LatestVersion; + if (version is not { } number) + { + return null; + } + + return number == asset.Value.LatestVersion + ? asset.Value.Latest + : (await library.GetVersionAsync(id, number, cancellationToken)) is { IsSuccess: true } stored ? stored.Value : null; + } + + // The media versions the subject's change set proposes; none when the subject belongs to no set. + private static async Task> ProposalsAsync(CheckInput input, CancellationToken cancellationToken) + { + if (input.Subject.ChangeSetId is not { } changeSetId + || input.Services.GetService() is not { } changeSets + || await changeSets.GetAsync(changeSetId, cancellationToken) is not { IsSuccess: true } set) + { + return new Dictionary(); + } + + return set.Value.Operations + .Where(operation => operation.ItemKind == ItemKind.Media) + .ToDictionary(operation => operation.ItemId, operation => operation.ProposedVersion); + } + // The value of the element's alt attribute, read attribute by attribute so a quoted value or a data-alt is not taken for it. private static string? Alt(string element) { diff --git a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs index 787876d20..6ce5def9a 100644 --- a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs @@ -39,7 +39,11 @@ public sealed class CheckTools : IToolProvider "exports/catalog-2026-10-18.csv", required: false), ToolSchemas.String("checks", "Which checks to run: \"all\", the default, or \"deterministic\".", "deterministic", required: false), - ToolSchemas.Uuid("id", "For a draft of an update: the entry it updates, so checks that ask about changes see only what differs.", required: false)), + ToolSchemas.Uuid("id", "For a draft of an update: the entry it updates, so checks that ask about changes see only what differs.", required: false), + ToolSchemas.Uuid( + "change_set_id", + "The change set the draft will go into, so checks read the media versions it proposes, such as alt text added with update_media.", + required: false)), // Not read-only: it stores nothing, but an LLM-judged check spends the agent's model budget. readOnly: false, @@ -54,6 +58,7 @@ private static async Task CheckDraftAsync(ToolInvocation call, Cance var sources = arguments.OptionalStrings("sources", "exports/catalog-2026-10-18.csv") ?? []; var checks = arguments.OptionalString("checks", "deterministic"); var id = arguments.OptionalGuid("id"); + var changeSetId = arguments.OptionalGuid("change_set_id"); var errors = arguments.Errors().ToList(); if (checks is not (null or "all" or "deterministic")) { @@ -94,7 +99,7 @@ [new Error(ToolErrorCodes.ArgumentInvalid, $"The entry {entryId} is of the conte var definition = type.Value.Definition; var refused = EntryFields.Validate(fields!.Value, definition, services.GetRequiredService()).Errors; var run = await services.GetRequiredService().RunAsync( - new CheckSubject(fields.Value, sources, slug, definition, changed), + new CheckSubject(fields.Value, sources, slug, definition, changed, changeSetId), checks == "deterministic" ? CheckSelection.Deterministic : CheckSelection.All, cancellationToken); diff --git a/src/FluentCMS.Core/Checks/CheckInput.cs b/src/FluentCMS.Core/Checks/CheckInput.cs index e7694c90e..b687b7bf6 100644 --- a/src/FluentCMS.Core/Checks/CheckInput.cs +++ b/src/FluentCMS.Core/Checks/CheckInput.cs @@ -8,10 +8,12 @@ public sealed record CheckInput(JsonElement Settings, CheckSubject Subject, ISer // One piece of content as a check sees it: its fields as an entry stores them, the sources its change cites, and its content type // when it has one. The fields hold only the ones the check's scope names. ChangedFields names the fields the change touches, and is -// null when every field counts as changed, as for new content or a draft. +// null when every field counts as changed, as for new content or a draft. ChangeSetId names the set the content goes live with, +// so a check can read what that set proposes for the items the content refers to. public sealed record CheckSubject( JsonElement Fields, IReadOnlyList Sources, string? ContentType = null, ContentTypeDefinition? Definition = null, - IReadOnlyList? ChangedFields = null); + IReadOnlyList? ChangedFields = null, + Guid? ChangeSetId = null); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs index e1d617696..6ab53ed2d 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using FluentCMS.Core.Agents; using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; using FluentCMS.Core.Content; using FluentCMS.Core.Media; using FluentCMS.Core.Schema; @@ -65,6 +66,62 @@ public async Task An_agent_uploads_an_image_and_attaches_it_to_a_page_in_one_set Assert.False((bool)listed["media"]![0]!["hasAltText"]!); } + // S2 with the alt text check (MED-02): a page that will show a published image without alt text fails until the same set adds + // the alt text with update_media. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_alt_text_check_passes_once_the_pages_set_adds_alt_text_with_update_media(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var page = await PageAsync(agent); + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "library", + ["rationale"] = "An image for the catalog.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + await PublishAsPersonAsync(agent, (string)uploaded["changeSet"]!["id"]!); + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("alt-text", AltTextCheck))); + await agent.AsPersonAsync(directory => directory.ActivateAsync("alt-text", 1)); + + var attached = await agent.CallAsync("attach_media", new() + { + ["entry_id"] = page.ToString(), + ["base_version"] = 1, + ["field"] = "hero", + ["asset_id"] = assetId, + ["session_id"] = "launch", + ["rationale"] = "Show the drill on its page.", + }); + var setId = (string)attached["changeSet"]!["id"]!; + Dictionary Draft() => new() + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse($$"""{"headline": "The Widget", "hero": "{{assetId}}"}"""), + ["id"] = page.ToString(), + ["change_set_id"] = setId, + }; + + var before = await agent.CallAsync("check_draft", Draft()); + await agent.CallAsync("update_media", new() + { + ["id"] = assetId, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "launch", + ["rationale"] = "Describe the drill for people who cannot see it.", + }); + var after = await agent.CallAsync("check_draft", Draft()); + + Assert.Equal("fail", (string?)before["status"]); + Assert.Equal("/hero", (string?)before["results"]![0]!["findings"]![0]!["pointer"]); + Assert.Equal("pass", (string?)after["status"]); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] @@ -197,6 +254,24 @@ await agent.QueryAsync(context => context.Entries return page.Id; } + private static readonly CheckDefinition AltTextCheck = new( + "altText", + "Every image a product page shows has alt text.", + JsonSerializer.Deserialize("{}"), + [ + new CheckExample("described", CheckVerdict.Pass, JsonSerializer.Deserialize("""{"headline": "A drill"}"""), contentType: "product-page"), + new CheckExample("bare", CheckVerdict.Fail, JsonSerializer.Deserialize("""{"headline": "![](/drill.jpg)"}"""), contentType: "product-page"), + ], + scope: new CheckScope(["product-page"])); + + private static async Task PublishAsPersonAsync(ConnectedAgent agent, string setId) + { + var id = Guid.Parse(setId); + await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + await agent.AsPersonAsync(directory => directory.TransitionAsync(id, ChangeSetState.Approved)); + await agent.AsPersonAsync(directory => directory.PublishAsync(id)); + } + private static string? Code(JsonObject failure) { return (string?)failure["errors"]![0]!["code"]; diff --git a/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs index 809680d8e..b9c22af8b 100644 --- a/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs @@ -1,9 +1,11 @@ using System.Text.Json; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Media; using FluentCMS.Core.Schema; using FluentCMS.Infrastructure.Persistence; using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.IntegrationTests.Media; @@ -41,7 +43,7 @@ public async Task A_media_field_naming_an_asset_without_alt_text_fails_and_names [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] - public async Task Alt_text_in_the_assets_latest_version_passes_on_pages_and_entities(string provider) + public async Task Alt_text_on_an_unpublished_assets_latest_version_passes_on_pages_and_entities(string provider) { await using var scenario = await EntryScenario.CreateAsync(provider); var asset = await UploadAsync(scenario, "drill.jpg", altText: null); @@ -59,16 +61,54 @@ public async Task Alt_text_in_the_assets_latest_version_passes_on_pages_and_enti new[] { page, entityAfter, unknown }.Select(outcome => outcome.Verdict)); } + // MED-02: what goes live counts. A published image without alt text fails while a newer version with alt text waits in a set, + // and passes when the page goes live with that set. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_published_version_counts_unless_the_pages_change_set_proposes_another(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await UploadAsync(scenario, "drill.jpg", altText: null); + await scenario.Schema.QueryAsync(context => context.MediaAssets + .Where(row => row.Id == asset.Id) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, 1))); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + var page = $$"""{"hero": "{{asset.Id}}"}"""; + + await using var scope = scenario.Services.CreateAsyncScope(); + var sets = scope.ServiceProvider.GetRequiredService(); + var set = (await sets.OpenAsync(new NewChangeSet("Describe the drill.", []))).Value; + Assert.True((await sets.AddOperationAsync(set.Id, ChangeOperation.Create(OperationKind.Update, ItemKind.Media, asset.Id, 1, 2).Value)).IsSuccess); + var other = (await sets.OpenAsync(new NewChangeSet("Something else.", []))).Value; + + Assert.Equal(CheckVerdict.Fail, (await EvaluateAsync(scenario, ProductPage, page)).Verdict); + Assert.Equal(CheckVerdict.Fail, (await EvaluateAsync(scenario, ProductPage, page, other.Id)).Verdict); + Assert.Equal(CheckVerdict.Pass, (await EvaluateAsync(scenario, ProductPage, page, set.Id)).Verdict); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Media_that_is_not_an_image_needs_no_alt_text(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var metadata = AssetMetadata.Create("manual.pdf", null, [], null, null).Value; + var manual = (await LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream("%PDF-1.7"u8.ToArray()), "application/pdf", metadata)))).Value; + + Assert.Equal(CheckVerdict.Pass, (await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{manual.Id}}"}""")).Verdict); + } + private static async Task UploadAsync(EntryScenario scenario, string fileName, string? altText) { var metadata = AssetMetadata.Create(fileName, null, [], altText, null).Value; - return (await LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream([1, 2, 3]), "image/jpeg", metadata)))).Value; + return (await LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream(MediaFiles.Jpeg()), "image/jpeg", metadata)))).Value; } - private static async Task EvaluateAsync(EntryScenario scenario, ContentTypeDefinition definition, string fields) + private static async Task EvaluateAsync(EntryScenario scenario, ContentTypeDefinition definition, string fields, Guid? changeSetId = null) { await using var scope = scenario.Services.CreateAsyncScope(); - var subject = new CheckSubject(JsonSerializer.Deserialize(fields), [], "product-page", definition); + var subject = new CheckSubject(JsonSerializer.Deserialize(fields), [], "product-page", definition, ChangeSetId: changeSetId); var input = new CheckInput(JsonSerializer.Deserialize("{}"), subject, scope.ServiceProvider); return await scope.ServiceProvider.GetRequiredService().EvaluateAsync("altText", input); From 3ceaa125ebbfe1c31e21c53afd001d09e840da7a Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:56:59 -0400 Subject: [PATCH 12/17] Refuse control characters in alt text and caption, and match folders in any case --- src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs | 2 +- src/FluentCMS.Core/Content/IWhereUsed.cs | 2 +- src/FluentCMS.Core/Media/AssetMetadata.cs | 6 +++++- src/FluentCMS.Infrastructure/Media/MediaLibrary.cs | 5 +++-- .../Media/MediaLibraryTests.cs | 9 ++++++--- tests/FluentCMS.Tests/Media/AssetMetadataTests.cs | 10 ++++++++++ 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs index 776ee22c2..de01fe9cd 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs @@ -89,7 +89,7 @@ public static Result Update(Entry entry, ItemKind kind) ContentErrorCodes.VersionNotFound => "Call get_entry_history for the versions the entry has.", ContentErrorCodes.FieldsMalformed or ContentErrorCodes.FieldUnknown or ContentErrorCodes.FieldRequired or SchemaErrorCodes.ValueInvalid => ReadSchema, ContentErrorCodes.ReferenceNotFound or ContentErrorCodes.ReferenceTypeMismatch => - "Call list_entries with the referenced content type's slug for the ids a reference field can take.", + "Call list_entries with the referenced content type's slug for the ids a reference field can take; a media field takes the id of an uploaded asset.", ContentErrorCodes.EntryNotPublished => "Only a published entry can be taken offline; get_entry shows its publishedVersion.", EntityErrorCodes.FieldNotRelation => "Call get_content_type for the entity type's reference fields and the types they point at.", ContentErrorCodes.EntryNotEntity => "Only entities have facts and relations; get_content_type shows whether a type is an entity type.", diff --git a/src/FluentCMS.Core/Content/IWhereUsed.cs b/src/FluentCMS.Core/Content/IWhereUsed.cs index e66944d4a..d7445edca 100644 --- a/src/FluentCMS.Core/Content/IWhereUsed.cs +++ b/src/FluentCMS.Core/Content/IWhereUsed.cs @@ -12,7 +12,7 @@ public interface IWhereUsed // References to one field of an entity, such as product.price. Task>> ForEntityFieldAsync(Guid entityId, string field, CancellationToken cancellationToken = default); - // Assets are not checked to exist until the media library does (F-MED). + // An asset the library does not hold has no uses, so it lists none. Task> ForMediaAsync(Guid assetId, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Core/Media/AssetMetadata.cs b/src/FluentCMS.Core/Media/AssetMetadata.cs index a00f31140..8013b92ac 100644 --- a/src/FluentCMS.Core/Media/AssetMetadata.cs +++ b/src/FluentCMS.Core/Media/AssetMetadata.cs @@ -62,8 +62,12 @@ public static Result Create(string fileName, string? folder, IEnu : Result.Failure(errors); } + // Line breaks are text; other control characters are not, and PostgreSQL refuses NUL outright. private static bool IsValidText(string? text, int maxLength) { - return text is null || (!string.IsNullOrWhiteSpace(text) && text.Trim().Length <= maxLength); + return text is null + || (!string.IsNullOrWhiteSpace(text) + && text.Trim().Length <= maxLength + && !text.Any(character => char.IsControl(character) && character is not ('\n' or '\r' or '\t'))); } } diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index 65fea9d1a..c25416fe3 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -65,7 +65,7 @@ public async Task> UploadAsync(NewAsset asset, CancellationTo var stored = await storage.SaveAsync(new CappedStream(new PrefixedStream(head.AsMemory(0, headLength), asset.Content), limit + 1), cancellationToken); if (stored.Length == 0 || stored.Length > limit) { - await storage.DeleteAsync(stored.Key, cancellationToken); + await storage.DeleteAsync(stored.Key, CancellationToken.None); return stored.Length == 0 ? Result.Failure(MediaErrorCodes.MediaEmpty, "The file is empty; upload the bytes of the asset.") : Result.Failure(MediaErrorCodes.MediaTooLarge, $"The file has more than {limit} bytes, and an upload may have at most {limit}."); @@ -186,7 +186,8 @@ public async Task> ListAsync(MediaQuery query, var latest = LatestVersions(); if (query.Folder is { } folder) { - var path = folder.Trim().Trim('/'); + // Folders are stored in lowercase, so both providers compare them alike whatever case the query uses. + var path = folder.Trim().Trim('/').ToLowerInvariant(); var prefix = path + "/"; latest = query.IncludeSubfolders ? latest.Where(version => path.Length == 0 || version.Folder == path || version.Folder.StartsWith(prefix)) diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs index 6b1178f77..665d009b3 100644 --- a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs @@ -116,13 +116,16 @@ public async Task Assets_list_by_folder_and_tag(string provider) var root = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("logo.png", tags: ["brand"]))).Value.Id; var drills = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("drill.png", "products/drills", ["product-shot"]))).Value.Id; var products = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("range.png", "products", ["product-shot", "brand"]))).Value.Id; + var catalog = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("catalog.png", "products"))).Value.Id; async Task> ListAsync(MediaQuery query) => (await LibraryAsync(scenario, library => library.ListAsync(query))).Select(asset => asset.Id).ToList(); - Assert.Equal(new[] { root, drills, products }.Order(), await ListAsync(new MediaQuery())); - Assert.Equal([products], await ListAsync(new MediaQuery("products"))); - Assert.Equal(new[] { drills, products }.Order(), await ListAsync(new MediaQuery("products", IncludeSubfolders: true))); + Assert.Equal(new[] { root, drills, products, catalog }.Order(), await ListAsync(new MediaQuery())); + Assert.Equal(new[] { products, catalog }.Order(), await ListAsync(new MediaQuery("products"))); + Assert.Equal(new[] { drills, products, catalog }.Order(), await ListAsync(new MediaQuery("products", IncludeSubfolders: true))); + Assert.Equal(new[] { drills, products, catalog }.Order(), await ListAsync(new MediaQuery("Products", IncludeSubfolders: true))); + Assert.Equal([drills], await ListAsync(new MediaQuery("Products/Drills"))); Assert.Equal([root], await ListAsync(new MediaQuery(""))); Assert.Equal(new[] { root, products }.Order(), await ListAsync(new MediaQuery(Tag: "brand"))); Assert.Equal([drills], await ListAsync(new MediaQuery("products/drills", Tag: "product-shot"))); diff --git a/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs b/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs index 94087cd25..7058943ce 100644 --- a/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs +++ b/tests/FluentCMS.Tests/Media/AssetMetadataTests.cs @@ -44,6 +44,16 @@ public void A_file_name_is_one_name_without_slashes(string fileName) Assert.Equal(MediaErrorCodes.FileNameInvalid, Assert.Single(AssetMetadata.Create(fileName, null, null, null, null).Errors).Code); } + [Fact] + public void Alt_text_and_caption_keep_line_breaks_and_refuse_other_control_characters() + { + var multiline = AssetMetadata.Create("a.png", null, null, "A drill.\nOn a bench.", "Line one\r\n\tline two"); + var withNul = AssetMetadata.Create("a.png", null, null, "alt\0text", "bell\a"); + + Assert.True(multiline.IsSuccess); + Assert.Equal([MediaErrorCodes.AltTextInvalid, MediaErrorCodes.CaptionInvalid], withNul.Errors.Select(error => error.Code)); + } + [Fact] public void An_asset_has_at_most_32_tags() { From f68a3cb440d41249c9e2f0daaf00d1520ecf281c Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 06:57:18 -0400 Subject: [PATCH 13/17] Leave the content model's own change set out of the count --- tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs index b694c5bca..77389a3fb 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -179,7 +179,7 @@ public async Task Refused_uploads_and_attachments_say_why_and_store_nothing(stri 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())); + 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. From b6b307f4918cba6183e850f3a372851a6e8a4475 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 07:27:15 -0400 Subject: [PATCH 14/17] Let a set's own checks read the media versions it proposes --- src/FluentCMS.Core/Checks/ChangeSetChecks.cs | 2 +- tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs index 9a83da529..3f5cdfba6 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -363,7 +363,7 @@ private async Task AnyJudgedAsync(CancellationToken cancellationToken) } var changed = before is null ? null : Changed(proposed.Value.Fields, before.Value.Fields); - subjects.Add((operation, entry.Value.ContentType, new CheckSubject(proposed.Value.Fields, set.Sources, entry.Value.ContentType, type.Definition, changed))); + subjects.Add((operation, entry.Value.ContentType, new CheckSubject(proposed.Value.Fields, set.Sources, entry.Value.ContentType, type.Definition, changed, set.Id))); } return (subjects, problems); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs index 63e7f87e7..3d035456b 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -120,6 +120,11 @@ public async Task The_alt_text_check_passes_once_the_pages_set_adds_alt_text_wit Assert.Equal("fail", (string?)before["status"]); Assert.Equal("/hero", (string?)before["results"]![0]!["findings"]![0]!["pointer"]); Assert.Equal("pass", (string?)after["status"]); + + // Submitted, the set's own checks read the version it proposes too. + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + var altText = submitted["checks"]!["items"]!.AsArray().Single()!["results"]!.AsArray().Single(result => (string?)result!["check"] == "alt-text")!; + Assert.Equal("pass", (string?)altText["status"]); } [Theory] From 0bf5796f87201220cb1bfba79908afda1e49bf05 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 07:34:28 -0400 Subject: [PATCH 15/17] Regenerate the media asset deletion migrations on top of v2 --- ...61019113220_MediaAssetDeletion.Designer.cs | 1560 +++++++++++++++++ .../20261019113220_MediaAssetDeletion.cs | 29 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 3 + ...61019113057_MediaAssetDeletion.Designer.cs | 1550 ++++++++++++++++ .../20261019113057_MediaAssetDeletion.cs | 29 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 3 + .../FluentCMS.Tests/Admin/MediaViewsTests.cs | 6 + .../FluentCMS.Tests/Admin/PickerFieldTests.cs | 6 + 8 files changed, 3186 insertions(+) create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.cs diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.Designer.cs new file mode 100644 index 000000000..e85b96d72 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.Designer.cs @@ -0,0 +1,1560 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019113220_MediaAssetDeletion")] + partial class MediaAssetDeletion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddedById") + .HasColumnType("uuid"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Events") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.cs new file mode 100644 index 000000000..e92535922 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019113220_MediaAssetDeletion.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class MediaAssetDeletion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "MediaAssets", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "MediaAssets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index d40af02d0..d98665927 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -819,6 +819,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + b.Property("LatestVersion") .HasColumnType("integer"); diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.Designer.cs new file mode 100644 index 000000000..3dffbacbb --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.Designer.cs @@ -0,0 +1,1550 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019113057_MediaAssetDeletion")] + partial class MediaAssetDeletion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.Property("AddedById") + .HasColumnType("TEXT"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DisabledAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.cs new file mode 100644 index 000000000..1812912a1 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019113057_MediaAssetDeletion.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class MediaAssetDeletion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "MediaAssets", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "MediaAssets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 63a920233..9b00d5175 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -813,6 +813,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("TEXT"); + b.Property("DeletedAt") + .HasColumnType("TEXT"); + b.Property("LatestVersion") .HasColumnType("INTEGER"); diff --git a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs index cd2db4fac..810bf3eb0 100644 --- a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs +++ b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs @@ -185,6 +185,12 @@ public Task> GetAsync(Guid id, CancellationToken cancellation public Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task DeleteAsync(Guid id, int baseVersion, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> RestoreAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ListDeletedAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FakeUsage(FakeLibrary library) : IWhereUsed diff --git a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs index 6fdd3c4e7..f1a481ffd 100644 --- a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs +++ b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs @@ -174,5 +174,11 @@ public Task> ListAsync(MediaQuery query, Cancel public Task> FoldersAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task DeleteAsync(Guid id, int baseVersion, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> RestoreAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ListDeletedAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); } } From f06478bd95caca162b3aa9004bc326e0ff4a840f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 08:21:11 -0400 Subject: [PATCH 16/17] Let update_media extend an upload in the same set, and fail an image that is neither published nor in the set --- .../Builtin/Checks/AltTextCheckType.cs | 56 +++--- .../Builtin/Tools/CheckTools.cs | 18 ++ .../Builtin/Tools/MediaTools.cs | 1 - src/FluentCMS.Core/ChangeSets/ChangeSet.cs | 4 +- .../Mcp/MediaToolsTests.cs | 161 +++++++++++++++++- .../Media/AltTextCheckTests.cs | 47 ++++- .../ChangeSets/ChangeSetTests.cs | 12 +- 7 files changed, 264 insertions(+), 35 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs index ac1bdcc6e..932c483c4 100644 --- a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs +++ b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs @@ -9,8 +9,9 @@ namespace FluentCMS.Core.Builtin.Checks; // Every image in the text has alt text that describes it (CHK-01): Markdown images, inline or by reference, and HTML img elements. -// A media field that names an image passes when the version readers will see has alt text; other media, such as a PDF, needs -// none. An asset the library does not hold is left to the reference checks of entries. +// A media field that names an image passes when the version readers will see has alt text: the one the content's change set proposes, +// else the published one. An image that is neither published nor in the set fails, since nothing checks what goes live with it later. +// Other media, such as a PDF, needs no alt text. An asset the library does not hold is left to the reference checks of entries. internal sealed class AltTextCheckType : ICheckType { private const RegexOptions Linear = RegexOptions.NonBacktracking | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; @@ -71,45 +72,58 @@ private static async Task> MediaFindingsAsync(CheckInput inpu || !MediaFields.Holds(field) || value.ValueKind != JsonValueKind.String || !Guid.TryParse(value.GetString(), out var id) - || await ShownAsync(library, id, proposals, cancellationToken) is not { } shown - || !shown.Blob.ContentType.StartsWith("image/", StringComparison.Ordinal) - || !string.IsNullOrWhiteSpace(shown.Metadata.AltText)) + || await library.GetAsync(id, cancellationToken) is not { IsSuccess: true } asset + || !asset.Value.Latest.Blob.ContentType.StartsWith("image/", StringComparison.Ordinal)) { continue; } - findings.Add(new CheckFinding( - $"Add alt text to the image '{shown.Metadata.FileName}' in {field.Name} that says what it shows; set it with update_media.", - pointer, - id.ToString())); + if (await ShownAsync(library, asset.Value, proposals, cancellationToken) is not { } shown) + { + // Neither published nor in this set: whichever version some other set publishes later, this content is not checked + // against it, so the image goes live here with no alt text a reader can rely on. + if (!proposals.ContainsKey(id)) + { + findings.Add(new CheckFinding( + $"The image '{asset.Value.Latest.Metadata.FileName}' in {field.Name} is not published; give it alt text with update_media " + + "in the change set this content goes into, so both go live together.", + pointer, + id.ToString())); + } + + continue; + } + + if (string.IsNullOrWhiteSpace(shown.Metadata.AltText)) + { + findings.Add(new CheckFinding( + $"Add alt text to the image '{shown.Metadata.FileName}' in {field.Name} that says what it shows; set it with update_media.", + pointer, + id.ToString())); + } } return findings; } // The version of an asset that readers will see once the content goes live (MED-02): the one the subject's change set proposes - // when it holds the asset, else the published one. An asset never published and outside the set is read at its latest version, - // the best guess at what will go live with it. Null when the asset is unknown or the set unpublishes it. + // when it holds the asset, else the published one. Null when the set unpublishes it, or when it is not published and the set does + // not hold it. private static async Task ShownAsync( IMediaLibrary library, - Guid id, + MediaAsset asset, IReadOnlyDictionary proposals, CancellationToken cancellationToken) { - if (await library.GetAsync(id, cancellationToken) is not { IsSuccess: true } asset) - { - return null; - } - - var version = proposals.TryGetValue(id, out var proposed) ? proposed : asset.Value.PublishedVersion ?? asset.Value.LatestVersion; + var version = proposals.TryGetValue(asset.Id, out var proposed) ? proposed : asset.PublishedVersion; if (version is not { } number) { return null; } - return number == asset.Value.LatestVersion - ? asset.Value.Latest - : (await library.GetVersionAsync(id, number, cancellationToken)) is { IsSuccess: true } stored ? stored.Value : null; + return number == asset.LatestVersion + ? asset.Latest + : (await library.GetVersionAsync(asset.Id, number, cancellationToken)) is { IsSuccess: true } stored ? stored.Value : null; } // The media versions the subject's change set proposes; none when the subject belongs to no set. diff --git a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs index 5b81f55a2..de8e9ed6a 100644 --- a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Content; using FluentCMS.Core.Results; @@ -86,6 +87,23 @@ private static async Task CheckDraftAsync(ToolInvocation call, Cance return ToolResult.Failure(type.Errors, type.Errors.Any(error => error.Code == SchemaErrorCodes.ContentTypeNotFound) ? FindContentTypes : null); } + // Checks read what the set proposes, so it must be a set the agent may read and one that can still go live. + if (changeSetId is { } setId) + { + var set = await services.GetRequiredService().GetAsync(setId, cancellationToken); + if (!set.IsSuccess) + { + return ToolResult.Failure(set.Errors); + } + + if (set.Value.State is ChangeSetState.Published or ChangeSetState.Rejected or ChangeSetState.Reverted) + { + return ToolResult.Failure([new Error( + ToolErrorCodes.ArgumentInvalid, + $"Change set {setId} is {ToolJson.Name(set.Value.State)}, so nothing goes live with it any more; give the set the draft goes into.")]); + } + } + IReadOnlyList? changed = null; if (id is { } entryId) { diff --git a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs index 99332f6a9..8fba1842f 100644 --- a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs @@ -329,7 +329,6 @@ private static async Task> AttachToEntryAsync( return await entries.UpdateAsync(entryId, baseVersion, JsonSerializer.SerializeToElement(fields), cancellationToken); } - // A single media field or a list of 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( diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index a3b1f4583..c0e8988f1 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -177,7 +177,7 @@ public Result AddOperation(ChangeOperation operation) } // One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06). A newer version of an - // entry or entity the set already proposes, as when a reopened set is fixed, moves that operation on to it. + // entry, entity or media asset the set already proposes, as when a reopened set is fixed, moves that operation on to it. var position = _operations.FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId); if (position >= 0) { @@ -358,7 +358,7 @@ private static string Duplicate(ChangeOperation existing, ChangeOperation operat // The same change from the same published version, proposing a later draft. private static bool Supersedes(ChangeOperation existing, ChangeOperation operation) { - return operation.ItemKind is ItemKind.Entry or ItemKind.Entity + return operation.ItemKind is ItemKind.Entry or ItemKind.Entity or ItemKind.Media && operation.Kind is OperationKind.Create or OperationKind.Update && operation.Kind == existing.Kind && operation.BaseVersion == existing.BaseVersion diff --git a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs index 3d035456b..c89544858 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -68,6 +68,158 @@ public async Task An_agent_uploads_an_image_and_attaches_it_to_a_page_in_one_set // S2 with the alt text check (MED-02): a page that will show a published image without alt text fails until the same set adds // the alt text with update_media. + // S2 in one set (MED-02): the agent uploads an image without alt text and attaches it to a page, the check names the field, the + // agent adds alt text to the image in the same set, and the set passes the check it runs when submitted. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_uploads_attaches_and_describes_an_image_in_one_set_until_the_check_passes(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var page = await PageAsync(agent); + await ActiveAltTextCheckAsync(agent); + + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "launch", + ["rationale"] = "Show the drill on its page.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + var setId = (string)uploaded["changeSet"]!["id"]!; + 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.", + }); + Dictionary Draft() => new() + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse($$"""{"headline": "The Widget", "hero": "{{assetId}}"}"""), + ["id"] = page.ToString(), + ["change_set_id"] = setId, + }; + + var before = await agent.CallAsync("check_draft", Draft()); + var described = await agent.CallAsync("update_media", new() + { + ["id"] = assetId, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "launch", + ["rationale"] = "Describe the drill for people who cannot see it.", + }); + var after = await agent.CallAsync("check_draft", Draft()); + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + + Assert.Equal("fail", (string?)before["status"]); + Assert.Equal("/hero", (string?)before["results"]![0]!["findings"]![0]!["pointer"]); + var media = described["changeSet"]!["operations"]!.AsArray().Single(operation => (string?)operation!["itemKind"] == "media")!; + Assert.Equal(("create", 2), ((string)media["kind"]!, (int)media["proposedVersion"]!)); + Assert.Equal("pass", (string?)after["status"]); + var altText = submitted["checks"]!["items"]!.AsArray().Single()!["results"]!.AsArray().Single(result => (string?)result!["check"] == "alt-text")!; + Assert.Equal("pass", (string?)altText["status"]); + } + + // The same for an entity's media fact: the photo of a product passes once update_media gives it alt text in the product's set. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_entity_s_media_fact_passes_once_update_media_describes_it_in_the_same_set(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await agent.AsPersonAsync(directory => directory.CreateAsync( + new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("photo", "media")], isEntity: true)), + TestChangeSetTargets.ContentModel)); + var check = new CheckDefinition( + "altText", + "Every image a product shows has alt text.", + JsonSerializer.Deserialize("{}"), + [ + new CheckExample("described", CheckVerdict.Pass, JsonSerializer.Deserialize("""{"name": {"value": "A drill"}}"""), contentType: "product"), + new CheckExample("bare", CheckVerdict.Fail, JsonSerializer.Deserialize("""{"name": {"value": "![](/drill.jpg)"}}"""), contentType: "product"), + ], + scope: new CheckScope(["product"])); + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("alt-text", check))); + await agent.AsPersonAsync(directory => directory.ActivateAsync("alt-text", 1)); + + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "catalog", + ["rationale"] = "A photo for the drill.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + var setId = (string)uploaded["changeSet"]!["id"]!; + Dictionary Draft() => new() + { + ["content_type"] = "product", + ["fields"] = JsonNode.Parse($$""" + { + "name": {"value": "Drill", "source": {"kind": "system", "reference": "seed"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "photo": {"value": "{{assetId}}", "source": {"kind": "system", "reference": "seed"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + } + """), + ["change_set_id"] = setId, + }; + + var before = await agent.CallAsync("check_draft", Draft()); + await agent.CallAsync("update_media", new() + { + ["id"] = assetId, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "catalog", + ["rationale"] = "Describe the photo.", + }); + var after = await agent.CallAsync("check_draft", Draft()); + + Assert.Equal("/photo/value", (string?)before["results"]![0]!["findings"]![0]!["pointer"]); + Assert.Equal("pass", (string?)after["status"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Check_draft_refuses_a_change_set_it_cannot_read_or_that_has_ended(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await PageAsync(agent); + var opened = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "rejected", + ["rationale"] = "A set to reject.", + }); + var rejectedId = Guid.Parse((string)opened["changeSet"]!["id"]!); + await agent.AsPersonAsync(directory => directory.TransitionAsync(rejectedId, ChangeSetState.ChecksRunning)); + await agent.AsPersonAsync(directory => directory.TransitionAsync(rejectedId, ChangeSetState.AwaitingReview)); + await agent.AsPersonAsync(directory => directory.RejectAsync(rejectedId, "Not now.")); + Dictionary Draft(Guid changeSetId) => new() + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse("""{"headline": "The Widget"}"""), + ["change_set_id"] = changeSetId.ToString(), + }; + + var unknown = await agent.FailAsync("check_draft", Draft(Guid.CreateVersion7())); + var ended = await agent.FailAsync("check_draft", Draft(rejectedId)); + + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Code(unknown)); + Assert.Equal("argument_invalid", Code(ended)); + Assert.Contains("rejected", (string?)ended["errors"]![0]!["message"]); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] @@ -85,8 +237,7 @@ public async Task The_alt_text_check_passes_once_the_pages_set_adds_alt_text_wit }); var assetId = (string)uploaded["media"]!["id"]!; await PublishAsPersonAsync(agent, (string)uploaded["changeSet"]!["id"]!); - await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("alt-text", AltTextCheck))); - await agent.AsPersonAsync(directory => directory.ActivateAsync("alt-text", 1)); + await ActiveAltTextCheckAsync(agent); var attached = await agent.CallAsync("attach_media", new() { @@ -270,6 +421,12 @@ await agent.QueryAsync(context => context.Entries ], scope: new CheckScope(["product-page"])); + private static async Task ActiveAltTextCheckAsync(ConnectedAgent agent) + { + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("alt-text", AltTextCheck))); + await agent.AsPersonAsync(directory => directory.ActivateAsync("alt-text", 1)); + } + private static async Task PublishAsPersonAsync(ConnectedAgent agent, string setId) { var id = Guid.Parse(setId); diff --git a/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs index b9c22af8b..d3212be0a 100644 --- a/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs @@ -27,8 +27,8 @@ public sealed class AltTextCheckTests public async Task A_media_field_naming_an_asset_without_alt_text_fails_and_names_the_field(string provider) { await using var scenario = await EntryScenario.CreateAsync(provider); - var bare = await UploadAsync(scenario, "drill.jpg", altText: null); - var described = await UploadAsync(scenario, "saw.jpg", altText: "A circular saw on a bench."); + var bare = await PublishedAsync(scenario, await UploadAsync(scenario, "drill.jpg", altText: null)); + var described = await PublishedAsync(scenario, await UploadAsync(scenario, "saw.jpg", altText: "A circular saw on a bench.")); var outcome = await EvaluateAsync(scenario, ProductPage, $$"""{"headline": "Drills", "hero": "{{bare.Id}}", "gallery": ["{{described.Id}}", "{{bare.Id}}"]}"""); @@ -43,14 +43,15 @@ public async Task A_media_field_naming_an_asset_without_alt_text_fails_and_names [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] - public async Task Alt_text_on_an_unpublished_assets_latest_version_passes_on_pages_and_entities(string provider) + public async Task Alt_text_of_the_published_version_passes_on_pages_and_entities(string provider) { await using var scenario = await EntryScenario.CreateAsync(provider); - var asset = await UploadAsync(scenario, "drill.jpg", altText: null); + var asset = await PublishedAsync(scenario, await UploadAsync(scenario, "drill.jpg", altText: null)); var entity = $$"""{"photo": {"value": "{{asset.Id}}", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} }"""; var before = await EvaluateAsync(scenario, Product, entity); await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + await PublishedAsync(scenario, asset, 2); var page = await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{asset.Id}}"}"""); var entityAfter = await EvaluateAsync(scenario, Product, entity); var unknown = await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{Guid.NewGuid()}}"}"""); @@ -61,6 +62,36 @@ public async Task Alt_text_on_an_unpublished_assets_latest_version_passes_on_pag new[] { page, entityAfter, unknown }.Select(outcome => outcome.Verdict)); } + // An image no set publishes with this content can go live later in any version, even one a rejected set described, so the + // content fails until the image is published or goes live with it. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_image_neither_published_nor_in_the_content_s_set_fails_whatever_its_latest_version_says(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await UploadAsync(scenario, "drill.jpg", altText: null); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + var page = $$"""{"hero": "{{asset.Id}}"}"""; + + await using var scope = scenario.Services.CreateAsyncScope(); + var sets = scope.ServiceProvider.GetRequiredService(); + var other = (await sets.OpenAsync(new NewChangeSet("Something else.", []))).Value; + var holding = (await sets.OpenAsync(new NewChangeSet("Launch the drill.", []))).Value; + Assert.True((await sets.AddOperationAsync(holding.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Media, asset.Id, null, 2).Value)).IsSuccess); + + var alone = await EvaluateAsync(scenario, ProductPage, page); + var withOther = await EvaluateAsync(scenario, ProductPage, page, other.Id); + var withHolding = await EvaluateAsync(scenario, ProductPage, page, holding.Id); + + var finding = Assert.Single(alone.Findings); + Assert.Equal( + "The image 'drill.jpg' in hero is not published; give it alt text with update_media in the change set this content goes into, so both go live together.", + finding.Message); + Assert.Equal(CheckVerdict.Fail, withOther.Verdict); + Assert.Equal(CheckVerdict.Pass, withHolding.Verdict); + } + // MED-02: what goes live counts. A published image without alt text fails while a newer version with alt text waits in a set, // and passes when the page goes live with that set. [Theory] @@ -99,6 +130,14 @@ public async Task Media_that_is_not_an_image_needs_no_alt_text(string provider) Assert.Equal(CheckVerdict.Pass, (await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{manual.Id}}"}""")).Verdict); } + private static async Task PublishedAsync(EntryScenario scenario, MediaAsset asset, int version = 1) + { + await scenario.Schema.QueryAsync(context => context.MediaAssets + .Where(row => row.Id == asset.Id) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, version))); + return asset; + } + private static async Task UploadAsync(EntryScenario scenario, string fileName, string? altText) { var metadata = AssetMetadata.Create(fileName, null, [], altText, null).Value; diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index d97336b99..c930c0ea9 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -97,15 +97,17 @@ public void An_item_appears_at_most_once_in_a_set() // A set reopened to fix an item takes the item's newer draft: the same change from the same published version, proposing the // later draft, so the item still has one diff and one version to restore (CHG-03, CHG-06). [Theory] - [InlineData(OperationKind.Create, null, 1)] - [InlineData(OperationKind.Update, 3, 4)] - public void An_update_from_the_version_a_set_proposes_moves_the_item_s_operation_on(OperationKind kind, int? baseVersion, int proposed) + [InlineData(ItemKind.Entry, OperationKind.Create, null, 1)] + [InlineData(ItemKind.Entry, OperationKind.Update, 3, 4)] + [InlineData(ItemKind.Media, OperationKind.Create, null, 1)] + [InlineData(ItemKind.Media, OperationKind.Update, 3, 4)] + public void An_update_from_the_version_a_set_proposes_moves_the_item_s_operation_on(ItemKind itemKind, OperationKind kind, int? baseVersion, int proposed) { var changeSet = TestChangeSets.Open(); var item = Guid.NewGuid(); - changeSet.AddOperation(ChangeOperation.Create(kind, ItemKind.Entry, item, baseVersion, proposed).Value); + changeSet.AddOperation(ChangeOperation.Create(kind, itemKind, item, baseVersion, proposed).Value); - var result = changeSet.AddOperation(ChangeOperation.Create(kind, ItemKind.Entry, item, baseVersion, proposed + 1).Value); + var result = changeSet.AddOperation(ChangeOperation.Create(kind, itemKind, item, baseVersion, proposed + 1).Value); Assert.True(result.IsSuccess); var operation = Assert.Single(changeSet.Operations); From 7855740f1083aa0a4db63d8244ffc912a5297447 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 25 Oct 2026 21:20:55 -0400 Subject: [PATCH 17/17] Test that a deleted asset has no live version --- tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs index 73f96ed35..631b85e08 100644 --- a/tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/MediaSoftDeleteTests.cs @@ -27,6 +27,7 @@ public async Task A_deleted_asset_is_gone_from_every_read_and_cannot_be_referenc Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.GetAsync(drill)))); Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.GetVersionAsync(drill, 1)))); + Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.LiveVersionAsync(drill)))); Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.OpenAsync(drill)))); Assert.Equal(MediaErrorCodes.AssetNotFound, Code(await LibraryAsync(scenario, library => library.UpdateAsync(drill, 1, Metadata("drill.jpg", "products"))))); Assert.Equal([kept], (await LibraryAsync(scenario, library => library.ListAsync(new MediaQuery()))).Select(asset => asset.Id));