Compare commits

...

3 Commits

Author SHA1 Message Date
63b2b989ba ♻️ No idea, but errors all gone 2025-07-08 23:55:31 +08:00
2c67472894 :drunk: AI did something 2025-07-08 00:08:35 +08:00
0d47716713 Setup etcd helper and magic onion 2025-07-07 22:32:03 +08:00
91 changed files with 1818 additions and 1109 deletions

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Permission;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;

View File

@@ -84,16 +84,16 @@ public class AccountEventService(
foreach (var userId in userIds)
{
var cacheKey = $"{StatusCacheKey}{userId}";
// var cachedStatus = await cache.GetAsync<Status>(cacheKey);
// if (cachedStatus != null)
// {
// cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
// results[userId] = cachedStatus;
// }
// else
// {
cacheMissUserIds.Add(userId);
// }
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus != null)
{
cachedStatus.IsOnline = !cachedStatus.IsInvisible /*&& ws.GetAccountIsConnected(userId)*/;
results[userId] = cachedStatus;
}
else
{
cacheMissUserIds.Add(userId);
}
}
if (cacheMissUserIds.Any())
@@ -115,7 +115,7 @@ public class AccountEventService(
status.IsOnline = !status.IsInvisible && isOnline;
results[status.AccountId] = status;
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
// await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
foundUserIds.Add(status.AccountId);
}
@@ -170,12 +170,12 @@ public class AccountEventService(
public async Task<bool> CheckInDailyDoAskCaptcha(Shared.Models.Account user)
{
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
// var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
// if (needsCaptcha is not null)
// return needsCaptcha!.Value;
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
if (needsCaptcha is not null)
return needsCaptcha!.Value;
var result = Random.Next(100) < CaptchaProbabilityPercent;
// await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
return result;
}

View File

@@ -0,0 +1,72 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using Microsoft.EntityFrameworkCore;
using MagicOnion.Server;
namespace DysonNetwork.Pass.Account;
public class AccountProfileService(AppDatabase db) : ServiceBase<IAccountProfileService>, IAccountProfileService
{
public async Task<Profile?> GetAccountProfileByIdAsync(Guid accountId)
{
return await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId);
}
public async Task<Profile> UpdateStellarMembershipAsync(Guid accountId, SubscriptionReferenceObject? subscription)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId);
if (profile == null)
{
profile = new Profile { AccountId = accountId };
db.AccountProfiles.Add(profile);
}
profile.StellarMembership = subscription;
await db.SaveChangesAsync();
return profile;
}
public async Task<List<Profile>> GetAccountsWithStellarMembershipAsync()
{
return await db.AccountProfiles
.Where(a => a.StellarMembership != null)
.ToListAsync();
}
public async Task<int> ClearStellarMembershipsAsync(List<Guid> accountIds)
{
return await db.AccountProfiles
.Where(a => accountIds.Contains(a.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(a => a.StellarMembership, p => null)
);
}
public async Task<Profile> UpdateProfilePictureAsync(Guid accountId, CloudFileReferenceObject? picture)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId);
if (profile == null)
{
profile = new Profile { AccountId = accountId };
db.AccountProfiles.Add(profile);
}
profile.Picture = picture;
await db.SaveChangesAsync();
return profile;
}
public async Task<Profile> UpdateProfileBackgroundAsync(Guid accountId, CloudFileReferenceObject? background)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId);
if (profile == null)
{
profile = new Profile { AccountId = accountId };
db.AccountProfiles.Add(profile);
}
profile.Background = background;
await db.SaveChangesAsync();
return profile;
}
}

View File

@@ -11,18 +11,27 @@ using OtpNet;
using Microsoft.Extensions.Logging;
using EFCore.BulkExtensions;
using MagicOnion.Server;
using Grpc.Core;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Localization;
using DysonNetwork.Shared.Services;
namespace DysonNetwork.Pass.Account;
public class AccountService(
AppDatabase db,
// MagicSpellService spells,
// AccountUsernameService uname,
// NotificationService nty,
// EmailService mailer,
// IStringLocalizer<NotificationResource> localizer,
MagicSpellService spells,
AccountUsernameService uname,
NotificationService nty,
// EmailService mailer, // Commented out for now
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
ILogger<AccountService> logger,
AuthService authService,
ActionLogService actionLogService,
RelationshipService relationshipService
) : ServiceBase<IAccountService>, IAccountService
{
public static void SetCultureInfo(Shared.Models.Account account)
@@ -134,15 +143,15 @@ public class AccountService(
}
else
{
// var spell = await spells.CreateMagicSpell(
// account,
// MagicSpellType.AccountActivation,
// new Dictionary<string, object>
// {
// { "contact_method", account.Contacts.First().Content }
// }
// );
// await spells.NotifyMagicSpell(spell, true);
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
}
);
await spells.NotifyMagicSpell(spell, true);
}
db.Accounts.Add(account);
@@ -167,9 +176,7 @@ public class AccountService(
? userInfo.DisplayName
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
// Generate username from email
// var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
var username = userInfo.Email.Split('@')[0]; // Placeholder
var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
return await CreateAccount(
username,
@@ -184,26 +191,26 @@ public class AccountService(
public async Task RequestAccountDeletion(Shared.Models.Account account)
{
// var spell = await spells.CreateMagicSpell(
// account,
// MagicSpellType.AccountRemoval,
// new Dictionary<string, object>(),
// SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
// preventRepeat: true
// );
// await spells.NotifyMagicSpell(spell);
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountRemoval,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task RequestPasswordReset(Shared.Models.Account account)
{
// var spell = await spells.CreateMagicSpell(
// account,
// MagicSpellType.AuthPasswordReset,
// new Dictionary<string, object>(),
// SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
// preventRepeat: true
// );
// await spells.NotifyMagicSpell(spell);
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AuthPasswordReset,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task<bool> CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type)
@@ -329,7 +336,6 @@ public class AccountService(
{
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId)
// .If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
.CountAsync();
if (count <= 1)
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
@@ -355,14 +361,14 @@ public class AccountService(
if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration.");
// await nty.SendNotification(
// account,
// "auth.verification",
// localizer["AuthCodeTitle"],
// null,
// localizer["AuthCodeBody", code],
// save: true
// );
await nty.SendNotification(
account,
"auth.verification",
localizer["AuthCodeTitle"],
null,
localizer["AuthCodeBody", code],
save: true
);
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
break;
case AccountAuthFactorType.EmailCode:
@@ -397,11 +403,11 @@ public class AccountService(
return;
}
// await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
// await mailer.SendTemplatedEmailAsync<DysonNetwork.Pass.Pages.Emails.VerificationEmail, DysonNetwork.Pass.Pages.Emails.VerificationEmailModel>(
// account.Nick,
// contact.Content,
// localizer["VerificationEmail"],
// new VerificationEmailModel
// new DysonNetwork.Pass.Pages.Emails.VerificationEmailModel
// {
// Name = account.Name,
// Code = code
@@ -454,7 +460,7 @@ public class AccountService(
);
}
public async Task<Session> UpdateSessionLabel(Shared.Models.Account account, Guid sessionId, string label)
public async Task<Shared.Models.Session> UpdateSessionLabel(Shared.Models.Account account, Guid sessionId, string label)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
@@ -491,7 +497,7 @@ public class AccountService(
.ToListAsync();
if (session.Challenge.DeviceId is not null)
// await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
// The current session should be included in the sessions' list
await db.AuthSessions
@@ -520,14 +526,14 @@ public class AccountService(
public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact)
{
// var spell = await spells.CreateMagicSpell(
// account,
// MagicSpellType.ContactVerification,
// new Dictionary<string, object> { { "contact_method", contact.Content } },
// expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
// preventRepeat: true
// );
// await spells.NotifyMagicSpell(spell);
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.ContactVerification,
new Dictionary<string, object> { { "contact_method", contact.Content } },
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task<AccountContact> SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact)
@@ -611,7 +617,7 @@ public class AccountService(
try
{
var badge = await db.AccountBadges
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
.Where(b => b.AccountId == account.Id && b.Id != badgeId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found.");
@@ -654,4 +660,246 @@ public class AccountService(
await db.BulkInsertAsync(newProfiles);
}
}
}
public async Task<Shared.Models.Account?> GetAccountById(Guid accountId, bool withProfile = false)
{
return await db.Accounts
.Where(a => a.Id == accountId)
.If(withProfile, q => q.Include(a => a.Profile))
.FirstOrDefaultAsync();
}
public async Task<Profile?> GetAccountProfile(Guid accountId)
{
return await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId);
}
public async Task<Challenge?> GetAuthChallenge(Guid challengeId)
{
return await db.AuthChallenges.FindAsync(challengeId);
}
public async Task<Challenge?> GetAuthChallenge(Guid accountId, string? ipAddress, string? userAgent, Instant now)
{
return await db.AuthChallenges
.Where(e => e.AccountId == accountId)
.Where(e => e.IpAddress == ipAddress)
.Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.FirstOrDefaultAsync();
}
public async Task<Challenge> CreateAuthChallenge(Challenge challenge)
{
db.AuthChallenges.Add(challenge);
await db.SaveChangesAsync();
return challenge;
}
public async Task<AccountAuthFactor?> GetAccountAuthFactor(Guid factorId, Guid accountId)
{
return await db.AccountAuthFactors.FirstOrDefaultAsync(f => f.Id == factorId && f.AccountId == accountId);
}
public async Task<List<AccountAuthFactor>> GetAccountAuthFactors(Guid accountId)
{
return await db.AccountAuthFactors
.Where(e => e.AccountId == accountId)
.Where(e => e.EnabledAt != null && e.Trustworthy >= 1)
.ToListAsync();
}
public async Task<Session?> GetAuthSession(Guid sessionId)
{
return await db.AuthSessions.FindAsync(sessionId);
}
public async Task<MagicSpell?> GetMagicSpell(Guid spellId)
{
return await db.MagicSpells.FindAsync(spellId);
}
public async Task<AbuseReport?> GetAbuseReport(Guid reportId)
{
return await db.AbuseReports.FindAsync(reportId);
}
public async Task<AbuseReport> CreateAbuseReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId)
{
var existingReport = await db.AbuseReports
.Where(r => r.ResourceIdentifier == resourceIdentifier &&
r.AccountId == accountId &&
r.DeletedAt == null)
.FirstOrDefaultAsync();
if (existingReport != null)
{
throw new InvalidOperationException("You have already reported this content.");
}
var report = new AbuseReport
{
ResourceIdentifier = resourceIdentifier,
Type = type,
Reason = reason,
AccountId = accountId
};
db.AbuseReports.Add(report);
await db.SaveChangesAsync();
logger.LogInformation("New abuse report created: {ReportId} for resource {ResourceId}",
report.Id, resourceIdentifier);
return report;
}
public async Task<int> CountAbuseReports(bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => includeResolved || r.ResolvedAt == null)
.CountAsync();
}
public async Task<int> CountUserAbuseReports(Guid accountId, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => r.AccountId == accountId)
.Where(r => includeResolved || r.ResolvedAt == null)
.CountAsync();
}
public async Task<List<AbuseReport>> GetAbuseReports(int skip = 0, int take = 20, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => includeResolved || r.ResolvedAt == null)
.OrderByDescending(r => r.CreatedAt)
.Skip(skip)
.Take(take)
.Include(r => r.Account)
.ToListAsync();
}
public async Task<List<AbuseReport>> GetUserAbuseReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => r.AccountId == accountId)
.Where(r => includeResolved || r.ResolvedAt == null)
.OrderByDescending(r => r.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
}
public async Task<AbuseReport> ResolveAbuseReport(Guid id, string resolution)
{
var report = await db.AbuseReports.FindAsync(id);
if (report == null)
{
throw new KeyNotFoundException("Report not found");
}
report.ResolvedAt = SystemClock.Instance.GetCurrentInstant();
report.Resolution = resolution;
await db.SaveChangesAsync();
return report;
}
public async Task<int> GetPendingAbuseReportsCount()
{
return await db.AbuseReports
.Where(r => r.ResolvedAt == null)
.CountAsync();
}
public async Task<bool> HasRelationshipWithStatus(Guid accountId1, Guid accountId2, Shared.Models.RelationshipStatus status)
{
return await db.AccountRelationships.AnyAsync(r =>
(r.AccountId == accountId1 && r.RelatedId == accountId2 && r.Status == status) ||
(r.AccountId == accountId2 && r.RelatedId == accountId1 && r.Status == status)
);
}
public async Task<Dictionary<Guid, Shared.Models.Status>> GetStatuses(List<Guid> accountIds)
{
return await db.AccountStatuses
.Where(s => accountIds.Contains(s.AccountId))
.GroupBy(s => s.AccountId)
.ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(s => s.CreatedAt).First());
}
public async Task SendNotification(Shared.Models.Account account, string topic, string title, string? subtitle, string body, string? actionUri = null)
{
await nty.SendNotification(account, topic, title, subtitle, body, actionUri: actionUri);
}
public async Task<List<Shared.Models.Account>> ListAccountFriends(Shared.Models.Account account)
{
return await relationshipService.ListAccountFriends(account);
}
public string CreateToken(Shared.Models.Session session)
{
return authService.CreateToken(session);
}
public string GetAuthCookieTokenName()
{
return AuthConstants.CookieTokenName;
}
public async Task<ActionLog> CreateActionLogFromRequest(string type, Dictionary<string, object> meta, string? ipAddress, string? userAgent, Shared.Models.Account? account = null)
{
return await actionLogService.CreateActionLogFromRequest(type, meta, ipAddress, userAgent, account);
}
public async Task<Challenge> UpdateAuthChallenge(Challenge challenge)
{
db.AuthChallenges.Update(challenge);
await db.SaveChangesAsync();
return challenge;
}
public async Task<Session> CreateSession(Instant lastGrantedAt, Instant expiredAt, Shared.Models.Account account, Challenge challenge)
{
var session = new Session
{
LastGrantedAt = lastGrantedAt,
ExpiredAt = expiredAt,
Account = account,
Challenge = challenge,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return session;
}
public async Task UpdateSessionLastGrantedAt(Guid sessionId, Instant lastGrantedAt)
{
await db.AuthSessions
.Where(s => s.Id == sessionId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, lastGrantedAt));
}
public async Task UpdateAccountProfileLastSeenAt(Guid accountId, Instant lastSeenAt)
{
await db.AccountProfiles
.Where(a => a.AccountId == accountId)
.ExecuteUpdateAsync(a => a.SetProperty(x => x.LastSeenAt, lastSeenAt));
}
public async Task<List<Shared.Models.Account>> SearchAccountsAsync(string searchTerm)
{
return await db.Accounts
.Where(a => EF.Functions.ILike(a.Name, $"%{searchTerm}%"))
.OrderBy(a => a.Name)
.Take(10)
.ToListAsync();
}
}

