Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f164865
feat: password reset via email (forgot-password + reset-password)
itpick Jun 15, 2026
e9b101b
chore: drop temporary auth debug logging from SessionController
itpick Jun 15, 2026
7bedc46
chore: actually remove temporary auth debug logging
itpick Jun 15, 2026
0c1220b
feat(admin): inline JSON editor for cloud files
itpick Jun 15, 2026
4054a0f
feat(matchmaking): server-driven Quick-Play endpoint
itpick Jun 15, 2026
a77d599
Merge branch 'feat/quickplay' into smoke-stack-integration
itpick Jun 15, 2026
e4909da
Merge branch 'feat/cloud-file-inline-editor' into smoke-stack-integra…
itpick Jun 15, 2026
20abe1e
fix(quickplay): wrap wait_times/estimate in object envelope
itpick Jun 15, 2026
8553060
fix(quickplay): wait_times returns bare array per Epic UT4 source
itpick Jun 15, 2026
aa0213c
feat(quickplay): unblock matchmaker against UT4 main-menu QuickPlay tile
itpick Jun 16, 2026
54bfbc6
fix(matchmaking): emit buildUniqueId as int + log matchmaker entries
itpick Jun 16, 2026
b1283a7
feat(quickplay+announcement): auto-injected matchmaking tags + local …
itpick Jun 16, 2026
9a8a7a9
fix(announcement): drop leading whitespace from title
itpick Jun 16, 2026
fcd0ea6
chore(scripts): add blitz-watchdog for ranked QuickPlay server
itpick Jun 16, 2026
76cac56
docs: deployment guide — smoke stack, controllers, Quick Play flow
itpick Jun 16, 2026
95195d1
feat(friends): backend ready — favorite field, persisted Created, rec…
itpick Jun 16, 2026
a4f80ed
fix(friends): blocklist returns {blockedUsers:[]} object, not bare array
itpick Jun 16, 2026
ff4d7cf
fix(playercard): stub 'oldplayercard' user-cloud file with empty JSON
itpick Jun 16, 2026
5216d9b
docs: update overnight progress + smoke-test oldplayercard
itpick Jun 16, 2026
0c1e80b
docs: source-rebuild feasibility — CDN dead, recommend binary-patch path
itpick Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions UT4MasterServer.Models/DTO/Request/QuickPlayRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace UT4MasterServer.Models.DTO.Requests;

