using System.Text; using System.Threading.RateLimiting; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; using EchoHub.Core.Services; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Config; using EchoHub.Server.Data; using EchoHub.Server.Hubs; using EchoHub.Server.Irc; using EchoHub.Server.Services; using EchoHub.Server.Services.ServerLogs; using EchoHub.Server.Setup; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Serilog; // ── First-run setup (once) ────────────────────────────────────────────────── FirstRunSetup.EnsureAppSettings(); // ── Bootstrap logger (replaced by full Serilog once host starts) ──────────── Log.Logger = new LoggerConfiguration() .WriteTo.Console() .CreateBootstrapLogger(); // ── Auto-restart loop ─────────────────────────────────────────────────────── const int maxConsecutiveFailures = 5; var consecutiveFailures = 0; while (true) { var startTime = DateTimeOffset.UtcNow; try { var builder = WebApplication.CreateBuilder(args); // ── Host options ──────────────────────────────────────────────────── builder.Services.Configure(options => options.ShutdownTimeout = TimeSpan.FromSeconds(5)); // ── Server log room (admin-configurable via the "ServerLogs" section) ─ // Bound before Serilog so the live-log sink can be wired into the pipeline. The sink // is a singleton shared between Serilog (producer) and the stream service (consumer). var serverLogsOptions = builder.Configuration.GetSection("ServerLogs").Get() ?? new ServerLogsOptions(); builder.Services.AddSingleton(serverLogsOptions); builder.Services.AddSingleton(); var serverLogsSink = serverLogsOptions.Enabled ? new ServerLogsSink(serverLogsOptions) : null; if (serverLogsSink is not null) builder.Services.AddSingleton(serverLogsSink); // ── Serilog ────────────────────────────────────────────────────────── builder.Host.UseSerilog((context, config) => { config.ReadFrom.Configuration(context.Configuration); if (serverLogsSink is not null) config.WriteTo.Sink(serverLogsSink); }); // ── SQLite + EF Core ───────────────────────────────────────────────── var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db"); var configured = builder.Configuration.GetConnectionString("DefaultConnection"); var connectionString = string.IsNullOrWhiteSpace(configured) ? $"Data Source={defaultDbPath}" : configured; builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); // ── JWT Authentication ─────────────────────────────────────────────── var jwtSecret = builder.Configuration["Jwt:Secret"] ?? throw new InvalidOperationException("Jwt:Secret must be configured."); var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server"; var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client"; 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, ValidIssuer = jwtIssuer, ValidAudience = jwtAudience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), }; // Allow SignalR clients to send the JWT via query string options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; var path = context.HttpContext.Request.Path; if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath)) { context.Token = accessToken; } return Task.CompletedTask; }, }; }); builder.Services.AddAuthorization(); // ── Controllers + SignalR ──────────────────────────────────────────── builder.Services.AddControllers(); builder.Services.AddSignalR(); // ── Upload limits (admin-configurable via the "Uploads" section) ───── var uploadLimits = builder.Configuration.GetSection("Uploads").Get() ?? new UploadLimits(); builder.Services.AddSingleton(uploadLimits); // ── Spam protection (admin-configurable via the "Spam" section) ────── var spamOptions = builder.Configuration.GetSection("Spam").Get() ?? new SpamOptions(); builder.Services.AddSingleton(spamOptions); builder.Services.AddSingleton(); // Raise the multipart form ceiling to match the configured limits; per-endpoint // request-body limits are applied at the action from the same values. builder.Services.Configure(o => o.MultipartBodyLengthLimit = uploadLimits.MaxRequestBodyBytes); // ── Services ───────────────────────────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); // Live server-log streaming (only when the sink is active) if (serverLogsSink is not null) builder.Services.AddHostedService(); // ── Encryption ───────────────────────────────────────────────────── builder.Services.AddSingleton(); // ── Chat Service + Broadcasters ───────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // ── IRC Gateway (optional) ────────────────────────────────────────── builder.AddIrcGateway(); builder.Services.AddHttpClient("ImageDownload", client => { client.Timeout = TimeSpan.FromSeconds(15); client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB }); builder.Services.AddHttpClient("OgFetch", client => { client.Timeout = TimeSpan.FromSeconds(5); client.MaxResponseContentBufferSize = 256 * 1024; // 256 KB client.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub/1.0 (Link Preview Bot)"); }); // ── Rate Limiting ──────────────────────────────────────────────────── builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("auth", limiter => { limiter.PermitLimit = 10; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 0; }); options.AddFixedWindowLimiter("upload", limiter => { limiter.PermitLimit = 5; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 0; }); options.AddFixedWindowLimiter("general", limiter => { limiter.PermitLimit = 100; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 0; }); }); // ── CORS ───────────────────────────────────────────────────────────── var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get(); builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { policy.AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); if (allowedOrigins is { Length: > 0 }) policy.WithOrigins(allowedOrigins); else policy.SetIsOriginAllowed(_ => true); }); }); await using var app = builder.Build(); // ── Database initialization ────────────────────────────────────────── await DatabaseSetup.InitializeAsync(app.Services); // ── Middleware ──────────────────────────────────────────────────────── app.UseCors(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // ── Routing ────────────────────────────────────────────────────────── app.MapControllers(); app.MapHub(HubConstants.ChatHubPath); await app.RunAsync(); // Graceful shutdown (Ctrl+C) — exit the loop Log.Information("Server shut down gracefully"); break; } catch (Exception ex) { var uptime = DateTimeOffset.UtcNow - startTime; // If server ran for over 60 seconds, it's a runtime crash — reset failure count if (uptime.TotalSeconds > 60) consecutiveFailures = 0; consecutiveFailures++; Log.Fatal(ex, "Server crashed after {Uptime:g} (failure {Count}/{Max})", uptime, consecutiveFailures, maxConsecutiveFailures); if (consecutiveFailures >= maxConsecutiveFailures) { Log.Fatal("Too many consecutive failures, server will not restart"); break; } var delaySeconds = Math.Min(Math.Pow(2, consecutiveFailures), 30); Log.Information("Restarting server in {Delay}s...", delaySeconds); await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); } } Log.CloseAndFlush();