7 Commits

213 changed files with 2735 additions and 101805 deletions

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public enum AbuseReportType public enum AbuseReportType
{ {
@ -26,5 +26,5 @@ public class AbuseReport : ModelBase
[MaxLength(8192)] public string? Resolution { get; set; } [MaxLength(8192)] public string? Resolution { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
} }

View File

@ -1,14 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Pass.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Extensions; using Microsoft.AspNetCore.Http;
using System.Collections.Generic;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/accounts")] [Route("/accounts")]
@ -20,9 +18,9 @@ public class AccountController(
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)] [ProducesResponseType<Shared.Models.Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Account?>> GetByName(string name) public async Task<ActionResult<Shared.Models.Account?>> GetByName(string name)
{ {
var account = await db.Accounts var account = await db.Accounts
.Include(e => e.Badges) .Include(e => e.Badges)
@ -73,9 +71,9 @@ public class AccountController(
} }
[HttpPost] [HttpPost]
[ProducesResponseType<Account>(StatusCodes.Status200OK)] [ProducesResponseType<Shared.Models.Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request) public async Task<ActionResult<Shared.Models.Account>> CreateAccount([FromBody] AccountCreateRequest request)
{ {
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
@ -163,7 +161,7 @@ public class AccountController(
} }
[HttpGet("search")] [HttpGet("search")]
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20) public async Task<List<Shared.Models.Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
return []; return [];

View File

@ -1,14 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Org.BouncyCastle.Utilities;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Pass.Account;
[Authorize] [Authorize]
[ApiController] [ApiController]
@ -16,16 +17,15 @@ namespace DysonNetwork.Sphere.Account;
public class AccountCurrentController( public class AccountCurrentController(
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,
FileReferenceService fileRefService,
AccountEventService events, AccountEventService events,
AuthService auth AuthService auth
) : ControllerBase ) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)] [ProducesResponseType<Shared.Models.Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Account>> GetCurrentIdentity() public async Task<ActionResult<Shared.Models.Account>> GetCurrentIdentity()
{ {
if (HttpContext.Items["CurrentUser"] is not 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 account = await db.Accounts var account = await db.Accounts
@ -44,9 +44,9 @@ public class AccountCurrentController(
} }
[HttpPatch] [HttpPatch]
public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request) public async Task<ActionResult<Shared.Models.Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id); var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
@ -77,7 +77,7 @@ public class AccountCurrentController(
[HttpPatch("profile")] [HttpPatch("profile")]
public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request) public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not 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 profile = await db.AccountProfiles var profile = await db.AccountProfiles
@ -95,61 +95,61 @@ public class AccountCurrentController(
if (request.Location is not null) profile.Location = request.Location; if (request.Location is not null) profile.Location = request.Location;
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone; if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
if (request.PictureId is not null) // if (request.PictureId is not null)
{ // {
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); // var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); // if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
//
var profileResourceId = $"profile:{profile.Id}"; // var profileResourceId = $"profile:{profile.Id}";
//
// Remove old references for the profile picture // // Remove old references for the profile picture
if (profile.Picture is not null) // if (profile.Picture is not null)
{ // {
var oldPictureRefs = // var oldPictureRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture"); // await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture");
foreach (var oldRef in oldPictureRefs) // foreach (var oldRef in oldPictureRefs)
{ // {
await fileRefService.DeleteReferenceAsync(oldRef.Id); // await fileRefService.DeleteReferenceAsync(oldRef.Id);
} // }
} // }
//
profile.Picture = picture.ToReferenceObject(); // profile.Picture = picture.ToReferenceObject();
//
// Create new reference // // Create new reference
await fileRefService.CreateReferenceAsync( // await fileRefService.CreateReferenceAsync(
picture.Id, // picture.Id,
"profile.picture", // "profile.picture",
profileResourceId // profileResourceId
); // );
} // }
//
if (request.BackgroundId is not null) // if (request.BackgroundId is not null)
{ // {
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); // var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); // if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
//
var profileResourceId = $"profile:{profile.Id}"; // var profileResourceId = $"profile:{profile.Id}";
//
// Remove old references for the profile background // // Remove old references for the profile background
if (profile.Background is not null) // if (profile.Background is not null)
{ // {
var oldBackgroundRefs = // var oldBackgroundRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background"); // await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background");
foreach (var oldRef in oldBackgroundRefs) // foreach (var oldRef in oldBackgroundRefs)
{ // {
await fileRefService.DeleteReferenceAsync(oldRef.Id); // await fileRefService.DeleteReferenceAsync(oldRef.Id);
} // }
} // }
//
profile.Background = background.ToReferenceObject(); // profile.Background = background.ToReferenceObject();
//
// Create new reference // // Create new reference
await fileRefService.CreateReferenceAsync( // await fileRefService.CreateReferenceAsync(
background.Id, // background.Id,
"profile.background", // "profile.background",
profileResourceId // profileResourceId
); // );
} // }
db.Update(profile); db.Update(profile);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -162,7 +162,7 @@ public class AccountCurrentController(
[HttpDelete] [HttpDelete]
public async Task<ActionResult> RequestDeleteAccount() public async Task<ActionResult> RequestDeleteAccount()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try try
{ {
@ -179,7 +179,7 @@ public class AccountCurrentController(
[HttpGet("statuses")] [HttpGet("statuses")]
public async Task<ActionResult<Status>> GetCurrentStatus() public async Task<ActionResult<Status>> GetCurrentStatus()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var status = await events.GetStatus(currentUser.Id); var status = await events.GetStatus(currentUser.Id);
return Ok(status); return Ok(status);
} }
@ -188,7 +188,7 @@ public class AccountCurrentController(
[RequiredPermission("global", "accounts.statuses.update")] [RequiredPermission("global", "accounts.statuses.update")]
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request) public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses var status = await db.AccountStatuses
@ -215,7 +215,7 @@ public class AccountCurrentController(
[RequiredPermission("global", "accounts.statuses.create")] [RequiredPermission("global", "accounts.statuses.create")]
public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request) public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var status = new Status var status = new Status
{ {
@ -233,7 +233,7 @@ public class AccountCurrentController(
[HttpDelete("me/statuses")] [HttpDelete("me/statuses")]
public async Task<ActionResult> DeleteStatus() public async Task<ActionResult> DeleteStatus()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses var status = await db.AccountStatuses
@ -250,7 +250,7 @@ public class AccountCurrentController(
[HttpGet("check-in")] [HttpGet("check-in")]
public async Task<ActionResult<CheckInResult>> GetCheckInResult() public async Task<ActionResult<CheckInResult>> GetCheckInResult()
{ {
if (HttpContext.Items["CurrentUser"] is not 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 now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
@ -270,7 +270,7 @@ public class AccountCurrentController(
[HttpPost("check-in")] [HttpPost("check-in")]
public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken) public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var isAvailable = await events.CheckInDailyIsAvailable(currentUser); var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
if (!isAvailable) if (!isAvailable)
@ -297,7 +297,7 @@ public class AccountCurrentController(
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month, public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
[FromQuery] int? year) [FromQuery] int? year)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date; var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
month ??= currentDate.Month; month ??= currentDate.Month;
@ -318,7 +318,7 @@ public class AccountCurrentController(
[FromQuery] int offset = 0 [FromQuery] int offset = 0
) )
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var query = db.ActionLogs var query = db.ActionLogs
.Where(log => log.AccountId == currentUser.Id) .Where(log => log.AccountId == currentUser.Id)
@ -338,7 +338,7 @@ public class AccountCurrentController(
[HttpGet("factors")] [HttpGet("factors")]
public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors() public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var factors = await db.AccountAuthFactors var factors = await db.AccountAuthFactors
.Include(f => f.Account) .Include(f => f.Account)
@ -358,7 +358,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request) public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
return BadRequest($"Auth factor with type {request.Type} is already exists."); return BadRequest($"Auth factor with type {request.Type} is already exists.");
@ -370,7 +370,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code) public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id) .Where(f => f.AccountId == currentUser.Id && f.Id == id)
@ -392,7 +392,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id) public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id) .Where(f => f.AccountId == currentUser.Id && f.Id == id)
@ -414,7 +414,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id) public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id) .Where(f => f.AccountId == currentUser.Id && f.Id == id)
@ -445,7 +445,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices() public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser || if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
@ -480,7 +480,7 @@ public class AccountCurrentController(
[FromQuery] int offset = 0 [FromQuery] int offset = 0
) )
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser || if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
var query = db.AuthSessions var query = db.AuthSessions
@ -505,7 +505,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<Session>> DeleteSession(Guid id) public async Task<ActionResult<Session>> DeleteSession(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try try
{ {
@ -522,7 +522,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<Session>> DeleteCurrentSession() public async Task<ActionResult<Session>> DeleteCurrentSession()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser || if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
try try
@ -539,7 +539,7 @@ public class AccountCurrentController(
[HttpPatch("sessions/{id:guid}/label")] [HttpPatch("sessions/{id:guid}/label")]
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label) public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try try
{ {
@ -555,7 +555,7 @@ public class AccountCurrentController(
[HttpPatch("sessions/current/label")] [HttpPatch("sessions/current/label")]
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label) public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser || if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
try try
@ -573,7 +573,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<List<AccountContact>>> GetContacts() public async Task<ActionResult<List<AccountContact>>> GetContacts()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var contacts = await db.AccountContacts var contacts = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id) .Where(c => c.AccountId == currentUser.Id)
@ -592,7 +592,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request) public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try try
{ {
@ -609,7 +609,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountContact>> VerifyContact(Guid id) public async Task<ActionResult<AccountContact>> VerifyContact(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var contact = await db.AccountContacts var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id) .Where(c => c.AccountId == currentUser.Id && c.Id == id)
@ -631,7 +631,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id) public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var contact = await db.AccountContacts var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id) .Where(c => c.AccountId == currentUser.Id && c.Id == id)
@ -653,7 +653,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<AccountContact>> DeleteContact(Guid id) public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var contact = await db.AccountContacts var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id) .Where(c => c.AccountId == currentUser.Id && c.Id == id)
@ -676,9 +676,9 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<List<Badge>>> GetBadges() public async Task<ActionResult<List<Badge>>> GetBadges()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var badges = await db.Badges var badges = await db.AccountBadges
.Where(b => b.AccountId == currentUser.Id) .Where(b => b.AccountId == currentUser.Id)
.ToListAsync(); .ToListAsync();
return Ok(badges); return Ok(badges);
@ -688,7 +688,7 @@ public class AccountCurrentController(
[Authorize] [Authorize]
public async Task<ActionResult<Badge>> ActivateBadge(Guid id) public async Task<ActionResult<Badge>> ActivateBadge(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try try
{ {

View File

@ -1,23 +1,19 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Sphere.Activity; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Connection; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Wallet; using MagicOnion.Server;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using Org.BouncyCastle.Asn1.X509;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class AccountEventService( public class AccountEventService(
AppDatabase db, AppDatabase db,
WebSocketService ws,
ICacheService cache, ICacheService cache,
PaymentService payment,
IStringLocalizer<Localization.AccountEventResource> localizer IStringLocalizer<Localization.AccountEventResource> localizer
) ) : ServiceBase<IAccountEventService>, IAccountEventService
{ {
private static readonly Random Random = new(); private static readonly Random Random = new();
private const string StatusCacheKey = "AccountStatus_"; private const string StatusCacheKey = "AccountStatus_";
@ -34,7 +30,7 @@ public class AccountEventService(
var cachedStatus = await cache.GetAsync<Status>(cacheKey); var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus is not null) if (cachedStatus is not null)
{ {
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); cachedStatus!.IsOnline = !cachedStatus.IsInvisible /*&& ws.GetAccountIsConnected(userId)*/;
return cachedStatus; return cachedStatus;
} }
@ -44,12 +40,17 @@ public class AccountEventService(
.Where(e => e.ClearedAt == null || e.ClearedAt > now) .Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var isOnline = ws.GetAccountIsConnected(userId); // var isOnline = ws.GetAccountIsConnected(userId);
var isOnline = false; // Placeholder
if (status is not null) if (status is not null)
{ {
status.IsOnline = !status.IsInvisible && isOnline; status.IsOnline = !status.IsInvisible && isOnline;
await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"], await cache.SetWithGroupsAsync(
TimeSpan.FromMinutes(5)); cacheKey,
status,
[$"{AccountService.AccountCachePrefix}{status.AccountId}"],
TimeSpan.FromMinutes(5)
);
return status; return status;
} }
@ -83,16 +84,16 @@ public class AccountEventService(
foreach (var userId in userIds) foreach (var userId in userIds)
{ {
var cacheKey = $"{StatusCacheKey}{userId}"; var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey); // var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus != null) // if (cachedStatus != null)
{ // {
cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); // cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
results[userId] = cachedStatus; // results[userId] = cachedStatus;
} // }
else // else
{ // {
cacheMissUserIds.Add(userId); cacheMissUserIds.Add(userId);
} // }
} }
if (cacheMissUserIds.Any()) if (cacheMissUserIds.Any())
@ -109,11 +110,12 @@ public class AccountEventService(
foreach (var status in statusesFromDb) foreach (var status in statusesFromDb)
{ {
var isOnline = ws.GetAccountIsConnected(status.AccountId); // var isOnline = ws.GetAccountIsConnected(status.AccountId);
var isOnline = false; // Placeholder
status.IsOnline = !status.IsInvisible && isOnline; status.IsOnline = !status.IsInvisible && isOnline;
results[status.AccountId] = status; results[status.AccountId] = status;
var cacheKey = $"{StatusCacheKey}{status.AccountId}"; var cacheKey = $"{StatusCacheKey}{status.AccountId}";
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5)); // await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
foundUserIds.Add(status.AccountId); foundUserIds.Add(status.AccountId);
} }
@ -122,7 +124,8 @@ public class AccountEventService(
{ {
foreach (var userId in usersWithoutStatus) foreach (var userId in usersWithoutStatus)
{ {
var isOnline = ws.GetAccountIsConnected(userId); // var isOnline = ws.GetAccountIsConnected(userId);
var isOnline = false; // Placeholder
var defaultStatus = new Status var defaultStatus = new Status
{ {
Attitude = StatusAttitude.Neutral, Attitude = StatusAttitude.Neutral,
@ -139,7 +142,7 @@ public class AccountEventService(
return results; return results;
} }
public async Task<Status> CreateStatus(Account user, Status status) public async Task<Status> CreateStatus(Shared.Models.Account user, Status status)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
await db.AccountStatuses await db.AccountStatuses
@ -152,7 +155,7 @@ public class AccountEventService(
return status; return status;
} }
public async Task ClearStatus(Account user, Status status) public async Task ClearStatus(Shared.Models.Account user, Status status)
{ {
status.ClearedAt = SystemClock.Instance.GetCurrentInstant(); status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(status); db.Update(status);
@ -164,19 +167,19 @@ public class AccountEventService(
private const string CaptchaCacheKey = "CheckInCaptcha_"; private const string CaptchaCacheKey = "CheckInCaptcha_";
private const int CaptchaProbabilityPercent = 20; private const int CaptchaProbabilityPercent = 20;
public async Task<bool> CheckInDailyDoAskCaptcha(Account user) public async Task<bool> CheckInDailyDoAskCaptcha(Shared.Models.Account user)
{ {
var cacheKey = $"{CaptchaCacheKey}{user.Id}"; var cacheKey = $"{CaptchaCacheKey}{user.Id}";
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey); // var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
if (needsCaptcha is not null) // if (needsCaptcha is not null)
return needsCaptcha!.Value; // return needsCaptcha!.Value;
var result = Random.Next(100) < CaptchaProbabilityPercent; var result = Random.Next(100) < CaptchaProbabilityPercent;
await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24)); // await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
return result; return result;
} }
public async Task<bool> CheckInDailyIsAvailable(Account user) public async Task<bool> CheckInDailyIsAvailable(Shared.Models.Account user)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var lastCheckIn = await db.AccountCheckInResults var lastCheckIn = await db.AccountCheckInResults
@ -193,9 +196,9 @@ public class AccountEventService(
return lastDate < currentDate; return lastDate < currentDate;
} }
public const string CheckInLockKey = "CheckInLock_"; private const string CheckInLockKey = "checkin-lock:";
public async Task<CheckInResult> CheckInDaily(Account user) public async Task<CheckInResult> CheckInDaily(Shared.Models.Account user)
{ {
var lockKey = $"{CheckInLockKey}{user.Id}"; var lockKey = $"{CheckInLockKey}{user.Id}";
@ -212,7 +215,8 @@ public class AccountEventService(
} }
// Now try to acquire the lock properly // Now try to acquire the lock properly
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); await using var lockObj =
await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress."); if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
var cultureInfo = new CultureInfo(user.Language, false); var cultureInfo = new CultureInfo(user.Language, false);
@ -255,13 +259,14 @@ public class AccountEventService(
try try
{ {
if (result.RewardPoints.HasValue) if (result.RewardPoints.HasValue)
await payment.CreateTransactionWithAccountAsync( // await payment.CreateTransactionWithAccountAsync(
null, // null,
user.Id, // user.Id,
WalletCurrency.SourcePoint, // WalletCurrency.SourcePoint,
result.RewardPoints.Value, // result.RewardPoints.Value,
$"Check-in reward on {now:yyyy/MM/dd}" // $"Check-in reward on {now:yyyy/MM/dd}"
); // );
Console.WriteLine($"Simulating transaction for {result.RewardPoints.Value} points");
} }
catch catch
{ {
@ -274,13 +279,54 @@ public class AccountEventService(
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience) s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
); );
db.AccountCheckInResults.Add(result); db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Don't forget to save changes to the database await db.SaveChangesAsync(); // Remember to save changes to the database
// The lock will be automatically released by the await using statement // The lock will be automatically released by the await using statement
return result; return result;
} }
public async Task<List<DailyEventResponse>> GetEventCalendar(Account user, int month, int year = 0, public async Task<int> GetCheckInStreak(Shared.Models.Account user)
{
var today = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
var yesterdayEnd = today.PlusDays(-1).AtMidnight().InUtc().ToInstant();
var yesterdayStart = today.PlusDays(-1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var tomorrowEnd = today.PlusDays(1).AtMidnight().InUtc().ToInstant();
var tomorrowStart = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var yesterdayResult = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.Where(x => x.CreatedAt >= yesterdayStart)
.Where(x => x.CreatedAt < yesterdayEnd)
.FirstOrDefaultAsync();
var tomorrowResult = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.Where(x => x.CreatedAt >= tomorrowStart)
.Where(x => x.CreatedAt < tomorrowEnd)
.FirstOrDefaultAsync();
if (yesterdayResult is null && tomorrowResult is null)
return 1;
var results = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync();
var streak = 0;
var day = today;
while (results.Any(x =>
x.CreatedAt >= day.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant() &&
x.CreatedAt < day.AtMidnight().InUtc().ToInstant()))
{
streak++;
day = day.PlusDays(-1);
}
return streak;
}
public async Task<List<DailyEventResponse>> GetEventCalendar(Shared.Models.Account user, int month, int year = 0,
bool replaceInvisible = false) bool replaceInvisible = false)
{ {
if (year == 0) if (year == 0)

View File

@ -1,32 +1,31 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Sphere.Auth.OpenId; using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Sphere.Email; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using Org.BouncyCastle.Utilities;
using OtpNet; using OtpNet;
using Microsoft.Extensions.Logging;
using EFCore.BulkExtensions;
using MagicOnion.Server;
namespace DysonNetwork.Sphere.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,
IStringLocalizer<NotificationResource> localizer, // IStringLocalizer<NotificationResource> localizer,
ICacheService cache, ICacheService cache,
ILogger<AccountService> logger ILogger<AccountService> logger
) ) : ServiceBase<IAccountService>, IAccountService
{ {
public static void SetCultureInfo(Account account) public static void SetCultureInfo(Shared.Models.Account account)
{ {
SetCultureInfo(account.Language); SetCultureInfo(account.Language);
} }
@ -40,12 +39,12 @@ public class AccountService(
public const string AccountCachePrefix = "account:"; public const string AccountCachePrefix = "account:";
public async Task PurgeAccountCache(Account account) public async Task PurgeAccountCache(Shared.Models.Account account)
{ {
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
} }
public async Task<Account?> LookupAccount(string probe) public async Task<Shared.Models.Account?> LookupAccount(string probe)
{ {
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync();
if (account is not null) return account; if (account is not null) return account;
@ -57,7 +56,7 @@ public class AccountService(
return contact?.Account; return contact?.Account;
} }
public async Task<Account?> LookupAccountByConnection(string identifier, string provider) public async Task<Shared.Models.Account?> LookupAccountByConnection(string identifier, string provider)
{ {
var connection = await db.AccountConnections var connection = await db.AccountConnections
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider) .Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
@ -74,7 +73,7 @@ public class AccountService(
return profile?.Level; return profile?.Level;
} }
public async Task<Account> CreateAccount( public async Task<Shared.Models.Account> CreateAccount(
string name, string name,
string nick, string nick,
string email, string email,
@ -91,7 +90,7 @@ public class AccountService(
if (dupeNameCount > 0) if (dupeNameCount > 0)
throw new InvalidOperationException("Account name has already been taken."); throw new InvalidOperationException("Account name has already been taken.");
var account = new Account var account = new Shared.Models.Account
{ {
Name = name, Name = name,
Nick = nick, Nick = nick,
@ -135,15 +134,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);
@ -159,7 +158,7 @@ public class AccountService(
} }
} }
public async Task<Account> CreateAccount(OidcUserInfo userInfo) public async Task<Shared.Models.Account> CreateAccount(OidcUserInfo userInfo)
{ {
if (string.IsNullOrEmpty(userInfo.Email)) if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation"); throw new ArgumentException("Email is required for account creation");
@ -169,7 +168,8 @@ public class AccountService(
: $"{userInfo.FirstName} {userInfo.LastName}".Trim(); : $"{userInfo.FirstName} {userInfo.LastName}".Trim();
// Generate username from email // 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,
@ -182,31 +182,31 @@ public class AccountService(
); );
} }
public async Task RequestAccountDeletion(Account account) public async Task RequestAccountDeletion(Shared.Models.Account account)
{ {
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(Account account) public async Task RequestPasswordReset(Shared.Models.Account account)
{ {
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(Account account, AccountAuthFactorType type) public async Task<bool> CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type)
{ {
var isExists = await db.AccountAuthFactors var isExists = await db.AccountAuthFactors
.Where(x => x.AccountId == account.Id && x.Type == type) .Where(x => x.AccountId == account.Id && x.Type == type)
@ -214,7 +214,7 @@ public class AccountService(
return isExists; return isExists;
} }
public async Task<AccountAuthFactor?> CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret) public async Task<AccountAuthFactor?> CreateAuthFactor(Shared.Models.Account account, AccountAuthFactorType type, string? secret)
{ {
AccountAuthFactor? factor = null; AccountAuthFactor? factor = null;
switch (type) switch (type)
@ -329,7 +329,7 @@ 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)) // .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.");
@ -345,7 +345,7 @@ public class AccountService(
/// <param name="account">The owner of the auth factor</param> /// <param name="account">The owner of the auth factor</param>
/// <param name="factor">The auth factor needed to send code</param> /// <param name="factor">The auth factor needed to send code</param>
/// <param name="hint">The part of the contact method for verification</param> /// <param name="hint">The part of the contact method for verification</param>
public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null) public async Task SendFactorCode(Shared.Models.Account account, AccountAuthFactor factor, string? hint = null)
{ {
var code = new Random().Next(100000, 999999).ToString("000000"); var code = new Random().Next(100000, 999999).ToString("000000");
@ -355,14 +355,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:
@ -397,16 +397,16 @@ public class AccountService(
return; return;
} }
await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>( // await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
account.Nick, // account.Nick,
contact.Content, // contact.Content,
localizer["VerificationEmail"], // localizer["VerificationEmail"],
new VerificationEmailModel // new VerificationEmailModel
{ // {
Name = account.Name, // Name = account.Name,
Code = code // Code = code
} // }
); // );
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30)); await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
break; break;
@ -454,7 +454,7 @@ public class AccountService(
); );
} }
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label) public async Task<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)
@ -477,7 +477,7 @@ public class AccountService(
return session; return session;
} }
public async Task DeleteSession(Account account, Guid sessionId) public async Task DeleteSession(Shared.Models.Account account, Guid sessionId)
{ {
var session = await db.AuthSessions var session = await db.AuthSessions
.Include(s => s.Challenge) .Include(s => s.Challenge)
@ -491,7 +491,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
@ -503,7 +503,7 @@ public class AccountService(
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
} }
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content) public async Task<AccountContact> CreateContactMethod(Shared.Models.Account account, AccountContactType type, string content)
{ {
var contact = new AccountContact var contact = new AccountContact
{ {
@ -518,19 +518,19 @@ public class AccountService(
return contact; return contact;
} }
public async Task VerifyContactMethod(Account account, AccountContact contact) public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact)
{ {
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(Account account, AccountContact contact) public async Task<AccountContact> SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact)
{ {
if (contact.AccountId != account.Id) if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account."); throw new InvalidOperationException("Contact method does not belong to this account.");
@ -559,7 +559,7 @@ public class AccountService(
} }
} }
public async Task DeleteContactMethod(Account account, AccountContact contact) public async Task DeleteContactMethod(Shared.Models.Account account, AccountContact contact)
{ {
if (contact.AccountId != account.Id) if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account."); throw new InvalidOperationException("Contact method does not belong to this account.");
@ -574,10 +574,10 @@ public class AccountService(
/// This method will grant a badge to the account. /// This method will grant a badge to the account.
/// Shouldn't be exposed to normal user and the user itself. /// Shouldn't be exposed to normal user and the user itself.
/// </summary> /// </summary>
public async Task<Badge> GrantBadge(Account account, Badge badge) public async Task<Badge> GrantBadge(Shared.Models.Account account, Badge badge)
{ {
badge.AccountId = account.Id; badge.AccountId = account.Id;
db.Badges.Add(badge); db.AccountBadges.Add(badge);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return badge; return badge;
} }
@ -586,9 +586,9 @@ public class AccountService(
/// This method will revoke a badge from the account. /// This method will revoke a badge from the account.
/// Shouldn't be exposed to normal user and the user itself. /// Shouldn't be exposed to normal user and the user itself.
/// </summary> /// </summary>
public async Task RevokeBadge(Account account, Guid badgeId) public async Task RevokeBadge(Shared.Models.Account account, Guid badgeId)
{ {
var badge = await db.Badges 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();
@ -604,19 +604,19 @@ public class AccountService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task ActiveBadge(Account account, Guid badgeId) public async Task ActiveBadge(Shared.Models.Account account, Guid badgeId)
{ {
await using var transaction = await db.Database.BeginTransactionAsync(); await using var transaction = await db.Database.BeginTransactionAsync();
try try
{ {
var badge = await db.Badges 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.");
await db.Badges await db.AccountBadges
.Where(b => b.AccountId == account.Id && b.Id != badgeId) .Where(b => b.AccountId == account.Id && b.Id != badgeId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null)); .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));

View File

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

View File

@ -1,12 +1,24 @@
using Quartz; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Connection; using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Storage; using MagicOnion.Server;
using DysonNetwork.Sphere.Storage.Handlers; using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class ActionLogService(GeoIpService geo, FlushBufferService fbs) public class ActionLogService : ServiceBase<IActionLogService>, IActionLogService
{ {
// private readonly GeoIpService _geo;
// private readonly FlushBufferService _fbs;
public ActionLogService(
// GeoIpService geo,
// FlushBufferService fbs
)
{
// _geo = geo;
// _fbs = fbs;
}
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta) public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
{ {
var log = new ActionLog var log = new ActionLog
@ -16,11 +28,11 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
Meta = meta, Meta = meta,
}; };
fbs.Enqueue(log); // fbs.Enqueue(log);
} }
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
Account? account = null) Shared.Models.Account? account = null)
{ {
var log = new ActionLog var log = new ActionLog
{ {
@ -28,19 +40,19 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
Meta = meta, Meta = meta,
UserAgent = request.Headers.UserAgent, UserAgent = request.Headers.UserAgent,
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(), IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) // Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
}; };
if (request.HttpContext.Items["CurrentUser"] is Account currentUser) if (request.HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser)
log.AccountId = currentUser.Id; log.AccountId = currentUser.Id;
else if (account != null) 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 Auth.Session currentSession) if (request.HttpContext.Items["CurrentSession"] is Session currentSession)
log.SessionId = currentSession.Id; log.SessionId = currentSession.Id;
fbs.Enqueue(log); // fbs.Enqueue(log);
} }
} }

View File

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/spells")] [Route("/spells")]

View File

@ -1,27 +1,25 @@
using System.Globalization; using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Email; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Pages.Emails; using DysonNetwork.Shared.Services;
using DysonNetwork.Sphere.Permission; using MagicOnion.Server;
using DysonNetwork.Sphere.Resources.Localization;
using DysonNetwork.Sphere.Resources.Pages.Emails;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class MagicSpellService( public class MagicSpellService(
AppDatabase db, AppDatabase db,
EmailService email,
IConfiguration configuration, IConfiguration configuration,
ILogger<MagicSpellService> logger, ILogger<MagicSpellService> logger
IStringLocalizer<Localization.EmailResource> localizer ) : ServiceBase<IMagicSpellService>, IMagicSpellService
)
{ {
public async Task<MagicSpell> CreateMagicSpell( public async Task<MagicSpell> CreateMagicSpell(
Account account, Shared.Models.Account account,
MagicSpellType type, MagicSpellType type,
Dictionary<string, object> meta, Dictionary<string, object> meta,
Instant? expiredAt = null, Instant? expiredAt = null,
@ -61,6 +59,17 @@ public class MagicSpellService(
return spell; return spell;
} }
public async Task<MagicSpell?> GetMagicSpellAsync(string token)
{
var now = SystemClock.Instance.GetCurrentInstant();
var spell = await db.MagicSpells
.Where(s => s.Spell == token)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
return spell;
}
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
{ {
var contact = await db.AccountContacts var contact = await db.AccountContacts
@ -87,54 +96,54 @@ public class MagicSpellService(
switch (spell.Type) switch (spell.Type)
{ {
case MagicSpellType.AccountActivation: case MagicSpellType.AccountActivation:
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>( // await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contact.Content, // contact.Content,
localizer["EmailLandingTitle"], // localizer["EmailLandingTitle"],
new LandingEmailModel // new LandingEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
case MagicSpellType.AccountRemoval: case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>( // await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contact.Content, // contact.Content,
localizer["EmailAccountDeletionTitle"], // localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel // new AccountDeletionEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
case MagicSpellType.AuthPasswordReset: case MagicSpellType.AuthPasswordReset:
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( // await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contact.Content, // contact.Content,
localizer["EmailAccountDeletionTitle"], // localizer["EmailAccountDeletionTitle"],
new PasswordResetEmailModel // new PasswordResetEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
case MagicSpellType.ContactVerification: case MagicSpellType.ContactVerification:
if (spell.Meta["contact_method"] is not string contactMethod) if (spell.Meta["contact_method"] is not string contactMethod)
throw new InvalidOperationException("Contact method is not found."); throw new InvalidOperationException("Contact method is not found.");
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>( // await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick, // contact.Account.Nick,
contactMethod!, // contactMethod!,
localizer["EmailContactVerificationTitle"], // localizer["EmailContactVerificationTitle"],
new ContactVerificationEmailModel // new ContactVerificationEmailModel
{ // {
Name = contact.Account.Name, // Name = contact.Account.Name,
Link = link // Link = link
} // }
); // );
break; break;
default: default:
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();
@ -146,8 +155,15 @@ public class MagicSpellService(
} }
} }
public async Task ApplyMagicSpell(MagicSpell spell) public async Task ApplyMagicSpell(string token)
{ {
var now = SystemClock.Instance.GetCurrentInstant();
var spell = await db.MagicSpells
.Where(s => s.Spell == token)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
if (spell is null) throw new ArgumentException("Magic spell not found.");
switch (spell.Type) switch (spell.Type)
{ {
case MagicSpellType.AuthPasswordReset: case MagicSpellType.AuthPasswordReset:

View File

@ -1,12 +1,14 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/notifications")] [Route("/notifications")]
@ -17,7 +19,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
public async Task<ActionResult<int>> CountUnreadNotifications() public async Task<ActionResult<int>> CountUnreadNotifications()
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized(); if (currentUserValue is not Shared.Models.Account currentUser) return Unauthorized();
var count = await db.Notifications var count = await db.Notifications
.Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null) .Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null)
@ -35,7 +37,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
) )
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized(); if (currentUserValue is not Shared.Models.Account currentUser) return Unauthorized();
var totalCount = await db.Notifications var totalCount = await db.Notifications
.Where(s => s.AccountId == currentUser.Id) .Where(s => s.AccountId == currentUser.Id)
@ -67,16 +69,15 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
{ {
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account; var currentUser = currentUserValue as Shared.Models.Account;
if (currentUser == null) return Unauthorized(); if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session; var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized(); if (currentSession == null) return Unauthorized();
var result =
await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!, await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!,
request.DeviceToken); request.DeviceToken);
return Ok(result); return Ok();
} }
[HttpDelete("subscription")] [HttpDelete("subscription")]
@ -85,7 +86,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
{ {
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account; var currentUser = currentUserValue as Shared.Models.Account;
if (currentUser == null) return Unauthorized(); if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session; var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized(); if (currentSession == null) return Unauthorized();

View File

@ -1,17 +1,20 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Connection; using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using DysonNetwork.Shared.Services;
using EFCore.BulkExtensions;
using MagicOnion.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class NotificationService( public class NotificationService(
AppDatabase db, AppDatabase db,
WebSocketService ws, IConfiguration config,
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory
IConfiguration config) ) : ServiceBase<INotificationService>, INotificationService
{ {
private readonly string _notifyTopic = config["Notifications:Topic"]!; private readonly string _notifyTopic = config["Notifications:Topic"]!;
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
@ -24,7 +27,7 @@ public class NotificationService(
} }
public async Task<NotificationPushSubscription> SubscribePushNotification( public async Task<NotificationPushSubscription> SubscribePushNotification(
Account account, Shared.Models.Account account,
NotificationPushProvider provider, NotificationPushProvider provider,
string deviceId, string deviceId,
string deviceToken string deviceToken
@ -63,14 +66,14 @@ public class NotificationService(
AccountId = account.Id, AccountId = account.Id,
}; };
db.NotificationPushSubscriptions.Add(subscription); // db.NotificationPushSubscriptions.Add(subscription);
await db.SaveChangesAsync(); // await db.SaveChangesAsync();
return subscription; return subscription;
} }
public async Task<Notification> SendNotification( public async Task<Notification> SendNotification(
Account account, Shared.Models.Account account,
string topic, string topic,
string? title = null, string? title = null,
string? subtitle = null, string? subtitle = null,
@ -103,18 +106,19 @@ public class NotificationService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
if (!isSilent) _ = DeliveryNotification(notification); if (!isSilent)
Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification);
return notification; return notification;
} }
public async Task DeliveryNotification(Notification notification) public async Task DeliveryNotification(Notification notification)
{ {
ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket // ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
{ // {
Type = "notifications.new", // Type = "notifications.new",
Data = notification // Data = notification
}); // });
// Pushing the notification // Pushing the notification
var subscribers = await db.NotificationPushSubscriptions var subscribers = await db.NotificationPushSubscriptions
@ -130,15 +134,15 @@ public class NotificationService(
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList(); var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
if (id.Count == 0) return; if (id.Count == 0) return;
await db.Notifications // await db.Notifications
.Where(n => id.Contains(n.Id)) // .Where(n => id.Contains(n.Id))
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now) // .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
); // );
} }
public async Task BroadcastNotification(Notification notification, bool save = false) public async Task BroadcastNotification(Notification notification, bool save = false)
{ {
var accounts = await db.Accounts.ToListAsync(); var accounts = new List<Shared.Models.Account>(); // await db.Accounts.ToListAsync();
if (save) if (save)
{ {
@ -157,18 +161,18 @@ public class NotificationService(
}; };
return newNotification; return newNotification;
}).ToList(); }).ToList();
await db.BulkInsertAsync(notifications); // await db.BulkInsertAsync(notifications);
} }
foreach (var account in accounts) foreach (var account in accounts)
{ {
notification.Account = account; notification.Account = account;
notification.AccountId = account.Id; notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket // ws.SendPacketToAccount(account.Id, new WebSocketPacket
{ // {
Type = "notifications.new", // Type = "notifications.new",
Data = notification // Data = notification
}); // });
} }
var subscribers = await db.NotificationPushSubscriptions var subscribers = await db.NotificationPushSubscriptions
@ -176,7 +180,8 @@ public class NotificationService(
await _PushNotification(notification, subscribers); await _PushNotification(notification, subscribers);
} }
public async Task SendNotificationBatch(Notification notification, List<Account> accounts, bool save = false) public async Task SendNotificationBatch(Notification notification, List<Shared.Models.Account> accounts,
bool save = false)
{ {
if (save) if (save)
{ {
@ -202,18 +207,18 @@ public class NotificationService(
{ {
notification.Account = account; notification.Account = account;
notification.AccountId = account.Id; notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket // ws.SendPacketToAccount(account.Id, new WebSocketPacket
{ // {
Type = "notifications.new", // Type = "notifications.new",
Data = notification // Data = notification
}); // });
} }
var accountsId = accounts.Select(x => x.Id).ToList(); // var accountsId = accounts.Select(x => x.Id).ToList();
var subscribers = await db.NotificationPushSubscriptions // var subscribers = await db.NotificationPushSubscriptions
.Where(s => accountsId.Contains(s.AccountId)) // .Where(s => accountsId.Contains(s.AccountId))
.ToListAsync(); // .ToListAsync();
await _PushNotification(notification, subscribers); // await _PushNotification(notification, subscribers);
} }
private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification, private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,

View File

@ -1,10 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
[ApiController] [ApiController]
[Route("/relationships")] [Route("/relationships")]
@ -15,7 +17,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
public async Task<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0, public async Task<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0,
[FromQuery] int take = 20) [FromQuery] int take = 20)
{ {
if (HttpContext.Items["CurrentUser"] is not 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 query = db.AccountRelationships.AsQueryable() var query = db.AccountRelationships.AsQueryable()
@ -46,7 +48,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult<List<Relationship>>> ListSentRequests() public async Task<ActionResult<List<Relationship>>> ListSentRequests()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var relationships = await db.AccountRelationships var relationships = await db.AccountRelationships
.Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending) .Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending)
@ -69,7 +71,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId, public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
[FromBody] RelationshipRequest request) [FromBody] RelationshipRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId); var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found."); if (relatedUser is null) return NotFound("Account was not found.");
@ -92,7 +94,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId, public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId,
[FromBody] RelationshipRequest request) [FromBody] RelationshipRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try try
{ {
@ -113,7 +115,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult<Relationship>> GetRelationship(Guid userId) public async Task<ActionResult<Relationship>> GetRelationship(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow); var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable() var queries = db.AccountRelationships.AsQueryable()
@ -133,7 +135,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId) public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId); var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found."); if (relatedUser is null) return NotFound("Account was not found.");
@ -158,7 +160,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult> DeleteFriendRequest(Guid userId) public async Task<ActionResult> DeleteFriendRequest(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
try try
{ {
@ -175,7 +177,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId) public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending); var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found."); if (relationship is null) return NotFound("Friend request was not found.");
@ -195,7 +197,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId) public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending); var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found."); if (relationship is null) return NotFound("Friend request was not found.");
@ -215,7 +217,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult<Relationship>> BlockUser(Guid userId) public async Task<ActionResult<Relationship>> BlockUser(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId); var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found."); if (relatedUser is null) return NotFound("Account was not found.");
@ -235,7 +237,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize] [Authorize]
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId) public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId); var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found."); if (relatedUser is null) return NotFound("Account was not found.");

View File

@ -1,10 +1,13 @@
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Services;
using MagicOnion.Server;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Pass.Account;
public class RelationshipService(AppDatabase db, ICacheService cache) public class RelationshipService(AppDatabase db, ICacheService cache) : ServiceBase<IRelationshipService>, IRelationshipService
{ {
private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
@ -34,7 +37,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
return relationship; return relationship;
} }
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status) public async Task<Relationship> CreateRelationship(Shared.Models.Account sender, Shared.Models.Account target, RelationshipStatus status)
{ {
if (status == RelationshipStatus.Pending) if (status == RelationshipStatus.Pending)
throw new InvalidOperationException( throw new InvalidOperationException(
@ -52,31 +55,31 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.AccountRelationships.Add(relationship); db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id); // await PurgeRelationshipCache(sender.Id, target.Id);
return relationship; return relationship;
} }
public async Task<Relationship> BlockAccount(Account sender, Account target) public async Task<Relationship> BlockAccount(Shared.Models.Account sender, Shared.Models.Account target)
{ {
if (await HasExistingRelationship(sender.Id, target.Id)) if (await HasExistingRelationship(sender.Id, target.Id))
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
return await CreateRelationship(sender, target, RelationshipStatus.Blocked); return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
} }
public async Task<Relationship> UnblockAccount(Account sender, Account target) public async Task<Relationship> UnblockAccount(Shared.Models.Account sender, Shared.Models.Account target)
{ {
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
db.Remove(relationship); db.Remove(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id); // await PurgeRelationshipCache(sender.Id, target.Id);
return relationship; return relationship;
} }
public async Task<Relationship> SendFriendRequest(Account sender, Account target) public async Task<Relationship> SendFriendRequest(Shared.Models.Account sender, Shared.Models.Account target)
{ {
if (await HasExistingRelationship(sender.Id, target.Id)) if (await HasExistingRelationship(sender.Id, target.Id))
throw new InvalidOperationException("Found existing relationship between you and target user."); throw new InvalidOperationException("Found existing relationship between you and target user.");
@ -104,7 +107,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending) .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); // await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
} }
public async Task<Relationship> AcceptFriendRelationship( public async Task<Relationship> AcceptFriendRelationship(
@ -133,7 +136,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); // await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
return relationshipBackward; return relationshipBackward;
} }
@ -152,7 +155,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
return relationship; return relationship;
} }
public async Task<List<Guid>> ListAccountFriends(Account account) public async Task<List<Guid>> 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<Guid>>(cacheKey);
@ -168,10 +171,10 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
} }
return friends ?? []; return friends;
} }
public async Task<List<Guid>> ListAccountBlocked(Account account) public async Task<List<Guid>> ListAccountBlocked(Shared.Models.Account account)
{ {
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}"; var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
var blocked = await cache.GetAsync<List<Guid>>(cacheKey); var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
@ -187,7 +190,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1)); await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
} }
return blocked ?? []; return blocked;
} }
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId, public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,

