♻️ 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,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public enum AbuseReportType
{
Copyright,
Harassment,
Impersonation,
OffensiveContent,
Spam,
PrivacyViolation,
IllegalContent,
Other
}
public class AbuseReport : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public AbuseReportType Type { get; set; }
[MaxLength(8192)] public string Reason { get; set; } = null!;
public Instant? ResolvedAt { get; set; }
[MaxLength(8192)] public string? Resolution { get; set; }
public Guid AccountId { get; set; }
public Shared.Models.Account Account { get; set; } = null!;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,155 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Pass.Safety;
[ApiController]
[Route("/safety/reports")]
public class AbuseReportController(
SafetyService safety
) : ControllerBase
{
public class CreateReportRequest
{
[Required] public string ResourceIdentifier { get; set; } = null!;
[Required] public AbuseReportType Type { get; set; }
[Required]
[MinLength(10)]
[MaxLength(1000)]
public string Reason { get; set; } = null!;
}
[HttpPost("")]
[Authorize]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AbuseReport>> CreateReport([FromBody] CreateReportRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try
{
var report = await safety.CreateReport(
request.ResourceIdentifier,
request.Type,
request.Reason,
currentUser.Id
);
return Ok(report);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("")]
[Authorize]
[RequiredPermission("safety", "reports.view")]
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
public async Task<ActionResult<List<AbuseReport>>> GetReports(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] bool includeResolved = false
)
{
var totalCount = await safety.CountReports(includeResolved);
var reports = await safety.GetReports(offset, take, includeResolved);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(reports);
}
[HttpGet("me")]
[Authorize]
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<AbuseReport>>> GetMyReports(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] bool includeResolved = false
)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var totalCount = await safety.CountUserReports(currentUser.Id, includeResolved);
var reports = await safety.GetUserReports(currentUser.Id, offset, take, includeResolved);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(reports);
}
[HttpGet("{id}")]
[Authorize]
[DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> GetReportById(Guid id)
{
var report = await safety.GetReportById(id);
return report == null ? NotFound() : Ok(report);
}
[HttpGet("me/{id}")]
[Authorize]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AbuseReport>> GetMyReportById(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var report = await safety.GetReportById(id);
if (report == null) return NotFound();
// Ensure the user only accesses their own reports
if (report.AccountId != currentUser.Id) return Forbid();
return Ok(report);
}
public class ResolveReportRequest
{
[Required]
[MinLength(5)]
[MaxLength(1000)]
public string Resolution { get; set; } = null!;
}
[HttpPost("{id}/resolve")]
[Authorize]
[DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.resolve")]
[ProducesResponseType<AbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request)
{
try
{
var report = await safety.ResolveReport(id, request.Resolution);
return Ok(report);
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("count")]
[Authorize]
[DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> GetReportsCount()
{
var count = await safety.GetPendingReportsCount();
return Ok(new { pendingCount = count });
}
}

View File

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

View File

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