diff --git a/UT4MasterServer.Models/DTO/Request/QuickPlayRequest.cs b/UT4MasterServer.Models/DTO/Request/QuickPlayRequest.cs
new file mode 100644
index 00000000..ef8a25d4
--- /dev/null
+++ b/UT4MasterServer.Models/DTO/Request/QuickPlayRequest.cs
@@ -0,0 +1,36 @@
+namespace UT4MasterServer.Models.DTO.Requests;
+
+///
+/// Request body for POST /ut/api/matchmaking/quickplay.
+///
+/// Returns one running game-server suitable for a Quick-Play join.
+/// Selection is server-side so the master server can steer Quick-Play
+/// players onto a small curated pool of always-on, bot-filled servers
+/// (humans displace bots as they join).
+///
+/// Mark a server as a Quick-Play target by setting the
+/// UT_RULETAG_s game-server attribute (e.g. "QuickPlay_iDM",
+/// "QuickPlay_CTF", "QuickPlay_DUEL"). Tags match the
+/// UniqueTag values in UnrealTournmentMCPGameRulesets.json.
+///
+public class QuickPlayRequest
+{
+ ///
+ /// Ruleset tag the player wants to play. Matched against the
+ /// UT_RULETAG_s attribute on candidate game servers. If null
+ /// or empty, any tagged Quick-Play server is eligible.
+ ///
+ public string? RulesetTag { get; set; } = null;
+
+ ///
+ /// Optional map preference (matched against the MAPNAME_s
+ /// attribute). If null, any map is acceptable.
+ ///
+ public string? PreferredMap { get; set; } = null;
+
+ ///
+ /// Optional build filter so old clients aren't routed to newer
+ /// servers and vice-versa.
+ ///
+ public string? BuildUniqueId { get; set; } = null;
+}
diff --git a/UT4MasterServer.Models/DTO/Response/WaitTimeEstimateResponse.cs b/UT4MasterServer.Models/DTO/Response/WaitTimeEstimateResponse.cs
index fa0a2f81..17f6f4c7 100644
--- a/UT4MasterServer.Models/DTO/Response/WaitTimeEstimateResponse.cs
+++ b/UT4MasterServer.Models/DTO/Response/WaitTimeEstimateResponse.cs
@@ -4,6 +4,10 @@ namespace UT4MasterServer.Models.DTO.Responses;
public sealed class WaitTimeEstimateResponse
{
+ // UTMcpUtils.cpp:186-223 reads JSON via Obj->GetStringField("ratingType"),
+ // GetIntegerField("numSamples"), GetNumberField("averageWaitTimeSecs") —
+ // strict camelCase. "Error: 1" is also raised when the response
+ // Content-Type isn't exactly "application/json" (no charset suffix).
[JsonPropertyName("ratingType")]
public string RatingType { get; set; }
diff --git a/UT4MasterServer.Models/Database/FriendRequest.cs b/UT4MasterServer.Models/Database/FriendRequest.cs
index c49c4934..c993c03d 100644
--- a/UT4MasterServer.Models/Database/FriendRequest.cs
+++ b/UT4MasterServer.Models/Database/FriendRequest.cs
@@ -21,4 +21,7 @@ public class FriendRequest
[BsonElement("Status")]
public FriendStatus Status { get; set; } = FriendStatus.Pending;
+
+ [BsonElement("Created")]
+ public DateTime Created { get; set; } = DateTime.UtcNow;
}
diff --git a/UT4MasterServer.Models/Database/GameServer.cs b/UT4MasterServer.Models/Database/GameServer.cs
index 5d5c433f..a0355182 100644
--- a/UT4MasterServer.Models/Database/GameServer.cs
+++ b/UT4MasterServer.Models/Database/GameServer.cs
@@ -226,6 +226,60 @@ public JsonObject ToJson(bool isResponseToClient)
// Do some preprocessing on attributes
JsonObject? attrs = Attributes.ToJObject();
+ // UT4's matchmaking gather (UTSearchPass + UTMatchmakingGather) does
+ // client-side validation against attributes the server may not
+ // advertise. Backfill sane defaults so the candidate isn't dropped:
+ // * UT_NEEDS_i — free slots; matchmaker requires NEEDS_i >= partySize
+ // * UT_TEAMELO_i — team ELO mid-point; default 1500 when untracked
+ // * UT_TEAMELO2_i — secondary team ELO mid-point
+ if (isResponseToClient)
+ {
+ int openPublic = Math.Max(0, MaxPublicPlayers - PublicPlayers.Count);
+ if (attrs!["UT_NEEDS_i"] is null)
+ attrs["UT_NEEDS_i"] = openPublic;
+ if (attrs["UT_TEAMELO_i"] is null)
+ attrs["UT_TEAMELO_i"] = 1500;
+ if (attrs["UT_TEAMELO2_i"] is null)
+ attrs["UT_TEAMELO2_i"] = 1500;
+
+ // AUTGameSessionRanked servers (UT_RANKED_i=1) PUT their own
+ // session settings on every heartbeat and that overwrites any
+ // QuickPlay tags an operator added via mongo updateOne. Without
+ // those tags the matchmaker filter returns 0 candidates and the
+ // QuickPlay tile spins forever. Auto-inject the standard tags
+ // so the operator never has to re-apply them after the server
+ // loses its FlagRun config (e.g. after a match cycles back to
+ // UTEmptyServerGameMode).
+ //
+ // This is local-dev-curated-pool behavior: every Ranked server
+ // is treated as a QuickPlay_Blitz candidate. If you ever run
+ // multiple Ranked playlists (DM, CTF, Showdown) on the same
+ // master, gate this off a server-tag lookup table keyed by
+ // e.g. ServerPort or OwningClientID instead.
+ if (attrs["UT_RANKED_i"]?.GetValue() == 1)
+ {
+ if (attrs["UT_RULETAG_s"] is null)
+ attrs["UT_RULETAG_s"] = "QuickPlay_Blitz";
+ if (attrs["UT_PLAYLISTID_i"] is null || attrs["UT_PLAYLISTID_i"]?.GetValue() == 0)
+ attrs["UT_PLAYLISTID_i"] = 11;
+ if (attrs["PLAYLISTID_i"] is null || attrs["PLAYLISTID_i"]?.GetValue() == 0)
+ attrs["PLAYLISTID_i"] = 11;
+ // UTSERVERTRUSTLEVEL_i: UTGameSessionRanked sets this to 1
+ // (Trusted) but UT4's QuickPlay tile criteria asks for
+ // UT_SERVERTRUSTLEVEL_i=0 (Epic-curated). Master-side
+ // criteria-key normalization already skips this filter, but
+ // fix it in the response too so any future client-side
+ // validation we don't know about doesn't drop the candidate.
+ attrs["UT_SERVERTRUSTLEVEL_i"] = 0;
+ if (attrs["UT_GAMEINSTANCE_i"] is null)
+ attrs["UT_GAMEINSTANCE_i"] = 0;
+ if (attrs["UT_MATCHSTATE_s"] is null || attrs["UT_MATCHSTATE_s"]?.GetValue() == "EMPTY")
+ attrs["UT_MATCHSTATE_s"] = "WaitingToStart";
+ if (attrs["UT_SERVERNAME_s"] is null)
+ attrs["UT_SERVERNAME_s"] = "Blitz Quick Play";
+ }
+ }
+
// build json
var obj = new List>();
@@ -268,7 +322,18 @@ public JsonObject ToJson(bool isResponseToClient)
obj.Add(new("usesPresence", UsesPresence));
obj.Add(new("allowJoinViaPresence", AllowJoinViaPresence));
obj.Add(new("allowJoinViaPresenceFriendsOnly", AllowJoinViaPresenceFriendsOnly));
- obj.Add(new("buildUniqueId", BuildUniqueID));
+ // UE4's FOnlineSessionSettings::BuildUniqueId is int32. If we emit it
+ // as a JSON string, FJsonObject::GetIntegerField returns 0 (or fails
+ // silently depending on the build), which doesn't match the client's
+ // BuildUniqueId so the session is dropped from SearchResults entirely.
+ if (int.TryParse(BuildUniqueID, out int buildIdNum))
+ {
+ obj.Add(new("buildUniqueId", buildIdNum));
+ }
+ else
+ {
+ obj.Add(new("buildUniqueId", BuildUniqueID));
+ }
obj.Add(new("lastUpdated", LastUpdated.ToStringISO()));
obj.Add(new("started", Started));
if (!isResponseToClient)
diff --git a/UT4MasterServer.Models/Database/PasswordResetToken.cs b/UT4MasterServer.Models/Database/PasswordResetToken.cs
new file mode 100644
index 00000000..d7ee0630
--- /dev/null
+++ b/UT4MasterServer.Models/Database/PasswordResetToken.cs
@@ -0,0 +1,50 @@
+using MongoDB.Bson.Serialization.Attributes;
+using UT4MasterServer.Common;
+
+namespace UT4MasterServer.Models.Database;
+
+///
+/// Single-use password reset token. Generated on forgot-password request,
+/// emailed to the account's address, consumed by reset-password.
+///
+/// Stored in collection "password_reset_tokens" with a TTL index on
+/// so mongo auto-expires stale rows.
+///
+[BsonIgnoreExtraElements]
+public sealed class PasswordResetToken
+{
+ /// Hex-encoded 32-byte random token. Stored hashed in a future hardening pass.
+ [BsonId]
+ public string Token { get; set; } = string.Empty;
+
+ [BsonElement("AccountID")]
+ public EpicID AccountID { get; set; } = EpicID.Empty;
+
+ [BsonElement("CreatedAt")]
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// Token expiry. Mongo TTL index will auto-delete past this time, but the
+ /// service also checks explicitly on each use as a defense-in-depth.
+ ///
+ [BsonElement("ExpiresAt")]
+ public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(1);
+
+ /// True after the token has been consumed by a reset.
+ [BsonElement("Consumed")]
+ public bool Consumed { get; set; } = false;
+
+ public PasswordResetToken() { }
+
+ public PasswordResetToken(string token, EpicID accountID, TimeSpan validity)
+ {
+ Token = token;
+ AccountID = accountID;
+ CreatedAt = DateTime.UtcNow;
+ ExpiresAt = DateTime.UtcNow.Add(validity);
+ Consumed = false;
+ }
+
+ public bool IsValid()
+ => !Consumed && DateTime.UtcNow < ExpiresAt;
+}
diff --git a/UT4MasterServer.Models/Settings/ApplicationSettings.cs b/UT4MasterServer.Models/Settings/ApplicationSettings.cs
index d28b7143..f50c9d66 100644
--- a/UT4MasterServer.Models/Settings/ApplicationSettings.cs
+++ b/UT4MasterServer.Models/Settings/ApplicationSettings.cs
@@ -30,4 +30,12 @@ public sealed class ApplicationSettings
/// IP addresses of trusted proxy servers.
///
public List ProxyServers { get; set; } = new List();
+
+ ///
+ /// SMTP transport for outbound mail (password resets, etc.). When
+ /// is false, mail-dependent
+ /// features no-op (request accepted, no email sent — anti-enumeration
+ /// surface still preserved).
+ ///
+ public MailSettings Mail { get; set; } = new MailSettings();
}
diff --git a/UT4MasterServer.Models/Settings/MailSettings.cs b/UT4MasterServer.Models/Settings/MailSettings.cs
new file mode 100644
index 00000000..38d99cfc
--- /dev/null
+++ b/UT4MasterServer.Models/Settings/MailSettings.cs
@@ -0,0 +1,43 @@
+namespace UT4MasterServer.Models.Settings;
+
+///
+/// SMTP / mail transport configuration. Populated from appsettings.json
+/// (section "ApplicationSettings:Mail") or the equivalent env vars
+/// (e.g. ApplicationSettings__Mail__Host).
+///
+public sealed class MailSettings
+{
+ /// SMTP hostname (e.g. "mailpit" in dev, real SMTP host in prod).
+ public string Host { get; set; } = string.Empty;
+
+ /// SMTP port. 1025 for mailpit; 587 for STARTTLS; 465 for SMTPS.
+ public int Port { get; set; } = 0;
+
+ /// Auth username; leave empty for unauthenticated SMTP (e.g. mailpit).
+ public string Username { get; set; } = string.Empty;
+
+ /// Auth password; leave empty for unauthenticated SMTP.
+ public string Password { get; set; } = string.Empty;
+
+ /// Whether to use STARTTLS when connecting.
+ public bool UseStartTls { get; set; } = false;
+
+ /// Whether to use implicit TLS (SMTPS, usually port 465).
+ public bool UseSsl { get; set; } = false;
+
+ /// From: address on outbound mail.
+ public string FromAddress { get; set; } = "noreply@ut4-hub.local";
+
+ /// From: display name on outbound mail.
+ public string FromName { get; set; } = "UT4 Master Server";
+
+ ///
+ /// When true (typical for dev), also log every outbound email body to the
+ /// API stdout. Lets you grab the reset link from logs when SMTP isn't
+ /// configured.
+ ///
+ public bool LogToConsole { get; set; } = false;
+
+ /// True iff Host is non-empty (used by EmailService to decide whether to dispatch).
+ public bool IsConfigured => !string.IsNullOrWhiteSpace(Host) && Port > 0;
+}
diff --git a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs
index b9f021dc..527954c9 100644
--- a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs
+++ b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs
@@ -14,13 +14,16 @@ public sealed class ApplicationStartupService : IHostedService
private readonly CloudStorageService cloudStorageService;
private readonly ClientService clientService;
private readonly RatingsService ratingsService;
+ private readonly PasswordResetService passwordResetService;
public ApplicationStartupService(
ILogger logger,
ILogger statsLogger,
IOptions settings,
ILogger cloudStorageLogger,
- ILogger ratingsLogger)
+ ILogger ratingsLogger,
+ ILogger emailLogger,
+ ILogger passwordResetLogger)
{
this.logger = logger;
var db = new DatabaseContext(settings);
@@ -29,6 +32,8 @@ public ApplicationStartupService(
cloudStorageService = new CloudStorageService(db, cloudStorageLogger);
clientService = new ClientService(db);
ratingsService = new RatingsService(ratingsLogger, db);
+ var emailService = new EmailService(settings, emailLogger);
+ passwordResetService = new PasswordResetService(db, accountService, emailService, settings, passwordResetLogger);
}
public async Task StartAsync(CancellationToken cancellationToken)
@@ -37,6 +42,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
await accountService.CreateIndexesAsync();
await statisticsService.CreateIndexesAsync();
await ratingsService.CreateIndexesAsync();
+ await passwordResetService.CreateIndexesAsync();
logger.LogInformation("Initializing MongoDB CloudStorage.");
await cloudStorageService.EnsureSystemFilesExistAsync();
diff --git a/UT4MasterServer.Services/Scoped/EmailService.cs b/UT4MasterServer.Services/Scoped/EmailService.cs
new file mode 100644
index 00000000..445fcb9e
--- /dev/null
+++ b/UT4MasterServer.Services/Scoped/EmailService.cs
@@ -0,0 +1,92 @@
+using MailKit.Net.Smtp;
+using MailKit.Security;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using MimeKit;
+using UT4MasterServer.Models.Settings;
+
+namespace UT4MasterServer.Services.Scoped;
+
+///
+/// Minimal SMTP email dispatcher. Used today by password reset; safe to
+/// generalize to any transactional email.
+///
+/// If is false,
+/// becomes a no-op (returns false). Callers must NOT change their externally
+/// observable behavior based on the return — the anti-enumeration guarantee
+/// of forgot-password depends on the response being identical whether or
+/// not an email actually fired.
+///
+public sealed class EmailService
+{
+ private readonly ILogger logger;
+ private readonly MailSettings settings;
+
+ public EmailService(IOptions appSettings, ILogger logger)
+ {
+ this.logger = logger;
+ this.settings = appSettings.Value.Mail;
+ }
+
+ ///
+ /// Send a plaintext + HTML email. Returns true if SMTP accepted the
+ /// message, false if mail is disabled or sending failed.
+ ///
+ public async Task SendAsync(
+ string toAddress,
+ string subject,
+ string plainBody,
+ string htmlBody,
+ CancellationToken cancellationToken = default)
+ {
+ if (settings.LogToConsole)
+ {
+ logger.LogInformation(
+ "EmailService.SendAsync: to={To} subject={Subject}\n--- plain ---\n{Plain}\n--- html ---\n{Html}",
+ toAddress, subject, plainBody, htmlBody);
+ }
+
+ if (!settings.IsConfigured)
+ {
+ logger.LogInformation("EmailService: skipping send to {To} — Mail.Host/Port not configured", toAddress);
+ return false;
+ }
+
+ var message = new MimeMessage();
+ message.From.Add(new MailboxAddress(settings.FromName, settings.FromAddress));
+ message.To.Add(MailboxAddress.Parse(toAddress));
+ message.Subject = subject;
+ message.Body = new BodyBuilder
+ {
+ TextBody = plainBody,
+ HtmlBody = htmlBody,
+ }.ToMessageBody();
+
+ try
+ {
+ using var client = new SmtpClient();
+ var sslOption = settings.UseSsl
+ ? SecureSocketOptions.SslOnConnect
+ : (settings.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
+
+ await client.ConnectAsync(settings.Host, settings.Port, sslOption, cancellationToken);
+
+ if (!string.IsNullOrEmpty(settings.Username))
+ {
+ await client.AuthenticateAsync(settings.Username, settings.Password, cancellationToken);
+ }
+
+ await client.SendAsync(message, cancellationToken);
+ await client.DisconnectAsync(quit: true, cancellationToken);
+ logger.LogInformation("EmailService: delivered to={To} subject={Subject}", toAddress, subject);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Swallow but record. The caller relies on us NOT throwing to keep
+ // the API response uniform regardless of SMTP outcome.
+ logger.LogWarning(ex, "EmailService: SMTP send failed to={To}", toAddress);
+ return false;
+ }
+ }
+}
diff --git a/UT4MasterServer.Services/Scoped/MatchmakingService.cs b/UT4MasterServer.Services/Scoped/MatchmakingService.cs
index c41d0f10..afd64047 100644
--- a/UT4MasterServer.Services/Scoped/MatchmakingService.cs
+++ b/UT4MasterServer.Services/Scoped/MatchmakingService.cs
@@ -119,9 +119,22 @@ public async Task> ListAsync(GameServerFilterRequest inputFilte
foreach (GameServerAttributeCriteria? condition in inputFilter.Criteria)
{
- // TODO: use skipped conditions to query dynamic value and sort results
- // (UTMatchmakingGather.cpp - search for SETTING_NEEDSSORT)
- if (condition.Key == "NEEDS" || condition.Key == "NEEDSSORT")
+ // Quick-Play search hints UT4 always sends but the master server
+ // either tracks differently or doesn't track at all:
+ // * NEEDS / NEEDS_i - "needs at least N more players";
+ // redundant with OpenPlayersRequired
+ // which is enforced below.
+ // * NEEDSSORT / NEEDSSORT_i - DISTANCE sort hint (UTMatchmakingGather.cpp).
+ // * REGION_s - Epic GeoIP region; not modeled.
+ // * UT_SERVERTRUSTLEVEL_i - UT4 always asks for "0" but our
+ // hub-spawned children advertise the
+ // hub's actual trust level. Letting
+ // this through excludes every valid
+ // Quick-Play server.
+ if (condition.Key == "NEEDS" || condition.Key == "NEEDSSORT" ||
+ condition.Key == "NEEDS_i" || condition.Key == "NEEDSSORT_i" ||
+ condition.Key == "REGION_s" ||
+ condition.Key == "UT_SERVERTRUSTLEVEL_i")
{
continue;
}
@@ -163,8 +176,33 @@ public async Task> ListAsync(GameServerFilterRequest inputFilte
if (compElem != null)
{
- var attrCheck = new BsonElement($"{nameof(GameServer.Attributes)}.{condition.Key}", new BsonDocument(compElem.Value));
- doc.Add(attrCheck);
+ // UT4 search criteria use unprefixed keys (PLAYLISTID_i, REGION_s)
+ // but game servers register them with the UT_ prefix
+ // (UT_PLAYLISTID_i, UT_REGION_s). Normalize to the prefixed form
+ // so the criterion lines up with how servers advertise attributes.
+ string attrKey = condition.Key.StartsWith("UT_")
+ ? condition.Key
+ : $"UT_{condition.Key}";
+
+ // AUTGameSessionRanked servers PUT their own session settings on
+ // every heartbeat, which overwrites UT_PLAYLISTID_i back to 0
+ // and erases UT_RULETAG_s. The matchmaker then can't find them
+ // via the PLAYLISTID_i criterion. Treat any UT_RANKED_i=1 server
+ // as a valid PLAYLISTID candidate so the criterion still matches
+ // the Ranked QuickPlay pool. (Local-dev convention: every Ranked
+ // server in the registry is implicitly part of the QuickPlay
+ // pool. Tighten this if you ever run multiple Ranked playlists.)
+ if (attrKey == "UT_PLAYLISTID_i")
+ {
+ var playlistMatch = new BsonDocument($"{nameof(GameServer.Attributes)}.{attrKey}", new BsonDocument(compElem.Value));
+ var rankedMatch = new BsonDocument($"{nameof(GameServer.Attributes)}.UT_RANKED_i", 1);
+ doc.Add("$or", new BsonArray { playlistMatch, rankedMatch });
+ }
+ else
+ {
+ var attrCheck = new BsonElement($"{nameof(GameServer.Attributes)}.{attrKey}", new BsonDocument(compElem.Value));
+ doc.Add(attrCheck);
+ }
}
}
@@ -201,6 +239,79 @@ public async Task> ListAsync(GameServerFilterRequest inputFilte
return ret;
}
+ ///
+ /// Find one running game server suitable for a Quick-Play join.
+ ///
+ /// Selection rules:
+ /// * server must be non-stale (LastUpdated within StaleAfter)
+ /// * if is supplied, the server must
+ /// advertise UT_RULETAG_s = rulesetTag
+ /// * if is supplied, MAPNAME_s must match
+ /// * if is supplied, BuildUniqueID must match
+ /// * server must have at least one open public slot
+ ///
+ /// Among candidates, the server with the fewest open public slots wins
+ /// (i.e. fillest server with capacity), so Quick-Play players concentrate
+ /// onto the busiest valid server — humans displace bots on the curated
+ /// always-on pool instead of spreading thin.
+ ///
+ /// Returns null when no server matches.
+ ///
+ public async Task FindQuickPlayServerAsync(
+ string? rulesetTag,
+ string? preferredMap,
+ string? buildUniqueId)
+ {
+ var doc = new BsonDocument();
+
+ if (DateTime.UtcNow - runtimeInfoService.StartupTime > StaleAfter)
+ {
+ doc.Add(new BsonElement(nameof(GameServer.LastUpdated), new BsonDocument("$gt", DateTime.UtcNow - StaleAfter)));
+ }
+
+ if (!string.IsNullOrWhiteSpace(buildUniqueId))
+ {
+ doc.Add(new BsonElement(nameof(GameServer.BuildUniqueID), buildUniqueId));
+ }
+
+ if (!string.IsNullOrWhiteSpace(rulesetTag))
+ {
+ doc.Add(new BsonElement($"{nameof(GameServer.Attributes)}.UT_RULETAG_s", rulesetTag));
+ }
+
+ if (!string.IsNullOrWhiteSpace(preferredMap))
+ {
+ doc.Add(new BsonElement($"{nameof(GameServer.Attributes)}.MAPNAME_s", preferredMap));
+ }
+
+ var options = new FindOptions
+ {
+ AllowPartialResults = true,
+ MaxAwaitTime = TimeSpan.FromSeconds(1.0),
+ };
+
+ var filter = new BsonDocumentFilterDefinition(doc);
+ IAsyncCursor? cursor = await serverCollection.FindAsync(filter, options);
+ List? candidates = await cursor.ToListAsync();
+
+ // Filter for spare capacity in memory — MaxPublicPlayers vs PublicPlayers.Count
+ // is awkward to express in Bson without computed fields.
+ List withCapacity = candidates
+ .Where(s => s.MaxPublicPlayers - s.PublicPlayers.Count > 0)
+ .ToList();
+
+ if (withCapacity.Count == 0)
+ {
+ return null;
+ }
+
+ // Fewest open slots wins → concentrate Quick-Play players onto the
+ // fullest server with capacity.
+ return withCapacity
+ .OrderBy(s => s.MaxPublicPlayers - s.PublicPlayers.Count)
+ .First();
+ }
+
public async Task RemoveAllStaleAsync()
{
DateTime now = DateTime.UtcNow; // Use the same value for all checks in this call
diff --git a/UT4MasterServer.Services/Scoped/PasswordResetService.cs b/UT4MasterServer.Services/Scoped/PasswordResetService.cs
new file mode 100644
index 00000000..fde713af
--- /dev/null
+++ b/UT4MasterServer.Services/Scoped/PasswordResetService.cs
@@ -0,0 +1,129 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using MongoDB.Driver;
+using UT4MasterServer.Common;
+using UT4MasterServer.Models.Database;
+using UT4MasterServer.Models.Settings;
+
+namespace UT4MasterServer.Services.Scoped;
+
+///
+/// Issue + consume single-use password reset tokens.
+/// Storage: mongo collection "password_reset_tokens" with TTL index on ExpiresAt.
+///
+public sealed class PasswordResetService
+{
+ private static readonly TimeSpan TokenValidity = TimeSpan.FromHours(1);
+
+ private readonly IMongoCollection collection;
+ private readonly AccountService accountService;
+ private readonly EmailService emailService;
+ private readonly ApplicationSettings appSettings;
+ private readonly ILogger logger;
+
+ public PasswordResetService(
+ DatabaseContext db,
+ AccountService accountService,
+ EmailService emailService,
+ IOptions appSettings,
+ ILogger logger)
+ {
+ this.collection = db.Database.GetCollection("password_reset_tokens");
+ this.accountService = accountService;
+ this.emailService = emailService;
+ this.appSettings = appSettings.Value;
+ this.logger = logger;
+ }
+
+ ///
+ /// Ensures the mongo TTL index is present. Called once at app startup
+ /// from ApplicationStartupService alongside the other CreateIndexes calls.
+ ///
+ public async Task CreateIndexesAsync()
+ {
+ var indexes = new[]
+ {
+ new CreateIndexModel(
+ Builders.IndexKeys.Ascending(x => x.ExpiresAt),
+ new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }),
+ new CreateIndexModel(
+ Builders.IndexKeys.Ascending(x => x.AccountID)),
+ };
+ await collection.Indexes.CreateManyAsync(indexes);
+ }
+
+ ///
+ /// Request a reset for the given email. Returns the issued token
+ /// AND the account, or null if no account matches the email.
+ /// CALLERS MUST NOT vary their response based on this returning null —
+ /// the anti-enumeration property of the forgot-password endpoint depends
+ /// on the response being identical either way.
+ ///
+ public async Task<(PasswordResetToken token, Account account)?> IssueAsync(string email)
+ {
+ var account = await accountService.GetAccountByEmailAsync(email);
+ if (account is null) return null;
+
+ var token = GenerateTokenString();
+ var doc = new PasswordResetToken(token, account.ID, TokenValidity);
+ await collection.InsertOneAsync(doc);
+ logger.LogInformation("PasswordResetService: issued token for {AccountId}", account.ID);
+ return (doc, account);
+ }
+
+ ///
+ /// Send the reset email. Same anti-enumeration caveat as IssueAsync.
+ /// Builds the URL from .
+ ///
+ public async Task SendResetEmailAsync(Account account, string token, CancellationToken ct = default)
+ {
+ var resetUrl = $"{appSettings.WebsiteDomain.TrimEnd('/')}/reset-password?token={token}";
+ var subject = "Reset your UT4 Master Server password";
+ var plain =
+ $"Hi {account.Username},\n\n" +
+ $"A password reset was requested for your UT4 Master Server account.\n" +
+ $"Open this link within 1 hour to choose a new password:\n\n {resetUrl}\n\n" +
+ $"If you didn't request a reset, ignore this email — your password remains unchanged.\n";
+ var html =
+ $"
Hi {System.Net.WebUtility.HtmlEncode(account.Username)},
" +
+ $"
A password reset was requested for your UT4 Master Server account.
" +
+ $"
Open this link within 1 hour to choose a new password: