Files
ReifenSpaeth/Lagerbestand/Program.cs
T

282 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>();
// 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>();
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();