View File

@@ -31,28 +31,28 @@ public class ActionLogService : ServiceBase<IActionLogService>, IActionLogServic
// fbs.Enqueue(log);
}
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
Shared.Models.Account? account = null)
public async Task<ActionLog> CreateActionLogFromRequest(string type, Dictionary<string, object> meta, string? ipAddress, string? userAgent, Shared.Models.Account? account = null)
{
var log = new ActionLog
{
Action = action,
Action = type,
Meta = meta,
UserAgent = request.Headers.UserAgent,
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
// Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
UserAgent = userAgent,
IpAddress = ipAddress,
// Location = geo.GetPointFromIp(ipAddress)
};
if (request.HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser)
log.AccountId = currentUser.Id;
else if (account != null)
if (account != null)
log.AccountId = account.Id;
else
throw new ArgumentException("No user context was found");
if (request.HttpContext.Items["CurrentSession"] is Session currentSession)
log.SessionId = currentSession.Id;
// For MagicOnion, HttpContext.Items["CurrentSession"] is not directly available.
// You might need to pass session ID explicitly if needed.
// if (request.HttpContext.Items["CurrentSession"] is Session currentSession)
// log.SessionId = currentSession.Id;
// fbs.Enqueue(log);
return log;
}
}

View File

@@ -70,6 +70,11 @@ public class MagicSpellService(
return spell;
}
public async Task<MagicSpell?> GetMagicSpellByIdAsync(Guid spellId)
{
return await db.MagicSpells.FirstOrDefaultAsync(s => s.Id == spellId);
}
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
{
var contact = await db.AccountContacts

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -141,7 +141,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
[HttpPost("send")]
[Authorize]
[RequiredPermission("global", "notifications.send")]
[DysonNetwork.Shared.Permission.RequiredPermission("global", "notifications.send")]
public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false

View File

@@ -66,8 +66,8 @@ public class NotificationService(
AccountId = account.Id,
};
// db.NotificationPushSubscriptions.Add(subscription);
// await db.SaveChangesAsync();
db.NotificationPushSubscriptions.Add(subscription);
await db.SaveChangesAsync();
return subscription;
}
@@ -107,7 +107,7 @@ public class NotificationService(
}
if (!isSilent)
Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification);
_ = DeliveryNotification(notification);
return notification;
}
@@ -134,10 +134,10 @@ public class NotificationService(
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
if (id.Count == 0) return;
// await db.Notifications
// .Where(n => id.Contains(n.Id))
// .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
// );
await db.Notifications
.Where(n => id.Contains(n.Id))
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
);
}
public async Task BroadcastNotification(Notification notification, bool save = false)
@@ -161,7 +161,7 @@ public class NotificationService(
};
return newNotification;
}).ToList();
// await db.BulkInsertAsync(notifications);
await db.BulkInsertAsync(notifications);
}
foreach (var account in accounts)

View File

@@ -155,18 +155,22 @@ public class RelationshipService(AppDatabase db, ICacheService cache) : ServiceB
return relationship;
}
public async Task<List<Guid>> ListAccountFriends(Shared.Models.Account account)
public async Task<List<Shared.Models.Account>> ListAccountFriends(Shared.Models.Account account)
{
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
var friends = await cache.GetAsync<List<Shared.Models.Account>>(cacheKey);
if (friends == null)
{
friends = await db.AccountRelationships
var friendIds = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.Status == RelationshipStatus.Friends)
.Select(r => r.AccountId)
.ToListAsync();
friends = await db.Accounts
.Where(a => friendIds.Contains(a.Id))
.ToListAsync();
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
}

View File

