Vollständige Implementierung des EngineeringSync-Middleware-Tools: - Windows Service (Kestrel :5050) mit FileSystemWatcher + SignalR - WPF Tray-App mit PendingChanges- und Projektverwaltungs-Fenster - Setup-Wizard (8-Schritte-Installer) - SQLite/EF Core Datenschicht (WAL-Modus) - SHA-256-basiertes Debouncing (2s Fenster) - Backup-System mit konfigurierbarer Aufbewahrung Bugfixes & Verbesserungen: - BUG-1: AppDbContext OnConfiguring invertierte Bedingung behoben - BUG-2: Event-Handler-Leak in TrayApp (Fenster-Singleton-Pattern) - BUG-3: ProjectConfigChanged SignalR-Signal in allen CRUD-Endpoints - BUG-5: Rename-Sync löscht alte Datei im Simulations-Ordner - BUG-6: Doppeltes Dispose von SignalR verhindert - BUG-7: Registry-Deinstallation nur EngineeringSync-Eintrag entfernt - S1: Path-Traversal-Schutz via SafeCombine() im SyncManager - E1: FSW Buffer 64KB + automatischer Re-Scan bei Overflow - E2: Retry-Logik (3x) für gesperrte Dateien mit exponentiellem Backoff - E4: Channel.Writer.TryComplete() beim Shutdown - C2: HubMethodNames-Konstanten statt Magic Strings - E3: Pagination in Changes-API (page/pageSize Query-Parameter) - A1: Fire-and-Forget mit try/catch + Logging Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
5.0 KiB
C#
144 lines
5.0 KiB
C#
using EngineeringSync.Domain.Entities;
|
|
using EngineeringSync.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace EngineeringSync.Service.Services;
|
|
|
|
public class SyncManager(IDbContextFactory<AppDbContext> dbFactory, ILogger<SyncManager> logger)
|
|
{
|
|
public async Task<SyncResult> SyncAsync(IEnumerable<Guid> changeIds, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
var ids = changeIds.ToList();
|
|
var changes = await db.PendingChanges
|
|
.Include(c => c.Project)
|
|
.Where(c => ids.Contains(c.Id) && c.Status == ChangeStatus.Pending)
|
|
.ToListAsync(ct);
|
|
|
|
int success = 0, failed = 0;
|
|
foreach (var change in changes)
|
|
{
|
|
try
|
|
{
|
|
await ProcessChangeAsync(change, ct);
|
|
change.Status = ChangeStatus.Synced;
|
|
change.SyncedAt = DateTime.UtcNow;
|
|
success++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Fehler beim Sync von {Path}", change.RelativePath);
|
|
failed++;
|
|
}
|
|
}
|
|
await db.SaveChangesAsync(ct);
|
|
return new SyncResult(success, failed);
|
|
}
|
|
|
|
public async Task IgnoreAsync(IEnumerable<Guid> changeIds, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
var ids = changeIds.ToList();
|
|
await db.PendingChanges
|
|
.Where(c => ids.Contains(c.Id) && c.Status == ChangeStatus.Pending)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(c => c.Status, ChangeStatus.Ignored), ct);
|
|
}
|
|
|
|
private async Task ProcessChangeAsync(PendingChange change, CancellationToken ct)
|
|
{
|
|
var project = change.Project;
|
|
var sourcePath = SafeCombine(project.EngineeringPath, change.RelativePath);
|
|
var targetPath = SafeCombine(project.SimulationPath, change.RelativePath);
|
|
|
|
if (change.ChangeType == ChangeType.Deleted)
|
|
{
|
|
if (File.Exists(targetPath))
|
|
{
|
|
BackupFile(targetPath, project);
|
|
File.Delete(targetPath);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (change.ChangeType == ChangeType.Renamed)
|
|
{
|
|
if (!string.IsNullOrEmpty(change.OldRelativePath))
|
|
{
|
|
var oldTargetPath = SafeCombine(project.SimulationPath, change.OldRelativePath);
|
|
if (File.Exists(oldTargetPath))
|
|
File.Delete(oldTargetPath);
|
|
}
|
|
}
|
|
|
|
if (!File.Exists(sourcePath))
|
|
throw new FileNotFoundException($"Quelldatei nicht gefunden: {sourcePath}");
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
|
|
|
if (File.Exists(targetPath))
|
|
BackupFile(targetPath, project);
|
|
|
|
await Task.Run(() => File.Copy(sourcePath, targetPath, overwrite: true), ct);
|
|
}
|
|
|
|
private void BackupFile(string targetPath, ProjectConfig project)
|
|
{
|
|
if (!project.BackupEnabled)
|
|
return;
|
|
|
|
var backupDir = project.BackupPath ?? Path.GetDirectoryName(targetPath)!;
|
|
Directory.CreateDirectory(backupDir);
|
|
|
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(targetPath);
|
|
var ext = Path.GetExtension(targetPath);
|
|
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
|
var backupPath = Path.Combine(backupDir, $"{nameWithoutExt}_{timestamp}{ext}.bak");
|
|
|
|
try
|
|
{
|
|
File.Move(targetPath, backupPath);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
File.Copy(targetPath, backupPath, overwrite: false);
|
|
File.Delete(targetPath);
|
|
}
|
|
|
|
if (project.MaxBackupsPerFile > 0)
|
|
CleanupOldBackups(backupDir, nameWithoutExt, ext, project.MaxBackupsPerFile);
|
|
}
|
|
|
|
private void CleanupOldBackups(string backupDir, string baseName, string ext, int max)
|
|
{
|
|
try
|
|
{
|
|
var pattern = $"{baseName}_*{ext}.bak";
|
|
var backups = Directory.GetFiles(backupDir, pattern)
|
|
.OrderBy(f => Path.GetFileName(f))
|
|
.ToList();
|
|
|
|
while (backups.Count > max)
|
|
{
|
|
File.Delete(backups[0]);
|
|
backups.RemoveAt(0);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Cleanup alter Backups fehlgeschlagen in {Dir}", backupDir);
|
|
}
|
|
}
|
|
|
|
private static string SafeCombine(string basePath, string relativePath)
|
|
{
|
|
var fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
|
|
var normalizedBase = Path.GetFullPath(basePath);
|
|
if (!fullPath.StartsWith(normalizedBase + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
|
|
!fullPath.Equals(normalizedBase, StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException($"Path traversal detected: {relativePath}");
|
|
return fullPath;
|
|
}
|
|
}
|
|
|
|
public record SyncResult(int Success, int Failed);
|