♻️ 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,7 +196,7 @@ 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)
{
@ -200,10 +204,10 @@ public class AccountEventService(
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)
// await lk.ReleaseAsync();
if (lk != null)
await lk.ReleaseAsync();
}
catch
{
@ -211,8 +215,9 @@ public class AccountEventService(
}
// 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

@ -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(
@ -36,27 +36,27 @@ 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

@ -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;
}
}

View File

@ -12,13 +12,13 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Grpc.Net.Client" Version="2.65.0" />
<PackageReference Include="Google.Protobuf" Version="3.27.2" />
<PackageReference Include="Grpc.Tools" Version="2.65.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MagicOnion.Client" Version="7.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.2.2" />
@ -27,4 +27,10 @@
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore">
<HintPath>..\..\..\..\..\..\opt\homebrew\Cellar\dotnet\9.0.6\libexec\shared\Microsoft.AspNetCore.App\9.0.6\Microsoft.AspNetCore.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

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

View File

@ -1,4 +1,4 @@
namespace DysonNetwork.Sphere.Auth.OpenId;
namespace DysonNetwork.Shared.Models;
/// <summary>
/// Represents the user information from an OIDC provider

View File

@ -1,51 +0,0 @@
syntax = "proto3";
package dyson_network.sphere.account;
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
option csharp_namespace = "DysonNetwork.Shared.Protos.Account";
service AccountService {
rpc GetAccount(google.protobuf.Empty) returns (AccountResponse);
rpc UpdateAccount(UpdateAccountRequest) returns (AccountResponse);
}
message AccountResponse {
string id = 1;
string name = 2;
string nick = 3;
string language = 4;
google.protobuf.Timestamp activated_at = 5;
bool is_superuser = 6;
Profile profile = 7;
}
message Profile {
string first_name = 1;
string last_name = 2;
string bio = 3;
string gender = 4;
string pronouns = 5;
string time_zone = 6;
string location = 7;
google.protobuf.Timestamp birthday = 8;
google.protobuf.Timestamp last_seen_at = 9;
int32 experience = 10;
int32 level = 11;
double leveling_progress = 12;
}
message UpdateAccountRequest {
optional string nick = 1;
optional string language = 2;
optional string first_name = 3;
optional string last_name = 4;
optional string bio = 5;
optional string gender = 6;
optional string pronouns = 7;
optional string time_zone = 8;
optional string location = 9;
optional google.protobuf.Timestamp birthday = 10;
}

View File

@ -1,69 +0,0 @@
syntax = "proto3";
package dyson_network.sphere.auth;
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
option csharp_namespace = "DysonNetwork.Shared.Protos.Auth";
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc IntrospectToken(IntrospectTokenRequest) returns (IntrospectionResponse);
rpc Logout(google.protobuf.Empty) returns (google.protobuf.Empty);
}
message LoginRequest {
string username = 1;
string password = 2;
optional string two_factor_code = 3;
}
message LoginResponse {
string access_token = 1;
string refresh_token = 2;
int64 expires_in = 3;
}
message IntrospectTokenRequest {
string token = 1;
optional string token_type_hint = 2;
}
message IntrospectionResponse {
bool active = 1;
string claims = 2;
string client_id = 3;
string username = 4;
string scope = 5;
google.protobuf.Timestamp iat = 6;
google.protobuf.Timestamp exp = 7;
}
message Session {
string id = 1;
string label = 2;
google.protobuf.Timestamp last_granted_at = 3;
google.protobuf.Timestamp expired_at = 4;
string account_id = 5;
string challenge_id = 6;
optional string app_id = 7;
}
message Challenge {
string id = 1;
google.protobuf.Timestamp expired_at = 2;
int32 step_remain = 3;
int32 step_total = 4;
int32 failed_attempts = 5;
string platform = 6;
string type = 7;
repeated string blacklist_factors = 8;
repeated string audiences = 9;
repeated string scopes = 10;
string ip_address = 11;
string user_agent = 12;
optional string device_id = 13;
optional string nonce = 14;
string account_id = 15;
}

View File

@ -0,0 +1,28 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using NodaTime;
namespace DysonNetwork.Shared.Services;
public interface IAccountEventService : IService<IAccountEventService>
{
/// <summary>
/// Purges the status cache for a user
/// </summary>
void PurgeStatusCache(Guid userId);
/// <summary>
/// Gets the status of a user
/// </summary>
Task<Status> GetStatus(Guid userId);
/// <summary>
/// Performs a daily check-in for a user
/// </summary>
Task<CheckInResult> CheckInDaily(Account user);
/// <summary>
/// Gets the check-in streak for a user
/// </summary>
Task<int> GetCheckInStreak(Account user);
}

