using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Remotely.Shared.Entities; using Remotely.Shared.Models; using Remotely.Shared.Utilities; using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; namespace Remotely.Server.Data; public class AppDb : IdentityDbContext { private static readonly ValueComparer _stringArrayComparer = new( (a, b) => (a ?? Array.Empty()).SequenceEqual(b ?? Array.Empty()), c => c.Aggregate(0, (a, b) => HashCode.Combine(a, b.GetHashCode())), c => c.ToArray()); private readonly IWebHostEnvironment _hostEnv; public AppDb(IWebHostEnvironment hostEnvironment) { _hostEnv = hostEnvironment; } public DbSet Alerts { get; set; } public DbSet ApiTokens { get; set; } public DbSet BrandingInfos { get; set; } public DbSet DeviceGroups { get; set; } public DbSet Devices { get; set; } public DbSet InviteLinks { get; set; } public DbSet Organizations { get; set; } public DbSet SavedScripts { get; set; } public DbSet ScriptResults { get; set; } public DbSet ScriptRuns { get; set; } public DbSet ScriptSchedules { get; set; } public DbSet SharedFiles { get; set; } public new DbSet Users { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder options) { options.ConfigureWarnings(x => x.Ignore(RelationalEventId.MultipleCollectionIncludeWarning)); options.LogTo((message) => System.Diagnostics.Debug.Write(message)); if (_hostEnv.IsDevelopment()) { options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); } } protected override void OnModelCreating(ModelBuilder builder) { var jsonOptions = JsonSerializerOptions.Default; base.OnModelCreating(builder); builder.Entity().ToTable("RemotelyUsers"); builder.Entity() .HasMany(x => x.Devices) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.RemotelyUsers) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.DeviceGroups) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.InviteLinks) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.SharedFiles) .WithOne(x => x.Organization) .IsRequired(false); builder.Entity() .HasMany(x => x.ApiTokens) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.Alerts) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.ScriptRuns) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.ScriptSchedules) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.ScriptResults) .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.SavedScripts) .WithOne(x => x.Organization); builder.Entity() .HasOne(x => x.BrandingInfo) .WithOne(x => x.Organization) .HasForeignKey(x => x.OrganizationId); builder.Entity() .HasOne(x => x.Organization) .WithMany(x => x.RemotelyUsers); builder.Entity() .HasMany(x => x.DeviceGroups) .WithMany(x => x.Users); builder.Entity() .HasMany(x => x.Alerts) .WithOne(x => x.User); builder.Entity() .Property(x => x.UserOptions) .HasConversion( x => JsonSerializer.Serialize(x, jsonOptions), x => JsonSerializer.Deserialize(x, jsonOptions)); builder.Entity() .HasMany(x => x.SavedScripts) .WithOne(x => x.Creator); builder.Entity() .HasMany(x => x.ScriptSchedules) .WithOne(x => x.Creator); builder.Entity() .HasIndex(x => x.UserName); builder.Entity() .Property(x => x.Drives) .HasConversion( x => JsonSerializer.Serialize(x, jsonOptions), x => TryDeserializeProperty>(x, jsonOptions)); builder.Entity() .Property(x => x.Drives) .Metadata.SetValueComparer(new ValueComparer>(true)); builder.Entity() .HasIndex(x => x.DeviceName); builder.Entity() .HasMany(x => x.Alerts) .WithOne(x => x.Device); builder.Entity() .HasMany(x => x.ScriptRuns) .WithMany(x => x.Devices); builder.Entity() .HasMany(x => x.ScriptSchedules) .WithMany(x => x.Devices); builder.Entity() .HasOne(x => x.DeviceGroup) .WithMany(x => x.Devices) .IsRequired(false); builder.Entity() .Property(x => x.MacAddresses) .HasConversion( x => JsonSerializer.Serialize(x, jsonOptions), x => DeserializeStringArray(x, jsonOptions), valueComparer: _stringArrayComparer); builder.Entity() .HasMany(x => x.Devices) .WithOne(x => x.DeviceGroup) .IsRequired(false); builder.Entity() .HasMany(x => x.ScriptSchedules) .WithMany(x => x.DeviceGroups); builder.Entity() .HasMany(x => x.Devices) .WithMany(x => x.ScriptRuns); builder.Entity() .HasMany(x => x.Results) .WithOne(x => x.ScriptRun) .IsRequired(false); builder.Entity() .HasOne(x => x.SavedScript) .WithMany(x => x.ScriptRuns) .IsRequired(false); builder.Entity() .Property(x => x.ErrorOutput) .HasConversion( x => JsonSerializer.Serialize(x, jsonOptions), x => DeserializeStringArray(x, jsonOptions)) .Metadata .SetValueComparer(_stringArrayComparer); builder.Entity() .Property(x => x.StandardOutput) .HasConversion( x => JsonSerializer.Serialize(x, jsonOptions), x => DeserializeStringArray(x, jsonOptions)) .Metadata .SetValueComparer(_stringArrayComparer); builder.Entity() .HasOne(x => x.ScriptRun) .WithMany(x => x.Results) .IsRequired(false); builder.Entity() .HasOne(x => x.SavedScript) .WithMany(x => x.ScriptResults) .IsRequired(false); builder.Entity() .HasOne(x => x.User) .WithMany(x => x.Alerts); if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") { // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations // here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations // To work around this, when the SQLite database provider is used, all model properties of type DateTimeOffset // use the DateTimeOffsetToBinaryConverter // Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754 // This only supports millisecond precision, but should be sufficient for most use cases. foreach (var entityType in builder.Model.GetEntityTypes()) { if (entityType.IsKeyless) { continue; } var properties = entityType.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset) || p.PropertyType == typeof(DateTimeOffset?)); foreach (var property in properties) { builder .Entity(entityType.Name) .Property(property.Name) .HasConversion(new DateTimeOffsetToStringConverter()); } } } } private static string[] DeserializeStringArray(string value, JsonSerializerOptions jsonOptions) { try { if (string.IsNullOrEmpty(value)) { return Array.Empty(); } return JsonSerializer.Deserialize(value, jsonOptions) ?? Array.Empty(); } catch { return Array.Empty(); } } private static T TryDeserializeProperty(string value, JsonSerializerOptions jsonOptions) where T: new() { try { if (string.IsNullOrEmpty(value)) { return new(); } return JsonSerializer.Deserialize(value, jsonOptions) ?? new(); } catch { return new(); } } }