diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index 35dd6df..3548976 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -33,6 +33,8 @@ + + diff --git a/DysonNetwork.Pass/Program.cs b/DysonNetwork.Pass/Program.cs index 666a9c5..9e20295 100644 --- a/DysonNetwork.Pass/Program.cs +++ b/DysonNetwork.Pass/Program.cs @@ -1,23 +1,40 @@ +using DysonNetwork.Pass; +using DysonNetwork.Pass.Startup; +using Microsoft.EntityFrameworkCore; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. +// Configure Kestrel and server options +builder.ConfigureAppKestrel(); -builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); +// Add metrics and telemetry +builder.Services.AddAppMetrics(); + +// Add application services +builder.Services.AddAppServices(builder.Configuration); +builder.Services.AddAppRateLimiting(); +builder.Services.AddAppAuthentication(); +builder.Services.AddAppSwagger(); + +// Add flush handlers and websocket handlers +builder.Services.AddAppFlushHandlers(); + +// Add business services +builder.Services.AddAppBusinessServices(builder.Configuration); + +// Add scheduled jobs +builder.Services.AddAppScheduledJobs(); var app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) +// Run database migrations +using (var scope = app.Services.CreateScope()) { - app.MapOpenApi(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); } -app.UseHttpsRedirection(); +// Configure application middleware pipeline +app.ConfigureAppMiddleware(builder.Configuration); -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); +app.Run(); \ No newline at end of file diff --git a/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs b/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs new file mode 100644 index 0000000..7895bdf --- /dev/null +++ b/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs @@ -0,0 +1,66 @@ +using System.Net; +using DysonNetwork.Pass.Permission; +using Microsoft.AspNetCore.HttpOverrides; +using Prometheus; + +namespace DysonNetwork.Pass.Startup; + +public static class ApplicationConfiguration +{ + public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) + { + app.MapMetrics(); + app.MapOpenApi(); + + app.UseSwagger(); + app.UseSwaggerUI(); + + app.UseRequestLocalization(); + + ConfigureForwardedHeaders(app, configuration); + + app.UseCors(opts => + opts.SetIsOriginAllowed(_ => true) + .WithExposedHeaders("*") + .WithHeaders() + .AllowCredentials() + .AllowAnyHeader() + .AllowAnyMethod() + ); + + app.UseWebSockets(); + app.UseRateLimiter(); + app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseMiddleware(); + + app.MapControllers().RequireRateLimiting("fixed"); + app.MapStaticAssets().RequireRateLimiting("fixed"); + app.MapRazorPages().RequireRateLimiting("fixed"); + + return app; + } + + private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration) + { + var knownProxiesSection = configuration.GetSection("KnownProxies"); + var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }; + + if (knownProxiesSection.Exists()) + { + var proxyAddresses = knownProxiesSection.Get(); + if (proxyAddresses != null) + foreach (var proxy in proxyAddresses) + if (IPAddress.TryParse(proxy, out var ipAddress)) + forwardedHeadersOptions.KnownProxies.Add(ipAddress); + } + else + { + forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any); + forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any); + } + + app.UseForwardedHeaders(forwardedHeadersOptions); + } +} diff --git a/DysonNetwork.Pass/Startup/KestrelConfiguration.cs b/DysonNetwork.Pass/Startup/KestrelConfiguration.cs new file mode 100644 index 0000000..b042534 --- /dev/null +++ b/DysonNetwork.Pass/Startup/KestrelConfiguration.cs @@ -0,0 +1,17 @@ +namespace DysonNetwork.Pass.Startup; + +public static class KestrelConfiguration +{ + public static WebApplicationBuilder ConfigureAppKestrel(this WebApplicationBuilder builder) + { + builder.Host.UseContentRoot(Directory.GetCurrentDirectory()); + builder.WebHost.ConfigureKestrel(options => + { + options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; + options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); + options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); + }); + + return builder; + } +} diff --git a/DysonNetwork.Pass/Startup/MetricsConfiguration.cs b/DysonNetwork.Pass/Startup/MetricsConfiguration.cs new file mode 100644 index 0000000..78e33cd --- /dev/null +++ b/DysonNetwork.Pass/Startup/MetricsConfiguration.cs @@ -0,0 +1,40 @@ +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Prometheus; +using Prometheus.SystemMetrics; + +namespace DysonNetwork.Pass.Startup; + +public static class MetricsConfiguration +{ + public static IServiceCollection AddAppMetrics(this IServiceCollection services) + { + // Prometheus + services.UseHttpClientMetrics(); + services.AddHealthChecks(); + services.AddSystemMetrics(); + services.AddPrometheusEntityFrameworkMetrics(); + services.AddPrometheusAspNetCoreMetrics(); + services.AddPrometheusHttpClientMetrics(); + + // OpenTelemetry + services.AddOpenTelemetry() + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter(); + }) + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddOtlpExporter(); + }); + + return services; + } +} diff --git a/DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs b/DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs new file mode 100644 index 0000000..92c7b48 --- /dev/null +++ b/DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs @@ -0,0 +1,54 @@ +using DysonNetwork.Pass.Handlers; +using DysonNetwork.Pass.Wallet; +using Quartz; + +namespace DysonNetwork.Pass.Startup; + +public static class ScheduledJobsConfiguration +{ + public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services) + { + services.AddQuartz(q => + { + var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling"); + q.AddJob(opts => opts.WithIdentity(appDatabaseRecyclingJob)); + q.AddTrigger(opts => opts + .ForJob(appDatabaseRecyclingJob) + .WithIdentity("AppDatabaseRecyclingTrigger") + .WithCronSchedule("0 0 0 * * ?")); + + var actionLogFlushJob = new JobKey("ActionLogFlush"); + q.AddJob(opts => opts.WithIdentity(actionLogFlushJob)); + q.AddTrigger(opts => opts + .ForJob(actionLogFlushJob) + .WithIdentity("ActionLogFlushTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(5) + .RepeatForever()) + ); + + var lastActiveFlushJob = new JobKey("LastActiveFlush"); + q.AddJob(opts => opts.WithIdentity(lastActiveFlushJob)); + q.AddTrigger(opts => opts + .ForJob(lastActiveFlushJob) + .WithIdentity("LastActiveFlushTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(5) + .RepeatForever()) + ); + + var subscriptionRenewalJob = new JobKey("SubscriptionRenewal"); + q.AddJob(opts => opts.WithIdentity(subscriptionRenewalJob)); + q.AddTrigger(opts => opts + .ForJob(subscriptionRenewalJob) + .WithIdentity("SubscriptionRenewalTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(30) + .RepeatForever()) + ); + }); + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); + + return services; + } +} diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..512b18d --- /dev/null +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -0,0 +1,195 @@ +using System.Globalization; +using DysonNetwork.Pass.Account; +using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass.Auth.OpenId; +using DysonNetwork.Pass.Email; +using DysonNetwork.Pass.Localization; +using DysonNetwork.Pass.Permission; +using DysonNetwork.Pass.Wallet; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.OpenApi.Models; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; +using StackExchange.Redis; +using System.Text.Json; +using System.Threading.RateLimiting; +using DysonNetwork.Pass.Auth.OidcProvider.Options; +using DysonNetwork.Pass.Auth.OidcProvider.Services; +using DysonNetwork.Pass.Handlers; +using DysonNetwork.Pass.Wallet.PaymentHandlers; +using DysonNetwork.Shared.Cache; +using DysonNetwork.Shared.Geo; + +namespace DysonNetwork.Pass.Startup; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddLocalization(options => options.ResourcesPath = "Resources"); + + services.AddDbContext(); + services.AddSingleton(_ => + { + var connection = configuration.GetConnectionString("FastRetrieve")!; + return ConnectionMultiplexer.Connect(connection); + }); + services.AddSingleton(SystemClock.Instance); + services.AddHttpContextAccessor(); + services.AddSingleton(); + + services.AddHttpClient(); + + // Register OIDC services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddControllers().AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; + + options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + }).AddDataAnnotationsLocalization(options => + { + options.DataAnnotationLocalizerProvider = (type, factory) => + factory.Create(typeof(SharedResource)); + }); + services.AddRazorPages(); + + services.Configure(options => + { + var supportedCultures = new[] + { + new CultureInfo("en-US"), + new CultureInfo("zh-Hans"), + }; + + options.SupportedCultures = supportedCultures; + options.SupportedUICultures = supportedCultures; + }); + + return services; + } + + public static IServiceCollection AddAppRateLimiting(this IServiceCollection services) + { + services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts => + { + opts.Window = TimeSpan.FromMinutes(1); + opts.PermitLimit = 120; + opts.QueueLimit = 2; + opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + })); + + return services; + } + + public static IServiceCollection AddAppAuthentication(this IServiceCollection services) + { + services.AddCors(); + services.AddAuthorization(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = AuthConstants.SchemeName; + options.DefaultChallengeScheme = AuthConstants.SchemeName; + }) + .AddScheme(AuthConstants.SchemeName, _ => { }); + + return services; + } + + public static IServiceCollection AddAppSwagger(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Solar Network API", + Description = "An open-source social network", + TermsOfService = new Uri("https://solsynth.dev/terms"), + License = new OpenApiLicense + { + Name = "APGLv3", + Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html") + } + }); + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + [] + } + }); + }); + services.AddOpenApi(); + + return services; + } + + public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddAppBusinessServices(this IServiceCollection services, + IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + services.Configure(configuration.GetSection("GeoIP")); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.Configure(configuration.GetSection("OidcProvider")); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index f7c5de9..72aa5d7 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -71,8 +71,8 @@ - - + +