♻️ No idea, but errors all gone

This commit is contained in:
2025-07-08 23:55:31 +08:00
parent 2c67472894
commit 63b2b989ba
74 changed files with 1551 additions and 1100 deletions

View File

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

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 Microsoft.Extensions.Logging;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using MagicOnion.Server; 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; namespace DysonNetwork.Pass.Account;
public class AccountService( public class AccountService(
AppDatabase db, AppDatabase db,
// MagicSpellService spells, MagicSpellService spells,
// AccountUsernameService uname, AccountUsernameService uname,
// NotificationService nty, NotificationService nty,
// EmailService mailer, // EmailService mailer, // Commented out for now
// IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
ICacheService cache, ICacheService cache,
ILogger<AccountService> logger ILogger<AccountService> logger,
AuthService authService,
ActionLogService actionLogService,
RelationshipService relationshipService
) : ServiceBase<IAccountService>, IAccountService ) : ServiceBase<IAccountService>, IAccountService
{ {
public static void SetCultureInfo(Shared.Models.Account account) public static void SetCultureInfo(Shared.Models.Account account)
@@ -134,15 +143,15 @@ public class AccountService(
} }
else else
{ {
// var spell = await spells.CreateMagicSpell( var spell = await spells.CreateMagicSpell(
// account, account,
// MagicSpellType.AccountActivation, MagicSpellType.AccountActivation,
// new Dictionary<string, object> new Dictionary<string, object>
// { {
// { "contact_method", account.Contacts.First().Content } { "contact_method", account.Contacts.First().Content }
// } }
// ); );
// await spells.NotifyMagicSpell(spell, true); await spells.NotifyMagicSpell(spell, true);
} }
db.Accounts.Add(account); db.Accounts.Add(account);
@@ -167,9 +176,7 @@ public class AccountService(
? userInfo.DisplayName ? userInfo.DisplayName
: $"{userInfo.FirstName} {userInfo.LastName}".Trim(); : $"{userInfo.FirstName} {userInfo.LastName}".Trim();
// Generate username from email var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
// var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
var username = userInfo.Email.Split('@')[0]; // Placeholder
return await CreateAccount( return await CreateAccount(
username, username,
@@ -184,28 +191,26 @@ public class AccountService(
public async Task RequestAccountDeletion(Shared.Models.Account account) public async Task RequestAccountDeletion(Shared.Models.Account account)
{ {
await Task.CompletedTask; var spell = await spells.CreateMagicSpell(
// var spell = await spells.CreateMagicSpell( account,
// account, MagicSpellType.AccountRemoval,
// MagicSpellType.AccountRemoval, new Dictionary<string, object>(),
// new Dictionary<string, object>(), SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
// SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true
// preventRepeat: true );
// ); await spells.NotifyMagicSpell(spell);
// await spells.NotifyMagicSpell(spell);
} }
public async Task RequestPasswordReset(Shared.Models.Account account) public async Task RequestPasswordReset(Shared.Models.Account account)
{ {
await Task.CompletedTask; var spell = await spells.CreateMagicSpell(
// var spell = await spells.CreateMagicSpell( account,
// account, MagicSpellType.AuthPasswordReset,
// MagicSpellType.AuthPasswordReset, new Dictionary<string, object>(),
// new Dictionary<string, object>(), SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
// SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true
// preventRepeat: true );
// ); await spells.NotifyMagicSpell(spell);
// await spells.NotifyMagicSpell(spell);
} }
public async Task<bool> CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type) public async Task<bool> CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type)
@@ -331,7 +336,6 @@ public class AccountService(
{ {
var count = await db.AccountAuthFactors var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId) .Where(f => f.AccountId == factor.AccountId)
// .If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
.CountAsync(); .CountAsync();
if (count <= 1) if (count <= 1)
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor."); throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
@@ -357,14 +361,14 @@ public class AccountService(
if (await _GetFactorCode(factor) is not null) if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration."); throw new InvalidOperationException("A factor code has been sent and in active duration.");
// await nty.SendNotification( await nty.SendNotification(
// account, account,
// "auth.verification", "auth.verification",
// localizer["AuthCodeTitle"], localizer["AuthCodeTitle"],
// null, null,
// localizer["AuthCodeBody", code], localizer["AuthCodeBody", code],
// save: true save: true
// ); );
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
break; break;
case AccountAuthFactorType.EmailCode: case AccountAuthFactorType.EmailCode:
@@ -399,11 +403,11 @@ public class AccountService(
return; 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, // account.Nick,
// contact.Content, // contact.Content,
// localizer["VerificationEmail"], // localizer["VerificationEmail"],
// new VerificationEmailModel // new DysonNetwork.Pass.Pages.Emails.VerificationEmailModel
// { // {
// Name = account.Name, // Name = account.Name,
// Code = code // Code = code
@@ -456,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 var session = await db.AuthSessions
.Include(s => s.Challenge) .Include(s => s.Challenge)
@@ -493,7 +497,7 @@ public class AccountService(
.ToListAsync(); .ToListAsync();
if (session.Challenge.DeviceId is not null) if (session.Challenge.DeviceId is not null)
// await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
// The current session should be included in the sessions' list // The current session should be included in the sessions' list
await db.AuthSessions await db.AuthSessions
@@ -522,15 +526,14 @@ public class AccountService(
public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact) public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact)
{ {
await Task.CompletedTask; var spell = await spells.CreateMagicSpell(
// var spell = await spells.CreateMagicSpell( account,
// account, MagicSpellType.ContactVerification,
// MagicSpellType.ContactVerification, new Dictionary<string, object> { { "contact_method", contact.Content } },
// new Dictionary<string, object> { { "contact_method", contact.Content } }, expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
// expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true
// preventRepeat: true );
// ); await spells.NotifyMagicSpell(spell);
// await spells.NotifyMagicSpell(spell);
} }
public async Task<AccountContact> SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact) public async Task<AccountContact> SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact)
@@ -614,7 +617,7 @@ public class AccountService(
try try
{ {
var badge = await db.AccountBadges var badge = await db.AccountBadges
.Where(b => b.AccountId == account.Id && b.Id == badgeId) .Where(b => b.AccountId == account.Id && b.Id != badgeId)
.OrderByDescending(b => b.CreatedAt) .OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found."); if (badge is null) throw new InvalidOperationException("Badge was not found.");
@@ -657,4 +660,246 @@ public class AccountService(
await db.BulkInsertAsync(newProfiles); 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); // fbs.Enqueue(log);
} }
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, public async Task<ActionLog> CreateActionLogFromRequest(string type, Dictionary<string, object> meta, string? ipAddress, string? userAgent, Shared.Models.Account? account = null)
Shared.Models.Account? account = null)
{ {
var log = new ActionLog var log = new ActionLog
{ {
Action = action, Action = type,
Meta = meta, Meta = meta,
UserAgent = request.Headers.UserAgent, UserAgent = userAgent,
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(), IpAddress = ipAddress,
// Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) // Location = geo.GetPointFromIp(ipAddress)
}; };
if (request.HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser) if (account != null)
log.AccountId = currentUser.Id;
else if (account != null)
log.AccountId = account.Id; log.AccountId = account.Id;
else else
throw new ArgumentException("No user context was found"); throw new ArgumentException("No user context was found");
if (request.HttpContext.Items["CurrentSession"] is Session currentSession) // For MagicOnion, HttpContext.Items["CurrentSession"] is not directly available.
log.SessionId = currentSession.Id; // 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); // fbs.Enqueue(log);
return log;
} }
} }

View File

@@ -70,6 +70,11 @@ public class MagicSpellService(
return spell; 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) public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
{ {
var contact = await db.AccountContacts var contact = await db.AccountContacts

View File

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

View File

@@ -155,19 +155,23 @@ public class RelationshipService(AppDatabase db, ICacheService cache) : ServiceB
return relationship; 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 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) if (friends == null)
{ {
friends = await db.AccountRelationships var friendIds = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id) .Where(r => r.RelatedId == account.Id)
.Where(r => r.Status == RelationshipStatus.Friends) .Where(r => r.Status == RelationshipStatus.Friends)
.Select(r => r.AccountId) .Select(r => r.AccountId)
.ToListAsync(); .ToListAsync();
friends = await db.Accounts
.Where(a => friendIds.Contains(a.Id))
.ToListAsync();
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); 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<MagicSpell> MagicSpells { get; set; }
public DbSet<AbuseReport> AbuseReports { 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) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(

View File

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

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.Pass.Localization
@using DysonNetwork.Shared.Localization
@using Microsoft.Extensions.Localization @using Microsoft.Extensions.Localization
<EmailLayout> <EmailLayout>

View File

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

View File

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

View File

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

View File

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

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MagicOnion.Server;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -24,6 +25,8 @@ builder.Services.AddDbContext<AppDatabase>(options =>
builder.Services.AddScoped<AccountService>(); builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<DysonNetwork.Pass.Publisher.PublisherService>();
builder.Services.AddScoped<DysonNetwork.Pass.Developer.CustomAppService>();
var app = builder.Build(); 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 System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Account; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Safety; namespace DysonNetwork.Pass.Safety;
[ApiController] [ApiController]
[Route("/safety/reports")] [Route("/safety/reports")]
@@ -85,7 +86,7 @@ public class AbuseReportController(
[HttpGet("{id}")] [HttpGet("{id}")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)] [ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> GetReportById(Guid id) public async Task<ActionResult<AbuseReport>> GetReportById(Guid id)
@@ -122,7 +123,7 @@ public class AbuseReportController(
[HttpPost("{id}/resolve")] [HttpPost("{id}/resolve")]
[Authorize] [Authorize]
[RequiredPermissionAttribute("safety", "reports.resolve")] [DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.resolve")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)] [ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request) public async Task<ActionResult<AbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request)
@@ -144,7 +145,7 @@ public class AbuseReportController(
[HttpGet("count")] [HttpGet("count")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")]
[ProducesResponseType<object>(StatusCodes.Status200OK)] [ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> GetReportsCount() 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<IAccountUsernameService, AccountUsernameService>();
services.AddScoped<IMagicSpellService, MagicSpellService>(); services.AddScoped<IMagicSpellService, MagicSpellService>();
services.AddScoped<IAccountEventService, AccountEventService>(); services.AddScoped<IAccountEventService, AccountEventService>();
services.AddScoped<IAccountProfileService, AccountProfileService>();
// Register OIDC services // Register OIDC services
services.AddScoped<OidcService, GoogleOidcService>(); services.AddScoped<OidcService, GoogleOidcService>();

View File

@@ -25,6 +25,10 @@
<PackageReference Include="NodaTime" Version="3.2.2" /> <PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="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" /> <PackageReference Include="StackExchange.Redis" Version="2.8.41" />
</ItemGroup> </ItemGroup>

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 System.ComponentModel.DataAnnotations;
using NodaTime; using NodaTime;
namespace DysonNetwork.Pass.Account; namespace DysonNetwork.Shared.Models;
public enum AbuseReportType public enum AbuseReportType
{ {

View File

@@ -5,10 +5,14 @@ using Microsoft.Extensions.DependencyInjection;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using System.Threading.Tasks; using System.Threading.Tasks;
using System; using System;
using System.Reflection;
using Grpc.Core;
using MagicOnion;
using MagicOnion.Server.Hubs;
namespace DysonNetwork.Shared.Permission; namespace DysonNetwork.Shared.Permission;
public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAttribute> public class MagicOnionPermissionFilter : IMagicOnionServiceFilter
{ {
private readonly IPermissionService _permissionService; private readonly IPermissionService _permissionService;
@@ -17,9 +21,20 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAt
_permissionService = permissionService; _permissionService = permissionService;
} }
public async ValueTask Invoke(ServiceContext context, RequiredPermissionAttribute attribute, Func<ServiceContext, ValueTask> next) public async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
{ {
var httpContext = context.GetHttpContext(); 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) if (httpContext == null)
{ {
throw new InvalidOperationException("HttpContext is not available in ServiceContext."); throw new InvalidOperationException("HttpContext is not available in ServiceContext.");
@@ -27,7 +42,7 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAt
if (httpContext.Items["CurrentUser"] is not Account currentUser) if (httpContext.Items["CurrentUser"] is not Account currentUser)
{ {
throw new ReturnStatusException(MagicOnion.Grpc.StatusCode.PermissionDenied, "Unauthorized: Current user not found."); throw new ReturnStatusException(StatusCode.PermissionDenied, "Unauthorized: Current user not found.");
} }
if (currentUser.IsSuperuser) if (currentUser.IsSuperuser)
@@ -36,12 +51,11 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAt
return; return;
} }
var actor = $"user:{currentUser.Id}"; var hasPermission = await _permissionService.CheckPermission(attribute.Scope, attribute.Permission);
var hasPermission = await _permissionService.CheckPermission(actor, attribute.Scope, attribute.Permission);
if (!hasPermission) if (!hasPermission)
{ {
throw new ReturnStatusException(MagicOnion.Grpc.StatusCode.PermissionDenied, $"Permission {attribute.Scope}/{attribute.Permission} was required."); throw new ReturnStatusException(StatusCode.PermissionDenied, $"Permission {attribute.Scope}/{attribute.Permission} was required.");
} }
await next(context); await next(context);

View File

@@ -1,18 +1,16 @@
using System; using System;
using MagicOnion.Server.Filters;
namespace DysonNetwork.Shared.Permission; namespace DysonNetwork.Shared.Permission;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = true)] [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
public class RequiredPermissionAttribute : MagicOnionFilterAttribute public class RequiredPermissionAttribute : Attribute
{ {
public string Scope { get; } public string Scope { get; }
public string Permission { get; } public string Permission { get; }
public RequiredPermissionAttribute(string scope, string permission) : base(typeof(MagicOnionPermissionFilter)) public RequiredPermissionAttribute(string scope, string permission)
{ {
Scope = scope; Scope = scope;
Permission = permission; Permission = permission;
Order = 999; // Ensure this runs after authentication filters
} }
} }

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 DysonNetwork.Shared.Models;
using MagicOnion; using MagicOnion;
using NodaTime; using NodaTime;
@@ -16,6 +17,11 @@ public interface IAccountEventService : IService<IAccountEventService>
/// </summary> /// </summary>
Task<Status> GetStatus(Guid userId); Task<Status> GetStatus(Guid userId);
/// <summary>
/// Gets the statuses of a list of users
/// </summary>
Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds);
/// <summary> /// <summary>
/// Performs a daily check-in for a user /// Performs a daily check-in for a user
/// </summary> /// </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 DysonNetwork.Shared.Models;
using MagicOnion; using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
namespace DysonNetwork.Shared.Services; namespace DysonNetwork.Shared.Services;
@@ -59,4 +62,247 @@ public interface IAccountService : IService<IAccountService>
/// <param name="userInfo">The OpenID Connect user information</param> /// <param name="userInfo">The OpenID Connect user information</param>
/// <returns>The newly created account</returns> /// <returns>The newly created account</returns>
Task<Account> CreateAccount(OidcUserInfo userInfo); 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 System.Collections.Generic;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using MagicOnion; using MagicOnion;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Shared.Services; namespace DysonNetwork.Shared.Services;
@@ -15,10 +14,11 @@ public interface IActionLogService : IService<IActionLogService>
/// <summary> /// <summary>
/// Creates an action log entry from an HTTP request /// Creates an action log entry from an HTTP request
/// </summary> /// </summary>
void CreateActionLogFromRequest( Task<ActionLog> CreateActionLogFromRequest(
string action, string type,
Dictionary<string, object> meta, Dictionary<string, object> meta,
HttpRequest request, string? ipAddress,
string? userAgent,
Account? account = null 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

@@ -23,6 +23,20 @@ public interface IMagicSpellService : IService<IMagicSpellService>
/// </summary> /// </summary>
Task<MagicSpell?> GetMagicSpellAsync(string token); 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> /// <summary>
/// Consumes a magic spell /// Consumes a magic spell
/// </summary> /// </summary>

View File

@@ -1,5 +1,7 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using MagicOnion; using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services; namespace DysonNetwork.Shared.Services;
@@ -20,4 +22,25 @@ public interface INotificationService : IService<INotificationService>
string deviceId, string deviceId,
string deviceToken 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,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 DysonNetwork.Shared.Models;
using MagicOnion; using MagicOnion;
@@ -24,4 +27,53 @@ public interface IRelationshipService : IService<IRelationshipService>
/// Creates a new relationship between two accounts /// Creates a new relationship between two accounts
/// </summary> /// </summary>
Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status); 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;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using NodaTime; using NodaTime;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Activity; namespace DysonNetwork.Sphere.Activity;

View File

@@ -11,7 +11,7 @@ namespace DysonNetwork.Sphere.Activity;
public class ActivityService( public class ActivityService(
AppDatabase db, AppDatabase db,
PublisherService pub, PublisherService pub,
DysonNetwork.Shared.Services.IRelationshipService rels, Shared.Services.IRelationshipService rels,
PostService ps, PostService ps,
DiscoveryService ds DiscoveryService ds
) )
@@ -125,7 +125,7 @@ public class ActivityService(
) )
{ {
var activities = new List<Activity>(); 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); var userPublishers = await pub.GetUserPublishers(currentUser.Id);
debugInclude ??= []; debugInclude ??= [];

View File

@@ -10,6 +10,8 @@ using Microsoft.EntityFrameworkCore.Query;
using NodaTime; using NodaTime;
using Quartz; using Quartz;
namespace DysonNetwork.Sphere;
public class AppDatabase( public class AppDatabase(
DbContextOptions<AppDatabase> options, DbContextOptions<AppDatabase> options,
IConfiguration configuration IConfiguration configuration
@@ -18,18 +20,18 @@ public class AppDatabase(
public DbSet<CloudFile> Files { get; set; } public DbSet<CloudFile> Files { get; set; }
public DbSet<CloudFileReference> FileReferences { get; set; } public DbSet<CloudFileReference> FileReferences { get; set; }
public DbSet<Publisher> Publishers { get; set; } public DbSet<Shared.Models.Publisher> Publishers { get; set; }
public DbSet<PublisherMember> PublisherMembers { get; set; } public DbSet<PublisherMember> PublisherMembers { get; set; }
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; } public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; }
public DbSet<PublisherFeature> PublisherFeatures { get; set; } public DbSet<PublisherFeature> PublisherFeatures { get; set; }
public DbSet<Post> Posts { get; set; } public DbSet<Post.Post> Posts { get; set; }
public DbSet<PostReaction> PostReactions { get; set; } public DbSet<PostReaction> PostReactions { get; set; }
public DbSet<PostTag> PostTags { get; set; } public DbSet<PostTag> PostTags { get; set; }
public DbSet<PostCategory> PostCategories { get; set; } public DbSet<PostCategory> PostCategories { get; set; }
public DbSet<PostCollection> PostCollections { get; set; } public DbSet<PostCollection> PostCollections { get; set; }
public DbSet<Realm> Realms { get; set; } public DbSet<Shared.Models.Realm> Realms { get; set; }
public DbSet<RealmMember> RealmMembers { get; set; } public DbSet<RealmMember> RealmMembers { get; set; }
public DbSet<ChatRoom> ChatRooms { get; set; } public DbSet<ChatRoom> ChatRooms { get; set; }
@@ -38,10 +40,10 @@ public class AppDatabase(
public DbSet<RealtimeCall> ChatRealtimeCall { get; set; } public DbSet<RealtimeCall> ChatRealtimeCall { get; set; }
public DbSet<MessageReaction> ChatReactions { get; set; } public DbSet<MessageReaction> ChatReactions { get; set; }
public DbSet<Sticker> Stickers { get; set; } public DbSet<Sticker.Sticker> Stickers { get; set; }
public DbSet<StickerPack> StickerPacks { get; set; } public DbSet<StickerPack> StickerPacks { get; set; }
public DbSet<Wallet> Wallets { get; set; } public DbSet<Shared.Models.Wallet> Wallets { get; set; }
public DbSet<WalletPocket> WalletPockets { get; set; } public DbSet<WalletPocket> WalletPockets { get; set; }
public DbSet<Order> PaymentOrders { get; set; } public DbSet<Order> PaymentOrders { get; set; }
public DbSet<Transaction> PaymentTransactions { get; set; } public DbSet<Transaction> PaymentTransactions { get; set; }
@@ -95,7 +97,7 @@ public class AppDatabase(
.HasForeignKey(ps => ps.AccountId) .HasForeignKey(ps => ps.AccountId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post>() modelBuilder.Entity<Post.Post>()
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content }) .HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
.HasIndex(p => p.SearchVector) .HasIndex(p => p.SearchVector)
.HasMethod("GIN"); .HasMethod("GIN");
@@ -110,25 +112,25 @@ public class AppDatabase(
.HasForeignKey(s => s.AppId) .HasForeignKey(s => s.AppId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post>() modelBuilder.Entity<Post.Post>()
.HasOne(p => p.RepliedPost) .HasOne(p => p.RepliedPost)
.WithMany() .WithMany()
.HasForeignKey(p => p.RepliedPostId) .HasForeignKey(p => p.RepliedPostId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Post>() modelBuilder.Entity<Post.Post>()
.HasOne(p => p.ForwardedPost) .HasOne(p => p.ForwardedPost)
.WithMany() .WithMany()
.HasForeignKey(p => p.ForwardedPostId) .HasForeignKey(p => p.ForwardedPostId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Post>() modelBuilder.Entity<Post.Post>()
.HasMany(p => p.Tags) .HasMany(p => p.Tags)
.WithMany(t => t.Posts) .WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("post_tag_links")); .UsingEntity(j => j.ToTable("post_tag_links"));
modelBuilder.Entity<Post>() modelBuilder.Entity<Post.Post>()
.HasMany(p => p.Categories) .HasMany(p => p.Categories)
.WithMany(c => c.Posts) .WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_category_links")); .UsingEntity(j => j.ToTable("post_category_links"));
modelBuilder.Entity<Post>() modelBuilder.Entity<Post.Post>()
.HasMany(p => p.Collections) .HasMany(p => p.Collections)
.WithMany(c => c.Posts) .WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links")); .UsingEntity(j => j.ToTable("post_collection_links"));

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Connection.WebReader; 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -59,7 +59,7 @@ public class WebReaderController(WebReaderService reader, ILogger<WebReaderContr
/// </summary> /// </summary>
[HttpDelete("link/cache")] [HttpDelete("link/cache")]
[Authorize] [Authorize]
[RequiredPermissionAttribute("maintenance", "cache.scrap")] [DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "cache.scrap")]
public async Task<IActionResult> InvalidateCache([FromQuery] string url) public async Task<IActionResult> InvalidateCache([FromQuery] string url)
{ {
if (string.IsNullOrEmpty(url)) if (string.IsNullOrEmpty(url))
@@ -76,7 +76,7 @@ public class WebReaderController(WebReaderService reader, ILogger<WebReaderContr
/// </summary> /// </summary>
[HttpDelete("cache/all")] [HttpDelete("cache/all")]
[Authorize] [Authorize]
[RequiredPermissionAttribute("maintenance", "cache.scrap")] [DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "cache.scrap")]
public async Task<IActionResult> InvalidateAllCache() public async Task<IActionResult> InvalidateAllCache()
{ {
await reader.InvalidateAllCachedPreviewsAsync(); await reader.InvalidateAllCachedPreviewsAsync();

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Shared.Permission;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -90,7 +90,7 @@ public class DeveloperController(
[HttpPost("{name}/enroll")] [HttpPost("{name}/enroll")]
[Authorize] [Authorize]
[RequiredPermissionAttribute("global", "developers.create")] [DysonNetwork.Shared.Permission.RequiredPermission("global", "developers.create")]
public async Task<ActionResult<Shared.Models.Publisher>> EnrollDeveloperProgram(string name) public async Task<ActionResult<Shared.Models.Publisher>> EnrollDeveloperProgram(string name)
{ {
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); 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="Livekit.Server.Sdk.Dotnet" Version="1.0.8" />
<PackageReference Include="MagicOnion.Client" Version="7.0.5" /> <PackageReference Include="MagicOnion.Client" Version="7.0.5" />
<PackageReference Include="MagicOnion.Server" 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="MailKit" Version="4.11.0" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
@@ -153,6 +155,7 @@
<DependentUpon>NotificationResource.resx</DependentUpon> <DependentUpon>NotificationResource.resx</DependentUpon>
</Compile> </Compile>
<Compile Remove="Auth\AppleAuthController.cs" /> <Compile Remove="Auth\AppleAuthController.cs" />
<Compile Remove="Permission\RequiredPermissionAttribute.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

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,10 +1,10 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using DysonNetwork.Pass.Auth;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Developer;
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
namespace DysonNetwork.Sphere.Pages.Auth; namespace DysonNetwork.Sphere.Pages.Auth;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using DysonNetwork.Shared.Services;
using MagicOnion;
namespace DysonNetwork.Sphere.Permission;
public class RequiredPermissionAttribute : TypeFilterAttribute
{
public RequiredPermissionAttribute(string scope, string permission) : base(typeof(RequiredPermissionFilter))
{
Arguments = new object[] { scope, permission };
}
private class RequiredPermissionFilter : IAsyncActionFilter
{
private readonly IPermissionService _permissionService;
private readonly string _scope;
private readonly string _permission;
public RequiredPermissionFilter(IPermissionService permissionService, string scope, string permission)
{
_permissionService = permissionService;
_scope = scope;
_permission = permission;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// Assuming the actor is always "user:current" for client-side checks
// You might need to adjust this based on how your client identifies itself
var hasPermission = await _permissionService.CheckPermission(_scope, _permission);
if (!hasPermission)
{
context.Result = new ForbidResult();
return;
}
await next();
}
}
}

View File

@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services; using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Shared.Permission;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -33,7 +33,9 @@ public class PostController(
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account; 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 userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName); 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) public async Task<ActionResult<Post>> GetPost(Guid id)
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account; var currentUser = currentUserValue as 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 userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var post = await db.Posts var post = await db.Posts
@@ -99,8 +103,10 @@ public class PostController(
return BadRequest("Search query cannot be empty"); return BadRequest("Search query cannot be empty");
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account; var currentUser = currentUserValue as 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 userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var queryable = db.Posts var queryable = db.Posts
@@ -136,8 +142,10 @@ public class PostController(
[FromQuery] int take = 20) [FromQuery] int take = 20)
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Shared.Models.Account; var currentUser = currentUserValue as 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 userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var parent = await db.Posts var parent = await db.Posts
@@ -264,9 +272,12 @@ public class PostController(
return BadRequest(err.Message); return BadRequest(err.Message);
} }
als.CreateActionLogFromRequest( await als.CreateActionLogFromRequest(
ActionLogType.PostCreate, 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; return post;
@@ -280,12 +291,12 @@ public class PostController(
[HttpPost("{id:guid}/reactions")] [HttpPost("{id:guid}/reactions")]
[Authorize] [Authorize]
[RequiredPermissionAttribute("global", "posts.react")] [RequiredPermission("global", "posts.react")]
public async Task<ActionResult<PostReaction>> ReactPost(Guid id, [FromBody] PostReactionRequest request) public async Task<ActionResult<PostReaction>> ReactPost(Guid id, [FromBody] PostReactionRequest request)
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Shared.Models.Account currentUser) return Unauthorized(); if (currentUserValue is not Account currentUser) return Unauthorized();
var userFriends = await rels.ListAccountFriends(currentUser); var userFriends = (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList();
var userPublishers = await pub.GetUserPublishers(currentUser.Id); var userPublishers = await pub.GetUserPublishers(currentUser.Id);
var post = await db.Posts var post = await db.Posts
@@ -319,9 +330,12 @@ public class PostController(
if (isRemoving) return NoContent(); if (isRemoving) return NoContent();
als.CreateActionLogFromRequest( await als.CreateActionLogFromRequest(
ActionLogType.PostReact, 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); return Ok(reaction);
@@ -368,9 +382,12 @@ public class PostController(
return BadRequest(err.Message); return BadRequest(err.Message);
} }
als.CreateActionLogFromRequest( await als.CreateActionLogFromRequest(
ActionLogType.PostUpdate, 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); return Ok(post);
@@ -392,9 +409,12 @@ public class PostController(
await ps.DeletePostAsync(post); await ps.DeletePostAsync(post);
als.CreateActionLogFromRequest( await als.CreateActionLogFromRequest(
ActionLogType.PostDelete, 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(); return NoContent();

View File

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

View File

@@ -2,6 +2,9 @@ using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Startup; using DysonNetwork.Sphere.Startup;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using tusdotnet.Stores; using tusdotnet.Stores;
using MagicOnion.Client;
using DysonNetwork.Shared.Services;
using Grpc.Net.Client;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -20,6 +23,42 @@ builder.Services.AddAppSwagger();
// Add gRPC services // Add gRPC services
builder.Services.AddGrpc(); 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 // Add file storage
builder.Services.AddAppFileStorage(builder.Configuration); builder.Services.AddAppFileStorage(builder.Configuration);
@@ -47,8 +86,8 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore); app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore);
// Map gRPC services // Remove direct gRPC service mappings for Pass services
app.MapGrpcService<DysonNetwork.Pass.Auth.AuthGrpcService>(); // app.MapGrpcService<DysonNetwork.Pass.Auth.AuthGrpcService>();
app.MapGrpcService<DysonNetwork.Pass.Account.AccountGrpcService>(); // app.MapGrpcService<DysonNetwork.Pass.Account.AccountGrpcService>();
app.Run(); app.Run();

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Sphere.Realm;
@@ -9,7 +10,7 @@ public class RealmService(AppDatabase db, DysonNetwork.Shared.Services.INoti
{ {
public async Task SendInviteNotify(RealmMember member) public async Task SendInviteNotify(RealmMember member)
{ {
AccountService.SetCultureInfo(member.Account); CultureInfoService.SetCultureInfo(member.Account);
await nty.SendNotification( await nty.SendNotification(
member.Account, member.Account,
"invites.realms", "invites.realms",

View File

@@ -1,105 +0,0 @@
using DysonNetwork.Pass.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,11 +20,11 @@ using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis; using StackExchange.Redis;
using System.Text.Json; using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Pass.Safety;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Safety;
using DysonNetwork.Sphere.Wallet.PaymentHandlers; using DysonNetwork.Sphere.Wallet.PaymentHandlers;
using tusdotnet.Stores; using tusdotnet.Stores;
using DysonNetwork.Shared.Etcd; using DysonNetwork.Shared.Etcd;
@@ -189,7 +189,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<EmailService>(); services.AddScoped<EmailService>();
services.AddScoped<FileService>(); services.AddScoped<FileService>();
services.AddScoped<FileReferenceService>(); services.AddScoped<FileReferenceService>();
services.AddScoped<FileReferenceMigrationService>();
services.AddScoped<PublisherService>(); services.AddScoped<PublisherService>();
services.AddScoped<PublisherSubscriptionService>(); services.AddScoped<PublisherSubscriptionService>();
services.AddScoped<ActivityService>(); services.AddScoped<ActivityService>();

View File

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

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -13,8 +13,7 @@ public class FileController(
AppDatabase db, AppDatabase db,
FileService fs, FileService fs,
IConfiguration configuration, IConfiguration configuration,
IWebHostEnvironment env, IWebHostEnvironment env
FileReferenceMigrationService rms
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -108,13 +107,4 @@ public class FileController(
return NoContent(); return NoContent();
} }
[HttpPost("/maintenance/migrateReferences")]
[Authorize]
[RequiredPermissionAttribute("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,24 +1,21 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Quartz; using Quartz;
using DysonNetwork.Shared.Services;
namespace DysonNetwork.Sphere.Storage.Handlers; namespace DysonNetwork.Sphere.Storage.Handlers;
public class LastActiveInfo public class LastActiveInfo
{ {
public Session Session { get; set; } = null!; 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 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) 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 // Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt
var distinctItems = items var distinctItems = items
.GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id)) .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 // Update sessions using native EF Core ExecuteUpdateAsync
foreach (var kvp in sessionIdMap) foreach (var kvp in sessionIdMap)
{ await accounts.UpdateSessionLastGrantedAt(kvp.Key, kvp.Value);
await db.AuthSessions
.Where(s => s.Id == kvp.Key)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, kvp.Value));
}
// Update account profiles using native EF Core ExecuteUpdateAsync // Update account profiles using native EF Core ExecuteUpdateAsync
foreach (var kvp in accountIdMap) foreach (var kvp in accountIdMap)
{ await accounts.UpdateAccountProfileLastSeenAt(kvp.Key, kvp.Value);
await db.AccountProfiles
.Where(a => a.AccountId == kvp.Key)
.ExecuteUpdateAsync(a => a.SetProperty(x => x.LastSeenAt, kvp.Value));
}
} }
} }

View File

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

View File

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

View File

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

View File

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