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:

" + + $"

{resetUrl}

" + + $"

If you didn't request a reset, ignore this email — your password remains unchanged.

"; + await emailService.SendAsync(account.Email, subject, plain, html, ct); + } + + /// + /// Look up + validate (not yet consumed, not expired) a reset token. + /// Returns null if not found / not valid. + /// + public async Task FindValidAsync(string token) + { + var doc = await collection.Find(x => x.Token == token).FirstOrDefaultAsync(); + if (doc is null || !doc.IsValid()) return null; + return doc; + } + + /// + /// Atomically mark the token as consumed. Returns true if this call + /// flipped Consumed from false→true (i.e. caller is the first to consume). + /// + public async Task ConsumeAsync(string token) + { + var update = Builders.Update.Set(x => x.Consumed, true); + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Token, token), + Builders.Filter.Eq(x => x.Consumed, false), + Builders.Filter.Gt(x => x.ExpiresAt, DateTime.UtcNow)); + var result = await collection.UpdateOneAsync(filter, update); + return result.ModifiedCount == 1; + } + + private static string GenerateTokenString() + { + Span buf = stackalloc byte[32]; + RandomNumberGenerator.Fill(buf); + return Convert.ToHexString(buf).ToLowerInvariant(); + } +} diff --git a/UT4MasterServer.Services/UT4MasterServer.Services.csproj b/UT4MasterServer.Services/UT4MasterServer.Services.csproj index 33e9edf4..285d1f29 100644 --- a/UT4MasterServer.Services/UT4MasterServer.Services.csproj +++ b/UT4MasterServer.Services/UT4MasterServer.Services.csproj @@ -9,6 +9,7 @@ + diff --git a/UT4MasterServer.Web/public/news/2023-01-23.html b/UT4MasterServer.Web/public/news/2023-01-23.html index e15e5c18..76dcf334 100644 --- a/UT4MasterServer.Web/public/news/2023-01-23.html +++ b/UT4MasterServer.Web/public/news/2023-01-23.html @@ -1,15 +1,45 @@ - + -

Hi there!

+

Dallas Pick — UT4 Local Stack Roadmap

+

Reviving UT4 pre-alpha (XAN-3525360) against a self-hosted master server.

-

- This is a small test of fancy announcements that we'll be able to use in - the future. -

+

Shipped

+
    +
  • Username/email + password auth in game (no "auth token" copy/paste)
  • +
  • "Forgot password" link in the login dialog goes to the master-server reset page
  • +
  • Password reset via email (Resend in prod, mailpit locally)
  • +
  • Quick-Play Blitz tile lands you in a real FlagRun match
  • +
  • Announcement panel populated from the master server (this page)
  • +
  • In-game chat works in-match (everyone + team channels)
  • +
-

Enjoy the game after the official shutdown! :D

+

Coming soon

+
    +
  • Voice chat (push-to-talk)
  • +
  • Collectable items / cosmetics
  • +
  • OnlineFriendsMcp wired up — friends list + chat
  • +
  • Show Player Card from scoreboard + friends UI
  • +
+ +
+ diff --git a/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue b/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue index 6bb39131..51cb8204 100644 --- a/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue +++ b/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue @@ -8,12 +8,73 @@
Update Cloud File

- Note: The filename will be - {{ file?.filename }} regardless of the name of the - uploaded file. + Editing + {{ file?.filename }} + — the filename on the server is preserved regardless of how the + contents are supplied.

-
- + + + +
+
+