@@ -50,6 +50,13 @@ public class AppDatabase(
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
public DbSet<CustomApp> CustomApps { get; set; }
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
public DbSet<DysonNetwork.Shared.Models.Publisher> Publishers { get; set; }
public DbSet<PublisherMember> PublisherMembers { get; set; }
public DbSet<PublisherFeature> PublisherFeatures { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(

View File

@@ -189,8 +189,6 @@ public class DysonTokenAuthHandler(
{
return false;
}
break;
default:
return false;
}

View File

@@ -11,8 +11,8 @@ namespace DysonNetwork.Pass.Auth;
public class AuthService(
AppDatabase db,
IConfiguration config
// IHttpClientFactory httpClientFactory,
IConfiguration config,
IHttpClientFactory httpClientFactory
// IHttpContextAccessor httpContextAccessor,
// ICacheService cache
)
@@ -105,55 +105,56 @@ public class AuthService(
public async Task<bool> ValidateCaptcha(string token)
{
await Task.CompletedTask;
if (string.IsNullOrWhiteSpace(token)) return false;
// var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
// var apiSecret = config.GetSection("Captcha")["ApiSecret"];
var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
var apiSecret = config.GetSection("Captcha")["ApiSecret"];
// var client = httpClientFactory.CreateClient();
var client = httpClientFactory.CreateClient();
// var jsonOpts = new JsonSerializerOptions
// {
// PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
// DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
// };
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
};
// switch (provider)
// {
// case "cloudflare":
// var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
// "application/x-www-form-urlencoded");
// var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
// content);
// response.EnsureSuccessStatusCode();
switch (provider)
{
case "cloudflare":
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
content);
response.EnsureSuccessStatusCode();
// var json = await response.Content.ReadAsStringAsync();
// var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
// return result?.Success == true;
// case "google":
// content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
// "application/x-www-form-urlencoded");
// response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
// response.EnsureSuccessStatusCode();
return result?.Success == true;
case "google":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
response.EnsureSuccessStatusCode();
// json = await response.Content.ReadAsStringAsync();
// result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
// return result?.Success == true;
// case "hcaptcha":
// content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
// "application/x-www-form-urlencoded");
// response = await client.PostAsync("https://hcaptcha.com/siteverify", content);
// response.EnsureSuccessStatusCode();
return result?.Success == true;
case "hcaptcha":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://hcaptcha.com/siteverify", content);
response.EnsureSuccessStatusCode();
// json = await response.Content.ReadAsStringAsync();
// result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
// return result?.Success == true;
// default:
// throw new ArgumentException("The server misconfigured for the captcha.");
// }
return result?.Success == true;
default:
throw new ArgumentException("The server misconfigured for the captcha.");
}
return true; // Placeholder for captcha validation
}

View File

@@ -21,8 +21,7 @@ public class OidcProviderController(
AppDatabase db,
OidcProviderService oidcService,
IConfiguration configuration,
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderController> logger
IOptions<OidcProviderOptions> options
)
: ControllerBase
{

View File

@@ -27,6 +27,7 @@ public class OidcProviderService(
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
{
await Task.CompletedTask;
return null;
// return await db.CustomApps
// .Include(c => c.Secrets)
@@ -35,6 +36,7 @@ public class OidcProviderService(
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
{
await Task.CompletedTask;
return null;
// return await db.CustomApps
// .Include(c => c.Secrets)

View File

@@ -0,0 +1,29 @@
using MagicOnion.Server;
using DysonNetwork.Shared.Services;
using DysonNetwork.Shared.Models;
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Developer;
public class CustomAppService : ServiceBase<ICustomAppService>, ICustomAppService
{
private readonly AppDatabase _db;
public CustomAppService(AppDatabase db)
{
_db = db;
}
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
{
return await _db.CustomApps.FirstOrDefaultAsync(app => app.Id == clientId);
}
public async Task<int> CountCustomAppsByPublisherId(Guid publisherId)
{
return await _db.CustomApps.CountAsync(app => app.PublisherId == publisherId);
}
}

View File

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

View File

@@ -1,4 +1,5 @@
@using DysonNetwork.Pass.Localization
@using DysonNetwork.Shared.Localization
@using Microsoft.Extensions.Localization
<EmailLayout>

View File

@@ -1,6 +1,5 @@
@using DysonNetwork.Pass.Localization
@using DysonNetwork.Shared.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>

View File

@@ -1,6 +1,5 @@
@using DysonNetwork.Pass.Localization
@using DysonNetwork.Shared.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>

View File

@@ -1,6 +1,6 @@
@using DysonNetwork.Pass.Localization
@using DysonNetwork.Shared.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>

View File

@@ -1,6 +1,6 @@
@using DysonNetwork.Pass.Localization
@using DysonNetwork.Shared.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>

View File

@@ -1,51 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Pass.Permission;
[AttributeUsage(AttributeTargets.Method, Inherited = true)]
public class RequiredPermissionAttribute(string area, string key) : Attribute
{
public string Area { get; set; } = area;
public string Key { get; } = key;
}
public class PermissionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext httpContext, PermissionService pm)
{
var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata
.OfType<RequiredPermissionAttribute>()
.FirstOrDefault();
if (attr != null)
{
if (httpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
if (currentUser.IsSuperuser)
{
// Bypass the permission check for performance
await next(httpContext);
return;
}
var actor = $"user:{currentUser.Id}";
var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Area, attr.Key);
if (!permNode)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} = {true} was required.");
return;
}
}
await next(httpContext);
}
}

View File

@@ -1,6 +1,9 @@
using MagicOnion;
using MagicOnion.Server;
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -9,7 +12,7 @@ namespace DysonNetwork.Pass.Permission;
public class PermissionService(
AppDatabase db,
ICacheService cache
)
) : ServiceBase<IPermissionService>, IPermissionService
{
private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1);
@@ -195,4 +198,11 @@ public class PermissionService(
Value = _SerializePermissionValue(value),
};
}
public async UnaryResult<bool> CheckPermission(string scope, string permission)
{
// Assuming the actor is always "user:current" for client-side checks
// You might need to adjust this based on how your client identifies itself
return await HasPermissionAsync("user:current", scope, permission);
}
}

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MagicOnion.Server;
var builder = WebApplication.CreateBuilder(args);
@@ -24,6 +25,8 @@ builder.Services.AddDbContext<AppDatabase>(options =>
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<DysonNetwork.Pass.Publisher.PublisherService>();
builder.Services.AddScoped<DysonNetwork.Pass.Developer.CustomAppService>();
var app = builder.Build();

View File

@@ -0,0 +1,53 @@
using MagicOnion.Server;
using DysonNetwork.Shared.Services;
using DysonNetwork.Shared.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Publisher;
public class PublisherService : ServiceBase<IPublisherService>, IPublisherService
{
private readonly AppDatabase _db;
public PublisherService(AppDatabase db)
{
_db = db;
}
public async Task<Shared.Models.Publisher?> GetPublisherByName(string name)
{
return await _db.Publishers.FirstOrDefaultAsync(p => p.Name == name);
}
public async Task<List<Shared.Models.Publisher>> GetUserPublishers(Guid accountId)
{
var publisherIds = await _db.PublisherMembers
.Where(m => m.AccountId == accountId)
.Select(m => m.PublisherId)
.ToListAsync();
return await _db.Publishers
.Where(p => publisherIds.Contains(p.Id))
.ToListAsync();
}
public async Task<bool> IsMemberWithRole(Guid publisherId, Guid accountId, PublisherMemberRole role)
{
return await _db.PublisherMembers.AnyAsync(m =>
m.PublisherId == publisherId &&
m.AccountId == accountId &&
m.Role >= role);
}
public async Task<List<PublisherFeature>> GetPublisherFeatures(Guid publisherId)
{
return await _db.PublisherFeatures
.Where(f => f.PublisherId == publisherId)
.ToListAsync();
}
}

View File

@@ -1,10 +1,11 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Safety;
namespace DysonNetwork.Pass.Safety;
[ApiController]
[Route("/safety/reports")]
@@ -85,7 +86,7 @@ public class AbuseReportController(
[HttpGet("{id}")]
[Authorize]
[RequiredPermission("safety", "reports.view")]
[DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> GetReportById(Guid id)
@@ -122,7 +123,7 @@ public class AbuseReportController(
[HttpPost("{id}/resolve")]
[Authorize]
[RequiredPermission("safety", "reports.resolve")]
[DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.resolve")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request)
@@ -144,7 +145,7 @@ public class AbuseReportController(
[HttpGet("count")]
[Authorize]
[RequiredPermission("safety", "reports.view")]
[DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> GetReportsCount()
{

View File

@@ -0,0 +1,61 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Pass.Safety;
public class SafetyService(AppDatabase db, IAccountService accountService, ILogger<SafetyService> logger)
{
public async Task<AbuseReport> CreateReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId)
{
// Check if a similar report already exists from this user
var existingReport = await db.AbuseReports
.Where(r => r.ResourceIdentifier == resourceIdentifier &&
r.AccountId == accountId &&
r.DeletedAt == null)
.FirstOrDefaultAsync();
if (existingReport != null)
{
throw new InvalidOperationException("You have already reported this content.");
}
return await accountService.CreateAbuseReport(resourceIdentifier, type, reason, accountId);
}
public async Task<int> CountReports(bool includeResolved = false)
{
return await accountService.CountAbuseReports(includeResolved);
}
public async Task<int> CountUserReports(Guid accountId, bool includeResolved = false)
{
return await accountService.CountUserAbuseReports(accountId, includeResolved);
}
public async Task<List<AbuseReport>> GetReports(int skip = 0, int take = 20, bool includeResolved = false)
{
return await accountService.GetAbuseReports(skip, take, includeResolved);
}
public async Task<List<AbuseReport>> GetUserReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false)
{
return await accountService.GetUserAbuseReports(accountId, skip, take, includeResolved);
}
public async Task<AbuseReport?> GetReportById(Guid id)
{
return await accountService.GetAbuseReport(id);
}
public async Task<AbuseReport> ResolveReport(Guid id, string resolution)
{
return await accountService.ResolveAbuseReport(id, resolution);
}
public async Task<int> GetPendingReportsCount()
{
return await accountService.GetPendingAbuseReportsCount();
}
}

View File

@@ -47,6 +47,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAccountUsernameService, AccountUsernameService>();
services.AddScoped<IMagicSpellService, MagicSpellService>();
services.AddScoped<IAccountEventService, AccountEventService>();
services.AddScoped<IAccountProfileService, AccountProfileService>();
// Register OIDC services
services.AddScoped<OidcService, GoogleOidcService>();

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -12,10 +12,11 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Google.Protobuf" Version="3.27.2" />
<PackageReference Include="dotnet-etcd" Version="8.0.1" />
<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="MagicOnion.Server" Version="7.0.5" />
<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" />
@@ -24,6 +25,10 @@
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="MimeKit" Version="4.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
</ItemGroup>
@@ -33,4 +38,6 @@
</Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,70 @@
using dotnet_etcd;
using Etcdserverpb;
using Grpc.Core;
namespace DysonNetwork.Shared.Etcd;
public class EtcdService(string connectionString) : IEtcdService
{
private readonly EtcdClient _etcdClient = new(connectionString);
private long _leaseId;
private string? _serviceKey;
private readonly CancellationTokenSource _cts = new();
public async Task RegisterServiceAsync(string serviceName, string serviceAddress, int ttl = 15)
{
_serviceKey = $"/services/{serviceName}/{Guid.NewGuid()}";
var leaseGrantResponse = await _etcdClient.LeaseGrantAsync(new LeaseGrantRequest { TTL = ttl });
_leaseId = leaseGrantResponse.ID;
await _etcdClient.PutAsync(new PutRequest
{
Key = Google.Protobuf.ByteString.CopyFromUtf8(_serviceKey),
Value = Google.Protobuf.ByteString.CopyFromUtf8(serviceAddress),
Lease = _leaseId
});
_ = Task.Run(async () =>
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
await _etcdClient.LeaseKeepAlive(new LeaseKeepAliveRequest { ID = _leaseId },
_ => { }, _cts.Token);
await Task.Delay(TimeSpan.FromSeconds(ttl / 3), _cts.Token);
}
catch (RpcException)
{
// Ignored
}
}
}, _cts.Token);
}
public async Task UnregisterServiceAsync()
{
if (!string.IsNullOrEmpty(_serviceKey))
{
await _etcdClient.DeleteRangeAsync(_serviceKey);
}
}
public async Task<List<string>> DiscoverServicesAsync(string serviceName)
{
var prefix = $"/services/{serviceName}/";
var rangeResponse = await _etcdClient.GetRangeAsync(prefix);
return rangeResponse.Kvs.Select(kv => kv.Value.ToStringUtf8()).ToList();
}
public void Dispose()
{
_cts.Cancel();
if (_leaseId != 0)
{
_etcdClient.LeaseRevoke(new LeaseRevokeRequest { ID = _leaseId });
}
_etcdClient.Dispose();
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Grpc.Net.Client;
using MagicOnion.Client;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Etcd
{
public static class EtcdServiceExtensions
{
public static IServiceCollection AddEtcdService(this IServiceCollection services, IConfiguration configuration)
{
var etcdConnectionString = configuration.GetConnectionString("Etcd");
services.AddSingleton<IEtcdService>(new EtcdService(etcdConnectionString!));
return services;
}
public static IServiceCollection AddMagicOnionService<TService>(this IServiceCollection services)
where TService : class, MagicOnion.IService<TService>
{
services.AddSingleton(serviceProvider =>
{
var etcdService = serviceProvider.GetRequiredService<IEtcdService>();
var serviceName = typeof(TService).Name.TrimStart('I'); // Convention: IMyService -> MyService
// Synchronously wait for service discovery (or handle asynchronously if preferred)
var endpoints = etcdService.DiscoverServicesAsync(serviceName).GetAwaiter().GetResult();
if (!endpoints.Any())
{
throw new InvalidOperationException($"No endpoints found for MagicOnion service: {serviceName}");
}
// For simplicity, use the first discovered endpoint
var endpoint = endpoints.First();
var channel = GrpcChannel.ForAddress(endpoint);
return MagicOnionClient.Create<TService>(channel);
});
return services;
}
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Etcd
{
public interface IEtcdService : IDisposable
{
Task RegisterServiceAsync(string serviceName, string serviceAddress, int ttl = 15);
Task UnregisterServiceAsync();
Task<List<string>> DiscoverServicesAsync(string serviceName);
}
}

View File

@@ -0,0 +1,18 @@
using System.Globalization;
namespace DysonNetwork.Shared.Localization;
public abstract class CultureInfoService
{
public static void SetCultureInfo(Shared.Models.Account account)
{
SetCultureInfo(account.Language);
}
public static void SetCultureInfo(string? languageCode)
{
var info = new CultureInfo(languageCode ?? "en-us", false);
CultureInfo.CurrentCulture = info;
CultureInfo.CurrentUICulture = info;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
using MagicOnion.Server.Filters;
using MagicOnion.Server;
using DysonNetwork.Shared.Services;
using Microsoft.Extensions.DependencyInjection;
using DysonNetwork.Shared.Models;
using System.Threading.Tasks;
using System;
using System.Reflection;
using Grpc.Core;
using MagicOnion;
using MagicOnion.Server.Hubs;
namespace DysonNetwork.Shared.Permission;
public class MagicOnionPermissionFilter : IMagicOnionServiceFilter
{
private readonly IPermissionService _permissionService;
public MagicOnionPermissionFilter(IPermissionService permissionService)
{
_permissionService = permissionService;
}
public async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
{
var attribute = context.MethodInfo.GetCustomAttribute<RequiredPermissionAttribute>();
if (attribute == null)
{
// If no RequiredPermissionAttribute is present, just continue
await next(context);
return;
}
// Correct way to get HttpContext from ServiceContext
var httpContext = context.CallContext.GetHttpContext();
if (httpContext == null)
{
throw new InvalidOperationException("HttpContext is not available in ServiceContext.");
}
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
throw new ReturnStatusException(StatusCode.PermissionDenied, "Unauthorized: Current user not found.");
}
if (currentUser.IsSuperuser)
{
await next(context);
return;
}
var hasPermission = await _permissionService.CheckPermission(attribute.Scope, attribute.Permission);
if (!hasPermission)
{
throw new ReturnStatusException(StatusCode.PermissionDenied, $"Permission {attribute.Scope}/{attribute.Permission} was required.");
}
await next(context);
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace DysonNetwork.Shared.Permission;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
public class RequiredPermissionAttribute : Attribute
{
public string Scope { get; }
public string Permission { get; }
public RequiredPermissionAttribute(string scope, string permission)
{
Scope = scope;
Permission = permission;
}
}

View File

@@ -0,0 +1,78 @@
using MailKit.Net.Smtp;
using MimeKit;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
using System.Collections.Generic;
namespace DysonNetwork.Shared.Services;
public class EmailService
{
private readonly IConfiguration _configuration;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration configuration, ILogger<EmailService> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task SendEmailAsync(
string toName,
string toEmail,
string subject,
string body,
Dictionary<string, string>? headers = null
)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(
_configuration["Email:SenderName"],
_configuration["Email:SenderEmail"]
));
message.To.Add(new MailboxAddress(toName, toEmail));
message.Subject = subject;
var bodyBuilder = new BodyBuilder { HtmlBody = body };
message.Body = bodyBuilder.ToMessageBody();
if (headers != null)
{
foreach (var header in headers)
{
message.Headers.Add(header.Key, header.Value);
}
}
using var client = new SmtpClient();
try
{
await client.ConnectAsync(
_configuration["Email:SmtpHost"],
int.Parse(_configuration["Email:SmtpPort"]),
MailKit.Security.SecureSocketOptions.StartTls
);
await client.AuthenticateAsync(
_configuration["Email:SmtpUser"],
_configuration["Email:SmtpPass"]
);
await client.SendAsync(message);
_logger.LogInformation("Email sent to {ToEmail}", toEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {ToEmail}", toEmail);
throw;
}
finally
{
await client.DisconnectAsync(true);
}
}
public async Task SendTemplatedEmailAsync<T>(string toName, string toEmail, string subject, string htmlBody)
{
await SendEmailAsync(toName, toEmail, subject, htmlBody);
}
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using MagicOnion;
using NodaTime;
@@ -10,17 +11,22 @@ public interface IAccountEventService : IService<IAccountEventService>
/// 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>
/// Gets the statuses of a list of users
/// </summary>
Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds);
/// <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>

View File

@@ -0,0 +1,54 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using System;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services
{
public interface IAccountProfileService : IService<IAccountProfileService>
{
/// <summary>
/// Gets an account profile by account ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <returns>The account profile if found, otherwise null.</returns>
Task<Profile?> GetAccountProfileByIdAsync(Guid accountId);
/// <summary>
/// Updates the StellarMembership of an account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="subscription">The subscription to set as the StellarMembership.</param>
/// <returns>The updated account profile.</returns>
Task<Profile> UpdateStellarMembershipAsync(Guid accountId, SubscriptionReferenceObject? subscription);
/// <summary>
/// Gets all account profiles that have a non-null StellarMembership.
/// </summary>
/// <returns>A list of account profiles with StellarMembership.</returns>
Task<List<Profile>> GetAccountsWithStellarMembershipAsync();
/// <summary>
/// Clears the StellarMembership for a list of account IDs.
/// </summary>
/// <param name="accountIds">The list of account IDs for which to clear the StellarMembership.</param>
/// <returns>The number of accounts updated.</returns>
Task<int> ClearStellarMembershipsAsync(List<Guid> accountIds);
/// <summary>
/// Updates the profile picture of an account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="picture">The new profile picture reference object.</param>
/// <returns>The updated profile.</returns>
Task<Profile> UpdateProfilePictureAsync(Guid accountId, CloudFileReferenceObject? picture);
/// <summary>
/// Updates the profile background of an account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="background">The new profile background reference object.</param>
/// <returns>The updated profile.</returns>
Task<Profile> UpdateProfileBackgroundAsync(Guid accountId, CloudFileReferenceObject? background);
}
}

View File

@@ -1,5 +1,8 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
namespace DysonNetwork.Shared.Services;
@@ -59,4 +62,247 @@ public interface IAccountService : IService<IAccountService>
/// <param name="userInfo">The OpenID Connect user information</param>
/// <returns>The newly created account</returns>
Task<Account> CreateAccount(OidcUserInfo userInfo);
}
/// <summary>
/// Gets an account by its ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="withProfile">Join the profile table or not.</param>
/// <returns>The account if found, otherwise null.</returns>
Task<Account?> GetAccountById(Guid accountId, bool withProfile = false);
/// <summary>
/// Gets an account profile by account ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <returns>The account profile if found, otherwise null.</returns>
Task<Profile?> GetAccountProfile(Guid accountId);
/// <summary>
/// Gets an authentication challenge by its ID.
/// </summary>
/// <param name="challengeId">The ID of the challenge.</param>
/// <returns>The authentication challenge if found, otherwise null.</returns>
Task<Challenge?> GetAuthChallenge(Guid challengeId);
/// <summary>
/// Gets an authentication challenge by account ID, IP address, and user agent.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="ipAddress">The IP address.</param>
/// <param name="userAgent">The user agent.</param>
/// <param name="now">The current instant.</param>
/// <returns>The authentication challenge if found, otherwise null.</returns>
Task<Challenge?> GetAuthChallenge(Guid accountId, string? ipAddress, string? userAgent, NodaTime.Instant now);
/// <summary>
/// Creates a new authentication challenge.
/// </summary>
/// <param name="challenge">The challenge to create.</param>
/// <returns>The created challenge.</returns>
Task<Challenge> CreateAuthChallenge(Challenge challenge);
/// <summary>
/// Gets an account authentication factor by its ID and account ID.
/// </summary>
/// <param name="factorId">The ID of the factor.</param>
/// <param name="accountId">The ID of the account.</param>
/// <returns>The account authentication factor if found, otherwise null.</returns>
Task<AccountAuthFactor?> GetAccountAuthFactor(Guid factorId, Guid accountId);
/// <summary>
/// Gets a list of account authentication factors for a given account ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <returns>A list of account authentication factors.</returns>
Task<List<AccountAuthFactor>> GetAccountAuthFactors(Guid accountId);
/// <summary>
/// Gets an authentication session by its ID.
/// </summary>
/// <param name="sessionId">The ID of the session.</param>
/// <returns>The authentication session if found, otherwise null.</returns>
Task<Session?> GetAuthSession(Guid sessionId);
/// <summary>
/// Gets a magic spell by its ID.
/// </summary>
/// <param name="spellId">The ID of the magic spell.</param>
/// <returns>The magic spell if found, otherwise null.</returns>
Task<MagicSpell?> GetMagicSpell(Guid spellId);
/// <summary>
/// Gets an abuse report by its ID.
/// </summary>
/// <param name="reportId">The ID of the abuse report.</param>
/// <returns>The abuse report if found, otherwise null.</returns>
Task<AbuseReport?> GetAbuseReport(Guid reportId);
/// <summary>
/// Creates a new abuse report.
/// </summary>
/// <param name="resourceIdentifier">The identifier of the resource being reported.</param>
/// <param name="type">The type of abuse report.</param>
/// <param name="reason">The reason for the report.</param>
/// <param name="accountId">The ID of the account making the report.</param>
/// <returns>The created abuse report.</returns>
Task<AbuseReport> CreateAbuseReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId);
/// <summary>
/// Counts abuse reports.
/// </summary>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>The count of abuse reports.</returns>
Task<int> CountAbuseReports(bool includeResolved = false);
/// <summary>
/// Counts abuse reports by a specific user.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>The count of abuse reports by the user.</returns>
Task<int> CountUserAbuseReports(Guid accountId, bool includeResolved = false);
/// <summary>
/// Gets a list of abuse reports.
/// </summary>
/// <param name="skip">Number of reports to skip.</param>
/// <param name="take">Number of reports to take.</param>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>A list of abuse reports.</returns>
Task<List<AbuseReport>> GetAbuseReports(int skip = 0, int take = 20, bool includeResolved = false);
/// <summary>
/// Gets a list of abuse reports by a specific user.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="skip">Number of reports to skip.</param>
/// <param name="take">Number of reports to take.</param>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>A list of abuse reports by the user.</returns>
Task<List<AbuseReport>> GetUserAbuseReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false);
/// <summary>
/// Resolves an abuse report.
/// </summary>
/// <param name="id">The ID of the report to resolve.</param>
/// <param name="resolution">The resolution message.</param>
/// <returns>The resolved abuse report.</returns>
Task<AbuseReport> ResolveAbuseReport(Guid id, string resolution);
/// <summary>
/// Gets the count of pending abuse reports.
/// </summary>
/// <returns>The count of pending abuse reports.</returns>
Task<int> GetPendingAbuseReportsCount();
/// <summary>
/// Checks if a relationship with a specific status exists between two accounts.
/// </summary>
/// <param name="accountId1">The ID of the first account.</param>
/// <param name="accountId2">The ID of the second account.</param>
/// <param name="status">The relationship status to check for.</param>
/// <returns>True if the relationship exists, otherwise false.</returns>
Task<bool> HasRelationshipWithStatus(Guid accountId1, Guid accountId2, RelationshipStatus status);
/// <summary>
/// Gets the statuses for a list of account IDs.
/// </summary>
/// <param name="accountIds">A list of account IDs.</param>
/// <returns>A dictionary where the key is the account ID and the value is the status.</returns>
Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> accountIds);
/// <summary>
/// Sends a notification to an account.
/// </summary>
/// <param name="account">The target account.</param>
/// <param name="topic">The notification topic.</param>
/// <param name="title">The notification title.</param>
/// <param name="subtitle">The notification subtitle.</param>
/// <param name="body">The notification body.</param>
/// <param name="actionUri">The action URI for the notification.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendNotification(Account account, string topic, string title, string? subtitle, string body, string? actionUri = null);
/// <summary>
/// Lists the friends of an account.
/// </summary>
/// <param name="account">The account.</param>
/// <returns>A list of friend accounts.</returns>
Task<List<Account>> ListAccountFriends(Account account);
/// <summary>
/// Verifies an authentication factor code.
/// </summary>
/// <param name="factor">The authentication factor.</param>
/// <param name="code">The code to verify.</param>
/// <returns>True if the code is valid, otherwise false.</returns>
Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code);
/// <summary>
/// Send the auth factor verification code to users, for factors like in-app code and email.
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
/// </summary>
/// <param name="account">The owner of the auth factor</param>
/// <param name="factor">The auth factor needed to send code</param>
/// <param name="hint">The part of the contact method for verification</param>
Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null);
/// <summary>
/// Creates an action log entry.
/// </summary>
/// <param name="type">The type of action log.</param>
/// <param name="meta">Additional metadata for the action log.</param>
/// <param name="request">The HTTP request.</param>
/// <param name="account">The account associated with the action.</param>
/// <returns>The created action log.</returns>
Task<ActionLog> CreateActionLogFromRequest(string type, Dictionary<string, object> meta, string? ipAddress, string? userAgent, Account? account = null);
/// <summary>
/// Creates a new session.
/// </summary>
/// <param name="lastGrantedAt">The last granted instant.</param>
/// <param name="expiredAt">The expiration instant.</param>
/// <param name="account">The associated account.</param>
/// <param name="challenge">The associated challenge.</param>
/// <returns>The created session.</returns>
Task<Session> CreateSession(NodaTime.Instant lastGrantedAt, NodaTime.Instant expiredAt, Account account, Challenge challenge);
/// <summary>
/// Updates the LastGrantedAt for a session.
/// </summary>
/// <param name="sessionId">The ID of the session.</param>
/// <param name="lastGrantedAt">The new LastGrantedAt instant.</param>
Task UpdateSessionLastGrantedAt(Guid sessionId, NodaTime.Instant lastGrantedAt);
/// <summary>
/// Updates the LastSeenAt for an account profile.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="lastSeenAt">The new LastSeenAt instant.</param>
Task UpdateAccountProfileLastSeenAt(Guid accountId, NodaTime.Instant lastSeenAt);
/// <summary>
/// Creates a token for a session.
/// </summary>
/// <param name="session">The session.</param>
/// <returns>The token string.</returns>
string CreateToken(Session session);
/// <summary>
/// Gets the AuthConstants.CookieTokenName.
/// </summary>
/// <returns>The cookie token name.</returns>
string GetAuthCookieTokenName();
/// <summary>
/// Searches for accounts by a search term.
/// </summary>
/// <param name="searchTerm">The term to search for.</param>
/// <returns>A list of matching accounts.</returns>
Task<List<Account>> SearchAccountsAsync(string searchTerm);
}

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using MagicOnion;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Shared.Services;
@@ -15,10 +14,11 @@ public interface IActionLogService : IService<IActionLogService>
/// <summary>
/// Creates an action log entry from an HTTP request
/// </summary>
void CreateActionLogFromRequest(
string action,
Task<ActionLog> CreateActionLogFromRequest(
string type,
Dictionary<string, object> meta,
HttpRequest request,
string? ipAddress,
string? userAgent,
Account? account = null
);
}
}

View File

@@ -0,0 +1,13 @@
using MagicOnion;
using DysonNetwork.Shared.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services;
public interface ICustomAppService : IService<ICustomAppService>
{
Task<CustomApp?> FindClientByIdAsync(Guid clientId);
Task<int> CountCustomAppsByPublisherId(Guid publisherId);
}

View File

@@ -22,7 +22,21 @@ public interface IMagicSpellService : IService<IMagicSpellService>
/// Gets a magic spell by its token
/// </summary>
Task<MagicSpell?> GetMagicSpellAsync(string token);
/// <summary>
/// Gets a magic spell by its ID.
/// </summary>
/// <param name="spellId">The ID of the magic spell.</param>
/// <returns>The magic spell if found, otherwise null.</returns>
Task<MagicSpell?> GetMagicSpellByIdAsync(Guid spellId);
/// <summary>
/// Applies a password reset magic spell.
/// </summary>
/// <param name="spell">The magic spell object.</param>
/// <param name="newPassword">The new password.</param>
Task ApplyPasswordReset(MagicSpell spell, string newPassword);
/// <summary>
/// Consumes a magic spell
/// </summary>

View File

@@ -1,5 +1,7 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services;
@@ -20,4 +22,25 @@ public interface INotificationService : IService<INotificationService>
string deviceId,
string deviceToken
);
Task<Notification> SendNotification(
Account account,
string topic,
string? title = null,
string? subtitle = null,
string? content = null,
Dictionary<string, object>? meta = null,
string? actionUri = null,
bool isSilent = false,
bool save = true
);
Task DeliveryNotification(Notification notification);
Task MarkNotificationsViewed(ICollection<Notification> notifications);
Task BroadcastNotification(Notification notification, bool save = false);
Task SendNotificationBatch(Notification notification, List<Account> accounts,
bool save = false);
}

