diff --git a/apps/server/AliasVault.Admin/Main/Models/RecentUsageAccountDeletions.cs b/apps/server/AliasVault.Admin/Main/Models/RecentUsageAccountDeletions.cs new file mode 100644 index 000000000..153027c57 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/RecentUsageAccountDeletions.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing usernames with most account deletions in the last 72 hours. +/// +public class RecentUsageAccountDeletions +{ + /// + /// Gets or sets the username. + /// + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the number of account deletions for this username in the last 72 hours. + /// + public int DeletionCount72h { get; set; } + + /// + /// Gets or sets the date when the most recent account with this username was registered. + /// + public DateTime? LastRegistrationDate { get; set; } + + /// + /// Gets or sets the date when the most recent account with this username was deleted. + /// + public DateTime? LastDeletionDate { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Models/RecentUsageDeletionsByIp.cs b/apps/server/AliasVault.Admin/Main/Models/RecentUsageDeletionsByIp.cs new file mode 100644 index 000000000..6202d706c --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/RecentUsageDeletionsByIp.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing IP addresses with most account deletions in the last 72 hours. +/// +public class RecentUsageDeletionsByIp +{ + /// + /// Gets or sets the original IP address (for linking purposes). + /// + public string OriginalIpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the anonymized IP address. + /// + public string IpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the number of account deletions from this IP in the last 72 hours. + /// + public int DeletionCount72h { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs b/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs index 4c461b131..32fb6687c 100644 --- a/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs +++ b/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs @@ -31,4 +31,14 @@ public class RecentUsageStatistics /// Gets or sets the list of IP addresses with most mobile login requests in the last 72 hours. /// public List TopIpsByMobileLogins72h { get; set; } = new(); + + /// + /// Gets or sets the list of IP addresses with most account deletions in the last 72 hours. + /// + public List TopIpsByDeletions72h { get; set; } = new(); + + /// + /// Gets or sets the list of usernames with most account deletions in the last 72 hours. + /// + public List TopUsernamesByDeletions72h { get; set; } = new(); } diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageAccountDeletionsTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageAccountDeletionsTable.razor new file mode 100644 index 000000000..3ef5237ed --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageAccountDeletionsTable.razor @@ -0,0 +1,83 @@ +@using AliasVault.Admin.Main.Models +@using AliasVault.RazorComponents.Tables + +
+
+
+

Top Usernames by Account Deletions (Last 72h)

+

Usernames with the most account deletion events in the last 72 hours

+
+
+ + @if (Data != null && Data.Any()) + { +
+ +
+
+ + @foreach (var deletion in PagedData) + { + + + + @deletion.Username + + + @deletion.DeletionCount72h.ToString("N0") + + @if (deletion.LastDeletionDate.HasValue) + { + @deletion.LastDeletionDate.Value.ToString("yyyy-MM-dd HH:mm:ss") UTC + } + else + { + - + } + + + } + +
+ } + else if (Data != null) + { +
+ + + +

No Recent Account Deletions

+

No account deletions occurred in the last 72 hours.

+
+ } + else + { +
+ +
+ } +
+ +@code { + [Parameter] + public List? Data { get; set; } + + private int CurrentPage { get; set; } = 1; + private int PageSize { get; set; } = 10; + + private IEnumerable PagedData => + Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); + + private readonly List _tableColumns = new() + { + new() { Title = "Username", PropertyName = "Username", Sortable = false }, + new() { Title = "Deletions (72h)", PropertyName = "DeletionCount72h", Sortable = false }, + new() { Title = "Last Deletion", PropertyName = "LastDeletionDate", Sortable = false } + }; + + private void HandlePageChanged(int page) + { + CurrentPage = page; + StateHasChanged(); + } +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageAliasesTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageAliasesTable.razor index 612400914..cf2c6c8e1 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageAliasesTable.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageAliasesTable.razor @@ -60,7 +60,7 @@ public List? Data { get; set; } private int CurrentPage { get; set; } = 1; - private int PageSize { get; set; } = 20; + private int PageSize { get; set; } = 10; private IEnumerable PagedData => Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageDeletionsByIpTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageDeletionsByIpTable.razor new file mode 100644 index 000000000..669e15c80 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageDeletionsByIpTable.razor @@ -0,0 +1,72 @@ +@using AliasVault.Admin.Main.Models +@using AliasVault.RazorComponents.Tables + +
+
+
+

