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>
This commit is contained in:
143
EngineeringSync.Service/Services/SyncManager.cs
Normal file
143
EngineeringSync.Service/Services/SyncManager.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user