using EngineeringSync.Domain.Entities; using EngineeringSync.Infrastructure; using Microsoft.EntityFrameworkCore; namespace EngineeringSync.Service.Services; public class SyncManager(IDbContextFactory dbFactory, ILogger logger) { public async Task SyncAsync(IEnumerable 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 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);