diff --git a/.idea/.idea.ProjectLighthouse/.idea/sqldialects.xml b/.idea/.idea.ProjectLighthouse/.idea/sqldialects.xml deleted file mode 100644 index 56782cab..00000000 --- a/.idea/.idea.ProjectLighthouse/.idea/sqldialects.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ProjectLighthouse.Localization/BaseLayout.resx b/ProjectLighthouse.Localization/BaseLayout.resx index 65d1229f..5a44aaea 100644 --- a/ProjectLighthouse.Localization/BaseLayout.resx +++ b/ProjectLighthouse.Localization/BaseLayout.resx @@ -93,4 +93,13 @@ This instance is currently in read-only mode. Level and photo uploads, comments, reviews, and certain profile changes will be restricted until read-only mode is disabled. + + This instance has email enforcement enabled. If you haven't already, you will need to set and verify an email address to use most features. + + + You do not have an email set on your account. You can set an email by opening the text chat and typing "/setemail [youremail@example.com]" (do not include the brackets.) + + + You have set an email address on your account, but you have not verified it. Make sure to check your inbox for a verification email. If you have not recieved an email, please contact an instance administrator for further assistance. + \ No newline at end of file diff --git a/ProjectLighthouse.Localization/StringLists/BaseLayoutStrings.cs b/ProjectLighthouse.Localization/StringLists/BaseLayoutStrings.cs index f46cb4e6..38e12333 100644 --- a/ProjectLighthouse.Localization/StringLists/BaseLayoutStrings.cs +++ b/ProjectLighthouse.Localization/StringLists/BaseLayoutStrings.cs @@ -26,5 +26,10 @@ public static class BaseLayoutStrings public static readonly TranslatableString ReadOnlyWarnTitle = create("read_only_warn_title"); public static readonly TranslatableString ReadOnlyWarn = create("read_only_warn"); + public static readonly TranslatableString EmailEnforcementWarnMain = create("email_enforcement_message_main"); + public static readonly TranslatableString EmailEnforcementWarnNoEmail = create("email_enforcement_message_no_email"); + public static readonly TranslatableString EmailEnforcementWarnVerifyEmail = create("email_enforcement_message_verify_email"); + + private static TranslatableString create(string key) => new(TranslationAreas.BaseLayout, key); } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/CommentController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/CommentController.cs index 7e3baeb9..8f550acf 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/CommentController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/CommentController.cs @@ -49,7 +49,7 @@ public class CommentController : ControllerBase GameTokenEntity token = this.GetToken(); UserEntity? user = await this.database.UserFromGameToken(token); - if (user == null) return this.Unauthorized(); + if (user == null) return this.Forbid(); if ((slotId == 0 || SlotHelper.IsTypeInvalid(slotType)) == (username == null)) return this.BadRequest(); @@ -97,7 +97,7 @@ public class CommentController : ControllerBase .ApplyPagination(pageData) .ToListAsync()).ToSerializableList(c => GameComment.CreateFromEntity(c, token.UserId)); - if (type == CommentType.Level && slotType == "developer" && user.IsModerator && pageData.PageStart == 1) + if (type == CommentType.Level && slotType == "developer" && user.IsModerator && pageData.PageStart == 1) { comments.Insert(0, new GameComment { diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs index e8b2aa37..428580b8 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs @@ -4,6 +4,7 @@ using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; using LBPUnion.ProjectLighthouse.Types.Entities.Level; +using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Token; using LBPUnion.ProjectLighthouse.Types.Users; using Microsoft.AspNetCore.Authorization; @@ -100,6 +101,9 @@ public class EnterLevelController : ControllerBase { GameTokenEntity token = this.GetToken(); + UserEntity? user = await this.database.UserFromGameToken(token); + if (user == null) return this.Unauthorized(); + if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest(); if (slotType == "developer") return this.Ok(); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs index 2b3b295b..ffba0d6a 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs @@ -41,7 +41,7 @@ public class MatchController : ControllerBase GameTokenEntity token = this.GetToken(); UserEntity? user = await this.database.UserFromGameToken(token); - if (user == null) return this.Forbid(); + if (user == null) return this.Unauthorized(); await LastContactHelper.SetLastContact(this.database, user, token.GameVersion, token.Platform); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs index 42b1d1b7..b6948dfd 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs @@ -55,16 +55,31 @@ along with this program. If not, see ."; { GameTokenEntity token = this.GetToken(); - string username = await this.database.UsernameFromGameToken(token); + UserEntity? user = await this.database.UserFromGameToken(token); + if (user == null) return this.BadRequest(); StringBuilder announceText = new(ServerConfiguration.Instance.AnnounceText); - announceText.Replace("%user", username); + announceText.Replace("%user", user.Username); announceText.Replace("%id", token.UserId.ToString()); if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) { - announceText.Insert(0, BaseLayoutStrings.ReadOnlyWarn.Translate(LocalizationManager.DefaultLang) + "\n\n"); + announceText.Append(BaseLayoutStrings.ReadOnlyWarn.Translate(LocalizationManager.DefaultLang) + "\n\n"); + } + + if (ServerConfiguration.Instance.EmailEnforcement.EnableEmailEnforcement) + { + announceText.Append("\n\n" + BaseLayoutStrings.EmailEnforcementWarnMain.Translate(LocalizationManager.DefaultLang) + "\n\n"); + + if (user.EmailAddress == null) + { + announceText.Append(BaseLayoutStrings.EmailEnforcementWarnNoEmail.Translate(LocalizationManager.DefaultLang) + "\n\n"); + } + else if (!user.EmailAddressVerified) + { + announceText.Append(BaseLayoutStrings.EmailEnforcementWarnVerifyEmail.Translate(LocalizationManager.DefaultLang) + "\n\n"); + } } #if DEBUG @@ -129,12 +144,12 @@ along with this program. If not, see ."; if (message.StartsWith("/setemail ") && ServerConfiguration.Instance.Mail.MailEnabled) { string email = message[(message.IndexOf(" ", StringComparison.Ordinal)+1)..]; - if (!SanitizationHelper.IsValidEmail(email)) return this.Ok(); - if (await this.database.Users.AnyAsync(u => u.EmailAddress == email)) return this.Ok(); + // Return a bad request on invalid email address + if (!SMTPHelper.IsValidEmail(this.database, email)) return this.BadRequest(); UserEntity? user = await this.database.UserFromGameToken(token); - if (user == null || user.EmailAddressVerified) return this.Ok(); + if (user == null || user.EmailAddressVerified) return this.BadRequest(); user.EmailAddress = email; await SMTPHelper.SendVerificationEmail(this.database, mailService, user); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs index 4c73d0a0..80798067 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs @@ -174,7 +174,6 @@ public class PhotosController : ControllerBase [HttpGet("photos/{slotType}/{id:int}")] public async Task SlotPhotos(string slotType, int id, [FromQuery] string? by) { - if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest(); if (slotType == "developer") id = await SlotHelper.GetPlaceholderSlotId(this.database, id, SlotType.Developer); @@ -202,7 +201,6 @@ public class PhotosController : ControllerBase [HttpGet("photos/by")] public async Task UserPhotosBy(string user) { - int targetUserId = await this.database.UserIdFromUsername(user); if (targetUserId == 0) return this.NotFound(); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs index 7231ccaf..081614d6 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs @@ -19,7 +19,6 @@ namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers.Resources; [Route("LITTLEBIGPLANETPS3_XML")] public class ResourcesController : ControllerBase { - [HttpPost("showModerated")] public IActionResult ShowModerated() => this.Ok(new ResourceList()); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs index 60ac1c58..214bc82f 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs @@ -38,9 +38,6 @@ public class CategoryController : ControllerBase { GameTokenEntity token = this.GetToken(); - UserEntity? user = await this.database.UserFromGameToken(token); - if (user == null) return this.Forbid(); - PaginationData pageData = this.Request.GetPaginationData(); pageData.TotalElements = CategoryHelper.Categories.Count(c => !string.IsNullOrWhiteSpace(c.Name)); @@ -72,9 +69,6 @@ public class CategoryController : ControllerBase { GameTokenEntity token = this.GetToken(); - UserEntity? user = await this.database.UserFromGameToken(token); - if (user == null) return this.Forbid(); - Category? category = CategoryHelper.Categories.FirstOrDefault(c => c.Endpoint == endpointName); if (category == null) return this.NotFound(); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs index dfb42c71..aee40fa8 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs @@ -40,7 +40,7 @@ public class PublishController : ControllerBase public async Task StartPublish() { GameTokenEntity token = this.GetToken(); - + UserEntity? user = await this.database.UserFromGameToken(token); if (user == null) return this.Forbid(); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/UserController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/UserController.cs index 7301bc52..463e2fee 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/UserController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/UserController.cs @@ -36,7 +36,7 @@ public class UserController : ControllerBase [HttpGet("user/{username}")] public async Task GetUser(string username) - { + { UserEntity? user = await this.database.Users.FirstOrDefaultAsync(u => u.Username == username); if (user == null) return this.NotFound(); diff --git a/ProjectLighthouse.Servers.GameServer/Middlewares/EmailEnforcementMiddleware.cs b/ProjectLighthouse.Servers.GameServer/Middlewares/EmailEnforcementMiddleware.cs new file mode 100644 index 00000000..7482439c --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Middlewares/EmailEnforcementMiddleware.cs @@ -0,0 +1,62 @@ +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Database; +using LBPUnion.ProjectLighthouse.Middlewares; +using LBPUnion.ProjectLighthouse.Types.Entities.Profile; +using LBPUnion.ProjectLighthouse.Types.Entities.Token; + +namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares; + +public class EmailEnforcementMiddleware : MiddlewareDBContext +{ + private static readonly HashSet enforcedPaths = ServerConfiguration.Instance.EmailEnforcement.BlockedEndpoints; + + public EmailEnforcementMiddleware(RequestDelegate next) : base(next) + { } + + public override async Task InvokeAsync(HttpContext context, DatabaseContext database) + { + if (ServerConfiguration.Instance.EmailEnforcement.EnableEmailEnforcement) + { + // Split path into segments + string[] pathSegments = context.Request.Path.ToString().Split("/", StringSplitOptions.RemoveEmptyEntries); + + if (pathSegments[0] == "LITTLEBIGPLANETPS3_XML") + { + // Get user via GameToken + GameTokenEntity? token = await database.GameTokenFromRequest(context.Request); + UserEntity? user = await database.UserFromGameToken(token); + + // Check second part of path to see if client is within an enforced path + // This could probably be reworked, seeing as you may want to check for a deeper sub-path + // But it should be perfectly fine for now + if (enforcedPaths.Contains(pathSegments[1])) + { + // Check if user is valid, don't want any exceptions + if (user == null) + { + // Send bad request status + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync("Not a valid user"); + + // Don't go to next in pipeline + return; + } + + // Check if email is there and verified + if (!user.EmailAddressVerified || user.EmailAddress == null) + { + // Send bad request status + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Invalid user email address"); + + // Don't go to next in pipeline + return; + } + } + } + } + + // Go to next in pipeline + await this.next(context); + } +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs b/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs index 0d372e8f..8b91a3e6 100644 --- a/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs +++ b/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs @@ -107,6 +107,7 @@ public class GameServerStartup app.UseMiddleware(); app.UseMiddleware(computeDigests); app.UseMiddleware(); + app.UseMiddleware(); app.UseRouting(); diff --git a/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs index b760f03e..7cca3b9c 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs @@ -39,7 +39,7 @@ public class SetEmailForm : BaseLayout UserEntity? user = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == token.UserId); if (user == null) return this.Redirect("~/login"); - if (!SanitizationHelper.IsValidEmail(emailAddress)) + if (!SMTPHelper.IsValidEmail(this.Database, emailAddress)) { this.Error = this.Translate(ErrorStrings.EmailInvalid); return this.Page(); diff --git a/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs index 9d9aeff7..c4bb84da 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs @@ -38,9 +38,9 @@ public class PasswordResetRequestForm : BaseLayout return this.Page(); } - if (!SanitizationHelper.IsValidEmail(email)) + if (!SMTPHelper.IsValidEmail(this.Database, email)) { - this.Error = "This email is in an invalid format"; + this.Error = "This email is invalid"; return this.Page(); } diff --git a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs index 55375a42..8904e294 100644 --- a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs @@ -64,18 +64,14 @@ public class UserSettingsPage : BaseLayout } } - if (ServerConfiguration.Instance.Mail.MailEnabled && - SanitizationHelper.IsValidEmail(email) && + if (ServerConfiguration.Instance.Mail.MailEnabled && + email != null && SMTPHelper.IsValidEmail(this.Database, email) && (this.User == this.ProfileUser || this.User.IsAdmin)) { - // if email hasn't already been used - if (!await this.Database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == email!.ToLower())) + if (this.ProfileUser.EmailAddress != email) { - if (this.ProfileUser.EmailAddress != email) - { - this.ProfileUser.EmailAddress = email; - this.ProfileUser.EmailAddressVerified = false; - } + this.ProfileUser.EmailAddress = email; + this.ProfileUser.EmailAddressVerified = false; } } diff --git a/ProjectLighthouse.Tests.GameApiTests/Unit/Controllers/MessageControllerTests.cs b/ProjectLighthouse.Tests.GameApiTests/Unit/Controllers/MessageControllerTests.cs index a0b42a0a..acda5cbc 100644 --- a/ProjectLighthouse.Tests.GameApiTests/Unit/Controllers/MessageControllerTests.cs +++ b/ProjectLighthouse.Tests.GameApiTests/Unit/Controllers/MessageControllerTests.cs @@ -224,7 +224,7 @@ along with this program. If not, see ." + "\nuni IActionResult result = await messageController.Filter(mailMock.Object); - Assert.IsType(result); + Assert.IsType(result); mailMock.Verify(x => x.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -249,7 +249,7 @@ along with this program. If not, see ." + "\nuni IActionResult result = await messageController.Filter(mailMock.Object); - Assert.IsType(result); + Assert.IsType(result); mailMock.Verify(x => x.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -271,7 +271,7 @@ along with this program. If not, see ." + "\nuni IActionResult result = await messageController.Filter(mailMock.Object); - Assert.IsType(result); + Assert.IsType(result); mailMock.Verify(x => x.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } } \ No newline at end of file diff --git a/ProjectLighthouse/Configuration/EmailEnforcementConfiguration.cs b/ProjectLighthouse/Configuration/EmailEnforcementConfiguration.cs new file mode 100644 index 00000000..ee381926 --- /dev/null +++ b/ProjectLighthouse/Configuration/EmailEnforcementConfiguration.cs @@ -0,0 +1,72 @@ +#nullable enable +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace LBPUnion.ProjectLighthouse.Configuration; + +public class EmailEnforcementConfiguration : ConfigurationBase +{ + public override int ConfigVersion { get; set; } = 1; + + public override string ConfigName { get; set; } = "enforce-email.yml"; + + public override bool NeedsConfiguration { get; set; } = false; + + public bool EnableEmailEnforcement { get; set; } = false; + public bool EnableEmailBlacklist { get; set; } = false; + + // No blacklist by default, add path to blacklist + public string BlacklistFilePath { get; set; } = ""; + + // Endpoints to be blocked + // This is kind of a random list so some may need to be added or removed + public HashSet BlockedEndpoints { get; set; } = new() + { + // Comments + "rateUserComment", + "rateComment", + "comments", + "userComments", + "postUserComment", + "postComment", + "deleteUserComment", + "deleteComment", + + // Slots + "showModerated", + "startPublish", + "slots", + "s", + "tags", + "tag", + "searches", + "genres", + "publish", + "unpublish", + + // Misc Resources + "upload", + "r", + + // Photos + "uploadPhoto", + "photos", + "deletePhoto", + + // Gameplay + "match", + "play", + "enterLevel", + "playlists", + + // Users + "user", + "users", + "updateUser", + "update_my_pins", + }; + + public override ConfigurationBase Deserialize + (IDeserializer deserializer, string text) => + deserializer.Deserialize(text); +} \ No newline at end of file diff --git a/ProjectLighthouse/Configuration/ServerConfiguration.cs b/ProjectLighthouse/Configuration/ServerConfiguration.cs index ba9f7f4a..584c1a0e 100644 --- a/ProjectLighthouse/Configuration/ServerConfiguration.cs +++ b/ProjectLighthouse/Configuration/ServerConfiguration.cs @@ -11,7 +11,7 @@ public class ServerConfiguration : ConfigurationBase // This is so Lighthouse can properly identify outdated configurations and update them with newer settings accordingly. // If you are modifying anything here, this value MUST be incremented. // Thanks for listening~ - public override int ConfigVersion { get; set; } = 27; + public override int ConfigVersion { get; set; } = 28; public override string ConfigName { get; set; } = "lighthouse.yml"; public string WebsiteListenUrl { get; set; } = "http://localhost:10060"; @@ -46,5 +46,7 @@ public class ServerConfiguration : ConfigurationBase public RichPresenceConfiguration RichPresenceConfiguration { get; set; } = new(); public NotificationConfiguration NotificationConfiguration { get; set; } = new(); + public EmailEnforcementConfiguration EmailEnforcement { get; set; } = new(); + public override ConfigurationBase Deserialize(IDeserializer deserializer, string text) => deserializer.Deserialize(text); } \ No newline at end of file diff --git a/ProjectLighthouse/Helpers/EmailHelper.cs b/ProjectLighthouse/Helpers/EmailHelper.cs index 52c69f9d..92b5bb14 100644 --- a/ProjectLighthouse/Helpers/EmailHelper.cs +++ b/ProjectLighthouse/Helpers/EmailHelper.cs @@ -2,13 +2,17 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; using System.Threading.Tasks; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Extensions; +using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Token; +using LBPUnion.ProjectLighthouse.Types.Logging; using LBPUnion.ProjectLighthouse.Types.Mail; using Microsoft.EntityFrameworkCore; @@ -16,11 +20,19 @@ namespace LBPUnion.ProjectLighthouse.Helpers; public static class SMTPHelper { + private static readonly string blacklistFilePath = ServerConfiguration.Instance.EmailEnforcement.BlacklistFilePath; + + // Null check blacklistFilePath and read into array + private static readonly string[] blacklistFile = + !string.IsNullOrWhiteSpace(blacklistFilePath) ? File.ReadAllLines(blacklistFilePath) : []; + // (User id, timestamp of last request + 30 seconds) private static readonly ConcurrentDictionary recentlySentMail = new(); private const long emailCooldown = 1000 * 30; + private static readonly HashSet blacklistedDomains = new(blacklistFile); + private static bool CanSendMail(UserEntity user) { // Remove expired entries @@ -68,6 +80,39 @@ public static class SMTPHelper recentlySentMail.TryAdd(user.UserId, TimeHelper.TimestampMillis + emailCooldown); } + + // Accumulate checks to determine email validity + public static bool IsValidEmail(DatabaseContext database, string email) + { + // Email should not be empty, should be an actual email, and shouldn't already be used by an account + if (string.IsNullOrWhiteSpace(email) || !emailValidator.IsValid(email) || EmailIsUsed(database, email).Result) + return false; + + // Don't even bother if there are no domains in blacklist (AKA file path is empty/invalid, or file itself is empty) + if (!ServerConfiguration.Instance.EmailEnforcement.EnableEmailBlacklist || blacklistedDomains.Count <= 0) + return true; + + // Get domain by splitting at '@' character + string domain = email.Split('@')[1]; + + // Return false if domain is found in blacklist + if (blacklistedDomains.Contains(domain)) + { + Logger.Info($"Invalid email address {email} submitted by user.", LogArea.Email); + return false; + } + + return true; + } + + // Don't want to allocate every single time we call EmailAddressAttribute.IsValidEmail() + private static readonly EmailAddressAttribute emailValidator = new(); + + // Check if email is already in use by an account + private static async Task EmailIsUsed(DatabaseContext database, string email) + { + return await database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == email.ToLower()); + } public static void SendRegistrationEmail(IMailService mail, UserEntity user) { diff --git a/ProjectLighthouse/Helpers/SanitizationHelper.cs b/ProjectLighthouse/Helpers/SanitizationHelper.cs deleted file mode 100644 index 0065bc9f..00000000 --- a/ProjectLighthouse/Helpers/SanitizationHelper.cs +++ /dev/null @@ -1,9 +0,0 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; - -namespace LBPUnion.ProjectLighthouse.Helpers; - -public static class SanitizationHelper -{ - public static bool IsValidEmail(string? email) => !string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email); -} \ No newline at end of file