Move statistic columns from user to group (#2159)
This commit is contained in:
@@ -185,12 +185,12 @@ else
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
<strong>Max Emails:</strong> @(User.MaxEmails == 0 ? "Unlimited" : User.MaxEmails.ToString("N0"))
|
||||
<strong>Max Emails:</strong> @(User.PersonalGroup.MaxEmails == 0 ? "Unlimited" : User.PersonalGroup.MaxEmails.ToString("N0"))
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
<strong>Max Age:</strong> @(User.MaxEmailAgeDays == 0 ? "Unlimited" : User.MaxEmailAgeDays + " days")
|
||||
<strong>Max Age:</strong> @(User.PersonalGroup.MaxEmailAgeDays == 0 ? "Unlimited" : User.PersonalGroup.MaxEmailAgeDays + " days")
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
@@ -354,9 +354,9 @@ else
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Load the aliases from the webapi via AliasService.
|
||||
// Load the aliases from the webapi via AliasService. Eager-load the personal group as it holds the user's email limits.
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
User = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
User = await dbContext.AliasVaultUsers.Include(u => u.PersonalGroup).FirstOrDefaultAsync(u => u.Id == Id);
|
||||
|
||||
// Get count of user authenticator tokens.
|
||||
TwoFactorKeysCount = await dbContext.UserTokens.CountAsync(x => x.UserId == User!.Id && x.Name == "AuthenticatorKey");
|
||||
@@ -642,8 +642,8 @@ Do you want to proceed with the restoration?")) {
|
||||
private void StartEditingEmailLimits()
|
||||
{
|
||||
IsEditingEmailLimits = true;
|
||||
EditMaxEmails = User!.MaxEmails;
|
||||
EditMaxEmailAgeDays = User.MaxEmailAgeDays;
|
||||
EditMaxEmails = User!.PersonalGroup.MaxEmails;
|
||||
EditMaxEmailAgeDays = User.PersonalGroup.MaxEmailAgeDays;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -662,12 +662,12 @@ Do you want to proceed with the restoration?")) {
|
||||
private async Task SaveEmailLimits()
|
||||
{
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
User = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
var personalGroup = await dbContext.Groups.FirstOrDefaultAsync(g => dbContext.AliasVaultUsers.Any(u => u.Id == Id && u.PersonalGroupId == g.Id));
|
||||
|
||||
if (User != null)
|
||||
if (personalGroup != null)
|
||||
{
|
||||
User.MaxEmails = EditMaxEmails;
|
||||
User.MaxEmailAgeDays = EditMaxEmailAgeDays;
|
||||
personalGroup.MaxEmails = EditMaxEmails;
|
||||
personalGroup.MaxEmailAgeDays = EditMaxEmailAgeDays;
|
||||
await dbContext.SaveChangesAsync();
|
||||
IsEditingEmailLimits = false;
|
||||
await RefreshData();
|
||||
|
||||
@@ -343,9 +343,8 @@ public class StatisticsService
|
||||
.Where(e => e.EncryptionKey.UserId == userId)
|
||||
.CountAsync();
|
||||
|
||||
// Get persistent emails received counter from user record (never decremented, even when emails are deleted)
|
||||
var user = await context.AliasVaultUsers.FindAsync(userId);
|
||||
stats.TotalEmailsReceivedPersistent = user?.EmailsReceived ?? 0;
|
||||
// Get persistent emails received counter from the user's personal group (never decremented, even when emails are deleted)
|
||||
stats.TotalEmailsReceivedPersistent = await context.AliasVaultUsers.Where(u => u.Id == userId).Select(u => u.PersonalGroup.EmailsReceived).FirstOrDefaultAsync();
|
||||
|
||||
// Get recent statistics (last 72 hours).
|
||||
var recentCurrentRevisions = await context.VaultManifests
|
||||
|
||||
@@ -53,46 +53,17 @@ public class AliasVaultUser : IdentityUser
|
||||
/// </summary>
|
||||
public DateTime? BlockedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user is marked as shadow-blocked.
|
||||
/// </summary>
|
||||
public bool ShadowBlocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp when the user was shadow-blocked. Used to only hide emails received after the
|
||||
/// block occurred. Null when the user has never been shadow-blocked (in which case all emails are hidden while
|
||||
/// ShadowBlocked is true, as a conservative fallback).
|
||||
/// </summary>
|
||||
public DateTime? ShadowBlockedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets updated timestamp.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of emails for all of user's aliases. 0 means unlimited.
|
||||
/// </summary>
|
||||
public int MaxEmails { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum age of emails in days. Emails older than this will be deleted. 0 means unlimited.
|
||||
/// </summary>
|
||||
public int MaxEmailAgeDays { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date of the user's last activity (login, API call, etc.).
|
||||
/// Updated automatically on successful authentication events.
|
||||
/// </summary>
|
||||
public DateTime? LastActivityDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total count of emails received by this user across all time.
|
||||
/// This is a persistent counter that is incremented when emails are received and is never decremented,
|
||||
/// even when emails are deleted. Used for abuse detection and usage statistics.
|
||||
/// </summary>
|
||||
public int EmailsReceived { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of vault unlock keys (KEK/VEK model). Empty for users still on the legacy
|
||||
/// model where the password-derived key encrypts the vault directly.
|
||||
|
||||
@@ -37,6 +37,34 @@ public class Group
|
||||
/// </summary>
|
||||
public virtual ICollection<GroupMember> Members { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the group is marked as shadow-blocked.
|
||||
/// </summary>
|
||||
public bool ShadowBlocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp when the group was shadow-blocked. Used to only hide emails received after the
|
||||
/// block occurred. Null when the group has never been shadow-blocked.
|
||||
/// </summary>
|
||||
public DateTime? ShadowBlockedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of emails for all aliases owned by this group. 0 means unlimited.
|
||||
/// </summary>
|
||||
public int MaxEmails { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum age of emails in days. Emails older than this will be deleted. 0 means unlimited.
|
||||
/// </summary>
|
||||
public int MaxEmailAgeDays { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total count of emails received by this group's aliases across all time.
|
||||
/// This is a persistent counter that is incremented when emails are received and is never decremented,
|
||||
/// even when emails are deleted. Used for abuse detection and usage statistics.
|
||||
/// </summary>
|
||||
public int EmailsReceived { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets created timestamp.
|
||||
/// </summary>
|
||||
|
||||
+1657
File diff suppressed because it is too large
Load Diff
+153
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace AliasServerDb.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MoveEmailAbuseCountersToGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EmailsReceived",
|
||||
table: "Groups",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxEmailAgeDays",
|
||||
table: "Groups",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxEmails",
|
||||
table: "Groups",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ShadowBlocked",
|
||||
table: "Groups",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ShadowBlockedAt",
|
||||
table: "Groups",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
// Move the existing per-user abuse counters and email limits onto each user's personal group.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "Groups" g
|
||||
SET "EmailsReceived" = u."EmailsReceived",
|
||||
"MaxEmailAgeDays" = u."MaxEmailAgeDays",
|
||||
"MaxEmails" = u."MaxEmails",
|
||||
"ShadowBlocked" = u."ShadowBlocked",
|
||||
"ShadowBlockedAt" = u."ShadowBlockedAt"
|
||||
FROM "AliasVaultUsers" u
|
||||
WHERE u."PersonalGroupId" = g."Id";
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmailsReceived",
|
||||
table: "AliasVaultUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxEmailAgeDays",
|
||||
table: "AliasVaultUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxEmails",
|
||||
table: "AliasVaultUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShadowBlocked",
|
||||
table: "AliasVaultUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShadowBlockedAt",
|
||||
table: "AliasVaultUsers");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EmailsReceived",
|
||||
table: "AliasVaultUsers",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxEmailAgeDays",
|
||||
table: "AliasVaultUsers",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxEmails",
|
||||
table: "AliasVaultUsers",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ShadowBlocked",
|
||||
table: "AliasVaultUsers",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ShadowBlockedAt",
|
||||
table: "AliasVaultUsers",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
// Move the abuse counters and email limits from each user's personal group back onto the user record.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "AliasVaultUsers" u
|
||||
SET "EmailsReceived" = g."EmailsReceived",
|
||||
"MaxEmailAgeDays" = g."MaxEmailAgeDays",
|
||||
"MaxEmails" = g."MaxEmails",
|
||||
"ShadowBlocked" = g."ShadowBlocked",
|
||||
"ShadowBlockedAt" = g."ShadowBlockedAt"
|
||||
FROM "Groups" g
|
||||
WHERE u."PersonalGroupId" = g."Id";
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmailsReceived",
|
||||
table: "Groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxEmailAgeDays",
|
||||
table: "Groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxEmails",
|
||||
table: "Groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShadowBlocked",
|
||||
table: "Groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShadowBlockedAt",
|
||||
table: "Groups");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -144,9 +144,6 @@ namespace AliasServerDb.Migrations
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("EmailsReceived")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastActivityDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -156,12 +153,6 @@ namespace AliasServerDb.Migrations
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MaxEmailAgeDays")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxEmails")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -186,12 +177,6 @@ namespace AliasServerDb.Migrations
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("ShadowBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("ShadowBlockedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SrpIdentity")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
@@ -558,11 +543,26 @@ namespace AliasServerDb.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailsReceived")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxEmailAgeDays")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxEmails")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("ShadowBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("ShadowBlockedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -67,10 +67,10 @@ public class EmailCleanupTask : IMaintenanceTask
|
||||
}
|
||||
}
|
||||
|
||||
// Now handle per-user age limits
|
||||
// Now handle per-user age limits. The email age limit is stored on the user's personal group.
|
||||
var usersWithAgeLimits = await dbContext.AliasVaultUsers
|
||||
.Where(u => u.MaxEmailAgeDays > 0)
|
||||
.Select(u => new { u.Id, u.UserName, u.MaxEmailAgeDays })
|
||||
.Where(u => u.PersonalGroup.MaxEmailAgeDays > 0)
|
||||
.Select(u => new { u.Id, u.UserName, u.PersonalGroup.MaxEmailAgeDays })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var user in usersWithAgeLimits)
|
||||
|
||||
@@ -45,11 +45,12 @@ public class EmailQuotaCleanupTask : IMaintenanceTask
|
||||
var settings = await _settingsService.GetAllSettingsAsync();
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Get all users with their email claims and limits
|
||||
// Get all users with their email claims and limits. The email limit is stored on the user's personal group.
|
||||
var usersWithClaims = await (from u in dbContext.AliasVaultUsers
|
||||
join m in dbContext.VaultManifests on u.PersonalGroupId equals m.OwnerGroupId
|
||||
join g in dbContext.Groups on u.PersonalGroupId equals g.Id
|
||||
join m in dbContext.VaultManifests on g.Id equals m.OwnerGroupId
|
||||
join c in dbContext.EmailClaims on (Guid?)m.ManifestId equals c.VaultManifestId
|
||||
select new { u.Id, u.UserName, u.MaxEmails, u.LastActivityDate, u.CreatedAt, c.Address })
|
||||
select new { u.Id, u.UserName, g.MaxEmails, u.LastActivityDate, u.CreatedAt, c.Address })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Get minimum activity date which is used to determine if user is active.
|
||||
|
||||
@@ -54,8 +54,16 @@ public class IpBlockListService(IAliasServerDbContextFactory dbContextFactory, I
|
||||
/// <returns>The earliest shadow-block timestamp, or null when not shadow-blocked.</returns>
|
||||
public async Task<DateTime?> GetShadowBlockCutoffAsync(AliasVaultUser user, IPAddress? ipAddress)
|
||||
{
|
||||
// Account-level shadow-block. When the timestamp is unknown, return min timestamp.
|
||||
DateTime? cutoff = user.ShadowBlocked ? (user.ShadowBlockedAt ?? DateTime.UnixEpoch) : null;
|
||||
// Account-level shadow-block, stored on the user's personal group. When the timestamp is unknown, return min timestamp.
|
||||
DateTime? cutoff = null;
|
||||
await using (var dbContext = await dbContextFactory.CreateDbContextAsync())
|
||||
{
|
||||
var personalGroup = await dbContext.Groups.AsNoTracking().Where(g => g.Id == user.PersonalGroupId).Select(g => new { g.ShadowBlocked, g.ShadowBlockedAt }).FirstOrDefaultAsync();
|
||||
if (personalGroup is not null && personalGroup.ShadowBlocked)
|
||||
{
|
||||
cutoff = personalGroup.ShadowBlockedAt ?? DateTime.UnixEpoch;
|
||||
}
|
||||
}
|
||||
|
||||
// IP-range shadow-block: the earliest matching block determines the cutoff time.
|
||||
if (ipAddress is not null)
|
||||
|
||||
Reference in New Issue
Block a user