From 769597d861eeccf84a3a7753dd60bfb252855213 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:48:20 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3782b3eb825b6f0730d1c98d85f5f85dddd5c2bd Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:37:32 -0400 Subject: [PATCH 3/4] 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 3ceaa125ebbfe1c31e21c53afd001d09e840da7a Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:56:59 -0400 Subject: [PATCH 4/4] 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() {