Files
EngineeringSync/EngineeringSync.Service/Services/SyncManager.cs
EngineeringSync 04ae8a0aae Initial commit: EngineeringSync v1.0.0
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>
2026-03-26 21:52:26 +01:00

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);