/// <summary>
/// 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
/// <c>UT_RULETAG_s</c> game-server attribute (e.g. "QuickPlay_iDM",
/// "QuickPlay_CTF", "QuickPlay_DUEL"). Tags match the
/// <c>UniqueTag</c> values in <c>UnrealTournmentMCPGameRulesets.json</c>.
/// </summary>
public class QuickPlayRequest
{
/// <summary>
/// Ruleset tag the player wants to play. Matched against the
/// <c>UT_RULETAG_s</c> attribute on candidate game servers. If null
/// or empty, any tagged Quick-Play server is eligible.
/// </summary>
public string? RulesetTag { get; set; } = null;

/// <summary>
/// Optional map preference (matched against the <c>MAPNAME_s</c>
/// attribute). If null, any map is acceptable.
/// </summary>
public string? PreferredMap { get; set; } = null;

/// <summary>
/// Optional build filter so old clients aren't routed to newer
/// servers and vice-versa.
/// </summary>
public string? BuildUniqueId { get; set; } = null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
3 changes: 3 additions & 0 deletions UT4MasterServer.Models/Database/FriendRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
67 changes: 66 additions & 1 deletion UT4MasterServer.Models/Database/GameServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>() == 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<int>() == 0)
attrs["UT_PLAYLISTID_i"] = 11;
if (attrs["PLAYLISTID_i"] is null || attrs["PLAYLISTID_i"]?.GetValue<int>() == 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<string>() == "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<KeyValuePair<string, JsonNode?>>();

Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions UT4MasterServer.Models/Database/PasswordResetToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using MongoDB.Bson.Serialization.Attributes;
using UT4MasterServer.Common;

namespace UT4MasterServer.Models.Database;

/// <summary>
/// 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
/// <see cref="ExpiresAt"/> so mongo auto-expires stale rows.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PasswordResetToken
{
/// <summary>Hex-encoded 32-byte random token. Stored hashed in a future hardening pass.</summary>
[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;

/// <summary>
/// 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.
/// </summary>
[BsonElement("ExpiresAt")]
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(1);

/// <summary>True after the token has been consumed by a reset.</summary>
[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;
}
8 changes: 8 additions & 0 deletions UT4MasterServer.Models/Settings/ApplicationSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ public sealed class ApplicationSettings
/// IP addresses of trusted proxy servers.
/// </summary>
public List<string> ProxyServers { get; set; } = new List<string>();

/// <summary>
/// SMTP transport for outbound mail (password resets, etc.). When
/// <see cref="MailSettings.IsConfigured"/> is false, mail-dependent
/// features no-op (request accepted, no email sent — anti-enumeration
/// surface still preserved).
/// </summary>
public MailSettings Mail { get; set; } = new MailSettings();
}
43 changes: 43 additions & 0 deletions UT4MasterServer.Models/Settings/MailSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace UT4MasterServer.Models.Settings;

/// <summary>
/// SMTP / mail transport configuration. Populated from appsettings.json
/// (section "ApplicationSettings:Mail") or the equivalent env vars
/// (e.g. ApplicationSettings__Mail__Host).
/// </summary>
public sealed class MailSettings
{
/// <summary>SMTP hostname (e.g. "mailpit" in dev, real SMTP host in prod).</summary>
public string Host { get; set; } = string.Empty;

/// <summary>SMTP port. 1025 for mailpit; 587 for STARTTLS; 465 for SMTPS.</summary>
public int Port { get; set; } = 0;

/// <summary>Auth username; leave empty for unauthenticated SMTP (e.g. mailpit).</summary>
public string Username { get; set; } = string.Empty;

/// <summary>Auth password; leave empty for unauthenticated SMTP.</summary>
public string Password { get; set; } = string.Empty;

/// <summary>Whether to use STARTTLS when connecting.</summary>
public bool UseStartTls { get; set; } = false;

/// <summary>Whether to use implicit TLS (SMTPS, usually port 465).</summary>
public bool UseSsl { get; set; } = false;

/// <summary>From: address on outbound mail.</summary>
public string FromAddress { get; set; } = "noreply@ut4-hub.local";

/// <summary>From: display name on outbound mail.</summary>
public string FromName { get; set; } = "UT4 Master Server";

/// <summary>
/// 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.
/// </summary>
public bool LogToConsole { get; set; } = false;

/// <summary>True iff Host is non-empty (used by EmailService to decide whether to dispatch).</summary>
public bool IsConfigured => !string.IsNullOrWhiteSpace(Host) && Port > 0;
}
8 changes: 7 additions & 1 deletion UT4MasterServer.Services/Hosted/ApplicationStartupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApplicationStartupService> logger,
ILogger<StatisticsService> statsLogger,
IOptions<ApplicationSettings> settings,
ILogger<CloudStorageService> cloudStorageLogger,
ILogger<RatingsService> ratingsLogger)
ILogger<RatingsService> ratingsLogger,
ILogger<EmailService> emailLogger,
ILogger<PasswordResetService> passwordResetLogger)
{
this.logger = logger;
var db = new DatabaseContext(settings);
Expand All @@ -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)
Expand All @@ -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();
Expand Down
92 changes: 92 additions & 0 deletions UT4MasterServer.Services/Scoped/EmailService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Minimal SMTP email dispatcher. Used today by password reset; safe to
/// generalize to any transactional email.
///
/// If <see cref="MailSettings.IsConfigured"/> is false, <see cref="SendAsync"/>
/// 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.
/// </summary>
public sealed class EmailService
{
private readonly ILogger<EmailService> logger;
private readonly MailSettings settings;

public EmailService(IOptions<ApplicationSettings> appSettings, ILogger<EmailService> logger)
{
this.logger = logger;
this.settings = appSettings.Value.Mail;
}

/// <summary>
/// Send a plaintext + HTML email. Returns true if SMTP accepted the
/// message, false if mail is disabled or sending failed.
/// </summary>
public async Task<bool> 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;
}
}
}
Loading