:drunk: Write shit code trying to split up the Auth (WIP)
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Pass.Data;
|
||||
using DysonNetwork.Pass.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Account.Services;
|
||||
|
||||
public class AccountEventService
|
||||
{
|
||||
private readonly PassDatabase db;
|
||||
private readonly ICacheService cache;
|
||||
private readonly IStringLocalizer<Localization.AccountEventResource> localizer;
|
||||
|
||||
public AccountEventService(
|
||||
PassDatabase db,
|
||||
ICacheService cache,
|
||||
IStringLocalizer<Localization.AccountEventResource> localizer
|
||||
)
|
||||
{
|
||||
this.db = db;
|
||||
this.cache = cache;
|
||||
this.localizer = localizer;
|
||||
}
|
||||
|
||||
private static readonly Random Random = new();
|
||||
private const string StatusCacheKey = "AccountStatus_";
|
||||
|
||||
public void PurgeStatusCache(Guid userId)
|
||||
{
|
||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||
cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
public async Task<Status> GetStatus(Guid userId)
|
||||
{
|
||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||
if (cachedStatus is not null)
|
||||
{
|
||||
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
|
||||
return cachedStatus;
|
||||
}
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var status = await db.AccountStatuses
|
||||
.Where(e => e.AccountId == userId)
|
||||
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var isOnline = ws.GetAccountIsConnected(userId);
|
||||
if (status is not null)
|
||||
{
|
||||
status.IsOnline = !status.IsInvisible && isOnline;
|
||||
await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
|
||||
TimeSpan.FromMinutes(5));
|
||||
return status;
|
||||
}
|
||||
|
||||
if (isOnline)
|
||||
{
|
||||
return new Status
|
||||
{
|
||||
Attitude = StatusAttitude.Neutral,
|
||||
IsOnline = true,
|
||||
IsCustomized = false,
|
||||
Label = "Online",
|
||||
AccountId = userId,
|
||||
};
|
||||
}
|
||||
|
||||
return new Status
|
||||
{
|
||||
Attitude = StatusAttitude.Neutral,
|
||||
IsOnline = false,
|
||||
IsCustomized = false,
|
||||
Label = "Offline",
|
||||
AccountId = userId,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
|
||||
{
|
||||
var results = new Dictionary<Guid, Status>();
|
||||
var cacheMissUserIds = new List<Guid>();
|
||||
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||
if (cachedStatus != null)
|
||||
{
|
||||
cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
|
||||
results[userId] = cachedStatus;
|
||||
}
|
||||
else
|
||||
{
|
||||
cacheMissUserIds.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheMissUserIds.Any())
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var statusesFromDb = await db.AccountStatuses
|
||||
.Where(e => cacheMissUserIds.Contains(e.AccountId))
|
||||
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||
.GroupBy(e => e.AccountId)
|
||||
.Select(g => g.OrderByDescending(e => e.CreatedAt).First())
|
||||
.ToListAsync();
|
||||
|
||||
var foundUserIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var status in statusesFromDb)
|
||||
{
|
||||
var isOnline = ws.GetAccountIsConnected(status.AccountId);
|
||||
status.IsOnline = !status.IsInvisible && isOnline;
|
||||
results[status.AccountId] = status;
|
||||
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
|
||||
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
|
||||
foundUserIds.Add(status.AccountId);
|
||||
}
|
||||
|
||||
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
|
||||
if (usersWithoutStatus.Any())
|
||||
{
|
||||
foreach (var userId in usersWithoutStatus)
|
||||
{
|
||||
var isOnline = ws.GetAccountIsConnected(userId);
|
||||
var defaultStatus = new Status
|
||||
{
|
||||
Attitude = StatusAttitude.Neutral,
|
||||
IsOnline = isOnline,
|
||||
IsCustomized = false,
|
||||
Label = isOnline ? "Online" : "Offline",
|
||||
AccountId = userId,
|
||||
};
|
||||
results[userId] = defaultStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<Status> CreateStatus(Common.Models.Account user, Status status)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.AccountStatuses
|
||||
.Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now));
|
||||
|
||||
db.AccountStatuses.Add(status);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task ClearStatus(Common.Models.Account user, Status status)
|
||||
{
|
||||
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(status);
|
||||
await db.SaveChangesAsync();
|
||||
PurgeStatusCache(user.Id);
|
||||
}
|
||||
|
||||
private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative)
|
||||
private const string CaptchaCacheKey = "CheckInCaptcha_";
|
||||
private const int CaptchaProbabilityPercent = 20;
|
||||
|
||||
public async Task<bool> CheckInDailyDoAskCaptcha(Common.Models.Account user)
|
||||
{
|
||||
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
|
||||
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
|
||||
if (needsCaptcha is not null)
|
||||
return needsCaptcha!.Value;
|
||||
|
||||
var result = Random.Next(100) < CaptchaProbabilityPercent;
|
||||
await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckInDailyIsAvailable(Common.Models.Account user)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var lastCheckIn = await db.AccountCheckInResults
|
||||
.Where(x => x.AccountId == user.Id)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (lastCheckIn == null)
|
||||
return true;
|
||||
|
||||
var lastDate = lastCheckIn.CreatedAt.InUtc().Date;
|
||||
var currentDate = now.InUtc().Date;
|
||||
|
||||
return lastDate < currentDate;
|
||||
}
|
||||
|
||||
public const string CheckInLockKey = "CheckInLock_";
|
||||
|
||||
public async Task<CheckInResult> CheckInDaily(Common.Models.Account user)
|
||||
{
|
||||
var lockKey = $"{CheckInLockKey}{user.Id}";
|
||||
|
||||
try
|
||||
{
|
||||
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
|
||||
|
||||
if (lk != null)
|
||||
await lk.ReleaseAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors from this pre-check
|
||||
}
|
||||
|
||||
// Now try to acquire the lock properly
|
||||
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
|
||||
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
|
||||
|
||||
var cultureInfo = new CultureInfo(user.Language, false);
|
||||
CultureInfo.CurrentCulture = cultureInfo;
|
||||
CultureInfo.CurrentUICulture = cultureInfo;
|
||||
|
||||
// Generate 2 positive tips
|
||||
var positiveIndices = Enumerable.Range(1, FortuneTipCount)
|
||||
.OrderBy(_ => Random.Next())
|
||||
.Take(2)
|
||||
.ToList();
|
||||
var tips = positiveIndices.Select(index => new FortuneTip
|
||||
{
|
||||
IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
|
||||
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
|
||||
}).ToList();
|
||||
|
||||
// Generate 2 negative tips
|
||||
var negativeIndices = Enumerable.Range(1, FortuneTipCount)
|
||||
.Except(positiveIndices)
|
||||
.OrderBy(_ => Random.Next())
|
||||
.Take(2)
|
||||
.ToList();
|
||||
tips.AddRange(negativeIndices.Select(index => new FortuneTip
|
||||
{
|
||||
IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
|
||||
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
|
||||
}));
|
||||
|
||||
var result = new CheckInResult
|
||||
{
|
||||
Tips = tips,
|
||||
Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
|
||||
AccountId = user.Id,
|
||||
RewardExperience = 100,
|
||||
RewardPoints = 10,
|
||||
};
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||
try
|
||||
{
|
||||
if (result.RewardPoints.HasValue)
|
||||
await payment.CreateTransactionWithAccountAsync(
|
||||
null,
|
||||
user.Id,
|
||||
WalletCurrency.SourcePoint,
|
||||
result.RewardPoints.Value,
|
||||
$"Check-in reward on {now:yyyy/MM/dd}"
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
result.RewardPoints = null;
|
||||
}
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == user.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
|
||||
);
|
||||
db.AccountCheckInResults.Add(result);
|
||||
await db.SaveChangesAsync(); // Don't forget to save changes to the database
|
||||
|
||||
// The lock will be automatically released by the await using statement
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<DailyEventResponse>> GetEventCalendar(Common.Models.Account user, int month, int year = 0,
|
||||
bool replaceInvisible = false)
|
||||
{
|
||||
if (year == 0)
|
||||
year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year;
|
||||
|
||||
// Create start and end dates for the specified month
|
||||
var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||
var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month)));
|
||||
|
||||
var statuses = await db.AccountStatuses
|
||||
.AsNoTracking()
|
||||
.TagWith("GetEventCalendar_Statuses")
|
||||
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
|
||||
.Select(x => new Status
|
||||
{
|
||||
Id = x.Id,
|
||||
Attitude = x.Attitude,
|
||||
IsInvisible = !replaceInvisible && x.IsInvisible,
|
||||
IsNotDisturb = x.IsNotDisturb,
|
||||
Label = x.Label,
|
||||
ClearedAt = x.ClearedAt,
|
||||
AccountId = x.AccountId,
|
||||
CreatedAt = x.CreatedAt
|
||||
})
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var checkIn = await db.AccountCheckInResults
|
||||
.AsNoTracking()
|
||||
.TagWith("GetEventCalendar_CheckIn")
|
||||
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
|
||||
.ToListAsync();
|
||||
|
||||
var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month))
|
||||
.Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant())
|
||||
.ToList();
|
||||
|
||||
var statusesByDate = statuses
|
||||
.GroupBy(s => s.CreatedAt.InUtc().Date)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var checkInByDate = checkIn
|
||||
.ToDictionary(c => c.CreatedAt.InUtc().Date);
|
||||
|
||||
return dates.Select(date =>
|
||||
{
|
||||
var utcDate = date.InUtc().Date;
|
||||
return new DailyEventResponse
|
||||
{
|
||||
Date = date,
|
||||
CheckInResult = checkInByDate.GetValueOrDefault(utcDate),
|
||||
Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>())
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
}
|
679
DysonNetwork.Pass/Features/Account/Services/AccountService.cs
Normal file
679
DysonNetwork.Pass/Features/Account/Services/AccountService.cs
Normal file
@@ -0,0 +1,679 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Pass.Features.Auth;
|
||||
using DysonNetwork.Pass.Features.Auth.OpenId;
|
||||
using DysonNetwork.Pass.Email;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Storage;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
using OtpNet;
|
||||
using DysonNetwork.Pass.Data;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Pass.Features.Auth.Services;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Account;
|
||||
|
||||
public class AccountService
|
||||
{
|
||||
private readonly PassDatabase db;
|
||||
private readonly MagicSpellService spells;
|
||||
private readonly AccountUsernameService uname;
|
||||
private readonly NotificationService nty;
|
||||
private readonly EmailService mailer;
|
||||
private readonly IStringLocalizer<NotificationResource> localizer;
|
||||
private readonly ICacheService cache;
|
||||
private readonly ILogger<AccountService> logger;
|
||||
|
||||
public AccountService(
|
||||
PassDatabase db,
|
||||
MagicSpellService spells,
|
||||
AccountUsernameService uname,
|
||||
NotificationService nty,
|
||||
EmailService mailer,
|
||||
IStringLocalizer<NotificationResource> localizer,
|
||||
ICacheService cache,
|
||||
ILogger<AccountService> logger
|
||||
)
|
||||
{
|
||||
this.db = db;
|
||||
this.spells = spells;
|
||||
this.uname = uname;
|
||||
this.nty = nty;
|
||||
this.mailer = mailer;
|
||||
this.localizer = localizer;
|
||||
this.cache = cache;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public static void SetCultureInfo(Models.Account account)
|
||||
{
|
||||
SetCultureInfo(account.Language);
|
||||
}
|
||||
|
||||
public static void SetCultureInfo(string? languageCode)
|
||||
{
|
||||
var info = new CultureInfo(languageCode ?? "en-us", false);
|
||||
CultureInfo.CurrentCulture = info;
|
||||
CultureInfo.CurrentUICulture = info;
|
||||
}
|
||||
|
||||
public const string AccountCachePrefix = "account:";
|
||||
|
||||
public async Task PurgeAccountCache(Models.Account account)
|
||||
{
|
||||
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
|
||||
}
|
||||
|
||||
public async Task<Models.Account?> LookupAccount(string probe)
|
||||
{
|
||||
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync();
|
||||
if (account is not null) return account;
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.Content == probe)
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
return contact?.Account;
|
||||
}
|
||||
|
||||
public async Task<Models.Account?> LookupAccountByConnection(string identifier, string provider)
|
||||
{
|
||||
var connection = await db.AccountConnections
|
||||
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
return connection?.Account;
|
||||
}
|
||||
|
||||
public async Task<int?> GetAccountLevel(Guid accountId)
|
||||
{
|
||||
var profile = await db.AccountProfiles
|
||||
.Where(a => a.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
return profile?.Level;
|
||||
}
|
||||
|
||||
public async Task<Models.Account> CreateAccount(
|
||||
string name,
|
||||
string nick,
|
||||
string email,
|
||||
string? password,
|
||||
string language = "en-US",
|
||||
bool isEmailVerified = false,
|
||||
bool isActivated = false
|
||||
)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
|
||||
if (dupeNameCount > 0)
|
||||
throw new InvalidOperationException("Account name has already been taken.");
|
||||
|
||||
var account = new Models.Account
|
||||
{
|
||||
Name = name,
|
||||
Nick = nick,
|
||||
Language = language,
|
||||
Contacts = new List<AccountContact>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = AccountContactType.Email,
|
||||
Content = email,
|
||||
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
|
||||
IsPrimary = true
|
||||
}
|
||||
},
|
||||
AuthFactors = password is not null
|
||||
? new List<AccountAuthFactor>
|
||||
{
|
||||
new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.Password,
|
||||
Secret = password,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant()
|
||||
}.HashSecret()
|
||||
}
|
||||
: [],
|
||||
Profile = new Profile()
|
||||
};
|
||||
|
||||
if (isActivated)
|
||||
{
|
||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
||||
if (defaultGroup is not null)
|
||||
{
|
||||
db.PermissionGroupMembers.Add(new PermissionGroupMember
|
||||
{
|
||||
Actor = $"user:{account.Id}",
|
||||
Group = defaultGroup
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var spell = await spells.CreateMagicSpell(
|
||||
account,
|
||||
MagicSpellType.AccountActivation,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "contact_method", account.Contacts.First().Content }
|
||||
}
|
||||
);
|
||||
await spells.NotifyMagicSpell(spell, true);
|
||||
}
|
||||
|
||||
db.Accounts.Add(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return account;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Models.Account> CreateAccount(OidcUserInfo userInfo)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userInfo.Email))
|
||||
throw new ArgumentException("Email is required for account creation");
|
||||
|
||||
var displayName = !string.IsNullOrEmpty(userInfo.DisplayName)
|
||||
? userInfo.DisplayName
|
||||
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
|
||||
|
||||
// Generate username from email
|
||||
var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
|
||||
|
||||
return await CreateAccount(
|
||||
username,
|
||||
displayName,
|
||||
userInfo.Email,
|
||||
null,
|
||||
"en-US",
|
||||
userInfo.EmailVerified,
|
||||
userInfo.EmailVerified
|
||||
);
|
||||
}
|
||||
|
||||
public async Task RequestAccountDeletion(Models.Account account)
|
||||
{
|
||||
var spell = await spells.CreateMagicSpell(
|
||||
account,
|
||||
MagicSpellType.AccountRemoval,
|
||||
new Dictionary<string, object>(),
|
||||
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||
preventRepeat: true
|
||||
);
|
||||
await spells.NotifyMagicSpell(spell);
|
||||
}
|
||||
|
||||
public async Task RequestPasswordReset(Models.Account account)
|
||||
{
|
||||
var spell = await spells.CreateMagicSpell(
|
||||
account,
|
||||
MagicSpellType.AuthPasswordReset,
|
||||
new Dictionary<string, object>(),
|
||||
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||
preventRepeat: true
|
||||
);
|
||||
await spells.NotifyMagicSpell(spell);
|
||||
}
|
||||
|
||||
public async Task<bool> CheckAuthFactorExists(Models.Account account, AccountAuthFactorType type)
|
||||
{
|
||||
var isExists = await db.AccountAuthFactors
|
||||
.Where(x => x.AccountId == account.Id && x.Type == type)
|
||||
.AnyAsync();
|
||||
return isExists;
|
||||
}
|
||||
|
||||
public async Task<AccountAuthFactor?> CreateAuthFactor(Models.Account account, AccountAuthFactorType type, string? secret)
|
||||
{
|
||||
AccountAuthFactor? factor = null;
|
||||
switch (type)
|
||||
{
|
||||
case AccountAuthFactorType.Password:
|
||||
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.Password,
|
||||
Trustworthy = 1,
|
||||
AccountId = account.Id,
|
||||
Secret = secret,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
}.HashSecret();
|
||||
break;
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.EmailCode,
|
||||
Trustworthy = 2,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
};
|
||||
break;
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.InAppCode,
|
||||
Trustworthy = 1,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant()
|
||||
};
|
||||
break;
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
var skOtp = KeyGeneration.GenerateRandomKey(20);
|
||||
var skOtp32 = Base32Encoding.ToString(skOtp);
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Secret = skOtp32,
|
||||
Type = AccountAuthFactorType.TimedCode,
|
||||
Trustworthy = 2,
|
||||
EnabledAt = null, // It needs to be tired once to enable
|
||||
CreatedResponse = new Dictionary<string, object>
|
||||
{
|
||||
["uri"] = new OtpUri(
|
||||
OtpType.Totp,
|
||||
skOtp32,
|
||||
account.Id.ToString(),
|
||||
"Solar Network"
|
||||
).ToString(),
|
||||
}
|
||||
};
|
||||
break;
|
||||
case AccountAuthFactorType.PinCode:
|
||||
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
|
||||
if (!secret.All(char.IsDigit) || secret.Length != 6)
|
||||
throw new ArgumentException("PIN code must be exactly 6 digits");
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.PinCode,
|
||||
Trustworthy = 0, // Only for confirming, can't be used for login
|
||||
Secret = secret,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
}.HashSecret();
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, null);
|
||||
}
|
||||
|
||||
if (factor is null) throw new InvalidOperationException("Unable to create auth factor.");
|
||||
factor.AccountId = account.Id;
|
||||
db.AccountAuthFactors.Add(factor);
|
||||
await db.SaveChangesAsync();
|
||||
return factor;
|
||||
}
|
||||
|
||||
public async Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code)
|
||||
{
|
||||
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
|
||||
if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode)
|
||||
{
|
||||
if (code is null || !factor.VerifyPassword(code))
|
||||
throw new InvalidOperationException(
|
||||
"Invalid code, you need to enter the correct code to enable the factor."
|
||||
);
|
||||
}
|
||||
|
||||
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(factor);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor)
|
||||
{
|
||||
if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled.");
|
||||
|
||||
var count = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == factor.AccountId && f.EnabledAt != null)
|
||||
.CountAsync();
|
||||
if (count <= 1)
|
||||
throw new InvalidOperationException(
|
||||
"Disabling this auth factor will cause you have no active auth factors.");
|
||||
|
||||
factor.EnabledAt = null;
|
||||
db.Update(factor);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
public async Task DeleteAuthFactor(AccountAuthFactor factor)
|
||||
{
|
||||
var count = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == factor.AccountId)
|
||||
.If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
|
||||
.CountAsync();
|
||||
if (count <= 1)
|
||||
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
|
||||
|
||||
db.AccountAuthFactors.Remove(factor);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send the auth factor verification code to users, for factors like in-app code and email.
|
||||
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
|
||||
/// </summary>
|
||||
/// <param name="account">The owner of the auth factor</param>
|
||||
/// <param name="factor">The auth factor needed to send code</param>
|
||||
/// <param name="hint">The part of the contact method for verification</param>
|
||||
public async Task SendFactorCode(Models.Account account, AccountAuthFactor factor, string? hint = null)
|
||||
{
|
||||
var code = new Random().Next(100000, 999999).ToString("000000");
|
||||
|
||||
switch (factor.Type)
|
||||
{
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
if (await _GetFactorCode(factor) is not null)
|
||||
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||
|
||||
await nty.SendNotification(
|
||||
account,
|
||||
"auth.verification",
|
||||
localizer["AuthCodeTitle"],
|
||||
null,
|
||||
localizer["AuthCodeBody", code],
|
||||
save: true
|
||||
);
|
||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
|
||||
break;
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
if (await _GetFactorCode(factor) is not null)
|
||||
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||
|
||||
ArgumentNullException.ThrowIfNull(hint);
|
||||
hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
|
||||
if (string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
|
||||
factor.Id,
|
||||
hint
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.Type == AccountContactType.Email)
|
||||
.Where(c => c.VerifiedAt != null)
|
||||
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
|
||||
factor.Id,
|
||||
hint
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
||||
account.Nick,
|
||||
contact.Content,
|
||||
localizer["VerificationEmail"],
|
||||
new VerificationEmailModel
|
||||
{
|
||||
Name = account.Name,
|
||||
Code = code
|
||||
}
|
||||
);
|
||||
|
||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
|
||||
break;
|
||||
case AccountAuthFactorType.Password:
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
default:
|
||||
// No need to send, such as password etc...
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code)
|
||||
{
|
||||
switch (factor.Type)
|
||||
{
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
var correctCode = await _GetFactorCode(factor);
|
||||
var isCorrect = correctCode is not null &&
|
||||
string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
|
||||
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
|
||||
return isCorrect;
|
||||
case AccountAuthFactorType.Password:
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
default:
|
||||
return factor.VerifyPassword(code);
|
||||
}
|
||||
}
|
||||
|
||||
private const string AuthFactorCachePrefix = "authfactor:";
|
||||
|
||||
private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires)
|
||||
{
|
||||
await cache.SetAsync(
|
||||
$"{AuthFactorCachePrefix}{factor.Id}:code",
|
||||
code,
|
||||
expires
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string?> _GetFactorCode(AccountAuthFactor factor)
|
||||
{
|
||||
return await cache.GetAsync<string?>(
|
||||
$"{AuthFactorCachePrefix}{factor.Id}:code"
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Session> UpdateSessionLabel(Models.Account account, Guid sessionId, string label)
|
||||
{
|
||||
var session = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ToListAsync();
|
||||
foreach (var item in sessions)
|
||||
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task DeleteSession(Models.Account account, Guid sessionId)
|
||||
{
|
||||
var session = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ToListAsync();
|
||||
|
||||
if (session.Challenge.DeviceId is not null)
|
||||
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
foreach (var item in sessions)
|
||||
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
||||
}
|
||||
|
||||
public async Task<AccountContact> CreateContactMethod(Models.Account account, AccountContactType type, string content)
|
||||
{
|
||||
var contact = new AccountContact
|
||||
{
|
||||
Type = type,
|
||||
Content = content,
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
db.AccountContacts.Add(contact);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
public async Task VerifyContactMethod(Models.Account account, AccountContact contact)
|
||||
{
|
||||
var spell = await spells.CreateMagicSpell(
|
||||
account,
|
||||
MagicSpellType.ContactVerification,
|
||||
new Dictionary<string, object> { { "contact_method", contact.Content } },
|
||||
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||
preventRepeat: true
|
||||
);
|
||||
await spells.NotifyMagicSpell(spell);
|
||||
}
|
||||
|
||||
public async Task<AccountContact> SetContactMethodPrimary(Models.Account account, AccountContact contact)
|
||||
{
|
||||
if (contact.AccountId != account.Id)
|
||||
throw new InvalidOperationException("Contact method does not belong to this account.");
|
||||
if (contact.VerifiedAt is null)
|
||||
throw new InvalidOperationException("Cannot set unverified contact method as primary.");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await db.AccountContacts
|
||||
.Where(c => c.AccountId == account.Id && c.Type == contact.Type)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false));
|
||||
|
||||
contact.IsPrimary = true;
|
||||
db.AccountContacts.Update(contact);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return contact;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteContactMethod(Models.Account account, AccountContact contact)
|
||||
{
|
||||
if (contact.AccountId != account.Id)
|
||||
throw new InvalidOperationException("Contact method does not belong to this account.");
|
||||
if (contact.IsPrimary)
|
||||
throw new InvalidOperationException("Cannot delete primary contact method.");
|
||||
|
||||
db.AccountContacts.Remove(contact);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method will grant a badge to the account.
|
||||
/// Shouldn't be exposed to normal user and the user itself.
|
||||
/// </summary>
|
||||
public async Task<Badge> GrantBadge(Models.Account account, Badge badge)
|
||||
{
|
||||
badge.AccountId = account.Id;
|
||||
db.Badges.Add(badge);
|
||||
await db.SaveChangesAsync();
|
||||
return badge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method will revoke a badge from the account.
|
||||
/// Shouldn't be exposed to normal user and the user itself.
|
||||
/// </summary>
|
||||
public async Task RevokeBadge(Models.Account account, Guid badgeId)
|
||||
{
|
||||
var badge = await db.Badges
|
||||
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (badge is null) throw new InvalidOperationException("Badge was not found.");
|
||||
|
||||
var profile = await db.AccountProfiles
|
||||
.Where(p => p.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id)
|
||||
profile.ActiveBadge = null;
|
||||
|
||||
db.Remove(badge);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task ActiveBadge(Models.Account account, Guid badgeId)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var badge = await db.Badges
|
||||
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (badge is null) throw new InvalidOperationException("Badge was not found.");
|
||||
|
||||
await db.Badges
|
||||
.Where(b => b.AccountId == account.Id && b.Id != badgeId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));
|
||||
|
||||
badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(badge);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == account.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference()));
|
||||
await PurgeAccountCache(account);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The maintenance method for server administrator.
|
||||
/// To check every user has an account profile and to create them if it isn't having one.
|
||||
/// </summary>
|
||||
public async Task EnsureAccountProfileCreated()
|
||||
{
|
||||
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
|
||||
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
|
||||
var missingId = accountsId.Except(existingId).ToList();
|
||||
|
||||
if (missingId.Count != 0)
|
||||
{
|
||||
var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList();
|
||||
await db.BulkInsertAsync(newProfiles);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,105 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Service for handling username generation and validation
|
||||
/// </summary>
|
||||
public class AccountUsernameService(AppDatabase db) : IAccountUsernameService
|
||||
{
|
||||
private readonly Random _random = new();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique username based on the provided base name
|
||||
/// </summary>
|
||||
/// <param name="baseName">The preferred username</param>
|
||||
/// <returns>A unique username</returns>
|
||||
public async Task<string> GenerateUniqueUsernameAsync(string baseName)
|
||||
{
|
||||
// Sanitize the base name
|
||||
var sanitized = SanitizeUsername(baseName);
|
||||
|
||||
// If the base name is empty after sanitization, use a default
|
||||
if (string.IsNullOrEmpty(sanitized))
|
||||
{
|
||||
sanitized = "user";
|
||||
}
|
||||
|
||||
// Check if the sanitized name is available
|
||||
if (!await IsUsernameExistsAsync(sanitized))
|
||||
{
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Try up to 10 times with random numbers
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var suffix = _random.Next(1000, 9999);
|
||||
var candidate = $"{sanitized}{suffix}";
|
||||
|
||||
if (!await IsUsernameExistsAsync(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts fail, use a timestamp
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
return $"{sanitized}{timestamp}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a username by removing invalid characters and converting to lowercase
|
||||
/// </summary>
|
||||
public string SanitizeUsername(string username)
|
||||
{
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return string.Empty;
|
||||
|
||||
// Replace spaces and special characters with underscores
|
||||
var sanitized = Regex.Replace(username, @"[^a-zA-Z0-9_\-]", "");
|
||||
|
||||
// Convert to lowercase
|
||||
sanitized = sanitized.ToLowerInvariant();
|
||||
|
||||
// Ensure it starts with a letter
|
||||
if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]))
|
||||
{
|
||||
sanitized = "u" + sanitized;
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.Length > 30)
|
||||
{
|
||||
sanitized = sanitized[..30];
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a username already exists
|
||||
/// </summary>
|
||||
public async Task<bool> IsUsernameExistsAsync(string username)
|
||||
{
|
||||
return await db.Accounts.AnyAsync(a => a.Name == username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a username from an email address
|
||||
/// </summary>
|
||||
/// <param name="email">The email address to generate a username from</param>
|
||||
/// <returns>A unique username derived from the email</returns>
|
||||
public async Task<string> GenerateUsernameFromEmailAsync(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email))
|
||||
return await GenerateUniqueUsernameAsync("user");
|
||||
|
||||
// Extract the local part of the email (before the @)
|
||||
var localPart = email.Split('@')[0];
|
||||
|
||||
// Use the local part as the base for username generation
|
||||
return await GenerateUniqueUsernameAsync(localPart);
|
||||
}
|
||||
}
|
@@ -0,0 +1,45 @@
|
||||
using DysonNetwork.Pass.Connection;
|
||||
using DysonNetwork.Pass.Storage;
|
||||
using DysonNetwork.Pass.Storage.Handlers;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Account;
|
||||
|
||||
public class ActionLogService(GeoIpService geo, FlushBufferService fbs) : IActionLogService
|
||||
{
|
||||
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
|
||||
{
|
||||
var log = new ActionLog
|
||||
{
|
||||
Action = action,
|
||||
AccountId = accountId,
|
||||
Meta = meta,
|
||||
};
|
||||
|
||||
fbs.Enqueue(log);
|
||||
}
|
||||
|
||||
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
|
||||
Models.Account? account = null)
|
||||
{
|
||||
var log = new ActionLog
|
||||
{
|
||||
Action = action,
|
||||
Meta = meta,
|
||||
UserAgent = request.Headers.UserAgent,
|
||||
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
|
||||
};
|
||||
|
||||
if (request.HttpContext.Items["CurrentUser"] is Models.Account currentUser)
|
||||
log.AccountId = currentUser.Id;
|
||||
else if (account != null)
|
||||
log.AccountId = account.Id;
|
||||
else
|
||||
throw new ArgumentException("No user context was found");
|
||||
|
||||
if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession)
|
||||
log.SessionId = currentSession.Id;
|
||||
|
||||
fbs.Enqueue(log);
|
||||
}
|
||||
}
|
259
DysonNetwork.Pass/Features/Account/Services/MagicSpellService.cs
Normal file
259
DysonNetwork.Pass/Features/Account/Services/MagicSpellService.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
|
||||
|
||||
using DysonNetwork.Common.Models;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Account;
|
||||
|
||||
public class MagicSpellService
|
||||
{
|
||||
private readonly PassDatabase db;
|
||||
private readonly EmailService email;
|
||||
private readonly IConfiguration configuration;
|
||||
private readonly ILogger<MagicSpellService> logger;
|
||||
private readonly IStringLocalizer<Localization.EmailResource> localizer;
|
||||
|
||||
public MagicSpellService(
|
||||
PassDatabase db,
|
||||
EmailService email,
|
||||
IConfiguration configuration,
|
||||
ILogger<MagicSpellService> logger,
|
||||
IStringLocalizer<Localization.EmailResource> localizer
|
||||
)
|
||||
{
|
||||
this.db = db;
|
||||
this.email = email;
|
||||
this.configuration = configuration;
|
||||
this.logger = logger;
|
||||
this.localizer = localizer;
|
||||
}
|
||||
|
||||
public async Task<MagicSpell> CreateMagicSpell(
|
||||
Models.Account account,
|
||||
MagicSpellType type,
|
||||
Dictionary<string, object> meta,
|
||||
Instant? expiredAt = null,
|
||||
Instant? affectedAt = null,
|
||||
bool preventRepeat = false
|
||||
)
|
||||
{
|
||||
if (preventRepeat)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var existingSpell = await db.MagicSpells
|
||||
.Where(s => s.AccountId == account.Id)
|
||||
.Where(s => s.Type == type)
|
||||
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existingSpell != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
|
||||
}
|
||||
}
|
||||
|
||||
var spellWord = _GenerateRandomString(128);
|
||||
var spell = new MagicSpell
|
||||
{
|
||||
Spell = spellWord,
|
||||
Type = type,
|
||||
ExpiresAt = expiredAt,
|
||||
AffectedAt = affectedAt,
|
||||
AccountId = account.Id,
|
||||
Meta = meta
|
||||
};
|
||||
|
||||
db.MagicSpells.Add(spell);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return spell;
|
||||
}
|
||||
|
||||
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
|
||||
{
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.Account.Id == spell.AccountId)
|
||||
.Where(c => c.Type == AccountContactType.Email)
|
||||
.Where(c => c.VerifiedAt != null || bypassVerify)
|
||||
.OrderByDescending(c => c.IsPrimary)
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null) throw new ArgumentException("Account has no contact method that can use");
|
||||
|
||||
var link = $"{configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}";
|
||||
|
||||
logger.LogInformation("Sending magic spell... {Link}", link);
|
||||
|
||||
var accountLanguage = await db.Accounts
|
||||
.Where(a => a.Id == spell.AccountId)
|
||||
.Select(a => a.Language)
|
||||
.FirstOrDefaultAsync();
|
||||
AccountService.SetCultureInfo(accountLanguage);
|
||||
|
||||
try
|
||||
{
|
||||
switch (spell.Type)
|
||||
{
|
||||
case MagicSpellType.AccountActivation:
|
||||
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailLandingTitle"],
|
||||
new LandingEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
Link = link
|
||||
}
|
||||
);
|
||||
break;
|
||||
case MagicSpellType.AccountRemoval:
|
||||
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailAccountDeletionTitle"],
|
||||
new AccountDeletionEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
Link = link
|
||||
}
|
||||
);
|
||||
break;
|
||||
case MagicSpellType.AuthPasswordReset:
|
||||
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailAccountDeletionTitle"],
|
||||
new PasswordResetEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
Link = link
|
||||
}
|
||||
);
|
||||
break;
|
||||
case MagicSpellType.ContactVerification:
|
||||
if (spell.Meta["contact_method"] is not string contactMethod)
|
||||
throw new InvalidOperationException("Contact method is not found.");
|
||||
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contactMethod!,
|
||||
localizer["EmailContactVerificationTitle"],
|
||||
new ContactVerificationEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
Link = link
|
||||
}
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
logger.LogError($"Error sending magic spell (${spell.Spell})... {err}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ApplyMagicSpell(MagicSpell spell)
|
||||
{
|
||||
switch (spell.Type)
|
||||
{
|
||||
case MagicSpellType.AuthPasswordReset:
|
||||
throw new ArgumentException(
|
||||
"For password reset spell, please use the ApplyPasswordReset method instead."
|
||||
);
|
||||
case MagicSpellType.AccountRemoval:
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||
if (account is null) break;
|
||||
db.Accounts.Remove(account);
|
||||
break;
|
||||
case MagicSpellType.AccountActivation:
|
||||
var contactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
|
||||
var contact = await
|
||||
db.AccountContacts.FirstOrDefaultAsync(c =>
|
||||
c.Content == contactMethod
|
||||
);
|
||||
if (contact is not null)
|
||||
{
|
||||
contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(contact);
|
||||
}
|
||||
|
||||
account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||
if (account is not null)
|
||||
{
|
||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(account);
|
||||
}
|
||||
|
||||
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
||||
if (defaultGroup is not null && account is not null)
|
||||
{
|
||||
db.PermissionGroupMembers.Add(new PermissionGroupMember
|
||||
{
|
||||
Actor = $"user:{account.Id}",
|
||||
Group = defaultGroup
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
case MagicSpellType.ContactVerification:
|
||||
var verifyContactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
|
||||
var verifyContact = await db.AccountContacts
|
||||
.FirstOrDefaultAsync(c => c.Content == verifyContactMethod);
|
||||
if (verifyContact is not null)
|
||||
{
|
||||
verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(verifyContact);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
db.Remove(spell);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
|
||||
{
|
||||
if (spell.Type != MagicSpellType.AuthPasswordReset)
|
||||
throw new ArgumentException("This spell is not a password reset spell.");
|
||||
|
||||
var passwordFactor = await db.AccountAuthFactors
|
||||
.Include(f => f.Account)
|
||||
.Where(f => f.Type == AccountAuthFactorType.Password && f.Account.Id == spell.AccountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (passwordFactor is null)
|
||||
{
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
|
||||
passwordFactor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.Password,
|
||||
Account = account,
|
||||
Secret = newPassword
|
||||
}.HashSecret();
|
||||
db.AccountAuthFactors.Add(passwordFactor);
|
||||
}
|
||||
else
|
||||
{
|
||||
passwordFactor.Secret = newPassword;
|
||||
passwordFactor.HashSecret();
|
||||
db.Update(passwordFactor);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string _GenerateRandomString(int length)
|
||||
{
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
var randomBytes = new byte[length];
|
||||
rng.GetBytes(randomBytes);
|
||||
|
||||
var base64String = Convert.ToBase64String(randomBytes);
|
||||
|
||||
return base64String.Substring(0, length);
|
||||
}
|
||||
}
|
@@ -0,0 +1,286 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Connection;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using DysonNetwork.Pass.Data;
|
||||
using DysonNetwork.Common.Models;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Account;
|
||||
|
||||
public class NotificationService(
|
||||
PassDatabase db,
|
||||
IConfiguration config) : INotificationService
|
||||
{
|
||||
private readonly string _notifyTopic = config["Notifications:Topic"]!;
|
||||
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
|
||||
|
||||
public async Task UnsubscribePushNotifications(string deviceId)
|
||||
{
|
||||
await db.NotificationPushSubscriptions
|
||||
.Where(s => s.DeviceId == deviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
public async Task<NotificationPushSubscription> SubscribePushNotification(
|
||||
Models.Account account,
|
||||
NotificationPushProvider provider,
|
||||
string deviceId,
|
||||
string deviceToken
|
||||
)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// First check if a matching subscription exists
|
||||
var existingSubscription = await db.NotificationPushSubscriptions
|
||||
.Where(s => s.AccountId == account.Id)
|
||||
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existingSubscription is not null)
|
||||
{
|
||||
// Update the existing subscription directly in the database
|
||||
await db.NotificationPushSubscriptions
|
||||
.Where(s => s.Id == existingSubscription.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(s => s.DeviceId, deviceId)
|
||||
.SetProperty(s => s.DeviceToken, deviceToken)
|
||||
.SetProperty(s => s.UpdatedAt, now));
|
||||
|
||||
// Return the updated subscription
|
||||
existingSubscription.DeviceId = deviceId;
|
||||
existingSubscription.DeviceToken = deviceToken;
|
||||
existingSubscription.UpdatedAt = now;
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
var subscription = new NotificationPushSubscription
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
DeviceToken = deviceToken,
|
||||
Provider = provider,
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
db.NotificationPushSubscriptions.Add(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<Notification> SendNotification(
|
||||
Models.Account account,
|
||||
string topic,
|
||||
string? title = null,
|
||||
string? subtitle = null,
|
||||
string? content = null,
|
||||
Dictionary<string, object>? meta = null,
|
||||
string? actionUri = null,
|
||||
bool isSilent = false,
|
||||
bool save = true
|
||||
)
|
||||
{
|
||||
if (title is null && subtitle is null && content is null)
|
||||
throw new ArgumentException("Unable to send notification that completely empty.");
|
||||
|
||||
meta ??= new Dictionary<string, object>();
|
||||
if (actionUri is not null) meta["action_uri"] = actionUri;
|
||||
|
||||
var notification = new Notification
|
||||
{
|
||||
Topic = topic,
|
||||
Title = title,
|
||||
Subtitle = subtitle,
|
||||
Content = content,
|
||||
Meta = meta,
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
if (save)
|
||||
{
|
||||
db.Add(notification);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!isSilent) _ = DeliveryNotification(notification);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
public async Task DeliveryNotification(Notification notification)
|
||||
{
|
||||
|
||||
|
||||
// Pushing the notification
|
||||
var subscribers = await db.NotificationPushSubscriptions
|
||||
.Where(s => s.AccountId == notification.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
|
||||
if (id.Count == 0) return;
|
||||
|
||||
await db.Notifications
|
||||
.Where(n => id.Contains(n.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task BroadcastNotification(Notification notification, bool save = false)
|
||||
{
|
||||
var accounts = await db.Accounts.ToListAsync();
|
||||
|
||||
if (save)
|
||||
{
|
||||
var notifications = accounts.Select(x =>
|
||||
{
|
||||
var newNotification = new Notification
|
||||
{
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
Account = x,
|
||||
AccountId = x.Id
|
||||
};
|
||||
return newNotification;
|
||||
}).ToList();
|
||||
await db.BulkInsertAsync(notifications);
|
||||
}
|
||||
|
||||
|
||||
|
||||
var subscribers = await db.NotificationPushSubscriptions
|
||||
.ToListAsync();
|
||||
await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
public async Task SendNotificationBatch(Notification notification, List<Models.Account> accounts, bool save = false)
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
var notifications = accounts.Select(x =>
|
||||
{
|
||||
var newNotification = new Notification
|
||||
{
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
Account = x,
|
||||
AccountId = x.Id
|
||||
};
|
||||
return newNotification;
|
||||
}).ToList();
|
||||
await db.BulkInsertAsync(notifications);
|
||||
}
|
||||
|
||||
|
||||
|
||||
var accountsId = accounts.Select(x => x.Id).ToList();
|
||||
var subscribers = await db.NotificationPushSubscriptions
|
||||
.Where(s => accountsId.Contains(s.AccountId))
|
||||
.ToListAsync();
|
||||
await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,
|
||||
IEnumerable<NotificationPushSubscription> subscriptions)
|
||||
{
|
||||
var subDict = subscriptions
|
||||
.GroupBy(x => x.Provider)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
|
||||
var notifications = subDict.Select(value =>
|
||||
{
|
||||
var platformCode = value.Key switch
|
||||
{
|
||||
NotificationPushProvider.Apple => 1,
|
||||
NotificationPushProvider.Google => 2,
|
||||
_ => throw new InvalidOperationException($"Unknown push provider: {value.Key}")
|
||||
};
|
||||
|
||||
var tokens = value.Value.Select(x => x.DeviceToken).ToList();
|
||||
return _BuildNotificationPayload(notification, platformCode, tokens);
|
||||
}).ToList();
|
||||
|
||||
return notifications.ToList();
|
||||
}
|
||||
|
||||
private Dictionary<string, object> _BuildNotificationPayload(Notification notification, int platformCode,
|
||||
IEnumerable<string> deviceTokens)
|
||||
{
|
||||
var alertDict = new Dictionary<string, object>();
|
||||
var dict = new Dictionary<string, object>
|
||||
{
|
||||
["notif_id"] = notification.Id.ToString(),
|
||||
["apns_id"] = notification.Id.ToString(),
|
||||
["topic"] = _notifyTopic,
|
||||
["tokens"] = deviceTokens,
|
||||
["data"] = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = notification.Topic,
|
||||
["meta"] = notification.Meta ?? new Dictionary<string, object>(),
|
||||
},
|
||||
["mutable_content"] = true,
|
||||
["priority"] = notification.Priority >= 5 ? "high" : "normal",
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Title))
|
||||
{
|
||||
dict["title"] = notification.Title;
|
||||
alertDict["title"] = notification.Title;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Content))
|
||||
{
|
||||
dict["message"] = notification.Content;
|
||||
alertDict["body"] = notification.Content;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Subtitle))
|
||||
{
|
||||
dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
|
||||
alertDict["subtitle"] = notification.Subtitle;
|
||||
}
|
||||
|
||||
if (notification.Priority >= 5)
|
||||
dict["name"] = "default";
|
||||
|
||||
dict["platform"] = platformCode;
|
||||
dict["alert"] = alertDict;
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
private async Task _PushNotification(Notification notification,
|
||||
IEnumerable<NotificationPushSubscription> subscriptions)
|
||||
{
|
||||
var subList = subscriptions.ToList();
|
||||
if (subList.Count == 0) return;
|
||||
|
||||
var requestDict = new Dictionary<string, object>
|
||||
{
|
||||
["notifications"] = _BuildNotificationPayload(notification, subList)
|
||||
};
|
||||
|
||||
var client = httpFactory.CreateClient();
|
||||
client.BaseAddress = _notifyEndpoint;
|
||||
var request = await client.PostAsync("/push", new StringContent(
|
||||
JsonSerializer.Serialize(requestDict),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
));
|
||||
request.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
@@ -0,0 +1,210 @@
|
||||
using DysonNetwork.Pass.Data;
|
||||
using DysonNetwork.Pass.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Account;
|
||||
|
||||
public class RelationshipService(PassDatabase db, ICacheService cache) : IRelationshipService
|
||||
{
|
||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||
|
||||
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||
{
|
||||
var count = await db.AccountRelationships
|
||||
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
|
||||
(r.AccountId == relatedId && r.AccountId == accountId))
|
||||
.CountAsync();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<Relationship?> GetRelationship(
|
||||
Guid accountId,
|
||||
Guid relatedId,
|
||||
RelationshipStatus? status = null,
|
||||
bool ignoreExpired = false
|
||||
)
|
||||
{
|
||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||
var queries = db.AccountRelationships.AsQueryable()
|
||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
|
||||
if (!ignoreExpired) queries = queries.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
|
||||
if (status is not null) queries = queries.Where(r => r.Status == status);
|
||||
var relationship = await queries.FirstOrDefaultAsync();
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<Relationship> CreateRelationship(Models.Account sender, Models.Account target, RelationshipStatus status)
|
||||
{
|
||||
if (status == RelationshipStatus.Pending)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot create relationship with pending status, use SendFriendRequest instead.");
|
||||
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||
|
||||
var relationship = new Relationship
|
||||
{
|
||||
AccountId = sender.Id,
|
||||
RelatedId = target.Id,
|
||||
Status = status
|
||||
};
|
||||
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<Relationship> BlockAccount(Models.Account sender, Models.Account target)
|
||||
{
|
||||
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
||||
}
|
||||
|
||||
public async Task<Relationship> UnblockAccount(Models.Account sender, Models.Account target)
|
||||
{
|
||||
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||
db.Remove(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<Relationship> SendFriendRequest(Models.Account sender, Models.Account target)
|
||||
{
|
||||
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||
|
||||
var relationship = new Relationship
|
||||
{
|
||||
AccountId = sender.Id,
|
||||
RelatedId = target.Id,
|
||||
Status = RelationshipStatus.Pending,
|
||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
|
||||
};
|
||||
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
||||
{
|
||||
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
|
||||
if (relationship is null) throw new ArgumentException("Friend request was not found.");
|
||||
|
||||
await db.AccountRelationships
|
||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||
}
|
||||
|
||||
public async Task<Relationship> AcceptFriendRelationship(
|
||||
Relationship relationship,
|
||||
RelationshipStatus status = RelationshipStatus.Friends
|
||||
)
|
||||
{
|
||||
if (relationship.Status != RelationshipStatus.Pending)
|
||||
throw new ArgumentException("Cannot accept friend request that not in pending status.");
|
||||
if (status == RelationshipStatus.Pending)
|
||||
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
|
||||
|
||||
// Whatever the receiver decides to apply which status to the relationship,
|
||||
// the sender should always see the user as a friend since the sender ask for it
|
||||
relationship.Status = RelationshipStatus.Friends;
|
||||
relationship.ExpiredAt = null;
|
||||
db.Update(relationship);
|
||||
|
||||
var relationshipBackward = new Relationship
|
||||
{
|
||||
AccountId = relationship.RelatedId,
|
||||
RelatedId = relationship.AccountId,
|
||||
Status = status
|
||||
};
|
||||
db.AccountRelationships.Add(relationshipBackward);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||
|
||||
return relationshipBackward;
|
||||
}
|
||||
|
||||
public async Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status)
|
||||
{
|
||||
var relationship = await GetRelationship(accountId, relatedId);
|
||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||
if (relationship.Status == status) return relationship;
|
||||
relationship.Status = status;
|
||||
db.Update(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PurgeRelationshipCache(accountId, relatedId);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> ListAccountFriends(Models.Account account)
|
||||
{
|
||||
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
|
||||
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
|
||||
if (friends == null)
|
||||
{
|
||||
friends = await db.AccountRelationships
|
||||
.Where(r => r.RelatedId == account.Id)
|
||||
.Where(r => r.Status == RelationshipStatus.Friends)
|
||||
.Select(r => r.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
return friends ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> ListAccountBlocked(Models.Account account)
|
||||
{
|
||||
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
|
||||
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
|
||||
if (blocked == null)
|
||||
{
|
||||
blocked = await db.AccountRelationships
|
||||
.Where(r => r.RelatedId == account.Id)
|
||||
.Where(r => r.Status == RelationshipStatus.Blocked)
|
||||
.Select(r => r.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
return blocked ?? [];
|
||||
}
|
||||
|
||||
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
||||
RelationshipStatus status = RelationshipStatus.Friends)
|
||||
{
|
||||
var relationship = await GetRelationship(accountId, relatedId, status);
|
||||
return relationship is not null;
|
||||
}
|
||||
|
||||
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
||||
{
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
|
||||
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user