using Lagerbestand.Components; using Lagerbestand.Data; using Lagerbestand.Services; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); // Configure Kestrel to listen on all interfaces in production if (!builder.Environment.IsDevelopment()) { builder.WebHost.ConfigureKestrel(serverOptions => { // Listen on all network interfaces on port 5000 serverOptions.ListenAnyIP(5000); }); } // Add Razor Components with interactive server-side rendering builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); // Circuit options — keep disconnected circuits alive longer to survive // idle workshop tablets / brief network drops. Retention is read from // SessionSettings (editable in Status page); default = 2 hours. // Requires a restart to take effect after changing in Status. var sessionRetentionHours = Lagerbestand.Models.SessionSettings.DefaultCircuitRetentionHours; try { var raw = builder.Configuration["SessionSettings:CircuitRetentionHours"]; if (!string.IsNullOrWhiteSpace(raw) && double.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var cfgHours)) { sessionRetentionHours = cfgHours; } } catch { /* ignore — use default */ } // Clamp to safe range 0.5h – 4h if (sessionRetentionHours < Lagerbestand.Models.SessionSettings.MinCircuitRetentionHours) sessionRetentionHours = Lagerbestand.Models.SessionSettings.MinCircuitRetentionHours; if (sessionRetentionHours > Lagerbestand.Models.SessionSettings.MaxCircuitRetentionHours) sessionRetentionHours = Lagerbestand.Models.SessionSettings.MaxCircuitRetentionHours; builder.Services.Configure(options => { options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromHours(sessionRetentionHours); options.DisconnectedCircuitMaxRetained = 200; options.JSInteropDefaultCallTimeout = TimeSpan.FromMinutes(3); options.DetailedErrors = builder.Environment.IsDevelopment(); }); // Response compression for better Core Web Vitals (production only) builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add(); options.Providers.Add(); options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] { "application/javascript", "text/css", "application/json", "text/html", "image/svg+xml" }); }); builder.Services.Configure(options => { options.Level = System.IO.Compression.CompressionLevel.Fastest; }); // Configure HSTS (HTTP Strict Transport Security) builder.Services.AddHsts(options => { options.Preload = true; options.IncludeSubDomains = true; options.MaxAge = TimeSpan.FromDays(365); }); // Configure MariaDB/MySQL connection with robust settings var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); // Use explicit MariaDB version to avoid connection during startup // MariaDB 10.x is common for most hosting providers var serverVersion = new MariaDbServerVersion(new Version(10, 6, 0)); builder.Services.AddDbContext(options => { options.UseMySql(connectionString, serverVersion, mySqlOptions => { // Connection resiliency for production mySqlOptions.EnableRetryOnFailure( maxRetryCount: 10, maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null); // Command timeout for slow connections mySqlOptions.CommandTimeout(60); // Enable string comparison fix for MySQL mySqlOptions.EnableStringComparisonTranslations(); }); // Enable detailed errors only in development if (builder.Environment.IsDevelopment()) { options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); } }); // Register application services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); // Register tire storage management services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Register Kanban service builder.Services.AddScoped(); // Lexware API sync service builder.Services.AddHttpClient("LexwareApi"); builder.Services.AddScoped(); builder.Services.AddHostedService(); var app = builder.Build(); // Configure the HTTP request pipeline if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); // HTTPS redirection and HSTS for production app.UseHttpsRedirection(); app.UseHsts(); // Enable response compression only in production // (Disabled in development to allow Browser Link/Hot Reload script injection) app.UseResponseCompression(); } // Security headers to prevent clickjacking, MIME sniffing, and control referrer/permissions app.Use(async (context, next) => { context.Response.Headers.Append("X-Frame-Options", "DENY"); context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); context.Response.Headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); if (IsPrivateOrUtilityPath(context.Request.Path)) { context.Response.Headers.Append("X-Robots-Tag", "noindex, nofollow, noarchive"); } await next(); }); static bool IsPrivateOrUtilityPath(PathString path) { return path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/login", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/tire-storage", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/tire-checkin", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/tire-checkout", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/customers", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/storage-locations", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/users", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/status", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/weather", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/_blazor", StringComparison.OrdinalIgnoreCase); } // Static files with caching headers for performance app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => { // Cache static assets for 1 year (immutable) - production only if (!app.Environment.IsDevelopment()) { if (ctx.File.Name.EndsWith(".css") || ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".woff2") || ctx.File.Name.EndsWith(".woff") || ctx.File.Name.EndsWith(".png") || ctx.File.Name.EndsWith(".jpg") || ctx.File.Name.EndsWith(".svg")) { ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=31536000, immutable"); } else if (ctx.File.Name.EndsWith(".html") || ctx.File.Name.EndsWith(".xml") || ctx.File.Name.EndsWith(".txt")) { ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=3600"); } } } }); app.UseAntiforgery(); // Initialize database on startup using (var scope = app.Services.CreateScope()) { var initializer = scope.ServiceProvider.GetRequiredService(); await initializer.InitializeAsync(); // Apply DB-stored circuit retention (overrides config default). // Effective on next restart after saving in the Status page. try { var settingsService = scope.ServiceProvider.GetRequiredService(); var sessionSettings = await settingsService.GetSessionSettingsAsync(); var effectiveHours = sessionSettings.GetSafeCircuitRetentionHours(); var circuitOptions = app.Services.GetRequiredService>().Value; circuitOptions.DisconnectedCircuitRetentionPeriod = TimeSpan.FromHours(effectiveHours); } catch { // Non-fatal — use compile-time default } } // Session settings endpoint — consumed by app-interop.js to configure // the idle-lock timeout in the browser without needing a JS-interop call. app.MapGet("/api/session-settings", async (ISiteSettingsService settingsService) => { var s = await settingsService.GetSessionSettingsAsync(); return Results.Json(new { idleTimeoutMinutes = s.GetSafeIdleTimeoutMinutes(), circuitRetentionHours = s.GetSafeCircuitRetentionHours() }); }); // Catalog image endpoint (serves images from NFS/file storage) app.MapGet("/api/catalog-image/{*relativePath}", async (string relativePath, IFileStorageService fileStorage) => { if (string.IsNullOrWhiteSpace(relativePath)) return Results.NotFound(); var fileBytes = await fileStorage.ReadFileAsync(relativePath); if (fileBytes == null) return Results.NotFound(); var extension = Path.GetExtension(relativePath).ToLowerInvariant(); var contentType = extension switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "application/octet-stream" }; return Results.File(fileBytes, contentType); }); app.MapRazorComponents() .AddInteractiveServerRenderMode(); app.Run();