Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user