408 lines
14 KiB
C#
408 lines
14 KiB
C#
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
using DysonNetwork.Common.Models;
|
|
using DysonNetwork.Pass.Features.Auth.Models;
|
|
// Permission types are now in DysonNetwork.Common.Models
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Design;
|
|
using NodaTime;
|
|
using Quartz;
|
|
using Account = DysonNetwork.Common.Models.Account;
|
|
using AccountConnection = DysonNetwork.Common.Models.AccountConnection;
|
|
using AccountAuthFactor = DysonNetwork.Common.Models.AccountAuthFactor;
|
|
using AuthSession = DysonNetwork.Pass.Features.Auth.Models.AuthSession;
|
|
using AuthChallenge = DysonNetwork.Pass.Features.Auth.Models.AuthChallenge;
|
|
|
|
namespace DysonNetwork.Pass.Data;
|
|
|
|
public class PassDatabase(
|
|
DbContextOptions<PassDatabase> options,
|
|
IConfiguration configuration
|
|
) : DbContext(options)
|
|
{
|
|
public DbSet<PermissionNode> PermissionNodes { get; set; }
|
|
public DbSet<PermissionGroup> PermissionGroups { get; set; }
|
|
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
|
|
|
public DbSet<MagicSpell> MagicSpells { get; set; }
|
|
public DbSet<Account> Accounts { get; set; } = null!;
|
|
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
|
|
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
|
|
public DbSet<Relationship> AccountRelationships { get; set; }
|
|
public DbSet<Notification> Notifications { get; set; }
|
|
public DbSet<Badge> Badges { get; set; }
|
|
public DbSet<ActionLog> ActionLogs { get; set; }
|
|
public DbSet<AbuseReport> AbuseReports { get; set; }
|
|
|
|
public DbSet<AuthSession> AuthSessions { get; set; }
|
|
public DbSet<AuthChallenge> AuthChallenges { get; set; }
|
|
|
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
|
{
|
|
optionsBuilder.UseNpgsql(
|
|
configuration.GetConnectionString("App"),
|
|
opt => opt
|
|
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
|
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
|
.UseNetTopologySuite()
|
|
.UseNodaTime()
|
|
).UseSnakeCaseNamingConvention();
|
|
|
|
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
|
|
{
|
|
// Add any initial seeding logic here if needed for PassDatabase
|
|
});
|
|
|
|
optionsBuilder.UseSeeding((context, _) => {});
|
|
|
|
base.OnConfiguring(optionsBuilder);
|
|
}
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
base.OnModelCreating(modelBuilder);
|
|
|
|
modelBuilder.Entity<PermissionGroupMember>()
|
|
.HasKey(pg => new { pg.GroupId, pg.Actor });
|
|
modelBuilder.Entity<PermissionGroupMember>()
|
|
.HasOne(pg => pg.Group)
|
|
.WithMany(g => g.Members)
|
|
.HasForeignKey(pg => pg.GroupId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<Relationship>()
|
|
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
|
|
modelBuilder.Entity<Relationship>()
|
|
.HasOne(r => r.Account)
|
|
.WithMany(a => a.OutgoingRelationships)
|
|
.HasForeignKey(r => r.AccountId);
|
|
modelBuilder.Entity<Relationship>()
|
|
.HasOne(r => r.Related)
|
|
.WithMany(a => a.IncomingRelationships)
|
|
.HasForeignKey(r => r.RelatedId);
|
|
|
|
// Configure AuthSession
|
|
modelBuilder.Entity<AuthSession>(entity =>
|
|
{
|
|
entity.HasKey(e => e.Id);
|
|
|
|
entity.Property(e => e.Id)
|
|
.ValueGeneratedOnAdd()
|
|
.HasDefaultValueSql("gen_random_uuid()");
|
|
|
|
entity.Property(e => e.Label)
|
|
.HasMaxLength(500);
|
|
|
|
entity.Property(e => e.LastGrantedAt)
|
|
.IsRequired();
|
|
|
|
entity.Property(e => e.ExpiredAt)
|
|
.IsRequired();
|
|
|
|
entity.Property(e => e.AccessToken)
|
|
.HasMaxLength(1000);
|
|
|
|
entity.Property(e => e.RefreshToken)
|
|
.HasMaxLength(1000);
|
|
|
|
entity.Property(e => e.IpAddress)
|
|
.HasMaxLength(128);
|
|
|
|
entity.Property(e => e.UserAgent)
|
|
.HasMaxLength(500);
|
|
|
|
entity.HasOne(s => s.Account)
|
|
.WithMany(a => a.Sessions)
|
|
.HasForeignKey(s => s.AccountId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
entity.HasOne(s => s.Challenge)
|
|
.WithMany()
|
|
.HasForeignKey(s => s.ChallengeId)
|
|
.OnDelete(DeleteBehavior.SetNull);
|
|
|
|
entity.Property(e => e.Metadata)
|
|
.HasColumnType("jsonb");
|
|
});
|
|
|
|
// Configure AuthChallenge
|
|
modelBuilder.Entity<AuthChallenge>(entity =>
|
|
{
|
|
entity.HasKey(e => e.Id);
|
|
|
|
entity.Property(e => e.Type)
|
|
.IsRequired()
|
|
.HasConversion<string>();
|
|
|
|
entity.Property(e => e.Platform)
|
|
.IsRequired()
|
|
.HasConversion<string>();
|
|
|
|
entity.Property(e => e.CreatedAt)
|
|
.IsRequired();
|
|
|
|
entity.Property(e => e.ExpiredAt);
|
|
|
|
entity.Property(e => e.StepRemain)
|
|
.IsRequired()
|
|
.HasDefaultValue(1);
|
|
|
|
entity.Property(e => e.StepTotal)
|
|
.IsRequired()
|
|
.HasDefaultValue(1);
|
|
|
|
entity.Property(e => e.FailedAttempts)
|
|
.IsRequired()
|
|
.HasDefaultValue(0);
|
|
|
|
entity.Property(e => e.IpAddress)
|
|
.HasMaxLength(128);
|
|
|
|
entity.Property(e => e.UserAgent)
|
|
.HasMaxLength(512);
|
|
|
|
entity.Property(e => e.DeviceId)
|
|
.HasMaxLength(256);
|
|
|
|
entity.Property(e => e.Nonce)
|
|
.HasMaxLength(1024);
|
|
|
|
entity.Property(e => e.BlacklistFactors)
|
|
.HasColumnType("jsonb");
|
|
|
|
entity.Property(e => e.Audiences)
|
|
.HasColumnType("jsonb");
|
|
|
|
entity.Property(e => e.Scopes)
|
|
.HasColumnType("jsonb");
|
|
|
|
entity.HasOne<Account>()
|
|
.WithMany(a => a.Challenges)
|
|
.HasForeignKey(e => e.AccountId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
entity.Ignore(e => e.Location); // Ignore Point type as it's not directly supported by EF Core
|
|
});
|
|
|
|
// Configure AccountAuthFactor
|
|
modelBuilder.Entity<AccountAuthFactor>(entity =>
|
|
{
|
|
entity.HasKey(e => e.Id);
|
|
|
|
entity.Property(e => e.Id)
|
|
.ValueGeneratedOnAdd()
|
|
.HasDefaultValueSql("gen_random_uuid()");
|
|
|
|
entity.Property(e => e.FactorType)
|
|
.IsRequired()
|
|
.HasConversion<string>();
|
|
|
|
entity.Property(e => e.Name)
|
|
.IsRequired()
|
|
.HasMaxLength(100);
|
|
|
|
entity.Property(e => e.Description)
|
|
.HasMaxLength(500);
|
|
|
|
entity.Property(e => e.Secret)
|
|
.IsRequired()
|
|
.HasMaxLength(1024);
|
|
|
|
entity.Property(e => e.IsDefault)
|
|
.IsRequired()
|
|
.HasDefaultValue(false);
|
|
|
|
entity.Property(e => e.IsBackup)
|
|
.IsRequired()
|
|
.HasDefaultValue(false);
|
|
|
|
entity.Property(e => e.LastUsedAt);
|
|
entity.Property(e => e.EnabledAt);
|
|
entity.Property(e => e.DisabledAt);
|
|
|
|
entity.Property(e => e.Metadata)
|
|
.HasColumnType("jsonb");
|
|
|
|
entity.HasOne(f => f.Account)
|
|
.WithMany(a => a.AuthFactors)
|
|
.HasForeignKey(f => f.AccountId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
// Remove the incorrect relationship configuration
|
|
// The relationship is already defined in the AuthSession configuration
|
|
});
|
|
|
|
// Configure Account
|
|
modelBuilder.Entity<Account>(entity =>
|
|
{
|
|
entity.HasKey(e => e.Id);
|
|
|
|
entity.Property(e => e.Email)
|
|
.IsRequired()
|
|
.HasMaxLength(256);
|
|
|
|
entity.Property(e => e.Name)
|
|
.IsRequired()
|
|
.HasMaxLength(256);
|
|
|
|
entity.Property(e => e.Status)
|
|
.HasMaxLength(32);
|
|
|
|
entity.Property(e => e.CreatedAt)
|
|
.IsRequired();
|
|
|
|
entity.Property(e => e.UpdatedAt)
|
|
.IsRequired();
|
|
});
|
|
|
|
// Configure AccountConnection
|
|
modelBuilder.Entity<AccountConnection>(entity =>
|
|
{
|
|
entity.HasKey(e => e.Id);
|
|
|
|
entity.Property(e => e.Provider)
|
|
.IsRequired()
|
|
.HasMaxLength(50);
|
|
|
|
entity.Property(e => e.ProviderId)
|
|
.IsRequired()
|
|
.HasMaxLength(256);
|
|
|
|
entity.Property(e => e.DisplayName)
|
|
.HasMaxLength(256);
|
|
|
|
entity.Property(e => e.AccessToken)
|
|
.HasMaxLength(1000);
|
|
|
|
entity.Property(e => e.RefreshToken)
|
|
.HasMaxLength(1000);
|
|
|
|
entity.Property(e => e.ExpiresAt);
|
|
|
|
entity.Property(e => e.ProfileData)
|
|
.HasColumnType("jsonb");
|
|
|
|
entity.HasOne<Account>()
|
|
.WithMany(a => a.Connections)
|
|
.HasForeignKey(e => e.AccountId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
});
|
|
|
|
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
|
{
|
|
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
|
|
var method = typeof(PassDatabase)
|
|
.GetMethod(nameof(SetSoftDeleteFilter),
|
|
BindingFlags.NonPublic | BindingFlags.Static)!
|
|
.MakeGenericMethod(entityType.ClrType);
|
|
|
|
method.Invoke(null, [modelBuilder]);
|
|
}
|
|
}
|
|
|
|
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
|
where TEntity : ModelBase
|
|
{
|
|
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
|
|
}
|
|
|
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var now = SystemClock.Instance.GetCurrentInstant();
|
|
|
|
foreach (var entry in ChangeTracker.Entries<ModelBase>())
|
|
{
|
|
switch (entry.State)
|
|
{
|
|
case EntityState.Added:
|
|
entry.Entity.CreatedAt = now;
|
|
entry.Entity.UpdatedAt = now;
|
|
break;
|
|
case EntityState.Modified:
|
|
entry.Entity.UpdatedAt = now;
|
|
break;
|
|
case EntityState.Deleted:
|
|
entry.State = EntityState.Modified;
|
|
entry.Entity.DeletedAt = now;
|
|
break;
|
|
case EntityState.Detached:
|
|
case EntityState.Unchanged:
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return await base.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|
|
|
|
public class PassDatabaseFactory : IDesignTimeDbContextFactory<PassDatabase>
|
|
{
|
|
public PassDatabase CreateDbContext(string[] args)
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.SetBasePath(Directory.GetCurrentDirectory())
|
|
.AddJsonFile("appsettings.json")
|
|
.Build();
|
|
|
|
var optionsBuilder = new DbContextOptionsBuilder<PassDatabase>();
|
|
return new PassDatabase(optionsBuilder.Options, configuration);
|
|
}
|
|
}
|
|
|
|
public class PassDatabaseRecyclingJob(PassDatabase db, ILogger<PassDatabaseRecyclingJob> logger) : IJob
|
|
{
|
|
public async Task Execute(IJobExecutionContext context)
|
|
{
|
|
var now = SystemClock.Instance.GetCurrentInstant();
|
|
|
|
logger.LogInformation("Cleaning up expired records...");
|
|
|
|
// Expired relationships
|
|
var affectedRows = await db.AccountRelationships
|
|
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
|
|
.ExecuteDeleteAsync();
|
|
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
|
|
|
|
logger.LogInformation("Deleting soft-deleted records...");
|
|
|
|
var threshold = now - Duration.FromDays(7);
|
|
|
|
var entityTypes = db.Model.GetEntityTypes()
|
|
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
|
|
.Select(t => t.ClrType);
|
|
|
|
foreach (var entityType in entityTypes)
|
|
{
|
|
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
|
.MakeGenericMethod(entityType).Invoke(db, null)!;
|
|
var parameter = Expression.Parameter(entityType, "e");
|
|
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
|
|
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
|
|
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
|
|
var finalCondition = Expression.AndAlso(notNull, condition);
|
|
var lambda = Expression.Lambda(finalCondition, parameter);
|
|
|
|
var queryable = set.Provider.CreateQuery(
|
|
Expression.Call(
|
|
typeof(Queryable),
|
|
"Where",
|
|
[entityType],
|
|
set.Expression,
|
|
Expression.Quote(lambda)
|
|
)
|
|
);
|
|
|
|
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
|
|
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
|
|
.MakeGenericMethod(entityType);
|
|
|
|
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
|
|
db.RemoveRange(items);
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
}
|