Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions src/FluentCMS.Admin/Components/Pages/MediaAssetView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@using FluentCMS.Core.Media
@inject IMediaLibrary Library
@inject IWhereUsed WhereUsed
@inject FluentCMS.Core.Schema.IContentTypeDirectory ContentTypes
@inject NavigationManager Navigation
@inject IStringLocalizer<MediaAssetView> L

Expand Down Expand Up @@ -66,7 +67,8 @@
<CardContent>
<form method="post" @onsubmit="SaveAsync" @formname="metadata" class="grid gap-4">
<AntiforgeryToken />
<input type="hidden" name="Form.BaseVersion" value="@asset.LatestVersion" />
@* The version the shown details were made from: a refused save keeps it, so a change saved meanwhile is a conflict. *@
<input type="hidden" name="Form.BaseVersion" value="@Shown.BaseVersion" />
@if (_message is not null)
{
<p role="status" class="rounded-md border bg-muted/50 px-3 py-2 text-sm" data-testid="media-saved">@_message</p>
Expand Down Expand Up @@ -120,7 +122,9 @@
{
<TableRow data-entry="@usage.EntryId.ToString("N")">
<TableCell>
<bdi>@usage.ContentType</bdi> <bdi class="font-mono text-xs text-muted-foreground" dir="ltr">@usage.EntryId.ToString("N")[..8]</bdi>
<a href="@($"audit?type={_subjects.GetValueOrDefault(usage.ContentType, "entry")}&id={usage.EntryId}")" class="underline-offset-4 hover:underline" data-testid="usage-history">
<bdi>@usage.ContentType</bdi> <bdi class="font-mono text-xs text-muted-foreground" dir="ltr">@usage.EntryId.ToString("N")[..8]</bdi>
</a>
</TableCell>
<TableCell>
<span class="flex flex-wrap items-center gap-1">
Expand Down Expand Up @@ -148,14 +152,20 @@
@code {
private MediaAsset? _asset;
private IReadOnlyList<EntryUsage> _usage = [];

// The audit subject of each using type's items: an entity type's items are entities in the log, every other type's are entries.
private Dictionary<string, string> _subjects = new(StringComparer.Ordinal);
private IReadOnlyList<string> _errors = [];
private string? _formError;
private string? _message;

[Parameter] public Guid Id { get; set; }

// Read as text, as the library's sub flag is, so a value that is not true or false shows the page rather than failing it.
[SupplyParameterFromQuery(Name = "uploaded")]
private bool Uploaded { get; set; }
private string? UploadedText { get; set; }

private bool Uploaded => bool.TryParse(UploadedText, out var uploaded) && uploaded;

[SupplyParameterFromForm(FormName = "metadata")]
private MediaMetadataForm? Form { get; set; }
Expand Down Expand Up @@ -192,6 +202,12 @@
BaseVersion = _asset.LatestVersion,
};
_usage = await WhereUsed.ForMediaAsync(Id);
foreach (var slug in _usage.Select(usage => usage.ContentType).Distinct())
{
var type = await ContentTypes.GetAsync(slug);
_subjects[slug] = type.IsSuccess && type.Value.Definition.IsEntity ? "entity" : "entry";
}

return true;
}

Expand Down
23 changes: 17 additions & 6 deletions src/FluentCMS.Admin/Components/Pages/MediaLibraryView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,17 @@
<Input Type="file" name="file" accept="@string.Join(",", MediaTypes.Allowed)" required />
</FormField>
<div class="grid gap-4 sm:grid-cols-2">
<FormField Label="@L["Folder"]" Description="@L["Optional, such as products/drills."]" Error="@UploadError(MediaErrorCodes.FolderInvalid)">
<Input name="Folder" value="@(UploadFolder ?? Folder)" dir="ltr" />
<FormField Label="@L["Folder"]" Description="@L["Optional, such as products/drills."]" Error="@(UploadError(MediaErrorCodes.FolderInvalid) ?? Dropped(MediaUploadForm.FolderName))">
<Input name="Folder" value="@(UploadErrors is null ? Folder : UploadFolder)" dir="ltr" />
</FormField>
<FormField Label="@L["Tags"]" Description="@L["Optional, separated by commas, such as product-shot, hero."]" Error="@UploadError(MediaErrorCodes.TagInvalid)">
<FormField Label="@L["Tags"]" Description="@L["Optional, separated by commas, such as product-shot, hero."]" Error="@(UploadError(MediaErrorCodes.TagInvalid) ?? Dropped(MediaUploadForm.TagsName))">
<Input name="Tags" value="@UploadTags" dir="ltr" />
</FormField>
</div>
<FormField Label="@L["Alt text"]" Error="@UploadError(MediaErrorCodes.AltTextInvalid)">
<FormField Label="@L["Alt text"]" Error="@(UploadError(MediaErrorCodes.AltTextInvalid) ?? Dropped(MediaUploadForm.AltTextName))">
<Textarea name="AltText" rows="2" value="@UploadAltText" />
</FormField>
<FormField Label="@L["Caption"]" Error="@UploadError(MediaErrorCodes.CaptionInvalid)">
<FormField Label="@L["Caption"]" Error="@(UploadError(MediaErrorCodes.CaptionInvalid) ?? Dropped(MediaUploadForm.CaptionName))">
<Textarea name="Caption" rows="2" value="@UploadCaption" />
</FormField>
@if (UploadFormError is { } formError)
Expand Down Expand Up @@ -162,8 +162,11 @@
[SupplyParameterFromQuery(Name = "folder")]
private string? Folder { get; set; }

// Read as text: a link may say 1 or 0 as well as true or false, and an unknown value means no subfolders rather than an error.
[SupplyParameterFromQuery(Name = "sub")]
private bool Subfolders { get; set; }
private string? Sub { get; set; }

private bool Subfolders => Sub == "1" || (bool.TryParse(Sub, out var subfolders) && subfolders);

[SupplyParameterFromQuery(Name = "tag")]
private string? Tag { get; set; }
Expand All @@ -184,6 +187,9 @@
[SupplyParameterFromQuery(Name = MediaUploadForm.CaptionName)]
private string? UploadCaption { get; set; }

[SupplyParameterFromQuery(Name = MediaUploadForm.DroppedName)]
private string? UploadDropped { get; set; }

private static readonly HashSet<string> FieldCodes =
[
MediaUploadForm.FileMissing,
Expand Down Expand Up @@ -232,6 +238,11 @@
};
}