View File

@ -0,0 +1,269 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass;
public abstract class ModelBase
{
public Instant CreatedAt { get; set; }
public Instant UpdatedAt { get; set; }
public Instant? DeletedAt { get; set; }
}
public class AppDatabase(
DbContextOptions<AppDatabase> options,
IConfiguration configuration
) : DbContext(options)
{
public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<Shared.Models.Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Status> AccountStatuses { get; set; }
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Badge> AccountBadges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(
configuration.GetConnectionString("App"),
opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
.UseNodaTime()
).UseSnakeCaseNamingConvention();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var defaultPermissionGroup = await context.Set<PermissionGroup>()
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
if (defaultPermissionGroup is null)
{
context.Set<PermissionGroup>().Add(new PermissionGroup
{
Key = "default",
Nodes = new List<string>
{
"posts.create",
"posts.react",
"publishers.create",
"files.create",
"chat.create",
"chat.messages.create",
"chat.realtime.create",
"accounts.statuses.create",
"accounts.statuses.update",
"stickers.packs.create",
"stickers.create"
}.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true))
.ToList()
});
await context.SaveChangesAsync(cancellationToken);
}
});
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]);
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : ModelBase
{
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Cleaning up expired records...");
// Expired relationships
var affectedRows = await db.AccountRelationships
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
// Expired permission group members
affectedRows = await db.PermissionGroupMembers
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
logger.LogInformation("Deleting soft-deleted records...");
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
.Select(t => t.ClrType);
foreach (var entityType in entityTypes)
{
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
.MakeGenericMethod(entityType).Invoke(db, null)!;
var parameter = Expression.Parameter(entityType, "e");
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
var finalCondition = Expression.AndAlso(notNull, condition);
var lambda = Expression.Lambda(finalCondition, parameter);
var queryable = set.Provider.CreateQuery(
Expression.Call(
typeof(Queryable),
"Where",
[entityType],
set.Expression,
Expression.Quote(lambda)
)
);
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
.MakeGenericMethod(entityType);
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
db.RemoveRange(items);
}
await db.SaveChangesAsync();
}
}
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
{
public AppDatabase CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
return new AppDatabase(optionsBuilder.Options, configuration);
}
}
public static class OptionalQueryExtensions
{
public static IQueryable<T> If<T>(
this IQueryable<T> source,
bool condition,
Func<IQueryable<T>, IQueryable<T>> transform
)
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, TP> source,
bool condition,
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, IEnumerable<TP>> source,
bool condition,
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
}

