287 lines
11 KiB
C#
287 lines
11 KiB
C#
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<Microsoft.AspNetCore.Components.Server.CircuitOptions>(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<BrotliCompressionProvider>();
|
||
options.Providers.Add<GzipCompressionProvider>();
|
||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
|
||
{
|
||
"application/javascript",
|
||
"text/css",
|
||
"application/json",
|
||
"text/html",
|
||
"image/svg+xml"
|
||
});
|
||
});
|
||
|
||
builder.Services.Configure<BrotliCompressionProviderOptions>(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<AppDbContext>(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<ICatalogService, CatalogService>();
|
||
builder.Services.AddScoped<IInventoryService, InventoryService>();
|
||
builder.Services.AddScoped<ITwoFactorService, TwoFactorService>();
|
||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||
builder.Services.AddScoped<UserSessionService>();
|
||
builder.Services.AddScoped<ISiteSettingsService, SiteSettingsService>();
|
||
builder.Services.AddScoped<IFileStorageService, FileStorageService>();
|
||
builder.Services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
||
|
||
// Register tire storage management services
|
||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||
builder.Services.AddScoped<ILocationCodeService, LocationCodeService>();
|
||
builder.Services.AddScoped<IStorageLocationService, StorageLocationService>();
|
||
builder.Services.AddScoped<ITireStackService, TireStackService>();
|
||
builder.Services.AddScoped<ICustomerMergeService, CustomerMergeService>();
|
||
|
||
// Launch TTS01 tire report (QR-Code) lookup
|
||
builder.Services.AddHttpClient("LaunchReport", client => client.Timeout = TimeSpan.FromSeconds(15));
|
||
builder.Services.AddScoped<ITireReportService, TireReportService>();
|
||
|
||
// Register Kanban service
|
||
builder.Services.AddScoped<IKanbanService, KanbanService>();
|
||
|
||
// Lexware API sync service
|
||
builder.Services.AddHttpClient("LexwareApi");
|
||
builder.Services.AddScoped<ILexwareSyncService, LexwareSyncService>();
|
||
builder.Services.AddHostedService<LexwareSyncBackgroundService>();
|
||
|
||
// PDF export (QuestPDF Community license)
|
||
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community;
|
||
builder.Services.AddSingleton<IStorageRowPdfService, StorageRowPdfService>();
|
||
|
||
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<IDatabaseInitializer>();
|
||
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<ISiteSettingsService>();
|
||
var sessionSettings = await settingsService.GetSessionSettingsAsync();
|
||
var effectiveHours = sessionSettings.GetSafeCircuitRetentionHours();
|
||
var circuitOptions = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Components.Server.CircuitOptions>>().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<App>()
|
||
.AddInteractiveServerRenderMode();
|
||
|
||
app.Run();
|