Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
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>();
// 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();