//----------------------------------------------------------------------- // // Copyright (c) aliasvault. All rights reserved. // Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- namespace AliasClientDb; using System.Globalization; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Configuration; /// /// The AliasClientDbContext class. /// public class AliasClientDbContext : DbContext { /// /// Initializes a new instance of the class. /// public AliasClientDbContext() { } /// /// Initializes a new instance of the class. /// /// The SQLite connection to use to connect to the SQLite database. /// The action to perform for logging. public AliasClientDbContext(SqliteConnection sqliteConnection, Action logAction) : base(GetOptions(sqliteConnection, logAction)) { } /// /// Initializes a new instance of the class. /// /// DbContextOptions to use. public AliasClientDbContext(DbContextOptions options) : base(options) { } /// /// Gets or sets the Alias DbSet. /// public DbSet Aliases { get; set; } /// /// Gets or sets the Attachment DbSet. /// public DbSet Attachments { get; set; } /// /// Gets or sets the Credential DbSet. /// public DbSet Credentials { get; set; } /// /// Gets or sets the Password DbSet. /// public DbSet Passwords { get; set; } /// /// Gets or sets the Service DbSet. /// public DbSet Services { get; set; } /// /// Gets or sets the EncryptionKey DbSet. /// public DbSet EncryptionKeys { get; set; } /// /// Gets or sets the Settings DbSet. /// public DbSet Settings { get; set; } /// /// Gets or sets the TotpCodes DbSet. /// public DbSet TotpCodes { get; set; } /// /// Gets or sets the Passkeys DbSet. /// public DbSet Passkeys { get; set; } /// /// The OnModelCreating method. /// /// ModelBuilder instance. protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); foreach (var entity in modelBuilder.Model.GetEntityTypes()) { foreach (var property in entity.GetProperties()) { // SQLite does not support varchar(max) so we use TEXT. if (property.ClrType == typeof(string) && property.GetMaxLength() == null) { property.SetColumnType("TEXT"); } } } // Create a value converter that maps DateTime.MinValue to an empty string and vice versa. // This prevents an empty string in the client DB from causing a fatal exception while loading // Alias objects. It also supports reading . and : as separators as pre 0.23.0 some clients were susceptible to use // local culture settings which could cause the birthdate field to be either format. // TODO: when the birthdate field is made optional in data model and all existing values have been converted from "yyyy-MM-dd HH.mm.ss" to "yyyy-MM-dd HH':'mm':'ss", this can probably // be removed. But test the usecase where the birthdate field is empty string (because of browser extension error). var emptyDateTimeConverter = new ValueConverter( v => DateTimeToString(v), v => StringToDateTime(v)); modelBuilder.Entity() .Property(e => e.BirthDate) .HasConversion(emptyDateTimeConverter); // Configure Credential - Alias relationship modelBuilder.Entity() .HasOne(l => l.Alias) .WithMany(c => c.Credentials) .HasForeignKey(l => l.AliasId) .OnDelete(DeleteBehavior.Cascade); // Configure Credential - Service relationship modelBuilder.Entity() .HasOne(l => l.Service) .WithMany(c => c.Credentials) .HasForeignKey(l => l.ServiceId) .OnDelete(DeleteBehavior.Cascade); // Configure Attachment - Credential relationship modelBuilder.Entity() .HasOne(l => l.Credential) .WithMany(c => c.Attachments) .HasForeignKey(l => l.CredentialId) .OnDelete(DeleteBehavior.Cascade); // Configure Password - Credential relationship modelBuilder.Entity() .HasOne(l => l.Credential) .WithMany(c => c.Passwords) .HasForeignKey(l => l.CredentialId) .OnDelete(DeleteBehavior.Cascade); // Configure TotpCode - Credential relationship modelBuilder.Entity() .HasOne(l => l.Credential) .WithMany(c => c.TotpCodes) .HasForeignKey(l => l.CredentialId) .OnDelete(DeleteBehavior.Cascade); // Configure Passkey - Credential relationship modelBuilder.Entity() .HasOne(p => p.Credential) .WithMany(c => c.Passkeys) .HasForeignKey(p => p.CredentialId) .OnDelete(DeleteBehavior.Cascade); // Configure Passkey indexes modelBuilder.Entity() .HasIndex(e => e.RpId); modelBuilder.Entity() .Property(e => e.RpId) .UseCollation("NOCASE"); } /// /// Sets up the connection string if it is not already configured. /// /// DbContextOptionsBuilder instance. protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // If the options are not already configured, use the appsettings.json file. if (!optionsBuilder.IsConfigured) { var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); optionsBuilder .UseSqlite(configuration.GetConnectionString("AliasClientDbContext")) .UseLazyLoadingProxies(); // Log queries made as debug output. optionsBuilder.LogTo(Console.WriteLine); } base.OnConfiguring(optionsBuilder); } /// /// Gets the options for the AliasClientDbContext. /// /// The SQLite connection to use to connect to the SQLite database. /// The action to perform for logging. /// The options for the AliasClientDbContext. private static DbContextOptions GetOptions(SqliteConnection connection, Action logAction) { var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseSqlite(connection); optionsBuilder.LogTo(logAction, new[] { DbLoggerCategory.Database.Command.Name }); return optionsBuilder.Options; } /// /// Converts a DateTime to a string in the standard format: "yyyy-MM-dd HH:mm:ss.fff" (23 characters with milliseconds). /// This format ensures SQLite native support, consistent precision, and proper sorting. /// /// The DateTime to convert. /// The string representation of the DateTime. private static string DateTimeToString(DateTime v) { return v == DateTime.MinValue ? string.Empty : v.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); } /// /// Converts a string to a DateTime. /// /// The string to convert. /// The DateTime representation of the string. private static DateTime StringToDateTime(string v) { if (string.IsNullOrEmpty(v)) { return DateTime.MinValue; } // Try to parse with all known formats first // Standard format is first for performance (most common case) string[] formats = new[] { "yyyy-MM-dd HH:mm:ss.fff", // Standard format with milliseconds (23 chars) "yyyy-MM-dd HH:mm:ss", // Standard format without milliseconds (19 chars) "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", // ISO 8601 with milliseconds and Zulu "yyyy-MM-dd'T'HH:mm:ss'Z'", // ISO 8601 with Zulu "yyyy-MM-dd'T'HH:mm:ss.fff", // ISO 8601 with milliseconds "yyyy-MM-dd'T'HH:mm:ss", // ISO 8601 basic "yyyy-MM-dd", // Date only }; foreach (var format in formats) { if (DateTime.TryParseExact(v, format, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)) { return dt; } } // Fallback: try to parse dynamically (handles most .NET and JS date strings) if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dynamicDt)) { return dynamicDt; } // If all parsing fails, return MinValue as a safe fallback return DateTime.MinValue; } }