private string? Dropped(string name)
{
return (UploadDropped ?? string.Empty).Split(',').Contains(name) ? L["This was too long to bring back here. Type it again."].Value : null;
}

private static string Filter(bool current) => ClassNames.Cn(
"block rounded-md px-2 py-1 hover:bg-accent hover:text-accent-foreground",
current ? "bg-secondary font-medium text-secondary-foreground" : "text-muted-foreground");
Expand Down
47 changes: 34 additions & 13 deletions src/FluentCMS.Admin/Media/MediaForms.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,27 +31,48 @@ public static IReadOnlyList<string> SplitTags(string? tags)
}

// The library's upload form as it posts to the host's upload endpoint; the file arrives beside it. A refused upload comes back to the
// library with ReturnQuery, so the page can show each problem next to its field and keep what was typed.
// library with ReturnQuery, so the page can show each problem next to its field and keep what was typed. The names are the upload's
// own, so they never filter the list the library shows.
public sealed class MediaUploadForm : MediaMetadataFields
{
public const string FileMissing = "media_file_missing";

public const string ErrorName = "error";
public const string FolderName = "folder";
public const string TagsName = "tags";
public const string AltTextName = "alt";
public const string CaptionName = "caption";
public const string ErrorName = "upload-error";
public const string FolderName = "upload-folder";
public const string TagsName = "upload-tags";
public const string AltTextName = "upload-alt";
public const string CaptionName = "upload-caption";
public const string DroppedName = "upload-dropped";

// Past this, a field is not carried back, so the redirect stays well inside the server's request line limit.
public const int MaxReturnedLength = 1500;

public Dictionary<string, string?> ReturnQuery(IEnumerable<Error> errors)
{
return new Dictionary<string, string?>
var query = new Dictionary<string, string?> { [ErrorName] = string.Join(",", errors.Select(error => error.Code).Distinct()) };
var dropped = new List<string>();
foreach (var (name, text) in new[] { (FolderName, Folder), (TagsName, Tags), (AltTextName, AltText), (CaptionName, Caption) })
{
if (text is null)
{
continue;
}

if (Uri.EscapeDataString(text).Length > MaxReturnedLength)
{
dropped.Add(name);
continue;
}

query[name] = text;
}

if (dropped.Count > 0)
{
[ErrorName] = string.Join(",", errors.Select(error => error.Code).Distinct()),
[FolderName] = Folder,
[TagsName] = Tags,
[AltTextName] = AltText,
[CaptionName] = Caption,
};
query[DroppedName] = string.Join(",", dropped);
}

return query;
}
}

Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Host/FluentCmsHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public static WebApplication BuildWebApplication(string[] args, Action<WebApplic
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseMediaUploadLimit();
app.UseAntiforgery();
MapStaticAssets(app);
app.MapHealthChecks("/health");
Expand Down
40 changes: 39 additions & 1 deletion src/FluentCMS.Host/Media/MediaEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using FluentCMS.Core.Media;
using FluentCMS.Core.Results;
using FluentCMS.Infrastructure.Media;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
Expand All @@ -16,9 +17,41 @@ internal static class MediaEndpoints
// Room for the form's other fields and the multipart framing around a file of the largest size the library accepts.
private const long FormOverhead = 1024 * 1024;

// The largest refused body read through to answer it, so a mistaken upload of a large file gets the message.
private const long DrainCap = 1024L * 1024 * 1024;

// A signed-in person's upload that declares a body over the limit is sent back to the library without being parsed, the
// anti-forgery check included, so they get the library's own message rather than a reset connection. The body is read and
// discarded first, since a browser still sending it treats an early answer as a failure. A body declared over DrainCap, or a
// chunked one that declares no length, still meets the server's limit.
public static IApplicationBuilder UseMediaUploadLimit(this IApplicationBuilder app)
{
var limit = UploadLimit(app.ApplicationServices);
return app.Use(async (context, next) =>
{
if (HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals(AdminRoutes.MediaUpload, StringComparison.OrdinalIgnoreCase)
&& context.User.Identity?.IsAuthenticated == true
&& context.Request.ContentLength is { } length && length > limit && length <= DrainCap)
{
if (context.Features.Get<IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size)
{
size.MaxRequestBodySize = null;
}

await context.Request.Body.CopyToAsync(Stream.Null, context.RequestAborted);
context.Response.Redirect(AdminRoutes.Media + QueryString.Create(new MediaUploadForm().ReturnQuery(
[new Error(MediaErrorCodes.MediaTooLarge, "The file is larger than the library accepts.")])));
return;
}

await next(context);
});
}

public static void MapMediaLibrary(this IEndpointRouteBuilder endpoints)
{
var limit = endpoints.ServiceProvider.GetRequiredService<IOptions<MediaOptions>>().Value.MaxBytes + FormOverhead;
var limit = UploadLimit(endpoints.ServiceProvider);

endpoints.MapPost(AdminRoutes.MediaUpload, UploadAsync)
.RequireAuthorization()
Expand Down Expand Up @@ -76,6 +109,11 @@ private static async Task<IResult> FileAsync(Guid id, int? version, IMediaLibrar
return Results.Stream(content.Content, content.ContentType);
}

private static long UploadLimit(IServiceProvider services)
{
return services.GetRequiredService<IOptions<MediaOptions>>().Value.MaxBytes + FormOverhead;
}

private static IResult BackToLibrary(MediaUploadForm form, IEnumerable<Error> errors)
{
return Results.LocalRedirect(AdminRoutes.Media + QueryString.Create(form.ReturnQuery(errors)));
Expand Down
15 changes: 12 additions & 3 deletions tests/FluentCMS.IntegrationTests/Desk/SignedInPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,19 @@ public async Task Every_signed_in_page_loads_every_time(string provider)
{
foreach (var path in paths)
{
var response = await host.Client.GetAsync(path);
if (response.StatusCode != HttpStatusCode.OK)
// A query that collides can hang the request rather than fail it, so each has a deadline that names the page.
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
failures.Add($"{path}: {(int)response.StatusCode}");
var response = await host.Client.GetAsync(path, deadline.Token);
if (response.StatusCode != HttpStatusCode.OK)
{
failures.Add($"{path}: {(int)response.StatusCode}");
}
}
catch (OperationCanceledException) when (deadline.IsCancellationRequested)
{
failures.Add($"{path}: no answer in 30 seconds, round {round + 1}");
}
}
}
Expand Down
Loading
Loading