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…
Reference in a new issue