diff --git a/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfiguration.cs b/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfiguration.cs index 11f6a80..93a96c7 100644 --- a/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfiguration.cs +++ b/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfiguration.cs @@ -10,6 +10,7 @@ public interface IJavascriptTranslationMiddlewareConfiguration string CompiledFolder { get; } Func> ShouldTranslateAsync { get; } Func> EnableCacheAsync { get; } + VersionedFileRedirectConfig? VersionedFileRedirectConfig { get; } } internal class JavascriptTranslationMiddlewareConfiguration : IJavascriptTranslationMiddlewareConfiguration @@ -18,6 +19,7 @@ internal class JavascriptTranslationMiddlewareConfiguration : IJavascriptTransla public string CompiledFolder { get; } public Func> ShouldTranslateAsync { get; set; } = (_, _) => Task.FromResult(true); public Func> EnableCacheAsync { get; set; } = _ => Task.FromResult(true); + public VersionedFileRedirectConfig? VersionedFileRedirectConfig { get; set; } public JavascriptTranslationMiddlewareConfiguration(PathString[] requestPathPrefixes, string compiledFolder) { diff --git a/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfigurator.cs b/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfigurator.cs index e42542a..9b23094 100644 --- a/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfigurator.cs +++ b/MN.L10n.JavascriptTranslationMiddleware/Configuration/JavascriptTranslationMiddlewareConfigurator.cs @@ -7,25 +7,14 @@ namespace MN.L10n.JavascriptTranslationMiddleware { public interface IJavascriptTranslationMiddlewareConfigurator { - /// - /// Add a path prefix for which the plugin should run. For example if you add /plugin the plugin will run for all requests starting with /plugin - /// - /// - /// IJavascriptTranslationMiddlewareConfigurator AddPathPrefix(PathString prefix); - /// - /// Use to set when the translations should be cached, by default it always is - /// - /// - /// IJavascriptTranslationMiddlewareConfigurator EnableCacheWhen(Func> predicate); - /// - /// Use to configure when the translationmiddleware should translate JS files. By default it always does. - /// - /// - /// - IJavascriptTranslationMiddlewareConfigurator TranslateWhen(Func>? predicate); + IJavascriptTranslationMiddlewareConfigurator TranslateWhen( + Func>? predicate); + + IJavascriptTranslationMiddlewareConfigurator EnableVersionedFileRedirect(Action? configure = null); + IJavascriptTranslationMiddlewareConfigurator DisableVersionedFileRedirect(); } internal class JavascriptTranslationMiddlewareConfigurator : IJavascriptTranslationMiddlewareConfigurator @@ -34,6 +23,7 @@ internal class JavascriptTranslationMiddlewareConfigurator : IJavascriptTranslat private readonly string _compiledFolder; private Func>? _shouldTranslateAsync; private Func>? _shouldEnableCacheAsync; + private VersionedFileRedirectConfig? _versionedFileRedirectConfig = new(); public JavascriptTranslationMiddlewareConfigurator(string compiledFolder) { @@ -57,6 +47,19 @@ public IJavascriptTranslationMiddlewareConfigurator TranslateWhen( _shouldTranslateAsync = predicate; return this; } + + public IJavascriptTranslationMiddlewareConfigurator EnableVersionedFileRedirect(Action? configure = null) + { + _versionedFileRedirectConfig = new VersionedFileRedirectConfig(); + configure?.Invoke(_versionedFileRedirectConfig); + return this; + } + + public IJavascriptTranslationMiddlewareConfigurator DisableVersionedFileRedirect() + { + _versionedFileRedirectConfig = null; + return this; + } public IJavascriptTranslationMiddlewareConfiguration Build() { @@ -67,7 +70,10 @@ public IJavascriptTranslationMiddlewareConfiguration Build() $"At least one pathPrefix must be provided, plase call {nameof(AddPathPrefix)} while configuring the {nameof(JavascriptTranslationMiddleware)}"); var config = - new JavascriptTranslationMiddlewareConfiguration(_pathPrefixes.ToArray(), _compiledFolder.Trim()); + new JavascriptTranslationMiddlewareConfiguration(_pathPrefixes.ToArray(), _compiledFolder.Trim()) + { + VersionedFileRedirectConfig = _versionedFileRedirectConfig + }; if (_shouldTranslateAsync is not null) config.ShouldTranslateAsync = _shouldTranslateAsync; if (_shouldEnableCacheAsync is not null) config.EnableCacheAsync = _shouldEnableCacheAsync; diff --git a/MN.L10n.JavascriptTranslationMiddleware/Configuration/VersionedFileRedirectConfig.cs b/MN.L10n.JavascriptTranslationMiddleware/Configuration/VersionedFileRedirectConfig.cs new file mode 100644 index 0000000..1e995b3 --- /dev/null +++ b/MN.L10n.JavascriptTranslationMiddleware/Configuration/VersionedFileRedirectConfig.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace MN.L10n.JavascriptTranslationMiddleware; + +public class VersionedFileRedirectConfig +{ + public int? MaxAge { get; set; } = 60 * 30; + public int? StaleWhileRevalidate { get; set; } = 60 * 60 * 24 * 7; + /// + /// Allows the user to override the Cache-Control max-age by using a query parameter on the referer level. + /// Intended for debugging. + /// + public string? RefererMaxAgeOverrideParameterName = "_l10ntsMaxAge"; + public Action? OnRedirect { get; set; } +} diff --git a/MN.L10n.JavascriptTranslationMiddleware/FileTranslator.cs b/MN.L10n.JavascriptTranslationMiddleware/FileTranslator.cs index cebb5ff..bd5d606 100644 --- a/MN.L10n.JavascriptTranslationMiddleware/FileTranslator.cs +++ b/MN.L10n.JavascriptTranslationMiddleware/FileTranslator.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -17,9 +18,12 @@ public class FileTranslator private readonly IFileHandle _fileHandle; private readonly Lazy _lazyFileInformation; private readonly SemaphoreSlim _translateSemaphor = new(1); + private readonly SemaphoreSlim _fileVersionSemaphore = new(1); private readonly ILogger _logger; + private readonly bool _enableFileVersionGeneration; + private string? _computedFileVersion; - public FileTranslator(IJavascriptTranslationL10nLanguageProvider languageProvider, IFileHandle fileHandle, string languageId, ILogger logger) + public FileTranslator(IJavascriptTranslationL10nLanguageProvider languageProvider, IFileHandle fileHandle, string languageId, ILogger logger, bool enableFileVersionGeneration) { _languageId = languageId; _logger = logger; @@ -28,6 +32,7 @@ public FileTranslator(IJavascriptTranslationL10nLanguageProvider languageProvide _lazyFileInformation = new Lazy(GetFileInformation); L10n.TranslationsReloaded += L10nOnTranslationsReloaded; _logger = logger; + _enableFileVersionGeneration = enableFileVersionGeneration; } private void L10nOnTranslationsReloaded(object? sender, EventArgs e) @@ -35,7 +40,7 @@ private void L10nOnTranslationsReloaded(object? sender, EventArgs e) HandleTranslationsChangedAsync().GetAwaiter(); } - public async Task TranslateFile(bool reuseExisting) + public async Task TranslateFile(bool reuseExisting) { var fileInformation = _lazyFileInformation.Value; await _translateSemaphor.WaitAsync(); @@ -45,11 +50,19 @@ public async Task TranslateFile(bool reuseExisting) if (File.Exists(fileInformation.FilePath) && reuseExisting) { _logger.LogTrace("File already exists, reusing it"); - return fileInformation; + return new TranslationResult + { + FileInformation = fileInformation, + FileVersion = await GetFileVersionAsync(fileInformation) + }; } await TranslateAndWriteTranslatedFileAsync(fileInformation); - return fileInformation; + return new TranslationResult + { + FileInformation = fileInformation, + FileVersion = await GetFileVersionAsync(fileInformation) + }; } finally { @@ -57,6 +70,43 @@ public async Task TranslateFile(bool reuseExisting) } } + private async Task GetFileVersionAsync(TranslatedFileInformation fileInformation) + { + if (!_enableFileVersionGeneration) + { + return null; + } + + if (_computedFileVersion != null) + { + return _computedFileVersion; + } + + try + { + await _fileVersionSemaphore.WaitAsync(); + if (_computedFileVersion != null) + { + return _computedFileVersion; + } + + await using var stream = File.OpenRead(fileInformation.FilePath); + return await ComputeFileVersionFromStreamAsync(stream); + } + finally + { + _fileVersionSemaphore.Release(); + } + } + + private async Task ComputeFileVersionFromStreamAsync(Stream stream) + { + using var md5 = MD5.Create(); + var hash = await md5.ComputeHashAsync(stream); + var fileVersion = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + return _computedFileVersion = fileVersion; + } + private static string? GetEscapedTranslation(string? translation, char stringContainer) { if (translation == null) @@ -123,17 +173,27 @@ private async Task HandleTranslationsChangedAsync() { _logger.LogTrace("Updating translated file because of translations change"); await _translateSemaphor.WaitAsync(); + await _fileVersionSemaphore.WaitAsync(); try { - await TranslateAndWriteTranslatedFileAsync(_lazyFileInformation.Value); + var translatedContents = await TranslateAndWriteTranslatedFileAsync(_lazyFileInformation.Value); + if (_enableFileVersionGeneration) + { + await ComputeFileVersionFromStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(translatedContents))); + } + else + { + _computedFileVersion = null; + } } finally { _translateSemaphor.Release(); + _fileVersionSemaphore.Release(); } } - private async Task TranslateAndWriteTranslatedFileAsync(TranslatedFileInformation fileInfo) + private async Task TranslateAndWriteTranslatedFileAsync(TranslatedFileInformation fileInfo) { var contents = await _fileHandle.GetFileContentsAsync(); _logger.LogTrace("Translating file contents"); @@ -141,6 +201,7 @@ private async Task TranslateAndWriteTranslatedFileAsync(TranslatedFileInformatio await using var fileWriter = File.CreateText(fileInfo.FilePath); _logger.LogTrace($"Writing translated contents to disk at {fileInfo.FilePath}"); await fileWriter.WriteAsync(translatedContents); + return translatedContents; } private TranslatedFileInformation GetFileInformation() @@ -165,6 +226,12 @@ private TranslatedFileInformation GetFileInformation() } } + public class TranslationResult + { + public string? FileVersion { get; set; } + public required TranslatedFileInformation FileInformation { get; set; } + } + public class TranslatedFileInformation { public string FilePath { get; } diff --git a/MN.L10n.JavascriptTranslationMiddleware/FileTranslatorProvider.cs b/MN.L10n.JavascriptTranslationMiddleware/FileTranslatorProvider.cs index 526b13b..9d8d040 100644 --- a/MN.L10n.JavascriptTranslationMiddleware/FileTranslatorProvider.cs +++ b/MN.L10n.JavascriptTranslationMiddleware/FileTranslatorProvider.cs @@ -7,24 +7,29 @@ public interface ITranslatorProvider { FileTranslator GetOrCreateTranslator(string languageId, IFileHandle fileHandle); } - + public class FileTranslatorProvider : ITranslatorProvider { private readonly ConcurrentDictionary _fileProviders = new(); private readonly IJavascriptTranslationL10nLanguageProvider _l10NLanguageProvider; private readonly ILogger _logger; + private readonly IJavascriptTranslationMiddlewareConfiguration _configuration; - public FileTranslatorProvider(IJavascriptTranslationL10nLanguageProvider l10NLanguageProvider, ILogger logger) + public FileTranslatorProvider(IJavascriptTranslationL10nLanguageProvider l10NLanguageProvider, + ILogger logger, IJavascriptTranslationMiddlewareConfiguration configuration) { _l10NLanguageProvider = l10NLanguageProvider; _logger = logger; + _configuration = configuration; } public FileTranslator GetOrCreateTranslator(string languageId, IFileHandle fileHandle) { var fileProviderId = $"{languageId}__{fileHandle.RelativeRequestPath}"; - - return _fileProviders.GetOrAdd(fileProviderId, _ => new FileTranslator(_l10NLanguageProvider, fileHandle, languageId, _logger)); + + return _fileProviders.GetOrAdd(fileProviderId, + _ => new FileTranslator(_l10NLanguageProvider, fileHandle, languageId, _logger, + enableFileVersionGeneration: _configuration.VersionedFileRedirectConfig != null)); } } } diff --git a/MN.L10n.JavascriptTranslationMiddleware/JavascriptTranslationMiddleware.cs b/MN.L10n.JavascriptTranslationMiddleware/JavascriptTranslationMiddleware.cs index 98a1fc0..c7859fe 100644 --- a/MN.L10n.JavascriptTranslationMiddleware/JavascriptTranslationMiddleware.cs +++ b/MN.L10n.JavascriptTranslationMiddleware/JavascriptTranslationMiddleware.cs @@ -27,22 +27,26 @@ public JavascriptTranslationMiddleware(IJavascriptTranslationMiddlewareConfigura public async Task InvokeAsync(HttpContext context, RequestDelegate next) { _logger.LogTrace($"Begin invoke at {context.Request.Path}"); - await ProcessRequest(context); - await next(context); + var requestCompleted = await ProcessRequest(context); + if (!requestCompleted) + { + await next(context); + } + _logger.LogTrace("End invoke"); } - private async Task ProcessRequest(HttpContext context) + private async Task ProcessRequest(HttpContext context) { if (!TryGetRewriteContext(context, out var rewriteContext)) { - return; + return false; } var remainingParts = rewriteContext.Remaining.Value!.Split('/', StringSplitOptions.RemoveEmptyEntries); if (remainingParts.Length == 0) { - return; + return false; } var languageId = remainingParts[0]; @@ -53,7 +57,7 @@ private async Task ProcessRequest(HttpContext context) if (!fileHandle.Exists) { _logger.LogDebug($"Unable to resolve file at {fileHandle.Path}"); - return; + return false; } var isSupportedFileType = fileHandle.FileName.EndsWith(".js", StringComparison.InvariantCultureIgnoreCase); @@ -76,19 +80,59 @@ private async Task ProcessRequest(HttpContext context) var path = string.Join('/', rewriteContext.MatchingSegment, diskPath); _logger.LogTrace($"Updated path to be {path}"); context.Request.Path = path; - return; + return false; } var translator = _translatorProvider.GetOrCreateTranslator(languageId, fileHandle); var enableCache = await _config.EnableCacheAsync(context); _logger.LogTrace($"Translating file at {fileHandle.Path}, {(enableCache ? "using cache" : "not using cache")}"); - var translatedFileInformation = await translator.TranslateFile(enableCache); + var result = await translator.TranslateFile(enableCache); + var translatedFileInformation = result.FileInformation; + var versionedRedirectConfig = _config.VersionedFileRedirectConfig; + if (versionedRedirectConfig != null && result.FileVersion != null) + { + var requestedVersion = context.Request.Query["v"]; + if (requestedVersion != result.FileVersion) + { + context.Response.StatusCode = 302; + var redirectTo = $"{context.Request.Path}?v={result.FileVersion}"; + context.Response.Headers["Location"] = redirectTo; + var maxAge = versionedRedirectConfig.MaxAge; + var referer = context.Request.Headers.Referer.ToString(); + if (!string.IsNullOrWhiteSpace(versionedRedirectConfig.RefererMaxAgeOverrideParameterName) && + !string.IsNullOrWhiteSpace(referer) && referer.Contains(versionedRedirectConfig.RefererMaxAgeOverrideParameterName)) + { + var refererMaxAge = referer.Split($"{versionedRedirectConfig.RefererMaxAgeOverrideParameterName}=")[1].Split("&")[0]; + if (int.TryParse(refererMaxAge, out var refererMaxAgeParsed)) + { + maxAge = refererMaxAgeParsed; + } + } + + if (maxAge >= 0) + { + var cacheControlHeader = $"public, max-age={maxAge}"; + if (versionedRedirectConfig.StaleWhileRevalidate >= 0) + { + cacheControlHeader += $", stale-while-revalidate={versionedRedirectConfig.StaleWhileRevalidate}"; + } + context.Response.Headers.CacheControl = cacheControlHeader; + } + + versionedRedirectConfig.OnRedirect?.Invoke(context); + + _logger.LogTrace("Redirecting to {location}", redirectTo); + await context.Response.CompleteAsync(); + return true; + } + } _logger.LogTrace($"Translated file saved at {translatedFileInformation.FilePath}"); var newPath = string.Join('/', rewriteContext.MatchingSegment, translatedFileInformation.RelativeRequestPath); context.Request.Path = newPath; _logger.LogTrace($"Updated path to be {newPath}"); + return false; } private bool TryGetRewriteContext(HttpContext context, [NotNullWhen(true)] out RewriteContext? rewriteContext) diff --git a/MN.L10n.JavascriptTranslationMiddleware/MN.L10n.JavascriptTranslationMiddleware.csproj b/MN.L10n.JavascriptTranslationMiddleware/MN.L10n.JavascriptTranslationMiddleware.csproj index 7622c5b..87ca53f 100644 --- a/MN.L10n.JavascriptTranslationMiddleware/MN.L10n.JavascriptTranslationMiddleware.csproj +++ b/MN.L10n.JavascriptTranslationMiddleware/MN.L10n.JavascriptTranslationMiddleware.csproj @@ -1,15 +1,16 @@ - net6.0;net7.0 + net7.0 Enable True - 1.0.4 + 2.0.0 lice, chbe, chga https://github.com/MultinetInteractive/MN.L10n © 20XX MultiNet Interactive AB https://github.com/MultinetInteractive/MN.L10n git + latest diff --git a/MN.L10n.Tests/JavascriptTranslationMiddleware/TranslatedFileProviderTests.cs b/MN.L10n.Tests/JavascriptTranslationMiddleware/TranslatedFileProviderTests.cs index 79903e8..3769f2e 100644 --- a/MN.L10n.Tests/JavascriptTranslationMiddleware/TranslatedFileProviderTests.cs +++ b/MN.L10n.Tests/JavascriptTranslationMiddleware/TranslatedFileProviderTests.cs @@ -101,7 +101,7 @@ public void HandlesTranslationWithQuotes3() }; var fakes = new Fakes(language); - var translator = new FileTranslator(fakes.LanguageProvider, fakes.FileHandle, languageId, fakes.Logger); + var translator = new FileTranslator(fakes.LanguageProvider, fakes.FileHandle, languageId, fakes.Logger, true); L10nLanguage tmp; A.CallTo(() => fakes.LanguageProvider.TryGetLanguage(languageId, out tmp)) diff --git a/MN.L10n.Tests/MN.L10n.Tests.csproj b/MN.L10n.Tests/MN.L10n.Tests.csproj index 53ebefb..bd5894b 100644 --- a/MN.L10n.Tests/MN.L10n.Tests.csproj +++ b/MN.L10n.Tests/MN.L10n.Tests.csproj @@ -6,6 +6,7 @@ + all