feat: add contact blacklist

This commit is contained in:
Oliver Booth 2023-12-22 14:26:18 +00:00
parent 25822c6577
commit 1f6825c9df
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
10 changed files with 256 additions and 18 deletions

View File

@ -0,0 +1,47 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Controllers;
[Controller]
[Route("contact/blacklist/formatted")]
public class FormattedBlacklistController : Controller
{
private readonly IContactService _contactService;
/// <summary>
/// Initializes a new instance of the <see cref="FormattedBlacklistController" /> class.
/// </summary>
/// <param name="contactService">The <see cref="IContactService" />.</param>
public FormattedBlacklistController(IContactService contactService)
{
_contactService = contactService;
}
[HttpGet("{format}")]
public IActionResult OnGet([FromRoute] string format)
{
IReadOnlyCollection<IBlacklistEntry> blacklist = _contactService.GetBlacklist();
switch (format)
{
case "json":
return Json(blacklist);
case "csv":
var builder = new StringBuilder();
builder.AppendLine("EmailAddress,Name,Reason");
foreach (IBlacklistEntry entry in blacklist)
{
builder.AppendLine($"{entry.EmailAddress},{entry.Name},{entry.Reason}");
}
return Content(builder.ToString(), "text/csv", Encoding.UTF8);
}
return NotFound();
}
}

View File

@ -0,0 +1,75 @@
namespace OliverBooth.Data.Web;
/// <inheritdoc cref="IBlacklistEntry"/>
internal sealed class BlacklistEntry : IEquatable<BlacklistEntry>, IBlacklistEntry
{
/// <inheritdoc/>
public string EmailAddress { get; internal set; } = string.Empty;
/// <inheritdoc/>
public string Name { get; internal set; } = string.Empty;
/// <inheritdoc/>
public string Reason { get; internal set; } = string.Empty;
/// <summary>
/// Returns a value indicating whether two instances of <see cref="BlacklistEntry" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="BlacklistEntry" /> to compare.</param>
/// <param name="right">The second instance of <see cref="BlacklistEntry" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator ==(BlacklistEntry? left, BlacklistEntry? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="BlacklistEntry" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="BlacklistEntry" /> to compare.</param>
/// <param name="right">The second instance of <see cref="BlacklistEntry" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator !=(BlacklistEntry? left, BlacklistEntry? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="BlacklistEntry" /> is equal to another
/// instance.
/// </summary>
/// <param name="other">An instance to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
/// <see langword="false" />.
/// </returns>
public bool Equals(BlacklistEntry? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return EmailAddress.Equals(other.EmailAddress);
}
/// <summary>
/// Returns a value indicating whether this instance is equal to a specified object.
/// </summary>
/// <param name="obj">An object to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="BlacklistEntry" /> and
/// equals the value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is BlacklistEntry other && Equals(other);
}
/// <summary>
/// Gets the hash code for this instance.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return EmailAddress.GetHashCode();
}
}

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="BlacklistEntry" /> entity.
/// </summary>
internal sealed class BlacklistEntryConfiguration : IEntityTypeConfiguration<BlacklistEntry>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<BlacklistEntry> builder)
{
builder.ToTable("ContactBlacklist");
builder.HasKey(entry => entry.EmailAddress);
builder.Property(entry => entry.EmailAddress).IsRequired();
builder.Property(entry => entry.Name).IsRequired();
builder.Property(entry => entry.Reason).IsRequired();
}
}

View File

@ -0,0 +1,25 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents an entry in the blacklist.
/// </summary>
public interface IBlacklistEntry
{
/// <summary>
/// Gets the email address of the entry.
/// </summary>
/// <value>The email address of the entry.</value>
string EmailAddress { get; }
/// <summary>
/// Gets the name of the entry.
/// </summary>
/// <value>The name of the entry.</value>
string Name { get; }
/// <summary>
/// Gets the reason for the entry.
/// </summary>
/// <value>The reason for the entry.</value>
string Reason { get; }
}

View File

