using System.Security.Cryptography; using System.Text; using System.Globalization; 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", "lists", "todo_lists", "todo_items", "entry_documents", "conversations", "conversation_messages"]; private readonly IJournalConfigService _config = config; public string GetDatabasePath() { var directory = ResolveDatabaseDirectory(); 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) ); """, ["lists"] = """ CREATE TABLE IF NOT EXISTS lists ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT UNIQUE, label TEXT NOT NULL, content TEXT, created_at TEXT, updated_at TEXT ); """, ["todo_lists"] = """ CREATE TABLE IF NOT EXISTS todo_lists ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT UNIQUE, label TEXT NOT NULL, created_at TEXT ); """, ["todo_items"] = """ CREATE TABLE IF NOT EXISTS todo_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT UNIQUE, list_id INTEGER NOT NULL, text TEXT NOT NULL, done INTEGER DEFAULT 0, 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 ); """, ["conversations"] = """ CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT UNIQUE, title TEXT NOT NULL, created_at TEXT, updated_at TEXT ); """, ["conversation_messages"] = """ CREATE TABLE IF NOT EXISTS conversation_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT UNIQUE, conversation_id INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at TEXT, FOREIGN KEY (conversation_id) REFERENCES conversations (id) ); """ }; } public JournalDatabaseStatus GetStatus(string password) { var tables = GetSchemaStatements().Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray(); var (Ready, Message) = ProbeRuntime(password); return new JournalDatabaseStatus( DatabasePath: GetDatabasePath(), KeyLengthBytes: DeriveDatabaseKey(password).Length, Iterations: Iterations, KeyDerivation: "PBKDF2-HMAC-SHA256", SchemaTables: tables, RuntimeReady: Ready, RuntimeMessage: Message); } public JournalDatabaseHydrationResult HydrateWorkspace(string password) { using var connection = OpenEncryptedConnection(password); EnsureSchemaReady(connection); var runtimeReady = HasRequiredTables(connection); var entryDocumentsProcessed = CountEntryDocuments(connection); return new JournalDatabaseHydrationResult( DatabasePath: GetDatabasePath(), EntryFilesProcessed: entryDocumentsProcessed, RuntimeReady: runtimeReady, Message: runtimeReady ? "Workspace hydration completed with SQLCipher runtime schema validation and document store readiness." : "Workspace hydration completed, but required SQLCipher 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) { if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Password cannot be empty.", nameof(password)); EnsureSqliteInitialized(); var connection = new SqliteConnection( $"Data Source={GetDatabasePath()};Mode=ReadWriteCreate;Pooling=False;Default Timeout=5"); 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(); using var busyCmd = connection.CreateCommand(); busyCmd.CommandText = "PRAGMA busy_timeout = 5000;"; busyCmd.ExecuteNonQuery(); return connection; } public void EnsureSchema(SqliteConnection connection) { foreach (var statement in GetSchemaStatements().Values) { using var cmd = connection.CreateCommand(); cmd.CommandText = statement; cmd.ExecuteNonQuery(); } } public void EnsureSchemaReady(SqliteConnection connection) { if (HasRequiredTables(connection)) return; EnsureSchema(connection); } 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) { try { using var connection = OpenEncryptedConnection(password); EnsureSchemaReady(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}"); } } private static int CountEntryDocuments(SqliteConnection connection) { using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM entry_documents;"; var scalar = cmd.ExecuteScalar(); if (scalar is null || scalar is DBNull) return 0; return Convert.ToInt32(scalar, CultureInfo.InvariantCulture); } private string ResolveDatabaseDirectory() { var overrideDir = Environment.GetEnvironmentVariable("JOURNAL_DATABASE_DIR"); if (!string.IsNullOrWhiteSpace(overrideDir)) return Path.GetFullPath(overrideDir); return Path.GetFullPath(Path.Combine(_config.Current.VaultDirectory, "db")); } }