This commit is contained in:
FeTetra 2025-04-22 17:52:36 +06:00 committed by GitHub
commit 04f57e456b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 238 additions and 51 deletions

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="PROJECT" dialect="MySQL" />
</component>
</project>

View file

@ -93,4 +93,13 @@
<data name="read_only_warn" xml:space="preserve">
<value>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.</value>
</data>
<data name="email_enforcement_message_main" xml:space="preserve">
<value>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.</value>
</data>
<data name="email_enforcement_message_no_email" xml:space="preserve">
<value>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.)</value>
</data>
<data name="email_enforcement_message_verify_email" xml:space="preserve">
<value>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.</value>
</data>
</root>

View file

@ -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);
}

View file

@ -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
{

View file

@ -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();

View file

@ -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);

View file

@ -55,16 +55,31 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.";
{
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 <https://www.gnu.org/licenses/>.";
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);

View file

@ -174,7 +174,6 @@ public class PhotosController : ControllerBase
[HttpGet("photos/{slotType}/{id:int}")]
public async Task<IActionResult> 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<IActionResult> UserPhotosBy(string user)
{
int targetUserId = await this.database.UserIdFromUsername(user);
if (targetUserId == 0) return this.NotFound();

View file

@ -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());

View file

@ -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();

View file

@ -40,7 +40,7 @@ public class PublishController : ControllerBase
public async Task<IActionResult> StartPublish()
{
GameTokenEntity token = this.GetToken();
UserEntity? user = await this.database.UserFromGameToken(token);
if (user == null) return this.Forbid();

View file

@ -36,7 +36,7 @@ public class UserController : ControllerBase
[HttpGet("user/{username}")]
public async Task<IActionResult> GetUser(string username)
{
{
UserEntity? user = await this.database.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user == null) return this.NotFound();

View file

@ -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<string> 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);
}
}

View file

@ -107,6 +107,7 @@ public class GameServerStartup
app.UseMiddleware<RateLimitMiddleware>();
app.UseMiddleware<DigestMiddleware>(computeDigests);
app.UseMiddleware<SetLastContactMiddleware>();
app.UseMiddleware<EmailEnforcementMiddleware>();
app.UseRouting();

View file

@ -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();

View file

@ -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();
}

View file

@ -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;
}
}

View file

@ -224,7 +224,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>." + "\nuni
IActionResult result = await messageController.Filter(mailMock.Object);
Assert.IsType<OkResult>(result);
Assert.IsType<BadRequestResult>(result);
mailMock.Verify(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
@ -249,7 +249,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>." + "\nuni
IActionResult result = await messageController.Filter(mailMock.Object);
Assert.IsType<OkResult>(result);
Assert.IsType<BadRequestResult>(result);
mailMock.Verify(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
@ -271,7 +271,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>." + "\nuni
IActionResult result = await messageController.Filter(mailMock.Object);
Assert.IsType<OkResult>(result);
Assert.IsType<BadRequestResult>(result);
mailMock.Verify(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
}

View file

@ -0,0 +1,72 @@
#nullable enable
using System.Collections.Generic;
using YamlDotNet.Serialization;
namespace LBPUnion.ProjectLighthouse.Configuration;
public class EmailEnforcementConfiguration : ConfigurationBase<EmailEnforcementConfiguration>
{
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<string> 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<EmailEnforcementConfiguration> Deserialize
(IDeserializer deserializer, string text) =>
deserializer.Deserialize<EmailEnforcementConfiguration>(text);
}

View file

@ -11,7 +11,7 @@ public class ServerConfiguration : ConfigurationBase<ServerConfiguration>
// 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<ServerConfiguration>
public RichPresenceConfiguration RichPresenceConfiguration { get; set; } = new();
public NotificationConfiguration NotificationConfiguration { get; set; } = new();
public EmailEnforcementConfiguration EmailEnforcement { get; set; } = new();
public override ConfigurationBase<ServerConfiguration> Deserialize(IDeserializer deserializer, string text) => deserializer.Deserialize<ServerConfiguration>(text);
}

View file

@ -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<int, long> recentlySentMail = new();
private const long emailCooldown = 1000 * 30;
private static readonly HashSet<string> 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<bool> 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)
{

View file

@ -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);
}