View File

@ -0,0 +1,62 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
namespace DysonNetwork.Shared.Services;
public interface IAccountService : IService<IAccountService>
{
/// <summary>
/// Removes all cached data for the specified account
/// </summary>
Task PurgeAccountCache(Account account);
/// <summary>
/// Looks up an account by username or contact information
/// </summary>
/// <param name="probe">Username or contact information to search for</param>
/// <returns>The matching account if found, otherwise null</returns>
Task<Account?> LookupAccount(string probe);
/// <summary>
/// Looks up an account by external authentication provider connection
/// </summary>
/// <param name="identifier">The provider's unique identifier for the user</param>
/// <param name="provider">The name of the authentication provider</param>
/// <returns>The matching account if found, otherwise null</returns>
Task<Account?> LookupAccountByConnection(string identifier, string provider);
/// <summary>
/// Gets the account level for the specified account ID
/// </summary>
/// <param name="accountId">The ID of the account</param>
/// <returns>The account level if found, otherwise null</returns>
Task<int?> GetAccountLevel(Guid accountId);
/// <summary>
/// Creates a new account with the specified details
/// </summary>
/// <param name="name">The account username</param>
/// <param name="nick">The display name/nickname</param>
/// <param name="email">The primary email address</param>
/// <param name="password">The account password (optional, can be set later)</param>
/// <param name="language">The preferred language (defaults to en-US)</param>
/// <param name="isEmailVerified">Whether the email is verified (defaults to false)</param>
/// <param name="isActivated">Whether the account is activated (defaults to false)</param>
/// <returns>The newly created account</returns>
Task<Account> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
bool isEmailVerified = false,
bool isActivated = false
);
/// <summary>
/// Creates a new account using OpenID Connect user information
/// </summary>
/// <param name="userInfo">The OpenID Connect user information</param>
/// <returns>The newly created account</returns>
Task<Account> CreateAccount(OidcUserInfo userInfo);
}

View File

@ -0,0 +1,23 @@
using MagicOnion;
namespace DysonNetwork.Shared.Services;
public interface IAccountUsernameService : IService<IAccountUsernameService>
{
/// <summary>
/// Generates a unique username based on the provided base name
/// </summary>
/// <param name="baseName">The preferred username</param>
/// <returns>A unique username</returns>
Task<string> GenerateUniqueUsernameAsync(string baseName);
/// <summary>
/// Checks if a username already exists
/// </summary>
Task<bool> IsUsernameExistsAsync(string username);
/// <summary>
/// Sanitizes a username to remove invalid characters
/// </summary>
string SanitizeUsername(string username);
}

View File

@ -0,0 +1,24 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using MagicOnion;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Shared.Services;
public interface IActionLogService : IService<IActionLogService>
{
/// <summary>
/// Creates an action log entry
/// </summary>
void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta);
/// <summary>
/// Creates an action log entry from an HTTP request
/// </summary>
void CreateActionLogFromRequest(
string action,
Dictionary<string, object> meta,
HttpRequest request,
Account? account = null
);
}

View File

@ -0,0 +1,30 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using NodaTime;
namespace DysonNetwork.Shared.Services;
public interface IMagicSpellService : IService<IMagicSpellService>
{
/// <summary>
/// Creates a new magic spell
/// </summary>
Task<MagicSpell> CreateMagicSpell(
Account account,
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
Instant? affectedAt = null,
bool preventRepeat = false
);
/// <summary>
/// Gets a magic spell by its token
/// </summary>
Task<MagicSpell?> GetMagicSpellAsync(string token);
/// <summary>
/// Consumes a magic spell
/// </summary>
Task ApplyMagicSpell(string token);
}

View File

@ -0,0 +1,23 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
namespace DysonNetwork.Shared.Services;
public interface INotificationService : IService<INotificationService>
{
/// <summary>
/// Unsubscribes a device from push notifications
/// </summary>
/// <param name="deviceId">The device ID to unsubscribe</param>
Task UnsubscribePushNotifications(string deviceId);
/// <summary>
/// Subscribes a device to push notifications
/// </summary>
Task<NotificationPushSubscription> SubscribePushNotification(
Account account,
NotificationPushProvider provider,
string deviceId,
string deviceToken
);
}

View File

@ -0,0 +1,27 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
namespace DysonNetwork.Shared.Services;
public interface IRelationshipService : IService<IRelationshipService>
{
/// <summary>
/// Checks if a relationship exists between two accounts
/// </summary>
Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId);
/// <summary>
/// Gets a relationship between two accounts
/// </summary>
Task<Relationship?> GetRelationship(
Guid accountId,
Guid relatedId,
RelationshipStatus? status = null,
bool ignoreExpired = false
);
/// <summary>
/// Creates a new relationship between two accounts
/// </summary>
Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status);
}

