using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Entities.Enums; using API.Extensions; using API.Logging; using API.SignalR; using Hangfire; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; public interface IBackupService { Task BackupDatabase(); /// /// Returns a list of all log files for Kavita /// /// If file rolling is enabled. Defaults to True. /// IEnumerable GetLogFiles(bool rollFiles = LogLevelOptions.LogRollingEnabled); } public class BackupService : IBackupService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IDirectoryService _directoryService; private readonly IEventHub _eventHub; private readonly IList _backupFiles; public BackupService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IEventHub eventHub) { _unitOfWork = unitOfWork; _logger = logger; _directoryService = directoryService; _eventHub = eventHub; _backupFiles = new List() { "appsettings.json", "Hangfire.db", // This is not used atm "Hangfire-log.db", // This is not used atm "kavita.db", "kavita.db-shm", // This wont always be there "kavita.db-wal" // This wont always be there }; } /// /// Returns a list of all log files for Kavita /// /// If file rolling is enabled. Defaults to True. /// public IEnumerable GetLogFiles(bool rollFiles = LogLevelOptions.LogRollingEnabled) { var multipleFileRegex = rollFiles ? @"\d*" : string.Empty; var fi = _directoryService.FileSystem.FileInfo.FromFileName(LogLevelOptions.LogFile); var files = rollFiles ? _directoryService.GetFiles(_directoryService.LogDirectory, $@"{_directoryService.FileSystem.Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") : new[] {_directoryService.FileSystem.Path.Join(_directoryService.LogDirectory, "kavita.log")}; return files; } /// /// Will backup anything that needs to be backed up. This includes logs, setting files, bare minimum cover images (just locked and first cover). /// [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] public async Task BackupDatabase() { _logger.LogInformation("Beginning backup of Database at {BackupTime}", DateTime.Now); var backupDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value; _logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory); if (!_directoryService.ExistOrCreate(backupDirectory)) { _logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent("Backup Service Error",$"Could not write to {backupDirectory}; aborting backup")); return; } await SendProgress(0F, "Started backup"); await SendProgress(0.1F, "Copying core files"); var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); if (File.Exists(zipPath)) { _logger.LogCritical("{ZipFile} already exists, aborting", zipPath); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent("Backup Service Error",$"{zipPath} already exists, aborting")); return; } var tempDirectory = Path.Join(_directoryService.TempDirectory, dateString); _directoryService.ExistOrCreate(tempDirectory); _directoryService.ClearDirectory(tempDirectory); _directoryService.CopyFilesToDirectory( _backupFiles.Select(file => _directoryService.FileSystem.Path.Join(_directoryService.ConfigDirectory, file)).ToList(), tempDirectory); CopyLogsToBackupDirectory(tempDirectory); await SendProgress(0.25F, "Copying cover images"); await CopyCoverImagesToBackupDirectory(tempDirectory); await SendProgress(0.5F, "Copying bookmarks"); await CopyBookmarksToBackupDirectory(tempDirectory); await SendProgress(0.75F, "Copying themes"); CopyThemesToBackupDirectory(tempDirectory); try { ZipFile.CreateFromDirectory(tempDirectory, zipPath); } catch (AggregateException ex) { _logger.LogError(ex, "There was an issue when archiving library backup"); } _directoryService.ClearAndDeleteDirectory(tempDirectory); _logger.LogInformation("Database backup completed"); await SendProgress(1F, "Completed backup"); } private void CopyLogsToBackupDirectory(string tempDirectory) { var files = GetLogFiles(); _directoryService.CopyFilesToDirectory(files, _directoryService.FileSystem.Path.Join(tempDirectory, "logs")); } private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) { var outputTempDir = Path.Join(tempDirectory, "covers"); _directoryService.ExistOrCreate(outputTempDir); try { var seriesImages = await _unitOfWork.SeriesRepository.GetLockedCoverImagesAsync(); _directoryService.CopyFilesToDirectory( seriesImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); var collectionTags = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync(); _directoryService.CopyFilesToDirectory( collectionTags.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); var chapterImages = await _unitOfWork.ChapterRepository.GetCoverImagesForLockedChaptersAsync(); _directoryService.CopyFilesToDirectory( chapterImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); var libraryImages = await _unitOfWork.LibraryRepository.GetAllCoverImagesAsync(); _directoryService.CopyFilesToDirectory( libraryImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); var readingListImages = await _unitOfWork.ReadingListRepository.GetAllCoverImagesAsync(); _directoryService.CopyFilesToDirectory( readingListImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); } catch (IOException) { // Swallow exception. This can be a duplicate cover being copied as chapter and volumes can share same file. } if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any()) { _directoryService.ClearAndDeleteDirectory(outputTempDir); } } private async Task CopyBookmarksToBackupDirectory(string tempDirectory) { var bookmarkDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; var outputTempDir = Path.Join(tempDirectory, "bookmarks"); _directoryService.ExistOrCreate(outputTempDir); try { _directoryService.CopyDirectoryToDirectory(bookmarkDirectory, outputTempDir); } catch (IOException) { // Swallow exception. } if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any()) { _directoryService.ClearAndDeleteDirectory(outputTempDir); } } private void CopyThemesToBackupDirectory(string tempDirectory) { var outputTempDir = Path.Join(tempDirectory, "themes"); _directoryService.ExistOrCreate(outputTempDir); try { _directoryService.CopyDirectoryToDirectory(_directoryService.SiteThemeDirectory, outputTempDir); } catch (IOException) { // Swallow exception. } if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any()) { _directoryService.ClearAndDeleteDirectory(outputTempDir); } } private async Task SendProgress(float progress, string subtitle) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.BackupDatabaseProgressEvent(progress, subtitle)); } }