diff --git a/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs b/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs index b5a8e79e..13f3a999 100644 --- a/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs +++ b/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs @@ -59,29 +59,27 @@ public class UserEndpoints : ApiEndpointController [HttpPost("user/inviteToken")] public async Task CreateUserInviteToken() { - if (Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration || - Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled) + if (!Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration && + !Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled) + return this.NotFound(); + + string authHeader = this.Request.Headers["Authorization"]; + if (string.IsNullOrWhiteSpace(authHeader)) return this.NotFound(); + + string authToken = authHeader[(authHeader.IndexOf(' ') + 1)..]; + + APIKey? apiKey = await this.database.APIKeys.FirstOrDefaultAsync(k => k.Key == authToken); + if (apiKey == null) return this.StatusCode(403, null); + + RegistrationToken token = new() { + Created = DateTime.Now, + Token = CryptoHelper.GenerateAuthToken(), + }; - string authHeader = this.Request.Headers["Authorization"]; - if (!string.IsNullOrWhiteSpace(authHeader)) - { - string authToken = authHeader.Substring(authHeader.IndexOf(' ') + 1); + this.database.RegistrationTokens.Add(token); + await this.database.SaveChangesAsync(); - APIKey? apiKey = await this.database.APIKeys.FirstOrDefaultAsync(k => k.Key == authToken); - if (apiKey == null) return this.StatusCode(403, null); - - RegistrationToken token = new(); - token.Created = DateTime.Now; - token.Token = CryptoHelper.GenerateAuthToken(); - - this.database.RegistrationTokens.Add(token); - await this.database.SaveChangesAsync(); - - return Ok(token.Token); - } - - } - return this.NotFound(); + return this.Ok(token.Token); } } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs index f8f91830..1f35667d 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs @@ -60,12 +60,12 @@ public class PublishController : ControllerBase Slot? oldSlot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slot.SlotId); if (oldSlot == null) { - Logger.Warn("Rejecting level reupload, could not find old slot", LogArea.Publish); + Logger.Warn("Rejecting level republish, could not find old slot", LogArea.Publish); return this.NotFound(); } if (oldSlot.CreatorId != user.UserId) { - Logger.Warn("Rejecting level reupload, old slot's creator is not publishing user", LogArea.Publish); + Logger.Warn("Rejecting level republish, old slot's creator is not publishing user", LogArea.Publish); return this.BadRequest(); } } @@ -87,7 +87,7 @@ public class PublishController : ControllerBase /// Endpoint actually used to publish a level /// [HttpPost("publish")] - public async Task Publish() + public async Task Publish([FromQuery] string? game) { (User, GameToken)? userAndToken = await this.database.UserAndGameTokenFromRequest(this.Request); @@ -178,6 +178,22 @@ public class PublishController : ControllerBase return this.BadRequest(); } + // I hate lbp3 + if (game != null) + { + GameVersion intendedVersion = FromAbbreviation(game); + if (intendedVersion != GameVersion.Unknown && intendedVersion != slotVersion) + { + // Delete the useless rootLevel that lbp3 just uploaded + if(slotVersion == GameVersion.LittleBigPlanet3) + FileHelper.DeleteResource(slot.RootLevel); + + slot.GameVersion = oldSlot.GameVersion; + slot.RootLevel = oldSlot.RootLevel; + slot.ResourceCollection = oldSlot.ResourceCollection; + } + } + oldSlot.Location.X = slot.Location.X; oldSlot.Location.Y = slot.Location.Y; @@ -277,6 +293,19 @@ public class PublishController : ControllerBase return this.Ok(); } + private static GameVersion FromAbbreviation(string abbr) + { + return abbr switch + { + "lbp1" => GameVersion.LittleBigPlanet1, + "lbp2" => GameVersion.LittleBigPlanet2, + "lbp3" => GameVersion.LittleBigPlanet3, + "lbpv" => GameVersion.LittleBigPlanetVita, + "lbppsp" => GameVersion.LittleBigPlanetPSP, + _ => GameVersion.Unknown, + }; + } + private async Task getSlotFromBody() { this.Request.Body.Position = 0; diff --git a/ProjectLighthouse.Servers.GameServer/Middlewares/DigestMiddleware.cs b/ProjectLighthouse.Servers.GameServer/Middlewares/DigestMiddleware.cs new file mode 100644 index 00000000..56610c89 --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Middlewares/DigestMiddleware.cs @@ -0,0 +1,118 @@ +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Middlewares; +using Microsoft.Extensions.Primitives; + +namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares; + +public class DigestMiddleware : Middleware +{ + private readonly bool computeDigests; + + public DigestMiddleware(RequestDelegate next, bool computeDigests) : base(next) + { + this.computeDigests = computeDigests; + } + + public override async Task InvokeAsync(HttpContext context) + { + // Client digest check. + if (!context.Request.Cookies.TryGetValue("MM_AUTH", out string? authCookie) || authCookie == null) + authCookie = string.Empty; + string digestPath = context.Request.Path; + #if !DEBUG + const string url = "/LITTLEBIGPLANETPS3_XML"; + string strippedPath = digestPath.Contains(url) ? digestPath[url.Length..] : ""; + #endif + Stream body = context.Request.Body; + + bool usedAlternateDigestKey = false; + + if (this.computeDigests && digestPath.StartsWith("/LITTLEBIGPLANETPS3_XML")) + { + // The game sets X-Digest-B on a resource upload instead of X-Digest-A + string digestHeaderKey = "X-Digest-A"; + bool excludeBodyFromDigest = false; + if (digestPath.Contains("/upload/")) + { + digestHeaderKey = "X-Digest-B"; + excludeBodyFromDigest = true; + } + + string clientRequestDigest = await CryptoHelper.ComputeDigest + (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.PrimaryDigestKey, excludeBodyFromDigest); + + // Check the digest we've just calculated against the digest header if the game set the header. They should match. + if (context.Request.Headers.TryGetValue(digestHeaderKey, out StringValues sentDigest)) + { + if (clientRequestDigest != sentDigest) + { + // If we got here, the normal ServerDigestKey failed to validate. Lets try again with the alternate digest key. + usedAlternateDigestKey = true; + + // Reset the body stream + body.Position = 0; + + clientRequestDigest = await CryptoHelper.ComputeDigest + (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.AlternateDigestKey, excludeBodyFromDigest); + if (clientRequestDigest != sentDigest) + { + #if DEBUG + Console.WriteLine("Digest failed"); + Console.WriteLine("digestKey: " + ServerConfiguration.Instance.DigestKey.PrimaryDigestKey); + Console.WriteLine("altDigestKey: " + ServerConfiguration.Instance.DigestKey.AlternateDigestKey); + Console.WriteLine("computed digest: " + clientRequestDigest); + #endif + // We still failed to validate. Abort the request. + context.Response.StatusCode = 403; + return; + } + } + } + #if !DEBUG + // The game doesn't start sending digests until after the announcement so if it's not one of those requests + // and it doesn't include a digest we need to reject the request + else if (!ServerStatics.IsUnitTesting && !strippedPath.Equals("/login") && !strippedPath.Equals("/eula") + && !strippedPath.Equals("/announce") && !strippedPath.Equals("/status") && !strippedPath.Equals("/farc_hashes")) + { + context.Response.StatusCode = 403; + return; + } + #endif + + context.Response.Headers.Add("X-Digest-B", clientRequestDigest); + context.Request.Body.Position = 0; + } + + // This does the same as above, but for the response stream. + await using MemoryStream responseBuffer = new(); + Stream oldResponseStream = context.Response.Body; + context.Response.Body = responseBuffer; + + await this.next(context); // Handle the request so we can get the server digest hash + responseBuffer.Position = 0; + + // Compute the server digest hash. + if (this.computeDigests) + { + responseBuffer.Position = 0; + + string digestKey = usedAlternateDigestKey + ? ServerConfiguration.Instance.DigestKey.AlternateDigestKey + : ServerConfiguration.Instance.DigestKey.PrimaryDigestKey; + + // Compute the digest for the response. + string serverDigest = await CryptoHelper.ComputeDigest(context.Request.Path, authCookie, responseBuffer, digestKey); + context.Response.Headers.Add("X-Digest-A", serverDigest); + } + + // Add a content-length header if it isn't present to disable response chunking + if (!context.Response.Headers.ContainsKey("Content-Length")) + context.Response.Headers.Add("Content-Length", responseBuffer.Length.ToString()); + + // Copy the buffered response to the actual response stream. + responseBuffer.Position = 0; + await responseBuffer.CopyToAsync(oldResponseStream); + context.Response.Body = oldResponseStream; + } +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Middlewares/SetLastContactMiddleware.cs b/ProjectLighthouse.Servers.GameServer/Middlewares/SetLastContactMiddleware.cs new file mode 100644 index 00000000..76c94f35 --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Middlewares/SetLastContactMiddleware.cs @@ -0,0 +1,29 @@ +using LBPUnion.ProjectLighthouse.Middlewares; +using LBPUnion.ProjectLighthouse.PlayerData; + +namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares; + +public class SetLastContactMiddleware : MiddlewareDBContext +{ + public SetLastContactMiddleware(RequestDelegate next) : base(next) + { } + + public override async Task InvokeAsync(HttpContext context, Database database) + { + #nullable enable + // Log LastContact for LBP1. This is done on LBP2/3/V on a Match request. + if (context.Request.Path.ToString().StartsWith("/LITTLEBIGPLANETPS3_XML")) + { + // We begin by grabbing a token from the request, if this is a LBPPS3_XML request of course. + GameToken? gameToken = await database.GameTokenFromRequest(context.Request); + + if (gameToken?.GameVersion == GameVersion.LittleBigPlanet1) + // Ignore UserFromGameToken null because user must exist for a token to exist + await LastContactHelper.SetLastContact + (database, (await database.UserFromGameToken(gameToken))!, GameVersion.LittleBigPlanet1, gameToken.Platform); + } + #nullable disable + + 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 daf0dab2..1ee53179 100644 --- a/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs +++ b/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs @@ -1,11 +1,9 @@ using LBPUnion.ProjectLighthouse.Configuration; -using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Middlewares; -using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.Serialization; +using LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.Extensions.Primitives; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Startup; @@ -66,134 +64,9 @@ public class GameServerStartup app.UseForwardedHeaders(); app.UseMiddleware(); - - // Digest check - app.Use - ( - async (context, next) => - { - // Client digest check. - if (!context.Request.Cookies.TryGetValue("MM_AUTH", out string? authCookie) || authCookie == null) authCookie = string.Empty; - string digestPath = context.Request.Path; - #if !DEBUG - const string url = "/LITTLEBIGPLANETPS3_XML"; - string strippedPath = digestPath.Contains(url) ? digestPath[url.Length..] : ""; - #endif - Stream body = context.Request.Body; - - bool usedAlternateDigestKey = false; - - if (computeDigests && digestPath.StartsWith("/LITTLEBIGPLANETPS3_XML")) - { - // The game sets X-Digest-B on a resource upload instead of X-Digest-A - string digestHeaderKey = "X-Digest-A"; - bool excludeBodyFromDigest = false; - if (digestPath.Contains("/upload/")) - { - digestHeaderKey = "X-Digest-B"; - excludeBodyFromDigest = true; - } - - string clientRequestDigest = await CryptoHelper.ComputeDigest - (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.PrimaryDigestKey, excludeBodyFromDigest); - - // Check the digest we've just calculated against the digest header if the game set the header. They should match. - if (context.Request.Headers.TryGetValue(digestHeaderKey, out StringValues sentDigest)) - { - if (clientRequestDigest != sentDigest) - { - // If we got here, the normal ServerDigestKey failed to validate. Lets try again with the alternate digest key. - usedAlternateDigestKey = true; - - // Reset the body stream - body.Position = 0; - - clientRequestDigest = await CryptoHelper.ComputeDigest - (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.AlternateDigestKey, excludeBodyFromDigest); - if (clientRequestDigest != sentDigest) - { - #if DEBUG - Console.WriteLine("Digest failed"); - Console.WriteLine("digestKey: " + ServerConfiguration.Instance.DigestKey.PrimaryDigestKey); - Console.WriteLine("altDigestKey: " + ServerConfiguration.Instance.DigestKey.AlternateDigestKey); - Console.WriteLine("computed digest: " + clientRequestDigest); - #endif - // We still failed to validate. Abort the request. - context.Response.StatusCode = 403; - return; - } - } - } - #if !DEBUG - // The game doesn't start sending digests until after the announcement so if it's not one of those requests - // and it doesn't include a digest we need to reject the request - else if (!ServerStatics.IsUnitTesting && !strippedPath.Equals("/login") && !strippedPath.Equals("/eula") - && !strippedPath.Equals("/announce") && !strippedPath.Equals("/status") && !strippedPath.Equals("/farc_hashes")) - { - context.Response.StatusCode = 403; - return; - } - #endif - - context.Response.Headers.Add("X-Digest-B", clientRequestDigest); - context.Request.Body.Position = 0; - } - - // This does the same as above, but for the response stream. - await using MemoryStream responseBuffer = new(); - Stream oldResponseStream = context.Response.Body; - context.Response.Body = responseBuffer; - - await next(context); // Handle the request so we can get the server digest hash - responseBuffer.Position = 0; - - // Compute the server digest hash. - if (computeDigests) - { - responseBuffer.Position = 0; - - string digestKey = usedAlternateDigestKey - ? ServerConfiguration.Instance.DigestKey.AlternateDigestKey - : ServerConfiguration.Instance.DigestKey.PrimaryDigestKey; - - // Compute the digest for the response. - string serverDigest = await CryptoHelper.ComputeDigest(context.Request.Path, authCookie, responseBuffer, digestKey); - context.Response.Headers.Add("X-Digest-A", serverDigest); - } - - // Add a content-length header if it isn't present to disable response chunking - if(!context.Response.Headers.ContainsKey("Content-Length")) - context.Response.Headers.Add("Content-Length", responseBuffer.Length.ToString()); - - // Copy the buffered response to the actual response stream. - responseBuffer.Position = 0; - await responseBuffer.CopyToAsync(oldResponseStream); - context.Response.Body = oldResponseStream; - } - ); - - app.Use - ( - async (context, next) => - { - #nullable enable - // Log LastContact for LBP1. This is done on LBP2/3/V on a Match request. - if (context.Request.Path.ToString().StartsWith("/LITTLEBIGPLANETPS3_XML")) - { - // We begin by grabbing a token from the request, if this is a LBPPS3_XML request of course. - await using Database database = new(); // Gets nuked at the end of the scope - GameToken? gameToken = await database.GameTokenFromRequest(context.Request); - - if (gameToken != null && gameToken.GameVersion == GameVersion.LittleBigPlanet1) - // Ignore UserFromGameToken null because user must exist for a token to exist - await LastContactHelper.SetLastContact - (database, (await database.UserFromGameToken(gameToken))!, GameVersion.LittleBigPlanet1, gameToken.Platform); - } - #nullable disable - - await next(context); - } - ); + app.UseMiddleware(computeDigests); + app.UseMiddleware(); + app.UseMiddleware(); app.UseRouting(); diff --git a/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml index 0a3fa0f6..b187bffb 100644 --- a/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml @@ -13,7 +13,8 @@ } else { -

