Add multi-user MAX authentication and tenant isolation
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
namespace QMax.Api.Contracts;
|
||||
|
||||
public sealed record DeviceLoginRequest(string PairingCode, string DeviceName);
|
||||
public sealed record BeginPhoneAuthRequest(string PhoneNumber, string DeviceName, string? RegistrationCode = null);
|
||||
public sealed record CompletePhoneAuthRequest(Guid ChallengeId, string ChallengeToken, string Code);
|
||||
public sealed record PhoneAuthChallengeResponse(Guid ChallengeId, string ChallengeToken, MaxBridgeStatusDto MaxStatus, DateTimeOffset ExpiresAt);
|
||||
public sealed record RefreshTokenRequest(string RefreshToken);
|
||||
public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User);
|
||||
public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath);
|
||||
|
||||
@@ -7,6 +7,7 @@ using QMax.Api.Contracts;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
@@ -15,10 +16,117 @@ namespace QMax.Api.Controllers;
|
||||
public sealed class AuthController(
|
||||
QMaxDbContext db,
|
||||
ITokenService tokenService,
|
||||
IOptions<QMaxOptions> options) : ControllerBase
|
||||
IOptions<QMaxOptions> options,
|
||||
IMaxBridgeClient maxBridge,
|
||||
ICurrentUserAccessor currentUser) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("phone/start")]
|
||||
public async Task<ActionResult<PhoneAuthChallengeResponse>> BeginPhoneAuth(
|
||||
BeginPhoneAuthRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!RegistrationCodeIsValid(request.RegistrationCode))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var phone = NormalizePhone(request.PhoneNumber);
|
||||
if (phone is null)
|
||||
{
|
||||
return BadRequest("Phone number must contain 10 to 15 digits.");
|
||||
}
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phone, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User { DisplayName = phone, PhoneNumber = phone };
|
||||
db.Users.Add(user);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.Entry(user).State = EntityState.Detached;
|
||||
user = await db.Users.FirstAsync(x => x.PhoneNumber == phone, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
var retryAfter = DateTimeOffset.UtcNow.AddSeconds(-60);
|
||||
var recentChallengeTimes = await db.MaxLoginChallenges.IgnoreQueryFilters()
|
||||
.Where(x => x.UserId == user.Id && x.CompletedAt == null)
|
||||
.Select(x => x.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (recentChallengeTimes.Any(x => x >= retryAfter))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, "Wait 60 seconds before requesting another MAX code.");
|
||||
}
|
||||
|
||||
var challengeToken = tokenService.CreateRefreshToken();
|
||||
var challenge = new MaxLoginChallenge
|
||||
{
|
||||
UserId = user.Id,
|
||||
DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(),
|
||||
SecretHash = tokenService.HashRefreshToken(challengeToken)
|
||||
};
|
||||
|
||||
using var tenant = currentUser.Push(user.Id);
|
||||
db.MaxLoginChallenges.Add(challenge);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
var status = await maxBridge.BeginNewPhoneLoginAsync(phone, cancellationToken);
|
||||
await SaveMaxStateAsync(user.Id, phone, status, cancellationToken);
|
||||
|
||||
return new PhoneAuthChallengeResponse(
|
||||
challenge.Id,
|
||||
challengeToken,
|
||||
ToDto(status),
|
||||
challenge.ExpiresAt);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("phone/code")]
|
||||
public async Task<ActionResult<AuthResponse>> CompletePhoneAuth(
|
||||
CompletePhoneAuthRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hash = tokenService.HashRefreshToken(request.ChallengeToken ?? "");
|
||||
var challenge = await db.MaxLoginChallenges
|
||||
.IgnoreQueryFilters()
|
||||
.Include(x => x.User)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.ChallengeId && x.SecretHash == hash, cancellationToken);
|
||||
if (challenge?.User is null || challenge.CompletedAt is not null ||
|
||||
challenge.ExpiresAt <= DateTimeOffset.UtcNow || challenge.FailedAttempts >= 5)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
using var tenant = currentUser.Push(challenge.UserId);
|
||||
var status = await maxBridge.SubmitLoginCodeAsync(request.Code?.Trim() ?? "", cancellationToken);
|
||||
await SaveMaxStateAsync(challenge.UserId, challenge.User.PhoneNumber ?? "", status, cancellationToken);
|
||||
if (!status.IsAuthorized)
|
||||
{
|
||||
challenge.FailedAttempts++;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Conflict(ToDto(status));
|
||||
}
|
||||
|
||||
challenge.CompletedAt = DateTimeOffset.UtcNow;
|
||||
challenge.User.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
var refreshToken = tokenService.CreateRefreshToken();
|
||||
var session = new UserSession
|
||||
{
|
||||
User = challenge.User,
|
||||
DeviceName = challenge.DeviceName,
|
||||
RefreshTokenHash = tokenService.HashRefreshToken(refreshToken)
|
||||
};
|
||||
db.UserSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return CreateAuthResponse(challenge.User, session, refreshToken);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("device/login")]
|
||||
public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
|
||||
@@ -127,4 +235,38 @@ public sealed class AuthController(
|
||||
return expectedBytes.Length == actualBytes.Length &&
|
||||
System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
private bool RegistrationCodeIsValid(string? supplied)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(_options.PairingCode) ||
|
||||
FixedTimeEquals(_options.PairingCode, supplied ?? "");
|
||||
}
|
||||
|
||||
private static string? NormalizePhone(string? value)
|
||||
{
|
||||
var digits = new string((value ?? "").Where(char.IsDigit).ToArray());
|
||||
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
|
||||
if (digits.Length == 10) digits = "7" + digits;
|
||||
return digits.Length is >= 10 and <= 15 ? "+" + digits : null;
|
||||
}
|
||||
|
||||
private async Task SaveMaxStateAsync(Guid userId, string phone, MaxBridgeStatus status, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
state = new MaxAccountState { UserId = userId };
|
||||
db.MaxAccountStates.Add(state);
|
||||
}
|
||||
state.PhoneNumber = phone;
|
||||
state.Status = status.Status;
|
||||
state.IsAuthorized = status.IsAuthorized;
|
||||
state.LastUrl = status.Url;
|
||||
state.LastError = status.LastError;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status) =>
|
||||
new(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
using QMax.Api.Services;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
@@ -64,7 +65,7 @@ public sealed class ChatsController(
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return projection.ToDto(chat);
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ public sealed class ChatsController(
|
||||
{
|
||||
chat.UnreadCount = 0;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
|
||||
var query = db.Messages
|
||||
@@ -240,7 +241,7 @@ public sealed class ChatsController(
|
||||
chat.UnreadCount = 0;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -279,7 +280,7 @@ public sealed class ChatsController(
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
DeleteFiles(files);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -327,7 +328,7 @@ public sealed class ChatsController(
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
DeleteFiles(files);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -450,7 +451,7 @@ public sealed class ChatsController(
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -504,7 +505,7 @@ public sealed class ChatsController(
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -541,7 +542,7 @@ public sealed class ChatsController(
|
||||
}
|
||||
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -717,7 +718,7 @@ public sealed class ChatsController(
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -785,7 +786,7 @@ public sealed class ChatsController(
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -929,7 +930,7 @@ public sealed class ChatsController(
|
||||
chat.WebUrl = nextWebUrl;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return chat.WebUrl;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Services;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
@@ -21,11 +22,8 @@ public sealed class MaxController(
|
||||
MaxBridgeSyncService syncService,
|
||||
QMaxDbContext db,
|
||||
IHubContext<QMaxHub> hubContext,
|
||||
ChatProjectionService projection,
|
||||
IOptions<QMaxOptions> options) : ControllerBase
|
||||
ChatProjectionService projection) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -37,7 +35,12 @@ public sealed class MaxController(
|
||||
[HttpPost("login/start")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber;
|
||||
var phone = request.PhoneNumber;
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
phone = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
{
|
||||
return BadRequest("Phone number is required.");
|
||||
@@ -111,27 +114,28 @@ public sealed class MaxController(
|
||||
return BadRequest("Channel was joined but was not saved in QMAX.");
|
||||
}
|
||||
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return projection.ToDto(chat);
|
||||
}
|
||||
|
||||
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||
var userId = User.GetUserId();
|
||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
state = new MaxAccountState { Id = 1 };
|
||||
state = new MaxAccountState { UserId = userId };
|
||||
db.MaxAccountStates.Add(state);
|
||||
}
|
||||
|
||||
state.PhoneNumber = _options.MaxPhoneNumber;
|
||||
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
|
||||
state.Status = status.Status;
|
||||
state.IsAuthorized = status.IsAuthorized;
|
||||
state.LastUrl = status.Url;
|
||||
state.LastError = status.LastError;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
|
||||
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
|
||||
}
|
||||
|
||||
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
|
||||
|
||||
@@ -3,6 +3,8 @@ namespace QMax.Api.Data.Entities;
|
||||
public sealed class Chat
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid? UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public ChatKind Kind { get; set; } = ChatKind.MaxDialog;
|
||||
public string Title { get; set; } = "MAX chat";
|
||||
|
||||
@@ -2,7 +2,8 @@ namespace QMax.Api.Data.Entities;
|
||||
|
||||
public sealed class MaxAccountState
|
||||
{
|
||||
public int Id { get; set; } = 1;
|
||||
public Guid UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
public string PhoneNumber { get; set; } = "";
|
||||
public string Status { get; set; } = "NotStarted";
|
||||
public bool IsAuthorized { get; set; }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace QMax.Api.Data.Entities;
|
||||
|
||||
public sealed class MaxLoginChallenge
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
public string SecretHash { get; set; } = "";
|
||||
public string DeviceName { get; set; } = "Android";
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddMinutes(10);
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public int FailedAttempts { get; set; }
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Data;
|
||||
|
||||
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbContext(options)
|
||||
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options, ICurrentUserAccessor? currentUser = null) : DbContext(options)
|
||||
{
|
||||
private Guid? TenantUserId => currentUser?.UserId;
|
||||
private bool TenantBypass => currentUser is null || currentUser.BypassTenantFilter || currentUser.UserId is null;
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||
public DbSet<Chat> Chats => Set<Chat>();
|
||||
@@ -13,12 +16,20 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
|
||||
public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>();
|
||||
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
||||
public DbSet<MaxAccountState> MaxAccountStates => Set<MaxAccountState>();
|
||||
public DbSet<MaxLoginChallenge> MaxLoginChallenges => Set<MaxLoginChallenge>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber);
|
||||
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber).IsUnique();
|
||||
modelBuilder.Entity<MaxLoginChallenge>().HasIndex(x => x.SecretHash).IsUnique();
|
||||
modelBuilder.Entity<MaxAccountState>().HasKey(x => x.UserId);
|
||||
modelBuilder.Entity<MaxAccountState>()
|
||||
.HasOne(x => x.User)
|
||||
.WithOne()
|
||||
.HasForeignKey<MaxAccountState>(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<UserSession>().HasIndex(x => x.RefreshTokenHash).IsUnique();
|
||||
modelBuilder.Entity<Chat>().HasIndex(x => x.ExternalId).IsUnique();
|
||||
modelBuilder.Entity<Chat>().HasIndex(x => new { x.UserId, x.ExternalId }).IsUnique();
|
||||
modelBuilder.Entity<Chat>().HasIndex(x => x.DeletedAt);
|
||||
modelBuilder.Entity<Chat>().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt });
|
||||
modelBuilder.Entity<Message>().HasIndex(x => new { x.ChatId, x.SentAt });
|
||||
@@ -28,6 +39,21 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
|
||||
modelBuilder.Entity<MessageReaction>().HasIndex(x => new { x.MessageId, x.ActorKey }).IsUnique();
|
||||
modelBuilder.Entity<PushDevice>().HasIndex(x => x.FirebaseToken).IsUnique();
|
||||
|
||||
modelBuilder.Entity<Chat>().HasQueryFilter(x =>
|
||||
TenantBypass || x.UserId == TenantUserId);
|
||||
modelBuilder.Entity<Message>().HasQueryFilter(x =>
|
||||
TenantBypass || (x.Chat != null && x.Chat.UserId == TenantUserId));
|
||||
modelBuilder.Entity<MessageAttachment>().HasQueryFilter(x =>
|
||||
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
|
||||
modelBuilder.Entity<MessageReaction>().HasQueryFilter(x =>
|
||||
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
|
||||
modelBuilder.Entity<PushDevice>().HasQueryFilter(x =>
|
||||
TenantBypass || x.UserId == TenantUserId);
|
||||
modelBuilder.Entity<MaxAccountState>().HasQueryFilter(x =>
|
||||
TenantBypass || x.UserId == TenantUserId);
|
||||
modelBuilder.Entity<MaxLoginChallenge>().HasQueryFilter(x =>
|
||||
TenantBypass || x.UserId == TenantUserId);
|
||||
|
||||
modelBuilder.Entity<Chat>()
|
||||
.Property(x => x.Kind)
|
||||
.HasConversion<string>();
|
||||
@@ -60,4 +86,26 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
|
||||
.HasForeignKey(x => x.MessageId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var newChats = ChangeTracker.Entries<Chat>()
|
||||
.Where(x => x.State == EntityState.Added && x.Entity.UserId is null)
|
||||
.ToArray();
|
||||
var userId = currentUser?.UserId;
|
||||
if (userId is null && currentUser is not null && newChats.Length > 0)
|
||||
{
|
||||
var existingUsers = await Users.Select(x => x.Id).Take(2).ToArrayAsync(cancellationToken);
|
||||
if (existingUsers.Length == 1) userId = existingUsers[0];
|
||||
}
|
||||
if (userId is not null)
|
||||
{
|
||||
foreach (var entry in newChats)
|
||||
{
|
||||
entry.Entity.UserId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
return await base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace QMax.Api.Infrastructure.Auth;
|
||||
|
||||
public interface ICurrentUserAccessor
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
bool BypassTenantFilter { get; }
|
||||
IDisposable Push(Guid? userId, bool bypassTenantFilter = false);
|
||||
}
|
||||
|
||||
public sealed class CurrentUserAccessor : ICurrentUserAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<State?> Current = new();
|
||||
|
||||
public Guid? UserId => Current.Value?.UserId;
|
||||
public bool BypassTenantFilter => Current.Value?.BypassTenantFilter == true;
|
||||
|
||||
public IDisposable Push(Guid? userId, bool bypassTenantFilter = false)
|
||||
{
|
||||
var previous = Current.Value;
|
||||
Current.Value = new State(userId, bypassTenantFilter);
|
||||
return new PopScope(previous);
|
||||
}
|
||||
|
||||
private sealed record State(Guid? UserId, bool BypassTenantFilter);
|
||||
|
||||
private sealed class PopScope(State? previous) : IDisposable
|
||||
{
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Current.Value = previous;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Hubs;
|
||||
|
||||
[Authorize]
|
||||
public sealed class QMaxHub : Hub
|
||||
public sealed class QMaxHub(QMaxDbContext db) : Hub
|
||||
{
|
||||
public Task JoinChat(string chatId)
|
||||
public async Task JoinChat(string chatId)
|
||||
{
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}");
|
||||
if (!Guid.TryParse(chatId, out var id) || !await db.Chats.AnyAsync(x => x.Id == id && x.UserId == Context.User!.GetUserId()))
|
||||
{
|
||||
throw new HubException("Chat not found.");
|
||||
}
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{id}");
|
||||
}
|
||||
|
||||
public Task LeaveChat(string chatId)
|
||||
|
||||
@@ -4,6 +4,8 @@ public interface IMaxBridgeClient
|
||||
{
|
||||
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
|
||||
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken);
|
||||
Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
|
||||
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
|
||||
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
|
||||
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
||||
|
||||
@@ -45,6 +45,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
|
||||
}
|
||||
|
||||
public Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
|
||||
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxContact>>([
|
||||
|
||||
@@ -2,12 +2,14 @@ using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public sealed class WorkerMaxBridgeClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<QMaxOptions> options,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -21,7 +23,17 @@ public sealed class WorkerMaxBridgeClient(
|
||||
|
||||
public async Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken)
|
||||
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<MaxBridgeStatus> BeginPhoneLoginCoreAsync(string phoneNumber, bool forceNewSession, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber, force = forceNewSession }, cancellationToken)
|
||||
?? ErrorStatus("Worker returned an empty login status.");
|
||||
}
|
||||
|
||||
@@ -171,7 +183,9 @@ public sealed class WorkerMaxBridgeClient(
|
||||
{
|
||||
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}";
|
||||
var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
AddAccountHeader(request);
|
||||
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
@@ -217,6 +231,7 @@ public sealed class WorkerMaxBridgeClient(
|
||||
{
|
||||
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
||||
AddAccountHeader(request);
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
@@ -239,6 +254,15 @@ public sealed class WorkerMaxBridgeClient(
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAccountHeader(HttpRequestMessage request)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
{
|
||||
throw new InvalidOperationException("A QMAX user context is required for a PyMax request.");
|
||||
}
|
||||
request.Headers.Add("X-QMax-Account-Id", userId.ToString("N"));
|
||||
}
|
||||
|
||||
private static MaxBridgeStatus ErrorStatus(string error)
|
||||
{
|
||||
return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow);
|
||||
|
||||
@@ -90,6 +90,7 @@ builder.Services.AddControllers().AddJsonOptions(options =>
|
||||
});
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<ICurrentUserAccessor, CurrentUserAccessor>();
|
||||
builder.Services.AddSingleton<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<ChatProjectionService>();
|
||||
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
|
||||
@@ -117,6 +118,15 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var currentUser = context.RequestServices.GetRequiredService<ICurrentUserAccessor>();
|
||||
var userId = context.User.Identity?.IsAuthenticated == true ? context.User.GetUserId() : (Guid?)null;
|
||||
using (currentUser.Push(userId))
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
});
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHub<QMaxHub>("/hubs/qmax");
|
||||
@@ -127,8 +137,12 @@ using (var scope = app.Services.CreateScope())
|
||||
Directory.CreateDirectory(options.StoragePath);
|
||||
Directory.CreateDirectory(options.ReleasesPath);
|
||||
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await EnsureCompatibilitySchemaAsync(db);
|
||||
var currentUser = scope.ServiceProvider.GetRequiredService<ICurrentUserAccessor>();
|
||||
using (currentUser.Push(null, bypassTenantFilter: true))
|
||||
{
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await EnsureCompatibilitySchemaAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
app.Run();
|
||||
@@ -152,6 +166,34 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
if (!chatColumns.Contains("UserId"))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN UserId TEXT NULL;");
|
||||
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
|
||||
if (legacyUserId != Guid.Empty)
|
||||
{
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE Chats SET UserId = {legacyUserId} WHERE UserId IS NULL;");
|
||||
}
|
||||
}
|
||||
|
||||
var maxStateColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA table_info(MaxAccountStates);";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) maxStateColumns.Add(reader.GetString(1));
|
||||
}
|
||||
if (maxStateColumns.Count > 0 && !maxStateColumns.Contains("UserId"))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MaxAccountStates ADD COLUMN UserId TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';");
|
||||
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
|
||||
if (legacyUserId != Guid.Empty)
|
||||
{
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE MaxAccountStates SET UserId = {legacyUserId} WHERE UserId = '00000000-0000-0000-0000-000000000000';");
|
||||
}
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxAccountStates_UserId ON MaxAccountStates (UserId);");
|
||||
}
|
||||
|
||||
if (!chatColumns.Contains("WebUrl"))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;");
|
||||
@@ -218,6 +260,10 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
|
||||
await QMaxDatabaseCleanup.MergeOutgoingRemoteAttachmentEchoesAsync(db);
|
||||
await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(db);
|
||||
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_MessageAttachments_MessageId_ExternalId;");
|
||||
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Chats_ExternalId;");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Chats_UserId_ExternalId ON Chats (UserId, ExternalId);");
|
||||
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Users_PhoneNumber;");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_PhoneNumber ON Users (PhoneNumber) WHERE PhoneNumber IS NOT NULL AND PhoneNumber <> '';");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
|
||||
ON MessageAttachments (MessageId, ExternalId)
|
||||
@@ -255,6 +301,22 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
|
||||
CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS MaxLoginChallenges (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_MaxLoginChallenges PRIMARY KEY,
|
||||
UserId TEXT NOT NULL,
|
||||
SecretHash TEXT NOT NULL,
|
||||
DeviceName TEXT NOT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
ExpiresAt TEXT NOT NULL,
|
||||
CompletedAt TEXT NULL,
|
||||
FailedAttempts INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT FK_MaxLoginChallenges_Users_UserId FOREIGN KEY (UserId) REFERENCES Users (Id) ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxLoginChallenges_SecretHash ON MaxLoginChallenges (SecretHash);");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_MaxLoginChallenges_UserId ON MaxLoginChallenges (UserId);");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
|
||||
ON MessageReactions (MessageId, ActorKey);
|
||||
|
||||
@@ -10,6 +10,7 @@ using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Services;
|
||||
|
||||
@@ -17,6 +18,7 @@ public sealed class MaxBridgeSyncService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IMaxBridgeClient maxBridgeClient,
|
||||
IHubContext<QMaxHub> hubContext,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<MaxBridgeSyncService> logger)
|
||||
{
|
||||
private const string PreviewExternalIdPrefix = "preview:";
|
||||
@@ -44,22 +46,22 @@ public sealed class MaxBridgeSyncService(
|
||||
var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
|
||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||
var userId = currentUser.UserId ?? throw new InvalidOperationException("MAX sync requires a user context.");
|
||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
state = new MaxAccountState { Id = 1 };
|
||||
state = new MaxAccountState { UserId = userId };
|
||||
db.MaxAccountStates.Add(state);
|
||||
}
|
||||
|
||||
state.PhoneNumber = options.MaxPhoneNumber;
|
||||
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
|
||||
state.Status = status.Status;
|
||||
state.IsAuthorized = status.IsAuthorized;
|
||||
state.LastUrl = status.Url;
|
||||
state.LastError = status.LastError;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
|
||||
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
|
||||
}
|
||||
catch (Exception statusError)
|
||||
{
|
||||
@@ -465,7 +467,7 @@ public sealed class MaxBridgeSyncService(
|
||||
}
|
||||
}
|
||||
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var request in genericMediaHistoryRequests
|
||||
@@ -1401,10 +1403,10 @@ public sealed class MaxBridgeSyncService(
|
||||
return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken);
|
||||
}
|
||||
|
||||
var titleMatches = await deletedChats
|
||||
var titleMatches = (await deletedChats.ToListAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.DeletedAt)
|
||||
.Take(2)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
return titleMatches.Count == 1 ? titleMatches[0] : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Services;
|
||||
|
||||
@@ -14,6 +15,7 @@ public sealed class MaxOutboxService(
|
||||
IAttachmentStorageService storage,
|
||||
ChatProjectionService projection,
|
||||
IHubContext<QMaxHub> hubContext,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<MaxOutboxService> logger)
|
||||
{
|
||||
private static readonly TimeSpan InitialChatActionRetryDelay = TimeSpan.FromMinutes(10);
|
||||
@@ -338,6 +340,6 @@ public sealed class MaxOutboxService(
|
||||
"MessageUpdated",
|
||||
projection.ToDto(updated),
|
||||
cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Services;
|
||||
|
||||
public sealed class MaxOutboxWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<QMaxOptions> options,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<MaxOutboxWorker> logger) : BackgroundService
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
@@ -48,9 +52,29 @@ public sealed class MaxOutboxWorker(
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
|
||||
await process(outbox, stoppingToken);
|
||||
Guid[] userIds;
|
||||
await using (var discoveryScope = scopeFactory.CreateAsyncScope())
|
||||
using (currentUser.Push(null, bypassTenantFilter: true))
|
||||
{
|
||||
var db = discoveryScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
userIds = await db.MaxAccountStates
|
||||
.Where(x => x.IsAuthorized)
|
||||
.Select(x => x.UserId)
|
||||
.ToArrayAsync(stoppingToken);
|
||||
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
|
||||
{
|
||||
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
using (currentUser.Push(userId))
|
||||
await using (var scope = scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
|
||||
await process(outbox, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Services;
|
||||
|
||||
public sealed class MaxSyncWorker(
|
||||
IOptions<QMaxOptions> options,
|
||||
MaxBridgeSyncService syncService,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<MaxSyncWorker> logger) : BackgroundService
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
@@ -19,7 +24,27 @@ public sealed class MaxSyncWorker(
|
||||
{
|
||||
try
|
||||
{
|
||||
await syncService.SyncOnceAsync(stoppingToken);
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
Guid[] userIds;
|
||||
using (currentUser.Push(null, bypassTenantFilter: true))
|
||||
{
|
||||
userIds = await db.MaxAccountStates
|
||||
.Where(x => x.IsAuthorized)
|
||||
.Select(x => x.UserId)
|
||||
.ToArrayAsync(stoppingToken);
|
||||
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
|
||||
{
|
||||
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
using (currentUser.Push(userId))
|
||||
{
|
||||
await syncService.SyncOnceAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user