♻️ Moving to MagicOnion

This commit is contained in:
2025-07-07 21:54:51 +08:00
parent 1672d46038
commit 8d2f4a4c47
41 changed files with 790 additions and 530 deletions

View File

@ -1,7 +1,9 @@
using System.Globalization;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using MagicOnion.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using NodaTime;
@ -9,11 +11,9 @@ namespace DysonNetwork.Pass.Account;
public class AccountEventService(
AppDatabase db,
// WebSocketService ws,
// ICacheService cache,
// PaymentService payment,
ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer
)
) : ServiceBase<IAccountEventService>, IAccountEventService
{
private static readonly Random Random = new();
private const string StatusCacheKey = "AccountStatus_";
@ -21,18 +21,18 @@ public class AccountEventService(
public void PurgeStatusCache(Guid userId)
{
var cacheKey = $"{StatusCacheKey}{userId}";
// cache.RemoveAsync(cacheKey);
cache.RemoveAsync(cacheKey);
}
public async Task<Status> GetStatus(Guid userId)
{
var cacheKey = $"{StatusCacheKey}{userId}";
// var cachedStatus = await cache.GetAsync<Status>(cacheKey);
// if (cachedStatus is not null)
// {
// cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
// return cachedStatus;
// }
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus is not null)
{
cachedStatus!.IsOnline = !cachedStatus.IsInvisible /*&& ws.GetAccountIsConnected(userId)*/;
return cachedStatus;
}
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
@ -45,8 +45,12 @@ public class AccountEventService(
if (status is not null)
{
status.IsOnline = !status.IsInvisible && isOnline;
// await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
// TimeSpan.FromMinutes(5));
await cache.SetWithGroupsAsync(
cacheKey,
status,
[$"{AccountService.AccountCachePrefix}{status.AccountId}"],
TimeSpan.FromMinutes(5)
);
return status;
}
@ -62,7 +66,7 @@ public class AccountEventService(
};
}
return new Status
return new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = false,
@ -88,7 +92,7 @@ public class AccountEventService(
// }
// else
// {
cacheMissUserIds.Add(userId);
cacheMissUserIds.Add(userId);
// }
}
@ -192,27 +196,28 @@ public class AccountEventService(
return lastDate < currentDate;
}
public const string CheckInLockKey = "CheckInLock_";
private const string CheckInLockKey = "checkin-lock:";
public async Task<CheckInResult> CheckInDaily(Shared.Models.Account user)
{
var lockKey = $"{CheckInLockKey}{user.Id}";
try
{
// var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
// if (lk != null)
// await lk.ReleaseAsync();
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
if (lk != null)
await lk.ReleaseAsync();
}
catch
{
// Ignore errors from this pre-check
}
// Now try to acquire the lock properly
// await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
// if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
await using var lockObj =
await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
var cultureInfo = new CultureInfo(user.Language, false);
CultureInfo.CurrentCulture = cultureInfo;
@ -274,12 +279,53 @@ public class AccountEventService(
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
);
db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Don't forget to save changes to the database
await db.SaveChangesAsync(); // Remember to save changes to the database
// The lock will be automatically released by the await using statement
return result;
}
public async Task<int> GetCheckInStreak(Shared.Models.Account user)
{
var today = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
var yesterdayEnd = today.PlusDays(-1).AtMidnight().InUtc().ToInstant();
var yesterdayStart = today.PlusDays(-1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var tomorrowEnd = today.PlusDays(1).AtMidnight().InUtc().ToInstant();
var tomorrowStart = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var yesterdayResult = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.Where(x => x.CreatedAt >= yesterdayStart)
.Where(x => x.CreatedAt < yesterdayEnd)
.FirstOrDefaultAsync();
var tomorrowResult = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.Where(x => x.CreatedAt >= tomorrowStart)
.Where(x => x.CreatedAt < tomorrowEnd)
.FirstOrDefaultAsync();
if (yesterdayResult is null && tomorrowResult is null)
return 1;
var results = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync();
var streak = 0;
var day = today;
while (results.Any(x =>
x.CreatedAt >= day.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant() &&
x.CreatedAt < day.AtMidnight().InUtc().ToInstant()))
{
streak++;
day = day.PlusDays(-1);
}
return streak;
}
public async Task<List<DailyEventResponse>> GetEventCalendar(Shared.Models.Account user, int month, int year = 0,
bool replaceInvisible = false)
{

View File

@ -1,61 +0,0 @@
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,14 +1,16 @@
using System.Globalization;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Shared.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using OtpNet;
using Microsoft.Extensions.Logging;
using EFCore.BulkExtensions;
using MagicOnion.Server;
namespace DysonNetwork.Pass.Account;
@ -21,7 +23,7 @@ public class AccountService(
// IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
)
) : ServiceBase<IAccountService>, IAccountService
{
public static void SetCultureInfo(Shared.Models.Account account)
{

View File

@ -1,4 +1,6 @@
using System.Text.RegularExpressions;
using DysonNetwork.Shared.Services;
using MagicOnion.Server;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Account;
@ -6,7 +8,7 @@ namespace DysonNetwork.Pass.Account;
/// <summary>
/// Service for handling username generation and validation
/// </summary>
public class AccountUsernameService(AppDatabase db)
public class AccountUsernameService(AppDatabase db) : ServiceBase<IAccountUsernameService>, IAccountUsernameService
{
private readonly Random _random = new();

View File

@ -1,13 +1,24 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using MagicOnion.Server;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Pass.Account;
public class ActionLogService(
// GeoIpService geo,
// FlushBufferService fbs
)
public class ActionLogService : ServiceBase<IActionLogService>, IActionLogService
{
// private readonly GeoIpService _geo;
// private readonly FlushBufferService _fbs;
public ActionLogService(
// GeoIpService geo,
// FlushBufferService fbs
)
{
// _geo = geo;
// _fbs = fbs;
}
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
{
var log = new ActionLog

View File

@ -1,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public enum MagicSpellType
{
AccountActivation,
AccountDeactivation,
AccountRemoval,
AuthPasswordReset,
ContactVerification,
}
[Index(nameof(Spell), IsUnique = true)]
public class MagicSpell : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[JsonIgnore] [MaxLength(1024)] public string Spell { get; set; } = null!;
public MagicSpellType Type { get; set; }
public Instant? ExpiresAt { get; set; }
public Instant? AffectedAt { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public Guid? AccountId { get; set; }
public Shared.Models.Account? Account { get; set; }
}

View File

@ -2,6 +2,8 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using MagicOnion.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
@ -12,11 +14,9 @@ namespace DysonNetwork.Pass.Account;
public class MagicSpellService(
AppDatabase db,
// EmailService email,
IConfiguration configuration,
ILogger<MagicSpellService> logger
// IStringLocalizer<Localization.EmailResource> localizer
)
) : ServiceBase<IMagicSpellService>, IMagicSpellService
{
public async Task<MagicSpell> CreateMagicSpell(
Shared.Models.Account account,
@ -59,6 +59,17 @@ public class MagicSpellService(
return spell;
}
public async Task<MagicSpell?> GetMagicSpellAsync(string token)
{
var now = SystemClock.Instance.GetCurrentInstant();
var spell = await db.MagicSpells
.Where(s => s.Spell == token)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
return spell;
}
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
{
var contact = await db.AccountContacts
@ -144,8 +155,15 @@ public class MagicSpellService(
}
}
public async Task ApplyMagicSpell(MagicSpell spell)
public async Task ApplyMagicSpell(string token)
{
var now = SystemClock.Instance.GetCurrentInstant();
var spell = await db.MagicSpells
.Where(s => s.Spell == token)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
if (spell is null) throw new ArgumentException("Magic spell not found.");
switch (spell.Type)
{
case MagicSpellType.AuthPasswordReset:

View File

@ -1,29 +1,29 @@
using System.Text;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using DysonNetwork.Shared.Services;
using EFCore.BulkExtensions;
using MagicOnion.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Net.Http;
namespace DysonNetwork.Pass.Account;
public class NotificationService(
AppDatabase db
// WebSocketService ws,
// IHttpClientFactory httpFactory,
// IConfiguration config
)
AppDatabase db,
IConfiguration config,
IHttpClientFactory httpFactory
) : ServiceBase<INotificationService>, INotificationService
{
// private readonly string _notifyTopic = config["Notifications:Topic"]!;
// private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
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();
await db.NotificationPushSubscriptions
.Where(s => s.DeviceId == deviceId)
.ExecuteDeleteAsync();
}
public async Task<NotificationPushSubscription> SubscribePushNotification(
@ -34,29 +34,29 @@ public class NotificationService(
)
{
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();
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));
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;
// }
// Return the updated subscription
existingSubscription.DeviceId = deviceId;
existingSubscription.DeviceToken = deviceToken;
existingSubscription.UpdatedAt = now;
return existingSubscription;
}
var subscription = new NotificationPushSubscription
{
@ -102,11 +102,12 @@ public class NotificationService(
if (save)
{
// db.Add(notification);
// await db.SaveChangesAsync();
db.Add(notification);
await db.SaveChangesAsync();
}
if (!isSilent) Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification);
if (!isSilent)
Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification);
return notification;
}
@ -120,11 +121,11 @@ public class NotificationService(
// });
// Pushing the notification
// var subscribers = await db.NotificationPushSubscriptions
// .Where(s => s.AccountId == notification.AccountId)
// .ToListAsync();
var subscribers = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == notification.AccountId)
.ToListAsync();
// await _PushNotification(notification, subscribers);
await _PushNotification(notification, subscribers);
}
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
@ -174,12 +175,13 @@ public class NotificationService(
// });
}
// var subscribers = await db.NotificationPushSubscriptions
// .ToListAsync();
// await _PushNotification(notification, subscribers);
var subscribers = await db.NotificationPushSubscriptions
.ToListAsync();
await _PushNotification(notification, subscribers);
}
public async Task SendNotificationBatch(Notification notification, List<Shared.Models.Account> accounts, bool save = false)
public async Task SendNotificationBatch(Notification notification, List<Shared.Models.Account> accounts,
bool save = false)
{
if (save)
{
@ -198,7 +200,7 @@ public class NotificationService(
};
return newNotification;
}).ToList();
// await db.BulkInsertAsync(notifications);
await db.BulkInsertAsync(notifications);
}
foreach (var account in accounts)
@ -219,93 +221,93 @@ public class NotificationService(
// 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());
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 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();
var tokens = value.Value.Select(x => x.DeviceToken).ToList();
return _BuildNotificationPayload(notification, platformCode, tokens);
}).ToList();
// return notifications.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",
// };
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.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.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 (!string.IsNullOrWhiteSpace(notification.Subtitle))
{
dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
alertDict["subtitle"] = notification.Subtitle;
}
// if (notification.Priority >= 5)
// dict["name"] = "default";
if (notification.Priority >= 5)
dict["name"] = "default";
// dict["platform"] = platformCode;
// dict["alert"] = alertDict;
dict["platform"] = platformCode;
dict["alert"] = alertDict;
// return dict;
// }
return dict;
}
// private async Task _PushNotification(Notification notification,
// IEnumerable<NotificationPushSubscription> subscriptions)
// {
// var subList = subscriptions.ToList();
// if (subList.Count == 0) return;
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 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();
// }
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

@ -1,13 +1,13 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using MagicOnion.Server;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public class RelationshipService(
AppDatabase db
// ICacheService cache
)
public class RelationshipService(AppDatabase db, ICacheService cache) : ServiceBase<IRelationshipService>, IRelationshipService
{
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
@ -150,7 +150,7 @@ public class RelationshipService(
db.Update(relationship);
await db.SaveChangesAsync();
// await PurgeRelationshipCache(accountId, relatedId);
await PurgeRelationshipCache(accountId, relatedId);
return relationship;
}
@ -158,8 +158,7 @@ public class RelationshipService(
public async Task<List<Guid>> ListAccountFriends(Shared.Models.Account account)
{
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
// var friends = await cache.GetAsync<List<Guid>>(cacheKey);
var friends = new List<Guid>(); // Placeholder
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null)
{
@ -169,17 +168,16 @@ public class RelationshipService(
.Select(r => r.AccountId)
.ToListAsync();
// await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
}
return friends ?? [];
return friends;
}
public async Task<List<Guid>> ListAccountBlocked(Shared.Models.Account account)
{
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
// var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
var blocked = new List<Guid>(); // Placeholder
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
if (blocked == null)
{
@ -189,10 +187,10 @@ public class RelationshipService(
.Select(r => r.AccountId)
.ToListAsync();
// await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
}
return blocked ?? [];
return blocked;
}
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
@ -204,9 +202,9 @@ public class RelationshipService(
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
{
// await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
// await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
// await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
// await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
}
}

View File

@ -1,96 +0,0 @@
using DysonNetwork.Shared.Protos.Auth;
using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Account;
using Challenge = DysonNetwork.Shared.Models.Challenge;
using Session = DysonNetwork.Shared.Models.Session;
namespace DysonNetwork.Pass.Auth;
public class AuthGrpcService(AppDatabase db, AccountService accounts, AuthService auth)
: DysonNetwork.Shared.Protos.Auth.AuthService.AuthServiceBase
{
public override async Task<LoginResponse> Login(LoginRequest request, ServerCallContext context)
{
var account = await accounts.LookupAccount(request.Username);
if (account == null)
{
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found."));
}
var factor = await db.AccountAuthFactors.FirstOrDefaultAsync(f => f.AccountId == account.Id && f.Type == AccountAuthFactorType.Password);
if (factor == null || !factor.VerifyPassword(request.Password))
{
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid credentials."));
}
var session = new Session
{
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
Account = account,
Challenge = new Challenge()
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
var token = auth.CreateToken(session);
return new LoginResponse
{
AccessToken = token,
ExpiresIn = (long)(session.ExpiredAt.Value - session.LastGrantedAt.Value).TotalSeconds
};
}
public override async Task<IntrospectionResponse> IntrospectToken(IntrospectTokenRequest request, ServerCallContext context)
{
if (auth.ValidateToken(request.Token, out var sessionId))
{
var session = await db.AuthSessions
.Include(s => s.Account)
.Include(s => s.Challenge)
.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session != null)
{
return new IntrospectionResponse
{
Active = true,
Claims = JsonSerializer.Serialize(new { sub = session.AccountId }),
ClientId = session.AppId?.ToString() ?? "",
Username = session.Account.Name,
Scope = string.Join(" ", session.Challenge.Scopes),
Iat = Timestamp.FromDateTime(session.CreatedAt.ToDateTimeUtc()),
Exp = Timestamp.FromDateTime(session.ExpiredAt?.ToDateTimeUtc() ?? DateTime.MaxValue)
};
}
}
return new IntrospectionResponse { Active = false };
}
public override async Task<Empty> Logout(Empty request, ServerCallContext context)
{
var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization");
if (authorizationHeader != null)
{
var token = authorizationHeader.Value.Replace("Bearer ", "");
if (auth.ValidateToken(token, out var sessionId))
{
var session = await db.AuthSessions.FindAsync(sessionId);
if (session != null)
{
db.AuthSessions.Remove(session);
await db.SaveChangesAsync();
}
}
}
return new Empty();
}
}

View File

@ -1,6 +1,6 @@
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

View File

@ -1,8 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OpenId;
namespace DysonNetwork.Pass.Auth.OpenId;
public class AppleMobileConnectRequest
{

View File

@ -4,7 +4,7 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;

View File

@ -1,7 +1,6 @@
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

View File

@ -1,7 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Pass.Auth.OpenId;

View File

@ -1,7 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Pass.Auth.OpenId;

View File

@ -1,7 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;

View File

@ -1,7 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Pass.Auth.OpenId;

View File

@ -1,7 +1,6 @@
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

View File

@ -3,7 +3,6 @@ using System.Net.Http.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

View File

@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OpenId;
namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary>
/// Represents the state parameter used in OpenID Connect flows.

View File

@ -1,49 +0,0 @@
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Represents the user information from an OIDC provider
/// </summary>
public class OidcUserInfo
{
public string? UserId { get; set; }
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string PreferredUsername { get; set; } = "";
public string? ProfilePictureUrl { get; set; }
public string Provider { get; set; } = "";
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public Dictionary<string, object> ToMetadata()
{
var metadata = new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(UserId))
metadata["user_id"] = UserId;
if (!string.IsNullOrWhiteSpace(Email))
metadata["email"] = Email;
metadata["email_verified"] = EmailVerified;
if (!string.IsNullOrWhiteSpace(FirstName))
metadata["first_name"] = FirstName;
if (!string.IsNullOrWhiteSpace(LastName))
metadata["last_name"] = LastName;
if (!string.IsNullOrWhiteSpace(DisplayName))
metadata["display_name"] = DisplayName;
if (!string.IsNullOrWhiteSpace(PreferredUsername))
metadata["preferred_username"] = PreferredUsername;
if (!string.IsNullOrWhiteSpace(ProfilePictureUrl))
metadata["profile_picture_url"] = ProfilePictureUrl;
return metadata;
}
}

View File

@ -9,24 +9,27 @@
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageReference Include="MagicOnion.Client" Version="7.0.5" />
<PackageReference Include="MagicOnion.Server" Version="7.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<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="StackExchange.Redis" Version="2.8.41" />
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" 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.AspNetCore.Mvc.Core" Version="2.3.0" />
<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="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
</ItemGroup>

View File

@ -1,6 +1,8 @@
using DysonNetwork.Pass;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Startup;
using DysonNetwork.Shared.Startup;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@ -8,9 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.ConfigureAppKestrel();
builder.Services.AddAppSwagger();
builder.Services.AddAppAuthentication();
builder.Services.AddAppRateLimiting();
builder.Services.AddAppBusinessServices(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddGrpc();
builder.Services.AddMagicOnion();
builder.Services.AddDbContext<AppDatabase>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("App")));
@ -21,10 +29,10 @@ var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseAuthorization();
app.ConfigureAppMiddleware(builder.Configuration);
app.MapControllers();
app.MapGrpcService<AccountGrpcService>();
app.MapGrpcService<AuthGrpcService>();
app.MapMagicOnionService();
// Run database migrations
using (var scope = app.Services.CreateScope())

View File

@ -0,0 +1,186 @@
using System.Globalization;
using System.Text.Json;
using System.Threading.RateLimiting;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis;
namespace DysonNetwork.Pass.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>();
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var connection = configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection);
});
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient();
// Register MagicOnion services
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IRelationshipService, RelationshipService>();
services.AddScoped<IActionLogService, ActionLogService>();
services.AddScoped<IAccountUsernameService, AccountUsernameService>();
services.AddScoped<IMagicSpellService, MagicSpellService>();
services.AddScoped<IAccountEventService, AccountEventService>();
// Register OIDC services
services.AddScoped<OidcService, GoogleOidcService>();
services.AddScoped<OidcService, AppleOidcService>();
services.AddScoped<OidcService, GitHubOidcService>();
services.AddScoped<OidcService, MicrosoftOidcService>();
services.AddScoped<OidcService, DiscordOidcService>();
services.AddScoped<OidcService, AfdianOidcService>();
services.AddScoped<GoogleOidcService>();
services.AddScoped<AppleOidcService>();
services.AddScoped<GitHubOidcService>();
services.AddScoped<MicrosoftOidcService>();
services.AddScoped<DiscordOidcService>();
services.AddScoped<AfdianOidcService>();
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}).AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
});
services.AddRazorPages();
services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[]
{
new CultureInfo("en-US"),
new CultureInfo("zh-Hans"),
};
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
});
return services;
}
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddCors();
services.AddAuthorization();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
options.DefaultChallengeScheme = AuthConstants.SchemeName;
})
.AddScheme<DysonTokenAuthOptions, DysonTokenAuthHandler>(AuthConstants.SchemeName, _ => { });
return services;
}
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "Solar Network API",
Description = "An open-source social network",
TermsOfService = new Uri("https://solsynth.dev/terms"),
License = new OpenApiLicense
{
Name = "APGLv3",
Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html")
}
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Please enter a valid token",
Name = "Authorization",
Type = SecuritySchemeType.Http,
BearerFormat = "JWT",
Scheme = "Bearer"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
[]
}
});
});
services.AddOpenApi();
return services;
}
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services,
IConfiguration configuration)
{
services.AddScoped<CompactTokenService>();
services.AddScoped<PermissionService>();
services.AddScoped<ActionLogService>();
services.AddScoped<AccountService>();
services.AddScoped<AccountEventService>();
services.AddScoped<ActionLogService>();
services.AddScoped<RelationshipService>();
services.AddScoped<MagicSpellService>();
services.AddScoped<NotificationService>();
services.AddScoped<AuthService>();
services.AddScoped<AccountUsernameService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>();
return services;
}
}