View File

@ -0,0 +1,60 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Shared.Startup;
public static class ApplicationConfiguration
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
{
app.MapOpenApi();
app.UseRequestLocalization();
ConfigureForwardedHeaders(app, configuration);
app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true)
.WithExposedHeaders("*")
.WithHeaders()
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod()
);
app.UseWebSockets();
app.UseRateLimiter();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers().RequireRateLimiting("fixed");
app.MapStaticAssets().RequireRateLimiting("fixed");
app.MapRazorPages().RequireRateLimiting("fixed");
return app;
}
private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration)
{
var knownProxiesSection = configuration.GetSection("KnownProxies");
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
if (knownProxiesSection.Exists())
{
var proxyAddresses = knownProxiesSection.Get<string[]>();
if (proxyAddresses != null)
foreach (var proxy in proxyAddresses)
if (IPAddress.TryParse(proxy, out var ipAddress))
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
}
else
{
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
}
app.UseForwardedHeaders(forwardedHeadersOptions);
}
}

View File

@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace DysonNetwork.Shared.Startup;
public static class KestrelConfiguration
{
public static WebApplicationBuilder ConfigureAppKestrel(this WebApplicationBuilder builder)
{
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
});
return builder;
}
}

View File

@ -25,9 +25,11 @@
<PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" />
<PackageReference Include="MagicOnion.Client" Version="7.0.5" />
<PackageReference Include="MagicOnion.Server" Version="7.0.5" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
@ -70,7 +72,7 @@
<PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="2.88.9" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.1" />
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.6" />
<PackageReference Include="tusdotnet" Version="2.8.1" />
@ -83,7 +85,6 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Auth\" />
<Folder Include="Discovery\" />
</ItemGroup>
@ -164,10 +165,6 @@
<_ContentIncludedByDefault Remove="app\publish\package.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\auth.proto" GrpcServices="Server" />
<Protobuf Include="Protos\account.proto" GrpcServices="Server" />

View File

@ -1,5 +1,4 @@
using System.Net;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.HttpOverrides;
using Prometheus;
@ -35,7 +34,6 @@ public static class ApplicationConfiguration
app.UseRateLimiter();
app.UseHttpsRedirection();
app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>();
app.MapControllers().RequireRateLimiting("fixed");
app.MapStaticAssets().RequireRateLimiting("fixed");

View File

@ -1,15 +1,11 @@
using System.Globalization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Connection.Handlers;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
@ -25,8 +21,6 @@ using StackExchange.Redis;
using System.Text.Json;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Discovery;
@ -54,20 +48,6 @@ public static class ServiceCollectionExtensions
services.AddHttpClient();
// 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;
@ -113,12 +93,12 @@ public static class ServiceCollectionExtensions
{
services.AddCors();
services.AddAuthorization();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
options.DefaultChallengeScheme = AuthConstants.SchemeName;
})
.AddScheme<DysonTokenAuthOptions, DysonTokenAuthHandler>(AuthConstants.SchemeName, _ => { });
// services.AddAuthentication(options =>
// {
// options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
// options.DefaultChallengeScheme = AuthConstants.SchemeName;
// })
// .AddScheme<DysonTokenAuthOptions, DysonTokenAuthHandler>(AuthConstants.SchemeName, _ => { });
return services;
}
@ -200,22 +180,11 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services,
IConfiguration configuration)
{
services.AddScoped<CompactTokenService>();
services.AddScoped<RazorViewRenderer>();
services.Configure<GeoIpOptions>(configuration.GetSection("GeoIP"));
services.AddScoped<GeoIpService>();
services.AddScoped<WebSocketService>();
services.AddScoped<EmailService>();
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.AddScoped<FileService>();
services.AddScoped<FileReferenceService>();
services.AddScoped<FileReferenceMigrationService>();
@ -238,9 +207,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<DiscoveryService>();
services.AddScoped<CustomAppService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>();
return services;
}
}

View File

@ -46,6 +46,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInternalServerError_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003Fb5_003Fc55acdd2_003FInternalServerError_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIPAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9019acc02f6742e3b19ac2eab79854df3be00_003F58_003F61b957a0_003FIPAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIServiceCollectionQuartzConfigurator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003F67_003Faee36f5b_003FIServiceCollectionQuartzConfigurator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIService_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff9c8c2674be647f2bd099567200bfca2ba00_003F29_003F5fd3562b_003FIService_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aa8ac544afb487082402c1fa422910f2e00_003F7f_003F8e728ed6_003FIStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>