Wave/Wave/Program.cs

384 lines
14 KiB
C#
Raw Normal View History

2024-02-20 10:35:20 +00:00
using System.Reflection;
2024-01-11 12:54:29 +00:00
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
2024-01-21 20:03:06 +00:00
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption;
using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel;
2024-01-11 12:54:29 +00:00
using Microsoft.AspNetCore.Identity;
2024-02-02 15:51:56 +00:00
using Microsoft.AspNetCore.StaticFiles;
2024-01-11 12:54:29 +00:00
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
2024-01-21 20:03:06 +00:00
using StackExchange.Redis;
using System.Text;
using AspNetCore.Authentication.ApiKey;
using Tomlyn.Extensions.Configuration;
2024-01-11 12:54:29 +00:00
using Wave.Components;
using Wave.Components.Account;
using Wave.Data;
using Wave.Services;
2024-02-03 14:54:16 +00:00
using Wave.Utilities;
2024-03-11 14:26:03 +00:00
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.Grafana.Loki;
using Wave.Utilities.Metrics;
#region Version Information
2024-01-11 12:54:29 +00:00
2024-02-20 10:35:20 +00:00
string humanReadableVersion = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion.Split("+", 2)[0] ?? "unknown";
#endregion
2024-02-07 11:19:33 +00:00
2024-01-11 12:54:29 +00:00
var builder = WebApplication.CreateBuilder(args);
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
var logger = Log.Logger.ForContext<Program>();
logger.Information("Starting Wave {WaveVersion}", humanReadableVersion);
builder.Services.AddCascadingValue("Version", _ => humanReadableVersion);
builder.Configuration
.AddJsonFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.json"), true, false)
.AddYamlFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.yml"), true, false)
.AddTomlFile(Path.Combine(FileSystemService.ConfigurationDirectory, "config.toml"), true, false)
.AddIniFile( Path.Combine(FileSystemService.ConfigurationDirectory, "config.ini"), true, false)
.AddXmlFile( Path.Combine(FileSystemService.ConfigurationDirectory, "config.xml"), true, false)
2024-02-07 11:19:33 +00:00
.AddEnvironmentVariables("WAVE_");
var customizations = builder.Configuration.GetSection(nameof(Customization)).Get<Customization>();
#region Logging
builder.Services.AddSerilog((services, configuration) => {
configuration
.MinimumLevel.Verbose()
.ReadFrom.Services(services)
.Enrich.WithProperty("Application", "Wave")
.Enrich.WithProperty("WaveVersion", humanReadableVersion)
.Enrich.WithProperty("AppName", customizations?.AppName)
.Enrich.FromLogContext();
if (builder.Configuration["loki"] is {} lokiConfiguration) {
configuration.WriteTo.GrafanaLoki(lokiConfiguration, null, [
"Application", "WaveVersion", "AppName", "level", "SourceContext", "RequestId", "RequestPath"
], restrictedToMinimumLevel:LogEventLevel.Debug);
} else {
configuration.WriteTo.Console(restrictedToMinimumLevel:LogEventLevel.Information);
}
});
#endregion
2024-01-11 12:54:29 +00:00
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
2024-02-03 14:54:16 +00:00
builder.Services.AddControllers(options => {
2024-02-07 11:19:33 +00:00
options.OutputFormatters.Add(new SyndicationFeedFormatter());
2024-02-03 14:54:16 +00:00
});
builder.Services.AddOutputCache();
2024-01-11 12:54:29 +00:00
2024-01-21 20:03:06 +00:00
#region Data Protection & Redis
if (builder.Configuration.GetConnectionString("Redis") is { } redisUri) {
2024-02-07 11:19:33 +00:00
var redis = ConnectionMultiplexer.Connect(redisUri);
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis)
.UseCryptographicAlgorithms(new AuthenticatedEncryptorConfiguration() {
EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC,
ValidationAlgorithm = ValidationAlgorithm.HMACSHA256
});
builder.Services.AddStackExchangeRedisCache(options => {
options.Configuration = redisUri;
options.InstanceName = "WaveDistributedCache";
});
builder.Services.AddStackExchangeRedisOutputCache(options => {
options.Configuration = redisUri;
options.InstanceName = "WaveOutputCache";
});
2024-01-21 20:03:06 +00:00
} else {
2024-02-07 11:19:33 +00:00
builder.Services.AddDataProtection()
.UseCryptographicAlgorithms(new AuthenticatedEncryptorConfiguration {
2024-02-07 11:19:33 +00:00
EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC,
ValidationAlgorithm = ValidationAlgorithm.HMACSHA256
});
builder.Services.AddDistributedMemoryCache();
logger.Warning("No Redis connection string found, running in-memory.");
2024-01-21 20:03:06 +00:00
}
#endregion
2024-01-11 14:39:09 +00:00
#region Authentication & Authorization
2024-01-11 12:54:29 +00:00
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<IdentityUserAccessor>();
builder.Services.AddScoped<IdentityRedirectManager>();
builder.Services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
2024-01-16 12:52:24 +00:00
// Authors: Can create Articles, require them to be reviewed
// Reviewers: Can review Articles, but cannot create them themselves
// Moderators: Can delete Articles / take them Offline
// Admins: Can do anything, and assign roles to other users
builder.Services.AddAuthorizationBuilder()
2024-02-07 11:19:33 +00:00
.AddPolicy("ArticleEditPermissions", p => p.RequireRole("Author", "Admin"))
.AddPolicy("ArticleReviewPermissions", p => p.RequireRole("Reviewer", "Admin"))
.AddPolicy("ArticleDeletePermissions", p => p.RequireRole("Moderator", "Admin"))
.AddPolicy("CategoryManagePermissions", p => p.RequireRole("Admin"))
.AddPolicy("RoleAssignPermissions", p => p.RequireRole("Admin"))
.AddPolicy("ArticleEditOrReviewPermissions", p => p.RequireRole("Author", "Reviewer", "Admin"))
.AddPolicy("EmailApi", p => p.RequireClaim("EmailApi")
2024-03-11 14:26:03 +00:00
.AddAuthenticationSchemes(ApiKeyDefaults.AuthenticationScheme));
2024-01-16 12:52:24 +00:00
builder.Services.AddAuthentication(options => {
2024-02-07 11:19:33 +00:00
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
}).AddApiKeyInHeader<ApiKeyProvider>(ApiKeyDefaults.AuthenticationScheme, options => {
options.KeyName = "X-API-KEY";
options.Realm = "Wave API";
2024-03-12 13:23:00 +00:00
}).AddApiKeyInRouteValues<ApiKeyProvider>("ApiKeyInRoute", options => {
options.KeyName = "apiKey";
options.Realm = "Wave API";
})
.AddIdentityCookies();
2024-03-11 14:26:03 +00:00
if (builder.Configuration.GetSection("Oidc").Get<OidcConfiguration>() is {} oidc && !string.IsNullOrWhiteSpace(oidc.Authority)) {
builder.Services.AddAuthentication(options => {
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
}).AddOpenIdConnect(options => {
options.SignInScheme = IdentityConstants.ExternalScheme;
options.Scope.Add(OpenIdConnectScope.OpenIdProfile);
options.Scope.Add(OpenIdConnectScope.OfflineAccess);
options.Authority = oidc.Authority;
options.ClientId = oidc.ClientId;
options.ClientSecret = oidc.ClientSecret;
options.ResponseType = OpenIdConnectResponseType.Code;
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
options.CallbackPath = new PathString("/signin-oidc");
options.SignedOutCallbackPath = new PathString("/signout-callback-oidc");
options.RemoteSignOutPath = new PathString("/signout-oidc");
2024-03-11 15:03:20 +00:00
options.Events.OnRedirectToIdentityProvider = context => {
var uri = new UriBuilder(context.ProtocolMessage.RedirectUri) {
Scheme = "https",
Port = -1
};
context.ProtocolMessage.RedirectUri = uri.ToString();
return Task.FromResult(0);
};
2024-03-11 14:26:03 +00:00
});
}
2024-01-11 12:54:29 +00:00
2024-01-11 14:39:09 +00:00
#endregion
#region Identity
string connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
2024-02-07 11:19:33 +00:00
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
2024-01-14 18:04:06 +00:00
builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
2024-02-07 11:19:33 +00:00
options.UseNpgsql(connectionString));
2024-01-11 12:54:29 +00:00
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
2024-03-11 14:26:03 +00:00
builder.Services.AddIdentityCore<ApplicationUser>(options => {
options.SignIn.RequireConfirmedAccount = true;
options.ClaimsIdentity.UserIdClaimType = "Id";
})
2024-02-07 11:19:33 +00:00
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders()
.AddClaimsPrincipalFactory<UserClaimsFactory>();
2024-01-11 12:54:29 +00:00
2024-01-11 14:39:09 +00:00
#endregion
#region Services
builder.Services.AddHealthChecks();
2024-01-11 14:39:09 +00:00
builder.Services.AddLocalization(options => {
2024-02-07 11:19:33 +00:00
options.ResourcesPath = "Resources";
2024-01-11 14:39:09 +00:00
});
builder.Services.AddScoped<ImageService>();
2024-02-03 18:45:46 +00:00
builder.Services.AddHttpClient();
2024-01-11 14:39:09 +00:00
builder.Services.Configure<Features>(builder.Configuration.GetSection(nameof(Features)));
builder.Services.Configure<Customization>(builder.Configuration.GetSection(nameof(Customization)));
builder.Services.AddCascadingValue("TitlePostfix", sf => " | " + (sf.GetService<IOptions<Customization>>()?.Value.AppName ?? "Wave"));
2024-02-18 14:06:31 +00:00
var emailConfig = builder.Configuration.GetSection("Email").Get<EmailConfiguration>();
builder.Services.Configure<EmailConfiguration>(builder.Configuration.GetSection("Email"));
builder.Services.AddSingleton<EmailTemplateService>();
builder.Services.AddScoped<EmailFactory>();
2024-02-18 14:06:31 +00:00
if (emailConfig?.Smtp.Count > 0) {
if (string.IsNullOrWhiteSpace(emailConfig.SenderEmail)) {
throw new ApplicationException(
"Email providers have been configured, but no SenderEmail. " +
"Please provider the sender email address used for email distribution.");
}
foreach (var smtp in emailConfig.Smtp) {
2024-03-28 13:17:54 +00:00
builder.Services.AddKeyedScoped<IEmailService, SmtpEmailService>(smtp.Key.ToLower(), (provider, key) =>
ActivatorUtilities.CreateInstance<SmtpEmailService>(provider,
2024-02-18 14:06:31 +00:00
provider.GetRequiredService<IOptions<EmailConfiguration>>().Value.Smtp[(string)key]));
}
if (emailConfig.Smtp.Keys.Any(k => k.Equals("live", StringComparison.CurrentCultureIgnoreCase))) {
builder.Services.AddScoped(sp => sp.GetKeyedService<IEmailService>("live")!);
2024-03-13 15:35:37 +00:00
builder.Services.AddScoped<IEmailSender<ApplicationUser>, IdentityEmailSender>();
2024-02-18 14:06:31 +00:00
} else {
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
logger.Warning("No 'live' email provider configured.");
2024-02-18 14:06:31 +00:00
}
if (emailConfig.Smtp.Keys.Any(k => k.Equals("bulk", StringComparison.CurrentCultureIgnoreCase))) {
builder.Services.AddScoped<NewsletterBackgroundService>();
builder.Services.AddHostedService<EmailBackgroundWorker>();
} else if (builder.Configuration.GetSection(nameof(Features)).Get<Features>()?.EmailSubscriptions is true) {
2024-02-18 14:06:31 +00:00
throw new ApplicationException(
"Email subscriptions have been enabled, but no 'bulk' email provider was configured. " +
"Disable email subscriptions or provide the mail provider for bulk sending");
}
} else {
builder.Services.AddSingleton<IEmailService, NoOpEmailService>();
2024-02-07 11:19:33 +00:00
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
logger.Warning("No email provider configured.");
}
builder.Services.AddSingleton<IMessageDisplay, MessageService>();
builder.Services.AddSingleton<FileSystemService>();
2024-02-07 23:50:24 +00:00
2024-01-11 14:39:09 +00:00
#endregion
#region Localization
2024-02-20 11:23:42 +00:00
var customization = builder.Configuration.GetSection(nameof(Customization)).Get<Customization>();
2024-01-22 15:08:47 +00:00
string[] cultures = ["en-US", "en-GB", "de-DE"];
2024-02-20 11:23:42 +00:00
string defaultLanguage = string.IsNullOrWhiteSpace(customization?.DefaultLanguage) ? cultures[0] : customization.DefaultLanguage;
2024-01-22 15:08:47 +00:00
builder.Services.Configure<RequestLocalizationOptions>(options => {
2024-02-07 11:19:33 +00:00
options.ApplyCurrentCultureToResponseHeaders = true;
options.FallBackToParentCultures = true;
options.FallBackToParentUICultures = true;
2024-02-20 11:23:42 +00:00
options.SetDefaultCulture(defaultLanguage)
2024-02-07 11:19:33 +00:00
.AddSupportedCultures(cultures)
.AddSupportedUICultures(cultures);
2024-01-22 15:08:47 +00:00
});
#endregion
#region Open Telemetry & Metrics
var features = builder.Configuration.GetSection(nameof(Features)).Get<Features>();
if (features?.Telemetry is true) {
var otel = builder.Services.AddOpenTelemetry();
otel.ConfigureResource(resource => resource.AddService(serviceName:customization?.AppName ?? "Wave"));
// Prometheus
otel.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("Microsoft.AspNetCore.Hosting")
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
.AddMeter("Microsoft.AspNetCore.Http.Connections")
.AddMeter("Microsoft.AspNetCore.Http.Routing")
.AddMeter("Microsoft.AspNetCore.Diagnostics")
.AddMeter("Wave.Api")
2024-04-11 12:00:43 +00:00
.AddMeter("Wave.Rss")
.AddPrometheusExporter());
// Jaeger etc.
if (builder.Configuration["OTLP_ENDPOINT_URL"] is {} otlpUrl) {
otel.WithTracing(tracing => {
tracing.AddAspNetCoreInstrumentation();
tracing.AddHttpClientInstrumentation();
tracing.AddOtlpExporter(options => options.Endpoint = new Uri(otlpUrl));
});
}
2024-04-11 12:00:43 +00:00
builder.Services.AddSingleton<RssMetrics>();
builder.Services.AddSingleton<ApiMetrics>();
}
#endregion
2024-01-11 12:54:29 +00:00
var app = builder.Build();
// Configure the HTTP request pipeline.
2024-01-11 14:39:09 +00:00
if (app.Environment.IsDevelopment()) {
2024-02-07 11:19:33 +00:00
app.UseMigrationsEndPoint();
2024-01-11 14:39:09 +00:00
} else {
2024-02-07 11:19:33 +00:00
app.UseExceptionHandler("/Error", createScopeForErrors: true);
2024-01-11 12:54:29 +00:00
}
if (features?.Telemetry is true) {
app.UseOpenTelemetryPrometheusScrapingEndpoint();
}
app.UseSerilogRequestLogging();
2024-02-02 15:51:56 +00:00
app.UseStaticFiles(new StaticFileOptions {
2024-02-07 11:19:33 +00:00
ContentTypeProvider = new FileExtensionContentTypeProvider {
Mappings = {
[".jxl"] = "image/jxl"
}
}
2024-02-02 15:51:56 +00:00
});
2024-01-11 12:54:29 +00:00
app.UseAntiforgery();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
2024-01-11 12:54:29 +00:00
// Add additional endpoints required by the Identity /Account Razor components.
app.MapAdditionalIdentityEndpoints();
app.MapHealthChecks("/health");
app.MapControllers();
app.UseOutputCache();
2024-01-22 15:08:47 +00:00
app.UseRequestLocalization();
2024-01-11 14:39:09 +00:00
2024-01-15 19:47:10 +00:00
{
2024-02-07 11:19:33 +00:00
using var scope = app.Services.CreateScope();
2024-02-20 11:23:42 +00:00
await using var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
2024-02-07 11:19:33 +00:00
context.Database.Migrate();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
if (userManager.GetUsersInRoleAsync("Admin").Result.Any() is false) {
2024-02-20 11:23:42 +00:00
var cache = app.Services.GetRequiredService<IDistributedCache>();
// Check first whether the password exists already
2024-02-20 11:23:42 +00:00
string? admin = await cache.GetStringAsync("admin_promote_key");
// If it does not exist, create a new one and save it to redis
if (string.IsNullOrWhiteSpace(admin)){
admin = Guid.NewGuid().ToString("N")[..16];
await cache.SetAsync("admin_promote_key", Encoding.UTF8.GetBytes(admin), new DistributedCacheEntryOptions());
}
2024-02-07 11:19:33 +00:00
app.Logger.LogWarning("There is currently no user in your installation with the admin role, " +
"go to /Admin and use the following password to self promote your account: {admin}", admin);
}
// Generate plain text for Articles created before 1.0.0-alpha.3
var oldArticles = await context.Set<Article>().IgnoreQueryFilters().IgnoreAutoIncludes()
.Where(a => a.BodyPlain.Length < 1).AsNoTracking().ToListAsync();
if (oldArticles.Count > 0) {
oldArticles.ForEach(a => a.BodyPlain = HtmlUtilities.GetPlainText(a.BodyHtml));
context.UpdateRange(oldArticles);
await context.SaveChangesAsync();
}
2024-01-15 19:47:10 +00:00
}
2024-01-11 12:54:29 +00:00
app.Run();