feat: rate limit emails to two per address per hour
This commit is contained in:
		
							parent
							
								
									5cb3faa92b
								
							
						
					
					
						commit
						ff8d53814d
					
				
					 6 changed files with 189 additions and 49 deletions
				
			
		|  | @ -66,6 +66,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | ||||||
|     public DbSet<Application> Applications { get; init; } = null!; |     public DbSet<Application> Applications { get; init; } = null!; | ||||||
|     public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!; |     public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!; | ||||||
|     public DbSet<DataExport> DataExports { get; init; } = null!; |     public DbSet<DataExport> DataExports { get; init; } = null!; | ||||||
|  |     public DbSet<SentEmail> SentEmails { get; init; } = null!; | ||||||
| 
 | 
 | ||||||
|     public DbSet<PrideFlag> PrideFlags { get; init; } = null!; |     public DbSet<PrideFlag> PrideFlags { get; init; } = null!; | ||||||
|     public DbSet<UserFlag> UserFlags { get; init; } = null!; |     public DbSet<UserFlag> UserFlags { get; init; } = null!; | ||||||
|  | @ -84,6 +85,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | ||||||
|         modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); |         modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); | ||||||
|         modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique(); |         modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique(); | ||||||
|         modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); |         modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); | ||||||
|  |         modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique(); | ||||||
|  |         modelBuilder.Entity<SentEmail>().HasIndex(e => new { e.Email, e.SentAt }); | ||||||
|  | 
 | ||||||
|  |         // Two indexes on auth_methods, one for fediverse auth and one for all other types. | ||||||
|         modelBuilder |         modelBuilder | ||||||
|             .Entity<AuthMethod>() |             .Entity<AuthMethod>() | ||||||
|             .HasIndex(m => new |             .HasIndex(m => new | ||||||
|  | @ -94,7 +99,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | ||||||
|             }) |             }) | ||||||
|             .HasFilter("fediverse_application_id IS NOT NULL") |             .HasFilter("fediverse_application_id IS NOT NULL") | ||||||
|             .IsUnique(); |             .IsUnique(); | ||||||
|         modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique(); |  | ||||||
| 
 | 
 | ||||||
|         modelBuilder |         modelBuilder | ||||||
|             .Entity<AuthMethod>() |             .Entity<AuthMethod>() | ||||||
|  |  | ||||||
|  | @ -0,0 +1,53 @@ | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  | 
 | ||||||
|  | #nullable disable | ||||||
|  | 
 | ||||||
