// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using EpicGames.Horde.Jobs; using EpicGames.Horde.Jobs.Bisect; using EpicGames.Horde.Jobs.Templates; using EpicGames.Horde.Streams; using EpicGames.Horde.Users; using HordeServer.Server; using HordeServer.Utilities; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using MongoDB.Bson; using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using MongoDB.Driver.Linq; namespace HordeServer.Users { /// /// Manages user documents /// class UserCollectionV2 : IUserCollection, IDisposable { class UserDocument : IUser { public UserId Id { get; set; } public string Name { get; set; } public string Login { get; set; } public string LoginUpper { get; set; } public string? Email { get; set; } public string? EmailUpper { get; set; } [BsonIgnoreIfDefault] public bool? Hidden { get; set; } [BsonConstructor] private UserDocument() { Name = null!; Login = null!; LoginUpper = null!; } public UserDocument(IUser other) : this(other.Id, other.Name, other.Login, other.Email) { } public UserDocument(UserId id, string name, string login, string? email) { Id = id; Name = name; Login = login; LoginUpper = login.ToUpperInvariant(); Email = email; EmailUpper = email?.ToUpperInvariant(); } } class UserClaimsDocument : IUserClaims { public UserId Id { get; set; } public List Claims { get; set; } = new List(); UserId IUserClaims.UserId => Id; IReadOnlyList IUserClaims.Claims => Claims; [BsonConstructor] private UserClaimsDocument() { } public UserClaimsDocument(UserId id) { Id = id; } public UserClaimsDocument(IUserClaims other) : this(other.UserId) { Claims.AddRange(other.Claims.Select(x => new UserClaim(x))); } } class JobTemplateSettingsDocument : IUserJobTemplateSettings { public StreamId StreamId { get; set; } public TemplateId TemplateId { get; set; } public string TemplateHash { get; set; } = String.Empty; public List Arguments { get; set; } = new List(); IReadOnlyList IUserJobTemplateSettings.Arguments => Arguments; public DateTime UpdateTimeUtc { get; set; } [BsonConstructor] private JobTemplateSettingsDocument() { } /// /// Constructor /// /// /// /// /// public JobTemplateSettingsDocument(StreamId streamId, TemplateId templateId, string templateHash, List arguments) { StreamId = streamId; TemplateId = templateId; TemplateHash = templateHash; Arguments = arguments; UpdateTimeUtc = DateTime.UtcNow; } } class UserSettingsDocument : IUserSettings { public UserId Id { get; set; } [BsonDefaultValue(false), BsonIgnoreIfDefault] public bool EnableExperimentalFeatures { get; set; } [BsonDefaultValue(false), BsonIgnoreIfDefault] public bool AlwaysTagPreflightCL { get; set; } public BsonValue DashboardSettings { get; set; } = BsonNull.Value; public List PinnedJobIds { get; set; } = new List(); public List PinnedBisectTaskIds { get; set; } = new List(); UserId IUserSettings.UserId => Id; IReadOnlyList IUserSettings.PinnedJobIds => PinnedJobIds; IReadOnlyList IUserSettings.PinnedBisectTaskIds => PinnedBisectTaskIds; public List JobTemplateSettings { get; set; } = new List(); IReadOnlyList? IUserSettings.JobTemplateSettings => JobTemplateSettings; [BsonConstructor] private UserSettingsDocument() { } public UserSettingsDocument(UserId id) { Id = id; } public UserSettingsDocument(IUserSettings other) : this(other.UserId) { EnableExperimentalFeatures = other.EnableExperimentalFeatures; DashboardSettings = other.DashboardSettings; PinnedJobIds = new List(other.PinnedJobIds); PinnedBisectTaskIds = new List(other.PinnedBisectTaskIds); } } class ClaimDocument : IUserClaim { public string Type { get; set; } public string Value { get; set; } private ClaimDocument() { Type = null!; Value = null!; } public ClaimDocument(string type, string value) { Type = type; Value = value; } public ClaimDocument(IUserClaim other) { Type = other.Type; Value = other.Value; } } readonly IMongoCollection _users; readonly IMongoCollection _userClaims; readonly IMongoCollection _userSettings; readonly ILogger _logger; readonly MemoryCache _userCache; /// /// Static constructor /// static UserCollectionV2() { BsonSerializer.RegisterDiscriminatorConvention(typeof(UserDocument), NullDiscriminatorConvention.Instance); BsonSerializer.RegisterDiscriminatorConvention(typeof(UserClaimsDocument), NullDiscriminatorConvention.Instance); BsonSerializer.RegisterDiscriminatorConvention(typeof(UserSettingsDocument), NullDiscriminatorConvention.Instance); BsonSerializer.RegisterDiscriminatorConvention(typeof(ClaimDocument), NullDiscriminatorConvention.Instance); } /// /// Constructor /// /// /// public UserCollectionV2(IMongoService mongoService, ILogger logger) { _logger = logger; List> userIndexes = new List>(); userIndexes.Add(keys => keys.Ascending(x => x.LoginUpper), unique: true); userIndexes.Add(keys => keys.Ascending(x => x.EmailUpper)); _users = mongoService.GetCollection("UsersV2", userIndexes); _userClaims = mongoService.GetCollection("UserClaimsV2"); _userSettings = mongoService.GetCollection("UserSettingsV2"); MemoryCacheOptions options = new MemoryCacheOptions(); _userCache = new MemoryCache(options); } /// public void Dispose() { _userCache.Dispose(); } /// public async Task GetUserAsync(UserId id, CancellationToken cancellationToken) { if (id == UserId.Anonymous) { // Anonymous user is handled as a special case so we can debug with local anonymous login against a production DB in read-only mode. return new UserDocument(id, "Anonymous", "anonymous", null); } IUser? user = await _users.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken); using (ICacheEntry entry = _userCache.CreateEntry(id)) { entry.SetValue(user); entry.SetSlidingExpiration(TimeSpan.FromMinutes(30.0)); } return user; } /// public async ValueTask GetCachedUserAsync(UserId? id, CancellationToken cancellationToken) { IUser? user; if (id == null) { return null; } else if (_userCache.TryGetValue(id.Value, out user)) { return user; } else { return await GetUserAsync(id.Value, cancellationToken); } } /// public async Task> FindUsersAsync(IEnumerable? ids, string? nameRegex = null, int? index = null, int? count = null, CancellationToken cancellationToken = default) { FilterDefinition filter = FilterDefinition.Empty; if (ids != null) { filter &= Builders.Filter.In(x => x.Id, ids); } if (nameRegex != null) { BsonRegularExpression regex = new BsonRegularExpression(nameRegex, "i"); filter &= Builders.Filter.Regex(x => x.Name, regex); } filter &= Builders.Filter.Ne(x => x.Hidden, true); return await _users.Find(filter).Range(index, count ?? 100).ToListAsync(cancellationToken); } /// public async Task FindUserByLoginAsync(string login, CancellationToken cancellationToken) { string loginUpper = login.ToUpperInvariant(); FilterDefinition filter = Builders.Filter.Eq(x => x.LoginUpper, loginUpper) & Builders.Filter.Ne(x => x.Hidden, true); return await _users.Find(filter).FirstOrDefaultAsync(cancellationToken); } /// public async Task FindUserByEmailAsync(string email, CancellationToken cancellationToken) { string emailUpper = email.ToUpperInvariant(); FilterDefinition filter = Builders.Filter.Eq(x => x.EmailUpper, emailUpper) & Builders.Filter.Ne(x => x.Hidden, true); return await _users.Find(filter).FirstOrDefaultAsync(cancellationToken); } /// public async Task FindOrAddUserByLoginAsync(string login, string? name, string? email, CancellationToken cancellationToken) { UserId newUserId = new UserId(BinaryIdUtils.CreateNew()); UpdateDefinition update = Builders.Update.SetOnInsert(x => x.Id, newUserId).SetOnInsert(x => x.Login, login).Unset(x => x.Hidden); if (name == null) { update = update.SetOnInsert(x => x.Name, login); } else { update = update.Set(x => x.Name, name); } if (email != null) { update = update.Set(x => x.Email, email).Set(x => x.EmailUpper, email.ToUpperInvariant()); } string loginUpper = login.ToUpperInvariant(); IUser user = await _users.FindOneAndUpdateAsync(x => x.LoginUpper == loginUpper, update, new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }, cancellationToken); if (user.Id == newUserId) { _logger.LogInformation("Added new user {Name} ({UserId}, {Login}, {Email})", user.Name, user.Id, user.Login, user.Email); } return user; } /// public async Task GetClaimsAsync(UserId userId, CancellationToken cancellationToken) { IUserClaims? claims = await _userClaims.Find(x => x.Id == userId).FirstOrDefaultAsync(cancellationToken); claims ??= new UserClaimsDocument(userId); return claims; } /// public async Task UpdateClaimsAsync(UserId userId, IEnumerable claims, CancellationToken cancellationToken) { UserClaimsDocument newDocument = new UserClaimsDocument(userId); newDocument.Claims.AddRange(claims.Select(x => new UserClaim(x))); await _userClaims.ReplaceOneAsync(x => x.Id == userId, newDocument, new ReplaceOptions { IsUpsert = true }, cancellationToken); } /// public async Task GetSettingsAsync(UserId userId, CancellationToken cancellationToken) { IUserSettings? settings = await _userSettings.Find(x => x.Id == userId).FirstOrDefaultAsync(cancellationToken); settings ??= new UserSettingsDocument(userId); return settings; } /// public async Task UpdateSettingsAsync(UserId userId, bool? enableExperimentalFeatures = null, bool? alwaysTagPreflightCL = null, BsonValue? dashboardSettings = null, IEnumerable? addPinnedJobIds = null, IEnumerable? removePinnedJobIds = null, UpdateUserJobTemplateOptions? templateOptions = null, IEnumerable? addBisectTaskIds = null, IEnumerable? removeBisectTaskIds = null, CancellationToken cancellationToken = default) { List> updates = new List>(); if (enableExperimentalFeatures != null) { updates.Add(Builders.Update.SetOrUnsetNull(x => x.EnableExperimentalFeatures, enableExperimentalFeatures)); } if (alwaysTagPreflightCL != null) { updates.Add(Builders.Update.SetOrUnsetNull(x => x.AlwaysTagPreflightCL, alwaysTagPreflightCL)); } if (dashboardSettings != null) { updates.Add(Builders.Update.Set(x => x.DashboardSettings, dashboardSettings)); } if (addPinnedJobIds != null && addPinnedJobIds.Any()) { updates.Add(Builders.Update.AddToSetEach(x => x.PinnedJobIds, addPinnedJobIds)); } if (removePinnedJobIds != null && removePinnedJobIds.Any()) { updates.Add(Builders.Update.PullAll(x => x.PinnedJobIds, removePinnedJobIds)); } if (addBisectTaskIds != null && addBisectTaskIds.Any()) { updates.Add(Builders.Update.AddToSetEach(x => x.PinnedBisectTaskIds, addBisectTaskIds)); } if (removeBisectTaskIds != null && removeBisectTaskIds.Any()) { updates.Add(Builders.Update.PullAll(x => x.PinnedBisectTaskIds, removeBisectTaskIds)); } if (templateOptions != null) { JobTemplateSettingsDocument doc = new JobTemplateSettingsDocument(templateOptions.StreamId, templateOptions.TemplateId, templateOptions.TemplateHash, templateOptions.Arguments.ToList()); FilterDefinition filter = Builders.Filter.Eq(x => x.Id, userId) & Builders.Filter.ElemMatch(x => x.JobTemplateSettings, t => t.StreamId == templateOptions.StreamId && t.TemplateId == templateOptions.TemplateId); UpdateResult result = await _userSettings.UpdateOneAsync(filter, Builders.Update.Set(x => x.JobTemplateSettings[-1], doc), null, cancellationToken); if (result.ModifiedCount == 0) { updates.Add(Builders.Update.PushEach(x => x.JobTemplateSettings, new[] { doc }, -100)); } } if (updates.Count > 0) { await _userSettings.UpdateOneAsync(x => x.Id == userId, Builders.Update.Combine(updates), new UpdateOptions { IsUpsert = true }, cancellationToken); } } /// /// Upgrade from V1 collection /// /// /// Cancellation token for the operation /// public async Task ResaveDocumentsAsync(UserCollectionV1 userCollectionV1, CancellationToken cancellationToken) { await foreach ((IUser user, IUserClaims claims, IUserSettings settings) in userCollectionV1.EnumerateDocumentsAsync(cancellationToken)) { try { await _users.ReplaceOneAsync(x => x.Id == user.Id, new UserDocument(user), new ReplaceOptions { IsUpsert = true }, cancellationToken); await _userClaims.ReplaceOneAsync(x => x.Id == user.Id, new UserClaimsDocument(claims), new ReplaceOptions { IsUpsert = true }, cancellationToken); await _userSettings.ReplaceOneAsync(x => x.Id == user.Id, new UserSettingsDocument(settings), new ReplaceOptions { IsUpsert = true }, cancellationToken); _logger.LogDebug("Updated user {UserId}", user.Id); } catch (MongoWriteException ex) { _logger.LogWarning(ex, "Unable to resave user {UserId}", user.Id); } if (settings.PinnedJobIds.Count > 0) { await UpdateSettingsAsync(user.Id, addPinnedJobIds: settings.PinnedJobIds, cancellationToken: cancellationToken); } } } } }