using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Nuuru.Server.Auth; using Nuuru.Server.Data; using Nuuru.Server.Middleware; using Nuuru.Server.Models; using Nuuru.Server.Filters; using Nuuru.Server.Hubs; using Nuuru.Server.Services; using Nuuru.Server.Services.Storage; using System.Text; using System.Threading.RateLimiting; using IPAddress = System.Net.IPAddress; using IPNetwork = System.Net.IPNetwork; namespace Nuuru.Server { public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(options => { options.Filters.Add(); options.Conventions.Add(new MutationRateLimitConvention()); }).AddJsonOptions(options => { options.JsonSerializerOptions.MaxDepth = 128; options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); }); // Add services to the container. var dbProvider = builder.Configuration.GetValue("DatabaseProvider") ?? "PostgreSQL"; if (dbProvider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase)) { builder.Services.AddDbContext(options => { options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")); options.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); } else { builder.Services.AddDbContext(options => { options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); options.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); builder.Services.AddScoped(sp => sp.GetRequiredService()); } builder.Services.AddIdentity(options => { options.User.RequireUniqueEmail = false; options.User.AllowedUserNameCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890-_"; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequireLowercase = false; options.Password.RequireDigit = false; options.Password.RequiredLength = 6; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders() .AddClaimsPrincipalFactory(); // Register Shimmie-compatible password hasher for migrated users builder.Services.AddScoped, Auth.ShimmiePasswordHasher>(); // Register application services builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); builder.Services.Configure( builder.Configuration.GetSection(CloudflareCachePurgeOptions.SectionName)); builder.Services.AddHttpContextAccessor(); builder.Services.Configure(builder.Configuration.GetSection("LiveKit")); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = 429; options.OnRejected = async (context, cancellationToken) => { if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); } context.HttpContext.Response.ContentType = "application/json"; await context.HttpContext.Response.WriteAsync( """{"error":"Too many requests. Please try again later."}""", cancellationToken); }; options.GlobalLimiter = PartitionedRateLimiter.Create(context => RateLimitPartition.GetTokenBucketLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new TokenBucketRateLimiterOptions { TokenLimit = 400, ReplenishmentPeriod = TimeSpan.FromSeconds(5), TokensPerPeriod = 40, QueueLimit = 0, AutoReplenishment = true })); options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(5), QueueLimit = 0 })); options.AddPolicy("api-mutation", context => RateLimitPartition.GetSlidingWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new SlidingWindowRateLimiterOptions { PermitLimit = 60, Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 3, QueueLimit = 0 })); options.AddPolicy("anonymous-upload", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(10), QueueLimit = 0 })); options.AddPolicy("anonymous-comment", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(5), QueueLimit = 0 })); options.AddPolicy("captcha", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 1, Window = TimeSpan.FromSeconds(3), QueueLimit = 0 })); options.AddPolicy("pow", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 2, Window = TimeSpan.FromSeconds(3), QueueLimit = 0 })); }); builder.Services.AddSignalR(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); options.MaximumReceiveMessageSize = 102400; // 100 KB }); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, RequireExpirationTime = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(builder.Configuration["Jwt:Key"])) }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/api/hubs")) { context.Token = accessToken; } return Task.CompletedTask; } }; }); // Configure authorization policies builder.Services.AddSingleton(); builder.Services.AddAuthorization(options => { }); builder.Services.Configure(options => { options.KnownIPNetworks.Add(new IPNetwork(IPAddress.Parse("0.0.0.0"), 0)); options.KnownIPNetworks.Add(new IPNetwork(IPAddress.Parse("::"), 0)); options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; }); builder.WebHost.ConfigureKestrel((options) => { options.Limits.MaxRequestBodySize = builder.Configuration.GetValue("Upload:MaxFileSizeBytes", 100 * 1024 * 1024); }); var app = builder.Build(); app.UseForwardedHeaders(); // Serve static assets from the Vike client build app.UseStaticFiles(); // Configure the HTTP request pipeline. app.UseHttpsRedirection(); app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); // Check for IP bans before user bans (applies to all requests, not just authenticated) app.UseMiddleware(); // Check for user bans after authentication/authorization app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); if (!app.Environment.IsEnvironment("Testing")) { app.UseRateLimiter(); } app.UseMiddleware(); app.UseMiddleware(); app.MapControllers(); app.MapHub("/api/hubs/live-update"); // Proxy non-API, non-static requests to the Node.js SSR server var ssrServerUrl = builder.Configuration["SsrServerUrl"] ?? "http://localhost:3000"; app.MapFallback(async (HttpContext context) => { var httpClientFactory = context.RequestServices.GetRequiredService(); var client = httpClientFactory.CreateClient(); // If this request already came through the SSR proxy, don't proxy again (breaks loop) if (context.Request.Headers.ContainsKey("X-SSR-Proxy")) { context.Response.StatusCode = 404; context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(new { error = "Not found" }); return; } try { var targetUrl = $"{ssrServerUrl}{context.Request.Path}{context.Request.QueryString}"; var requestMessage = new HttpRequestMessage(HttpMethod.Get, targetUrl); requestMessage.Headers.Add("X-SSR-Proxy", "true"); // Check if SSR is disabled via site setting var siteSettings = context.RequestServices.GetRequiredService(); var ssrEnabled = await siteSettings.GetBoolAsync(SiteSettingKeys.SsrEnabled, SiteSettingKeys.DefaultSsrEnabled); if (!ssrEnabled) { requestMessage.Headers.Add("X-SSR-Disabled", "true"); } // Forward original headers the SSR server may need foreach (var header in context.Request.Headers) { if (!header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase)) requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); } var response = await client.SendAsync(requestMessage); context.Response.StatusCode = (int)response.StatusCode; // Forward response headers (includes CSP, Cache-Control, etc.) foreach (var header in response.Headers) { if (!header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) && !header.Key.Equals("Connection", StringComparison.OrdinalIgnoreCase)) context.Response.Headers[header.Key] = header.Value.ToArray(); } // Forward content headers (Content-Type, etc.) foreach (var header in response.Content.Headers) { context.Response.Headers[header.Key] = header.Value.ToArray(); } await response.Content.CopyToAsync(context.Response.Body); } catch { // If SSR server is unavailable, serve a minimal fallback context.Response.StatusCode = 503; context.Response.ContentType = "text/html"; await context.Response.WriteAsync("SoyBooru

