diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index 6e8936d536..74d99455df 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -39,7 +39,7 @@ public class BackupService : IBackupService ReferenceHandler = ReferenceHandler.IgnoreCycles, }; - private readonly Version _backupEngineVersion = Version.Parse("0.1.0"); + private readonly Version _backupEngineVersion = Version.Parse("0.2.0"); /// /// Initializes a new instance of the class. @@ -120,26 +120,29 @@ public class BackupService : IBackupService void CopyDirectory(string source, string target) { - source = Path.GetFullPath(source); - Directory.CreateDirectory(source); - + var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar); + var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar; foreach (var item in zipArchive.Entries) { - var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar); - if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal)) + var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName)); + var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName))); + + if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal) + || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)) { continue; } - var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/')); _logger.LogInformation("Restore and override {File}", targetPath); - item.ExtractToFile(targetPath); + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + item.ExtractToFile(targetPath, overwrite: true); } } - CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/"); - CopyDirectory(_applicationPaths.DataPath, "Data/"); - CopyDirectory(_applicationPaths.RootFolderPath, "Root/"); + CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath); + CopyDirectory("Data", _applicationPaths.DataPath); + CopyDirectory("Root", _applicationPaths.RootFolderPath); if (manifest.Options.Database) { @@ -148,7 +151,7 @@ public class BackupService : IBackupService await using (dbContext.ConfigureAwait(false)) { // restore migration history manually - var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json"); + var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json"))); if (historyEntry is null) { _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation"); @@ -193,7 +196,7 @@ public class BackupService : IBackupService { _logger.LogInformation("Read backup of {Table}", entityType.Type.Name); - var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json"); + var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json"))); if (zipEntry is null) { _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); @@ -205,7 +208,7 @@ public class BackupService : IBackupService { _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name); var records = 0; - await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable(zipEntryStream, _serializerSettings).ConfigureAwait(false)!) + await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable(zipEntryStream, _serializerSettings).ConfigureAwait(false)) { var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]); if (entity is null) @@ -288,7 +291,7 @@ public class BackupService : IBackupService await using (dbContext.ConfigureAwait(false)) { dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - static IAsyncEnumerable GetValues(IQueryable dbSet, Type type) + static IAsyncEnumerable GetValues(IQueryable dbSet) { var method = dbSet.GetType().GetMethod(nameof(DbSet.AsAsyncEnumerable))!; var enumerable = method.Invoke(dbSet, null)!; @@ -303,8 +306,8 @@ public class BackupService : IBackupService .. typeof(JellyfinDbContext) .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) - .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))), - (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: new Func>(() => migrations.ToAsyncEnumerable())) + .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), + (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) ]; manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); @@ -316,7 +319,7 @@ public class BackupService : IBackupService foreach (var entityType in entityTypes) { _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); - var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.SourceName}.json"); + var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); var entities = 0; var zipEntryStream = zipEntry.Open(); await using (zipEntryStream.ConfigureAwait(false)) @@ -354,7 +357,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) { - zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item))); + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); } void CopyDirectory(string source, string target, string filter = "*") @@ -368,7 +371,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) { - zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\'))); + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); } } @@ -516,4 +519,14 @@ public class BackupService : IBackupService Database = options.Database }; } + + /// + /// Windows is able to handle '/' as a path seperator in zip files + /// but linux isn't able to handle '\' as a path seperator in zip files, + /// So normalize to '/'. + /// + /// The path to normalize. + /// The normalized path. + private static string NormalizePathSeparator(string path) + => path.Replace('\\', '/'); }