diff --git a/Journal.Core/Repositories/SqliteEntryFileRepository.cs b/Journal.Core/Repositories/SqliteEntryFileRepository.cs new file mode 100644 index 0000000..f6d8ae6 --- /dev/null +++ b/Journal.Core/Repositories/SqliteEntryFileRepository.cs @@ -0,0 +1,139 @@ +using Journal.Core.Services.Database; +using Journal.Core.Services.Entries; + +namespace Journal.Core.Repositories; + +public sealed class SqliteEntryFileRepository(IDatabaseSessionService session) : IEntryFileRepository +{ + private const string EntryPrefix = "db://entry/"; + private const string TemplatePrefix = "db://template/"; + private readonly IDatabaseSessionService _session = session; + + public IReadOnlyList ListMarkdownFiles(string dataDirectory) + { + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT file_name + FROM entry_documents + ORDER BY file_name; + """; + + var paths = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + if (reader.IsDBNull(0)) + continue; + + var fileName = reader.GetString(0); + paths.Add(ToCanonicalPath(fileName)); + } + + return paths; + } + + public string ReadFile(string filePath) + { + var fileName = ResolveFileName(filePath); + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT content + FROM entry_documents + WHERE file_name = @fileName; + """; + cmd.Parameters.AddWithValue("@fileName", fileName); + var result = cmd.ExecuteScalar(); + if (result is null || result is DBNull) + throw new FileNotFoundException($"Entry file not found: {fileName}"); + return Convert.ToString(result) ?? ""; + } + + public void WriteFile(string filePath, string content) + { + var fileName = ResolveFileName(filePath); + var isTemplate = EntryFileNaming.IsTemplateFileName(fileName) ? 1 : 0; + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO entry_documents (guid, file_name, content, is_template, updated_at) + VALUES (@guid, @fileName, @content, @isTemplate, @updatedAt) + ON CONFLICT(file_name) DO UPDATE SET + content = excluded.content, + is_template = excluded.is_template, + updated_at = excluded.updated_at; + """; + cmd.Parameters.AddWithValue("@guid", Guid.NewGuid().ToString("D")); + cmd.Parameters.AddWithValue("@fileName", fileName); + cmd.Parameters.AddWithValue("@content", content ?? ""); + cmd.Parameters.AddWithValue("@isTemplate", isTemplate); + cmd.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); + cmd.ExecuteNonQuery(); + } + + public void AppendFile(string filePath, string content) + { + var fileName = ResolveFileName(filePath); + var existing = FileExists(fileName) ? ReadFile(fileName) : ""; + WriteFile(fileName, existing + content); + } + + public bool FileExists(string filePath) + { + var fileName = ResolveFileName(filePath); + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT 1 + FROM entry_documents + WHERE file_name = @fileName + LIMIT 1; + """; + cmd.Parameters.AddWithValue("@fileName", fileName); + return cmd.ExecuteScalar() is not null; + } + + public string GetFullPath(string filePath) + { + var fileName = ResolveFileName(filePath); + return ToCanonicalPath(fileName); + } + + public string GetFileName(string filePath) => ResolveFileName(filePath); + + public string GetFileNameWithoutExtension(string filePath) + => Path.GetFileNameWithoutExtension(ResolveFileName(filePath)); + + public void EnsureDirectory(string path) { } + + public void DeleteFile(string filePath) + { + var fileName = ResolveFileName(filePath); + var conn = _session.GetConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM entry_documents WHERE file_name = @fileName;"; + cmd.Parameters.AddWithValue("@fileName", fileName); + cmd.ExecuteNonQuery(); + } + + private static string ResolveFileName(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return ""; + + if (input.StartsWith(EntryPrefix, StringComparison.OrdinalIgnoreCase)) + return Uri.UnescapeDataString(input[EntryPrefix.Length..]); + if (input.StartsWith(TemplatePrefix, StringComparison.OrdinalIgnoreCase)) + return Uri.UnescapeDataString(input[TemplatePrefix.Length..]); + + var fileName = Path.GetFileName(input); + return fileName ?? input.Trim(); + } + + private static string ToCanonicalPath(string fileName) + { + var prefix = EntryFileNaming.IsTemplateFileName(fileName) ? TemplatePrefix : EntryPrefix; + return prefix + Uri.EscapeDataString(fileName); + } +} diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs index 649d8a3..bc43165 100644 --- a/Journal.Core/ServiceCollectionExtensions.cs +++ b/Journal.Core/ServiceCollectionExtensions.cs @@ -58,7 +58,7 @@ public static class ServiceCollectionExtensions message: $"Python speech sidecar unavailable: {ex.Message}"); } }); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Journal.Core/Services/Database/JournalDatabaseService.cs b/Journal.Core/Services/Database/JournalDatabaseService.cs index ae0c147..a2c33dc 100644 --- a/Journal.Core/Services/Database/JournalDatabaseService.cs +++ b/Journal.Core/Services/Database/JournalDatabaseService.cs @@ -14,7 +14,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour private static readonly Lock SqliteInitLock = new(); private static bool _sqliteInitialized; private static readonly IReadOnlyList RequiredSchemaTables = - ["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items"]; + ["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents"]; private readonly IJournalConfigService _config = config; @@ -120,6 +120,16 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour sort_order INTEGER DEFAULT 0, FOREIGN KEY (list_id) REFERENCES todo_lists (id) ); + """, + ["entry_documents"] = """ + CREATE TABLE IF NOT EXISTS entry_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + file_name TEXT NOT NULL UNIQUE, + content TEXT NOT NULL, + is_template INTEGER NOT NULL DEFAULT 0, + updated_at TEXT + ); """ }; } diff --git a/Journal.Core/Services/Entries/EntrySearchService.cs b/Journal.Core/Services/Entries/EntrySearchService.cs index b1df327..b32762e 100644 --- a/Journal.Core/Services/Entries/EntrySearchService.cs +++ b/Journal.Core/Services/Entries/EntrySearchService.cs @@ -1,19 +1,23 @@ using Journal.Core.Dtos; +using Journal.Core.Repositories; using System.Globalization; +using System.Security.Cryptography; +using System.Text; namespace Journal.Core.Services.Entries; -public class EntrySearchService : IEntrySearchService +public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService { + private readonly IEntryFileRepository _repo = repo; + private readonly Lock _cacheLock = new(); + private readonly Dictionary _entryCache = new(StringComparer.Ordinal); + public Task> SearchEntriesAsync(EntrySearchRequestDto request) { ArgumentNullException.ThrowIfNull(request); if (string.IsNullOrWhiteSpace(request.DataDirectory)) throw new ArgumentException("Data directory is required.", nameof(request.DataDirectory)); - if (!Directory.Exists(request.DataDirectory)) - return Task.FromResult>([]); - var hasQuery = !string.IsNullOrWhiteSpace(request.Query); var query = request.Query?.Trim() ?? ""; var hasSectionFilter = !string.IsNullOrWhiteSpace(request.Section); @@ -31,17 +35,20 @@ public class EntrySearchService : IEntrySearchService if (startDate.HasValue && endDate.HasValue && startDate.Value > endDate.Value) throw new ArgumentException("startDate cannot be after endDate."); + var currentFiles = _repo.ListMarkdownFiles(request.DataDirectory) + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + .ToArray(); + + var currentFileSet = new HashSet(currentFiles, StringComparer.Ordinal); var results = new List(); - foreach (var filePath in Directory.GetFiles(request.DataDirectory, "*.md") - .OrderBy(Path.GetFileName, StringComparer.Ordinal)) + foreach (var filePath in currentFiles) { - var fileName = Path.GetFileName(filePath); + var fileName = _repo.GetFileName(filePath); if (EntryFileNaming.IsTemplateFileName(fileName)) continue; - var fileStem = Path.GetFileNameWithoutExtension(filePath); - var rawContent = File.ReadAllText(filePath); - var entry = JournalParser.ParseJournalContent(rawContent, fileStem); + var cached = GetOrBuildCachedEntry(filePath); + var entry = cached.Result.Entry; if (startDate.HasValue || endDate.HasValue) { @@ -57,7 +64,7 @@ public class EntrySearchService : IEntrySearchService var contentMatch = true; if (hasQuery) { - var haystack = hasSectionFilter ? entry.GetSection(section) : entry.RawContent; + var haystack = hasSectionFilter ? GetSection(entry, section) : entry.RawContent; contentMatch = haystack.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; } if (!contentMatch) @@ -76,12 +83,103 @@ public class EntrySearchService : IEntrySearchService if (!checkboxMatch) continue; - results.Add(new EntrySearchResultDto(fileName, entry.ToDto())); + results.Add(cached.Result); } + RemoveStaleCacheEntries(currentFileSet); + return Task.FromResult>(results); } + private CachedEntry GetOrBuildCachedEntry(string filePath) + { + var diskSignature = TryGetDiskFileSignature(filePath); + + if (diskSignature is not null) + { + lock (_cacheLock) + { + if (_entryCache.TryGetValue(filePath, out var cached) && + cached.Signature == diskSignature.Value) + { + return cached; + } + } + } + + var fileName = _repo.GetFileName(filePath); + var fileStem = _repo.GetFileNameWithoutExtension(filePath); + var rawContent = _repo.ReadFile(filePath); + var signature = diskSignature ?? BuildContentSignature(rawContent); + + lock (_cacheLock) + { + if (_entryCache.TryGetValue(filePath, out var cached) && + cached.Signature == signature) + { + return cached; + } + } + + var entry = JournalParser.ParseJournalContent(rawContent, fileStem).ToDto(); + var built = new CachedEntry(signature, new EntrySearchResultDto(fileName, entry)); + + lock (_cacheLock) + { + _entryCache[filePath] = built; + } + + return built; + } + + private static FileSignature? TryGetDiskFileSignature(string filePath) + { + if (filePath.StartsWith("db://", StringComparison.OrdinalIgnoreCase)) + return null; + if (!File.Exists(filePath)) + return null; + + var info = new FileInfo(filePath); + return new FileSignature(info.Length, info.LastWriteTimeUtc.Ticks, null); + } + + private static FileSignature BuildContentSignature(string content) + { + var bytes = Encoding.UTF8.GetBytes(content ?? ""); + var hash = Convert.ToHexString(SHA256.HashData(bytes)); + return new FileSignature(bytes.Length, 0, hash); + } + + private void RemoveStaleCacheEntries(HashSet currentFileSet) + { + lock (_cacheLock) + { + if (_entryCache.Count == 0) + return; + + var staleKeys = _entryCache.Keys + .Where(path => !currentFileSet.Contains(path)) + .ToArray(); + + foreach (var key in staleKeys) + _entryCache.Remove(key); + } + } + + private static string GetSection(JournalEntryDto entry, string sectionTitle) + { + if (string.IsNullOrWhiteSpace(sectionTitle)) + return ""; + + foreach (var (key, value) in entry.Sections) + { + if (string.Equals(key, sectionTitle, StringComparison.OrdinalIgnoreCase)) + return string.Join("\n", value.Content); + } + + return ""; + } + private static HashSet NormalizeSet(IReadOnlyList? values) { if (values is null || values.Count == 0) @@ -108,4 +206,7 @@ public class EntrySearchService : IEntrySearchService throw new ArgumentException($"Invalid {argumentName} value. Expected yyyy-MM-dd."); } + + private readonly record struct FileSignature(long Length, long LastWriteUtcTicks, string? ContentHash); + private sealed record CachedEntry(FileSignature Signature, EntrySearchResultDto Result); } diff --git a/Journal.Core/Services/Vault/VaultStorageService.cs b/Journal.Core/Services/Vault/VaultStorageService.cs index 649663c..36a091f 100644 --- a/Journal.Core/Services/Vault/VaultStorageService.cs +++ b/Journal.Core/Services/Vault/VaultStorageService.cs @@ -1,17 +1,16 @@ using System.Diagnostics; -using System.IO.Compression; -using System.Globalization; using System.Security.Cryptography; -using System.Text; namespace Journal.Core.Services.Vault; public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService { private readonly IVaultCryptoService _crypto = crypto; - private readonly Dictionary _monthFingerprintCache = new(StringComparer.Ordinal); private readonly object _vaultIoLock = new(); + private const string DatabaseVaultPrefix = "_db_"; + private const string DatabaseVaultSuffix = ".vault"; + public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault"; public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory) @@ -20,64 +19,11 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ lock (_vaultIoLock) { - _monthFingerprintCache.Clear(); - PrepareDataDirectory(dataDirectory); - + Directory.CreateDirectory(dataDirectory); if (!Directory.Exists(vaultDirectory)) return true; - var vaultFiles = Directory.GetFiles(vaultDirectory, "*.vault") - .OrderBy(Path.GetFileName, StringComparer.Ordinal) - .ToArray(); - if (vaultFiles.Length == 0) - return true; - - // Restore database vault files first - RestoreDatabaseVaults(password, vaultDirectory, dataDirectory); - - var anyDecrypted = false; - var anyVaultFiles = false; - foreach (var vaultFile in vaultFiles) - { - var fileName = Path.GetFileName(vaultFile); - if (string.Equals(fileName, "_init_vault.vault", StringComparison.OrdinalIgnoreCase)) - { - try - { - File.Delete(vaultFile); - } - catch (Exception ex) - { - Debug.WriteLine($"[VaultStorageService] Failed to delete legacy vault file {fileName}: {ex.Message}"); - } - continue; - } - - if (IsReservedVaultFile(fileName)) - continue; - - anyVaultFiles = true; - try - { - var encrypted = File.ReadAllBytes(vaultFile); - var decryptedZip = _crypto.DecryptData(encrypted, password); - ExtractZipContent(decryptedZip, dataDirectory); - anyDecrypted = true; - } - catch (CryptographicException) - { - Debug.WriteLine($"[VaultStorageService] Decryption failed for {fileName} (likely wrong password)"); - } - catch (Exception ex) - { - Debug.WriteLine($"[VaultStorageService] Failed to load vault {fileName}: {ex.GetType().Name} - {ex.Message}"); - } - } - - if (!anyDecrypted && anyVaultFiles) - return false; - - return true; + return RestoreDatabaseVaults(password, vaultDirectory, dataDirectory); } } @@ -91,28 +37,8 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ if (!Directory.Exists(dataDirectory)) return false; - var monthKey = now.ToString("yyyy-MM", CultureInfo.InvariantCulture); - var filesInMonth = Directory.GetFiles(dataDirectory, "*.md") - .Where(path => Path.GetFileNameWithoutExtension(path).StartsWith(monthKey, StringComparison.Ordinal)) - .OrderBy(Path.GetFileName, StringComparer.Ordinal) - .ToList(); - - var savedMonth = false; - if (filesInMonth.Count > 0) - { - var currentFingerprint = ComputeMonthFingerprint(filesInMonth); - if (!_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) || - !string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal)) - { - SaveMonth(password, monthKey, filesInMonth, vaultDirectory); - savedMonth = true; - } - } - - // Also persist custom-named entries alongside the current month vault - SaveCustomEntries(password, vaultDirectory, dataDirectory); - - return savedMonth; + SaveDatabaseVaults(password, vaultDirectory, dataDirectory); + return true; } } @@ -126,27 +52,6 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ if (!Directory.Exists(dataDirectory)) return; - var monthlyFiles = new Dictionary>(StringComparer.Ordinal); - foreach (var filePath in Directory.GetFiles(dataDirectory, "*.md")) - { - var stem = Path.GetFileNameWithoutExtension(filePath); - if (!DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fileDate)) - continue; - - var monthKey = fileDate.ToString("yyyy-MM", CultureInfo.InvariantCulture); - if (!monthlyFiles.TryGetValue(monthKey, out var files)) - { - files = []; - monthlyFiles[monthKey] = files; - } - - files.Add(filePath); - } - - foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) - SaveMonth(password, monthKey, filesInMonth, vaultDirectory); - - SaveCustomEntries(password, vaultDirectory, dataDirectory); SaveDatabaseVaults(password, vaultDirectory, dataDirectory); } } @@ -158,15 +63,76 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ lock (_vaultIoLock) { - PrepareDataDirectory(dataDirectory); - _monthFingerprintCache.Clear(); + DeleteDirectoryWithRetries(dataDirectory); + Directory.CreateDirectory(dataDirectory); } } - private static void PrepareDataDirectory(string dataDirectory) + private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory) { - DeleteDirectoryWithRetries(dataDirectory); - Directory.CreateDirectory(dataDirectory); + var dbFiles = Directory.GetFiles(dataDirectory, "*.db"); + foreach (var dbPath in dbFiles) + { + try + { + var dbFileName = Path.GetFileName(dbPath); + var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}"; + var vaultPath = Path.Combine(vaultDirectory, vaultFileName); + + var dbBytes = File.ReadAllBytes(dbPath); + var encrypted = _crypto.EncryptData(dbBytes, password); + File.WriteAllBytes(vaultPath, encrypted); + } + catch (Exception ex) + { + Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}"); + } + } + } + + private bool RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory) + { + var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}"); + if (dbVaultFiles.Length == 0) + return true; + + var anyRestored = false; + foreach (var vaultFile in dbVaultFiles) + { + try + { + var vaultFileName = Path.GetFileName(vaultFile); + var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length]; + if (string.IsNullOrWhiteSpace(dbFileName)) + continue; + + var encrypted = File.ReadAllBytes(vaultFile); + var dbBytes = _crypto.DecryptData(encrypted, password); + var targetPath = Path.Combine(dataDirectory, dbFileName); + File.WriteAllBytes(targetPath, dbBytes); + anyRestored = true; + } + catch (CryptographicException) + { + Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)"); + } + catch (Exception ex) + { + Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}"); + } + } + + return anyRestored; + } + + private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password cannot be empty.", nameof(password)); + if (string.IsNullOrWhiteSpace(vaultDirectory)) + throw new ArgumentException("Vault directory is required.", nameof(vaultDirectory)); + if (string.IsNullOrWhiteSpace(dataDirectory)) + throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); } private static void DeleteDirectoryWithRetries(string dataDirectory, int retries = 5, int delayMs = 200) @@ -193,194 +159,4 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ Directory.Delete(dataDirectory, recursive: true); } - - // ── Custom entries vault helpers ────────────────────────────── - - private const string CustomEntriesVaultFileName = "_custom_entries.vault"; - - private List GetCustomEntryFiles(string dataDirectory) - { - return Directory.GetFiles(dataDirectory, "*.md") - .Where(path => - { - var stem = Path.GetFileNameWithoutExtension(path); - return !DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); - }) - .OrderBy(Path.GetFileName, StringComparer.Ordinal) - .ToList(); - } - - private void SaveCustomEntries(string password, string vaultDirectory, string dataDirectory) - { - var customFiles = GetCustomEntryFiles(dataDirectory); - var vaultPath = Path.Combine(vaultDirectory, CustomEntriesVaultFileName); - - if (customFiles.Count == 0) - { - // Remove stale custom vault if no custom entries remain - if (File.Exists(vaultPath)) - File.Delete(vaultPath); - return; - } - - var zipBytes = CreateMonthlyArchive(customFiles); - var encrypted = _crypto.EncryptData(zipBytes, password); - File.WriteAllBytes(vaultPath, encrypted); - } - - private static bool IsCustomEntriesVaultFile(string fileName) - => string.Equals(fileName, CustomEntriesVaultFileName, StringComparison.OrdinalIgnoreCase); - - // ── Database vault helpers ───────────────────────────────────── - - private const string DatabaseVaultPrefix = "_db_"; - private const string DatabaseVaultSuffix = ".vault"; - - private static bool IsReservedVaultFile(string fileName) - => fileName.StartsWith(DatabaseVaultPrefix, StringComparison.OrdinalIgnoreCase); - - private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory) - { - var dbFiles = Directory.GetFiles(dataDirectory, "*.db"); - foreach (var dbPath in dbFiles) - { - try - { - var dbFileName = Path.GetFileName(dbPath); - var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}"; - var vaultPath = Path.Combine(vaultDirectory, vaultFileName); - - var dbBytes = File.ReadAllBytes(dbPath); - var encrypted = _crypto.EncryptData(dbBytes, password); - File.WriteAllBytes(vaultPath, encrypted); - - Debug.WriteLine($"[VaultStorageService] Saved database vault: {vaultFileName}"); - } - catch (Exception ex) - { - Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}"); - } - } - } - - private void RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory) - { - var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}"); - foreach (var vaultFile in dbVaultFiles) - { - try - { - var vaultFileName = Path.GetFileName(vaultFile); - // Strip prefix and suffix to get original DB filename - var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length]; - if (string.IsNullOrWhiteSpace(dbFileName)) - continue; - - var encrypted = File.ReadAllBytes(vaultFile); - var dbBytes = _crypto.DecryptData(encrypted, password); - var targetPath = Path.Combine(dataDirectory, dbFileName); - File.WriteAllBytes(targetPath, dbBytes); - - Debug.WriteLine($"[VaultStorageService] Restored database from vault: {vaultFileName} → {dbFileName}"); - } - catch (CryptographicException) - { - Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)"); - } - catch (Exception ex) - { - Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}"); - } - } - } - - private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory) - { - if (string.IsNullOrWhiteSpace(password)) - throw new ArgumentException("Password cannot be empty.", nameof(password)); - if (string.IsNullOrWhiteSpace(vaultDirectory)) - throw new ArgumentException("Vault directory is required.", nameof(vaultDirectory)); - if (string.IsNullOrWhiteSpace(dataDirectory)) - throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); - } - - private void SaveMonth(string password, string monthKey, List filesInMonth, string vaultDirectory) - { - var monthDate = DateTime.ParseExact(monthKey, "yyyy-MM", CultureInfo.InvariantCulture); - var monthlyVaultPath = Path.Combine(vaultDirectory, GetMonthlyVaultFileName(monthDate)); - - var zipBytes = CreateMonthlyArchive(filesInMonth); - var encryptedPayload = _crypto.EncryptData(zipBytes, password); - File.WriteAllBytes(monthlyVaultPath, encryptedPayload); - - _monthFingerprintCache[monthKey] = ComputeMonthFingerprint(filesInMonth); - } - - private static byte[] CreateMonthlyArchive(List filesInMonth) - { - using var memoryStream = new MemoryStream(); - using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) - { - foreach (var filePath in filesInMonth.OrderBy(Path.GetFileName, StringComparer.Ordinal)) - { - var fileName = Path.GetFileName(filePath); - if (string.IsNullOrWhiteSpace(fileName)) - continue; - - var entry = archive.CreateEntry(fileName, CompressionLevel.Optimal); - using var entryStream = entry.Open(); - using var sourceStream = File.OpenRead(filePath); - sourceStream.CopyTo(entryStream); - } - } - - return memoryStream.ToArray(); - } - - private static string ComputeMonthFingerprint(List files) - { - using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - - foreach (var filePath in files.OrderBy(Path.GetFileName, StringComparer.Ordinal)) - { - var fileInfo = new FileInfo(filePath); - if (!fileInfo.Exists) - continue; - - AppendUtf8(hash, fileInfo.Name); - AppendAscii(hash, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)); - AppendAscii(hash, fileInfo.Length.ToString(CultureInfo.InvariantCulture)); - } - - return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); - } - - private static void AppendUtf8(IncrementalHash hash, string value) => hash.AppendData(Encoding.UTF8.GetBytes(value)); - private static void AppendAscii(IncrementalHash hash, string value) => hash.AppendData(Encoding.ASCII.GetBytes(value)); - - private static void ExtractZipContent(byte[] zipBytes, string dataDirectory) - { - using var stream = new MemoryStream(zipBytes); - using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - - var dataRoot = Path.GetFullPath(dataDirectory); - if (!dataRoot.EndsWith(Path.DirectorySeparatorChar)) - dataRoot += Path.DirectorySeparatorChar; - - foreach (var entry in archive.Entries) - { - if (string.IsNullOrEmpty(entry.Name)) - continue; - - var destinationPath = Path.GetFullPath(Path.Combine(dataDirectory, entry.FullName)); - if (!destinationPath.StartsWith(dataRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException("Zip entry path escapes target data directory."); - - var destinationDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDir)) - Directory.CreateDirectory(destinationDir); - - entry.ExtractToFile(destinationPath, overwrite: true); - } - } }