|  | namespace Foxnouns.Backend.Database.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     [DbContext(typeof(DatabaseContext))] | ||||||
|  |     [Migration("20241211193653_AddSentEmailCache")] | ||||||
|  |     public partial class AddSentEmailCache : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "sent_emails", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table | ||||||
|  |                         .Column<int>(type: "integer", nullable: false) | ||||||
|  |                         .Annotation( | ||||||
|  |                             "Npgsql:ValueGenerationStrategy", | ||||||
|  |                             NpgsqlValueGenerationStrategy.IdentityByDefaultColumn | ||||||
|  |                         ), | ||||||
|  |                     email = table.Column<string>(type: "text", nullable: false), | ||||||
|  |                     sent_at = table.Column<Instant>( | ||||||
|  |                         type: "timestamp with time zone", | ||||||
|  |                         nullable: false | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_sent_emails", x => x.id); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  | 
 | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_sent_emails_email_sent_at", | ||||||
|  |                 table: "sent_emails", | ||||||
|  |                 columns: new[] { "email", "sent_at" } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropTable(name: "sent_emails"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,5 +1,4 @@ | ||||||
| // <auto-generated /> | // <auto-generated /> | ||||||
| using System; |  | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using Foxnouns.Backend.Database; | using Foxnouns.Backend.Database; | ||||||
| using Foxnouns.Backend.Database.Models; | using Foxnouns.Backend.Database.Models; | ||||||
|  | @ -20,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|         { |         { | ||||||
| #pragma warning disable 612, 618 | #pragma warning disable 612, 618 | ||||||
|             modelBuilder |             modelBuilder | ||||||
|                 .HasAnnotation("ProductVersion", "8.0.7") |                 .HasAnnotation("ProductVersion", "9.0.0") | ||||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
| 
 | 
 | ||||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  | @ -46,12 +45,12 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         .HasColumnType("text") |                         .HasColumnType("text") | ||||||
|                         .HasColumnName("name"); |                         .HasColumnName("name"); | ||||||
| 
 | 
 | ||||||
|                     b.Property<string[]>("RedirectUris") |                     b.PrimitiveCollection<string[]>("RedirectUris") | ||||||
|                         .IsRequired() |                         .IsRequired() | ||||||
|                         .HasColumnType("text[]") |                         .HasColumnType("text[]") | ||||||
|                         .HasColumnName("redirect_uris"); |                         .HasColumnName("redirect_uris"); | ||||||
| 
 | 
 | ||||||
|                     b.Property<string[]>("Scopes") |                     b.PrimitiveCollection<string[]>("Scopes") | ||||||
|                         .IsRequired() |                         .IsRequired() | ||||||
|                         .HasColumnType("text[]") |                         .HasColumnType("text[]") | ||||||
|                         .HasColumnName("scopes"); |                         .HasColumnName("scopes"); | ||||||
|  | @ -193,7 +192,7 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("fields"); |                         .HasColumnName("fields"); | ||||||
| 
 | 
 | ||||||
|                     b.Property<string[]>("Links") |                     b.PrimitiveCollection<string[]>("Links") | ||||||
|                         .IsRequired() |                         .IsRequired() | ||||||
|                         .HasColumnType("text[]") |                         .HasColumnType("text[]") | ||||||
|                         .HasColumnName("links"); |                         .HasColumnName("links"); | ||||||
|  | @ -303,6 +302,33 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     b.ToTable("pride_flags", (string)null); |                     b.ToTable("pride_flags", (string)null); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|  |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.SentEmail", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<int>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("integer") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  | 
 | ||||||
|  |                     NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | ||||||
|  | 
 | ||||||
|  |                     b.Property<string>("Email") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("email"); | ||||||
|  | 
 | ||||||
|  |                     b.Property<Instant>("SentAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("sent_at"); | ||||||
|  | 
 | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_sent_emails"); | ||||||
|  | 
 | ||||||
|  |                     b.HasIndex("Email", "SentAt") | ||||||
|  |                         .HasDatabaseName("ix_sent_emails_email_sent_at"); | ||||||
|  | 
 | ||||||
|  |                     b.ToTable("sent_emails", (string)null); | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<long>("Id") |                     b.Property<long>("Id") | ||||||
|  | @ -359,7 +385,7 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         .HasColumnType("boolean") |                         .HasColumnType("boolean") | ||||||
|                         .HasColumnName("manually_expired"); |                         .HasColumnName("manually_expired"); | ||||||
| 
 | 
 | ||||||
|                     b.Property<string[]>("Scopes") |                     b.PrimitiveCollection<string[]>("Scopes") | ||||||
|                         .IsRequired() |                         .IsRequired() | ||||||
|                         .HasColumnType("text[]") |                         .HasColumnType("text[]") | ||||||
|                         .HasColumnName("scopes"); |                         .HasColumnName("scopes"); | ||||||
|  | @ -428,7 +454,7 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("last_sid_reroll"); |                         .HasColumnName("last_sid_reroll"); | ||||||
| 
 | 
 | ||||||
|                     b.Property<string[]>("Links") |                     b.PrimitiveCollection<string[]>("Links") | ||||||
|                         .IsRequired() |                         .IsRequired() | ||||||
|                         .HasColumnType("text[]") |                         .HasColumnType("text[]") | ||||||
|                         .HasColumnName("links"); |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
							
								
								
									
										13
									
								
								Foxnouns.Backend/Database/Models/SentEmail.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								Foxnouns.Backend/Database/Models/SentEmail.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | ||||||
|  | using System.ComponentModel.DataAnnotations.Schema; | ||||||
|  | using NodaTime; | ||||||
|  | 
 | ||||||
|  | namespace Foxnouns.Backend.Database.Models; | ||||||
|  | 
 | ||||||
|  | public class SentEmail | ||||||
|  | { | ||||||
|  |     [DatabaseGenerated(DatabaseGeneratedOption.Identity)] | ||||||
|  |     public int Id { get; init; } | ||||||
|  | 
 | ||||||
|  |     public required string Email { get; init; } | ||||||
|  |     public required Instant SentAt { get; init; } | ||||||
|  | } | ||||||
|  | @ -33,13 +33,24 @@ public class DataCleanupService( | ||||||
| 
 | 
 | ||||||
|     public async Task InvokeAsync(CancellationToken ct = default) |     public async Task InvokeAsync(CancellationToken ct = default) | ||||||
|     { |     { | ||||||
|         _logger.Information("Cleaning up expired users"); |         _logger.Debug("Cleaning up sent email cache"); | ||||||
|  |         await CleanEmailsAsync(ct); | ||||||
|  | 
 | ||||||
|  |         _logger.Debug("Cleaning up expired users"); | ||||||
|         await CleanUsersAsync(ct); |         await CleanUsersAsync(ct); | ||||||
| 
 | 
 | ||||||
|         _logger.Information("Cleaning up expired data exports"); |         _logger.Debug("Cleaning up expired data exports"); | ||||||
|         await CleanExportsAsync(ct); |         await CleanExportsAsync(ct); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     private async Task CleanEmailsAsync(CancellationToken ct = default) | ||||||
|  |     { | ||||||
|  |         Instant expiry = clock.GetCurrentInstant() - Duration.FromHours(2); | ||||||
|  |         int count = await db.SentEmails.Where(e => e.SentAt < expiry).ExecuteDeleteAsync(ct); | ||||||
|  |         if (count != 0) | ||||||
|  |             _logger.Information("Deleted {Count} entries from the sent email cache", expiry); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     private async Task CleanUsersAsync(CancellationToken ct = default) |     private async Task CleanUsersAsync(CancellationToken ct = default) | ||||||
|     { |     { | ||||||
|         Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; |         Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; | ||||||
|  |  | ||||||
|  | @ -12,13 +12,25 @@ | ||||||
| // | // | ||||||
| // You should have received a copy of the GNU Affero General Public License | // You should have received a copy of the GNU Affero General Public License | ||||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | using Coravel.Mailer.Mail; | ||||||
| using Coravel.Mailer.Mail.Interfaces; | using Coravel.Mailer.Mail.Interfaces; | ||||||
| using Coravel.Queuing.Interfaces; | using Coravel.Queuing.Interfaces; | ||||||
|  | using Foxnouns.Backend.Database; | ||||||
|  | using Foxnouns.Backend.Database.Models; | ||||||
| using Foxnouns.Backend.Mailables; | using Foxnouns.Backend.Mailables; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
| 
 | 
 | ||||||
| namespace Foxnouns.Backend.Services; | namespace Foxnouns.Backend.Services; | ||||||
| 
 | 
 | ||||||
| public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config) | public class MailService( | ||||||
|  |     ILogger logger, | ||||||
|  |     IMailer mailer, | ||||||
|  |     IQueue queue, | ||||||
|  |     IClock clock, | ||||||
|  |     Config config, | ||||||
|  |     IServiceProvider serviceProvider | ||||||
|  | ) | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<MailService>(); |     private readonly ILogger _logger = logger.ForContext<MailService>(); | ||||||
| 
 | 
 | ||||||
|  | @ -26,10 +38,8 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | ||||||
|     { |     { | ||||||
|         queue.QueueAsyncTask(async () => |         queue.QueueAsyncTask(async () => | ||||||
|         { |         { | ||||||
|             _logger.Debug("Sending account creation email to {ToEmail}", to); |             await SendEmailAsync( | ||||||
|             try |                 to, | ||||||
|             { |  | ||||||
|                 await mailer.SendAsync( |  | ||||||
|                 new AccountCreationMailable( |                 new AccountCreationMailable( | ||||||
|                     config, |                     config, | ||||||
|                     new AccountCreationMailableView |                     new AccountCreationMailableView | ||||||
|  | @ -40,11 +50,6 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | ||||||
|                     } |                     } | ||||||
|                 ) |                 ) | ||||||
|             ); |             ); | ||||||
|             } |  | ||||||
|             catch (Exception exc) |  | ||||||
|             { |  | ||||||
|                 _logger.Error(exc, "Sending account creation email"); |  | ||||||
|             } |  | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -53,9 +58,8 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | ||||||
|         _logger.Debug("Sending add email address email to {ToEmail}", to); |         _logger.Debug("Sending add email address email to {ToEmail}", to); | ||||||
|         queue.QueueAsyncTask(async () => |         queue.QueueAsyncTask(async () => | ||||||
|         { |         { | ||||||
|             try |             await SendEmailAsync( | ||||||
|             { |                 to, | ||||||
|                 await mailer.SendAsync( |  | ||||||
|                 new AddEmailMailable( |                 new AddEmailMailable( | ||||||
|                     config, |                     config, | ||||||
|                     new AddEmailMailableView |                     new AddEmailMailableView | ||||||
|  | @ -67,11 +71,40 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | ||||||
|                     } |                     } | ||||||
|                 ) |                 ) | ||||||
|             ); |             ); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private async Task SendEmailAsync<T>(string to, Mailable<T> mailable) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             // ReSharper disable SuggestVarOrType_SimpleTypes | ||||||
|  |             await using var scope = serviceProvider.CreateAsyncScope(); | ||||||
|  |             await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); | ||||||
|  |             // ReSharper restore SuggestVarOrType_SimpleTypes | ||||||
|  | 
 | ||||||
|  |             Instant now = clock.GetCurrentInstant(); | ||||||
|  | 
 | ||||||
|  |             int count = await db.SentEmails.CountAsync(e => | ||||||
|  |                 e.Email == to && e.SentAt > (now - Duration.FromHours(1)) | ||||||
|  |             ); | ||||||
|  |             if (count >= 2) | ||||||
|  |             { | ||||||
|  |                 _logger.Information( | ||||||
|  |                     "Have already sent 2 or more emails to {ToAddress} in the past hour, not sending new email", | ||||||
|  |                     to | ||||||
|  |                 ); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             await mailer.SendAsync(mailable); | ||||||
|  | 
 | ||||||
|  |             db.SentEmails.Add(new SentEmail { Email = to, SentAt = now }); | ||||||
|  |             await db.SaveChangesAsync(); | ||||||
|         } |         } | ||||||
|         catch (Exception exc) |         catch (Exception exc) | ||||||
|         { |         { | ||||||
|                 _logger.Error(exc, "Sending add email address email"); |             _logger.Error(exc, "Sending email"); | ||||||
|             } |         } | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue