♻️ Moved services & controllers to Pass

This commit is contained in:
2025-07-06 23:41:10 +08:00
parent 4b220e7ed7
commit 15fb93c2bb
75 changed files with 1733 additions and 1141 deletions

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public enum AbuseReportType public enum AbuseReportType
{ {

View File

@ -1,15 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Pass.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Extensions; using Microsoft.AspNetCore.Http;
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/accounts")] [Route("/accounts")]

View File

@ -1,15 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Org.BouncyCastle.Utilities;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Pass.Account;
[Authorize] [Authorize]
[ApiController] [ApiController]
@ -17,7 +17,6 @@ namespace DysonNetwork.Sphere.Account;
public class AccountCurrentController( public class AccountCurrentController(
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,
FileReferenceService fileRefService,
AccountEventService events, AccountEventService events,
AuthService auth AuthService auth
) : ControllerBase ) : ControllerBase
@ -96,61 +95,61 @@ public class AccountCurrentController(
if (request.Location is not null) profile.Location = request.Location; if (request.Location is not null) profile.Location = request.Location;
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone; if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
if (request.PictureId is not null) // if (request.PictureId is not null)
{ // {
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); // var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); // if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
//
var profileResourceId = $"profile:{profile.Id}"; // var profileResourceId = $"profile:{profile.Id}";
//
// Remove old references for the profile picture // // Remove old references for the profile picture
if (profile.Picture is not null) // if (profile.Picture is not null)
{ // {
var oldPictureRefs = // var oldPictureRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture"); // await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture");
foreach (var oldRef in oldPictureRefs) // foreach (var oldRef in oldPictureRefs)
{ // {
await fileRefService.DeleteReferenceAsync(oldRef.Id); // await fileRefService.DeleteReferenceAsync(oldRef.Id);
} // }
} // }
//
profile.Picture = picture.ToReferenceObject(); // profile.Picture = picture.ToReferenceObject();
//
// Create new reference // // Create new reference
await fileRefService.CreateReferenceAsync( // await fileRefService.CreateReferenceAsync(
picture.Id, // picture.Id,
"profile.picture", // "profile.picture",
profileResourceId // profileResourceId
); // );
} // }
//
if (request.BackgroundId is not null) // if (request.BackgroundId is not null)
{ // {
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); // var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); // if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
//
var profileResourceId = $"profile:{profile.Id}"; // var profileResourceId = $"profile:{profile.Id}";
//
// Remove old references for the profile background // // Remove old references for the profile background
if (profile.Background is not null) // if (profile.Background is not null)
{ // {
var oldBackgroundRefs = // var oldBackgroundRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background"); // await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background");
foreach (var oldRef in oldBackgroundRefs) // foreach (var oldRef in oldBackgroundRefs)
{ // {
await fileRefService.DeleteReferenceAsync(oldRef.Id); // await fileRefService.DeleteReferenceAsync(oldRef.Id);
} // }
} // }
//
profile.Background = background.ToReferenceObject(); // profile.Background = background.ToReferenceObject();
//
// Create new reference // // Create new reference
await fileRefService.CreateReferenceAsync( // await fileRefService.CreateReferenceAsync(
background.Id, // background.Id,
"profile.background", // "profile.background",
profileResourceId // profileResourceId
); // );
} // }
db.Update(profile); db.Update(profile);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -679,7 +678,7 @@ public class AccountCurrentController(
{ {
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var badges = await db.Badges var badges = await db.AccountBadges
.Where(b => b.AccountId == currentUser.Id) .Where(b => b.AccountId == currentUser.Id)
.ToListAsync(); .ToListAsync();
return Ok(badges); return Ok(badges);

View File

@ -1,22 +1,17 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using Org.BouncyCastle.Asn1.X509;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class AccountEventService( public class AccountEventService(
AppDatabase db, AppDatabase db,
WebSocketService ws, // WebSocketService ws,
ICacheService cache, // ICacheService cache,
PaymentService payment, // PaymentService payment,
IStringLocalizer<Localization.AccountEventResource> localizer IStringLocalizer<Localization.AccountEventResource> localizer
) )
{ {
@ -26,18 +21,18 @@ public class AccountEventService(
public void PurgeStatusCache(Guid userId) public void PurgeStatusCache(Guid userId)
{ {
var cacheKey = $"{StatusCacheKey}{userId}"; var cacheKey = $"{StatusCacheKey}{userId}";
cache.RemoveAsync(cacheKey); // cache.RemoveAsync(cacheKey);
} }
public async Task<Status> GetStatus(Guid userId) public async Task<Status> GetStatus(Guid userId)
{ {
var cacheKey = $"{StatusCacheKey}{userId}"; var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey); // var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus is not null) // if (cachedStatus is not null)
{ // {
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); // cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
return cachedStatus; // return cachedStatus;
} // }
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses var status = await db.AccountStatuses
@ -45,12 +40,13 @@ public class AccountEventService(
.Where(e => e.ClearedAt == null || e.ClearedAt > now) .Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var isOnline = ws.GetAccountIsConnected(userId); // var isOnline = ws.GetAccountIsConnected(userId);
var isOnline = false; // Placeholder
if (status is not null) if (status is not null)
{ {
status.IsOnline = !status.IsInvisible && isOnline; status.IsOnline = !status.IsInvisible && isOnline;
await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"], // await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
TimeSpan.FromMinutes(5)); // TimeSpan.FromMinutes(5));
return status; return status;
} }
@ -84,16 +80,16 @@ public class AccountEventService(
foreach (var userId in userIds) foreach (var userId in userIds)
{ {
var cacheKey = $"{StatusCacheKey}{userId}"; var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey); // var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus != null) // if (cachedStatus != null)
{ // {
cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); // cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
results[userId] = cachedStatus; // results[userId] = cachedStatus;
} // }
else // else
{ // {
cacheMissUserIds.Add(userId); cacheMissUserIds.Add(userId);
} // }
} }
if (cacheMissUserIds.Any()) if (cacheMissUserIds.Any())
@ -110,11 +106,12 @@ public class AccountEventService(
foreach (var status in statusesFromDb) foreach (var status in statusesFromDb)
{ {
var isOnline = ws.GetAccountIsConnected(status.AccountId); // var isOnline = ws.GetAccountIsConnected(status.AccountId);
var isOnline = false; // Placeholder
status.IsOnline = !status.IsInvisible && isOnline; status.IsOnline = !status.IsInvisible && isOnline;
results[status.AccountId] = status; results[status.AccountId] = status;
var cacheKey = $"{StatusCacheKey}{status.AccountId}"; var cacheKey = $"{StatusCacheKey}{status.AccountId}";
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5)); // await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
foundUserIds.Add(status.AccountId); foundUserIds.Add(status.AccountId);
} }
@ -123,7 +120,8 @@ public class AccountEventService(
{ {
foreach (var userId in usersWithoutStatus) foreach (var userId in usersWithoutStatus)
{ {
var isOnline = ws.GetAccountIsConnected(userId); // var isOnline = ws.GetAccountIsConnected(userId);
var isOnline = false; // Placeholder
var defaultStatus = new Status var defaultStatus = new Status
{ {
Attitude = StatusAttitude.Neutral, Attitude = StatusAttitude.Neutral,
@ -168,12 +166,12 @@ public class AccountEventService(
public async Task<bool> CheckInDailyDoAskCaptcha(Shared.Models.Account user) public async Task<bool> CheckInDailyDoAskCaptcha(Shared.Models.Account user)
{ {
var cacheKey = $"{CaptchaCacheKey}{user.Id}"; var cacheKey = $"{CaptchaCacheKey}{user.Id}";
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey); // var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
if (needsCaptcha is not null) // if (needsCaptcha is not null)
return needsCaptcha!.Value; // return needsCaptcha!.Value;
var result = Random.Next(100) < CaptchaProbabilityPercent; var result = Random.Next(100) < CaptchaProbabilityPercent;
await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24)); // await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
return result; return result;
} }
@ -202,10 +200,10 @@ public class AccountEventService(
try try
{ {
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100)); // var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
if (lk != null) // if (lk != null)
await lk.ReleaseAsync(); // await lk.ReleaseAsync();
} }
catch catch
{ {
@ -213,8 +211,8 @@ public class AccountEventService(
} }
// Now try to acquire the lock properly // Now try to acquire the lock properly
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); // 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."); // if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
var cultureInfo = new CultureInfo(user.Language, false); var cultureInfo = new CultureInfo(user.Language, false);
CultureInfo.CurrentCulture = cultureInfo; CultureInfo.CurrentCulture = cultureInfo;
@ -256,13 +254,14 @@ public class AccountEventService(
try try
{ {
if (result.RewardPoints.HasValue) if (result.RewardPoints.HasValue)
await payment.CreateTransactionWithAccountAsync( // await payment.CreateTransactionWithAccountAsync(
null, // null,
user.Id, // user.Id,
WalletCurrency.SourcePoint, // WalletCurrency.SourcePoint,
result.RewardPoints.Value, // result.RewardPoints.Value,
$"Check-in reward on {now:yyyy/MM/dd}" // $"Check-in reward on {now:yyyy/MM/dd}"
); // );
Console.WriteLine($"Simulating transaction for {result.RewardPoints.Value} points");
} }
catch catch
{ {

View File

@ -0,0 +1,61 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Protos.Account;
using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Auth;
namespace DysonNetwork.Pass.Account;
public class AccountGrpcService(AppDatabase db, AuthService auth)
: DysonNetwork.Shared.Protos.Account.AccountService.AccountServiceBase
{
public override async Task<AccountResponse> GetAccount(Empty request, ServerCallContext context)
{
var account = await GetAccountFromContext(context);
return ToAccountResponse(account);
}
public override async Task<AccountResponse> UpdateAccount(UpdateAccountRequest request, ServerCallContext context)
{
var account = await GetAccountFromContext(context);
// TODO: implement
await db.SaveChangesAsync();
return ToAccountResponse(account);
}
private async Task<Shared.Models.Account> GetAccountFromContext(ServerCallContext context)
{
var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization");
if (authorizationHeader == null)
{
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Missing authorization header."));
}
var token = authorizationHeader.Value.Replace("Bearer ", "");
if (!auth.ValidateToken(token, out var sessionId))
{
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid token."));
}
var session = await db.AuthSessions.Include(s => s.Account).ThenInclude(a => a.Contacts)
.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session == null)
{
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Session not found."));
}
return session.Account;
}
private AccountResponse ToAccountResponse(Shared.Models.Account account)
{
// TODO: implement
return new AccountResponse
{
};
}
}

View File

@ -1,28 +1,24 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId; using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using Org.BouncyCastle.Utilities;
using OtpNet; using OtpNet;
using Microsoft.Extensions.Logging;
using EFCore.BulkExtensions;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class AccountService( public class AccountService(
AppDatabase db, AppDatabase db,
MagicSpellService spells, // MagicSpellService spells,
AccountUsernameService uname, // AccountUsernameService uname,
NotificationService nty, // NotificationService nty,
EmailService mailer, // EmailService mailer,
IStringLocalizer<NotificationResource> localizer, // IStringLocalizer<NotificationResource> localizer,
ICacheService cache, ICacheService cache,
ILogger<AccountService> logger ILogger<AccountService> logger
) )
@ -136,15 +132,15 @@ public class AccountService(
} }
else else
{ {
var spell = await spells.CreateMagicSpell( // var spell = await spells.CreateMagicSpell(
account, // account,
MagicSpellType.AccountActivation, // MagicSpellType.AccountActivation,
new Dictionary<string, object> // new Dictionary<string, object>
{ // {
{ "contact_method", account.Contacts.First().Content } // { "contact_method", account.Contacts.First().Content }
} // }
); // );
await spells.NotifyMagicSpell(spell, true); // await spells.NotifyMagicSpell(spell, true);
} }
db.Accounts.Add(account); db.Accounts.Add(account);
@ -170,7 +166,8 @@ public class AccountService(
: $"{userInfo.FirstName} {userInfo.LastName}".Trim(); : $"{userInfo.FirstName} {userInfo.LastName}".Trim();
// Generate username from email // Generate username from email
var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email); // var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
var username = userInfo.Email.Split('@')[0]; // Placeholder
return await CreateAccount( return await CreateAccount(
username, username,
@ -185,26 +182,26 @@ public class AccountService(
public async Task RequestAccountDeletion(Shared.Models.Account account) public async Task RequestAccountDeletion(Shared.Models.Account account)
{ {
var spell = await spells.CreateMagicSpell( // var spell = await spells.CreateMagicSpell(
account, // account,
MagicSpellType.AccountRemoval, // MagicSpellType.AccountRemoval,
new Dictionary<string, object>(), // new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), // SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true // preventRepeat: true
); // );
await spells.NotifyMagicSpell(spell); // await spells.NotifyMagicSpell(spell);
} }
public async Task RequestPasswordReset(Shared.Models.Account account) public async Task RequestPasswordReset(Shared.Models.Account account)
{ {
var spell = await spells.CreateMagicSpell( // var spell = await spells.CreateMagicSpell(
account, // account,
MagicSpellType.AuthPasswordReset, // MagicSpellType.AuthPasswordReset,
new Dictionary<string, object>(), // new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), // SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true // preventRepeat: true
); // );
await spells.NotifyMagicSpell(spell); // await spells.NotifyMagicSpell(spell);
} }
public async Task<bool> CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type) public async Task<bool> CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type)
@ -330,7 +327,7 @@ public class AccountService(
{ {
var count = await db.AccountAuthFactors var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId) .Where(f => f.AccountId == factor.AccountId)
.If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null)) // .If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
.CountAsync(); .CountAsync();
if (count <= 1) if (count <= 1)
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor."); throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
@ -356,14 +353,14 @@ public class AccountService(
if (await _GetFactorCode(factor) is not null) if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration."); throw new InvalidOperationException("A factor code has been sent and in active duration.");
await nty.SendNotification( // await nty.SendNotification(
account, // account,
"auth.verification", // "auth.verification",
localizer["AuthCodeTitle"], // localizer["AuthCodeTitle"],
null, // null,
localizer["AuthCodeBody", code], // localizer["AuthCodeBody", code],
save: true // save: true
); // );
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
break; break;
case AccountAuthFactorType.EmailCode: case AccountAuthFactorType.EmailCode:
@ -398,16 +395,16 @@ public class AccountService(
return; return;
} }
await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>( // await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
account.Nick, // account.Nick,
contact.Content, // contact.Content,
localizer["VerificationEmail"], // localizer["VerificationEmail"],
new VerificationEmailModel // new VerificationEmailModel
{ // {
Name = account.Name, // Name = account.Name,
Code = code // Code = code
} // }
); // );
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30)); await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
break; break;
@ -492,7 +489,7 @@ public class AccountService(
.ToListAsync(); .ToListAsync();
if (session.Challenge.DeviceId is not null) if (session.Challenge.DeviceId is not null)
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); // await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
// The current session should be included in the sessions' list // The current session should be included in the sessions' list
await db.AuthSessions await db.AuthSessions
@ -521,14 +518,14 @@ public class AccountService(
public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact) public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact)
{ {
var spell = await spells.CreateMagicSpell( // var spell = await spells.CreateMagicSpell(
account, // account,
MagicSpellType.ContactVerification, // MagicSpellType.ContactVerification,
new Dictionary<string, object> { { "contact_method", contact.Content } }, // new Dictionary<string, object> { { "contact_method", contact.Content } },
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), // expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true // preventRepeat: true
); // );
await spells.NotifyMagicSpell(spell); // await spells.NotifyMagicSpell(spell);
} }
public async Task<AccountContact> SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact) public async Task<AccountContact> SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact)
@ -578,7 +575,7 @@ public class AccountService(
public async Task<Badge> GrantBadge(Shared.Models.Account account, Badge badge) public async Task<Badge> GrantBadge(Shared.Models.Account account, Badge badge)
{ {
badge.AccountId = account.Id; badge.AccountId = account.Id;
db.Badges.Add(badge); db.AccountBadges.Add(badge);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return badge; return badge;
} }
@ -589,7 +586,7 @@ public class AccountService(
/// </summary> /// </summary>
public async Task RevokeBadge(Shared.Models.Account account, Guid badgeId) public async Task RevokeBadge(Shared.Models.Account account, Guid badgeId)
{ {
var badge = await db.Badges var badge = await db.AccountBadges
.Where(b => b.AccountId == account.Id && b.Id == badgeId) .Where(b => b.AccountId == account.Id && b.Id == badgeId)
.OrderByDescending(b => b.CreatedAt) .OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@ -611,13 +608,13 @@ public class AccountService(
try try
{ {
var badge = await db.Badges var badge = await db.AccountBadges
.Where(b => b.AccountId == account.Id && b.Id == badgeId) .Where(b => b.AccountId == account.Id && b.Id == badgeId)
.OrderByDescending(b => b.CreatedAt) .OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found."); if (badge is null) throw new InvalidOperationException("Badge was not found.");
await db.Badges await db.AccountBadges
.Where(b => b.AccountId == account.Id && b.Id != badgeId) .Where(b => b.AccountId == account.Id && b.Id != badgeId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null)); .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));

View File

@ -1,7 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
/// <summary> /// <summary>
/// Service for handling username generation and validation /// Service for handling username generation and validation

View File

@ -1,12 +1,12 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Quartz; using Microsoft.AspNetCore.Http;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class ActionLogService(GeoIpService geo, FlushBufferService fbs) public class ActionLogService(
// GeoIpService geo,
// FlushBufferService fbs
)
{ {
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta) public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
{ {
@ -17,7 +17,7 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
Meta = meta, Meta = meta,
}; };
fbs.Enqueue(log); // fbs.Enqueue(log);
} }
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
@ -29,7 +29,7 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
Meta = meta, Meta = meta,
UserAgent = request.Headers.UserAgent, UserAgent = request.Headers.UserAgent,
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(), IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) // Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
}; };
if (request.HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser) if (request.HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser)
@ -42,6 +42,6 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
if (request.HttpContext.Items["CurrentSession"] is Session currentSession) if (request.HttpContext.Items["CurrentSession"] is Session currentSession)
log.SessionId = currentSession.Id; log.SessionId = currentSession.Id;
fbs.Enqueue(log); // fbs.Enqueue(log);
} }
} }

View File

@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public enum MagicSpellType public enum MagicSpellType
{ {

View File

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/spells")] [Route("/spells")]

View File

@ -2,23 +2,20 @@ using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Resources.Localization;
using DysonNetwork.Sphere.Resources.Pages.Emails;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class MagicSpellService( public class MagicSpellService(
AppDatabase db, AppDatabase db,
EmailService email, // EmailService email,
IConfiguration configuration, IConfiguration configuration,
ILogger<MagicSpellService> logger, ILogger<MagicSpellService> logger
IStringLocalizer<Localization.EmailResource> localizer // IStringLocalizer<Localization.EmailResource> localizer
) )
{ {
public async Task<MagicSpell> CreateMagicSpell( public async Task<MagicSpell> CreateMagicSpell(
@ -88,54 +85,54 @@ public class MagicSpellService(
switch (spell.Type) switch (spell.Type)
{ {
case MagicSpellType.AccountActivation: case MagicSpellType.AccountActivation:
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>( // await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contact.Content, // contact.Content,
localizer["EmailLandingTitle"], // localizer["EmailLandingTitle"],
new LandingEmailModel // new LandingEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
case MagicSpellType.AccountRemoval: case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>( // await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contact.Content, // contact.Content,
localizer["EmailAccountDeletionTitle"], // localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel // new AccountDeletionEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
case MagicSpellType.AuthPasswordReset: case MagicSpellType.AuthPasswordReset:
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( // await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contact.Content, // contact.Content,
localizer["EmailAccountDeletionTitle"], // localizer["EmailAccountDeletionTitle"],
new PasswordResetEmailModel // new PasswordResetEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
case MagicSpellType.ContactVerification: case MagicSpellType.ContactVerification:
if (spell.Meta["contact_method"] is not string contactMethod) if (spell.Meta["contact_method"] is not string contactMethod)
throw new InvalidOperationException("Contact method is not found."); throw new InvalidOperationException("Contact method is not found.");
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>( // await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contactMethod!, // contactMethod!,
localizer["EmailContactVerificationTitle"], // localizer["EmailContactVerificationTitle"],
new ContactVerificationEmailModel // new ContactVerificationEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
default: default:
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();

View File

@ -1,13 +1,13 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/notifications")] [Route("/notifications")]
@ -49,7 +49,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
.ToListAsync(); .ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
await nty.MarkNotificationsViewed(notifications); // await nty.MarkNotificationsViewed(notifications);
return Ok(notifications); return Ok(notifications);
} }
@ -73,11 +73,11 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
var currentSession = currentSessionValue as Session; var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized(); if (currentSession == null) return Unauthorized();
var result = // var result =
await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!, // await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!,
request.DeviceToken); // request.DeviceToken);
return Ok(result); return Ok();
} }
[HttpDelete("subscription")] [HttpDelete("subscription")]
@ -111,26 +111,26 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
[HttpPost("broadcast")] [HttpPost("broadcast")]
[Authorize] [Authorize]
[RequiredPermission("global", "notifications.broadcast")] // [RequiredPermission("global", "notifications.broadcast")]
public async Task<ActionResult> BroadcastNotification( public async Task<ActionResult> BroadcastNotification(
[FromBody] NotificationRequest request, [FromBody] NotificationRequest request,
[FromQuery] bool save = false [FromQuery] bool save = false
) )
{ {
await nty.BroadcastNotification( // await nty.BroadcastNotification(
new Notification // new Notification
{ // {
CreatedAt = SystemClock.Instance.GetCurrentInstant(), // CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(), // UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic, // Topic = request.Topic,
Title = request.Title, // Title = request.Title,
Subtitle = request.Subtitle, // Subtitle = request.Subtitle,
Content = request.Content, // Content = request.Content,
Meta = request.Meta, // Meta = request.Meta,
Priority = request.Priority, // Priority = request.Priority,
}, // },
save // save
); // );
return Ok(); return Ok();
} }
@ -141,27 +141,27 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
[HttpPost("send")] [HttpPost("send")]
[Authorize] [Authorize]
[RequiredPermission("global", "notifications.send")] // [RequiredPermission("global", "notifications.send")]
public async Task<ActionResult> SendNotification( public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request, [FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false [FromQuery] bool save = false
) )
{ {
var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync(); var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync();
await nty.SendNotificationBatch( // await nty.SendNotificationBatch(
new Notification // new Notification
{ // {
CreatedAt = SystemClock.Instance.GetCurrentInstant(), // CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(), // UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic, // Topic = request.Topic,
Title = request.Title, // Title = request.Title,
Subtitle = request.Subtitle, // Subtitle = request.Subtitle,
Content = request.Content, // Content = request.Content,
Meta = request.Meta, // Meta = request.Meta,
}, // },
accounts, // accounts,
save // save
); // );
return Ok(); return Ok();
} }
} }

View File

@ -0,0 +1,311 @@
using System.Text;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Microsoft.Extensions.Configuration;
using System.Net.Http;
namespace DysonNetwork.Pass.Account;
public class NotificationService(
AppDatabase db
// WebSocketService ws,
// IHttpClientFactory httpFactory,
// IConfiguration config
)
{
// 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(
Shared.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(
Shared.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) Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification);
return notification;
}
public async Task DeliveryNotification(Notification notification)
{
// ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
// {
// Type = "notifications.new",
// Data = 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 = new List<Shared.Models.Account>(); // 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);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
// ws.SendPacketToAccount(account.Id, new WebSocketPacket
// {
// Type = "notifications.new",
// Data = notification
// });
}
// var subscribers = await db.NotificationPushSubscriptions
// .ToListAsync();
// await _PushNotification(notification, subscribers);
}
public async Task SendNotificationBatch(Notification notification, List<Shared.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);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
// ws.SendPacketToAccount(account.Id, new WebSocketPacket
// {
// Type = "notifications.new",
// Data = notification
// });
}
// 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();
// }
}

View File

@ -4,8 +4,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/relationships")] [Route("/relationships")]

View File

@ -1,11 +1,13 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class RelationshipService(AppDatabase db, ICacheService cache) public class RelationshipService(
AppDatabase db
// ICacheService cache
)
{ {
private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
@ -53,7 +55,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.AccountRelationships.Add(relationship); db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id); // await PurgeRelationshipCache(sender.Id, target.Id);
return relationship; return relationship;
} }
@ -72,7 +74,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.Remove(relationship); db.Remove(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id); // await PurgeRelationshipCache(sender.Id, target.Id);
return relationship; return relationship;
} }
@ -105,7 +107,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending) .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); // await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
} }
public async Task<Relationship> AcceptFriendRelationship( public async Task<Relationship> AcceptFriendRelationship(
@ -134,7 +136,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); // await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
return relationshipBackward; return relationshipBackward;
} }
@ -148,7 +150,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.Update(relationship); db.Update(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(accountId, relatedId); // await PurgeRelationshipCache(accountId, relatedId);
return relationship; return relationship;
} }
@ -156,7 +158,8 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
public async Task<List<Guid>> ListAccountFriends(Shared.Models.Account account) public async Task<List<Guid>> ListAccountFriends(Shared.Models.Account account)
{ {
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}"; var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
var friends = await cache.GetAsync<List<Guid>>(cacheKey); // var friends = await cache.GetAsync<List<Guid>>(cacheKey);
var friends = new List<Guid>(); // Placeholder
if (friends == null) if (friends == null)
{ {
@ -166,7 +169,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
.Select(r => r.AccountId) .Select(r => r.AccountId)
.ToListAsync(); .ToListAsync();
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); // await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
} }
return friends ?? []; return friends ?? [];
@ -175,7 +178,8 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
public async Task<List<Guid>> ListAccountBlocked(Shared.Models.Account account) public async Task<List<Guid>> ListAccountBlocked(Shared.Models.Account account)
{ {
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}"; var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
var blocked = await cache.GetAsync<List<Guid>>(cacheKey); // var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
var blocked = new List<Guid>(); // Placeholder
if (blocked == null) if (blocked == null)
{ {
@ -185,7 +189,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
.Select(r => r.AccountId) .Select(r => r.AccountId)
.ToListAsync(); .ToListAsync();
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1)); // await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
} }
return blocked ?? []; return blocked ?? [];
@ -200,9 +204,9 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId) private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
{ {
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); // await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}"); // await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}"); // await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}"); // await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
} }
} }

View File

@ -1,8 +1,15 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using Quartz;
namespace DysonNetwork.Pass; namespace DysonNetwork.Pass;
@ -22,23 +29,67 @@ public class AppDatabase(
public DbSet<PermissionGroup> PermissionGroups { get; set; } public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<Account> Accounts { get; set; } public DbSet<Shared.Models.Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; } public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; } public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; } public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; } public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Status> AccountStatuses { get; set; }
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Badge> AccountBadges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Session> AuthSessions { get; set; } public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; } public DbSet<Challenge> AuthChallenges { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
configuration.GetConnectionString("App"), configuration.GetConnectionString("App"),
opt => opt opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
.UseNodaTime() .UseNodaTime()
).UseSnakeCaseNamingConvention(); ).UseSnakeCaseNamingConvention();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var defaultPermissionGroup = await context.Set<PermissionGroup>()
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
if (defaultPermissionGroup is null)
{
context.Set<PermissionGroup>().Add(new PermissionGroup
{
Key = "default",
Nodes = new List<string>
{
"posts.create",
"posts.react",
"publishers.create",
"files.create",
"chat.create",
"chat.messages.create",
"chat.realtime.create",
"accounts.statuses.create",
"accounts.statuses.update",
"stickers.packs.create",
"stickers.create"
}.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true))
.ToList()
});
await context.SaveChangesAsync(cancellationToken);
}
});
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
} }
@ -49,9 +100,40 @@ public class AppDatabase(
modelBuilder.Entity<PermissionGroupMember>() modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor }); .HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Relationship>() modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId }); .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]);
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : ModelBase
{
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
} }
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@ -80,6 +162,66 @@ public class AppDatabase(
} }
} }
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Cleaning up expired records...");
// Expired relationships
var affectedRows = await db.AccountRelationships
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
// Expired permission group members
affectedRows = await db.PermissionGroupMembers
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
logger.LogInformation("Deleting soft-deleted records...");
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
.Select(t => t.ClrType);
foreach (var entityType in entityTypes)
{
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
.MakeGenericMethod(entityType).Invoke(db, null)!;
var parameter = Expression.Parameter(entityType, "e");
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
var finalCondition = Expression.AndAlso(notNull, condition);
var lambda = Expression.Lambda(finalCondition, parameter);
var queryable = set.Provider.CreateQuery(
Expression.Call(
typeof(Queryable),
"Where",
[entityType],
set.Expression,
Expression.Quote(lambda)
)
);
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
.MakeGenericMethod(entityType);
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
db.RemoveRange(items);
}
await db.SaveChangesAsync();
}
}
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase> public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
{ {
public AppDatabase CreateDbContext(string[] args) public AppDatabase CreateDbContext(string[] args)
@ -92,4 +234,36 @@ public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>(); var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
return new AppDatabase(optionsBuilder.Options, configuration); return new AppDatabase(optionsBuilder.Options, configuration);
} }
} }
public static class OptionalQueryExtensions
{
public static IQueryable<T> If<T>(
this IQueryable<T> source,
bool condition,
Func<IQueryable<T>, IQueryable<T>> transform
)
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, TP> source,
bool condition,
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, IEnumerable<TP>> source,
bool condition,
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
}

View File

@ -1,23 +1,16 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
using System.Text; using Microsoft.AspNetCore.Http;
using DysonNetwork.Shared.Models; using Microsoft.Extensions.Configuration;
using DysonNetwork.Sphere.Auth.OidcProvider.Controllers; using Microsoft.Extensions.Logging;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using SystemClock = Microsoft.Extensions.Internal.SystemClock;
using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public static class AuthConstants public static class AuthConstants
{ {
@ -47,12 +40,11 @@ public class DysonTokenAuthHandler(
IConfiguration configuration, IConfiguration configuration,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
AppDatabase database, AppDatabase database
OidcProviderService oidc, // OidcProviderService oidc,
ICacheService cache, // ICacheService cache,
FlushBufferService fbs // FlushBufferService fbs
) ) : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{ {
public const string AuthCachePrefix = "auth:"; public const string AuthCachePrefix = "auth:";
@ -65,36 +57,42 @@ public class DysonTokenAuthHandler(
try try
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = NodaTime.SystemClock.Instance.GetCurrentInstant();
// Validate token and extract session ID // Validate token and extract session ID
if (!ValidateToken(tokenInfo.Token, out var sessionId)) if (!ValidateToken(tokenInfo.Token, out var sessionId))
return AuthenticateResult.Fail("Invalid token."); return AuthenticateResult.Fail("Invalid token.");
// Try to get session from cache first // Try to get session from cache first
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}"); // var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
var session = await database.AuthSessions
// If not in cache, load from database
if (session is null)
{
session = await database.AuthSessions
.Where(e => e.Id == sessionId) .Where(e => e.Id == sessionId)
.Include(e => e.Challenge) .Include(e => e.Challenge)
.Include(e => e.Account) .Include(e => e.Account)
.ThenInclude(e => e.Profile) .ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (session is not null) // If not in cache, load from database
{ // if (session is null)
// Store in cache for future requests // {
await cache.SetWithGroupsAsync( // session = await database.AuthSessions
$"auth:{sessionId}", // .Where(e => e.Id == sessionId)
session, // .Include(e => e.Challenge)
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"], // .Include(e => e.Account)
TimeSpan.FromHours(1) // .ThenInclude(e => e.Profile)
); // .FirstOrDefaultAsync();
}
} // if (session is not null)
// {
// // Store in cache for future requests
// await cache.SetWithGroupsAsync(
// $"auth:{sessionId}",
// session,
// // [$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
// TimeSpan.FromHours(1)
// );
// }
// }
// Check if the session exists // Check if the session exists
if (session == null) if (session == null)
@ -130,13 +128,13 @@ public class DysonTokenAuthHandler(
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName); var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
var lastInfo = new LastActiveInfo // var lastInfo = new LastActiveInfo
{ // {
Account = session.Account, // Account = session.Account,
Session = session, // Session = session,
SeenAt = SystemClock.Instance.GetCurrentInstant(), // SeenAt = SystemClock.Instance.GetCurrentInstant(),
}; // };
fbs.Enqueue(lastInfo); // fbs.Enqueue(lastInfo);
return AuthenticateResult.Success(ticket); return AuthenticateResult.Success(ticket);
} }
@ -159,12 +157,13 @@ public class DysonTokenAuthHandler(
// Handle JWT tokens (3 parts) // Handle JWT tokens (3 parts)
case 3: case 3:
{ {
var (isValid, jwtResult) = oidc.ValidateToken(token); // var (isValid, jwtResult) = oidc.ValidateToken(token);
if (!isValid) return false; // if (!isValid) return false;
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value; // var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti is null) return false; // if (jti is null) return false;
return Guid.TryParse(jti, out sessionId); // return Guid.TryParse(jti, out sessionId);
return false; // Placeholder
} }
// Handle compact tokens (2 parts) // Handle compact tokens (2 parts)
case 2: case 2:

View File

@ -1,24 +1,23 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Account;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.IdentityModel.Tokens.Jwt; using NodaTime;
using Microsoft.AspNetCore.Http;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Connection;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
[ApiController] [ApiController]
[Route("/auth")] [Route("/auth")]
public class AuthController( public class AuthController(
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,
AuthService auth, AuthService auth
GeoIpService geo, // GeoIpService geo,
ActionLogService als // ActionLogService als
) : ControllerBase ) : ControllerBase
{ {
public class ChallengeRequest public class ChallengeRequest
@ -60,7 +59,7 @@ public class AuthController(
Scopes = request.Scopes, Scopes = request.Scopes,
IpAddress = ipAddress, IpAddress = ipAddress,
UserAgent = userAgent, UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress), // Location = geo.GetPointFromIp(ipAddress),
DeviceId = request.DeviceId, DeviceId = request.DeviceId,
AccountId = account.Id AccountId = account.Id
}.Normalize(); }.Normalize();
@ -68,9 +67,9 @@ public class AuthController(
await db.AuthChallenges.AddAsync(challenge); await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt, // als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account // new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
); // );
return challenge; return challenge;
} }
@ -161,13 +160,13 @@ public class AuthController(
challenge.StepRemain = Math.Max(0, challenge.StepRemain); challenge.StepRemain = Math.Max(0, challenge.StepRemain);
challenge.BlacklistFactors.Add(factor.Id); challenge.BlacklistFactors.Add(factor.Id);
db.Update(challenge); db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, // als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object> // new Dictionary<string, object>
{ // {
{ "challenge_id", challenge.Id }, // { "challenge_id", challenge.Id },
{ "factor_id", factor.Id } // { "factor_id", factor.Id }
}, Request, challenge.Account // }, Request, challenge.Account
); // );
} }
else else
{ {
@ -178,26 +177,26 @@ public class AuthController(
{ {
challenge.FailedAttempts++; challenge.FailedAttempts++;
db.Update(challenge); db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, // als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object> // new Dictionary<string, object>
{ // {
{ "challenge_id", challenge.Id }, // { "challenge_id", challenge.Id },
{ "factor_id", factor.Id } // { "factor_id", factor.Id }
}, Request, challenge.Account // }, Request, challenge.Account
); // );
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return BadRequest("Invalid password."); return BadRequest("Invalid password.");
} }
if (challenge.StepRemain == 0) if (challenge.StepRemain == 0)
{ {
als.CreateActionLogFromRequest(ActionLogType.NewLogin, // als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object> // new Dictionary<string, object>
{ // {
{ "challenge_id", challenge.Id }, // { "challenge_id", challenge.Id },
{ "account_id", challenge.AccountId } // { "account_id", challenge.AccountId }
}, Request, challenge.Account // }, Request, challenge.Account
); // );
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@ -1,16 +1,18 @@
using DysonNetwork.Sphere.Auth.Proto; using DysonNetwork.Shared.Protos.Auth;
using Grpc.Core; using Grpc.Core;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Account;
using Challenge = DysonNetwork.Shared.Models.Challenge;
using Session = DysonNetwork.Shared.Models.Session;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public class AuthGrpcService(AppDatabase db, AccountService accounts, AuthService auth) public class AuthGrpcService(AppDatabase db, AccountService accounts, AuthService auth)
: DysonNetwork.Sphere.Auth.Proto.AuthService.AuthServiceBase : DysonNetwork.Shared.Protos.Auth.AuthService.AuthServiceBase
{ {
public override async Task<LoginResponse> Login(LoginRequest request, ServerCallContext context) public override async Task<LoginResponse> Login(LoginRequest request, ServerCallContext context)
{ {
@ -31,7 +33,7 @@ public class AuthGrpcService(AppDatabase db, AccountService accounts, AuthServic
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
Account = account, Account = account,
Challenge = new Challenge() // Create a dummy challenge Challenge = new Challenge()
}; };
db.AuthSessions.Add(session); db.AuthSessions.Add(session);

View File

@ -1,22 +1,23 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Http;
using System.IO;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public class AuthService( public class AuthService(
AppDatabase db, AppDatabase db,
IConfiguration config, IConfiguration config
IHttpClientFactory httpClientFactory, // IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor, // IHttpContextAccessor httpContextAccessor,
ICacheService cache // ICacheService cache
) )
{ {
private HttpContext HttpContext => httpContextAccessor.HttpContext!; // private HttpContext HttpContext => httpContextAccessor.HttpContext!;
/// <summary> /// <summary>
/// Detect the risk of the current request to login /// Detect the risk of the current request to login
@ -79,8 +80,8 @@ public class AuthService(
var challenge = new Challenge var challenge = new Challenge
{ {
AccountId = account.Id, AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), IpAddress = "127.0.0.1", // HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent, UserAgent = "TestAgent", // HttpContext.Request.Headers.UserAgent,
StepRemain = 1, StepRemain = 1,
StepTotal = 1, StepTotal = 1,
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
@ -106,53 +107,54 @@ public class AuthService(
{ {
if (string.IsNullOrWhiteSpace(token)) return false; if (string.IsNullOrWhiteSpace(token)) return false;
var provider = config.GetSection("Captcha")["Provider"]?.ToLower(); // var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
var apiSecret = config.GetSection("Captcha")["ApiSecret"]; // var apiSecret = config.GetSection("Captcha")["ApiSecret"];
var client = httpClientFactory.CreateClient(); // var client = httpClientFactory.CreateClient();
var jsonOpts = new JsonSerializerOptions // var jsonOpts = new JsonSerializerOptions
{ // {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, // PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower // DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
}; // };
switch (provider) // switch (provider)
{ // {
case "cloudflare": // case "cloudflare":
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, // var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded"); // "application/x-www-form-urlencoded");
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", // var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
content); // content);
response.EnsureSuccessStatusCode(); // response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(); // var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts); // var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true; // return result?.Success == true;
case "google": // case "google":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, // content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded"); // "application/x-www-form-urlencoded");
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content); // response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
response.EnsureSuccessStatusCode(); // response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync(); // json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts); // result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true; // return result?.Success == true;
case "hcaptcha": // case "hcaptcha":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, // content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded"); // "application/x-www-form-urlencoded");
response = await client.PostAsync("https://hcaptcha.com/siteverify", content); // response = await client.PostAsync("https://hcaptcha.com/siteverify", content);
response.EnsureSuccessStatusCode(); // response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync(); // json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts); // result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true; // return result?.Success == true;
default: // default:
throw new ArgumentException("The server misconfigured for the captcha."); // throw new ArgumentException("The server misconfigured for the captcha.");
} // }
return true; // Placeholder for captcha validation
} }
public string CreateToken(Session session) public string CreateToken(Session session)
@ -184,56 +186,56 @@ public class AuthService(
return $"{payloadBase64}.{signatureBase64}"; return $"{payloadBase64}.{signatureBase64}";
} }
public async Task<bool> ValidateSudoMode(Session session, string? pinCode) // public async Task<bool> ValidateSudoMode(Session session, string? pinCode)
{ // {
// Check if the session is already in sudo mode (cached) // // Check if the session is already in sudo mode (cached)
var sudoModeKey = $"accounts:{session.Id}:sudo"; // var sudoModeKey = $"accounts:{session.Id}:sudo";
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey); // var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
if (found) // if (found)
{ // {
// Session is already in sudo mode // // Session is already in sudo mode
return true; // return true;
} // }
// Check if the user has a pin code // // Check if the user has a pin code
var hasPinCode = await db.AccountAuthFactors // var hasPinCode = await db.AccountAuthFactors
.Where(f => f.AccountId == session.AccountId) // .Where(f => f.AccountId == session.AccountId)
.Where(f => f.EnabledAt != null) // .Where(f => f.EnabledAt != null)
.Where(f => f.Type == AccountAuthFactorType.PinCode) // .Where(f => f.Type == AccountAuthFactorType.PinCode)
.AnyAsync(); // .AnyAsync();
if (!hasPinCode) // if (!hasPinCode)
{ // {
// User doesn't have a pin code, no validation needed // // User doesn't have a pin code, no validation needed
return true; // return true;
} // }
// If pin code is not provided, we can't validate // // If pin code is not provided, we can't validate
if (string.IsNullOrEmpty(pinCode)) // if (string.IsNullOrEmpty(pinCode))
{ // {
return false; // return false;
} // }
try // try
{ // {
// Validate the pin code // // Validate the pin code
var isValid = await ValidatePinCode(session.AccountId, pinCode); // var isValid = await ValidatePinCode(session.AccountId, pinCode);
if (isValid) // if (isValid)
{ // {
// Set session in sudo mode for 5 minutes // // Set session in sudo mode for 5 minutes
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5)); // await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
} // }
return isValid; // return isValid;
} // }
catch (InvalidOperationException) // catch (InvalidOperationException)
{ // {
// No pin code enabled for this account, so validation is successful // // No pin code enabled for this account, so validation is successful
return true; // return true;
} // }
} // }
public async Task<bool> ValidatePinCode(Guid accountId, string pinCode) public async Task<bool> ValidatePinCode(Guid accountId, string pinCode)
{ {

View File

@ -1,4 +1,4 @@
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public class CaptchaVerificationResponse public class CaptchaVerificationResponse
{ {

View File

@ -1,7 +1,8 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public class CompactTokenService(IConfiguration config) public class CompactTokenService(IConfiguration config)
{ {

View File

@ -1,19 +1,19 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Auth.OidcProvider.Options; using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses; using DysonNetwork.Pass.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; namespace DysonNetwork.Pass.Auth.OidcProvider.Controllers;
[Route("/auth/open")] [Route("/auth/open")]
[ApiController] [ApiController]

View File

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Models; namespace DysonNetwork.Pass.Auth.OidcProvider.Models;
public class AuthorizationCodeInfo public class AuthorizationCodeInfo
{ {

View File

@ -1,6 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options; namespace DysonNetwork.Pass.Auth.OidcProvider.Options;
public class OidcProviderOptions public class OidcProviderOptions
{ {

View File

@ -1,6 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class AuthorizationResponse public class AuthorizationResponse
{ {

View File

@ -1,6 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class ErrorResponse public class ErrorResponse
{ {

View File

@ -1,6 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class TokenResponse public class TokenResponse
{ {

View File

@ -2,18 +2,18 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using DysonNetwork.Pass.Auth.OidcProvider.Models;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth.OidcProvider.Models;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Services; namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
public class OidcProviderService( public class OidcProviderService(
AppDatabase db, AppDatabase db,
@ -27,16 +27,18 @@ public class OidcProviderService(
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId) public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
{ {
return await db.CustomApps return null;
.Include(c => c.Secrets) // return await db.CustomApps
.FirstOrDefaultAsync(c => c.Id == clientId); // .Include(c => c.Secrets)
// .FirstOrDefaultAsync(c => c.Id == clientId);
} }
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId) public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
{ {
return await db.CustomApps return null;
.Include(c => c.Secrets) // return await db.CustomApps
.FirstOrDefaultAsync(c => c.Id == appId); // .Include(c => c.Secrets)
// .FirstOrDefaultAsync(c => c.Id == appId);
} }
public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId) public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId)

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class AfdianOidcService( public class AfdianOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -3,10 +3,12 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary> /// <summary>
/// Implementation of OpenID Connect service for Apple Sign In /// Implementation of OpenID Connect service for Apple Sign In

View File

@ -1,12 +1,14 @@
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Storage;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
[ApiController] [ApiController]
[Route("/accounts/me/connections")] [Route("/accounts/me/connections")]

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class DiscordOidcService( public class DiscordOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class GitHubOidcService( public class GitHubOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,11 +1,11 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography; using DysonNetwork.Shared.Cache;
using System.Text; using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Storage; using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class GoogleOidcService( public class GoogleOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class MicrosoftOidcService( public class MicrosoftOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,12 +1,14 @@
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
[ApiController] [ApiController]
[Route("/auth/login")] [Route("/auth/login")]

View File

@ -1,14 +1,16 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary> /// <summary>
/// Base service for OpenID Connect authentication providers /// Base service for OpenID Connect authentication providers

View File

@ -1,95 +0,0 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using NodaTime;
namespace DysonNetwork.Pass.Data;
public abstract class ModelBase
{
public Instant CreatedAt { get; set; }
public Instant UpdatedAt { get; set; }
public Instant? DeletedAt { get; set; }
}
public class AppDatabase(
DbContextOptions<AppDatabase> options,
IConfiguration configuration
) : DbContext(options)
{
public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(
configuration.GetConnectionString("App"),
opt => opt
.UseNodaTime()
).UseSnakeCaseNamingConvention();
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
{
public AppDatabase CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
return new AppDatabase(optionsBuilder.Options, configuration);
}
}

View File

@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@ -16,10 +17,18 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" /> <PackageReference Include="StackExchange.Redis" Version="2.8.41" />
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" /> <PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0-preview.6.24328.4" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.0-preview1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -32,4 +41,34 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Update="Pages\Emails\ContactVerificationEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\EmailLayout.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\LandingEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\PasswordResetEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\VerificationEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\AccountDeletionEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Pass.Localization;
public class AccountEventResource
{
}

View File

@ -0,0 +1,5 @@
namespace DysonNetwork.Pass.Localization;
public class EmailResource
{
}

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Pass.Localization;
public class NotificationResource
{
}

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Pass.Localization;
public class SharedResource
{
}

View File

@ -0,0 +1,42 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
<p>@(Localizer["AccountDeletionPara2"])</p>
<p>@(Localizer["AccountDeletionPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["AccountDeletionButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["AccountDeletionPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@ -0,0 +1,43 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["ContactVerificationHeader"])</p>
<p>@(Localizer["ContactVerificationPara1"]) @Name,</p>
<p>@(Localizer["ContactVerificationPara2"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["ContactVerificationButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["ContactVerificationPara3"])</p>
<p>@(Localizer["ContactVerificationPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@ -0,0 +1,337 @@
@inherits LayoutComponentBase
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style media="all" type="text/css">
body {
font-family: Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 16px;
line-height: 1.3;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td {
font-family: Helvetica, sans-serif;
font-size: 16px;
vertical-align: top;
}
body {
background-color: #f4f5f6;
margin: 0;
padding: 0;
}
.body {
background-color: #f4f5f6;
width: 100%;
}
.container {
margin: 0 auto !important;
max-width: 600px;
padding: 0;
padding-top: 24px;
width: 600px;
}
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 600px;
padding: 0;
}
.main {
background: #ffffff;
border: 1px solid #eaebed;
border-radius: 16px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
padding: 24px;
}
.footer {
clear: both;
padding-top: 24px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #9a9ea6;
font-size: 16px;
text-align: center;
}
p {
font-family: Helvetica, sans-serif;
font-size: 16px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
}
a {
color: #0867ec;
text-decoration: underline;
}
.btn {
box-sizing: border-box;
min-width: 100% !important;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 16px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 4px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 2px #0867ec;
border-radius: 4px;
box-sizing: border-box;
color: #0867ec;
cursor: pointer;
display: inline-block;
font-size: 16px;
font-weight: bold;
margin: 0;
padding: 12px 24px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #0867ec;
}
.btn-primary a {
background-color: #0867ec;
border-color: #0867ec;
color: #ffffff;
}
.font-bold {
font-weight: bold;
}
.verification-code
{
font-family: "Courier New", Courier, monospace;
font-size: 24px;
letter-spacing: 0.5em;
}
@@media all {
.btn-primary table td:hover {
background-color: #ec0867 !important;
}
.btn-primary a:hover {
background-color: #ec0867 !important;
border-color: #ec0867 !important;
}
}
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.text-link {
color: #0867ec !important;
text-decoration: underline !important;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
@@media only screen and (max-width: 640px) {
.main p,
.main td,
.main span {
font-size: 16px !important;
}
.wrapper {
padding: 8px !important;
}
.content {
padding: 0 !important;
}
.container {
padding: 0 !important;
padding-top: 8px !important;
width: 100% !important;
}
.main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
.btn table {
max-width: 100% !important;
width: 100% !important;
}
.btn a {
font-size: 16px !important;
max-width: 100% !important;
width: 100% !important;
}
}
@@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
</style>
</head>
<body>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
<!-- START MAIN CONTENT AREA -->
@ChildContent
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
<span class="apple-link">Solar Network</span>
<br> Solsynth LLC © @(DateTime.Now.Year)
</td>
</tr>
<tr>
<td class="content-block powered-by">
Powered by <a href="https://github.com/solsynth/dysonnetwork">Dyson Network</a>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER --></div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
}

View File

@ -0,0 +1,43 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["LandingHeader1"])</p>
<p>@(Localizer["LandingPara1"]) @@@Name,</p>
<p>@(Localizer["LandingPara2"])</p>
<p>@(Localizer["LandingPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["LandingButton1"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["LandingPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@ -0,0 +1,44 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["PasswordResetHeader"])</p>
<p>@(Localizer["PasswordResetPara1"]) @@@Name,</p>
<p>@(Localizer["PasswordResetPara2"])</p>
<p>@(Localizer["PasswordResetPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["PasswordResetButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["PasswordResetPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@ -0,0 +1,27 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["VerificationHeader1"])</p>
<p>@(Localizer["VerificationPara1"]) @@@Name,</p>
<p>@(Localizer["VerificationPara2"])</p>
<p>@(Localizer["VerificationPara3"])</p>
<p class="verification-code">@Code</p>
<p>@(Localizer["VerificationPara4"])</p>
<p>@(Localizer["VerificationPara5"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Code { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@ -1,6 +1,6 @@
namespace DysonNetwork.Sphere.Permission; using Microsoft.AspNetCore.Http;
using System; namespace DysonNetwork.Pass.Permission;
[AttributeUsage(AttributeTargets.Method, Inherited = true)] [AttributeUsage(AttributeTargets.Method, Inherited = true)]
public class RequiredPermissionAttribute(string area, string key) : Attribute public class RequiredPermissionAttribute(string area, string key) : Attribute

View File

@ -1,10 +1,10 @@
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Permission; namespace DysonNetwork.Pass.Permission;
public class PermissionService( public class PermissionService(
AppDatabase db, AppDatabase db,

View File

@ -1,4 +1,6 @@
using DysonNetwork.Pass.Data; using DysonNetwork.Pass;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -8,15 +10,21 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddGrpc();
builder.Services.AddDbContext<AppDatabase>(options => builder.Services.AddDbContext<AppDatabase>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("App"))); options.UseNpgsql(builder.Configuration.GetConnectionString("App")));
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<AuthService>();
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.MapGrpcService<AccountGrpcService>();
app.MapGrpcService<AuthGrpcService>();
// Run database migrations // Run database migrations
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())

View File

@ -4,7 +4,7 @@ using NodaTime;
using NodaTime.Serialization.JsonNet; using NodaTime.Serialization.JsonNet;
using StackExchange.Redis; using StackExchange.Redis;
namespace DysonNetwork.Sphere.Storage; namespace DysonNetwork.Shared.Cache;
/// <summary> /// <summary>
/// Represents a distributed lock that can be used to synchronize access across multiple processes /// Represents a distributed lock that can be used to synchronize access across multiple processes

View File

@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Protobuf Include="Protos\*.proto" GrpcServices="Client" /> <Protobuf Include="Protos\*.proto" GrpcServices="Both" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -20,7 +20,9 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" /> <PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.2.2" /> <PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" /> <PackageReference Include="StackExchange.Redis" Version="2.8.41" />
</ItemGroup> </ItemGroup>

View File

@ -1,88 +0,0 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account.Proto;
using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Auth;
namespace DysonNetwork.Sphere.Account;
public class AccountGrpcService : DysonNetwork.Sphere.Account.Proto.AccountService.AccountServiceBase
{
private readonly AppDatabase _db;
private readonly AuthService _auth;
public AccountGrpcService(AppDatabase db, AuthService auth)
{
_db = db;
_auth = auth;
}
public override async Task<AccountResponse> GetAccount(Empty request, ServerCallContext context)
{
var account = await GetAccountFromContext(context);
return ToAccountResponse(account);
}
public override async Task<AccountResponse> UpdateAccount(UpdateAccountRequest request, ServerCallContext context)
{
var account = await GetAccountFromContext(context);
if (request.Email != null)
{
var emailContact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.AccountId == account.Id && c.Type == AccountContactType.Email);
if (emailContact != null)
{
emailContact.Content = request.Email;
}
else
{
account.Contacts.Add(new AccountContact { Type = AccountContactType.Email, Content = request.Email });
}
}
if (request.DisplayName != null)
{
account.Nick = request.DisplayName;
}
await _db.SaveChangesAsync();
return ToAccountResponse(account);
}
private async Task<Shared.Models.Account> GetAccountFromContext(ServerCallContext context)
{
var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization");
if (authorizationHeader == null)
{
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Missing authorization header."));
}
var token = authorizationHeader.Value.Replace("Bearer ", "");
if (!_auth.ValidateToken(token, out var sessionId))
{
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid token."));
}
var session = await _db.AuthSessions.Include(s => s.Account).ThenInclude(a => a.Contacts).FirstOrDefaultAsync(s => s.Id == sessionId);
if (session == null)
{
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Session not found."));
}
return session.Account;
}
private AccountResponse ToAccountResponse(Shared.Models.Account account)
{
var emailContact = account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
return new AccountResponse
{
Id = account.Id.ToString(),
Username = account.Name,
Email = emailContact?.Content ?? "",
DisplayName = account.Nick
};
}
}

View File

@ -1,309 +0,0 @@
using System.Text;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Connection;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class NotificationService(
AppDatabase db,
WebSocketService ws,
IHttpClientFactory httpFactory,
IConfiguration config)
{
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(
Shared.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(
Shared.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)
{
ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
{
Type = "notifications.new",
Data = 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);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
var subscribers = await db.NotificationPushSubscriptions
.ToListAsync();
await _PushNotification(notification, subscribers);
}
public async Task SendNotificationBatch(Notification notification, List<Shared.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);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
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();
}
}

View File

@ -13,7 +13,8 @@ public class ActivityService(
PublisherService pub, PublisherService pub,
RelationshipService rels, RelationshipService rels,
PostService ps, PostService ps,
DiscoveryService ds) DiscoveryService ds
)
{ {
private static double CalculateHotRank(Post.Post post, Instant now) private static double CalculateHotRank(Post.Post post, Instant now)
{ {

View File

@ -1,22 +1,12 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Sticker; using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query;
using NodaTime; using NodaTime;
using Npgsql;
using Quartz; using Quartz;
namespace DysonNetwork.Sphere; namespace DysonNetwork.Sphere;
@ -33,28 +23,6 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Shared.Models.Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Status> AccountStatuses { get; set; }
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Badge> Badges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; }
public DbSet<CloudFile> Files { get; set; } public DbSet<CloudFile> Files { get; set; }
public DbSet<CloudFileReference> FileReferences { get; set; } public DbSet<CloudFileReference> FileReferences { get; set; }
@ -104,39 +72,7 @@ public class AppDatabase(
.UseNetTopologySuite() .UseNetTopologySuite()
.UseNodaTime() .UseNodaTime()
).UseSnakeCaseNamingConvention(); ).UseSnakeCaseNamingConvention();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var defaultPermissionGroup = await context.Set<PermissionGroup>()
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
if (defaultPermissionGroup is null)
{
context.Set<PermissionGroup>().Add(new PermissionGroup
{
Key = "default",
Nodes = new List<string>
{
"posts.create",
"posts.react",
"publishers.create",
"files.create",
"chat.create",
"chat.messages.create",
"chat.realtime.create",
"accounts.statuses.create",
"accounts.statuses.update",
"stickers.packs.create",
"stickers.create"
}.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true))
.ToList()
});
await context.SaveChangesAsync(cancellationToken);
}
});
optionsBuilder.UseSeeding((context, _) => {});
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
} }
@ -144,25 +80,6 @@ public class AppDatabase(
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<PublisherMember>() modelBuilder.Entity<PublisherMember>()
.HasKey(pm => new { pm.PublisherId, pm.AccountId }); .HasKey(pm => new { pm.PublisherId, pm.AccountId });
modelBuilder.Entity<PublisherMember>() modelBuilder.Entity<PublisherMember>()
@ -333,23 +250,9 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin
{ {
public async Task Execute(IJobExecutionContext context) public async Task Execute(IJobExecutionContext context)
{ {
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Cleaning up expired records...");
// Expired relationships
var affectedRows = await db.AccountRelationships
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
// Expired permission group members
affectedRows = await db.PermissionGroupMembers
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
logger.LogInformation("Deleting soft-deleted records..."); logger.LogInformation("Deleting soft-deleted records...");
var now = SystemClock.Instance.GetCurrentInstant();
var threshold = now - Duration.FromDays(7); var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes() var entityTypes = db.Model.GetEntityTypes()

View File

@ -1,3 +1,4 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;

View File

@ -4,6 +4,7 @@ using Livekit.Server.Sdk.Dotnet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Chat.Realtime; namespace DysonNetwork.Sphere.Chat.Realtime;

View File

@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using AngleSharp; using AngleSharp;
using AngleSharp.Dom; using AngleSharp.Dom;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using HtmlAgilityPack; using HtmlAgilityPack;

View File

@ -83,6 +83,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Auth\" />
<Folder Include="Discovery\" /> <Folder Include="Discovery\" />
</ItemGroup> </ItemGroup>
@ -164,7 +165,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.65.0" /> <PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,4 +1,5 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;

View File

@ -1,3 +1,4 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;

View File

@ -1,3 +1,4 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;

View File

@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis; using StackExchange.Redis;
using System.Text.Json; using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OidcProvider.Options; using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Connection.WebReader;

View File

@ -1,3 +1,4 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;

View File

@ -1,3 +1,4 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;

View File

@ -2,6 +2,7 @@ using System.Globalization;
using FFMpegCore; using FFMpegCore;
using System.Security.Cryptography; using System.Security.Cryptography;
using AngleSharp.Text; using AngleSharp.Text;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Minio; using Minio;

View File

@ -1,3 +1,4 @@
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Quartz; using Quartz;

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;