mirror of
https://github.com/LBPUnion/ProjectLighthouse.git
synced 2025-05-15 06:02:28 +00:00
Implement POST request rate limiting (#490)
* Initial work for rate limiting * Refactor GameServerStartup and change default rate limit config * Adjust config naming and add Enabled option to global and override rate limits * Fix LBP3 republish bug * Fix bugs in rate limiting and allow for multiple matched overrides * Add this qualifier for private variable * Changes from self review
This commit is contained in:
parent
110d81f117
commit
3ad211e5c8
16 changed files with 451 additions and 206 deletions
|
@ -59,29 +59,27 @@ public class UserEndpoints : ApiEndpointController
|
||||||
[HttpPost("user/inviteToken")]
|
[HttpPost("user/inviteToken")]
|
||||||
public async Task<IActionResult> CreateUserInviteToken()
|
public async Task<IActionResult> CreateUserInviteToken()
|
||||||
{
|
{
|
||||||
if (Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration ||
|
if (!Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration &&
|
||||||
Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled)
|
!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"];
|
this.database.RegistrationTokens.Add(token);
|
||||||
if (!string.IsNullOrWhiteSpace(authHeader))
|
await this.database.SaveChangesAsync();
|
||||||
{
|
|
||||||
string authToken = authHeader.Substring(authHeader.IndexOf(' ') + 1);
|
|
||||||
|
|
||||||
APIKey? apiKey = await this.database.APIKeys.FirstOrDefaultAsync(k => k.Key == authToken);
|
return this.Ok(token.Token);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -60,12 +60,12 @@ public class PublishController : ControllerBase
|
||||||
Slot? oldSlot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slot.SlotId);
|
Slot? oldSlot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slot.SlotId);
|
||||||
if (oldSlot == null)
|
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();
|
return this.NotFound();
|
||||||
}
|
}
|
||||||
if (oldSlot.CreatorId != user.UserId)
|
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();
|
return this.BadRequest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ public class PublishController : ControllerBase
|
||||||
/// Endpoint actually used to publish a level
|
/// Endpoint actually used to publish a level
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("publish")]
|
[HttpPost("publish")]
|
||||||
public async Task<IActionResult> Publish()
|
public async Task<IActionResult> Publish([FromQuery] string? game)
|
||||||
{
|
{
|
||||||
(User, GameToken)? userAndToken = await this.database.UserAndGameTokenFromRequest(this.Request);
|
(User, GameToken)? userAndToken = await this.database.UserAndGameTokenFromRequest(this.Request);
|
||||||
|
|
||||||
|
@ -178,6 +178,22 @@ public class PublishController : ControllerBase
|
||||||
return this.BadRequest();
|
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.X = slot.Location.X;
|
||||||
oldSlot.Location.Y = slot.Location.Y;
|
oldSlot.Location.Y = slot.Location.Y;
|
||||||
|
|
||||||
|
@ -277,6 +293,19 @@ public class PublishController : ControllerBase
|
||||||
return this.Ok();
|
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<Slot?> getSlotFromBody()
|
private async Task<Slot?> getSlotFromBody()
|
||||||
{
|
{
|
||||||
this.Request.Body.Position = 0;
|
this.Request.Body.Position = 0;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,9 @@
|
||||||
using LBPUnion.ProjectLighthouse.Configuration;
|
using LBPUnion.ProjectLighthouse.Configuration;
|
||||||
using LBPUnion.ProjectLighthouse.Helpers;
|
|
||||||
using LBPUnion.ProjectLighthouse.Logging;
|
using LBPUnion.ProjectLighthouse.Logging;
|
||||||
using LBPUnion.ProjectLighthouse.Middlewares;
|
using LBPUnion.ProjectLighthouse.Middlewares;
|
||||||
using LBPUnion.ProjectLighthouse.PlayerData;
|
|
||||||
using LBPUnion.ProjectLighthouse.Serialization;
|
using LBPUnion.ProjectLighthouse.Serialization;
|
||||||
|
using LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
|
|
||||||
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Startup;
|
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Startup;
|
||||||
|
|
||||||
|
@ -66,134 +64,9 @@ public class GameServerStartup
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
app.UseMiddleware<RequestLogMiddleware>();
|
app.UseMiddleware<RequestLogMiddleware>();
|
||||||
|
app.UseMiddleware<DigestMiddleware>(computeDigests);
|
||||||
// Digest check
|
app.UseMiddleware<SetLastContactMiddleware>();
|
||||||
app.Use
|
app.UseMiddleware<RateLimitMiddleware>();
|
||||||
(
|
|
||||||
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.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,8 @@
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p>Failed to send email, please try again later</p>
|
<p>Failed to send email, please try again later.</p>
|
||||||
|
<p>If this issue persists please contact an Administrator</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<a href="/login/sendVerificationEmail">
|
<a href="/login/sendVerificationEmail">
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using LBPUnion.ProjectLighthouse.Configuration;
|
using LBPUnion.ProjectLighthouse.Configuration;
|
||||||
|
using LBPUnion.ProjectLighthouse.Extensions;
|
||||||
using LBPUnion.ProjectLighthouse.Helpers;
|
using LBPUnion.ProjectLighthouse.Helpers;
|
||||||
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
|
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
|
||||||
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
|
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
|
||||||
|
@ -14,6 +16,9 @@ public class SendVerificationEmailPage : BaseLayout
|
||||||
public SendVerificationEmailPage(Database database) : base(database)
|
public SendVerificationEmailPage(Database database) : base(database)
|
||||||
{}
|
{}
|
||||||
|
|
||||||
|
// (User id, timestamp of last request + 30 seconds)
|
||||||
|
private static readonly ConcurrentDictionary<int, long> recentlySentEmail = new();
|
||||||
|
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
public async Task<IActionResult> OnGet()
|
public async Task<IActionResult> OnGet()
|
||||||
|
@ -35,22 +40,34 @@ public class SendVerificationEmailPage : BaseLayout
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
EmailVerificationToken? verifyToken = await this.Database.EmailVerificationTokens.FirstOrDefaultAsync(v => v.UserId == user.UserId);
|
// Remove expired entries
|
||||||
// If user doesn't have a token or it is expired then regenerate
|
for (int i = recentlySentEmail.Count - 1; i >= 0; i--)
|
||||||
if (verifyToken == null || DateTime.Now > verifyToken.ExpiresAt)
|
|
||||||
{
|
{
|
||||||
verifyToken = new EmailVerificationToken
|
KeyValuePair<int, long> entry = recentlySentEmail.ElementAt(i);
|
||||||
{
|
if (TimeHelper.TimestampMillis > recentlySentEmail[user.UserId]) recentlySentEmail.TryRemove(entry.Key, out _);
|
||||||
UserId = user.UserId,
|
|
||||||
User = user,
|
|
||||||
EmailToken = CryptoHelper.GenerateAuthToken(),
|
|
||||||
ExpiresAt = DateTime.Now.AddHours(6),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.Database.EmailVerificationTokens.Add(verifyToken);
|
|
||||||
await this.Database.SaveChangesAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" +
|
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" +
|
$"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" +
|
$"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);
|
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();
|
return this.Page();
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -81,6 +81,7 @@ public class WebsiteStartup
|
||||||
app.UseMiddleware<HandlePageErrorMiddleware>();
|
app.UseMiddleware<HandlePageErrorMiddleware>();
|
||||||
app.UseMiddleware<RequestLogMiddleware>();
|
app.UseMiddleware<RequestLogMiddleware>();
|
||||||
app.UseMiddleware<UserRequiredRedirectMiddleware>();
|
app.UseMiddleware<UserRequiredRedirectMiddleware>();
|
||||||
|
app.UseMiddleware<RateLimitMiddleware>();
|
||||||
|
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,6 @@ namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories;
|
||||||
|
|
||||||
public class AuthenticationConfiguration
|
public class AuthenticationConfiguration
|
||||||
{
|
{
|
||||||
[Obsolete("Obsolete. This feature has been removed.", true)]
|
|
||||||
public bool BlockDeniedUsers { get; set; }
|
|
||||||
|
|
||||||
public bool RegistrationEnabled { get; set; } = true;
|
public bool RegistrationEnabled { get; set; } = true;
|
||||||
public bool PrivateRegistration { get; set; } = false;
|
public bool PrivateRegistration { get; set; } = false;
|
||||||
public bool UseExternalAuth { get; set; }
|
public bool UseExternalAuth { get; set; }
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories;
|
||||||
|
|
||||||
|
public class RateLimitConfiguration
|
||||||
|
{
|
||||||
|
public RateLimitOptions GlobalOptions { get; set; } = new();
|
||||||
|
|
||||||
|
public Dictionary<string, RateLimitOptions> OverrideOptions { get; set; } = new()
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"/example/*/wildcard", new RateLimitOptions
|
||||||
|
{
|
||||||
|
RequestInterval = 5,
|
||||||
|
RequestsPerInterval = 10,
|
||||||
|
Enabled = true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
8
ProjectLighthouse/Configuration/RateLimitOptions.cs
Normal file
8
ProjectLighthouse/Configuration/RateLimitOptions.cs
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ public class ServerConfiguration
|
||||||
// You can use an ObsoleteAttribute instead. Make sure you set it to error, though.
|
// You can use an ObsoleteAttribute instead. Make sure you set it to error, though.
|
||||||
//
|
//
|
||||||
// Thanks for listening~
|
// Thanks for listening~
|
||||||
public const int CurrentConfigVersion = 10;
|
public const int CurrentConfigVersion = 11;
|
||||||
|
|
||||||
#region Meta
|
#region Meta
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ public class ServerConfiguration
|
||||||
|
|
||||||
#region Setup
|
#region Setup
|
||||||
|
|
||||||
private static FileSystemWatcher fileWatcher;
|
private static readonly FileSystemWatcher fileWatcher;
|
||||||
|
|
||||||
// ReSharper disable once NotNullMemberIsNotInitialized
|
// ReSharper disable once NotNullMemberIsNotInitialized
|
||||||
#pragma warning disable CS8618
|
#pragma warning disable CS8618
|
||||||
|
@ -99,46 +99,53 @@ public class ServerConfiguration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up reloading
|
// 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);
|
Path = Environment.CurrentDirectory,
|
||||||
fileWatcher = new FileSystemWatcher
|
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
|
#pragma warning restore CS8618
|
||||||
|
|
||||||
private static void onConfigChanged(object sender, FileSystemEventArgs e)
|
private static void onConfigChanged(object sender, FileSystemEventArgs e)
|
||||||
{
|
{
|
||||||
Debug.Assert(e.Name == ConfigFileName);
|
try
|
||||||
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);
|
fileWatcher.EnableRaisingEvents = false;
|
||||||
return;
|
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 INamingConvention namingConvention = CamelCaseNamingConvention.Instance;
|
||||||
|
|
||||||
private static ServerConfiguration? fromFile(string path)
|
private static ServerConfiguration? fromFile(string path)
|
||||||
{
|
{
|
||||||
IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).Build();
|
IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).IgnoreUnmatchedProperties().Build();
|
||||||
|
|
||||||
string text;
|
string text;
|
||||||
|
|
||||||
|
@ -163,12 +170,6 @@ public class ServerConfiguration
|
||||||
|
|
||||||
#endregion
|
#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 WebsiteListenUrl { get; set; } = "http://localhost:10060";
|
||||||
public string GameApiListenUrl { get; set; } = "http://localhost:10061";
|
public string GameApiListenUrl { get; set; } = "http://localhost:10061";
|
||||||
public string ApiListenUrl { get; set; } = "http://localhost:10062";
|
public string ApiListenUrl { get; set; } = "http://localhost:10062";
|
||||||
|
@ -197,4 +198,5 @@ public class ServerConfiguration
|
||||||
public UserGeneratedContentLimitConfiguration UserGeneratedContentLimits { get; set; } = new();
|
public UserGeneratedContentLimitConfiguration UserGeneratedContentLimits { get; set; } = new();
|
||||||
public WebsiteConfiguration WebsiteConfiguration { get; set; } = new();
|
public WebsiteConfiguration WebsiteConfiguration { get; set; } = new();
|
||||||
public CustomizationConfiguration Customization { get; set; } = new();
|
public CustomizationConfiguration Customization { get; set; } = new();
|
||||||
|
public RateLimitConfiguration RateLimitConfiguration { get; set; } = new();
|
||||||
}
|
}
|
|
@ -231,6 +231,15 @@ public static class FileHelper
|
||||||
|
|
||||||
public static bool ResourceExists(string hash) => File.Exists(GetResourcePath(hash));
|
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)
|
public static int ResourceSize(string hash)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
@ -24,4 +24,5 @@ public enum LogArea
|
||||||
Publish,
|
Publish,
|
||||||
Maintenance,
|
Maintenance,
|
||||||
Score,
|
Score,
|
||||||
|
RateLimit,
|
||||||
}
|
}
|
139
ProjectLighthouse/Middlewares/RateLimitMiddleware.cs
Normal file
139
ProjectLighthouse/Middlewares/RateLimitMiddleware.cs
Normal file
|
@ -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<IPAddress, List<LighthouseRequest>> 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<string, RateLimitOptions> overrides = ServerConfiguration.Instance.RateLimitConfiguration.OverrideOptions;
|
||||||
|
List<string> 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<LighthouseRequest>()).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<LighthouseRequest> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -10,8 +10,8 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="Pfim" Version="0.11.1"/>
|
<PackageReference Include="Pfim" Version="0.11.1" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3"/>
|
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
||||||
<PackageReference Include="Discord.Net.Webhook" Version="3.8.1" />
|
<PackageReference Include="Discord.Net.Webhook" Version="3.8.1" />
|
||||||
<PackageReference Include="InfluxDB.Client" Version="4.5.0" />
|
<PackageReference Include="InfluxDB.Client" Version="4.5.0" />
|
||||||
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0" />
|
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0" />
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue