Skip to content
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The admin stylesheet is compiled by the Tailwind CSS standalone CLI (ADR-0011).

Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `FLUENTCMS_TEST_POSTGRES` to a connection string for an empty database, for example `Host=localhost;Port=5432;Database=fluentcms_test;Username=postgres;Password=postgres`. CI does this with a service container. Without the variable those tests are skipped.

To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Media__Storage` (the storage new uploads go to, `local` by default) and `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default), and from an optional `appsettings.json` next to the binary.
To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Media__Storage` (the storage new uploads go to, `local` by default), `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) and `Media__MaxBytes` (the largest upload, 50 MB by default), 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.

Expand Down
2 changes: 1 addition & 1 deletion src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public static Result<ChangeOperation> 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.",
Expand Down
2 changes: 1 addition & 1 deletion src/FluentCMS.Core/Content/IWhereUsed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public interface IWhereUsed
// References to one field of an entity, such as product.price.
Task<Result<IReadOnlyList<EntryUsage>>> 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<IReadOnlyList<EntryUsage>> ForMediaAsync(Guid assetId, CancellationToken cancellationToken = default);
}

Expand Down
73 changes: 73 additions & 0 deletions src/FluentCMS.Core/Media/AssetMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 record AssetMetadata(string FileName, string Folder, IReadOnlyList<string> 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<AssetMetadata> Create(string fileName, string? folder, IEnumerable<string>? tags, string? altText, string? caption)
{
var errors = new List<Error>();
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<AssetMetadata>.Success(new AssetMetadata(name, path, tagList, altText?.Trim(), caption?.Trim()))
: Result<AssetMetadata>.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
&& !text.Any(character => char.IsControl(character) && character is not ('\n' or '\r' or '\t')));
}
}
60 changes: 60 additions & 0 deletions src/FluentCMS.Core/Media/IMediaLibrary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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<Result<MediaAsset>> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default);

// The caller names the version it read; the new version keeps that version's bytes.
Task<Result<MediaAsset>> UpdateAsync(Guid id, int baseVersion, AssetMetadata metadata, CancellationToken cancellationToken = default);

Task<Result<MediaAsset>> GetAsync(Guid id, CancellationToken cancellationToken = default);

Task<Result<MediaAssetVersion>> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default);

// By the latest versions' metadata; oldest asset first.
Task<IReadOnlyList<MediaAssetSummary>> ListAsync(MediaQuery query, CancellationToken cancellationToken = default);

// The folders the latest versions sit in, each once, in order; the root is "".
Task<IReadOnlyList<string>> FoldersAsync(CancellationToken cancellationToken = default);

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

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<string> 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 ContentMismatch = "media_content_mismatch";
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";
}
34 changes: 34 additions & 0 deletions src/FluentCMS.Core/Media/MediaTypes.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<byte> head)
{
return type switch
{
"image/jpeg" => head.StartsWith((ReadOnlySpan<byte>)[0xFF, 0xD8, 0xFF]),
"image/png" => head.StartsWith((ReadOnlySpan<byte>)[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<byte>)[0x1A, 0x45, 0xDF, 0xA3]),
_ => false,
};
}
}
69 changes: 42 additions & 27 deletions src/FluentCMS.Infrastructure/Content/EntryDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Error>> ReferenceErrorsAsync(IReadOnlyList<ValueReference> references, CancellationToken cancellationToken)
{
var errors = new List<Error>();
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<Error>();
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ private static void AddMedia(IServiceCollection services, IConfiguration configu
services.AddFluentCmsExtension<IMediaStorage, LocalDiskMediaStorage>();
services.AddOptions<MediaOptions>().Bind(configuration.GetSection(MediaOptions.SectionName)).ValidateOnStart();
services.AddSingleton<IValidateOptions<MediaOptions>, MediaOptionsValidation>();
services.AddScoped<IMediaLibrary, MediaLibrary>();
}

private static void AddContent(IServiceCollection services, IConfiguration configuration)
Expand Down
Loading
Loading