View File

@ -1,22 +1,16 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
using System.Text; using Microsoft.AspNetCore.Http;
using DysonNetwork.Sphere.Auth.OidcProvider.Controllers; using Microsoft.Extensions.Configuration;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using Microsoft.Extensions.Logging;
using SystemClock = NodaTime.SystemClock; using SystemClock = Microsoft.Extensions.Internal.SystemClock;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public static class AuthConstants public static class AuthConstants
{ {
@ -46,12 +40,11 @@ public class DysonTokenAuthHandler(
IConfiguration configuration, IConfiguration configuration,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
AppDatabase database, AppDatabase database
OidcProviderService oidc, // OidcProviderService oidc,
ICacheService cache, // ICacheService cache,
FlushBufferService fbs // FlushBufferService fbs
) ) : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{ {
public const string AuthCachePrefix = "auth:"; public const string AuthCachePrefix = "auth:";
@ -64,36 +57,42 @@ public class DysonTokenAuthHandler(
try try
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = NodaTime.SystemClock.Instance.GetCurrentInstant();
// Validate token and extract session ID // Validate token and extract session ID
if (!ValidateToken(tokenInfo.Token, out var sessionId)) if (!ValidateToken(tokenInfo.Token, out var sessionId))
return AuthenticateResult.Fail("Invalid token."); return AuthenticateResult.Fail("Invalid token.");
// Try to get session from cache first // Try to get session from cache first
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}"); // var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
var session = await database.AuthSessions
// If not in cache, load from database
if (session is null)
{
session = await database.AuthSessions
.Where(e => e.Id == sessionId) .Where(e => e.Id == sessionId)
.Include(e => e.Challenge) .Include(e => e.Challenge)
.Include(e => e.Account) .Include(e => e.Account)
.ThenInclude(e => e.Profile) .ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (session is not null) // If not in cache, load from database
{ // if (session is null)
// Store in cache for future requests // {
await cache.SetWithGroupsAsync( // session = await database.AuthSessions
$"auth:{sessionId}", // .Where(e => e.Id == sessionId)
session, // .Include(e => e.Challenge)
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"], // .Include(e => e.Account)
TimeSpan.FromHours(1) // .ThenInclude(e => e.Profile)
); // .FirstOrDefaultAsync();
}
} // if (session is not null)
// {
// // Store in cache for future requests
// await cache.SetWithGroupsAsync(
// $"auth:{sessionId}",
// session,
// // [$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
// TimeSpan.FromHours(1)
// );
// }
// }
// Check if the session exists // Check if the session exists
if (session == null) if (session == null)
@ -129,13 +128,13 @@ public class DysonTokenAuthHandler(
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName); var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
var lastInfo = new LastActiveInfo // var lastInfo = new LastActiveInfo
{ // {
Account = session.Account, // Account = session.Account,
Session = session, // Session = session,
SeenAt = SystemClock.Instance.GetCurrentInstant(), // SeenAt = SystemClock.Instance.GetCurrentInstant(),
}; // };
fbs.Enqueue(lastInfo); // fbs.Enqueue(lastInfo);
return AuthenticateResult.Success(ticket); return AuthenticateResult.Success(ticket);
} }
@ -158,12 +157,13 @@ public class DysonTokenAuthHandler(
// Handle JWT tokens (3 parts) // Handle JWT tokens (3 parts)
case 3: case 3:
{ {
var (isValid, jwtResult) = oidc.ValidateToken(token); // var (isValid, jwtResult) = oidc.ValidateToken(token);
if (!isValid) return false; // if (!isValid) return false;
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value; // var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti is null) return false; // if (jti is null) return false;
return Guid.TryParse(jti, out sessionId); // return Guid.TryParse(jti, out sessionId);
return false; // Placeholder
} }
// Handle compact tokens (2 parts) // Handle compact tokens (2 parts)
case 2: case 2:

View File

@ -1,23 +1,23 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Account;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.IdentityModel.Tokens.Jwt; using NodaTime;
using Microsoft.AspNetCore.Http;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Connection;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
[ApiController] [ApiController]
[Route("/auth")] [Route("/auth")]
public class AuthController( public class AuthController(
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,
AuthService auth, AuthService auth
GeoIpService geo, // GeoIpService geo,
ActionLogService als // ActionLogService als
) : ControllerBase ) : ControllerBase
{ {
public class ChallengeRequest public class ChallengeRequest
@ -59,7 +59,7 @@ public class AuthController(
Scopes = request.Scopes, Scopes = request.Scopes,
IpAddress = ipAddress, IpAddress = ipAddress,
UserAgent = userAgent, UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress), // Location = geo.GetPointFromIp(ipAddress),
DeviceId = request.DeviceId, DeviceId = request.DeviceId,
AccountId = account.Id AccountId = account.Id
}.Normalize(); }.Normalize();
@ -67,9 +67,9 @@ public class AuthController(
await db.AuthChallenges.AddAsync(challenge); await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt, // als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account // new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
); // );
return challenge; return challenge;
} }
@ -160,13 +160,13 @@ public class AuthController(
challenge.StepRemain = Math.Max(0, challenge.StepRemain); challenge.StepRemain = Math.Max(0, challenge.StepRemain);
challenge.BlacklistFactors.Add(factor.Id); challenge.BlacklistFactors.Add(factor.Id);
db.Update(challenge); db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, // als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object> // new Dictionary<string, object>
{ // {
{ "challenge_id", challenge.Id }, // { "challenge_id", challenge.Id },
{ "factor_id", factor.Id } // { "factor_id", factor.Id }
}, Request, challenge.Account // }, Request, challenge.Account
); // );
} }
else else
{ {
@ -177,26 +177,26 @@ public class AuthController(
{ {
challenge.FailedAttempts++; challenge.FailedAttempts++;
db.Update(challenge); db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, // als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object> // new Dictionary<string, object>
{ // {
{ "challenge_id", challenge.Id }, // { "challenge_id", challenge.Id },
{ "factor_id", factor.Id } // { "factor_id", factor.Id }
}, Request, challenge.Account // }, Request, challenge.Account
); // );
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return BadRequest("Invalid password."); return BadRequest("Invalid password.");
} }
if (challenge.StepRemain == 0) if (challenge.StepRemain == 0)
{ {
als.CreateActionLogFromRequest(ActionLogType.NewLogin, // als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object> // new Dictionary<string, object>
{ // {
{ "challenge_id", challenge.Id }, // { "challenge_id", challenge.Id },
{ "account_id", challenge.AccountId } // { "account_id", challenge.AccountId }
}, Request, challenge.Account // }, Request, challenge.Account
); // );
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@ -1,21 +1,23 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Account; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Http;
using System.IO;
namespace DysonNetwork.Sphere.Auth; 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
) )
{ {
private HttpContext HttpContext => httpContextAccessor.HttpContext!; // private HttpContext HttpContext => httpContextAccessor.HttpContext!;
/// <summary> /// <summary>
/// Detect the risk of the current request to login /// Detect the risk of the current request to login
@ -24,7 +26,7 @@ public class AuthService(
/// <param name="request">The request context</param> /// <param name="request">The request context</param>
/// <param name="account">The account to login</param> /// <param name="account">The account to login</param>
/// <returns>The required steps to login</returns> /// <returns>The required steps to login</returns>
public async Task<int> DetectChallengeRisk(HttpRequest request, Account.Account account) public async Task<int> DetectChallengeRisk(HttpRequest request, Shared.Models.Account account)
{ {
// 1) Find out how many authentication factors the account has enabled. // 1) Find out how many authentication factors the account has enabled.
var maxSteps = await db.AccountAuthFactors var maxSteps = await db.AccountAuthFactors
@ -73,13 +75,13 @@ public class AuthService(
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) public async Task<Session> CreateSessionForOidcAsync(Shared.Models.Account account, Instant time, Guid? customAppId = null)
{ {
var challenge = new Challenge var challenge = new Challenge
{ {
AccountId = account.Id, AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), IpAddress = "127.0.0.1", // HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent, UserAgent = "TestAgent", // HttpContext.Request.Headers.UserAgent,
StepRemain = 1, StepRemain = 1,
StepTotal = 1, StepTotal = 1,
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
@ -105,53 +107,54 @@ public class AuthService(
{ {
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
} }
public string CreateToken(Session session) public string CreateToken(Session session)
@ -183,56 +186,56 @@ public class AuthService(
return $"{payloadBase64}.{signatureBase64}"; return $"{payloadBase64}.{signatureBase64}";
} }
public async Task<bool> ValidateSudoMode(Session session, string? pinCode) // public async Task<bool> ValidateSudoMode(Session session, string? pinCode)
{ // {
// Check if the session is already in sudo mode (cached) // // Check if the session is already in sudo mode (cached)
var sudoModeKey = $"accounts:{session.Id}:sudo"; // var sudoModeKey = $"accounts:{session.Id}:sudo";
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey); // var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
if (found) // if (found)
{ // {
// Session is already in sudo mode // // Session is already in sudo mode
return true; // return true;
} // }
// Check if the user has a pin code // // Check if the user has a pin code
var hasPinCode = await db.AccountAuthFactors // var hasPinCode = await db.AccountAuthFactors
.Where(f => f.AccountId == session.AccountId) // .Where(f => f.AccountId == session.AccountId)
.Where(f => f.EnabledAt != null) // .Where(f => f.EnabledAt != null)
.Where(f => f.Type == AccountAuthFactorType.PinCode) // .Where(f => f.Type == AccountAuthFactorType.PinCode)
.AnyAsync(); // .AnyAsync();
if (!hasPinCode) // if (!hasPinCode)
{ // {
// User doesn't have a pin code, no validation needed // // User doesn't have a pin code, no validation needed
return true; // return true;
} // }
// If pin code is not provided, we can't validate // // If pin code is not provided, we can't validate
if (string.IsNullOrEmpty(pinCode)) // if (string.IsNullOrEmpty(pinCode))
{ // {
return false; // return false;
} // }
try // try
{ // {
// Validate the pin code // // Validate the pin code
var isValid = await ValidatePinCode(session.AccountId, pinCode); // var isValid = await ValidatePinCode(session.AccountId, pinCode);
if (isValid) // if (isValid)
{ // {
// Set session in sudo mode for 5 minutes // // Set session in sudo mode for 5 minutes
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5)); // await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
} // }
return isValid; // return isValid;
} // }
catch (InvalidOperationException) // catch (InvalidOperationException)
{ // {
// No pin code enabled for this account, so validation is successful // // No pin code enabled for this account, so validation is successful
return true; // return true;
} // }
} // }
public async Task<bool> ValidatePinCode(Guid accountId, string pinCode) public async Task<bool> ValidatePinCode(Guid accountId, string pinCode)
{ {

View File

@ -1,4 +1,4 @@
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public class CaptchaVerificationResponse public class CaptchaVerificationResponse
{ {

View File

@ -1,6 +1,8 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Pass.Auth;
public class CompactTokenService(IConfiguration config) public class CompactTokenService(IConfiguration config)
{ {

View File

@ -1,18 +1,19 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Auth.OidcProvider.Options; using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses; using DysonNetwork.Pass.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; namespace DysonNetwork.Pass.Auth.OidcProvider.Controllers;
[Route("/auth/open")] [Route("/auth/open")]
[ApiController] [ApiController]
@ -114,7 +115,7 @@ public class OidcProviderController(
[Authorize] [Authorize]
public async Task<IActionResult> GetUserInfo() public async Task<IActionResult> GetUserInfo()
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
// Get requested scopes from the token // Get requested scopes from the token

View File

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Models; namespace DysonNetwork.Pass.Auth.OidcProvider.Models;
public class AuthorizationCodeInfo public class AuthorizationCodeInfo
{ {

View File

@ -1,6 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options; namespace DysonNetwork.Pass.Auth.OidcProvider.Options;
public class OidcProviderOptions public class OidcProviderOptions
{ {

View File

@ -1,6 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class AuthorizationResponse public class AuthorizationResponse
{ {

View File

@ -1,6 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class ErrorResponse public class ErrorResponse
{ {

View File

@ -1,6 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class TokenResponse public class TokenResponse
{ {

View File

@ -2,17 +2,18 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Models; using DysonNetwork.Pass.Auth.OidcProvider.Models;
using DysonNetwork.Sphere.Auth.OidcProvider.Options; using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses; using DysonNetwork.Pass.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Services; namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
public class OidcProviderService( public class OidcProviderService(
AppDatabase db, AppDatabase db,
@ -26,16 +27,18 @@ public class OidcProviderService(
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId) public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
{ {
return await db.CustomApps return null;
.Include(c => c.Secrets) // return await db.CustomApps
.FirstOrDefaultAsync(c => c.Id == clientId); // .Include(c => c.Secrets)
// .FirstOrDefaultAsync(c => c.Id == clientId);
} }
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId) public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
{ {
return await db.CustomApps return null;
.Include(c => c.Secrets) // return await db.CustomApps
.FirstOrDefaultAsync(c => c.Id == appId); // .Include(c => c.Secrets)
// .FirstOrDefaultAsync(c => c.Id == appId);
} }
public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId) public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId)

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class AfdianOidcService( public class AfdianOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

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

View File

@ -3,10 +3,12 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary> /// <summary>
/// Implementation of OpenID Connect service for Apple Sign In /// Implementation of OpenID Connect service for Apple Sign In

View File

@ -1,11 +1,13 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Storage;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
[ApiController] [ApiController]
[Route("/accounts/me/connections")] [Route("/accounts/me/connections")]
@ -25,7 +27,7 @@ public class ConnectionController(
[HttpGet] [HttpGet]
public async Task<ActionResult<List<AccountConnection>>> GetConnections() public async Task<ActionResult<List<AccountConnection>>> GetConnections()
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var connections = await db.AccountConnections var connections = await db.AccountConnections
@ -48,7 +50,7 @@ public class ConnectionController(
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult> RemoveConnection(Guid id) public async Task<ActionResult> RemoveConnection(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var connection = await db.AccountConnections var connection = await db.AccountConnections
@ -66,7 +68,7 @@ public class ConnectionController(
[HttpPost("/auth/connect/apple/mobile")] [HttpPost("/auth/connect/apple/mobile")]
public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request) public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
if (GetOidcService("apple") is not AppleOidcService appleService) if (GetOidcService("apple") is not AppleOidcService appleService)
@ -132,7 +134,7 @@ public class ConnectionController(
[HttpPost("connect")] [HttpPost("connect")]
public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request) public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var oidcService = GetOidcService(request.Provider); var oidcService = GetOidcService(request.Provider);

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class DiscordOidcService( public class DiscordOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class GitHubOidcService( public class GitHubOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,11 +1,11 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography; using DysonNetwork.Shared.Cache;
using System.Text; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage; using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class GoogleOidcService( public class GoogleOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,8 +1,10 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.Extensions.Configuration;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
public class MicrosoftOidcService( public class MicrosoftOidcService(
IConfiguration configuration, IConfiguration configuration,

View File

@ -1,11 +1,13 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
[ApiController] [ApiController]
[Route("/auth/login")] [Route("/auth/login")]
@ -32,7 +34,7 @@ public class OidcController(
var oidcService = GetOidcService(provider); var oidcService = GetOidcService(provider);
// If the user is already authenticated, treat as an account connection request // If the user is already authenticated, treat as an account connection request
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser)
{ {
var state = Guid.NewGuid().ToString(); var state = Guid.NewGuid().ToString();
var nonce = Guid.NewGuid().ToString(); var nonce = Guid.NewGuid().ToString();
@ -125,7 +127,7 @@ public class OidcController(
}; };
} }
private async Task<Account.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider) private async Task<Shared.Models.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
{ {
if (string.IsNullOrEmpty(userInfo.Email)) if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation"); throw new ArgumentException("Email is required for account creation");

View File

@ -1,13 +1,15 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account; using DysonNetwork.Shared.Cache;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary> /// <summary>
/// Base service for OpenID Connect authentication providers /// Base service for OpenID Connect authentication providers
@ -190,7 +192,7 @@ public abstract class OidcService(
/// </summary> /// </summary>
public async Task<Challenge> CreateChallengeForUserAsync( public async Task<Challenge> CreateChallengeForUserAsync(
OidcUserInfo userInfo, OidcUserInfo userInfo,
Account.Account account, Shared.Models.Account account,
HttpContext request, HttpContext request,
string deviceId string deviceId
) )

View File

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

View File

@ -0,0 +1,77 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="MagicOnion.Client" Version="7.0.5" />
<PackageReference Include="MagicOnion.Server" Version="7.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore">
<HintPath>..\..\..\..\..\..\opt\homebrew\Cellar\dotnet\9.0.6\libexec\shared\Microsoft.AspNetCore.App\9.0.6\Microsoft.AspNetCore.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Content Update="Pages\Emails\ContactVerificationEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\EmailLayout.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\LandingEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\PasswordResetEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\VerificationEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Update="Pages\Emails\AccountDeletionEmail.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,42 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
<p>@(Localizer["AccountDeletionPara2"])</p>
<p>@(Localizer["AccountDeletionPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["AccountDeletionButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["AccountDeletionPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@ -0,0 +1,43 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["ContactVerificationHeader"])</p>
<p>@(Localizer["ContactVerificationPara1"]) @Name,</p>
<p>@(Localizer["ContactVerificationPara2"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["ContactVerificationButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["ContactVerificationPara3"])</p>
<p>@(Localizer["ContactVerificationPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@ -0,0 +1,337 @@
@inherits LayoutComponentBase
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style media="all" type="text/css">
body {
font-family: Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 16px;
line-height: 1.3;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td {
font-family: Helvetica, sans-serif;
font-size: 16px;
vertical-align: top;
}
body {
background-color: #f4f5f6;
margin: 0;
padding: 0;
}
.body {
background-color: #f4f5f6;
width: 100%;
}
.container {
margin: 0 auto !important;
max-width: 600px;
padding: 0;
padding-top: 24px;
width: 600px;
}
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 600px;
padding: 0;
}
.main {
background: #ffffff;
border: 1px solid #eaebed;
border-radius: 16px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
padding: 24px;
}
.footer {
clear: both;
padding-top: 24px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #9a9ea6;
font-size: 16px;
text-align: center;
}
p {
font-family: Helvetica, sans-serif;
font-size: 16px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
}
a {
color: #0867ec;
text-decoration: underline;
}
.btn {
box-sizing: border-box;
min-width: 100% !important;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 16px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 4px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 2px #0867ec;
border-radius: 4px;
box-sizing: border-box;
color: #0867ec;
cursor: pointer;
display: inline-block;
font-size: 16px;
font-weight: bold;
margin: 0;
padding: 12px 24px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #0867ec;
}
.btn-primary a {
background-color: #0867ec;
border-color: #0867ec;
color: #ffffff;
}
.font-bold {
font-weight: bold;
}
.verification-code
{
font-family: "Courier New", Courier, monospace;
font-size: 24px;
letter-spacing: 0.5em;
}
@@media all {
.btn-primary table td:hover {
background-color: #ec0867 !important;
}
.btn-primary a:hover {
background-color: #ec0867 !important;
border-color: #ec0867 !important;
}
}
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.text-link {
color: #0867ec !important;
text-decoration: underline !important;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
@@media only screen and (max-width: 640px) {
.main p,
.main td,
.main span {
font-size: 16px !important;
}
.wrapper {
padding: 8px !important;
}
.content {
padding: 0 !important;
}
.container {
padding: 0 !important;
padding-top: 8px !important;
width: 100% !important;
}
.main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
.btn table {
max-width: 100% !important;
width: 100% !important;
}
.btn a {
font-size: 16px !important;
max-width: 100% !important;
width: 100% !important;
}
}
@@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
</style>
</head>
<body>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
<!-- START MAIN CONTENT AREA -->
@ChildContent
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
<span class="apple-link">Solar Network</span>
<br> Solsynth LLC © @(DateTime.Now.Year)
</td>
</tr>
<tr>
<td class="content-block powered-by">
Powered by <a href="https://github.com/solsynth/dysonnetwork">Dyson Network</a>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER --></div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
}

View File

@ -0,0 +1,43 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["LandingHeader1"])</p>
<p>@(Localizer["LandingPara1"]) @@@Name,</p>
<p>@(Localizer["LandingPara2"])</p>
<p>@(Localizer["LandingPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["LandingButton1"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["LandingPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@ -0,0 +1,44 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["PasswordResetHeader"])</p>
<p>@(Localizer["PasswordResetPara1"]) @@@Name,</p>
<p>@(Localizer["PasswordResetPara2"])</p>
<p>@(Localizer["PasswordResetPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["PasswordResetButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["PasswordResetPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@ -0,0 +1,27 @@
@using DysonNetwork.Pass.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["VerificationHeader1"])</p>
<p>@(Localizer["VerificationPara1"]) @@@Name,</p>
<p>@(Localizer["VerificationPara2"])</p>
<p>@(Localizer["VerificationPara3"])</p>
<p class="verification-code">@Code</p>
<p>@(Localizer["VerificationPara4"])</p>
<p>@(Localizer["VerificationPara5"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Code { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@ -1,6 +1,6 @@
namespace DysonNetwork.Sphere.Permission; using Microsoft.AspNetCore.Http;
using System; namespace DysonNetwork.Pass.Permission;
[AttributeUsage(AttributeTargets.Method, Inherited = true)] [AttributeUsage(AttributeTargets.Method, Inherited = true)]
public class RequiredPermissionAttribute(string area, string key) : Attribute public class RequiredPermissionAttribute(string area, string key) : Attribute
@ -21,7 +21,7 @@ public class PermissionMiddleware(RequestDelegate next)
if (attr != null) if (attr != null)
{ {
if (httpContext.Items["CurrentUser"] is not Account.Account currentUser) if (httpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
{ {
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized"); await httpContext.Response.WriteAsync("Unauthorized");

View File

@ -1,9 +1,10 @@
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Permission; namespace DysonNetwork.Pass.Permission;
public class PermissionService( public class PermissionService(
AppDatabase db, AppDatabase db,

View File

@ -0,0 +1,44 @@
using DysonNetwork.Pass;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Startup;
using DysonNetwork.Shared.Startup;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.ConfigureAppKestrel();
builder.Services.AddAppSwagger();
builder.Services.AddAppAuthentication();
builder.Services.AddAppRateLimiting();
builder.Services.AddAppBusinessServices(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddMagicOnion();
builder.Services.AddDbContext<AppDatabase>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("App")));
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<AuthService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseAuthorization();
app.ConfigureAppMiddleware(builder.Configuration);
app.MapControllers();
app.MapMagicOnionService();
// Run database migrations
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.Database.MigrateAsync();
}
app.Run();

View File

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

View File

@ -0,0 +1,11 @@
{
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network_pass;Username=postgres;Password=password"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -4,7 +4,7 @@ using NodaTime;
using NodaTime.Serialization.JsonNet; using NodaTime.Serialization.JsonNet;
using StackExchange.Redis; using StackExchange.Redis;
namespace DysonNetwork.Sphere.Storage; namespace DysonNetwork.Shared.Cache;
/// <summary> /// <summary>
/// Represents a distributed lock that can be used to synchronize access across multiple processes /// Represents a distributed lock that can be used to synchronize access across multiple processes

View File

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\*.proto" GrpcServices="Both" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Google.Protobuf" Version="3.27.2" />
<PackageReference Include="MagicOnion.Client" Version="7.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore">
<HintPath>..\..\..\..\..\..\opt\homebrew\Cellar\dotnet\9.0.6\libexec\shared\Microsoft.AspNetCore.App\9.0.6\Microsoft.AspNetCore.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -1,14 +1,11 @@
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.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using OtpNet; using OtpNet;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
[Index(nameof(Name), IsUnique = true)] [Index(nameof(Name), IsUnique = true)]
public class Account : ModelBase public class Account : ModelBase
@ -26,8 +23,8 @@ public class Account : ModelBase
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>(); [JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>(); [JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>(); [JsonIgnore] public ICollection<Session> Sessions { get; set; } = new List<Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>(); [JsonIgnore] public ICollection<Challenge> Challenges { get; set; } = new List<Challenge>();
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>(); [JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>(); [JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();

View File

@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
public enum StatusAttitude public enum StatusAttitude
{ {
@ -23,7 +23,7 @@ public class Status : ModelBase
public Instant? ClearedAt { get; set; } public Instant? ClearedAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
} }
public enum CheckInResultLevel public enum CheckInResultLevel
@ -44,7 +44,7 @@ public class CheckInResult : ModelBase
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>(); [Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
} }
public class FortuneTip public class FortuneTip

View File

@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Point = NetTopologySuite.Geometries.Point; using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
public abstract class ActionLogType public abstract class ActionLogType
{ {
@ -53,6 +53,6 @@ public class ActionLog : ModelBase
public Point? Location { get; set; } public Point? Location { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
public Guid? SessionId { get; set; } public Guid? SessionId { get; set; }
} }

View File

@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
public class Badge : ModelBase public class Badge : ModelBase
{ {
@ -16,7 +16,7 @@ public class Badge : ModelBase
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!; [JsonIgnore] public Shared.Models.Account Account { get; set; } = null!;
public BadgeReferenceObject ToReference() public BadgeReferenceObject ToReference()
{ {

View File

@ -1,10 +1,9 @@
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.Sphere.Storage;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Shared.Models;
public enum ChatRoomType public enum ChatRoomType
{ {
@ -31,7 +30,7 @@ public class ChatRoom : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<ChatMember> Members { get; set; } = new List<ChatMember>(); [JsonIgnore] public ICollection<ChatMember> Members { get; set; } = new List<ChatMember>();
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
public Realm.Realm? Realm { get; set; } public Shared.Models.Realm? Realm { get; set; }
[NotMapped] [NotMapped]
[JsonPropertyName("members")] [JsonPropertyName("members")]
@ -73,7 +72,7 @@ public class ChatMember : ModelBase
public Guid ChatRoomId { get; set; } public Guid ChatRoomId { get; set; }
public ChatRoom ChatRoom { get; set; } = null!; public ChatRoom ChatRoom { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; } [MaxLength(1024)] public string? Nick { get; set; }
@ -105,7 +104,7 @@ public class ChatMemberTransmissionObject : ModelBase
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid ChatRoomId { get; set; } public Guid ChatRoomId { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; } [MaxLength(1024)] public string? Nick { get; set; }

View File

@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Storage; namespace DysonNetwork.Shared.Models;
public class RemoteStorageConfig public class RemoteStorageConfig
{ {
@ -20,6 +20,59 @@ public class RemoteStorageConfig
public string? AccessProxy { get; set; } public string? AccessProxy { get; set; }
} }
/// <summary>
/// Common interface for cloud file entities that can be used in file operations.
/// This interface exposes the essential properties needed for file operations
/// and is implemented by both CloudFile and CloudFileReferenceObject.
/// </summary>
public interface ICloudFile
{
public Instant CreatedAt { get; }
public Instant UpdatedAt { get; }
public Instant? DeletedAt { get; }
/// <summary>
/// Gets the unique identifier of the cloud file.
/// </summary>
string Id { get; }
/// <summary>
/// Gets the name of the cloud file.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the file metadata dictionary.
/// </summary>
Dictionary<string, object>? FileMeta { get; }
/// <summary>
/// Gets the user metadata dictionary.
/// </summary>
Dictionary<string, object>? UserMeta { get; }
/// <summary>
/// Gets the MIME type of the file.
/// </summary>
string? MimeType { get; }
/// <summary>
/// Gets the hash of the file content.
/// </summary>
string? Hash { get; }
/// <summary>
/// Gets the size of the file in bytes.
/// </summary>
long Size { get; }
/// <summary>
/// Gets whether the file has a compressed version available.
/// </summary>
bool HasCompression { get; }
}
/// <summary> /// <summary>
/// The class that used in jsonb columns which referenced the cloud file. /// The class that used in jsonb columns which referenced the cloud file.
/// The aim of this class is to store some properties that won't change to a file to reduce the database load. /// The aim of this class is to store some properties that won't change to a file to reduce the database load.
@ -74,7 +127,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
[MaxLength(4096)] [MaxLength(4096)]
public string? StorageUrl { get; set; } public string? StorageUrl { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Shared.Models.Account Account { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public CloudFileReferenceObject ToReferenceObject() public CloudFileReferenceObject ToReferenceObject()

View File

@ -1,11 +1,9 @@
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.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Developer; namespace DysonNetwork.Shared.Models;
public enum CustomAppStatus public enum CustomAppStatus
{ {
@ -33,7 +31,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>(); [JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!; public Publisher Developer { get; set; } = null!;
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id; [NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
} }

View File

@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
public enum MagicSpellType public enum MagicSpellType
{ {
@ -26,5 +26,5 @@ public class MagicSpell : ModelBase
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public Account? Account { get; set; } public Shared.Models.Account? Account { get; set; }
} }

View File

@ -1,11 +1,9 @@
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.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Shared.Models;
public class Message : ModelBase, IIdentifiedResource public class Message : ModelBase, IIdentifiedResource
{ {

View File

@ -0,0 +1,15 @@
using NodaTime;
namespace DysonNetwork.Shared.Models;
public interface IIdentifiedResource
{
public string ResourceIdentifier { get; }
}
public abstract class ModelBase
{
public Instant CreatedAt { get; set; }
public Instant UpdatedAt { get; set; }
public Instant? DeletedAt { get; set; }
}

View File

@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
public class Notification : ModelBase public class Notification : ModelBase
{ {
@ -18,7 +18,7 @@ public class Notification : ModelBase
public Instant? ViewedAt { get; set; } public Instant? ViewedAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!; [JsonIgnore] public Shared.Models.Account Account { get; set; } = null!;
} }
public enum NotificationPushProvider public enum NotificationPushProvider
@ -37,5 +37,5 @@ public class NotificationPushSubscription : ModelBase
public Instant? LastUsedAt { get; set; } public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!; [JsonIgnore] public Shared.Models.Account Account { get; set; } = null!;
} }

View File

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

View File

@ -1,9 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Sphere.Developer;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Shared.Models;
public class WalletCurrency public class WalletCurrency
{ {
@ -32,7 +31,7 @@ public class Order : ModelBase
public Instant ExpiredAt { get; set; } public Instant ExpiredAt { get; set; }
public Guid? PayeeWalletId { get; set; } public Guid? PayeeWalletId { get; set; }
public Wallet? PayeeWallet { get; set; } = null!; public Shared.Models.Wallet? PayeeWallet { get; set; } = null!;
public Guid? TransactionId { get; set; } public Guid? TransactionId { get; set; }
public Transaction? Transaction { get; set; } public Transaction? Transaction { get; set; }
public Guid? IssuerAppId { get; set; } public Guid? IssuerAppId { get; set; }
@ -56,8 +55,8 @@ public class Transaction : ModelBase
// When the payer is null, it's pay from the system // When the payer is null, it's pay from the system
public Guid? PayerWalletId { get; set; } public Guid? PayerWalletId { get; set; }
public Wallet? PayerWallet { get; set; } public Shared.Models.Wallet? PayerWallet { get; set; }
// When the payee is null, it's pay for the system // When the payee is null, it's pay for the system
public Guid? PayeeWalletId { get; set; } public Guid? PayeeWalletId { get; set; }
public Wallet? PayeeWallet { get; set; } public Shared.Models.Wallet? PayeeWallet { get; set; }
} }

View File

@ -5,7 +5,7 @@ using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Permission; namespace DysonNetwork.Shared.Models;
/// The permission node model provides the infrastructure of permission control in Dyson Network. /// The permission node model provides the infrastructure of permission control in Dyson Network.
/// It based on the ABAC permission model. /// It based on the ABAC permission model.

View File

@ -1,12 +1,10 @@
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.Sphere.Post;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Publisher; namespace DysonNetwork.Shared.Models;
public enum PublisherType public enum PublisherType
{ {
@ -30,10 +28,8 @@ public class Publisher : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<Post.Post> Posts { get; set; } = new List<Post.Post>();
[JsonIgnore] public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>();
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>(); [JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
[JsonIgnore] public ICollection<PublisherFeature> Features { get; set; } = new List<PublisherFeature>(); [JsonIgnore] public ICollection<PublisherFeature> Features { get; set; } = new List<PublisherFeature>();
@ -41,9 +37,9 @@ public class Publisher : ModelBase, IIdentifiedResource
public ICollection<PublisherSubscription> Subscriptions { get; set; } = new List<PublisherSubscription>(); public ICollection<PublisherSubscription> Subscriptions { get; set; } = new List<PublisherSubscription>();
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public Account.Account? Account { get; set; } public Shared.Models.Account? Account { get; set; }
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
[JsonIgnore] public Realm.Realm? Realm { get; set; } [JsonIgnore] public Realm? Realm { get; set; }
public string ResourceIdentifier => $"publisher/{Id}"; public string ResourceIdentifier => $"publisher/{Id}";
} }
@ -61,7 +57,7 @@ public class PublisherMember : ModelBase
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
[JsonIgnore] public Publisher Publisher { get; set; } = null!; [JsonIgnore] public Publisher Publisher { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
public PublisherMemberRole Role { get; set; } = PublisherMemberRole.Viewer; public PublisherMemberRole Role { get; set; } = PublisherMemberRole.Viewer;
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }
@ -81,7 +77,7 @@ public class PublisherSubscription : ModelBase
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
[JsonIgnore] public Publisher Publisher { get; set; } = null!; [JsonIgnore] public Publisher Publisher { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Shared.Models.Account Account { get; set; } = null!;
public PublisherSubscriptionStatus Status { get; set; } = PublisherSubscriptionStatus.Active; public PublisherSubscriptionStatus Status { get; set; } = PublisherSubscriptionStatus.Active;
public int Tier { get; set; } = 0; public int Tier { get; set; } = 0;

View File

@ -1,12 +1,10 @@
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.Sphere.Chat;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Shared.Models;
[Index(nameof(Slug), IsUnique = true)] [Index(nameof(Slug), IsUnique = true)]
public class Realm : ModelBase, IIdentifiedResource public class Realm : ModelBase, IIdentifiedResource
@ -25,14 +23,13 @@ public class Realm : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<RealmMember> Members { get; set; } = new List<RealmMember>(); [JsonIgnore] public ICollection<RealmMember> Members { get; set; } = new List<RealmMember>();
[JsonIgnore] public ICollection<ChatRoom> ChatRooms { get; set; } = new List<ChatRoom>(); [JsonIgnore] public ICollection<ChatRoom> ChatRooms { get; set; } = new List<ChatRoom>();
[JsonIgnore] public ICollection<RealmTag> RealmTags { get; set; } = new List<RealmTag>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Shared.Models.Account Account { get; set; } = null!;
public string ResourceIdentifier => $"realm/{Id}"; public string ResourceIdentifier => $"realm/{Id}";
} }
@ -49,7 +46,7 @@ public class RealmMember : ModelBase
public Guid RealmId { get; set; } public Guid RealmId { get; set; }
public Realm Realm { get; set; } = null!; public Realm Realm { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
public int Role { get; set; } = RealmMemberRole.Normal; public int Role { get; set; } = RealmMemberRole.Normal;
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }

View File

@ -1,12 +1,8 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Chat.Realtime;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Shared.Models;
public class RealtimeCall : ModelBase public class RealtimeCall : ModelBase
{ {

View File

@ -1,6 +1,6 @@
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
public enum RelationshipStatus : short public enum RelationshipStatus : short
{ {
@ -12,9 +12,9 @@ public enum RelationshipStatus : short
public class Relationship : ModelBase public class Relationship : ModelBase
{ {
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
public Guid RelatedId { get; set; } public Guid RelatedId { get; set; }
public Account Related { get; set; } = null!; public Shared.Models.Account Related { get; set; } = null!;
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }

View File

@ -1,11 +1,10 @@
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.Sphere.Developer;
using NodaTime; using NodaTime;
using Point = NetTopologySuite.Geometries.Point; using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Shared.Models;
public class Session : ModelBase public class Session : ModelBase
{ {
@ -15,7 +14,7 @@ public class Session : ModelBase
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; } public Guid ChallengeId { get; set; }
public Challenge Challenge { get; set; } = null!; public Challenge Challenge { get; set; } = null!;
public Guid? AppId { get; set; } public Guid? AppId { get; set; }
@ -49,9 +48,9 @@ public class Challenge : ModelBase
public int FailedAttempts { get; set; } public int FailedAttempts { get; set; }
public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified; public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
public ChallengeType Type { get; set; } = ChallengeType.Login; public ChallengeType Type { get; set; } = ChallengeType.Login;
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new(); [Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = [];
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new(); [Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = [];
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new(); [Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
[MaxLength(128)] public string? IpAddress { get; set; } [MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; } [MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(256)] public string? DeviceId { get; set; } [MaxLength(256)] public string? DeviceId { get; set; }
@ -59,7 +58,7 @@ public class Challenge : ModelBase
public Point? Location { get; set; } public Point? Location { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account Account { get; set; } = null!;
public Challenge Normalize() public Challenge Normalize()
{ {

View File

@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Shared.Models;
public record class SubscriptionTypeData( public record class SubscriptionTypeData(
string Identifier, string Identifier,
@ -138,7 +138,7 @@ public class Subscription : ModelBase
public Instant? RenewalAt { get; set; } public Instant? RenewalAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
[NotMapped] [NotMapped]
public bool IsAvailable public bool IsAvailable

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Shared.Models;
/// <summary> /// <summary>
/// The verification info of a resource /// The verification info of a resource

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Shared.Models;
public class Wallet : ModelBase public class Wallet : ModelBase
{ {
@ -10,7 +10,7 @@ public class Wallet : ModelBase
public ICollection<WalletPocket> Pockets { get; set; } = new List<WalletPocket>(); public ICollection<WalletPocket> Pockets { get; set; } = new List<WalletPocket>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Shared.Models.Account Account { get; set; } = null!;
} }
public class WalletPocket : ModelBase public class WalletPocket : ModelBase

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ public class ActivityController(
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>(); var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
return currentUserValue is not Account.Account currentUser return currentUserValue is not Shared.Models.Account currentUser
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet)) ? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet))
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet)); : Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet));
} }

View File

@ -13,7 +13,8 @@ public class ActivityService(
PublisherService pub, PublisherService pub,
RelationshipService rels, RelationshipService rels,
PostService ps, PostService ps,
DiscoveryService ds) DiscoveryService ds
)
{ {
private static double CalculateHotRank(Post.Post post, Instant now) private static double CalculateHotRank(Post.Post post, Instant now)
{ {
@ -32,7 +33,7 @@ public class ActivityService(
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2)) if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
{ {
var realms = await ds.GetPublicRealmsAsync(null, null, 5, 0, true); var realms = await ds.GetPublicRealmsAsync(null, 5, 0, true);
if (realms.Count > 0) if (realms.Count > 0)
{ {
activities.Add(new DiscoveryActivity( activities.Add(new DiscoveryActivity(
@ -118,7 +119,7 @@ public class ActivityService(
public async Task<List<Activity>> GetActivities( public async Task<List<Activity>> GetActivities(
int take, int take,
Instant? cursor, Instant? cursor,
Account.Account currentUser, Shared.Models.Account currentUser,
string? filter = null, string? filter = null,
HashSet<string>? debugInclude = null HashSet<string>? debugInclude = null
) )
@ -132,7 +133,7 @@ public class ActivityService(
{ {
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2)) if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
{ {
var realms = await ds.GetPublicRealmsAsync(null, null, 5, 0, true); var realms = await ds.GetPublicRealmsAsync(null, 5, 0, true);
if (realms.Count > 0) if (realms.Count > 0)
{ {
activities.Add(new DiscoveryActivity( activities.Add(new DiscoveryActivity(
@ -257,7 +258,7 @@ public class ActivityService(
return score + postCount; return score + postCount;
} }
private async Task<List<Publisher.Publisher>> GetPopularPublishers(int take) private async Task<List<Shared.Models.Publisher>> GetPopularPublishers(int take)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var recent = now.Minus(Duration.FromDays(7)); var recent = now.Minus(Duration.FromDays(7));

View File

@ -1,30 +1,16 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using DysonNetwork.Sphere.Account; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Sticker; using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query;
using NodaTime; using NodaTime;
using Npgsql;
using Quartz; using Quartz;
namespace DysonNetwork.Sphere; namespace DysonNetwork.Sphere;
public interface IIdentifiedResource
{
public string ResourceIdentifier { get; }
}
public abstract class ModelBase public abstract class ModelBase
{ {
public Instant CreatedAt { get; set; } public Instant CreatedAt { get; set; }
@ -37,32 +23,10 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Account.Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Status> AccountStatuses { get; set; }
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Badge> Badges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; }
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.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; }
@ -73,10 +37,8 @@ public class AppDatabase(
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.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<Tag> Tags { get; set; }
public DbSet<RealmTag> RealmTags { get; set; }
public DbSet<ChatRoom> ChatRooms { get; set; } public DbSet<ChatRoom> ChatRooms { get; set; }
public DbSet<ChatMember> ChatMembers { get; set; } public DbSet<ChatMember> ChatMembers { get; set; }
@ -87,7 +49,7 @@ public class AppDatabase(
public DbSet<Sticker.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.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; }
@ -111,38 +73,6 @@ public class AppDatabase(
.UseNodaTime() .UseNodaTime()
).UseSnakeCaseNamingConvention(); ).UseSnakeCaseNamingConvention();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var defaultPermissionGroup = await context.Set<PermissionGroup>()
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
if (defaultPermissionGroup is null)
{
context.Set<PermissionGroup>().Add(new PermissionGroup
{
Key = "default",
Nodes = new List<string>
{
"posts.create",
"posts.react",
"publishers.create",
"files.create",
"chat.create",
"chat.messages.create",
"chat.realtime.create",
"accounts.statuses.create",
"accounts.statuses.update",
"stickers.packs.create",
"stickers.create"
}.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true))
.ToList()
});
await context.SaveChangesAsync(cancellationToken);
}
});
optionsBuilder.UseSeeding((context, _) => {});
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
} }
@ -150,25 +80,6 @@ public class AppDatabase(
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<PublisherMember>() modelBuilder.Entity<PublisherMember>()
.HasKey(pm => new { pm.PublisherId, pm.AccountId }); .HasKey(pm => new { pm.PublisherId, pm.AccountId });
modelBuilder.Entity<PublisherMember>() modelBuilder.Entity<PublisherMember>()
@ -243,19 +154,6 @@ public class AppDatabase(
.HasForeignKey(pm => pm.AccountId) .HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasKey(rt => new { rt.RealmId, rt.TagId });
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Realm)
.WithMany(r => r.RealmTags)
.HasForeignKey(rt => rt.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Tag)
.WithMany(t => t.RealmTags)
.HasForeignKey(rt => rt.TagId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChatMember>() modelBuilder.Entity<ChatMember>()
.HasKey(pm => new { pm.Id }); .HasKey(pm => new { pm.Id });
modelBuilder.Entity<ChatMember>() modelBuilder.Entity<ChatMember>()
@ -352,23 +250,9 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin
{ {
public async Task Execute(IJobExecutionContext context) public async Task Execute(IJobExecutionContext context)
{ {
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Cleaning up expired records...");
// Expired relationships
var affectedRows = await db.AccountRelationships
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
// Expired permission group members
affectedRows = await db.PermissionGroupMembers
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
logger.LogInformation("Deleting soft-deleted records..."); logger.LogInformation("Deleting soft-deleted records...");
var now = SystemClock.Instance.GetCurrentInstant();
var threshold = now - Duration.FromDays(7); var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes() var entityTypes = db.Model.GetEntityTypes()

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -32,7 +33,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
[Authorize] [Authorize]
public async Task<ActionResult<Dictionary<Guid, ChatSummaryResponse>>> GetChatSummary() public async Task<ActionResult<Dictionary<Guid, ChatSummaryResponse>>> GetChatSummary()
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var unreadMessages = await cs.CountUnreadMessageForUser(currentUser.Id); var unreadMessages = await cs.CountUnreadMessageForUser(currentUser.Id);
var lastMessages = await cs.ListLastMessageForUser(currentUser.Id); var lastMessages = await cs.ListLastMessageForUser(currentUser.Id);
@ -65,7 +66,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
public async Task<ActionResult<List<Message>>> ListMessages(Guid roomId, [FromQuery] int offset, public async Task<ActionResult<List<Message>>> ListMessages(Guid roomId, [FromQuery] int offset,
[FromQuery] int take = 20) [FromQuery] int take = 20)
{ {
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account; var currentUser = HttpContext.Items["CurrentUser"] as Shared.Models.Account;
var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
@ -102,7 +103,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
[HttpGet("{roomId:guid}/messages/{messageId:guid}")] [HttpGet("{roomId:guid}/messages/{messageId:guid}")]
public async Task<ActionResult<Message>> GetMessage(Guid roomId, Guid messageId) public async Task<ActionResult<Message>> GetMessage(Guid roomId, Guid messageId)
{ {
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account; var currentUser = HttpContext.Items["CurrentUser"] as Shared.Models.Account;
var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
@ -139,7 +140,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
[RequiredPermission("global", "chat.messages.create")] [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 Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
request.Content = TextSanitizer.Sanitize(request.Content); request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(request.Content) && if (string.IsNullOrWhiteSpace(request.Content) &&
@ -216,7 +217,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
[Authorize] [Authorize]
public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId) public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
request.Content = TextSanitizer.Sanitize(request.Content); request.Content = TextSanitizer.Sanitize(request.Content);
@ -269,7 +270,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
[Authorize] [Authorize]
public async Task<ActionResult> DeleteMessage(Guid roomId, Guid messageId) public async Task<ActionResult> DeleteMessage(Guid roomId, Guid messageId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var message = await db.ChatMessages var message = await db.ChatMessages
.Include(m => m.Sender) .Include(m => m.Sender)
@ -295,7 +296,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
[HttpPost("{roomId:guid}/sync")] [HttpPost("{roomId:guid}/sync")]
public async Task<ActionResult<SyncResponse>> GetSyncData([FromBody] SyncRequest request, Guid roomId) public async Task<ActionResult<SyncResponse>> GetSyncData([FromBody] SyncRequest request, Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var isMember = await db.ChatMembers var isMember = await db.ChatMembers

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
@ -36,7 +37,7 @@ public class ChatRoomController(
if (chatRoom is null) return NotFound(); if (chatRoom is null) return NotFound();
if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom); if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser)
chatRoom = await crs.LoadDirectMessageMembers(chatRoom, currentUser.Id); chatRoom = await crs.LoadDirectMessageMembers(chatRoom, currentUser.Id);
return Ok(chatRoom); return Ok(chatRoom);
@ -46,7 +47,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 Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var userId = currentUser.Id; var userId = currentUser.Id;
@ -72,7 +73,7 @@ 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 Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
@ -134,7 +135,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid userId) public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid userId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var room = await db.ChatRooms var room = await db.ChatRooms
@ -164,7 +165,7 @@ public class ChatRoomController(
[RequiredPermission("global", "chat.create")] [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 Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
if (request.Name is null) return BadRequest("You cannot create a chat room without a name."); if (request.Name is null) return BadRequest("You cannot create a chat room without a name.");
var chatRoom = new ChatRoom var chatRoom = new ChatRoom
@ -236,7 +237,7 @@ public class ChatRoomController(
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<ActionResult<ChatRoom>> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request) public async Task<ActionResult<ChatRoom>> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(e => e.Id == id) .Where(e => e.Id == id)
@ -321,7 +322,7 @@ public class ChatRoomController(
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult> DeleteChatRoom(Guid id) public async Task<ActionResult> DeleteChatRoom(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(e => e.Id == id) .Where(e => e.Id == id)
@ -356,7 +357,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult<ChatMember>> GetRoomIdentity(Guid roomId) public async Task<ActionResult<ChatMember>> GetRoomIdentity(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser)
return Unauthorized(); return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
@ -375,7 +376,7 @@ public class ChatRoomController(
public async Task<ActionResult<List<ChatMember>>> ListMembers(Guid roomId, [FromQuery] int take = 20, public async Task<ActionResult<List<ChatMember>>> ListMembers(Guid roomId, [FromQuery] int take = 20,
[FromQuery] int skip = 0, [FromQuery] bool withStatus = false, [FromQuery] string? status = null) [FromQuery] int skip = 0, [FromQuery] bool withStatus = false, [FromQuery] string? status = null)
{ {
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account; var currentUser = HttpContext.Items["CurrentUser"] as Shared.Models.Account;
var room = await db.ChatRooms var room = await db.ChatRooms
.FirstOrDefaultAsync(r => r.Id == roomId); .FirstOrDefaultAsync(r => r.Id == roomId);
@ -448,7 +449,7 @@ public class ChatRoomController(
public async Task<ActionResult<ChatMember>> InviteMember(Guid roomId, public async Task<ActionResult<ChatMember>> InviteMember(Guid roomId,
[FromBody] ChatMemberRequest request) [FromBody] ChatMemberRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.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 db.Accounts.FindAsync(request.RelatedUserId);
@ -519,7 +520,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult<List<ChatMember>>> ListChatInvites() public async Task<ActionResult<List<ChatMember>>> ListChatInvites()
{ {
if (HttpContext.Items["CurrentUser"] is not Account.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 members = await db.ChatMembers var members = await db.ChatMembers
@ -544,7 +545,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult<ChatRoom>> AcceptChatInvite(Guid roomId) public async Task<ActionResult<ChatRoom>> AcceptChatInvite(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.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 member = await db.ChatMembers var member = await db.ChatMembers
@ -571,7 +572,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult> DeclineChatInvite(Guid roomId) public async Task<ActionResult> DeclineChatInvite(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.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 member = await db.ChatMembers var member = await db.ChatMembers
@ -600,7 +601,7 @@ public class ChatRoomController(
[FromBody] ChatMemberNotifyRequest request [FromBody] ChatMemberNotifyRequest request
) )
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId) .Where(r => r.Id == roomId)
@ -629,7 +630,7 @@ public class ChatRoomController(
public async Task<ActionResult<ChatMember>> UpdateChatMemberRole(Guid roomId, Guid memberId, [FromBody] int newRole) public async Task<ActionResult<ChatMember>> UpdateChatMemberRole(Guid roomId, Guid memberId, [FromBody] int newRole)
{ {
if (newRole >= ChatMemberRole.Owner) return BadRequest("Unable to set chat member to owner or greater role."); if (newRole >= ChatMemberRole.Owner) return BadRequest("Unable to set chat member to owner or greater role.");
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId) .Where(r => r.Id == roomId)
@ -688,7 +689,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult> RemoveChatMember(Guid roomId, Guid memberId) public async Task<ActionResult> RemoveChatMember(Guid roomId, Guid memberId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId) .Where(r => r.Id == roomId)
@ -736,7 +737,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult<ChatRoom>> JoinChatRoom(Guid roomId) public async Task<ActionResult<ChatRoom>> JoinChatRoom(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId) .Where(r => r.Id == roomId)
@ -774,7 +775,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult> LeaveChat(Guid roomId) public async Task<ActionResult> LeaveChat(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id) .Where(m => m.AccountId == currentUser.Id)
@ -807,7 +808,7 @@ public class ChatRoomController(
return NoContent(); return NoContent();
} }
private async Task _SendInviteNotify(ChatMember member, Account.Account sender) private async Task _SendInviteNotify(ChatMember member, Shared.Models.Account sender)
{ {
string title = localizer["ChatInviteTitle"]; string title = localizer["ChatInviteTitle"];

View File

@ -1,3 +1,5 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;

View File

@ -1,4 +1,5 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection; using DysonNetwork.Sphere.Connection;
@ -241,7 +242,7 @@ public partial class ChatService(
Priority = 10, Priority = 10,
}; };
List<Account.Account> accountsToNotify = []; List<Shared.Models.Account> accountsToNotify = [];
foreach (var member in members) foreach (var member in members)
{ {
scopedWs.SendPacketToAccount(member.AccountId, new WebSocketPacket scopedWs.SendPacketToAccount(member.AccountId, new WebSocketPacket
@ -252,7 +253,7 @@ public partial class ChatService(
if (member.Account.Id == sender.AccountId) continue; if (member.Account.Id == sender.AccountId) continue;
if (member.Notify == ChatMemberNotify.None) continue; if (member.Notify == ChatMemberNotify.None) continue;
// if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue; if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue;
if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.Account.Id)) if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.Account.Id))
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();

View File

@ -36,7 +36,7 @@ public interface IRealtimeService
/// <param name="sessionId">The session identifier</param> /// <param name="sessionId">The session identifier</param>
/// <param name="isAdmin">The user is the admin of session</param> /// <param name="isAdmin">The user is the admin of session</param>
/// <returns>User-specific token for the session</returns> /// <returns>User-specific token for the session</returns>
string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false); string GetUserToken(Shared.Models.Account account, string sessionId, bool isAdmin = false);
/// <summary> /// <summary>
/// Processes incoming webhook requests from the realtime service provider /// Processes incoming webhook requests from the realtime service provider

View File

@ -4,6 +4,8 @@ using Livekit.Server.Sdk.Dotnet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Chat.Realtime; namespace DysonNetwork.Sphere.Chat.Realtime;
@ -111,7 +113,7 @@ public class LivekitRealtimeService : IRealtimeService
} }
/// <inheritdoc /> /// <inheritdoc />
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false) public string GetUserToken(Shared.Models.Account account, string sessionId, bool isAdmin = false)
{ {
var token = _accessToken.WithIdentity(account.Name) var token = _accessToken.WithIdentity(account.Name)
.WithName(account.Nick) .WithName(account.Nick)

View File

@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Chat.Realtime;
using Livekit.Server.Sdk.Dotnet; using Livekit.Server.Sdk.Dotnet;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -46,7 +47,7 @@ public class RealtimeCallController(
[Authorize] [Authorize]
public async Task<ActionResult<RealtimeCall>> GetOngoingCall(Guid roomId) public async Task<ActionResult<RealtimeCall>> GetOngoingCall(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
@ -71,7 +72,7 @@ public class RealtimeCallController(
[Authorize] [Authorize]
public async Task<ActionResult<JoinCallResponse>> JoinCall(Guid roomId) public async Task<ActionResult<JoinCallResponse>> JoinCall(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
// Check if the user is a member of the chat room // Check if the user is a member of the chat room
var member = await db.ChatMembers var member = await db.ChatMembers
@ -144,7 +145,7 @@ public class RealtimeCallController(
[Authorize] [Authorize]
public async Task<ActionResult<RealtimeCall>> StartCall(Guid roomId) public async Task<ActionResult<RealtimeCall>> StartCall(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
@ -163,7 +164,7 @@ public class RealtimeCallController(
[Authorize] [Authorize]
public async Task<ActionResult<RealtimeCall>> EndCall(Guid roomId) public async Task<ActionResult<RealtimeCall>> EndCall(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId) .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)

View File

@ -1,4 +1,5 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -19,7 +20,7 @@ public class MessageReadHandler(
public const string ChatMemberCacheKey = "ChatMember_{0}_{1}"; public const string ChatMemberCacheKey = "ChatMember_{0}_{1}";
public async Task HandleAsync( public async Task HandleAsync(
Account.Account currentUser, Shared.Models.Account currentUser,
string deviceId, string deviceId,
WebSocketPacket packet, WebSocketPacket packet,
WebSocket socket, WebSocket socket,

View File

@ -10,7 +10,7 @@ public class MessageTypingHandler(ChatRoomService crs) : IWebSocketPacketHandler
public string PacketType => "messages.typing"; public string PacketType => "messages.typing";
public async Task HandleAsync( public async Task HandleAsync(
Account.Account currentUser, Shared.Models.Account currentUser,
string deviceId, string deviceId,
WebSocketPacket packet, WebSocketPacket packet,
WebSocket socket, WebSocket socket,

View File

@ -9,7 +9,7 @@ public class MessagesSubscribeHandler(ChatRoomService crs) : IWebSocketPacketHan
public string PacketType => "messages.subscribe"; public string PacketType => "messages.subscribe";
public async Task HandleAsync( public async Task HandleAsync(
Account.Account currentUser, Shared.Models.Account currentUser,
string deviceId, string deviceId,
WebSocketPacket packet, WebSocketPacket packet,
WebSocket socket, WebSocket socket,

View File

@ -8,7 +8,7 @@ public class MessagesUnsubscribeHandler() : IWebSocketPacketHandler
public string PacketType => "messages.unsubscribe"; public string PacketType => "messages.unsubscribe";
public Task HandleAsync( public Task HandleAsync(
Account.Account currentUser, Shared.Models.Account currentUser,
string deviceId, string deviceId,
WebSocketPacket packet, WebSocketPacket packet,
WebSocket socket, WebSocket socket,

Some files were not shown because too many files have changed in this diff Show More