@ -25,6 +25,12 @@ internal sealed class WebContext : DbContext
/// <value>The collection of books.</value> /// <value>The collection of books.</value>
public DbSet<Book> Books { get; private set; } = null!; public DbSet<Book> Books { get; private set; } = null!;
/// <summary>
/// Gets the collection of blacklist entries in the database.
/// </summary>
/// <value>The collection of blacklist entries.</value>
public DbSet<BlacklistEntry> ContactBlacklist { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets the collection of projects in the database. /// Gets the collection of projects in the database.
/// </summary> /// </summary>
@ -53,6 +59,7 @@ internal sealed class WebContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.ApplyConfiguration(new BlacklistEntryConfiguration());
modelBuilder.ApplyConfiguration(new BookConfiguration()); modelBuilder.ApplyConfiguration(new BookConfiguration());
modelBuilder.ApplyConfiguration(new ProjectConfiguration()); modelBuilder.ApplyConfiguration(new ProjectConfiguration());
modelBuilder.ApplyConfiguration(new TemplateConfiguration()); modelBuilder.ApplyConfiguration(new TemplateConfiguration());

View File

@ -0,0 +1,36 @@
@page
@using OliverBooth.Data.Web
@using OliverBooth.Services
@inject IContactService ContactService
@{
ViewData["Title"] = "Blacklist";
}
<h1 class="display-4">Contact Blacklist</h1>
<p>
Below is a list of email addresses that have been blocked from contacting me. This list is public so that others may
also block these addresses if they wish. Any email address that contains an asterisk (*) is a wildcard, meaning that
any email address that matches the pattern will be blocked.
</p>
<p>
You can view this list in JSON format
<a asp-controller="FormattedBlacklist" asp-action="OnGet" asp-route-format="json">here</a>,
or in CSV format
<a asp-controller="FormattedBlacklist" asp-action="OnGet" asp-route-format="csv">here</a>.
</p>
<table class="table">
<tr>
<th>Name / Email</th>
<th>Reason</th>
</tr>
@foreach (IBlacklistEntry entry in ContactService.GetBlacklist())
{
<tr>
<td>@entry.Name &lt;@entry.EmailAddress&gt;</td>
<td>@entry.Reason</td>
</tr>
}
</table>

View File

@ -12,26 +12,12 @@
<div class="alert alert-warning"> <div class="alert alert-warning">
<p class="lead"><i class="fa-solid fa-triangle-exclamation"></i> Spam warning</p> <p class="lead"><i class="fa-solid fa-triangle-exclamation"></i> Spam warning</p>
<p>
In the short time that this site has been live, I have received a considerable amount of spam that I suspect is
automated. As such, I added background reCaptcha validation to this form to minimise the amount of
bot-written junk I receive. However, some spam still gets through - that means there are humans reading this.
</p>
<p> <p>
I am politely asking that you respect my inbox and keep your unsolicited advertising away. This is a simple I am politely asking that you respect my inbox and keep your unsolicited advertising away. This is a simple
request. request. If you send me any kind of spam after this, you have demonstrated that you do not have the basic human
</p> decency to respect my wishes or my privacy, and you have lost the privilege for me to respect yours. I
<p> <strong>will</strong> block your email address, and I will add your name to my public blacklist of spammers,
If you send me any kind of spam after this, you have demonstrated that you do not have the basic human decency which you can find <a asp-page="/Contact/Blacklist">here</a>.
to respect my wishes or my privacy, and you have lost the privilege for me to respect yours. I
<strong>will</strong> block your email address and report you to your email provider. I will also begin to
maintain a public blacklist of spammers, so that others may do the same. You will lose all credibility, and you
<strong>will</strong> be publicly shamed for it.
</p>
<p>
This <strong>does not apply</strong> to legitimate businesses who are offering something genuine
<span style="text-decoration: underline;">to me specifically</span>, nor to individuals who are interested in my
services.
</p> </p>
</div> </div>

View File

@ -32,6 +32,7 @@ builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
builder.Services.AddDbContextFactory<BlogContext>(); builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddDbContextFactory<WebContext>(); builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddSingleton<IContactService, ContactService>();
builder.Services.AddSingleton<ITemplateService, TemplateService>(); builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>(); builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IBlogUserService, BlogUserService>(); builder.Services.AddSingleton<IBlogUserService, BlogUserService>();

View File

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <inheritdoc cref="IContactService" />
internal sealed class ContactService : IContactService
{
private readonly IDbContextFactory<WebContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ContactService" /> class.
/// </summary>
/// <param name="dbContextFactory">The <see cref="IDbContextFactory{TContext}" />.</param>
public ContactService(IDbContextFactory<WebContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public IReadOnlyCollection<IBlacklistEntry> GetBlacklist()
{
using WebContext context = _dbContextFactory.CreateDbContext();
return context.ContactBlacklist.OrderBy(b => b.EmailAddress).ToArray();
}
}

View File

@ -0,0 +1,14 @@
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for managing contact information.
/// </summary>
public interface IContactService
{
/// <summary>
/// Gets the blacklist.
/// </summary>
IReadOnlyCollection<IBlacklistEntry> GetBlacklist();
}