View File

@@ -0,0 +1,8 @@
using MagicOnion;
namespace DysonNetwork.Shared.Services;
public interface IPermissionService : IService<IPermissionService>
{
UnaryResult<bool> CheckPermission(string scope, string permission);
}

View File

@@ -0,0 +1,15 @@
using MagicOnion;
using DysonNetwork.Shared.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services;
public interface IPublisherService : IService<IPublisherService>
{
Task<Publisher?> GetPublisherByName(string name);
Task<List<Publisher>> GetUserPublishers(Guid accountId);
Task<bool> IsMemberWithRole(Guid publisherId, Guid accountId, PublisherMemberRole role);
Task<List<PublisherFeature>> GetPublisherFeatures(Guid publisherId);
}

View File

@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DysonNetwork.Shared.Models;
using MagicOnion;
@@ -9,7 +12,7 @@ public interface IRelationshipService : IService<IRelationshipService>
/// Checks if a relationship exists between two accounts
/// </summary>
Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId);
/// <summary>
/// Gets a relationship between two accounts
/// </summary>
@@ -19,9 +22,58 @@ public interface IRelationshipService : IService<IRelationshipService>
RelationshipStatus? status = null,
bool ignoreExpired = false
);
/// <summary>
/// Creates a new relationship between two accounts
/// </summary>
Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status);
/// <summary>
/// Blocks a user
/// </summary>
Task<Relationship> BlockAccount(Account sender, Account target);
/// <summary>
/// Unblocks a user
/// </summary>
Task<Relationship> UnblockAccount(Account sender, Account target);
/// <summary>
/// Sends a friend request to a user
/// </summary>
Task<Relationship> SendFriendRequest(Account sender, Account target);
/// <summary>
/// Deletes a friend request
/// </summary>
Task DeleteFriendRequest(Guid accountId, Guid relatedId);
/// <summary>
/// Accepts a friend request
/// </summary>
Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
);
/// <summary>
/// Updates a relationship between two users
/// </summary>
Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status);
/// <summary>
/// Lists all friends of an account
/// </summary>
Task<List<Account>> ListAccountFriends(Account account);
/// <summary>
/// Lists all blocked users of an account
/// </summary>
Task<List<Guid>> ListAccountBlocked(Account account);
/// <summary>
/// Checks if a relationship with a specific status exists between two accounts
/// </summary>
Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
RelationshipStatus status = RelationshipStatus.Friends);
}

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Activity;

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post;
@@ -11,7 +11,7 @@ namespace DysonNetwork.Sphere.Activity;
public class ActivityService(
AppDatabase db,
PublisherService pub,
RelationshipService rels,
Shared.Services.IRelationshipService rels,
PostService ps,
DiscoveryService ds
)
@@ -125,7 +125,7 @@ public class ActivityService(
)
{
var activities = new List<Activity>();
var userFriends = await rels.ListAccountFriends(currentUser);
var userFriends = (await rels.ListAccountFriends(currentUser)).Select(x => x.Id).ToList();
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
debugInclude ??= [];

View File

@@ -1,6 +1,7 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Sticker;
using Microsoft.EntityFrameworkCore;
@@ -11,13 +12,6 @@ using Quartz;
namespace DysonNetwork.Sphere;
public abstract class ModelBase
{
public Instant CreatedAt { get; set; }
public Instant UpdatedAt { get; set; }
public Instant? DeletedAt { get; set; }
}
public class AppDatabase(
DbContextOptions<AppDatabase> options,
IConfiguration configuration
@@ -59,8 +53,8 @@ public class AppDatabase(
public DbSet<Subscription> WalletSubscriptions { get; set; }
public DbSet<Coupon> WalletCoupons { get; set; }
public DbSet<Connection.WebReader.WebArticle> WebArticles { get; set; }
public DbSet<Connection.WebReader.WebFeed> WebFeeds { get; set; }
public DbSet<WebArticle> WebArticles { get; set; }
public DbSet<WebFeed> WebFeeds { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -189,11 +183,11 @@ public class AppDatabase(
.HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Connection.WebReader.WebFeed>()
modelBuilder.Entity<WebFeed>()
.HasIndex(f => f.Url)
.IsUnique();
modelBuilder.Entity<Connection.WebReader.WebArticle>()
modelBuilder.Entity<WebArticle>()
.HasIndex(a => a.Url)
.IsUnique();

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -137,7 +137,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
[HttpPost("{roomId:guid}/messages")]
[Authorize]
[RequiredPermission("global", "chat.messages.create")]
[DysonNetwork.Shared.Permission.RequiredPermission("global", "chat.messages.create")]
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();

View File

@@ -1,10 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
@@ -20,11 +20,12 @@ public class ChatRoomController(
FileReferenceService fileRefService,
ChatRoomService crs,
RealmService rs,
ActionLogService als,
NotificationService nty,
RelationshipService rels,
IStringLocalizer<NotificationResource> localizer,
AccountEventService aes
IAccountService accounts,
IActionLogService als,
INotificationService nty,
IRelationshipService rels,
IAccountEventService aes,
IStringLocalizer<NotificationResource> localizer
) : ControllerBase
{
[HttpGet("{id:guid}")]
@@ -47,7 +48,7 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<List<ChatRoom>>> ListJoinedChatRooms()
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var userId = currentUser.Id;
@@ -73,10 +74,10 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatRoom>> CreateDirectMessage([FromBody] DirectMessageRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
var relatedUser = await accounts.GetAccountById(request.RelatedUserId);
if (relatedUser is null)
return BadRequest("Related user was not found");
@@ -105,7 +106,7 @@ public class ChatRoomController(
{
AccountId = currentUser.Id,
Role = ChatMemberRole.Owner,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
},
new()
{
@@ -119,9 +120,12 @@ public class ChatRoomController(
db.ChatRooms.Add(dmRoom);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomCreate,
new Dictionary<string, object> { { "chatroom_id", dmRoom.Id } }, Request
new Dictionary<string, object> { { "chatroom_id", dmRoom.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId);
@@ -162,7 +166,7 @@ public class ChatRoomController(
[HttpPost]
[Authorize]
[RequiredPermission("global", "chat.create")]
[DysonNetwork.Shared.Permission.RequiredPermission("global", "chat.create")]
public async Task<ActionResult<ChatRoom>> CreateChatRoom(ChatRoomRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
@@ -225,9 +229,12 @@ public class ChatRoomController(
chatRoomResourceId
);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomCreate,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(chatRoom);
@@ -311,9 +318,12 @@ public class ChatRoomController(
db.ChatRooms.Update(chatRoom);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomUpdate,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(chatRoom);
@@ -345,9 +355,12 @@ public class ChatRoomController(
db.ChatRooms.Remove(chatRoom);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomDelete,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -436,7 +449,6 @@ public class ChatRoomController(
}
}
public class ChatMemberRequest
{
@@ -452,7 +464,7 @@ public class ChatRoomController(
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
var relatedUser = await accounts.GetAccountById(request.RelatedUserId);
if (relatedUser is null) return BadRequest("Related user was not found");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
@@ -508,9 +520,12 @@ public class ChatRoomController(
newMember.ChatRoom = chatRoom;
await _SendInviteNotify(newMember, currentUser);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomInvite,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } }, Request
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(newMember);
@@ -560,9 +575,12 @@ public class ChatRoomController(
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomJoin,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
new Dictionary<string, object> { { "chatroom_id", roomId } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(member);
@@ -676,7 +694,9 @@ public class ChatRoomController(
ActionLogType.RealmAdjustRole,
new Dictionary<string, object>
{ { "chatroom_id", roomId }, { "account_id", memberId }, { "new_role", newRole } },
Request
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(targetMember);
@@ -723,7 +743,10 @@ public class ChatRoomController(
als.CreateActionLogFromRequest(
ActionLogType.ChatroomKick,
new Dictionary<string, object> { { "chatroom_id", roomId }, { "account_id", memberId } }, Request
new Dictionary<string, object> { { "chatroom_id", roomId }, { "account_id", memberId } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -763,9 +786,12 @@ public class ChatRoomController(
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomJoin,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
new Dictionary<string, object> { { "chatroom_id", roomId } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(chatRoom);
@@ -800,15 +826,18 @@ public class ChatRoomController(
await db.SaveChangesAsync();
await crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomLeave,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
new Dictionary<string, object> { { "chatroom_id", roomId } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
}
private async Task _SendInviteNotify(ChatMember member, Shared.Models.Account sender)
private async Task _SendInviteNotify(ChatMember member, Account sender)
{
string title = localizer["ChatInviteTitle"];

View File

@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
@@ -205,7 +205,7 @@ public partial class ChatService(
using var scope = scopeFactory.CreateScope();
var scopedWs = scope.ServiceProvider.GetRequiredService<WebSocketService>();
var scopedNty = scope.ServiceProvider.GetRequiredService<NotificationService>();
var scopedNty = scope.ServiceProvider.GetRequiredService<INotificationService>();
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -6,7 +7,7 @@ namespace DysonNetwork.Sphere.Connection;
[ApiController]
[Route("completion")]
public class AutoCompletionController(AppDatabase db)
public class AutoCompletionController(IAccountService accounts, AppDatabase db)
: ControllerBase
{
[HttpPost]
@@ -38,19 +39,15 @@ public class AutoCompletionController(AppDatabase db)
private async Task<List<CompletionItem>> GetAccountCompletions(string searchTerm)
{
return await db.Accounts
.Where(a => EF.Functions.ILike(a.Name, $"%{searchTerm}%"))
.OrderBy(a => a.Name)
.Take(10)
.Select(a => new CompletionItem
{
Id = a.Id.ToString(),
DisplayName = a.Name,
SecondaryText = a.Nick,
Type = "account",
Data = a
})
.ToListAsync();
var data = await accounts.SearchAccountsAsync(searchTerm);
return data.Select(a => new CompletionItem
{
Id = a.Id.ToString(),
DisplayName = a.Name,
SecondaryText = a.Nick,
Type = "account",
Data = a
}).ToList();
}
private async Task<List<CompletionItem>> GetStickerCompletions(string searchTerm)

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Connection.WebReader;

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@@ -59,7 +59,7 @@ public class WebReaderController(WebReaderService reader, ILogger<WebReaderContr
/// </summary>
[HttpDelete("link/cache")]
[Authorize]
[RequiredPermission("maintenance", "cache.scrap")]
[DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "cache.scrap")]
public async Task<IActionResult> InvalidateCache([FromQuery] string url)
{
if (string.IsNullOrEmpty(url))
@@ -76,7 +76,7 @@ public class WebReaderController(WebReaderService reader, ILogger<WebReaderContr
/// </summary>
[HttpDelete("cache/all")]
[Authorize]
[RequiredPermission("maintenance", "cache.scrap")]
[DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "cache.scrap")]
public async Task<IActionResult> InvalidateAllCache()
{
await reader.InvalidateAllCachedPreviewsAsync();

View File

@@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

View File

@@ -1,6 +1,5 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -14,7 +13,7 @@ namespace DysonNetwork.Sphere.Developer;
public class DeveloperController(
AppDatabase db,
PublisherService ps,
ActionLogService als
DysonNetwork.Shared.Services.IActionLogService als
)
: ControllerBase
{
@@ -91,7 +90,7 @@ public class DeveloperController(
[HttpPost("{name}/enroll")]
[Authorize]
[RequiredPermission("global", "developers.create")]
[DysonNetwork.Shared.Permission.RequiredPermission("global", "developers.create")]
public async Task<ActionResult<Shared.Models.Publisher>> EnrollDeveloperProgram(string name)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();

View File

@@ -27,6 +27,8 @@
<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="Grpc.Net.Client" Version="2.71.0" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.71.0" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
@@ -153,6 +155,7 @@
<DependentUpon>NotificationResource.resx</DependentUpon>
</Compile>
<Compile Remove="Auth\AppleAuthController.cs" />
<Compile Remove="Permission\RequiredPermissionAttribute.cs" />
</ItemGroup>
<ItemGroup>
@@ -172,6 +175,7 @@
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,106 +0,0 @@
using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Components;
using MimeKit;
namespace DysonNetwork.Sphere.Email;
public class EmailServiceConfiguration
{
public string Server { get; set; } = null!;
public int Port { get; set; }
public bool UseSsl { get; set; }
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
public string FromAddress { get; set; } = null!;
public string FromName { get; set; } = null!;
public string SubjectPrefix { get; set; } = null!;
}
public class EmailService
{
private readonly EmailServiceConfiguration _configuration;
private readonly RazorViewRenderer _viewRenderer;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger<EmailService> logger)
{
var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>();
_configuration = cfg ?? throw new ArgumentException("Email service was not configured.");
_viewRenderer = viewRenderer;
_logger = logger;
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
{
await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null);
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody,
string? htmlBody)
{
subject = $"[{_configuration.SubjectPrefix}] {subject}";
var emailMessage = new MimeMessage();
emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress));
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
emailMessage.Subject = subject;
var bodyBuilder = new BodyBuilder
{
TextBody = textBody
};
if (!string.IsNullOrEmpty(htmlBody))
bodyBuilder.HtmlBody = htmlBody;
emailMessage.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl);
await client.AuthenticateAsync(_configuration.Username, _configuration.Password);
await client.SendAsync(emailMessage);
await client.DisconnectAsync(true);
}
private static string _ConvertHtmlToPlainText(string html)
{
// Remove style tags and their contents
html = System.Text.RegularExpressions.Regex.Replace(html, "<style[^>]*>.*?</style>", "",
System.Text.RegularExpressions.RegexOptions.Singleline);
// Replace header tags with text + newlines
html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Replace line breaks
html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n");
// Remove all remaining HTML tags
html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "");
// Decode HTML entities
html = System.Net.WebUtility.HtmlDecode(html);
// Remove excess whitespace
html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim();
return html;
}
public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail,
string subject, TModel model)
where TComponent : IComponent
{
try
{
var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model);
var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody);
await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody);
}
catch (Exception err)
{
_logger.LogError(err, "Failed to render email template...");
throw;
}
}
}

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Pass.Auth;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

View File

@@ -1,14 +1,14 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
namespace DysonNetwork.Sphere.Pages.Auth;
public class AuthorizeModel(OidcProviderService oidcService, IConfiguration configuration) : PageModel
public class AuthorizeModel( DysonNetwork.Pass.Auth.OidcProvider.Services.OidcProviderService oidcService, IConfiguration configuration) : PageModel
{
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }

View File

@@ -2,8 +2,8 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Connection;
using NodaTime;
using Microsoft.EntityFrameworkCore;
@@ -11,13 +11,13 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class LoginModel(
AppDatabase db,
AccountService accounts,
AuthService auth,
DysonNetwork.Shared.Services.IAccountService accounts,
DysonNetwork.Pass.Auth.AuthService auth,
GeoIpService geo,
ActionLogService als
DysonNetwork.Shared.Services.IActionLogService als
) : PageModel
{
[BindProperty] [Required] public string Username { get; set; } = string.Empty;
[BindProperty]
@@ -52,13 +52,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var existingChallenge = await db.AuthChallenges
.Where(e => e.Account == account)
.Where(e => e.IpAddress == ipAddress)
.Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.FirstOrDefaultAsync();
var existingChallenge = await accounts.GetAuthChallenge(account.Id, ipAddress, userAgent, now);
if (existingChallenge is not null)
{
@@ -79,8 +73,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
AccountId = account.Id
}.Normalize();
await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync();
await accounts.CreateAuthChallenge(challenge);
// If we have a return URL, pass it to the verify page
if (TempData.TryGetValue("ReturnUrl", out var returnUrl) && returnUrl is string url)

View File

@@ -1,6 +1,5 @@
@page "/web/auth/challenge/{id:guid}/select-factor"
@using DysonNetwork.Shared.Models
@using DysonNetwork.Sphere.Account
@model DysonNetwork.Sphere.Pages.Auth.SelectFactorModel
@{
ViewData["Title"] = "Select Authentication Method";

View File

@@ -2,16 +2,14 @@ using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Account;
namespace DysonNetwork.Sphere.Pages.Auth;
public class SelectFactorModel(
AppDatabase db,
AccountService accounts
)
: PageModel
DysonNetwork.Shared.Services.IAccountService accounts
) : PageModel
{
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
@@ -31,13 +29,11 @@ public class SelectFactorModel(
public async Task<IActionResult> OnPostSelectFactorAsync()
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
var challenge = await accounts.GetAuthChallenge(Id);
if (challenge == null) return NotFound();
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
var factor = await accounts.GetAccountAuthFactor(SelectedFactorId, challenge.Account.Id);
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
return BadRequest("Invalid authentication method.");
@@ -81,16 +77,11 @@ public class SelectFactorModel(
private async Task LoadChallengeAndFactors()
{
AuthChallenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
AuthChallenge = await accounts.GetAuthChallenge(Id);
if (AuthChallenge != null)
{
AuthFactors = await db.AccountAuthFactors
.Where(e => e.AccountId == AuthChallenge.Account.Id)
.Where(e => e.EnabledAt != null && e.Trustworthy >= 1)
.ToListAsync();
AuthFactors = await accounts.GetAccountAuthFactors(AuthChallenge.Account.Id);
}
}

View File

@@ -1,6 +1,5 @@
@page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}"
@using DysonNetwork.Shared.Models
@using DysonNetwork.Sphere.Account
@model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel
@{
ViewData["Title"] = "Verify Your Identity";

View File

@@ -3,21 +3,18 @@ using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Services;
using NodaTime;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class VerifyFactorModel(
AppDatabase db,
AccountService accounts,
AuthService auth,
ActionLogService als,
IConfiguration configuration,
IHttpClientFactory httpClientFactory
)
: PageModel
IAccountService accountService,
DysonNetwork.Pass.Auth.AuthService authService,
IActionLogService actionLogService,
IConfiguration configuration
) : PageModel
{
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
@@ -55,30 +52,36 @@ namespace DysonNetwork.Sphere.Pages.Auth
try
{
if (await accounts.VerifyFactorCode(Factor, Code))
if (await accountService.VerifyFactorCode(Factor, Code))
{
AuthChallenge.StepRemain -= Factor.Trustworthy;
AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain);
AuthChallenge.BlacklistFactors.Add(Factor.Id);
db.Update(AuthChallenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
await actionLogService.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object>
{
{ "challenge_id", AuthChallenge.Id },
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
}, Request, AuthChallenge.Account);
},
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
AuthChallenge.Account
);
await db.SaveChangesAsync();
if (AuthChallenge.StepRemain == 0)
{
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
await actionLogService.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object>
{
{ "challenge_id", AuthChallenge.Id },
{ "account_id", AuthChallenge.AccountId }
}, Request, AuthChallenge.Account);
},
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
AuthChallenge.Account
);
return await ExchangeTokenAndRedirect();
}
@@ -98,16 +101,18 @@ namespace DysonNetwork.Sphere.Pages.Auth
{
if (AuthChallenge != null)
{
AuthChallenge.FailedAttempts++;
db.Update(AuthChallenge);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
await actionLogService.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object>
{
{ "challenge_id", AuthChallenge.Id },
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
}, Request, AuthChallenge.Account);
},
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
AuthChallenge.Account
);
}
@@ -118,47 +123,30 @@ namespace DysonNetwork.Sphere.Pages.Auth
private async Task LoadChallengeAndFactor()
{
AuthChallenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
AuthChallenge = await accountService.GetAuthChallenge(Id);
if (AuthChallenge?.Account != null)
{
Factor = await db.AccountAuthFactors
.FirstOrDefaultAsync(e => e.Id == FactorId &&
e.AccountId == AuthChallenge.Account.Id &&
e.EnabledAt != null &&
e.Trustworthy > 0);
Factor = await accountService.GetAccountAuthFactor(FactorId, AuthChallenge.Account.Id);
}
}
private async Task<IActionResult> ExchangeTokenAndRedirect()
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
var challenge = await accountService.GetAuthChallenge(Id);
if (challenge == null) return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
var session = await db.AuthSessions
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
var session = await accountService.CreateSession(
Instant.FromDateTimeUtc(DateTime.UtcNow),
Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
challenge.Account,
challenge
);
if (session == null)
{
session = new Session
{
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
Account = challenge.Account,
Challenge = challenge,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
}
var token = auth.CreateToken(session);
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions
var token = authService.CreateToken(session);
Response.Cookies.Append(accountService.GetAuthCookieTokenName(), token, new CookieOptions
{
HttpOnly = true,
Secure = !configuration.GetValue<bool>("Debug"),

View File

@@ -1,4 +1,4 @@
@using DysonNetwork.Sphere.Auth
@using DysonNetwork.Pass.Auth
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>

View File

@@ -1,5 +1,5 @@
@page "/spells/{spellWord}"
@using DysonNetwork.Sphere.Account
@using DysonNetwork.Shared.Models
@model DysonNetwork.Sphere.Pages.Spell.MagicSpellPage
@{

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
@@ -6,7 +6,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Pages.Spell;
public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageModel
public class MagicSpellPage(DysonNetwork.Shared.Services.IMagicSpellService magicSpellService) : PageModel
{
[BindProperty] public MagicSpell? CurrentSpell { get; set; }
[BindProperty] public string? NewPassword { get; set; }
@@ -17,12 +17,7 @@ public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageMode
{
spellWord = Uri.UnescapeDataString(spellWord);
var now = SystemClock.Instance.GetCurrentInstant();
CurrentSpell = await db.MagicSpells
.Where(e => e.Spell == spellWord)
.Where(e => e.ExpiresAt == null || now < e.ExpiresAt)
.Where(e => e.AffectedAt == null || now >= e.AffectedAt)
.Include(e => e.Account)
.FirstOrDefaultAsync();
CurrentSpell = await magicSpellService.GetMagicSpellAsync(spellWord);
return Page();
}
@@ -33,19 +28,15 @@ public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageMode
return Page();
var now = SystemClock.Instance.GetCurrentInstant();
var spell = await db.MagicSpells
.Where(e => e.Id == CurrentSpell.Id)
.Where(e => e.ExpiresAt == null || now < e.ExpiresAt)
.Where(e => e.AffectedAt == null || now >= e.AffectedAt)
.FirstOrDefaultAsync();
var spell = await magicSpellService.GetMagicSpellByIdAsync(CurrentSpell.Id);
if (spell == null || spell.Type == MagicSpellType.AuthPasswordReset && string.IsNullOrWhiteSpace(NewPassword))
return Page();
if (spell.Type == MagicSpellType.AuthPasswordReset)
await spells.ApplyPasswordReset(spell, NewPassword!);
await magicSpellService.ApplyPasswordReset(spell, NewPassword!);
else
await spells.ApplyMagicSpell(spell);
await magicSpellService.ApplyMagicSpell(spell.Spell);
IsSuccess = true;
return Page();
}

View File

@@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Services;
using DysonNetwork.Shared.Permission;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
@@ -19,8 +19,8 @@ public class PostController(
AppDatabase db,
PostService ps,
PublisherService pub,
RelationshipService rels,
ActionLogService als
DysonNetwork.Shared.Services.IRelationshipService rels,
DysonNetwork.Shared.Services.IActionLogService als
)
: ControllerBase
{
@@ -33,7 +33,9 @@ public class PostController(
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var userFriends = currentUser is null
? []
: (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList();
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
@@ -67,8 +69,10 @@ public class PostController(
public async Task<ActionResult<Post>> GetPost(Guid id)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var currentUser = currentUserValue as Account;
var userFriends = currentUser is null
? []
: (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList();
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var post = await db.Posts
@@ -99,8 +103,10 @@ public class PostController(
return BadRequest("Search query cannot be empty");
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var currentUser = currentUserValue as Account;
var userFriends = currentUser is null
? []
: (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList();
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var queryable = db.Posts
@@ -136,8 +142,10 @@ public class PostController(
[FromQuery] int take = 20)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var currentUser = currentUserValue as Account;
var userFriends = currentUser is null
? []
: (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList();
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var parent = await db.Posts
@@ -264,9 +272,12 @@ public class PostController(
return BadRequest(err.Message);
}
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PostCreate,
new Dictionary<string, object> { { "post_id", post.Id } }, Request
new Dictionary<string, object> { { "post_id", post.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return post;
@@ -284,8 +295,8 @@ public class PostController(
public async Task<ActionResult<PostReaction>> ReactPost(Guid id, [FromBody] PostReactionRequest request)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Shared.Models.Account currentUser) return Unauthorized();
var userFriends = await rels.ListAccountFriends(currentUser);
if (currentUserValue is not Account currentUser) return Unauthorized();
var userFriends = (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList();
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
var post = await db.Posts
@@ -319,9 +330,12 @@ public class PostController(
if (isRemoving) return NoContent();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PostReact,
new Dictionary<string, object> { { "post_id", post.Id }, { "reaction", request.Symbol } }, Request
new Dictionary<string, object> { { "post_id", post.Id }, { "reaction", request.Symbol } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(reaction);
@@ -368,9 +382,12 @@ public class PostController(
return BadRequest(err.Message);
}
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PostUpdate,
new Dictionary<string, object> { { "post_id", post.Id } }, Request
new Dictionary<string, object> { { "post_id", post.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(post);
@@ -392,9 +409,12 @@ public class PostController(
await ps.DeletePostAsync(post);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PostDelete,
new Dictionary<string, object> { { "post_id", post.Id } }, Request
new Dictionary<string, object> { { "post_id", post.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Localization;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Publisher;
@@ -158,14 +159,13 @@ public partial class PostService(
var sender = post.Publisher;
using var scope = factory.CreateScope();
var pub = scope.ServiceProvider.GetRequiredService<PublisherService>();
var nty = scope.ServiceProvider.GetRequiredService<NotificationService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<PostService>>();
var nty = scope.ServiceProvider.GetRequiredService<INotificationService>();
try
{
var members = await pub.GetPublisherMembers(post.RepliedPost.PublisherId);
foreach (var member in members)
{
AccountService.SetCultureInfo(member.Account);
CultureInfoService.SetCultureInfo(member.Account);
var (_, content) = ChopPostForNotification(post);
await nty.SendNotification(
member.Account,
@@ -439,14 +439,14 @@ public partial class PostService(
{
using var scope = factory.CreateScope();
var pub = scope.ServiceProvider.GetRequiredService<PublisherService>();
var nty = scope.ServiceProvider.GetRequiredService<NotificationService>();
var nty = scope.ServiceProvider.GetRequiredService<INotificationService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<PostService>>();
try
{
var members = await pub.GetPublisherMembers(post.PublisherId);
foreach (var member in members)
{
AccountService.SetCultureInfo(member.Account);
CultureInfoService.SetCultureInfo(member.Account);
await nty.SendNotification(
member.Account,
"posts.reactions.new",

View File

@@ -2,6 +2,9 @@ using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Startup;
using Microsoft.EntityFrameworkCore;
using tusdotnet.Stores;
using MagicOnion.Client;
using DysonNetwork.Shared.Services;
using Grpc.Net.Client;
var builder = WebApplication.CreateBuilder(args);
@@ -20,6 +23,42 @@ builder.Services.AddAppSwagger();
// Add gRPC services
builder.Services.AddGrpc();
// Configure MagicOnion client for IAccountService
builder.Services.AddSingleton<IAccountService>(provider =>
{
var passServiceUrl = builder.Configuration["PassService:Url"];
if (string.IsNullOrEmpty(passServiceUrl))
{
throw new InvalidOperationException("PassService:Url configuration is missing.");
}
var channel = GrpcChannel.ForAddress(passServiceUrl);
return MagicOnionClient.Create<IAccountService>(channel);
});
// Configure MagicOnion client for IPublisherService
builder.Services.AddSingleton<IPublisherService>(provider =>
{
var passServiceUrl = builder.Configuration["PassService:Url"];
if (string.IsNullOrEmpty(passServiceUrl))
{
throw new InvalidOperationException("PassService:Url configuration is missing.");
}
var channel = GrpcChannel.ForAddress(passServiceUrl);
return MagicOnionClient.Create<IPublisherService>(channel);
});
// Configure MagicOnion client for ICustomAppService
builder.Services.AddSingleton<ICustomAppService>(provider =>
{
var passServiceUrl = builder.Configuration["PassService:Url"];
if (string.IsNullOrEmpty(passServiceUrl))
{
throw new InvalidOperationException("PassService:Url configuration is missing.");
}
var channel = GrpcChannel.ForAddress(passServiceUrl);
return MagicOnionClient.Create<ICustomAppService>(channel);
});
// Add file storage
builder.Services.AddAppFileStorage(builder.Configuration);
@@ -47,8 +86,8 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
// Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore);
// Map gRPC services
app.MapGrpcService<DysonNetwork.Sphere.Auth.AuthGrpcService>();
app.MapGrpcService<DysonNetwork.Sphere.Account.AccountGrpcService>();
// Remove direct gRPC service mappings for Pass services
// app.MapGrpcService<DysonNetwork.Pass.Auth.AuthGrpcService>();
// app.MapGrpcService<DysonNetwork.Pass.Account.AccountGrpcService>();
app.Run();

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Permission;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
@@ -17,7 +17,9 @@ public class PublisherController(
AppDatabase db,
PublisherService ps,
FileReferenceService fileRefService,
ActionLogService als)
IAccountService accounts,
IActionLogService als
)
: ControllerBase
{
[HttpGet("{name}")]
@@ -29,10 +31,7 @@ public class PublisherController(
if (publisher is null) return NotFound();
if (publisher.AccountId is null) return Ok(publisher);
var account = await db.Accounts
.Where(a => a.Id == publisher.AccountId)
.Include(a => a.Profile)
.FirstOrDefaultAsync();
var account = await accounts.GetAccountById(publisher.AccountId.Value, true);
publisher.Account = account;
return Ok(publisher);
@@ -80,7 +79,7 @@ public class PublisherController(
public class PublisherMemberRequest
{
[Required] public long RelatedUserId { get; set; }
[Required] public Guid RelatedUserId { get; set; }
[Required] public PublisherMemberRole Role { get; set; }
}
@@ -92,7 +91,7 @@ public class PublisherController(
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
var relatedUser = await accounts.GetAccountById(request.RelatedUserId);
if (relatedUser is null) return BadRequest("Related user was not found");
var publisher = await db.Publishers
@@ -113,13 +112,16 @@ public class PublisherController(
db.PublisherMembers.Add(newMember);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherMemberInvite,
new Dictionary<string, object>
{
{ "publisher_id", publisher.Id },
{ "account_id", relatedUser.Id }
}, Request
},
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(newMember);
@@ -143,9 +145,12 @@ public class PublisherController(
db.Update(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherMemberJoin,
new Dictionary<string, object> { { "account_id", member.AccountId } }, Request
new Dictionary<string, object> { { "account_id", member.AccountId } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(member);
@@ -168,9 +173,12 @@ public class PublisherController(
db.PublisherMembers.Remove(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherMemberLeave,
new Dictionary<string, object> { { "account_id", member.AccountId } }, Request
new Dictionary<string, object> { { "account_id", member.AccountId } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -198,13 +206,16 @@ public class PublisherController(
db.PublisherMembers.Remove(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherMemberKick,
new Dictionary<string, object>
{
{ "publisher_id", publisher.Id },
{ "account_id", memberId }
}, Request
},
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -222,8 +233,9 @@ public class PublisherController(
[HttpPost("individual")]
[Authorize]
[RequiredPermission("global", "publishers.create")]
public async Task<ActionResult<Shared.Models.Publisher>> CreatePublisherIndividual([FromBody] PublisherRequest request)
[DysonNetwork.Shared.Permission.RequiredPermission("global", "publishers.create")]
public async Task<ActionResult<Shared.Models.Publisher>> CreatePublisherIndividual(
[FromBody] PublisherRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
@@ -261,9 +273,12 @@ public class PublisherController(
background
);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherCreate,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
new Dictionary<string, object> { { "publisher_id", publisher.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(publisher);
@@ -271,7 +286,7 @@ public class PublisherController(
[HttpPost("organization/{realmSlug}")]
[Authorize]
[RequiredPermission("global", "publishers.create")]
[DysonNetwork.Shared.Permission.RequiredPermission("global", "publishers.create")]
public async Task<ActionResult<Shared.Models.Publisher>> CreatePublisherOrganization(string realmSlug,
[FromBody] PublisherRequest request)
{
@@ -316,9 +331,12 @@ public class PublisherController(
background
);
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherCreate,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
new Dictionary<string, object> { { "publisher_id", publisher.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(publisher);
@@ -394,9 +412,12 @@ public class PublisherController(
db.Update(publisher);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherUpdate,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
new Dictionary<string, object> { { "publisher_id", publisher.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(publisher);
@@ -432,9 +453,12 @@ public class PublisherController(
db.Publishers.Remove(publisher);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.PublisherDelete,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
new Dictionary<string, object> { { "publisher_id", publisher.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -531,7 +555,7 @@ public class PublisherController(
[HttpPost("{name}/features")]
[Authorize]
[RequiredPermission("maintenance", "publishers.features")]
[DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "publishers.features")]
public async Task<ActionResult<PublisherFeature>> AddPublisherFeature(string name,
[FromBody] PublisherFeatureRequest request)
{

View File

@@ -1,6 +1,6 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Pass.Account;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage;

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
@@ -15,9 +16,10 @@ public class RealmController(
AppDatabase db,
RealmService rs,
FileReferenceService fileRefService,
RelationshipService rels,
ActionLogService als,
AccountEventService aes
IRelationshipService rels,
IActionLogService als,
IAccountEventService aes,
IAccountService accounts
) : Controller
{
[HttpGet("{slug}")]
@@ -79,7 +81,7 @@ public class RealmController(
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
var relatedUser = await accounts.GetAccountById(request.RelatedUserId);
if (relatedUser is null) return BadRequest("Related user was not found");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
@@ -111,9 +113,12 @@ public class RealmController(
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmInvite,
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", member.AccountId } }, Request
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", member.AccountId } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
member.Account = relatedUser;
@@ -141,10 +146,12 @@ public class RealmController(
db.Update(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmJoin,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(member);
@@ -167,10 +174,12 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -245,7 +254,6 @@ public class RealmController(
}
[HttpGet("{slug}/members/me")]
[Authorize]
public async Task<ActionResult<RealmMember>> GetCurrentIdentity(string slug)
@@ -284,10 +292,12 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -349,9 +359,12 @@ public class RealmController(
db.Realms.Add(realm);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmCreate,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
new Dictionary<string, object> { { "realm_id", realm.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
var realmResourceId = $"realm:{realm.Id}";
@@ -455,9 +468,12 @@ public class RealmController(
db.Realms.Update(realm);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmUpdate,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
new Dictionary<string, object> { { "realm_id", realm.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(realm);
@@ -494,10 +510,12 @@ public class RealmController(
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmJoin,
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", currentUser.Id } },
Request
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(member);
@@ -525,10 +543,12 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.ChatroomKick,
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", memberId } },
Request
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return NoContent();
@@ -559,11 +579,13 @@ public class RealmController(
db.RealmMembers.Update(member);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmAdjustRole,
new Dictionary<string, object>
{ { "realm_id", realm.Id }, { "account_id", memberId }, { "new_role", newRole } },
Request
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
return Ok(member);
@@ -588,9 +610,12 @@ public class RealmController(
db.Realms.Remove(realm);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
await als.CreateActionLogFromRequest(
ActionLogType.RealmDelete,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
new Dictionary<string, object> { { "realm_id", realm.Id } },
Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
currentUser
);
// Delete all file references for this realm
@@ -599,4 +624,4 @@ public class RealmController(
return NoContent();
}
}
}

View File

@@ -1,16 +1,16 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
namespace DysonNetwork.Sphere.Realm;
public class RealmService(AppDatabase db, NotificationService nty, IStringLocalizer<NotificationResource> localizer)
public class RealmService(AppDatabase db, DysonNetwork.Shared.Services.INotificationService nty, IStringLocalizer<NotificationResource> localizer)
{
public async Task SendInviteNotify(RealmMember member)
{
AccountService.SetCultureInfo(member.Account);
CultureInfoService.SetCultureInfo(member.Account);
await nty.SendNotification(
member.Account,
"invites.realms",

View File

@@ -1,105 +0,0 @@
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Safety;
public class SafetyService(AppDatabase db, ILogger<SafetyService> logger)
{
public async Task<AbuseReport> CreateReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId)
{
// Check if a similar report already exists from this user
var existingReport = await db.AbuseReports
.Where(r => r.ResourceIdentifier == resourceIdentifier &&
r.AccountId == accountId &&
r.DeletedAt == null)
.FirstOrDefaultAsync();
if (existingReport != null)
{
throw new InvalidOperationException("You have already reported this content.");
}
var report = new AbuseReport
{
ResourceIdentifier = resourceIdentifier,
Type = type,
Reason = reason,
AccountId = accountId
};
db.AbuseReports.Add(report);
await db.SaveChangesAsync();
logger.LogInformation("New abuse report created: {ReportId} for resource {ResourceId}",
report.Id, resourceIdentifier);
return report;
}
public async Task<int> CountReports(bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => includeResolved || r.ResolvedAt == null)
.CountAsync();
}
public async Task<int> CountUserReports(Guid accountId, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => r.AccountId == accountId)
.Where(r => includeResolved || r.ResolvedAt == null)
.CountAsync();
}
public async Task<List<AbuseReport>> GetReports(int skip = 0, int take = 20, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => includeResolved || r.ResolvedAt == null)
.OrderByDescending(r => r.CreatedAt)
.Skip(skip)
.Take(take)
.Include(r => r.Account)
.ToListAsync();
}
public async Task<List<AbuseReport>> GetUserReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false)
{
return await db.AbuseReports
.Where(r => r.AccountId == accountId)
.Where(r => includeResolved || r.ResolvedAt == null)
.OrderByDescending(r => r.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
}
public async Task<AbuseReport?> GetReportById(Guid id)
{
return await db.AbuseReports
.Include(r => r.Account)
.FirstOrDefaultAsync(r => r.Id == id);
}
public async Task<AbuseReport> ResolveReport(Guid id, string resolution)
{
var report = await db.AbuseReports.FindAsync(id);
if (report == null)
{
throw new KeyNotFoundException("Report not found");
}
report.ResolvedAt = SystemClock.Instance.GetCurrentInstant();
report.Resolution = resolution;
await db.SaveChangesAsync();
return report;
}
public async Task<int> GetPendingReportsCount()
{
return await db.AbuseReports
.Where(r => r.ResolvedAt == null)
.CountAsync();
}
}

View File

@@ -20,13 +20,15 @@ using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis;
using System.Text.Json;
using System.Threading.RateLimiting;
using DysonNetwork.Pass.Safety;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Safety;
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
using tusdotnet.Stores;
using DysonNetwork.Shared.Etcd;
using DysonNetwork.Shared.Services;
namespace DysonNetwork.Sphere.Startup;
@@ -187,7 +189,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<EmailService>();
services.AddScoped<FileService>();
services.AddScoped<FileReferenceService>();
services.AddScoped<FileReferenceMigrationService>();
services.AddScoped<PublisherService>();
services.AddScoped<PublisherSubscriptionService>();
services.AddScoped<ActivityService>();
@@ -206,6 +207,15 @@ public static class ServiceCollectionExtensions
services.AddScoped<SafetyService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<CustomAppService>();
// Add MagicOnion services
services.AddMagicOnionService<IAccountService>();
services.AddMagicOnionService<IAccountEventService>();
services.AddMagicOnionService<IAccountUsernameService>();
services.AddMagicOnionService<IActionLogService>();
services.AddMagicOnionService<IMagicSpellService>();
services.AddMagicOnionService<INotificationService>();
services.AddMagicOnionService<IRelationshipService>();
return services;
}

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage;
@@ -76,7 +76,7 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
}
[HttpPost]
[RequiredPermission("global", "stickers.packs.create")]
[DysonNetwork.Shared.Permission.RequiredPermission("global", "stickers.packs.create")]
public async Task<ActionResult<StickerPack>> CreateStickerPack([FromBody] StickerPackRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
@@ -271,7 +271,7 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa
public const int MaxStickersPerPack = 24;
[HttpPost("{packId:guid}/content")]
[RequiredPermission("global", "stickers.create")]
[DysonNetwork.Shared.Permission.RequiredPermission("global", "stickers.create")]
public async Task<IActionResult> CreateSticker(Guid packId, [FromBody] StickerRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -13,8 +13,7 @@ public class FileController(
AppDatabase db,
FileService fs,
IConfiguration configuration,
IWebHostEnvironment env,
FileReferenceMigrationService rms
IWebHostEnvironment env
) : ControllerBase
{
[HttpGet("{id}")]
@@ -108,13 +107,4 @@ public class FileController(
return NoContent();
}
[HttpPost("/maintenance/migrateReferences")]
[Authorize]
[RequiredPermission("maintenance", "files.references")]
public async Task<ActionResult> MigrateFileReferences()
{
await rms.ScanAndMigrateReferences();
return Ok();
}
}

View File

@@ -1,340 +0,0 @@
using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Storage;
public class FileReferenceMigrationService(AppDatabase db)
{
public async Task ScanAndMigrateReferences()
{
// Scan Posts for file references
await ScanPosts();
// Scan Messages for file references
await ScanMessages();
// Scan Profiles for file references
await ScanProfiles();
// Scan Chat entities for file references
await ScanChatRooms();
// Scan Realms for file references
await ScanRealms();
// Scan Publishers for file references
await ScanPublishers();
// Scan Stickers for file references
await ScanStickers();
}
private async Task ScanPosts()
{
var posts = await db.Posts
.Include(p => p.OutdatedAttachments)
.Where(p => p.OutdatedAttachments.Any())
.ToListAsync();
foreach (var post in posts)
{
var updatedAttachments = new List<CloudFileReferenceObject>();
foreach (var attachment in post.OutdatedAttachments)
{
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == attachment.Id);
if (file != null)
{
// Create a reference for the file
var reference = new CloudFileReference
{
FileId = file.Id,
File = file,
Usage = "post",
ResourceId = post.ResourceIdentifier
};
await db.FileReferences.AddAsync(reference);
updatedAttachments.Add(file.ToReferenceObject());
}
else
{
// Keep the existing reference object if file not found
updatedAttachments.Add(attachment.ToReferenceObject());
}
}
post.Attachments = updatedAttachments;
db.Posts.Update(post);
}
await db.SaveChangesAsync();
}
private async Task ScanMessages()
{
var messages = await db.ChatMessages
.Include(m => m.OutdatedAttachments)
.Where(m => m.OutdatedAttachments.Any())
.ToListAsync();
var fileReferences = messages.SelectMany(message => message.OutdatedAttachments.Select(attachment =>
new CloudFileReference
{
FileId = attachment.Id,
File = attachment,
Usage = "chat",
ResourceId = message.ResourceIdentifier,
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
})
).ToList();
foreach (var message in messages)
{
message.Attachments = message.OutdatedAttachments.Select(a => a.ToReferenceObject()).ToList();
db.ChatMessages.Update(message);
}
await db.BulkInsertAsync(fileReferences);
await db.SaveChangesAsync();
}
private async Task ScanProfiles()
{
var profiles = await db.AccountProfiles
.Where(p => p.PictureId != null || p.BackgroundId != null)
.ToListAsync();
foreach (var profile in profiles)
{
if (profile is { PictureId: not null, Picture: null })
{
var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == profile.PictureId);
if (avatarFile != null)
{
// Create a reference for the avatar file
var reference = new CloudFileReference
{
FileId = avatarFile.Id,
File = avatarFile,
Usage = "profile.picture",
ResourceId = profile.Id.ToString()
};
await db.FileReferences.AddAsync(reference);
profile.Picture = avatarFile.ToReferenceObject();
db.AccountProfiles.Update(profile);
}
}
// Also check for the banner if it exists
if (profile is not { BackgroundId: not null, Background: null }) continue;
var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == profile.BackgroundId);
if (bannerFile == null) continue;
{
// Create a reference for the banner file
var reference = new CloudFileReference
{
FileId = bannerFile.Id,
File = bannerFile,
Usage = "profile.background",
ResourceId = profile.Id.ToString()
};
await db.FileReferences.AddAsync(reference);
profile.Background = bannerFile.ToReferenceObject();
db.AccountProfiles.Update(profile);
}
}
await db.SaveChangesAsync();
}
private async Task ScanChatRooms()
{
var chatRooms = await db.ChatRooms
.Where(c => c.PictureId != null || c.BackgroundId != null)
.ToListAsync();
foreach (var chatRoom in chatRooms)
{
if (chatRoom is { PictureId: not null, Picture: null })
{
var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == chatRoom.PictureId);
if (avatarFile != null)
{
// Create a reference for the avatar file
var reference = new CloudFileReference
{
FileId = avatarFile.Id,
File = avatarFile,
Usage = "chatroom.picture",
ResourceId = chatRoom.ResourceIdentifier
};
await db.FileReferences.AddAsync(reference);
chatRoom.Picture = avatarFile.ToReferenceObject();
db.ChatRooms.Update(chatRoom);
}
}
if (chatRoom is not { BackgroundId: not null, Background: null }) continue;
var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == chatRoom.BackgroundId);
if (bannerFile == null) continue;
{
// Create a reference for the banner file
var reference = new CloudFileReference
{
FileId = bannerFile.Id,
File = bannerFile,
Usage = "chatroom.background",
ResourceId = chatRoom.ResourceIdentifier
};
await db.FileReferences.AddAsync(reference);
chatRoom.Background = bannerFile.ToReferenceObject();
db.ChatRooms.Update(chatRoom);
}
}
await db.SaveChangesAsync();
}
private async Task ScanRealms()
{
var realms = await db.Realms
.Where(r => r.PictureId != null && r.BackgroundId != null)
.ToListAsync();
foreach (var realm in realms)
{
// Process avatar if it exists
if (realm is { PictureId: not null, Picture: null })
{
var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == realm.PictureId);
if (avatarFile != null)
{
// Create a reference for the avatar file
var reference = new CloudFileReference
{
FileId = avatarFile.Id,
File = avatarFile,
Usage = "realm.picture",
ResourceId = realm.ResourceIdentifier
};
await db.FileReferences.AddAsync(reference);
realm.Picture = avatarFile.ToReferenceObject();
}
}
// Process banner if it exists
if (realm is { BackgroundId: not null, Background: null })
{
var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == realm.BackgroundId);
if (bannerFile != null)
{
// Create a reference for the banner file
var reference = new CloudFileReference
{
FileId = bannerFile.Id,
File = bannerFile,
Usage = "realm.background",
ResourceId = realm.ResourceIdentifier
};
await db.FileReferences.AddAsync(reference);
realm.Background = bannerFile.ToReferenceObject();
}
}
db.Realms.Update(realm);
}
await db.SaveChangesAsync();
}
private async Task ScanPublishers()
{
var publishers = await db.Publishers
.Where(p => p.PictureId != null || p.BackgroundId != null)
.ToListAsync();
foreach (var publisher in publishers)
{
if (publisher is { PictureId: not null, Picture: null })
{
var pictureFile = await db.Files.FirstOrDefaultAsync(f => f.Id == publisher.PictureId);
if (pictureFile != null)
{
// Create a reference for the picture file
var reference = new CloudFileReference
{
FileId = pictureFile.Id,
File = pictureFile,
Usage = "publisher.picture",
ResourceId = publisher.Id.ToString()
};
await db.FileReferences.AddAsync(reference);
publisher.Picture = pictureFile.ToReferenceObject();
}
}
if (publisher is { BackgroundId: not null, Background: null })
{
var backgroundFile = await db.Files.FirstOrDefaultAsync(f => f.Id == publisher.BackgroundId);
if (backgroundFile != null)
{
// Create a reference for the background file
var reference = new CloudFileReference
{
FileId = backgroundFile.Id,
File = backgroundFile,
Usage = "publisher.background",
ResourceId = publisher.ResourceIdentifier
};
await db.FileReferences.AddAsync(reference);
publisher.Background = backgroundFile.ToReferenceObject();
}
}
db.Publishers.Update(publisher);
}
await db.SaveChangesAsync();
}
private async Task ScanStickers()
{
var stickers = await db.Stickers
.Where(s => s.ImageId != null && s.Image == null)
.ToListAsync();
foreach (var sticker in stickers)
{
var imageFile = await db.Files.FirstOrDefaultAsync(f => f.Id == sticker.ImageId);
if (imageFile != null)
{
// Create a reference for the sticker image file
var reference = new CloudFileReference
{
FileId = imageFile.Id,
File = imageFile,
Usage = "sticker.image",
ResourceId = sticker.ResourceIdentifier
};
await db.FileReferences.AddAsync(reference);
sticker.Image = imageFile.ToReferenceObject();
db.Stickers.Update(sticker);
}
}
await db.SaveChangesAsync();
}
}

View File

@@ -1,5 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using EFCore.BulkExtensions;
using Quartz;

View File

@@ -1,24 +1,21 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
using DysonNetwork.Shared.Services;
namespace DysonNetwork.Sphere.Storage.Handlers;
public class LastActiveInfo
{
public Session Session { get; set; } = null!;
public Shared.Models.Account Account { get; set; } = null!;
public Account Account { get; set; } = null!;
public Instant SeenAt { get; set; }
}
public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<LastActiveInfo>
public class LastActiveFlushHandler(DysonNetwork.Shared.Services.IAccountService accounts, DysonNetwork.Shared.Services.IAccountProfileService profiles) : IFlushHandler<LastActiveInfo>
{
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
{
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
// Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt
var distinctItems = items
.GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id))
@@ -36,19 +33,11 @@ public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHa
// Update sessions using native EF Core ExecuteUpdateAsync
foreach (var kvp in sessionIdMap)
{
await db.AuthSessions
.Where(s => s.Id == kvp.Key)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, kvp.Value));
}
await accounts.UpdateSessionLastGrantedAt(kvp.Key, kvp.Value);
// Update account profiles using native EF Core ExecuteUpdateAsync
foreach (var kvp in accountIdMap)
{
await db.AccountProfiles
.Where(a => a.AccountId == kvp.Key)
.ExecuteUpdateAsync(a => a.SetProperty(x => x.LastSeenAt, kvp.Value));
}
await accounts.UpdateAccountProfileLastSeenAt(kvp.Key, kvp.Value);
}
}

View File

@@ -1,7 +1,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using tusdotnet.Interfaces;
@@ -10,7 +10,7 @@ using tusdotnet.Models.Configuration;
namespace DysonNetwork.Sphere.Storage;
public abstract class TusService
public class TusService(DefaultTusConfiguration config, ITusStore store)
{
public static DefaultTusConfiguration BuildConfiguration(ITusStore store) => new()
{

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Pass.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

View File

@@ -1,6 +1,6 @@
using System.Globalization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
@@ -12,7 +12,8 @@ namespace DysonNetwork.Sphere.Wallet;
public class PaymentService(
AppDatabase db,
WalletService wat,
NotificationService nty,
INotificationService nty,
IAccountService acc,
IStringLocalizer<NotificationResource> localizer
)
{
@@ -197,10 +198,10 @@ public class PaymentService(
private async Task NotifyOrderPaid(Order order)
{
if (order.PayeeWallet is null) return;
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId);
var account = await acc.GetAccountById(order.PayeeWallet.AccountId);
if (account is null) return;
AccountService.SetCultureInfo(account);
// AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];

View File

@@ -5,11 +5,14 @@ using Quartz;
namespace DysonNetwork.Sphere.Wallet;
using DysonNetwork.Shared.Services;
public class SubscriptionRenewalJob(
AppDatabase db,
SubscriptionService subscriptionService,
PaymentService paymentService,
WalletService walletService,
IAccountProfileService accountProfileService,
ILogger<SubscriptionRenewalJob> logger
) : IJob
{
@@ -138,10 +141,7 @@ public class SubscriptionRenewalJob(
logger.LogInformation("Validating user stellar memberships...");
// Get all account IDs with StellarMembership
var accountsWithMemberships = await db.AccountProfiles
.Where(a => a.StellarMembership != null)
.Select(a => new { a.Id, a.StellarMembership })
.ToListAsync();
var accountsWithMemberships = await accountProfileService.GetAccountsWithStellarMembershipAsync();
logger.LogInformation("Found {Count} accounts with stellar memberships to validate",
accountsWithMemberships.Count);
@@ -187,11 +187,7 @@ public class SubscriptionRenewalJob(
}
// Update all accounts in a single batch operation
var updatedCount = await db.AccountProfiles
.Where(a => accountIdsToUpdate.Contains(a.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(a => a.StellarMembership, p => null)
);
var updatedCount = await accountProfileService.ClearStellarMembershipsAsync(accountIdsToUpdate);
logger.LogInformation("Updated {Count} accounts with expired/invalid stellar memberships", updatedCount);
}

View File

@@ -1,7 +1,8 @@
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Localization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
@@ -14,8 +15,9 @@ namespace DysonNetwork.Sphere.Wallet;
public class SubscriptionService(
AppDatabase db,
PaymentService payment,
AccountService accounts,
NotificationService nty,
IAccountService accounts,
IAccountProfileService profiles,
INotificationService nty,
IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration,
ICacheService cache,
@@ -23,7 +25,7 @@ public class SubscriptionService(
)
{
public async Task<Subscription> CreateSubscriptionAsync(
Shared.Models.Account account,
Account account,
string identifier,
string paymentMethod,
PaymentDetails paymentDetails,
@@ -57,9 +59,7 @@ public class SubscriptionService(
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
var profile = await profiles.GetAccountProfileByIdAsync(account.Id);
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
@@ -141,7 +141,7 @@ public class SubscriptionService(
if (!string.IsNullOrEmpty(provider))
account = await accounts.LookupAccountByConnection(order.AccountId, provider);
else if (Guid.TryParse(order.AccountId, out var accountId))
account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId);
account = await accounts.GetAccountById(accountId);
if (account is null)
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
@@ -302,9 +302,7 @@ public class SubscriptionService(
if (subscription.Identifier.StartsWith(SubscriptionType.StellarProgram))
{
await db.AccountProfiles
.Where(a => a.AccountId == subscription.AccountId)
.ExecuteUpdateAsync(s => s.SetProperty(a => a.StellarMembership, subscription.ToReference()));
await profiles.UpdateStellarMembershipAsync(subscription.AccountId, subscription.ToReference());
}
await NotifySubscriptionBegun(subscription);
@@ -348,10 +346,10 @@ public class SubscriptionService(
private async Task NotifySubscriptionBegun(Subscription subscription)
{
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId);
var account = await accounts.GetAccountById(subscription.AccountId);
if (account is null) return;
AccountService.SetCultureInfo(account);
CultureInfoService.SetCultureInfo(account);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -75,7 +75,7 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
[HttpPost("balance")]
[Authorize]
[RequiredPermission("maintenance", "wallets.balance.modify")]
[DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "wallets.balance.modify")]
public async Task<ActionResult<Transaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request)
{
var wallet = await ws.GetWalletAsync(request.AccountId);