- Slim Entry.cs from ~550 to ~330 lines (thin dispatcher only) - Extract HtmlSanitizer, CommandLogger, EntryFileService from Entry.cs - Extract PythonSidecarClient from duplicated sidecar plumbing - Add IEntryFileRepository + DiskEntryFileRepository (mirrors Fragment pattern) - Move payload records to Dtos/CommandDtos.cs - Move database result records to Dtos/DatabaseDtos.cs - Register new services and repository in DI - All 70/70 smoke tests pass, no behavior changes Co-Authored-By: Warp <agent@warp.dev>
235 lines
8.5 KiB
C#
235 lines
8.5 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Journal.Core.Dtos;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace Journal.Core.Services;
|
|
|
|
public sealed class JournalDatabaseService : 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 object SqliteInitLock = new();
|
|
private static bool _sqliteInitialized;
|
|
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
|
["entries", "sections", "fragments", "tags", "fragment_tags"];
|
|
|
|
private readonly IJournalConfigService _config;
|
|
|
|
public JournalDatabaseService(IJournalConfigService config)
|
|
{
|
|
_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<string, string> GetSchemaStatements()
|
|
{
|
|
return new Dictionary<string, string>(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,
|
|
entry_id INTEGER NOT NULL,
|
|
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);
|
|
CreateSchema(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;
|
|
}
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private void CreateSchema(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<string>(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);
|
|
CreateSchema(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}");
|
|
}
|
|
}
|
|
}
|