diff --git a/src/OrchardCore.Modules/OrchardCore.Localization/Services/LocalizationService.cs b/src/OrchardCore.Modules/OrchardCore.Localization/Services/LocalizationService.cs index c1c0fc19e8e..7775da8b8ff 100644 --- a/src/OrchardCore.Modules/OrchardCore.Localization/Services/LocalizationService.cs +++ b/src/OrchardCore.Modules/OrchardCore.Localization/Services/LocalizationService.cs @@ -1,58 +1,57 @@ using System.Globalization; using System.Threading.Tasks; -using OrchardCore.Entities; using OrchardCore.Localization.Models; using OrchardCore.Settings; -namespace OrchardCore.Localization.Services +namespace OrchardCore.Localization.Services; + +/// +/// Represents a localization service. +/// +public class LocalizationService : ILocalizationService { + private static readonly string _defaultCulture = CultureInfo.InstalledUICulture.Name; + private static readonly string[] _supportedCultures = [CultureInfo.InstalledUICulture.Name]; + + private readonly ISiteService _siteService; + + private LocalizationSettings _localizationSettings; + /// - /// Represents a localization service. + /// Creates a new instance of . /// - public class LocalizationService : ILocalizationService + /// The . + public LocalizationService(ISiteService siteService) { - private static readonly string _defaultCulture = CultureInfo.InstalledUICulture.Name; - private static readonly string[] _supportedCultures = new[] { CultureInfo.InstalledUICulture.Name }; - - private readonly ISiteService _siteService; + _siteService = siteService; + } - private LocalizationSettings _localizationSettings; + /// + public async Task GetDefaultCultureAsync() + { + var settings = await GetLocalizationSettingsAsync(); - /// - /// Creates a new instance of . - /// - /// The . - public LocalizationService(ISiteService siteService) - { - _siteService = siteService; - } + return settings.DefaultCulture ?? _defaultCulture; + } - /// - public async Task GetDefaultCultureAsync() - { - await InitializeLocalizationSettingsAsync(); + /// + public async Task GetSupportedCulturesAsync() + { + var settings = await GetLocalizationSettingsAsync(); - return _localizationSettings.DefaultCulture ?? _defaultCulture; - } + return settings.SupportedCultures?.Length == 0 + ? _supportedCultures + : settings.SupportedCultures; + } - /// - public async Task GetSupportedCulturesAsync() + private async Task GetLocalizationSettingsAsync() + { + if (_localizationSettings == null) { - await InitializeLocalizationSettingsAsync(); - - return _localizationSettings.SupportedCultures == null || _localizationSettings.SupportedCultures.Length == 0 - ? _supportedCultures - : _localizationSettings.SupportedCultures - ; + var siteSettings = await _siteService.GetSiteSettingsAsync(); + _localizationSettings = siteSettings.As(); } - private async Task InitializeLocalizationSettingsAsync() - { - if (_localizationSettings == null) - { - var siteSettings = await _siteService.GetSiteSettingsAsync(); - _localizationSettings = siteSettings.As(); - } - } + return _localizationSettings; } } diff --git a/src/OrchardCore.Modules/OrchardCore.Localization/Services/RequestLocalizationMiddleware.cs b/src/OrchardCore.Modules/OrchardCore.Localization/Services/RequestLocalizationMiddleware.cs new file mode 100644 index 00000000000..1eb52110183 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Localization/Services/RequestLocalizationMiddleware.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace OrchardCore.Localization.Services; +/// +/// Enables automatic setting of the culture for s based on information +/// sent by the client in headers and logic provided by the application. +/// The middleware is inspired by +/// and should have the same logic. +/// +public class RequestLocalizationMiddleware +{ + private const int MaxCultureFallbackDepth = 5; + + private readonly RequestDelegate _next; + private readonly CultureOptions _cultureOptions; + private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; + + /// + /// Creates a new . + /// + /// The representing the next middleware in the pipeline. + /// The used for logging. + /// The used for logging. + /// The used for logging. + /// + public RequestLocalizationMiddleware( + RequestDelegate next, + ILoggerFactory loggerFactory, + IOptions cultureOptions, + ILocalizationService localizationService) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _cultureOptions = cultureOptions.Value; + _localizationService = localizationService; + _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); + } + + /// + /// Invokes the logic of the middleware. + /// + /// The . + /// A that completes when the middleware has completed processing. + public async Task Invoke(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var localizationOptions = new RequestLocalizationOptions(); + var supportedCultures = await _localizationService.GetSupportedCulturesAsync(); + new LocalizationOptionsUpdater(localizationOptions, _cultureOptions.IgnoreSystemSettings) + .SetDefaultCulture(await _localizationService.GetDefaultCultureAsync()) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures); + + var requestCulture = localizationOptions.DefaultRequestCulture; + + IRequestCultureProvider winningProvider = null; + + if (localizationOptions.RequestCultureProviders != null) + { + foreach (var provider in localizationOptions.RequestCultureProviders) + { + var providerResultCulture = await provider.DetermineProviderCultureResult(context); + if (providerResultCulture == null) + { + continue; + } + + var cultures = providerResultCulture.Cultures; + var uiCultures = providerResultCulture.UICultures; + + CultureInfo cultureInfo = null; + CultureInfo uiCultureInfo = null; + if (localizationOptions.SupportedCultures != null) + { + cultureInfo = GetCultureInfo( + cultures, + localizationOptions.SupportedCultures, + localizationOptions.FallBackToParentCultures); + + if (cultureInfo == null) + { + _logger.UnsupportedCultures(provider.GetType().Name, cultures); + } + } + + if (localizationOptions.SupportedUICultures != null) + { + uiCultureInfo = GetCultureInfo( + uiCultures, + localizationOptions.SupportedUICultures, + localizationOptions.FallBackToParentUICultures); + + if (uiCultureInfo == null) + { + _logger.UnsupportedUICultures(provider.GetType().Name, uiCultures); + } + } + + if (cultureInfo == null && uiCultureInfo == null) + { + continue; + } + + cultureInfo ??= localizationOptions.DefaultRequestCulture.Culture; + uiCultureInfo ??= localizationOptions.DefaultRequestCulture.UICulture; + + var result = new RequestCulture(cultureInfo, uiCultureInfo); + requestCulture = result; + winningProvider = provider; + + break; + } + } + + context.Features.Set(new RequestCultureFeature(requestCulture, winningProvider)); + + SetCurrentThreadCulture(requestCulture); + + if (localizationOptions.ApplyCurrentCultureToResponseHeaders) + { + var headers = context.Response.Headers; + headers.ContentLanguage = requestCulture.UICulture.Name; + } + + await _next(context); + } + + private static void SetCurrentThreadCulture(RequestCulture requestCulture) + { + CultureInfo.CurrentCulture = requestCulture.Culture; + CultureInfo.CurrentUICulture = requestCulture.UICulture; + } + + private static CultureInfo GetCultureInfo( + IList cultureNames, + IList supportedCultures, + bool fallbackToParentCultures) + { + foreach (var cultureName in cultureNames) + { + // Allow empty string values as they map to InvariantCulture, whereas null culture values will throw in + // the CultureInfo ctor + if (cultureName != null) + { + var cultureInfo = GetCultureInfo(cultureName, supportedCultures, fallbackToParentCultures, currentDepth: 0); + if (cultureInfo != null) + { + return cultureInfo; + } + } + } + + return null; + } + + private static CultureInfo GetCultureInfo( + StringSegment cultureName, + IList supportedCultures, + bool fallbackToParentCultures, + int currentDepth) + { + // If the cultureName is an empty string there + // is no chance we can resolve the culture info. + if (cultureName.Equals(string.Empty)) + { + return null; + } + + var culture = GetCultureInfo(cultureName, supportedCultures); + + if (culture == null && fallbackToParentCultures && currentDepth < MaxCultureFallbackDepth) + { + try + { + culture = CultureInfo.GetCultureInfo(cultureName.ToString()); + + culture = GetCultureInfo(culture.Parent.Name, supportedCultures, fallbackToParentCultures, currentDepth + 1); + } + catch (CultureNotFoundException) + { + } + } + + return culture; + } + + private static CultureInfo GetCultureInfo(StringSegment name, IList supportedCultures) + { + // Allow only known culture names as this API is called with input from users (HTTP requests) and + // creating CultureInfo objects is expensive and we don't want it to throw either. + if (name == null || supportedCultures == null) + { + return null; + } + + var culture = supportedCultures.FirstOrDefault( + supportedCulture => StringSegment.Equals(supportedCulture.Name, name, StringComparison.OrdinalIgnoreCase)); + + if (culture == null) + { + return null; + } + + return CultureInfo.ReadOnly(culture); + } +} + +internal static partial class RequestCultureProviderLoggerExtensions +{ + [LoggerMessage(1, LogLevel.Debug, "{requestCultureProvider} returned the following unsupported cultures '{cultures}'.", EventName = "UnsupportedCulture")] + public static partial void UnsupportedCultures(this ILogger logger, string requestCultureProvider, IList cultures); + + [LoggerMessage(2, LogLevel.Debug, "{requestCultureProvider} returned the following unsupported UI Cultures '{uiCultures}'.", EventName = "UnsupportedUICulture")] + public static partial void UnsupportedUICultures(this ILogger logger, string requestCultureProvider, IList uiCultures); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Localization/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Localization/Startup.cs index 8c825301c3d..083a3f7c8a0 100644 --- a/src/OrchardCore.Modules/OrchardCore.Localization/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Localization/Startup.cs @@ -26,13 +26,12 @@ public class Startup : StartupBase { public override int ConfigureOrder => -100; - /// public override void ConfigureServices(IServiceCollection services) { services.AddScoped, LocalizationSettingsDisplayDriver>(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); services.AddPortableObjectLocalization(options => options.ResourcesPath = "Localization"). AddDataAnnotationsPortableObjectLocalization(); @@ -40,23 +39,9 @@ public override void ConfigureServices(IServiceCollection services) services.Replace(ServiceDescriptor.Singleton()); } - /// public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) { - var localizationService = serviceProvider.GetService(); - - var defaultCulture = localizationService.GetDefaultCultureAsync().GetAwaiter().GetResult(); - var supportedCultures = localizationService.GetSupportedCulturesAsync().GetAwaiter().GetResult(); - - var localizationOptions = serviceProvider.GetService>().Value; - var ignoreSystemSettings = serviceProvider.GetService>().Value.IgnoreSystemSettings; - - new LocalizationOptionsUpdater(localizationOptions, ignoreSystemSettings) - .SetDefaultCulture(defaultCulture) - .AddSupportedCultures(supportedCultures) - .AddSupportedUICultures(supportedCultures); - - app.UseRequestLocalization(localizationOptions); + app.UseMiddleware(); } }