using System.Security.Cryptography; using System.Text; using Journal.Core.Dtos; using Journal.Core.Services.Config; using Microsoft.Data.Sqlite; namespace Journal.Core.Services.Database; public sealed class JournalDatabaseService(IJournalConfigService config) : IJournalDatabaseService { public const int KeySize = 32; public const int Iterations = 600_000; private static readonly byte[] DatabaseKeySalt = Encoding.UTF8.GetBytes("a_fixed_salt_for_the_db_key_deriv"); private static readonly Lock SqliteInitLock = new(); private static bool _sqliteInitialized; private static readonly IReadOnlyList RequiredSchemaTables = ["entries", "sections", "fragments", "tags", "fragment_tags"]; private readonly IJournalConfigService _config = config; public string GetDatabasePath(string? dataDirectory = null) { var directory = string.IsNullOrWhiteSpace(dataDirectory) ? _config.Current.DataDirectory : dataDirectory; Directory.CreateDirectory(directory); return Path.GetFullPath(Path.Combine(directory, _config.Current.DatabaseFilename)); } public byte[] DeriveDatabaseKey(string password) { if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Password cannot be empty.", nameof(password)); return Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(password), DatabaseKeySalt, Iterations, HashAlgorithmName.SHA256, KeySize); } public string BuildPragmaKeyStatement(string password) { var dbKeyHex = Convert.ToHexString(DeriveDatabaseKey(password)).ToLowerInvariant(); return $"PRAGMA key = \"x'{dbKeyHex}'\""; } public IReadOnlyDictionary GetSchemaStatements() { return new Dictionary(StringComparer.Ordinal) { ["entries"] = """ CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL UNIQUE ); """, ["sections"] = """ CREATE TABLE IF NOT EXISTS sections ( id INTEGER PRIMARY KEY AUTOINCREMENT, entry_id INTEGER NOT NULL, title TEXT NOT NULL, content TEXT, FOREIGN KEY (entry_id) REFERENCES entries (id) ); """, ["fragments"] = """ CREATE TABLE IF NOT EXISTS fragments ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT UNIQUE, entry_id INTEGER, type TEXT NOT NULL, description TEXT, time TEXT, FOREIGN KEY (entry_id) REFERENCES entries (id) ); """, ["tags"] = """ CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE ); """, ["fragment_tags"] = """ CREATE TABLE IF NOT EXISTS fragment_tags ( fragment_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (fragment_id, tag_id), FOREIGN KEY (fragment_id) REFERENCES fragments (id), FOREIGN KEY (tag_id) REFERENCES tags (id) ); """ }; } public string WriteSchemaBootstrap(string? dataDirectory = null) { var directory = string.IsNullOrWhiteSpace(dataDirectory) ? _config.Current.DataDirectory : dataDirectory; Directory.CreateDirectory(directory); var bootstrapPath = Path.GetFullPath(Path.Combine(directory, "journal_schema.sql")); var statements = GetSchemaStatements() .Select(pair => $"-- {pair.Key}\n{pair.Value.Trim()}") .ToArray(); var content = string.Join("\n\n", statements) + "\n"; File.WriteAllText(bootstrapPath, content); return bootstrapPath; } public JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null) { var tables = GetSchemaStatements().Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray(); var bootstrapPath = WriteSchemaBootstrap(dataDirectory); var runtime = ProbeRuntime(password, dataDirectory); return new JournalDatabaseStatus( DatabasePath: GetDatabasePath(dataDirectory), KeyLengthBytes: DeriveDatabaseKey(password).Length, Iterations: Iterations, KeyDerivation: "PBKDF2-HMAC-SHA256", SchemaTables: tables, SchemaBootstrapPath: bootstrapPath, RuntimeReady: runtime.Ready, RuntimeMessage: runtime.Message); } public JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null) { var directory = string.IsNullOrWhiteSpace(dataDirectory) ? _config.Current.DataDirectory : dataDirectory; Directory.CreateDirectory(directory); using var connection = OpenEncryptedConnection(password, directory); EnsureSchema(connection); var runtimeReady = HasRequiredTables(connection); var entryFilesProcessed = Directory.GetFiles(directory, "*.md", SearchOption.TopDirectoryOnly).Length; var schemaPath = WriteSchemaBootstrap(directory); return new JournalDatabaseHydrationResult( DatabasePath: GetDatabasePath(directory), SchemaBootstrapPath: schemaPath, EntryFilesProcessed: entryFilesProcessed, RuntimeReady: runtimeReady, Message: runtimeReady ? "Workspace hydration completed with SQLCipher runtime schema validation." : "Workspace hydration completed, but required schema tables were not found."); } private static void EnsureSqliteInitialized() { if (_sqliteInitialized) return; lock (SqliteInitLock) { if (_sqliteInitialized) return; SQLitePCL.Batteries_V2.Init(); _sqliteInitialized = true; } } public SqliteConnection OpenEncryptedConnection(string password, string? dataDirectory = null) { if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Password cannot be empty.", nameof(password)); EnsureSqliteInitialized(); var connection = new SqliteConnection($"Data Source={GetDatabasePath(dataDirectory)};Mode=ReadWriteCreate;Pooling=False"); connection.Open(); using var keyCmd = connection.CreateCommand(); keyCmd.CommandText = BuildPragmaKeyStatement(password) + ";"; keyCmd.ExecuteNonQuery(); using var verifyCmd = connection.CreateCommand(); verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;"; _ = verifyCmd.ExecuteScalar(); return connection; } public void EnsureSchema(SqliteConnection connection) { foreach (var statement in GetSchemaStatements().Values) { using var cmd = connection.CreateCommand(); cmd.CommandText = statement; cmd.ExecuteNonQuery(); } } private static bool HasRequiredTables(SqliteConnection connection) { var existing = new HashSet(StringComparer.OrdinalIgnoreCase); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table'"; using var reader = cmd.ExecuteReader(); while (reader.Read()) { if (!reader.IsDBNull(0)) existing.Add(reader.GetString(0)); } return RequiredSchemaTables.All(existing.Contains); } private (bool Ready, string Message) ProbeRuntime(string password, string? dataDirectory) { try { using var connection = OpenEncryptedConnection(password, dataDirectory); EnsureSchema(connection); var ready = HasRequiredTables(connection); return ready ? (true, "SQLCipher runtime is available and schema tables are present.") : (false, "SQLCipher runtime opened, but required schema tables are missing."); } catch (Exception ex) { return (false, $"SQLCipher runtime check failed: {ex.Message}"); } } }