diff --git a/backend/src/Squidex.Web/CommandMiddlewares/ETagCommandMiddleware.cs b/backend/src/Squidex.Web/CommandMiddlewares/ETagCommandMiddleware.cs index a5eec9c11..aa353eba1 100644 --- a/backend/src/Squidex.Web/CommandMiddlewares/ETagCommandMiddleware.cs +++ b/backend/src/Squidex.Web/CommandMiddlewares/ETagCommandMiddleware.cs @@ -38,7 +38,7 @@ namespace Squidex.Web.CommandMiddlewares if (command.ExpectedVersion == EtagVersion.Auto) { - if (TryParseEtag(httpContext, out var expectedVersion)) + if (httpContext.TryParseEtagVersion(HeaderNames.IfMatch, out var expectedVersion)) { command.ExpectedVersion = expectedVersion; } @@ -64,25 +64,5 @@ namespace Squidex.Web.CommandMiddlewares { httpContext.Response.Headers[HeaderNames.ETag] = version.ToString(CultureInfo.InvariantCulture); } - - private static bool TryParseEtag(HttpContext httpContext, out long version) - { - version = default; - - if (httpContext.Request.Headers.TryGetString(HeaderNames.IfMatch, out var etag)) - { - if (etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) - { - etag = etag[2..]; - } - - if (long.TryParse(etag, NumberStyles.Any, CultureInfo.InvariantCulture, out version)) - { - return true; - } - } - - return false; - } } } diff --git a/backend/src/Squidex.Web/ETagExtensions.cs b/backend/src/Squidex.Web/ETagExtensions.cs index d85c36282..8c6811e5b 100644 --- a/backend/src/Squidex.Web/ETagExtensions.cs +++ b/backend/src/Squidex.Web/ETagExtensions.cs @@ -8,6 +8,8 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; using Squidex.Domain.Apps.Entities; using Squidex.Infrastructure; @@ -58,5 +60,27 @@ namespace Squidex.Web { return entity.Version.ToString(CultureInfo.InvariantCulture); } + + public static bool TryParseEtagVersion(this HttpContext httpContext, string header, out long version) + { + version = default; + + if (httpContext.Request.Headers.TryGetString(header, out var etag)) + { + var span = etag.AsSpan(); + + if (span.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) + { + span = span[2..]; + } + + if (long.TryParse(span, NumberStyles.Any, CultureInfo.InvariantCulture, out version)) + { + return true; + } + } + + return false; + } } } diff --git a/backend/src/Squidex.Web/ETagUtils.cs b/backend/src/Squidex.Web/ETagUtils.cs new file mode 100644 index 000000000..3c3bf36ad --- /dev/null +++ b/backend/src/Squidex.Web/ETagUtils.cs @@ -0,0 +1,54 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Web +{ + public static class ETagUtils + { + public static string ToWeakEtag(string? etag) + { + return $"W/{etag}"; + } + + public static bool IsWeakEtag(string etag) + { + return IsWeakEtag(etag.AsSpan()); + } + + public static bool IsWeakEtag(ReadOnlySpan etag) + { + return etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsSameEtag(string lhs, string rhs) + { + return IsSameEtag(lhs.AsSpan(), rhs.AsSpan()); + } + + public static bool IsSameEtag(ReadOnlySpan lhs, ReadOnlySpan rhs) + { + var isMatch = lhs.Equals(rhs, StringComparison.Ordinal); + + if (isMatch) + { + return true; + } + + if (lhs.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) + { + lhs = lhs[2..]; + } + + if (rhs.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) + { + rhs = rhs[2..]; + } + + return lhs.Equals(rhs, StringComparison.Ordinal); + } + } +} diff --git a/backend/src/Squidex.Web/Pipeline/CachingFilter.cs b/backend/src/Squidex.Web/Pipeline/CachingFilter.cs index df0fc6df6..51c251598 100644 --- a/backend/src/Squidex.Web/Pipeline/CachingFilter.cs +++ b/backend/src/Squidex.Web/Pipeline/CachingFilter.cs @@ -41,10 +41,17 @@ namespace Squidex.Web.Pipeline private static bool IsCacheable(HttpContext httpContext, string etag) { - return HttpMethods.IsGet(httpContext.Request.Method) && - httpContext.Response.StatusCode == 200 && - httpContext.Request.Headers.TryGetString(HeaderNames.IfNoneMatch, out var noneMatch) && - string.Equals(etag, noneMatch, StringComparison.Ordinal); + if (!HttpMethods.IsGet(httpContext.Request.Method) || httpContext.Response.StatusCode != 200) + { + return false; + } + + if (!httpContext.Request.Headers.TryGetString(HeaderNames.IfNoneMatch, out var noneMatchValue)) + { + return false; + } + + return ETagUtils.IsSameEtag(noneMatchValue, etag); } } } diff --git a/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs b/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs index 410219eb0..c54446ace 100644 --- a/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs @@ -32,15 +32,15 @@ namespace Squidex.Web.Pipeline AppendAuthHeaders(context); - context.Response.OnStarting(x => + context.Response.OnStarting(_ => { - var httpContext = (HttpContext)x; + var httpContext = (HttpContext)_; if (httpContext.Response.Headers.TryGetString(HeaderNames.ETag, out var etag)) { - if (!cachingOptions.StrongETag && IsWeakEtag(etag)) + if (!cachingOptions.StrongETag && !ETagUtils.IsWeakEtag(etag)) { - httpContext.Response.Headers[HeaderNames.ETag] = ToWeakEtag(etag); + httpContext.Response.Headers[HeaderNames.ETag] = ETagUtils.ToWeakEtag(etag); } } @@ -65,15 +65,5 @@ namespace Squidex.Web.Pipeline cachingManager.AddHeader("Auth-ClientId"); } } - - private static string ToWeakEtag(string? etag) - { - return $"W/{etag}"; - } - - private static bool IsWeakEtag(string etag) - { - return !etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase); - } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index ad677a608..22baaff9b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -6,6 +6,7 @@ // ========================================================================== using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities; @@ -206,7 +207,12 @@ namespace Squidex.Areas.Api.Controllers.Contents return NotFound(); } - var response = ContentDto.FromContent(content, Resources); + var response = Deferred.Response(() => + { + return ContentDto.FromContent(content, Resources); + }); + + Response.Headers[HeaderNames.ETag] = content.ToEtag(); return Ok(response); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs index 81d003885..5d1c93231 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs @@ -46,13 +46,17 @@ namespace Squidex.Web.Pipeline sut = new CachingFilter(cachingManager); } - [Fact] - public async Task Should_return_304_for_same_etags() + [Theory] + [InlineData("13", "13")] + [InlineData("13", "W/13")] + [InlineData("W/13", "13")] + [InlineData("W/13", "W/13")] + public async Task Should_return_304_for_same_etags(string ifNoneMatch, string etag) { httpContext.Request.Method = HttpMethods.Get; - httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/13"; + httpContext.Request.Headers[HeaderNames.IfNoneMatch] = ifNoneMatch; - httpContext.Response.Headers[HeaderNames.ETag] = "W/13"; + httpContext.Response.Headers[HeaderNames.ETag] = etag; await sut.OnActionExecutionAsync(executingContext, Next());