Compare commits
3 Commits
d7d4fde06a
...
aabe8269f5
Author | SHA1 | Date | |
---|---|---|---|
aabe8269f5 | |||
6358c49090 | |||
0db003abc2 |
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using NodaTime.Extensions;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
@ -318,17 +319,17 @@ public class AccountController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var today = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||
var localTime = new TimeOnly(0, 0);
|
||||
var startOfDay = today.ToDateOnly().ToDateTime(localTime).ToUniversalTime().ToInstant();
|
||||
var endOfDay = today.PlusDays(1).ToDateOnly().ToDateTime(localTime).ToUniversalTime().ToInstant();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var today = now.InUtc().Date;
|
||||
var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||
var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||
|
||||
var result = await db.AccountCheckInResults
|
||||
.Where(x => x.AccountId == userId)
|
||||
.Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
@ -390,7 +391,30 @@ public class AccountController(
|
||||
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
|
||||
return Ok(calendar);
|
||||
}
|
||||
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("me/actions")]
|
||||
[ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<List<ActionLog>>> GetActionLogs([FromQuery] int take = 20, [FromQuery] int offset = 0)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var query = db.ActionLogs
|
||||
.Where(log => log.AccountId == currentUser.Id)
|
||||
.OrderByDescending(log => log.CreatedAt);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
|
||||
var logs = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
|
||||
{
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Sphere.Activity;
|
||||
using DysonNetwork.Sphere.Connection;
|
||||
using DysonNetwork.Sphere.Wallet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Localization;
|
||||
@ -13,6 +14,7 @@ public class AccountEventService(
|
||||
ActivityService act,
|
||||
WebSocketService ws,
|
||||
IMemoryCache cache,
|
||||
PaymentService payment,
|
||||
IStringLocalizer<Localization.AccountEventResource> localizer
|
||||
)
|
||||
{
|
||||
@ -163,12 +165,29 @@ public class AccountEventService(
|
||||
{
|
||||
Tips = tips,
|
||||
Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
|
||||
AccountId = user.Id
|
||||
AccountId = user.Id,
|
||||
RewardExperience = 100,
|
||||
RewardPoints = 10,
|
||||
};
|
||||
|
||||
db.AccountCheckInResults.Add(result);
|
||||
await db.SaveChangesAsync();
|
||||
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||
try
|
||||
{
|
||||
if (result.RewardPoints.HasValue)
|
||||
await payment.CreateTransactionWithAccountAsync(
|
||||
null,
|
||||
user.Id,
|
||||
WalletCurrency.SourcePoint,
|
||||
result.RewardPoints.Value,
|
||||
$"Check-in reward on {now:yyyy/MM/dd}"
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
result.RewardPoints = null;
|
||||
}
|
||||
|
||||
db.AccountCheckInResults.Add(result);
|
||||
await act.CreateActivity(
|
||||
user,
|
||||
"accounts.check-in",
|
||||
|
57
DysonNetwork.Sphere/Account/ActionLog.cs
Normal file
57
DysonNetwork.Sphere/Account/ActionLog.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
public class ActionLogType
|
||||
{
|
||||
public const string NewLogin = "login";
|
||||
public const string ChallengeAttempt = "challenges.attempt";
|
||||
public const string ChallengeSuccess = "challenges.success";
|
||||
public const string ChallengeFailure = "challenges.failure";
|
||||
public const string PostCreate = "posts.create";
|
||||
public const string PostUpdate = "posts.update";
|
||||
public const string PostDelete = "posts.delete";
|
||||
public const string PostReact = "posts.react";
|
||||
public const string MessageCreate = "messages.create";
|
||||
public const string MessageUpdate = "messages.update";
|
||||
public const string MessageDelete = "messages.delete";
|
||||
public const string MessageReact = "messages.react";
|
||||
public const string PublisherCreate = "publishers.create";
|
||||
public const string PublisherUpdate = "publishers.update";
|
||||
public const string PublisherDelete = "publishers.delete";
|
||||
public const string PublisherMemberInvite = "publishers.members.invite";
|
||||
public const string PublisherMemberJoin = "publishers.members.join";
|
||||
public const string PublisherMemberLeave = "publishers.members.leave";
|
||||
public const string PublisherMemberKick = "publishers.members.kick";
|
||||
public const string RealmCreate = "realms.create";
|
||||
public const string RealmUpdate = "realms.update";
|
||||
public const string RealmDelete = "realms.delete";
|
||||
public const string RealmInvite = "realms.invite";
|
||||
public const string RealmJoin = "realms.join";
|
||||
public const string RealmLeave = "realms.leave";
|
||||
public const string RealmKick = "realms.kick";
|
||||
public const string ChatroomCreate = "chatrooms.create";
|
||||
public const string ChatroomUpdate = "chatrooms.update";
|
||||
public const string ChatroomDelete = "chatrooms.delete";
|
||||
public const string ChatroomInvite = "chatrooms.invite";
|
||||
public const string ChatroomJoin = "chatrooms.join";
|
||||
public const string ChatroomLeave = "chatrooms.leave";
|
||||
public const string ChatroomKick = "chatrooms.kick";
|
||||
}
|
||||
|
||||
public class ActionLog : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(4096)] public string Action { get; set; } = null!;
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||
public Point? Location { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account Account { get; set; } = null!;
|
||||
public Guid? SessionId { get; set; }
|
||||
public Auth.Session? Session { get; set; } = null!;
|
||||
}
|
80
DysonNetwork.Sphere/Account/ActionLogService.cs
Normal file
80
DysonNetwork.Sphere/Account/ActionLogService.cs
Normal file
@ -0,0 +1,80 @@
|
||||
using Quartz;
|
||||
using System.Collections.Concurrent;
|
||||
using DysonNetwork.Sphere.Connection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
public class ActionLogService(AppDatabase db, GeoIpService geo) : IDisposable
|
||||
{
|
||||
private readonly ConcurrentQueue<ActionLog> _creationQueue = new();
|
||||
|
||||
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
|
||||
{
|
||||
var log = new ActionLog
|
||||
{
|
||||
Action = action,
|
||||
AccountId = accountId,
|
||||
Meta = meta,
|
||||
};
|
||||
|
||||
_creationQueue.Enqueue(log);
|
||||
}
|
||||
|
||||
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request)
|
||||
{
|
||||
if (request.HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
throw new ArgumentException("No user context was found");
|
||||
if (request.HttpContext.Items["CurrentSession"] is not Auth.Session currentSession)
|
||||
throw new ArgumentException("No session context was found");
|
||||
|
||||
var log = new ActionLog
|
||||
{
|
||||
Action = action,
|
||||
AccountId = currentUser.Id,
|
||||
SessionId = currentSession.Id,
|
||||
Meta = meta,
|
||||
UserAgent = request.Headers.UserAgent,
|
||||
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
|
||||
};
|
||||
|
||||
_creationQueue.Enqueue(log);
|
||||
}
|
||||
|
||||
public async Task FlushQueue()
|
||||
{
|
||||
var workingQueue = new List<ActionLog>();
|
||||
while (_creationQueue.TryDequeue(out var log))
|
||||
workingQueue.Add(log);
|
||||
|
||||
if (workingQueue.Count != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.ActionLogs.AddRangeAsync(workingQueue);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
foreach (var log in workingQueue)
|
||||
_creationQueue.Enqueue(log);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FlushQueue().Wait();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
public class ActionLogFlushJob(ActionLogService als) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
await als.FlushQueue();
|
||||
}
|
||||
}
|
@ -39,6 +39,8 @@ public class CheckInResult : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public CheckInResultLevel Level { get; set; }
|
||||
public decimal? RewardPoints { get; set; }
|
||||
public int? RewardExperience { get; set; }
|
||||
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
@ -36,6 +36,7 @@ public class AppDatabase(
|
||||
public DbSet<Account.Notification> Notifications { get; set; }
|
||||
public DbSet<Account.NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
|
||||
public DbSet<Account.Badge> Badges { get; set; }
|
||||
public DbSet<Account.ActionLog> ActionLogs { get; set; }
|
||||
|
||||
public DbSet<Auth.Session> AuthSessions { get; set; }
|
||||
public DbSet<Auth.Challenge> AuthChallenges { get; set; }
|
||||
@ -74,6 +75,7 @@ public class AppDatabase(
|
||||
public DbSet<Wallet.Transaction> PaymentTransactions { get; set; }
|
||||
|
||||
public DbSet<Developer.CustomApp> CustomApps { get; set; }
|
||||
public DbSet<Developer.CustomAppSecret> CustomAppSecrets { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
@ -84,7 +86,10 @@ public class AppDatabase(
|
||||
|
||||
optionsBuilder.UseNpgsql(
|
||||
dataSource,
|
||||
opt => opt.UseNodaTime().UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||
opt => opt
|
||||
.UseNodaTime()
|
||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||
.UseNetTopologySuite()
|
||||
).UseSnakeCaseNamingConvention();
|
||||
|
||||
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
|
||||
|
@ -6,6 +6,7 @@ using NodaTime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Sphere.Connection;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth;
|
||||
|
||||
@ -15,14 +16,15 @@ public class AuthController(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
IConfiguration configuration
|
||||
GeoIpService geo,
|
||||
ActionLogService als
|
||||
) : ControllerBase
|
||||
{
|
||||
public class ChallengeRequest
|
||||
{
|
||||
[Required] public ChallengePlatform Platform { get; set; }
|
||||
[Required] [MaxLength(256)] public string Account { get; set; } = string.Empty;
|
||||
[Required] [MaxLength(512)] public string DeviceId { get; set; }
|
||||
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
|
||||
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||
public List<string> Audiences { get; set; } = new();
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
}
|
||||
@ -57,16 +59,22 @@ public class AuthController(
|
||||
Scopes = request.Scopes,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent,
|
||||
Location = geo.GetPointFromIp(ipAddress),
|
||||
DeviceId = request.DeviceId,
|
||||
AccountId = account.Id
|
||||
}.Normalize();
|
||||
|
||||
await db.AuthChallenges.AddAsync(challenge);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
|
||||
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request
|
||||
);
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
[HttpGet("challenge/{id}/factors")]
|
||||
[HttpGet("challenge/{id:guid}/factors")]
|
||||
public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
|
||||
{
|
||||
var challenge = await db.AuthChallenges
|
||||
@ -78,7 +86,7 @@ public class AuthController(
|
||||
: challenge.Account.AuthFactors.ToList();
|
||||
}
|
||||
|
||||
[HttpPost("challenge/{id}/factors/{factorId:guid}")]
|
||||
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
|
||||
public async Task<ActionResult> RequestFactorCode([FromRoute] Guid id, [FromRoute] Guid factorId)
|
||||
{
|
||||
var challenge = await db.AuthChallenges
|
||||
@ -97,11 +105,11 @@ public class AuthController(
|
||||
|
||||
public class PerformChallengeRequest
|
||||
{
|
||||
[Required] public long FactorId { get; set; }
|
||||
[Required] public Guid FactorId { get; set; }
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[HttpPatch("challenge/{id}")]
|
||||
[HttpPatch("challenge/{id:guid}")]
|
||||
public async Task<ActionResult<Challenge>> DoChallenge(
|
||||
[FromRoute] Guid id,
|
||||
[FromBody] PerformChallengeRequest request
|
||||
@ -124,6 +132,12 @@ public class AuthController(
|
||||
challenge.StepRemain--;
|
||||
challenge.BlacklistFactors.Add(factor.Id);
|
||||
db.Update(challenge);
|
||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
||||
new Dictionary<string, object> {
|
||||
{ "challenge_id", challenge.Id },
|
||||
{ "factor_id", factor.Id }
|
||||
}, Request
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -134,10 +148,26 @@ public class AuthController(
|
||||
{
|
||||
challenge.FailedAttempts++;
|
||||
db.Update(challenge);
|
||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
||||
new Dictionary<string, object> {
|
||||
{ "challenge_id", challenge.Id },
|
||||
{ "factor_id", factor.Id }
|
||||
}, Request
|
||||
);
|
||||
await db.SaveChangesAsync();
|
||||
return BadRequest("Invalid password.");
|
||||
}
|
||||
|
||||
if (challenge.StepRemain == 0)
|
||||
{
|
||||
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
||||
new Dictionary<string, object> {
|
||||
{ "challenge_id", challenge.Id },
|
||||
{ "account_id", challenge.AccountId }
|
||||
}, Request
|
||||
);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return challenge;
|
||||
}
|
||||
@ -210,20 +240,6 @@ public class AuthController(
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("test")]
|
||||
public async Task<ActionResult> Test()
|
||||
{
|
||||
var sessionIdClaim = HttpContext.User.FindFirst("session_id")?.Value;
|
||||
if (!Guid.TryParse(sessionIdClaim, out var sessionId))
|
||||
return Unauthorized();
|
||||
|
||||
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
if (session is null) return NotFound();
|
||||
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("captcha")]
|
||||
public async Task<ActionResult> ValidateCaptcha([FromBody] string token)
|
||||
{
|
||||
|
@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.VisualStudio.Web.CodeGenerators.Mvc.Templates.BlazorIdentity.Pages;
|
||||
using NodaTime;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth;
|
||||
|
||||
@ -50,6 +51,7 @@ public class Challenge : ModelBase
|
||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||
[MaxLength(256)] public string? DeviceId { get; set; }
|
||||
[MaxLength(1024)] public string? Nonce { get; set; }
|
||||
public Point? Location { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Realm;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
@ -10,7 +11,12 @@ namespace DysonNetwork.Sphere.Chat;
|
||||
|
||||
[ApiController]
|
||||
[Route("/chat")]
|
||||
public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService crs, RealmService rs) : ControllerBase
|
||||
public class ChatRoomController(
|
||||
AppDatabase db,
|
||||
FileService fs,
|
||||
ChatRoomService crs,
|
||||
RealmService rs,
|
||||
ActionLogService als) : ControllerBase
|
||||
{
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<ChatRoom>> GetChatRoom(Guid id)
|
||||
@ -126,6 +132,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
db.ChatRooms.Add(dmRoom);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomCreate,
|
||||
new Dictionary<string, object> { { "chatroom_id", dmRoom.Id } }, Request
|
||||
);
|
||||
|
||||
var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId);
|
||||
await crs.SendInviteNotify(invitedMember);
|
||||
|
||||
@ -194,6 +205,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
if (chatRoom.Background is not null)
|
||||
await fs.MarkUsageAsync(chatRoom.Background, 1);
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomCreate,
|
||||
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(chatRoom);
|
||||
}
|
||||
|
||||
@ -255,6 +271,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
db.ChatRooms.Update(chatRoom);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomUpdate,
|
||||
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(chatRoom);
|
||||
}
|
||||
|
||||
@ -286,6 +307,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
if (chatRoom.Background is not null)
|
||||
await fs.MarkUsageAsync(chatRoom.Background, -1);
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomDelete,
|
||||
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -401,6 +427,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
|
||||
await crs.SendInviteNotify(newMember);
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomInvite,
|
||||
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(newMember);
|
||||
}
|
||||
|
||||
@ -440,6 +471,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
db.Update(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomJoin,
|
||||
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
|
||||
);
|
||||
|
||||
return Ok(member);
|
||||
}
|
||||
|
||||
@ -559,6 +595,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
db.ChatMembers.Remove(targetMember);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomKick,
|
||||
new Dictionary<string, object> { { "chatroom_id", roomId }, { "account_id", memberId } }, Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -593,6 +634,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
|
||||
db.ChatMembers.Remove(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.ChatroomLeave,
|
||||
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
56
DysonNetwork.Sphere/Connection/GeoIpService.cs
Normal file
56
DysonNetwork.Sphere/Connection/GeoIpService.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using MaxMind.GeoIP2;
|
||||
using NetTopologySuite.Geometries;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
namespace DysonNetwork.Sphere.Connection;
|
||||
|
||||
public class GeoIpOptions
|
||||
{
|
||||
public string DatabasePath { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class GeoIpService(IOptions<GeoIpOptions> options)
|
||||
{
|
||||
private readonly string _databasePath = options.Value.DatabasePath;
|
||||
private readonly GeometryFactory _geometryFactory = new(new PrecisionModel(), 4326); // 4326 is the SRID for WGS84
|
||||
|
||||
public Point? GetPointFromIp(string? ipAddress)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ipAddress))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new DatabaseReader(_databasePath);
|
||||
var city = reader.City(ipAddress);
|
||||
|
||||
if (city?.Location == null || !city.Location.HasCoordinates)
|
||||
return null;
|
||||
|
||||
return _geometryFactory.CreatePoint(new Coordinate(
|
||||
city.Location.Longitude ?? 0,
|
||||
city.Location.Latitude ?? 0));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public MaxMind.GeoIP2.Responses.CityResponse? GetFromIp(string? ipAddress)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ipAddress))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new DatabaseReader(_databasePath);
|
||||
return reader.City(ipAddress);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Newtonsoft.Json;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Developer;
|
||||
@ -19,7 +20,20 @@ public class CustomApp : ModelBase
|
||||
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
|
||||
public Instant? VerifiedAt { get; set; }
|
||||
[MaxLength(4096)] public string? VerifiedAs { get; set; }
|
||||
|
||||
[JsonIgnore] private ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||
|
||||
public Guid PublisherId { get; set; }
|
||||
public Publisher.Publisher Developer { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class CustomAppSecret : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Secret { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Remarks { get; set; } = null!;
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public Guid AppId { get; set; }
|
||||
public CustomApp App { get; set; } = null!;
|
||||
}
|
@ -15,6 +15,7 @@
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||
<PackageReference Include="MailKit" Version="4.11.0" />
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||
@ -35,6 +36,7 @@
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
|
||||
|
3282
DysonNetwork.Sphere/Migrations/20250514164520_AddCustomAppsAndSecrets.Designer.cs
generated
Normal file
3282
DysonNetwork.Sphere/Migrations/20250514164520_AddCustomAppsAndSecrets.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomAppsAndSecrets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "issuer_app_id",
|
||||
table: "payment_orders",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "custom_app_secrets",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
secret = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
remarks = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
app_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_custom_app_secrets", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_custom_app_secrets_custom_apps_app_id",
|
||||
column: x => x.app_id,
|
||||
principalTable: "custom_apps",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_payment_orders_issuer_app_id",
|
||||
table: "payment_orders",
|
||||
column: "issuer_app_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_custom_app_secrets_app_id",
|
||||
table: "custom_app_secrets",
|
||||
column: "app_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_payment_orders_custom_apps_issuer_app_id",
|
||||
table: "payment_orders",
|
||||
column: "issuer_app_id",
|
||||
principalTable: "custom_apps",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_payment_orders_custom_apps_issuer_app_id",
|
||||
table: "payment_orders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "custom_app_secrets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_payment_orders_issuer_app_id",
|
||||
table: "payment_orders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "issuer_app_id",
|
||||
table: "payment_orders");
|
||||
}
|
||||
}
|
||||
}
|
3290
DysonNetwork.Sphere/Migrations/20250514175019_AddRewardToCheckIn.Designer.cs
generated
Normal file
3290
DysonNetwork.Sphere/Migrations/20250514175019_AddRewardToCheckIn.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRewardToCheckIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "reward_experience",
|
||||
table: "account_check_in_results",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "reward_points",
|
||||
table: "account_check_in_results",
|
||||
type: "numeric",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "reward_experience",
|
||||
table: "account_check_in_results");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "reward_points",
|
||||
table: "account_check_in_results");
|
||||
}
|
||||
}
|
||||
}
|
3379
DysonNetwork.Sphere/Migrations/20250515165017_AddActionLogs.Designer.cs
generated
Normal file
3379
DysonNetwork.Sphere/Migrations/20250515165017_AddActionLogs.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NetTopologySuite.Geometries;
|
||||
using NodaTime;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddActionLogs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||
|
||||
migrationBuilder.AddColumn<Point>(
|
||||
name: "location",
|
||||
table: "auth_challenges",
|
||||
type: "geometry",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "action_logs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
action = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||
user_agent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
ip_address = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
location = table.Column<Point>(type: "geometry", nullable: true),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
session_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_action_logs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_action_logs_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_action_logs_auth_sessions_session_id",
|
||||
column: x => x.session_id,
|
||||
principalTable: "auth_sessions",
|
||||
principalColumn: "id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_action_logs_account_id",
|
||||
table: "action_logs",
|
||||
column: "account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_action_logs_session_id",
|
||||
table: "action_logs",
|
||||
column: "session_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "action_logs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "location",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.AlterDatabase()
|
||||
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||
}
|
||||
}
|
||||
}
|
@ -7,9 +7,11 @@ using DysonNetwork.Sphere.Account;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NetTopologySuite.Geometries;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@ -25,6 +27,7 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
|
||||
@ -169,6 +172,70 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.ToTable("account_contacts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Account.ActionLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("action");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<Point>("Location")
|
||||
.HasColumnType("geometry")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<Dictionary<string, object>>("Meta")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta");
|
||||
|
||||
b.Property<Guid?>("SessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_action_logs");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_action_logs_account_id");
|
||||
|
||||
b.HasIndex("SessionId")
|
||||
.HasDatabaseName("ix_action_logs_session_id");
|
||||
|
||||
b.ToTable("action_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Account.Badge", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -249,6 +316,14 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("level");
|
||||
|
||||
b.Property<int?>("RewardExperience")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("reward_experience");
|
||||
|
||||
b.Property<decimal?>("RewardPoints")
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("reward_points");
|
||||
|
||||
b.Property<ICollection<FortuneTip>>("Tips")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
@ -706,6 +781,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<Point>("Location")
|
||||
.HasColumnType("geometry")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<string>("Nonce")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
@ -1197,6 +1276,53 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Remarks")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("remarks");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -2177,6 +2303,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Guid?>("IssuerAppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("issuer_app_id");
|
||||
|
||||
b.Property<Guid>("PayeeWalletId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("payee_wallet_id");
|
||||
@ -2201,6 +2331,9 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_payment_orders");
|
||||
|
||||
b.HasIndex("IssuerAppId")
|
||||
.HasDatabaseName("ix_payment_orders_issuer_app_id");
|
||||
|
||||
b.HasIndex("PayeeWalletId")
|
||||
.HasDatabaseName("ix_payment_orders_payee_wallet_id");
|
||||
|
||||
@ -2423,6 +2556,25 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Account.ActionLog", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_action_logs_accounts_account_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Sphere.Auth.Session", "Session")
|
||||
.WithMany()
|
||||
.HasForeignKey("SessionId")
|
||||
.HasConstraintName("fk_action_logs_auth_sessions_session_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Account.Badge", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||
@ -2742,6 +2894,18 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
|
||||
.WithMany()
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroupMember", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Permission.PermissionGroup", "Group")
|
||||
@ -3021,6 +3185,11 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Order", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "IssuerApp")
|
||||
.WithMany()
|
||||
.HasForeignKey("IssuerAppId")
|
||||
.HasConstraintName("fk_payment_orders_custom_apps_issuer_app_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet")
|
||||
.WithMany()
|
||||
.HasForeignKey("PayeeWalletId")
|
||||
@ -3033,6 +3202,8 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasForeignKey("TransactionId")
|
||||
.HasConstraintName("fk_payment_orders_payment_transactions_transaction_id");
|
||||
|
||||
b.Navigation("IssuerApp");
|
||||
|
||||
b.Navigation("PayeeWallet");
|
||||
|
||||
b.Navigation("Transaction");
|
||||
|
@ -17,7 +17,8 @@ public class PostController(
|
||||
PostService ps,
|
||||
PublisherService pub,
|
||||
RelationshipService rels,
|
||||
IServiceScopeFactory factory
|
||||
IServiceScopeFactory factory,
|
||||
ActionLogService als
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
@ -227,6 +228,11 @@ public class PostController(
|
||||
await subs.NotifySubscribersPostAsync(post);
|
||||
});
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PostCreate,
|
||||
new Dictionary<string, object> { { "post_id", post.Id } }, Request
|
||||
);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
@ -268,6 +274,12 @@ public class PostController(
|
||||
var isRemoving = await ps.ModifyPostVotes(post, reaction, isExistingReaction, isSelfReact);
|
||||
|
||||
if (isRemoving) return NoContent();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PostReact,
|
||||
new Dictionary<string, object> { { "post_id", post.Id }, { "reaction", request.Symbol } }, Request
|
||||
);
|
||||
|
||||
return Ok(reaction);
|
||||
}
|
||||
|
||||
@ -312,6 +324,11 @@ public class PostController(
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PostUpdate,
|
||||
new Dictionary<string, object> { { "post_id", post.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(post);
|
||||
}
|
||||
|
||||
@ -331,6 +348,12 @@ public class PostController(
|
||||
return StatusCode(403, "You need at least be an editor to delete the publisher's post.");
|
||||
|
||||
await ps.DeletePostAsync(post);
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PostDelete,
|
||||
new Dictionary<string, object> { { "post_id", post.Id } }, Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
@ -148,11 +148,14 @@ builder.Services.AddSingleton(tusDiskStore);
|
||||
builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>();
|
||||
|
||||
// Services
|
||||
builder.Services.Configure<GeoIpOptions>(builder.Configuration.GetSection("GeoIP"));
|
||||
builder.Services.AddScoped<GeoIpService>();
|
||||
builder.Services.AddScoped<WebSocketService>();
|
||||
builder.Services.AddScoped<EmailService>();
|
||||
builder.Services.AddScoped<PermissionService>();
|
||||
builder.Services.AddScoped<AccountService>();
|
||||
builder.Services.AddScoped<AccountEventService>();
|
||||
builder.Services.AddSingleton<ActionLogService>();
|
||||
builder.Services.AddScoped<RelationshipService>();
|
||||
builder.Services.AddScoped<MagicSpellService>();
|
||||
builder.Services.AddScoped<NotificationService>();
|
||||
@ -188,6 +191,16 @@ builder.Services.AddQuartz(q =>
|
||||
.WithIdentity("CloudFilesUnusedRecyclingTrigger")
|
||||
.WithSimpleSchedule(o => o.WithIntervalInHours(1).RepeatForever())
|
||||
);
|
||||
|
||||
var actionLogFlushJob = new JobKey("ActionLogFlush");
|
||||
q.AddJob<ActionLogFlushJob>(opts => opts.WithIdentity(actionLogFlushJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(actionLogFlushJob)
|
||||
.WithIdentity("ActionLogFlushTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInMinutes(5)
|
||||
.RepeatForever())
|
||||
);
|
||||
});
|
||||
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Post;
|
||||
using DysonNetwork.Sphere.Realm;
|
||||
@ -12,11 +13,11 @@ namespace DysonNetwork.Sphere.Publisher;
|
||||
|
||||
[ApiController]
|
||||
[Route("/publishers")]
|
||||
public class PublisherController(AppDatabase db, PublisherService ps, FileService fs)
|
||||
public class PublisherController(AppDatabase db, PublisherService ps, FileService fs, ActionLogService als)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("{name}")]
|
||||
public async Task<ActionResult<Sphere.Publisher.Publisher>> GetPublisher(string name)
|
||||
public async Task<ActionResult<Publisher>> GetPublisher(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
@ -38,7 +39,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Sphere.Publisher.Publisher>>> ListManagedPublishers()
|
||||
public async Task<ActionResult<List<Publisher>>> ListManagedPublishers()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
@ -96,15 +97,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
.FirstOrDefaultAsync();
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
var member = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.PublisherId == publisher.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher.");
|
||||
if (member.Role < PublisherMemberRole.Manager)
|
||||
return StatusCode(403,
|
||||
"You need at least be a manager to invite other members to collaborate this publisher.");
|
||||
if (member.Role < request.Role)
|
||||
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, request.Role))
|
||||
return StatusCode(403, "You cannot invite member has higher permission than yours.");
|
||||
|
||||
var newMember = new PublisherMember
|
||||
@ -117,12 +110,21 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
db.PublisherMembers.Add(newMember);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherMemberInvite,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "publisher_id", publisher.Id },
|
||||
{ "account_id", relatedUser.Id }
|
||||
}, Request
|
||||
);
|
||||
|
||||
return Ok(newMember);
|
||||
}
|
||||
|
||||
[HttpPost("invites/{name}/accept")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Sphere.Publisher.Publisher>> AcceptMemberInvite(string name)
|
||||
public async Task<ActionResult<Publisher>> AcceptMemberInvite(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
@ -138,6 +140,11 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
db.Update(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherMemberJoin,
|
||||
new Dictionary<string, object> { { "account_id", member.AccountId } }, Request
|
||||
);
|
||||
|
||||
return Ok(member);
|
||||
}
|
||||
|
||||
@ -158,6 +165,45 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
db.PublisherMembers.Remove(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherMemberLeave,
|
||||
new Dictionary<string, object> { { "account_id", member.AccountId } }, Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{name}/members/{memberId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> RemoveMember(string name, Guid memberId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Where(p => p.Name == name)
|
||||
.FirstOrDefaultAsync();
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
var member = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == memberId)
|
||||
.Where(m => m.PublisherId == publisher.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return NotFound("Member was not found");
|
||||
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Manager))
|
||||
return StatusCode(403, "You need at least be a manager to remove members from this publisher.");
|
||||
|
||||
db.PublisherMembers.Remove(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherMemberKick,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "publisher_id", publisher.Id },
|
||||
{ "account_id", memberId }
|
||||
}, Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -170,11 +216,11 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
public string? PictureId { get; set; }
|
||||
public string? BackgroundId { get; set; }
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("individual")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "publishers.create")]
|
||||
public async Task<ActionResult<Sphere.Publisher.Publisher>> CreatePublisherIndividual([FromBody] PublisherRequest request)
|
||||
public async Task<ActionResult<Publisher>> CreatePublisherIndividual([FromBody] PublisherRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
@ -212,43 +258,51 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
background
|
||||
);
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherCreate,
|
||||
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("organization/{realmSlug}")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "publishers.create")]
|
||||
public async Task<ActionResult<Sphere.Publisher.Publisher>> CreatePublisherOrganization(string realmSlug, [FromBody] PublisherRequest request)
|
||||
public async Task<ActionResult<Publisher>> CreatePublisherOrganization(string realmSlug,
|
||||
[FromBody] PublisherRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
|
||||
var realm = await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmSlug);
|
||||
if (realm == null) return NotFound("Realm not found");
|
||||
|
||||
|
||||
var isAdmin = await db.RealmMembers
|
||||
.AnyAsync(m => m.RealmId == realm.Id && m.AccountId == currentUser.Id && m.Role >= RealmMemberRole.Moderator);
|
||||
if (!isAdmin) return StatusCode(403, "You need to be a moderator of the realm to create an organization publisher");
|
||||
|
||||
.AnyAsync(m =>
|
||||
m.RealmId == realm.Id && m.AccountId == currentUser.Id && m.Role >= RealmMemberRole.Moderator);
|
||||
if (!isAdmin)
|
||||
return StatusCode(403, "You need to be a moderator of the realm to create an organization publisher");
|
||||
|
||||
var takenName = request.Name ?? realm.Slug;
|
||||
var duplicateNameCount = await db.Publishers
|
||||
.Where(p => p.Name == takenName)
|
||||
.CountAsync();
|
||||
if (duplicateNameCount > 0)
|
||||
return BadRequest("The name you requested has already been taken");
|
||||
|
||||
|
||||
CloudFile? picture = null, background = null;
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
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 (request.BackgroundId is not null)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
|
||||
var publisher = await ps.CreateOrganizationPublisher(
|
||||
realm,
|
||||
currentUser,
|
||||
@ -258,15 +312,19 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
picture,
|
||||
background
|
||||
);
|
||||
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherCreate,
|
||||
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPatch("{name}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Sphere.Publisher.Publisher>> UpdatePublisher(string name, PublisherRequest request)
|
||||
public async Task<ActionResult<Publisher>> UpdatePublisher(string name, PublisherRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
@ -312,12 +370,17 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
db.Update(publisher);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherUpdate,
|
||||
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
[HttpDelete("{name}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Sphere.Publisher.Publisher>> DeletePublisher(string name)
|
||||
public async Task<ActionResult<Publisher>> DeletePublisher(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
@ -345,6 +408,11 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
db.Publishers.Remove(publisher);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.PublisherDelete,
|
||||
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -8,7 +9,7 @@ namespace DysonNetwork.Sphere.Realm;
|
||||
|
||||
[ApiController]
|
||||
[Route("/realms")]
|
||||
public class RealmController(AppDatabase db, RealmService rs, FileService fs) : Controller
|
||||
public class RealmController(AppDatabase db, RealmService rs, FileService fs, ActionLogService als) : Controller
|
||||
{
|
||||
[HttpGet("{slug}")]
|
||||
public async Task<ActionResult<Realm>> GetRealm(string slug)
|
||||
@ -91,6 +92,11 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
|
||||
db.RealmMembers.Add(newMember);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.RealmInvite,
|
||||
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", newMember.AccountId } }, Request
|
||||
);
|
||||
|
||||
newMember.Account = relatedUser;
|
||||
await rs.SendInviteNotify(newMember);
|
||||
|
||||
@ -115,6 +121,12 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
|
||||
db.Update(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.RealmJoin,
|
||||
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
|
||||
Request
|
||||
);
|
||||
|
||||
return Ok(member);
|
||||
}
|
||||
|
||||
@ -135,6 +147,12 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
|
||||
db.RealmMembers.Remove(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.RealmLeave,
|
||||
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
|
||||
Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -214,6 +232,12 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
|
||||
db.RealmMembers.Remove(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.RealmLeave,
|
||||
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
|
||||
Request
|
||||
);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -273,6 +297,11 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
|
||||
db.Realms.Add(realm);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.RealmCreate,
|
||||
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
|
||||
);
|
||||
|
||||
if (realm.Picture is not null) await fs.MarkUsageAsync(realm.Picture, 1);
|
||||
if (realm.Background is not null) await fs.MarkUsageAsync(realm.Background, 1);
|
||||
|
||||
@ -334,6 +363,12 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
|
||||
|
||||
db.Realms.Update(realm);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.RealmUpdate,
|
||||
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
|
||||
);
|
||||
|
||||
return Ok(realm);
|
||||
}
|
||||
|
||||
@ -359,6 +394,11 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
|
||||
db.Realms.Remove(realm);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(
|
||||
ActionLogType.RealmDelete,
|
||||
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
|
||||
);
|
||||
|
||||
if (realm.Picture is not null)
|
||||
await fs.MarkUsageAsync(realm.Picture, -1);
|
||||
if (realm.Background is not null)
|
||||
|
@ -1,8 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Developer;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
public class WalletCurrency
|
||||
{
|
||||
public const string SourcePoint = "points";
|
||||
}
|
||||
|
||||
public enum OrderStatus
|
||||
{
|
||||
Unpaid,
|
||||
@ -25,6 +31,8 @@ public class Order : ModelBase
|
||||
public Wallet PayeeWallet { get; set; } = null!;
|
||||
public Guid? TransactionId { get; set; }
|
||||
public Transaction? Transaction { get; set; }
|
||||
public Guid? IssuerAppId { get; set; }
|
||||
public CustomApp? IssuerApp { get; set; }
|
||||
}
|
||||
|
||||
public enum TransactionType
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
@ -20,6 +21,36 @@ public class PaymentService(AppDatabase db, WalletService wat)
|
||||
return order;
|
||||
}
|
||||
|
||||
public async Task<Transaction> CreateTransactionWithAccountAsync(
|
||||
Guid? payerAccountId,
|
||||
Guid? payeeAccountId,
|
||||
string currency,
|
||||
decimal amount,
|
||||
string? remarks = null,
|
||||
TransactionType type = TransactionType.System
|
||||
)
|
||||
{
|
||||
Wallet? payer = null, payee = null;
|
||||
if (payerAccountId.HasValue)
|
||||
payer = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerAccountId.Value);
|
||||
if (payeeAccountId.HasValue)
|
||||
payee = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeAccountId.Value);
|
||||
|
||||
if (payer == null && payerAccountId.HasValue)
|
||||
throw new ArgumentException("Payer account was specified, but wallet was not found");
|
||||
if (payee == null && payeeAccountId.HasValue)
|
||||
throw new ArgumentException("Payee account was specified, but wallet was not found");
|
||||
|
||||
return await CreateTransactionAsync(
|
||||
payer?.Id,
|
||||
payee?.Id,
|
||||
currency,
|
||||
amount,
|
||||
remarks,
|
||||
type
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Transaction> CreateTransactionAsync(
|
||||
Guid? payerWalletId,
|
||||
Guid? payeeWalletId,
|
||||
@ -29,6 +60,10 @@ public class PaymentService(AppDatabase db, WalletService wat)
|
||||
TransactionType type = TransactionType.System
|
||||
)
|
||||
{
|
||||
if (payerWalletId == null && payeeWalletId == null)
|
||||
throw new ArgumentException("At least one wallet must be specified.");
|
||||
if (amount <= 0) throw new ArgumentException("Cannot create transaction with negative or zero amount.");
|
||||
|
||||
var transaction = new Transaction
|
||||
{
|
||||
PayerWalletId = payerWalletId,
|
||||
@ -41,26 +76,28 @@ public class PaymentService(AppDatabase db, WalletService wat)
|
||||
|
||||
if (payerWalletId.HasValue)
|
||||
{
|
||||
var payerPocket = await wat.GetOrCreateWalletPocketAsync(
|
||||
(await db.Wallets.FindAsync(payerWalletId.Value))!.AccountId,
|
||||
currency);
|
||||
var (payerPocket, isNewlyCreated) =
|
||||
await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency);
|
||||
|
||||
if (payerPocket.Amount < amount)
|
||||
{
|
||||
if (isNewlyCreated || payerPocket.Amount < amount)
|
||||
throw new InvalidOperationException("Insufficient funds");
|
||||
}
|
||||
|
||||
payerPocket.Amount -= amount;
|
||||
await db.WalletPockets
|
||||
.Where(p => p.Id == payerPocket.Id && p.Amount >= amount)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(p => p.Amount, p => p.Amount - amount));
|
||||
}
|
||||
|
||||
if (payeeWalletId.HasValue)
|
||||
{
|
||||
var payeeWallet = await db.Wallets.FindAsync(payeeWalletId.Value);
|
||||
var payeePocket = await wat.GetOrCreateWalletPocketAsync(
|
||||
payeeWallet!.AccountId,
|
||||
currency);
|
||||
var (payeePocket, isNewlyCreated) =
|
||||
await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount);
|
||||
|
||||
payeePocket.Amount += amount;
|
||||
if (!isNewlyCreated)
|
||||
await db.WalletPockets
|
||||
.Where(p => p.Id == payeePocket.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(p => p.Amount, p => p.Amount + amount));
|
||||
}
|
||||
|
||||
db.PaymentTransactions.Add(transaction);
|
||||
@ -158,21 +195,22 @@ public class PaymentService(AppDatabase db, WalletService wat)
|
||||
|
||||
return (order, refundTransaction);
|
||||
}
|
||||
|
||||
public async Task<Transaction> TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency, decimal amount)
|
||||
|
||||
public async Task<Transaction> TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency,
|
||||
decimal amount)
|
||||
{
|
||||
var payerWallet = await wat.GetWalletAsync(payerAccountId);
|
||||
if (payerWallet == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Payer wallet not found for account {payerAccountId}");
|
||||
}
|
||||
|
||||
|
||||
var payeeWallet = await wat.GetWalletAsync(payeeAccountId);
|
||||
if (payeeWallet == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
|
||||
}
|
||||
|
||||
|
||||
return await CreateTransactionAsync(
|
||||
payerWallet.Id,
|
||||
payeeWallet.Id,
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
@ -19,5 +20,5 @@ public class WalletPocket : ModelBase
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public Guid WalletId { get; set; }
|
||||
public Wallet Wallet { get; set; } = null!;
|
||||
[JsonIgnore] public Wallet Wallet { get; set; } = null!;
|
||||
}
|
63
DysonNetwork.Sphere/Wallet/WalletController.cs
Normal file
63
DysonNetwork.Sphere/Wallet/WalletController.cs
Normal file
@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/wallets")]
|
||||
public class WalletController(AppDatabase db, WalletService ws) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Wallet>> CreateWallet()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var wallet = await ws.CreateWalletAsync(currentUser.Id);
|
||||
return Ok(wallet);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Wallet>> GetWallet()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var wallet = await ws.GetWalletAsync(currentUser.Id);
|
||||
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
|
||||
return Ok(wallet);
|
||||
}
|
||||
|
||||
[HttpGet("transactions")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Transaction>>> GetTransactions(
|
||||
[FromQuery] int offset = 0, [FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var query = db.PaymentTransactions.AsQueryable()
|
||||
.Include(t => t.PayeeWallet)
|
||||
.Include(t => t.PayerWallet)
|
||||
.Where(t => (t.PayeeWallet != null && t.PayeeWallet.AccountId == currentUser.Id) ||
|
||||
(t.PayerWallet != null && t.PayerWallet.AccountId == currentUser.Id));
|
||||
|
||||
var transactionCount = await query.CountAsync();
|
||||
var transactions = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
Response.Headers["X-Total"] = transactionCount.ToString();
|
||||
|
||||
return Ok(transactions);
|
||||
}
|
||||
}
|
@ -10,45 +10,40 @@ public class WalletService(AppDatabase db)
|
||||
.Include(w => w.Pockets)
|
||||
.FirstOrDefaultAsync(w => w.AccountId == accountId);
|
||||
}
|
||||
|
||||
|
||||
public async Task<Wallet> CreateWalletAsync(Guid accountId)
|
||||
{
|
||||
var wallet = new Wallet
|
||||
var existingWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == accountId);
|
||||
if (existingWallet != null)
|
||||
{
|
||||
AccountId = accountId
|
||||
};
|
||||
throw new InvalidOperationException($"Wallet already exists for account {accountId}");
|
||||
}
|
||||
|
||||
var wallet = new Wallet { AccountId = accountId };
|
||||
|
||||
db.Wallets.Add(wallet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public async Task<WalletPocket> GetOrCreateWalletPocketAsync(Guid accountId, string currency)
|
||||
public async Task<(WalletPocket wallet, bool isNewlyCreated)> GetOrCreateWalletPocketAsync(
|
||||
Guid walletId,
|
||||
string currency,
|
||||
decimal? initialAmount = null
|
||||
)
|
||||
{
|
||||
var wallet = await db.Wallets
|
||||
.Include(w => w.Pockets)
|
||||
.FirstOrDefaultAsync(w => w.AccountId == accountId);
|
||||
var pocket = await db.WalletPockets.FirstOrDefaultAsync(p => p.Currency == currency && p.WalletId == walletId);
|
||||
if (pocket != null) return (pocket, false);
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Wallet not found for account {accountId}");
|
||||
}
|
||||
|
||||
var pocket = wallet.Pockets.FirstOrDefault(p => p.Currency == currency);
|
||||
|
||||
if (pocket != null) return pocket;
|
||||
|
||||
pocket = new WalletPocket
|
||||
{
|
||||
Currency = currency,
|
||||
Amount = 0,
|
||||
WalletId = wallet.Id
|
||||
Amount = initialAmount ?? 0,
|
||||
WalletId = walletId
|
||||
};
|
||||
|
||||
wallet.Pockets.Add(pocket);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return pocket;
|
||||
db.WalletPockets.Add(pocket);
|
||||
return (pocket, true);
|
||||
}
|
||||
}
|
@ -43,6 +43,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFoundResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F28_003F290250f5_003FNotFoundResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANpgsqlEntityTypeBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fccb1faacaea4420db96b09857fc56178a1600_003Fd9_003F9acf9507_003FNpgsqlEntityTypeBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F6a_003Fea17bf26_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fd3_003F7b05b2bd_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@ -56,6 +57,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollectionContainerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc0e30e11d8f5456cb7a11b21ebee6c5a35c00_003F60_003F78b485f5_003FServiceCollectionContainerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASetPropertyCalls_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F458b5f22476b4599b87176214d5e4026c2327b148f4d3f885ee92362b4dac3_003FSetPropertyCalls_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATagging_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F36f4c2e6baa65ba603de42eedad12ea36845aa35a910a6a82d82baf688e3e1_003FTagging_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F12_003Fe0a28ad6_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
|
Loading…
x
Reference in New Issue
Block a user