using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Threading; using System.Threading.Tasks; using Hangfire; using Kavita.API.Database; using Kavita.API.Services; using Kavita.API.Services.SignalR; using Kavita.Common.EnvironmentInfo; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Kavita.Services; public class BackupService( ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IEventHub eventHub) : IBackupService { private readonly IList _backupFiles = [ "appsettings.json" ]; /// /// Returns a list of all log files for Kavita /// /// If file rolling is enabled. Defaults to True. /// public IEnumerable GetLogFiles(bool rollFiles = true) { var multipleFileRegex = rollFiles ? @"\d*" : string.Empty; var fi = directoryService.FileSystem.FileInfo.New(IBackupService.LogFile); var files = rollFiles ? directoryService.GetFiles(directoryService.LogDirectory, $@"{directoryService.FileSystem.Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") : [directoryService.FileSystem.Path.Join(directoryService.LogDirectory, "kavita.log")]; return files; } /// /// Will back up 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(CancellationToken ct = default) { 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"), ct: ct); return; } await SendProgress(0F, "Started backup", ct); await SendProgress(0.1F, "Copying core files", ct); var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow:s}Z".Replace("/", "_").Replace(":", "_"); var zipPath = directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_v{BuildInfo.Version}_{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"), ct: ct); return; } var tempDirectory = Path.Join(directoryService.TempDirectory, dateString); directoryService.ExistOrCreate(tempDirectory); directoryService.ClearDirectory(tempDirectory); await SendProgress(0.1F, "Backing up database", ct); await BackupDatabaseFile(tempDirectory); await SendProgress(0.15F, "Copying config files", ct); directoryService.CopyFilesToDirectory( _backupFiles.Select(file => directoryService.FileSystem.Path.Join(directoryService.ConfigDirectory, file)), tempDirectory); // Copy any csv's as those are used for manual migrations directoryService.CopyFilesToDirectory( directoryService.GetFilesWithCertainExtensions(directoryService.ConfigDirectory, @"\.csv"), tempDirectory); await SendProgress(0.2F, "Copying logs", ct); CopyLogsToBackupDirectory(tempDirectory); await SendProgress(0.25F, "Copying cover images", ct); await CopyCoverImagesToBackupDirectory(tempDirectory); await SendProgress(0.35F, "Copying templates images", ct); CopyTemplatesToBackupDirectory(tempDirectory); await SendProgress(0.5F, "Copying bookmarks", ct); await CopyBookmarksToBackupDirectory(tempDirectory); await SendProgress(0.6F, "Copying Fonts", ct); CopyFontsToBackupDirectory(tempDirectory); await SendProgress(0.75F, "Copying themes", ct); CopyThemesToBackupDirectory(tempDirectory); await SendProgress(0.85F, "Copying favicons", ct); CopyFaviconsToBackupDirectory(tempDirectory); try { await ZipFile.CreateFromDirectoryAsync(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", ct); } private void CopyLogsToBackupDirectory(string tempDirectory) { var files = GetLogFiles(); directoryService.CopyFilesToDirectory(files, directoryService.FileSystem.Path.Join(tempDirectory, "logs")); } /// /// Creates a backup of the SQLite database using VACUUM INTO command. /// This method safely backs up the database while it's in use, without locking issues. /// /// The directory where the backup file will be created private async Task BackupDatabaseFile(string tempDirectory) { var backupPath = directoryService.FileSystem.Path.Join(tempDirectory, "kavita.db"); // Validate the backup path to prevent SQL injection // The path must not contain single quotes which could break the SQL command if (backupPath.Contains('\'')) { throw new ArgumentException("Backup path contains invalid characters", nameof(tempDirectory)); } try { // Use VACUUM INTO to create a safe backup of the database while it's running // This creates a consistent snapshot without locking the main database // Note: VACUUM INTO requires a literal path and cannot use SQL parameters #pragma warning disable EF1002 // The backup path is validated above to not contain SQL injection characters await unitOfWork.DataContext.Database.ExecuteSqlRawAsync($"VACUUM INTO '{backupPath}'"); #pragma warning restore EF1002 logger.LogDebug("Database backup created successfully at {BackupPath}", backupPath); } catch (Exception ex) { logger.LogError(ex, "Failed to create database backup using VACUUM INTO at {BackupPath}", backupPath); throw new InvalidOperationException($"Failed to create database backup at {backupPath}", ex); } } private void CopyFaviconsToBackupDirectory(string tempDirectory) { directoryService.CopyDirectoryToDirectory(directoryService.FaviconDirectory, directoryService.FileSystem.Path.Join(tempDirectory, "favicons")); } private void CopyTemplatesToBackupDirectory(string tempDirectory) { directoryService.CopyDirectoryToDirectory(directoryService.TemplateDirectory, directoryService.FileSystem.Path.Join(tempDirectory, "templates")); } 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 volumeImages = await unitOfWork.VolumeRepository.GetCoverImagesForLockedVolumesAsync(); directoryService.CopyFilesToDirectory( volumeImages.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 CopyFontsToBackupDirectory(string tempDirectory) { var outputTempDir = Path.Join(tempDirectory, "fonts"); directoryService.ExistOrCreate(outputTempDir); try { directoryService.CopyDirectoryToDirectory(directoryService.EpubFontDirectory, outputTempDir); } catch (IOException ex) { logger.LogWarning(ex, "Failed to copy fonts to backup directory '{OutputTempDir}'. Fonts will not be included in the backup.", outputTempDir); } 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, CancellationToken ct = default) { await eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.BackupDatabaseProgressEvent(progress, subtitle), ct: ct); } }