Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ public async Task<PasskeyRegistrationResult> VerifyRegistrationAsync(int userId,
!clientData.Origin.StartsWith("http://localhost"))
{
_logger.LogWarning("WebAuthn registration failed: Origin mismatch. Expected {Expected}, got {Actual}", Origin, clientData.Origin);
return new PasskeyRegistrationResult { Success = false, Error = "Origin stimmt nicht überein" };
}

// Verify type
Expand Down Expand Up @@ -408,7 +409,8 @@ public async Task<PasskeyAuthenticationResult> VerifyAuthenticationAsync(string
// Verify signature counter (replay protection)
if (authData.SignatureCounter <= passkey.SignatureCounter && passkey.SignatureCounter > 0)
{
_logger.LogWarning("WebAuthn authentication: Signature counter not incremented. Possible cloned authenticator!");
_logger.LogWarning("WebAuthn authentication failed: Signature counter not incremented for user {UserId}. Possible cloned authenticator!", passkey.UserId);
return new PasskeyAuthenticationResult { Success = false, Error = "Signaturzähler ungültig" };
}

// Verify signature
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,14 +535,18 @@ public async Task CleanupOldBackupsAsync(int retentionDays, CancellationToken ca

context.BackupHistory.RemoveRange(oldDaily);

// Weekly/monthly backups should survive further into the past than daily ones -
// AddDays needs a NEGATIVE offset here to push the cutoff further back in time
// (a positive offset moved it forward, making weekly stricter than daily and
// monthly land in the future, so every monthly backup was deleted unconditionally).
var oldWeekly = await context.BackupHistory
.Where(h => h.RetentionType == BackupRetentionType.Weekly && h.BackupDate < cutoffDate.AddDays(28))
.Where(h => h.RetentionType == BackupRetentionType.Weekly && h.BackupDate < cutoffDate.AddDays(-28))
.ToListAsync(cancellationToken);

context.BackupHistory.RemoveRange(oldWeekly);

var oldMonthly = await context.BackupHistory
.Where(h => h.RetentionType == BackupRetentionType.Monthly && h.BackupDate < cutoffDate.AddDays(365))
.Where(h => h.RetentionType == BackupRetentionType.Monthly && h.BackupDate < cutoffDate.AddDays(-365))
.ToListAsync(cancellationToken);

context.BackupHistory.RemoveRange(oldMonthly);
Expand Down Expand Up @@ -645,16 +649,33 @@ public async Task CleanupBackupsByProviderSettingsAsync(CancellationToken cancel
LogCleanupStarting(_logger);

var now = DateTime.UtcNow;
var dailyCutoff = now.AddDays(-settings.RetentionDays);

int totalDeleted = 0;
totalDeleted += await CleanupBackupsOlderThanAsync(context, BackupRetentionType.Daily, dailyCutoff, cancellationToken);
// Same "further into the past" reasoning as CleanupOldBackupsAsync: weekly/monthly
// backups get an additional grace period on top of the daily retention window.
totalDeleted += await CleanupBackupsOlderThanAsync(context, BackupRetentionType.Weekly, dailyCutoff.AddDays(-28), cancellationToken);
totalDeleted += await CleanupBackupsOlderThanAsync(context, BackupRetentionType.Monthly, dailyCutoff.AddDays(-365), cancellationToken);

// Daily backups
var dailyCutoff = now.AddDays(-settings.RetentionDays);
var oldDaily = await context.BackupHistory
await context.SaveChangesAsync(cancellationToken);

LogCleanupComplete(_logger, totalDeleted);
}

private async Task<int> CleanupBackupsOlderThanAsync(
InventoryDbContext context,
BackupRetentionType retentionType,
DateTime cutoff,
CancellationToken cancellationToken)
{
var candidates = await context.BackupHistory
.Include(h => h.BackupProvider)
.Where(h => h.RetentionType == BackupRetentionType.Daily && h.BackupDate < dailyCutoff)
.Where(h => h.RetentionType == retentionType && h.BackupDate < cutoff)
.ToListAsync(cancellationToken);

foreach (var backup in oldDaily)
var deletedCount = 0;
foreach (var backup in candidates)
{
try
{
Expand All @@ -666,21 +687,22 @@ public async Task CleanupBackupsByProviderSettingsAsync(CancellationToken cancel

var uploader = _providerFactory.GetUploader(backup.BackupProvider.Type);
await uploader.DeleteAsync(backup);
totalDeleted++;

// Only remove the DB row once the remote side is confirmed handled (deleted,
// or DeleteAsync returned false because it was already gone - either way not
// an exception). If DeleteAsync throws, leave the row in place so the app
// doesn't "forget" a backup that may still exist at the provider; it's picked
// up again on the next cleanup run.
context.BackupHistory.Remove(backup);
deletedCount++;
}
catch (Exception ex)
{
LogBackupDeleteError(_logger, ex, backup.Id);
}
}

context.BackupHistory.RemoveRange(oldDaily);

// Weekly and monthly handled analogously...

await context.SaveChangesAsync(cancellationToken);

LogCleanupComplete(_logger, totalDeleted);
return deletedCount;
}

private async Task SendBackupNotificationAsync(BackupResult result, BackupSettings settings, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LagersystemLVHome.Application.Configuration;
Expand Down Expand Up @@ -74,7 +75,21 @@ public async Task<RestoreValidationResult> ValidateBackupAsync(Stream backupStre
// 1. Verify ZIP structure
if (!await IsValidZipAsync(backupStream))
{
result.ErrorMessage = "Keine gueltige ZIP-Datei";
// Not a ZIP - this is exactly the shape BackupManagementService.EncryptBackupAsync
// produces (a 16-byte IV followed by opaque AES ciphertext; the plaintext ZIP only
// exists again after decryption). Treat it as an encrypted-backup candidate instead
// of rejecting outright; a wrong password or genuinely corrupt upload still surfaces
// as a clear error once decryption is attempted.
backupStream.Position = 0;
if (backupStream.Length <= 16)
{
result.ErrorMessage = "Keine gueltige ZIP-Datei";
return result;
}

result.IsEncrypted = true;
result.RequiresPassword = true;
result.IsValid = true;
return result;
}

Expand Down Expand Up @@ -132,8 +147,8 @@ public async Task<bool> IsBackupEncryptedAsync(Stream backupStream, Cancellation
return true;
}

// 2. Check for backup_metadata.json (normal backups have this)
var metadataEntry = archive.GetEntry("backup_metadata.json");
// 2. Check for metadata.json (normal backups have this - see JsonBackupHelper)
var metadataEntry = archive.GetEntry("metadata.json");
if (metadataEntry != null)
{
return false;
Expand Down Expand Up @@ -468,13 +483,37 @@ private async Task DecryptAndExtractAsync(Stream encryptedStream, string targetD

try
{
// Decryption delegated to EncryptionService when backup encryption is enabled
using (var fileStream = File.Create(tempZip))
// Mirrors BackupManagementService.EncryptBackupAsync's exact on-disk format: a
// 16-byte random IV followed by AES-CBC ciphertext, key derived from the password
// the same way (SHA256). leaveOpen on the CryptoStream since encryptedStream is
// owned by the caller.
var iv = new byte[16];
var ivBytesRead = await encryptedStream.ReadAsync(iv.AsMemory(0, 16), cancellationToken);
if (ivBytesRead != 16)
{
await encryptedStream.CopyToAsync(fileStream);
throw new InvalidOperationException("Verschluesselte Datei ist zu kurz, um einen gueltigen IV zu enthalten.");
}

ZipFile.ExtractToDirectory(tempZip, targetDir);
try
{
using var aes = Aes.Create();
aes.Key = DeriveKeyFromPassword(password);
aes.IV = iv;

await using (var fileStream = File.Create(tempZip))
await using (var cryptoStream = new CryptoStream(encryptedStream, aes.CreateDecryptor(), CryptoStreamMode.Read, leaveOpen: true))
{
await cryptoStream.CopyToAsync(fileStream, cancellationToken);
}

ZipFile.ExtractToDirectory(tempZip, targetDir);
}
catch (Exception ex) when (ex is CryptographicException or InvalidDataException)
{
// A wrong password decrypts to garbage: CryptoStream's PKCS7 unpadding rejects it
// (CryptographicException), or the "decrypted" bytes simply aren't a ZIP (InvalidDataException).
throw new InvalidOperationException("Entschluesselung fehlgeschlagen - falsches Passwort?", ex);
}
}
finally
{
Expand All @@ -485,6 +524,14 @@ private async Task DecryptAndExtractAsync(Stream encryptedStream, string targetD
}
}

// Must stay byte-for-byte identical to BackupManagementService.DeriveKeyFromPassword -
// this is the decrypt half of the same encrypt/decrypt pair.
private static byte[] DeriveKeyFromPassword(string password)
{
using var sha256 = SHA256.Create();
return sha256.ComputeHash(Encoding.UTF8.GetBytes(password));
}

private async Task ReplaceDatabaseAsync(string backupDirectory, CancellationToken cancellationToken)
{
_logger.LogInformation("Starting JSON-based database restore for {Provider}...", _databaseSettings.Provider);
Expand All @@ -500,66 +547,12 @@ private async Task ReplaceDatabaseAsync(string backupDirectory, CancellationToke
_logger.LogInformation("JSON restore completed successfully for {Provider}", _databaseSettings.Provider);
}

private async Task ReplaceSQLiteDatabaseAsync(string backupDbFile, CancellationToken cancellationToken = default)
{
var currentDbPath = GetDatabasePath();

await DisconnectAllClientsAsync();
await Task.Delay(500);

var oldDbPath = $"{currentDbPath}.old";
if (File.Exists(currentDbPath))
{
File.Move(currentDbPath, oldDbPath, true);
}

File.Copy(backupDbFile, currentDbPath, true);

if (!await ValidateDatabaseIntegrityAsync(currentDbPath))
{
File.Delete(currentDbPath);
if (File.Exists(oldDbPath))
{
File.Move(oldDbPath, currentDbPath, true);
}
throw new InvalidOperationException("Database validation failed - rollback performed");
}

if (File.Exists(oldDbPath))
{
File.Delete(oldDbPath);
}
}

private async Task DisconnectAllClientsAsync(CancellationToken cancellationToken = default)
{
GC.Collect();
GC.WaitForPendingFinalizers();
await Task.Delay(100);
}

private async Task ReInitializeDatabaseConnectionAsync(CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
await context.Database.CanConnectAsync();
}

private async Task<bool> ValidateDatabaseIntegrityAsync(string dbPath, CancellationToken cancellationToken = default)
{
try
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
await context.Database.CanConnectAsync();

var userCount = await context.Users.CountAsync(cancellationToken);
return userCount >= 0;
}
catch
{
return false;
}
}

private async Task<int> CountTablesAsync(CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ public async Task<DatabaseHealthReport> GetHealthReportAsync(CancellationToken c
// 5. Query performance
report.AverageQueryTimeMs = await GetAverageQueryTimeAsync(context);

// 5b. Last successful backup
report.LastBackup = await GetLastBackupDateAsync(context, cancellationToken);

// 6. Calculate health score
report.HealthScore = CalculateHealthScore(report, tableStats);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,10 @@ public async Task<PerformanceMetric> GetCurrentPerformanceAsync(CancellationToke
{
CpuUsagePercent = GetCpuUsage(),
MemoryUsedMB = process.WorkingSet64 / 1024 / 1024,
MemoryTotalMB = long.MinValue,
// TotalAvailableMemoryBytes reflects the container's cgroup memory limit when
// running in Docker (as this app typically does), which is more meaningful here
// than raw host physical memory.
MemoryTotalMB = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1024 / 1024,
ActiveUsers = await GetActiveUserCountAsync(context),
TotalRequests = await context.ApiRequests
.Where(r => r.Timestamp >= DateTime.UtcNow.AddHours(-1))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,9 @@ public async Task<byte[]> ExportStorageLocationsToExcelAsync(int warehouseId, Ca
try
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
// Room is a plain string column on StorageLocation, not a navigation property -
// no Include needed, EF loads it with the entity automatically.
var locations = await context.StorageLocations
.Include(sl => sl.Room)
.Where(sl => sl.WarehouseId == warehouseId)
.OrderBy(sl => sl.Code)
.ToListAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public class CategoryPredictionInput

[LoadColumn(3)]
public string? Manufacturer { get; set; }

// The trained pipeline's first step (MapValueToKey("Label")) requires a "Label"
// input column to exist in whatever schema CreatePredictionEngine is built against -
// even though a real caller predicting a category obviously doesn't know it yet.
// Left null/unset at prediction time; MapValueToKey maps unseen/missing values to
// the "NA" key without throwing, and the actual prediction comes from the model's own
// "PredictedLabel" output column, never from this field.
public string? Label { get; set; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,10 @@ public async Task<List<string>> FindSimilarProductsAsync(string productName, int

var words = ExtractKeywords(productName);

// words are already lowercased by ExtractKeywords - match p.Name case-insensitively
// too, or a product name like "Batterie" would never match the keyword "batterie".
var products = await context.Products
.Where(p => words.Any(w => p.Name.Contains(w)))
.Where(p => words.Any(w => p.Name.ToLower().Contains(w)))
.Select(p => p.Name)
.Take(limit)
.ToListAsync(cancellationToken);
Expand All @@ -339,27 +341,10 @@ private void LoadModelIfExists()
{
if (File.Exists(_modelPath))
{
try
{
_trainedModel = _mlContext.Model.Load(_modelPath, out var modelSchema);
_predictionEngine = _mlContext.Model
.CreatePredictionEngine<CategoryPredictionInput, CategoryPredictionOutput>(_trainedModel);
_logger.LogInformation("Loaded existing category prediction model");
}
catch (ArgumentOutOfRangeException ex) when (ex.Message.Contains("Label"))
{
_logger.LogWarning(
"Old model format detected (with Label). Deleting old model - please retrain.");
try
{
File.Delete(_modelPath);
_logger.LogInformation("Deleted old model file: {ModelPath}", _modelPath);
}
catch (Exception deleteEx)
{
_logger.LogWarning(deleteEx, "Could not delete old model file");
}
}
_trainedModel = _mlContext.Model.Load(_modelPath, out var modelSchema);
_predictionEngine = _mlContext.Model
.CreatePredictionEngine<CategoryPredictionInput, CategoryPredictionOutput>(_trainedModel);
_logger.LogInformation("Loaded existing category prediction model");
}
}
catch (Exception ex)
Expand Down
Loading
Loading