Failed to load.

"); } }); using (var scope = app.Services.CreateScope()) { try { var services = scope.ServiceProvider; var context = services.GetRequiredService(); // Skip migrations in Testing environment - let tests manage the database if (!app.Environment.IsEnvironment("Testing")) { if (dbProvider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase)) { context.Database.Migrate(); } else { services.GetRequiredService().Database.Migrate(); } } Console.WriteLine("Database initialized successfully"); // Ensure anonymous system account exists (skip in Testing environment) if (!app.Environment.IsEnvironment("Testing")) { AnonymousUserSeeder.EnsureAnonymousUserAsync(services).Wait(); } // In Testing environment, test setup manages schema/data explicitly. if (!app.Environment.IsEnvironment("Testing")) { // Only allow seeding in Development, regardless of config flag var seedEnabled = app.Environment.IsDevelopment() && builder.Configuration.GetValue("SeedTestUsers", false); if (seedEnabled) { var userManager = services.GetRequiredService>(); var roleManager = services.GetRequiredService>(); var seeder = new DbSeeder(userManager, roleManager, services.GetRequiredService>()); seeder.SeedAsync().Wait(); Console.WriteLine("Roles and test users seeded (Development only)"); } var maintenance = services.GetRequiredService(); maintenance.SyncPostStatsAsync().Wait(); maintenance.SyncThreadLastPostInfoAsync().Wait(); } } catch (Exception ex) { Console.WriteLine($"An error occurred while initializing the database: {ex.Message}"); throw; } } app.Run(); } } }