diff --git a/src/Sitko.Core.App.Web/BaseStartup.cs b/src/Sitko.Core.App.Web/BaseStartup.cs deleted file mode 100644 index 73bfcad34..000000000 --- a/src/Sitko.Core.App.Web/BaseStartup.cs +++ /dev/null @@ -1,266 +0,0 @@ -// TODO: Move to builder -// using JetBrains.Annotations; -// using Microsoft.AspNetCore.Builder; -// using Microsoft.AspNetCore.Cors.Infrastructure; -// using Microsoft.AspNetCore.DataProtection; -// using Microsoft.AspNetCore.Http; -// using Microsoft.AspNetCore.HttpOverrides; -// using Microsoft.AspNetCore.Routing; -// using Microsoft.Extensions.Configuration; -// using Microsoft.Extensions.DependencyInjection; -// using Microsoft.Extensions.Hosting; -// -// namespace Sitko.Core.App.Web; -// -// [PublicAPI] -// public abstract class BaseStartup -// { -// private readonly Dictionary corsPolicies = -// new(); -// -// protected BaseStartup(IConfiguration configuration, IHostEnvironment environment) -// { -// Configuration = configuration; -// Environment = environment; -// } -// -// protected IConfiguration Configuration { get; } -// protected IHostEnvironment Environment { get; } -// -// protected virtual bool EnableMvc { get; } = true; -// protected virtual bool AddHttpContextAccessor { get; } = true; -// protected virtual bool EnableSameSiteCookiePolicy { get; } = true; -// protected virtual bool AllowAllForwardedHeaders { get; } = true; -// protected virtual bool EnableStaticFiles { get; } = true; -// -// public void ConfigureServices(IServiceCollection services) -// { -// if (EnableMvc) -// { -// ConfigureMvc(services.AddControllersWithViews().AddControllersAsServices()); -// } -// -// if (AddHttpContextAccessor) -// { -// services.AddHttpContextAccessor(); -// } -// -// if (EnableSameSiteCookiePolicy) -// { -// services.Configure(options => -// { -// options.MinimumSameSitePolicy = SameSiteMode.None; -// options.OnAppendCookie = cookieContext => -// CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); -// options.OnDeleteCookie = cookieContext => -// CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); -// }); -// } -// -// if (corsPolicies.Any()) -// { -// services.AddCors(options => -// { -// foreach (var (name, (policy, _)) in corsPolicies) -// { -// options.AddPolicy(name, policy); -// } -// }); -// } -// -// if (AllowAllForwardedHeaders) -// { -// services.Configure(options => -// { -// options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | -// ForwardedHeaders.XForwardedHost; -// options.KnownProxies.Clear(); -// options.KnownNetworks.Clear(); -// }); -// } -// -// AddDataProtection(services); -// ConfigureHealthChecks(services.AddHealthChecks()); -// ConfigureAppServices(services); -// } -// -// public virtual void AddRedisCache(IServiceCollection services, string redisConnectionsString) => -// services.AddStackExchangeRedisCache( -// options => { options.Configuration = redisConnectionsString; }); -// -// public virtual void AddMemoryCache(IServiceCollection services) => services.AddMemoryCache(); -// -// private void AddDataProtection(IServiceCollection services) => -// ConfigureDataProtection(services.AddDataProtection()); -// -// protected virtual IDataProtectionBuilder -// ConfigureDataProtection(IDataProtectionBuilder dataProtectionBuilder) => -// dataProtectionBuilder -// .SetApplicationName(Environment.ApplicationName) -// .SetDefaultKeyLifetime(TimeSpan.FromDays(90)); -// -// protected virtual void ConfigureAppServices(IServiceCollection services) -// { -// } -// -// protected virtual IMvcBuilder ConfigureMvc(IMvcBuilder mvcBuilder) => mvcBuilder; -// -// protected virtual IHealthChecksBuilder ConfigureHealthChecks(IHealthChecksBuilder healthChecksBuilder) => -// healthChecksBuilder; -// -// protected virtual void ConfigureBeforeRoutingMiddleware(IApplicationBuilder app) -// { -// } -// -// protected virtual void ConfigureBeforeRoutingModulesHook(IApplicationBuilder app) -// { -// } -// -// protected virtual void ConfigureAfterRoutingMiddleware(IApplicationBuilder app) -// { -// } -// -// protected virtual void ConfigureAfterRoutingModulesHook(IApplicationBuilder app) -// { -// } -// -// protected virtual void ConfigureEndpoints(IApplicationBuilder app, -// IEndpointRouteBuilder endpoints) -// { -// if (EnableMvc) -// { -// endpoints.MapControllers(); -// } -// } -// -// public void Configure(IApplicationBuilder appBuilder, WebApplication application, -// IApplicationContext applicationContext) -// { -// if (AllowAllForwardedHeaders) -// { -// appBuilder.UseForwardedHeaders(); -// } -// -// //ConfigureHook(appBuilder); -// //application.AppBuilderHook(applicationContext, appBuilder); -// -// if (Environment.IsDevelopment()) -// { -// appBuilder.UseDeveloperExceptionPage(); -// } -// else -// { -// appBuilder.UseExceptionHandler("/Error"); -// } -// -// if (EnableSameSiteCookiePolicy) -// { -// appBuilder.UseCookiePolicy(); -// } -// -// if (EnableStaticFiles) -// { -// UseStaticFiles(appBuilder); -// } -// -// //ConfigureBeforeRoutingModulesHook(appBuilder); -// //application.BeforeRoutingHook(applicationContext, appBuilder); -// //ConfigureBeforeRoutingMiddleware(appBuilder); -// //appBuilder.UseRouting(); -// if (corsPolicies.Any()) -// { -// var defaultPolicy = corsPolicies.Where(item => item.Value.isDefault).Select(item => item.Key) -// .FirstOrDefault(); -// if (!string.IsNullOrEmpty(defaultPolicy)) -// { -// appBuilder.UseCors(defaultPolicy); -// } -// } -// -// //ConfigureAfterRoutingMiddleware(appBuilder); -// // application.AuthMiddlewareHook(applicationContext, appBuilder); -// // application.AfterRoutingHook(applicationContext, appBuilder); -// //ConfigureAfterRoutingModulesHook(appBuilder); -// -// appBuilder.UseEndpoints(endpoints => -// { -// //application.EndpointsHook(applicationContext, appBuilder, endpoints); -// ConfigureEndpoints(appBuilder, endpoints); -// }); -// } -// -// protected virtual void ConfigureHook(IApplicationBuilder appBuilder) { } -// -// protected virtual void UseStaticFiles(IApplicationBuilder appBuilder) => appBuilder.UseStaticFiles(); -// -// public void AddCorsPolicy(string name, CorsPolicy policy, bool isDefault = false) -// { -// if (corsPolicies.ContainsKey(name)) -// { -// throw new ArgumentException($"Cors policy with name {name} already registered", nameof(name)); -// } -// -// if (isDefault && corsPolicies.Any(c => c.Value.isDefault)) -// { -// throw new ArgumentException("Default policy already registered", nameof(isDefault)); -// } -// -// corsPolicies.Add(name, (policy, isDefault)); -// } -// -// public void AddCorsPolicy(string name, Action buildPolicy, bool isDefault = false) -// { -// var builder = new CorsPolicyBuilder(); -// buildPolicy(builder); -// AddCorsPolicy(name, builder.Build(), isDefault); -// } -// -// // https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/ -// private static void CheckSameSite(HttpContext httpContext, CookieOptions options) -// { -// if (options.SameSite > SameSiteMode.None) -// { -// var userAgent = httpContext.Request.Headers["User-Agent"].ToString(); -// if (DisallowsSameSiteNone(userAgent)) -// { -// options.SameSite = SameSiteMode.Unspecified; -// } -// } -// } -// -// private static bool DisallowsSameSiteNone(string userAgent) -// { -// // Cover all iOS based browsers here. This includes: -// // - Safari on iOS 12 for iPhone, iPod Touch, iPad -// // - WkWebview on iOS 12 for iPhone, iPod Touch, iPad -// // - Chrome on iOS 12 for iPhone, iPod Touch, iPad -// // All of which are broken by SameSite=None, because they use the iOS networking stack -// if (userAgent.Contains("CPU iPhone OS 12") || userAgent.Contains("iPad; CPU OS 12")) -// { -// return true; -// } -// -// // Cover Mac OS X based browsers that use the Mac OS networking stack. This includes: -// // - Safari on Mac OS X. -// // This does not include: -// // - Chrome on Mac OS X -// // Because they do not use the Mac OS networking stack. -// if (userAgent.Contains("Macintosh; Intel Mac OS X 10_14") && -// userAgent.Contains("Version/") && userAgent.Contains("Safari")) -// { -// return true; -// } -// -// // Cover Chrome 50-69, because some versions are broken by SameSite=None, -// // and none in this range require it. -// // Note: this covers some pre-Chromium Edge versions, -// // but pre-Chromium Edge does not require SameSite=None. -// if (userAgent.Contains("Chrome/5") || userAgent.Contains("Chrome/6")) -// { -// return true; -// } -// -// return false; -// } -// } -// diff --git a/src/Sitko.Core.App.Web/SitkoCoreWebApplicationBuilder.cs b/src/Sitko.Core.App.Web/SitkoCoreWebApplicationBuilder.cs new file mode 100644 index 000000000..f8d9d39e7 --- /dev/null +++ b/src/Sitko.Core.App.Web/SitkoCoreWebApplicationBuilder.cs @@ -0,0 +1,191 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; + +namespace Sitko.Core.App.Web; + +public interface ISitkoCoreWebApplicationBuilder : ISitkoCoreApplicationBuilder +{ + internal SitkoCoreWebOptions WebOptions { get; } +} + +public class SitkoCoreWebApplicationBuilder : SitkoCoreServerApplicationBuilder, ISitkoCoreWebApplicationBuilder +{ + private readonly WebApplicationBuilder webApplicationBuilder; + private readonly SitkoCoreWebOptions webOptions = new(); + SitkoCoreWebOptions ISitkoCoreWebApplicationBuilder.WebOptions => webOptions; + + public SitkoCoreWebApplicationBuilder(WebApplicationBuilder builder, string[] args) : base(builder, args) + { + webApplicationBuilder = builder; + webApplicationBuilder.Services.AddSingleton(webOptions); + } + + protected override void ConfigureHostBuilder(ApplicationModuleRegistration registration) + { + base.ConfigureHostBuilder(registration); + + if (typeof(TModule).IsAssignableTo(typeof(IWebApplicationModule))) + { + var module = registration.GetInstance(); + var (_, options) = registration.GetOptions(BootApplicationContext); + if (module is TModule and IWebApplicationModule webModule && + options is TModuleOptions webModuleOptions) + { + webModule.ConfigureWebHost(BootApplicationContext, webApplicationBuilder.WebHost, webModuleOptions); + } + } + } + + protected override void BeforeContainerBuild() + { + base.BeforeContainerBuild(); + if (webOptions.EnableMvc) + { + Services.AddControllersWithViews().AddControllersAsServices(); + } + + if (webOptions.AddHttpContextAccessor) + { + Services.AddHttpContextAccessor(); + } + + if (webOptions.EnableSameSiteCookiePolicy) + { + Services.Configure(options => + { + options.MinimumSameSitePolicy = SameSiteMode.None; + options.OnAppendCookie = cookieContext => + CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); + options.OnDeleteCookie = cookieContext => + CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); + }); + } + + if (webOptions.CorsPolicies.Count != 0) + { + Services.AddCors(options => + { + foreach (var (name, (policy, _)) in webOptions.CorsPolicies) + { + options.AddPolicy(name, policy); + } + }); + } + + if (webOptions.AllowAllForwardedHeaders) + { + Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | + ForwardedHeaders.XForwardedHost; + options.KnownProxies.Clear(); + options.KnownNetworks.Clear(); + }); + } + + AddDataProtection(); + ConfigureHealthChecks(Services.AddHealthChecks()); + } + + protected virtual IHealthChecksBuilder ConfigureHealthChecks(IHealthChecksBuilder healthChecksBuilder) => + healthChecksBuilder; + + private void AddDataProtection() => + ConfigureDataProtection(Services.AddDataProtection()); + + protected virtual IDataProtectionBuilder + ConfigureDataProtection(IDataProtectionBuilder dataProtectionBuilder) => + dataProtectionBuilder + .SetApplicationName(BootApplicationContext.Name) + .SetDefaultKeyLifetime(TimeSpan.FromDays(90)); + + // https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/ + private static void CheckSameSite(HttpContext httpContext, CookieOptions options) + { + if (options.SameSite > SameSiteMode.None) + { + var userAgent = httpContext.Request.Headers.UserAgent.ToString(); + if (DisallowsSameSiteNone(userAgent)) + { + options.SameSite = SameSiteMode.Unspecified; + } + } + } + + private static bool DisallowsSameSiteNone(string userAgent) + { + // Cover all iOS based browsers here. This includes: + // - Safari on iOS 12 for iPhone, iPod Touch, iPad + // - WkWebview on iOS 12 for iPhone, iPod Touch, iPad + // - Chrome on iOS 12 for iPhone, iPod Touch, iPad + // All of which are broken by SameSite=None, because they use the iOS networking stack + if (userAgent.Contains("CPU iPhone OS 12") || userAgent.Contains("iPad; CPU OS 12")) + { + return true; + } + + // Cover Mac OS X based browsers that use the Mac OS networking stack. This includes: + // - Safari on Mac OS X. + // This does not include: + // - Chrome on Mac OS X + // Because they do not use the Mac OS networking stack. + if (userAgent.Contains("Macintosh; Intel Mac OS X 10_14") && + userAgent.Contains("Version/") && userAgent.Contains("Safari")) + { + return true; + } + + // Cover Chrome 50-69, because some versions are broken by SameSite=None, + // and none in this range require it. + // Note: this covers some pre-Chromium Edge versions, + // but pre-Chromium Edge does not require SameSite=None. + if (userAgent.Contains("Chrome/5") || userAgent.Contains("Chrome/6")) + { + return true; + } + + return false; + } +} + +[PublicAPI] +public class SitkoCoreWebOptions +{ + internal Dictionary CorsPolicies { get; } = new(); + public bool EnableMvc { get; set; } = true; + public bool AddHttpContextAccessor { get; set; } = true; + public bool EnableSameSiteCookiePolicy { get; set; } = true; + public bool AllowAllForwardedHeaders { get; set; } = true; + public bool EnableStaticFiles { get; set; } = true; + + public SitkoCoreWebOptions AddCorsPolicy(string name, CorsPolicy policy, bool isDefault = false) + { + if (CorsPolicies.ContainsKey(name)) + { + throw new ArgumentException($"Cors policy with name {name} already registered", nameof(name)); + } + + if (isDefault && CorsPolicies.Any(c => c.Value.isDefault)) + { + throw new ArgumentException("Default policy already registered", nameof(isDefault)); + } + + CorsPolicies.Add(name, (policy, isDefault)); + + return this; + } + + public SitkoCoreWebOptions AddCorsPolicy(string name, Action buildPolicy, bool isDefault = false) + { + var builder = new CorsPolicyBuilder(); + buildPolicy(builder); + AddCorsPolicy(name, builder.Build(), isDefault); + + return this; + } +} diff --git a/src/Sitko.Core.App.Web/SitkoCoreWebStartupFilter.cs b/src/Sitko.Core.App.Web/SitkoCoreWebStartupFilter.cs new file mode 100644 index 000000000..bd07c7346 --- /dev/null +++ b/src/Sitko.Core.App.Web/SitkoCoreWebStartupFilter.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace Sitko.Core.App.Web; + +public class SitkoCoreWebStartupFilter : IStartupFilter +{ + private readonly IApplicationContext applicationContext; + private readonly IReadOnlyList webModules; + + public SitkoCoreWebStartupFilter(IApplicationContext applicationContext, + IEnumerable applicationModuleRegistrations) + { + this.applicationContext = applicationContext; + webModules = + ModulesHelper.GetEnabledModuleRegistrations(applicationContext, applicationModuleRegistrations) + .Select(r => r.GetInstance()) + .OfType() + .ToList(); + } + + public Action Configure(Action next) => + appBuilder => + { + foreach (var webModule in webModules) + { + webModule.ConfigureAppBuilder(applicationContext, appBuilder); + } + + next(appBuilder); + }; +} diff --git a/src/Sitko.Core.App.Web/WebApplicationBuilderExtensions.cs b/src/Sitko.Core.App.Web/WebApplicationBuilderExtensions.cs new file mode 100644 index 000000000..c6fa4442c --- /dev/null +++ b/src/Sitko.Core.App.Web/WebApplicationBuilderExtensions.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Sitko.Core.App.Web; + +public static class WebApplicationBuilderExtensions +{ + public static ISitkoCoreWebApplicationBuilder AddSitkoCoreWeb(this WebApplicationBuilder builder) => + builder.AddSitkoCoreWeb(Array.Empty()); + + public static ISitkoCoreWebApplicationBuilder AddSitkoCoreWeb(this WebApplicationBuilder builder, string[] args) + { + builder.Services.TryAddTransient(); + return ApplicationBuilderFactory.GetOrCreateApplicationBuilder(builder, + applicationBuilder => new SitkoCoreWebApplicationBuilder(applicationBuilder, args)); + } + + public static TBuilder ConfigureWeb(this TBuilder builder, Action configure) + where TBuilder : ISitkoCoreWebApplicationBuilder + { + configure(builder.WebOptions); + return builder; + } + + public static WebApplication MapSitkoCore(this WebApplication webApplication) + { + var applicationContext = webApplication.Services.GetRequiredService(); + var applicationModuleRegistrations = webApplication.Services.GetServices(); + var webOptions = webApplication.Services.GetRequiredService(); + + if (webOptions.AllowAllForwardedHeaders) + { + webApplication.UseForwardedHeaders(); + } + + if (applicationContext.IsDevelopment()) + { + webApplication.UseDeveloperExceptionPage(); + } + else + { + webApplication.UseExceptionHandler("/Error"); + } + + if (webOptions.EnableSameSiteCookiePolicy) + { + webApplication.UseCookiePolicy(); + } + + if (webOptions.EnableStaticFiles) + { + webApplication.UseStaticFiles(); + } + + webApplication.UseAntiforgery(); + + if (webOptions.CorsPolicies.Count != 0) + { + var defaultPolicy = webOptions.CorsPolicies.Where(item => item.Value.isDefault).Select(item => item.Key) + .FirstOrDefault(); + if (!string.IsNullOrEmpty(defaultPolicy)) + { + webApplication.UseCors(defaultPolicy); + } + } + + if (webOptions.EnableMvc) + { + webApplication.MapControllers(); + } + + var webModules = + ModulesHelper.GetEnabledModuleRegistrations(applicationContext, applicationModuleRegistrations) + .Select(r => r.GetInstance()) + .OfType() + .ToList(); + foreach (var webModule in webModules) + { + webModule.ConfigureBeforeUseRouting(applicationContext, webApplication); + webModule.ConfigureAfterUseRouting(applicationContext, webApplication); + webModule.ConfigureAuthMiddleware(applicationContext, webApplication); + webModule.ConfigureEndpoints(applicationContext, webApplication, webApplication); + } + + return webApplication; + } +} diff --git a/src/Sitko.Core.App.Web/WebApplicationExtensions.cs b/src/Sitko.Core.App.Web/WebApplicationExtensions.cs deleted file mode 100644 index 61e695d04..000000000 --- a/src/Sitko.Core.App.Web/WebApplicationExtensions.cs +++ /dev/null @@ -1,112 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Sitko.Core.App.Web; - -public interface ISitkoCoreWebApplicationBuilder : ISitkoCoreApplicationBuilder -{ -} - -public class SitkoCoreWebApplicationBuilder : SitkoCoreServerApplicationBuilder, ISitkoCoreWebApplicationBuilder -{ - private readonly WebApplicationBuilder webApplicationBuilder; - - public SitkoCoreWebApplicationBuilder(WebApplicationBuilder builder, string[] args) : base(builder, args) => - webApplicationBuilder = builder; - - protected override void ConfigureHostBuilder(ApplicationModuleRegistration registration) - { - base.ConfigureHostBuilder(registration); - - if (typeof(TModule).IsAssignableTo(typeof(IWebApplicationModule))) - { - var module = registration.GetInstance(); - var (_, options) = registration.GetOptions(BootApplicationContext); - if (module is TModule and IWebApplicationModule webModule && - options is TModuleOptions webModuleOptions) - { - webModule.ConfigureWebHost(BootApplicationContext, webApplicationBuilder.WebHost, webModuleOptions); - } - } - } -} - -public static class WebApplicationExtensions -{ - public static ISitkoCoreWebApplicationBuilder AddSitkoCoreWeb(this WebApplicationBuilder builder) => - builder.AddSitkoCoreWeb(Array.Empty()); - - public static ISitkoCoreWebApplicationBuilder AddSitkoCoreWeb(this WebApplicationBuilder builder, string[] args) - { - builder.Services.TryAddTransient(); - return ApplicationBuilderFactory.GetOrCreateApplicationBuilder(builder, - applicationBuilder => new SitkoCoreWebApplicationBuilder(applicationBuilder, args)); - } - - public static WebApplication MapSitkoCore(this WebApplication webApplication) - { - var applicationContext = webApplication.Services.GetRequiredService(); - var applicationModuleRegistrations = webApplication.Services.GetServices(); - var webModules = - ModulesHelper.GetEnabledModuleRegistrations(applicationContext, applicationModuleRegistrations) - .Select(r => r.GetInstance()) - .OfType() - .ToList(); - foreach (var webModule in webModules) - { - webModule.ConfigureEndpoints(applicationContext, webApplication, webApplication); - } - - return webApplication; - } -} - -public class SitkoCoreWebStartupFilter : IStartupFilter -{ - private readonly IApplicationContext applicationContext; - private readonly IReadOnlyList webModules; - - public SitkoCoreWebStartupFilter(IApplicationContext applicationContext, - IEnumerable applicationModuleRegistrations) - { - this.applicationContext = applicationContext; - webModules = - ModulesHelper.GetEnabledModuleRegistrations(applicationContext, applicationModuleRegistrations) - .Select(r => r.GetInstance()) - .OfType() - .ToList(); - } - - public Action Configure(Action next) => - appBuilder => - { - // Configure the HTTP request pipeline. - if (!applicationContext.IsDevelopment()) - { - appBuilder.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - appBuilder.UseHsts(); - } - - appBuilder.UseHttpsRedirection(); - - appBuilder.UseStaticFiles(); - - foreach (var webModule in webModules) - { - webModule.ConfigureAppBuilder(applicationContext, appBuilder); - } - - next(appBuilder); - - // foreach (var webModule in webModules) - // { - // appBuilder.UseEndpoints(endpointRouteBuilder => - // { - // webModule.ConfigureEndpoints(applicationContext, appBuilder, endpointRouteBuilder); - // }); - // } - }; -}