Top IP Addresses by Account Deletions (Last 72h)

+

IP addresses with the most account deletions in the last 72 hours (last octet anonymized)

+
+
+ + @if (Data != null && Data.Any()) + { +
+ +
+
+ + @foreach (var ip in PagedData) + { + + + + @ip.IpAddress + + + @ip.DeletionCount72h.ToString("N0") + + } + +
+ } + else if (Data != null) + { +
+ + + +

No Recent Account Deletions

+

No account deletions occurred in the last 72 hours.

+
+ } + else + { +
+ +
+ } +
+ +@code { + [Parameter] + public List? Data { get; set; } + + private int CurrentPage { get; set; } = 1; + private int PageSize { get; set; } = 10; + + private IEnumerable PagedData => + Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); + + private readonly List _tableColumns = new() + { + new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false }, + new() { Title = "Deletions (72h)", PropertyName = "DeletionCount72h", Sortable = false } + }; + + private void HandlePageChanged(int page) + { + CurrentPage = page; + StateHasChanged(); + } +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageEmailsTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageEmailsTable.razor index a3122fe08..b642f80db 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageEmailsTable.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageEmailsTable.razor @@ -61,7 +61,7 @@ public List? Data { get; set; } private int CurrentPage { get; set; } = 1; - private int PageSize { get; set; } = 20; + private int PageSize { get; set; } = 10; private IEnumerable PagedData => Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor index e77521759..b52f2280a 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor @@ -53,7 +53,7 @@ public List? Data { get; set; } private int CurrentPage { get; set; } = 1; - private int PageSize { get; set; } = 20; + private int PageSize { get; set; } = 10; private IEnumerable PagedData => Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageRegistrationsTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageRegistrationsTable.razor index 7bfe05d5e..a8eb70026 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageRegistrationsTable.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageRegistrationsTable.razor @@ -53,7 +53,7 @@ public List? Data { get; set; } private int CurrentPage { get; set; } = 1; - private int PageSize { get; set; } = 20; + private int PageSize { get; set; } = 10; private IEnumerable PagedData => Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor index 4c313e585..dc2432817 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor @@ -30,6 +30,12 @@ + + + + + + @if (_loadingError) diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs index 5e90c8511..a9a73a9e5 100644 --- a/apps/server/AliasVault.Admin/Services/StatisticsService.cs +++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs @@ -116,6 +116,8 @@ public class StatisticsService GetTopUsersByEmails72hAsync().ContinueWith(t => stats.TopUsersByEmails72h = t.Result), GetTopIpsByRegistrations72hAsync().ContinueWith(t => stats.TopIpsByRegistrations72h = t.Result), GetTopIpsByMobileLogins72hAsync().ContinueWith(t => stats.TopIpsByMobileLogins72h = t.Result), + GetTopIpsByDeletions72hAsync().ContinueWith(t => stats.TopIpsByDeletions72h = t.Result), + GetTopUsernamesByDeletions72hAsync().ContinueWith(t => stats.TopUsernamesByDeletions72h = t.Result), }; await Task.WhenAll(tasks); @@ -475,7 +477,7 @@ public class StatisticsService } /// - /// Gets the top 20 users by number of aliases created in the last 72 hours. + /// Gets the top 100 users by number of aliases created in the last 72 hours. /// /// List of top users by recent aliases. private async Task> GetTopUsersByAliases72hAsync() @@ -495,7 +497,7 @@ public class StatisticsService AliasCount72h = g.Count(), }) .OrderByDescending(u => u.AliasCount72h) - .Take(20) + .Take(100) .ToListAsync(); return topUsers.Select(u => new RecentUsageAliases @@ -509,7 +511,7 @@ public class StatisticsService } /// - /// Gets the top 20 users by number of emails received in the last 72 hours. + /// Gets the top 100 users by number of emails received in the last 72 hours. /// /// List of top users by recent emails. private async Task> GetTopUsersByEmails72hAsync() @@ -529,7 +531,7 @@ public class StatisticsService EmailCount72h = g.Count(), }) .OrderByDescending(u => u.EmailCount72h) - .Take(20) + .Take(100) .ToListAsync(); return topUsers.Select(u => new RecentUsageEmails @@ -543,7 +545,7 @@ public class StatisticsService } /// - /// Gets the top 20 IP addresses by number of registrations in the last 72 hours. + /// Gets the top 100 IP addresses by number of registrations in the last 72 hours. /// /// List of top IP addresses by recent registrations. private async Task> GetTopIpsByRegistrations72hAsync() @@ -565,7 +567,7 @@ public class StatisticsService RegistrationCount72h = g.Count(), }) .OrderByDescending(ip => ip.RegistrationCount72h) - .Take(20) + .Take(100) .ToListAsync(); return topIps.Select(ip => new RecentUsageRegistrations @@ -577,7 +579,7 @@ public class StatisticsService } /// - /// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours. + /// Gets the top 100 IP addresses by number of mobile login requests in the last 72 hours. /// /// List of top IP addresses by mobile login requests. private async Task> GetTopIpsByMobileLogins72hAsync() @@ -597,7 +599,7 @@ public class StatisticsService MobileLoginCount72h = g.Count(), }) .OrderByDescending(ip => ip.MobileLoginCount72h) - .Take(20) + .Take(100) .ToListAsync(); return topIps.Select(ip => new RecentUsageMobileLogins @@ -607,4 +609,70 @@ public class StatisticsService MobileLoginCount72h = ip.MobileLoginCount72h, }).ToList(); } + + /// + /// Gets the top 100 IP addresses by number of account deletions in the last 72 hours. + /// + /// List of top IP addresses by recent account deletions. + private async Task> GetTopIpsByDeletions72hAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + var cutoffDate = DateTime.UtcNow.AddHours(-72); + + // Get account deletions by IP from auth logs (using AccountDeletion event type) + var topIps = await context.AuthLogs + .Where(al => al.Timestamp >= cutoffDate && + al.IpAddress != null && + al.IpAddress != "xxx.xxx.xxx.xxx" && + al.EventType == AuthEventType.AccountDeletion) + .GroupBy(al => al.IpAddress) + .Select(g => new + { + IpAddress = g.Key, + DeletionCount72h = g.Count(), + }) + .OrderByDescending(ip => ip.DeletionCount72h) + .Take(100) + .ToListAsync(); + + return topIps.Select(ip => new RecentUsageDeletionsByIp + { + OriginalIpAddress = ip.IpAddress!, + IpAddress = AnonymizeIpAddress(ip.IpAddress!), + DeletionCount72h = ip.DeletionCount72h, + }).ToList(); + } + + /// + /// Gets the top 100 usernames by number of account deletions in the last 72 hours. + /// + /// List of top usernames by recent account deletions. + private async Task> GetTopUsernamesByDeletions72hAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + var cutoffDate = DateTime.UtcNow.AddHours(-72); + + // Get account deletions by username from auth logs (using AccountDeletion event type) + var topUsernames = await context.AuthLogs + .Where(al => al.Timestamp >= cutoffDate && + al.Username != null && + al.EventType == AuthEventType.AccountDeletion) + .GroupBy(al => al.Username) + .Select(g => new + { + Username = g.Key, + DeletionCount72h = g.Count(), + LastDeletionDate = g.Max(al => al.Timestamp), + }) + .OrderByDescending(u => u.DeletionCount72h) + .Take(100) + .ToListAsync(); + + return topUsernames.Select(u => new RecentUsageAccountDeletions + { + Username = u.Username!, + DeletionCount72h = u.DeletionCount72h, + LastDeletionDate = u.LastDeletionDate, + }).ToList(); + } }