Failed to send email, please try again later

+

Failed to send email, please try again later.

+

If this issue persists please contact an Administrator

} diff --git a/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml.cs index 7431acc4..1a9db832 100644 --- a/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/SendVerificationEmailPage.cshtml.cs @@ -1,5 +1,7 @@ #nullable enable +using System.Collections.Concurrent; using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email; @@ -14,6 +16,9 @@ public class SendVerificationEmailPage : BaseLayout public SendVerificationEmailPage(Database database) : base(database) {} + // (User id, timestamp of last request + 30 seconds) + private static readonly ConcurrentDictionary recentlySentEmail = new(); + public bool Success { get; set; } public async Task OnGet() @@ -35,22 +40,34 @@ public class SendVerificationEmailPage : BaseLayout } #endif - EmailVerificationToken? verifyToken = await this.Database.EmailVerificationTokens.FirstOrDefaultAsync(v => v.UserId == user.UserId); - // If user doesn't have a token or it is expired then regenerate - if (verifyToken == null || DateTime.Now > verifyToken.ExpiresAt) + // Remove expired entries + for (int i = recentlySentEmail.Count - 1; i >= 0; i--) { - verifyToken = new EmailVerificationToken - { - UserId = user.UserId, - User = user, - EmailToken = CryptoHelper.GenerateAuthToken(), - ExpiresAt = DateTime.Now.AddHours(6), - }; - - this.Database.EmailVerificationTokens.Add(verifyToken); - await this.Database.SaveChangesAsync(); + KeyValuePair entry = recentlySentEmail.ElementAt(i); + if (TimeHelper.TimestampMillis > recentlySentEmail[user.UserId]) recentlySentEmail.TryRemove(entry.Key, out _); } + if (recentlySentEmail.ContainsKey(user.UserId) && recentlySentEmail[user.UserId] > TimeHelper.TimestampMillis) + { + this.Success = true; + return this.Page(); + } + + string? existingToken = await this.Database.EmailVerificationTokens.Where(v => v.UserId == user.UserId).Select(v => v.EmailToken).FirstOrDefaultAsync(); + if(existingToken != null) + this.Database.EmailVerificationTokens.RemoveWhere(t => t.EmailToken == existingToken); + + EmailVerificationToken verifyToken = new() + { + UserId = user.UserId, + User = user, + EmailToken = CryptoHelper.GenerateAuthToken(), + ExpiresAt = DateTime.Now.AddHours(6), + }; + + this.Database.EmailVerificationTokens.Add(verifyToken); + await this.Database.SaveChangesAsync(); + string body = "Hello,\n\n" + $"This email is a request to verify this email for your (likely new!) Project Lighthouse account ({user.Username}).\n\n" + $"To verify your account, click the following link: {ServerConfiguration.Instance.ExternalUrl}/verifyEmail?token={verifyToken.EmailToken}\n\n\n" + @@ -58,6 +75,9 @@ public class SendVerificationEmailPage : BaseLayout this.Success = SMTPHelper.SendEmail(user.EmailAddress, "Project Lighthouse Email Verification", body); + // Don't send another email for 30 seconds + recentlySentEmail.TryAdd(user.UserId, TimeHelper.TimestampMillis + 30 * 1000); + return this.Page(); } } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Startup/WebsiteStartup.cs b/ProjectLighthouse.Servers.Website/Startup/WebsiteStartup.cs index 159b4abb..9202b73d 100644 --- a/ProjectLighthouse.Servers.Website/Startup/WebsiteStartup.cs +++ b/ProjectLighthouse.Servers.Website/Startup/WebsiteStartup.cs @@ -81,6 +81,7 @@ public class WebsiteStartup app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); app.UseRouting(); diff --git a/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs b/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs index d716a7d9..f39975a9 100644 --- a/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs +++ b/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs @@ -4,9 +4,6 @@ namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories; public class AuthenticationConfiguration { - [Obsolete("Obsolete. This feature has been removed.", true)] - public bool BlockDeniedUsers { get; set; } - public bool RegistrationEnabled { get; set; } = true; public bool PrivateRegistration { get; set; } = false; public bool UseExternalAuth { get; set; } diff --git a/ProjectLighthouse/Configuration/ConfigurationCategories/RateLimitConfiguration.cs b/ProjectLighthouse/Configuration/ConfigurationCategories/RateLimitConfiguration.cs new file mode 100644 index 00000000..4abbabf9 --- /dev/null +++ b/ProjectLighthouse/Configuration/ConfigurationCategories/RateLimitConfiguration.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories; + +public class RateLimitConfiguration +{ + public RateLimitOptions GlobalOptions { get; set; } = new(); + + public Dictionary OverrideOptions { get; set; } = new() + { + { + "/example/*/wildcard", new RateLimitOptions + { + RequestInterval = 5, + RequestsPerInterval = 10, + Enabled = true, + } + }, + }; +} \ No newline at end of file diff --git a/ProjectLighthouse/Configuration/RateLimitOptions.cs b/ProjectLighthouse/Configuration/RateLimitOptions.cs new file mode 100644 index 00000000..8f0ba344 --- /dev/null +++ b/ProjectLighthouse/Configuration/RateLimitOptions.cs @@ -0,0 +1,8 @@ +namespace LBPUnion.ProjectLighthouse.Configuration; + +public class RateLimitOptions +{ + public bool Enabled = false; + public int RequestsPerInterval { get; set; } = 5; + public int RequestInterval { get; set; } = 15; +} \ No newline at end of file diff --git a/ProjectLighthouse/Configuration/ServerConfiguration.cs b/ProjectLighthouse/Configuration/ServerConfiguration.cs index f7d4aa05..ca5cd38a 100644 --- a/ProjectLighthouse/Configuration/ServerConfiguration.cs +++ b/ProjectLighthouse/Configuration/ServerConfiguration.cs @@ -23,7 +23,7 @@ public class ServerConfiguration // You can use an ObsoleteAttribute instead. Make sure you set it to error, though. // // Thanks for listening~ - public const int CurrentConfigVersion = 10; + public const int CurrentConfigVersion = 11; #region Meta @@ -39,7 +39,7 @@ public class ServerConfiguration #region Setup - private static FileSystemWatcher fileWatcher; + private static readonly FileSystemWatcher fileWatcher; // ReSharper disable once NotNullMemberIsNotInitialized #pragma warning disable CS8618 @@ -99,46 +99,53 @@ public class ServerConfiguration } // Set up reloading - if (Instance.ConfigReloading) + if (!Instance.ConfigReloading) return; + + Logger.Info("Setting up config reloading...", LogArea.Config); + fileWatcher = new FileSystemWatcher { - Logger.Info("Setting up config reloading...", LogArea.Config); - fileWatcher = new FileSystemWatcher - { - Path = Environment.CurrentDirectory, - Filter = ConfigFileName, - NotifyFilter = NotifyFilters.LastWrite, // only watch for writes to config file - }; + Path = Environment.CurrentDirectory, + Filter = ConfigFileName, + NotifyFilter = NotifyFilters.LastWrite, // only watch for writes to config file + }; - fileWatcher.Changed += onConfigChanged; // add event handler + fileWatcher.Changed += onConfigChanged; // add event handler - fileWatcher.EnableRaisingEvents = true; // begin watching - } + fileWatcher.EnableRaisingEvents = true; // begin watching } #pragma warning restore CS8618 private static void onConfigChanged(object sender, FileSystemEventArgs e) { - Debug.Assert(e.Name == ConfigFileName); - Logger.Info("Configuration file modified, reloading config...", LogArea.Config); - Logger.Warn("Some changes may not apply; they will require a restart of Lighthouse.", LogArea.Config); - - ServerConfiguration? configuration = fromFile(ConfigFileName); - if (configuration == null) + try { - Logger.Warn("The new configuration was unable to be loaded for some reason. The old config has been kept.", LogArea.Config); - return; + fileWatcher.EnableRaisingEvents = false; + Debug.Assert(e.Name == ConfigFileName); + Logger.Info("Configuration file modified, reloading config...", LogArea.Config); + Logger.Warn("Some changes may not apply; they will require a restart of Lighthouse.", LogArea.Config); + + ServerConfiguration? configuration = fromFile(ConfigFileName); + if (configuration == null) + { + Logger.Warn("The new configuration was unable to be loaded for some reason. The old config has been kept.", LogArea.Config); + return; + } + + Instance = configuration; + + Logger.Success("Successfully reloaded the configuration!", LogArea.Config); + } + finally + { + fileWatcher.EnableRaisingEvents = true; } - - Instance = configuration; - - Logger.Success("Successfully reloaded the configuration!", LogArea.Config); } private static INamingConvention namingConvention = CamelCaseNamingConvention.Instance; private static ServerConfiguration? fromFile(string path) { - IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).Build(); + IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).IgnoreUnmatchedProperties().Build(); string text; @@ -163,12 +170,6 @@ public class ServerConfiguration #endregion - // TODO: Find a way to properly remove config options - // YamlDotNet hates that and it's fucking annoying. - // This seriously sucks. /rant - [Obsolete("Obsolete. Use the Website/GameApi/Api listen URLS instead.")] - public string ListenUrl { get; set; } = "http://localhost:10060"; - public string WebsiteListenUrl { get; set; } = "http://localhost:10060"; public string GameApiListenUrl { get; set; } = "http://localhost:10061"; public string ApiListenUrl { get; set; } = "http://localhost:10062"; @@ -197,4 +198,5 @@ public class ServerConfiguration public UserGeneratedContentLimitConfiguration UserGeneratedContentLimits { get; set; } = new(); public WebsiteConfiguration WebsiteConfiguration { get; set; } = new(); public CustomizationConfiguration Customization { get; set; } = new(); + public RateLimitConfiguration RateLimitConfiguration { get; set; } = new(); } \ No newline at end of file diff --git a/ProjectLighthouse/Files/FileHelper.cs b/ProjectLighthouse/Files/FileHelper.cs index 9e2d9452..9e2e82e1 100644 --- a/ProjectLighthouse/Files/FileHelper.cs +++ b/ProjectLighthouse/Files/FileHelper.cs @@ -231,6 +231,15 @@ public static class FileHelper public static bool ResourceExists(string hash) => File.Exists(GetResourcePath(hash)); + public static void DeleteResource(string hash) + { + // sanity check so someone doesn't somehow delete the entire resource folder + if (ResourceExists(hash) && (File.GetAttributes(hash) & FileAttributes.Directory) != FileAttributes.Directory) + { + File.Delete(GetResourcePath(hash)); + } + } + public static int ResourceSize(string hash) { try diff --git a/ProjectLighthouse/Logging/LogArea.cs b/ProjectLighthouse/Logging/LogArea.cs index c80b003e..b4599959 100644 --- a/ProjectLighthouse/Logging/LogArea.cs +++ b/ProjectLighthouse/Logging/LogArea.cs @@ -24,4 +24,5 @@ public enum LogArea Publish, Maintenance, Score, + RateLimit, } \ No newline at end of file diff --git a/ProjectLighthouse/Middlewares/RateLimitMiddleware.cs b/ProjectLighthouse/Middlewares/RateLimitMiddleware.cs new file mode 100644 index 00000000..9fbfb736 --- /dev/null +++ b/ProjectLighthouse/Middlewares/RateLimitMiddleware.cs @@ -0,0 +1,139 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Logging; +using Microsoft.AspNetCore.Http; + +namespace LBPUnion.ProjectLighthouse.Middlewares; + +public class RateLimitMiddleware : MiddlewareDBContext +{ + + // (userId, requestData) + private static readonly ConcurrentDictionary> recentRequests = new(); + + public RateLimitMiddleware(RequestDelegate next) : base(next) + { } + + public override async Task InvokeAsync(HttpContext ctx, Database database) + { + // We only want to rate limit POST requests + if (ctx.Request.Method != "POST") + { + await this.next(ctx); + return; + } + IPAddress? address = ctx.Connection.RemoteIpAddress; + if (address == null) + { + await this.next(ctx); + return; + } + + PathString path = RemoveTrailingSlash(ctx.Request.Path.ToString()); + + RateLimitOptions? options = GetRateLimitOverride(path); + + if (!IsRateLimitEnabled(options)) + { + await this.next(ctx); + return; + } + + RemoveExpiredEntries(); + + if (GetNumRequestsForPath(address, path) >= GetMaxNumRequests(options)) + { + Logger.Info($"Request limit reached for {address.ToString()} ({ctx.Request.Path})", LogArea.RateLimit); + ctx.Response.Headers.Add("Retry-After", "" + Math.Ceiling((recentRequests[address][0].Expiration - TimeHelper.TimestampMillis) / 1000f)); + ctx.Response.StatusCode = 429; + return; + } + + LogRequest(address, path, options); + + // Handle request as normal + await this.next(ctx); + } + + private static int GetMaxNumRequests(RateLimitOptions? options) => options?.RequestsPerInterval ?? ServerConfiguration.Instance.RateLimitConfiguration.GlobalOptions.RequestsPerInterval; + + private static bool IsRateLimitEnabled(RateLimitOptions? options) => options?.Enabled ?? ServerConfiguration.Instance.RateLimitConfiguration.GlobalOptions.Enabled; + + private static long GetRequestInterval(RateLimitOptions? options) => options?.RequestInterval ?? ServerConfiguration.Instance.RateLimitConfiguration.GlobalOptions.RequestInterval; + + private static RateLimitOptions? GetRateLimitOverride(PathString path) + { + Dictionary overrides = ServerConfiguration.Instance.RateLimitConfiguration.OverrideOptions; + List matchingOptions = overrides.Keys.Where(s => new Regex("^" + s.Replace("/", @"\/").Replace("*", ".*") + "$").Match(path).Success).ToList(); + if (matchingOptions.Count == 0) return null; + // return 0 for equal, -1 for a, and 1 for b + matchingOptions.Sort((a, b) => + { + int aWeight = 100; + int bWeight = 100; + if (a.Contains('*')) aWeight -= 20; + if (b.Contains('*')) bWeight -= 20; + + aWeight += a.Length; + bWeight += b.Length; + + if (aWeight > bWeight) return -1; + + if (bWeight > aWeight) return 1; + + return 0; + }); + return overrides[matchingOptions.First()]; + } + + private static void LogRequest(IPAddress address, PathString path, RateLimitOptions? options) + { + recentRequests.GetOrAdd(address, new List()).Add(LighthouseRequest.Create(path, GetRequestInterval(options) * 1000 + TimeHelper.TimestampMillis)); + } + + private static void RemoveExpiredEntries() + { + for (int i = recentRequests.Count - 1; i >= 0; i--) + { + IPAddress address = recentRequests.ElementAt(i).Key; + recentRequests[address].RemoveAll(r => TimeHelper.TimestampMillis > r.Expiration); + // Remove empty entries + if (recentRequests[address].Count == 0) recentRequests.TryRemove(address, out _); + } + } + + private static string RemoveTrailingSlash(string s) => s.TrimEnd('/').TrimEnd('\\'); + + private static int GetNumRequestsForPath(IPAddress address, PathString path) + { + if (!recentRequests.ContainsKey(address)) return 0; + + List requests = recentRequests[address]; + return requests.Count(r => r.Path == path); + } + + private class LighthouseRequest + { + public PathString Path { get; private init; } = ""; + public long Expiration { get; private init; } + + public static LighthouseRequest Create(PathString path, long expiration) + { + LighthouseRequest request = new() + { + Path = path, + Expiration = expiration, + }; + return request; + } + } + +} \ No newline at end of file diff --git a/ProjectLighthouse/ProjectLighthouse.csproj b/ProjectLighthouse/ProjectLighthouse.csproj index fd7a2b22..8f670e49 100644 --- a/ProjectLighthouse/ProjectLighthouse.csproj +++ b/ProjectLighthouse/ProjectLighthouse.csproj @@ -10,8 +10,8 @@ - - + +