Action logs

This commit is contained in:
LittleSheep 2025-05-16 01:41:24 +08:00
parent 6358c49090
commit aabe8269f5
16 changed files with 4036 additions and 52 deletions

View File

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Extensions; using NodaTime.Extensions;
using System.Collections.Generic;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Sphere.Account;
@ -390,7 +391,30 @@ public class AccountController(
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true); var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
return Ok(calendar); 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")] [HttpGet("search")]
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20) public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
{ {

View 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!;
}

View 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();
}
}

View File

@ -36,6 +36,7 @@ public class AppDatabase(
public DbSet<Account.Notification> Notifications { get; set; } public DbSet<Account.Notification> Notifications { get; set; }
public DbSet<Account.NotificationPushSubscription> NotificationPushSubscriptions { get; set; } public DbSet<Account.NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Account.Badge> Badges { 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.Session> AuthSessions { get; set; }
public DbSet<Auth.Challenge> AuthChallenges { get; set; } public DbSet<Auth.Challenge> AuthChallenges { get; set; }
@ -85,7 +86,10 @@ public class AppDatabase(
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
dataSource, dataSource,
opt => opt.UseNodaTime().UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) opt => opt
.UseNodaTime()
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
).UseSnakeCaseNamingConvention(); ).UseSnakeCaseNamingConvention();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) => optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>

View File

@ -6,6 +6,7 @@ using NodaTime;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Connection;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Sphere.Auth;
@ -15,14 +16,15 @@ public class AuthController(
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,
AuthService auth, AuthService auth,
IConfiguration configuration GeoIpService geo,
ActionLogService als
) : ControllerBase ) : ControllerBase
{ {
public class ChallengeRequest public class ChallengeRequest
{ {
[Required] public ChallengePlatform Platform { get; set; } [Required] public ChallengePlatform Platform { get; set; }
[Required] [MaxLength(256)] public string Account { get; set; } = string.Empty; [Required] [MaxLength(256)] public string Account { get; set; } = null!;
[Required] [MaxLength(512)] public string DeviceId { get; set; } [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
public List<string> Audiences { get; set; } = new(); public List<string> Audiences { get; set; } = new();
public List<string> Scopes { get; set; } = new(); public List<string> Scopes { get; set; } = new();
} }
@ -57,12 +59,18 @@ public class AuthController(
Scopes = request.Scopes, Scopes = request.Scopes,
IpAddress = ipAddress, IpAddress = ipAddress,
UserAgent = userAgent, UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress),
DeviceId = request.DeviceId, DeviceId = request.DeviceId,
AccountId = account.Id AccountId = account.Id
}.Normalize(); }.Normalize();
await db.AuthChallenges.AddAsync(challenge); await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request
);
return challenge; return challenge;
} }
@ -124,6 +132,12 @@ public class AuthController(
challenge.StepRemain--; challenge.StepRemain--;
challenge.BlacklistFactors.Add(factor.Id); challenge.BlacklistFactors.Add(factor.Id);
db.Update(challenge); db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object> {
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request
);
} }
else else
{ {
@ -134,10 +148,26 @@ public class AuthController(
{ {
challenge.FailedAttempts++; challenge.FailedAttempts++;
db.Update(challenge); db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object> {
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request
);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return BadRequest("Invalid password."); 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(); await db.SaveChangesAsync();
return challenge; 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")] [HttpPost("captcha")]
public async Task<ActionResult> ValidateCaptcha([FromBody] string token) public async Task<ActionResult> ValidateCaptcha([FromBody] string token)
{ {

View File

@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.VisualStudio.Web.CodeGenerators.Mvc.Templates.BlazorIdentity.Pages; using Microsoft.VisualStudio.Web.CodeGenerators.Mvc.Templates.BlazorIdentity.Pages;
using NodaTime; using NodaTime;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Sphere.Auth;
@ -50,6 +51,7 @@ public class Challenge : ModelBase
[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; }
[MaxLength(1024)] public string? Nonce { get; set; } [MaxLength(1024)] public string? Nonce { 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 Account { get; set; } = null!;

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.Sphere.Account;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Realm; using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
@ -10,7 +11,12 @@ namespace DysonNetwork.Sphere.Chat;
[ApiController] [ApiController]
[Route("/chat")] [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}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<ChatRoom>> GetChatRoom(Guid id) public async Task<ActionResult<ChatRoom>> GetChatRoom(Guid id)
@ -126,6 +132,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
db.ChatRooms.Add(dmRoom); db.ChatRooms.Add(dmRoom);
await db.SaveChangesAsync(); 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); var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId);
await crs.SendInviteNotify(invitedMember); await crs.SendInviteNotify(invitedMember);
@ -194,6 +205,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
if (chatRoom.Background is not null) if (chatRoom.Background is not null)
await fs.MarkUsageAsync(chatRoom.Background, 1); await fs.MarkUsageAsync(chatRoom.Background, 1);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomCreate,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
);
return Ok(chatRoom); return Ok(chatRoom);
} }
@ -255,6 +271,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
db.ChatRooms.Update(chatRoom); db.ChatRooms.Update(chatRoom);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomUpdate,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
);
return Ok(chatRoom); return Ok(chatRoom);
} }
@ -286,6 +307,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
if (chatRoom.Background is not null) if (chatRoom.Background is not null)
await fs.MarkUsageAsync(chatRoom.Background, -1); await fs.MarkUsageAsync(chatRoom.Background, -1);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomDelete,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
);
return NoContent(); return NoContent();
} }
@ -401,6 +427,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
await crs.SendInviteNotify(newMember); await crs.SendInviteNotify(newMember);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomInvite,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } }, Request
);
return Ok(newMember); return Ok(newMember);
} }
@ -440,6 +471,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
db.Update(member); db.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomJoin,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
);
return Ok(member); return Ok(member);
} }
@ -559,6 +595,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
db.ChatMembers.Remove(targetMember); db.ChatMembers.Remove(targetMember);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomKick,
new Dictionary<string, object> { { "chatroom_id", roomId }, { "account_id", memberId } }, Request
);
return NoContent(); return NoContent();
} }
@ -593,6 +634,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
db.ChatMembers.Remove(member); db.ChatMembers.Remove(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomLeave,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
);
return NoContent(); return NoContent();
} }
} }

View 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;
}
}
}

View File

@ -15,6 +15,7 @@
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="MailKit" Version="4.11.0" /> <PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
@ -35,6 +36,7 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> <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="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" /> <PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" /> <PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />

File diff suppressed because it is too large Load Diff

View File

@ -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", ",,");
}
}
}

View File

@ -7,9 +7,11 @@ using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NetTopologySuite.Geometries;
using NodaTime; using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes; using NpgsqlTypes;
using Point = NetTopologySuite.Geometries.Point;
#nullable disable #nullable disable
@ -25,6 +27,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasAnnotation("ProductVersion", "9.0.3") .HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
@ -169,6 +172,70 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("account_contacts", (string)null); 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 => modelBuilder.Entity("DysonNetwork.Sphere.Account.Badge", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -714,6 +781,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
.HasColumnName("ip_address"); .HasColumnName("ip_address");
b.Property<Point>("Location")
.HasColumnType("geometry")
.HasColumnName("location");
b.Property<string>("Nonce") b.Property<string>("Nonce")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
@ -2485,6 +2556,25 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Account"); 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 => modelBuilder.Entity("DysonNetwork.Sphere.Account.Badge", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")

View File

@ -17,7 +17,8 @@ public class PostController(
PostService ps, PostService ps,
PublisherService pub, PublisherService pub,
RelationshipService rels, RelationshipService rels,
IServiceScopeFactory factory IServiceScopeFactory factory,
ActionLogService als
) )
: ControllerBase : ControllerBase
{ {
@ -227,6 +228,11 @@ public class PostController(
await subs.NotifySubscribersPostAsync(post); await subs.NotifySubscribersPostAsync(post);
}); });
als.CreateActionLogFromRequest(
ActionLogType.PostCreate,
new Dictionary<string, object> { { "post_id", post.Id } }, Request
);
return post; return post;
} }
@ -268,6 +274,12 @@ public class PostController(
var isRemoving = await ps.ModifyPostVotes(post, reaction, isExistingReaction, isSelfReact); var isRemoving = await ps.ModifyPostVotes(post, reaction, isExistingReaction, isSelfReact);
if (isRemoving) return NoContent(); if (isRemoving) return NoContent();
als.CreateActionLogFromRequest(
ActionLogType.PostReact,
new Dictionary<string, object> { { "post_id", post.Id }, { "reaction", request.Symbol } }, Request
);
return Ok(reaction); return Ok(reaction);
} }
@ -312,6 +324,11 @@ public class PostController(
return BadRequest(err.Message); return BadRequest(err.Message);
} }
als.CreateActionLogFromRequest(
ActionLogType.PostUpdate,
new Dictionary<string, object> { { "post_id", post.Id } }, Request
);
return Ok(post); 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."); return StatusCode(403, "You need at least be an editor to delete the publisher's post.");
await ps.DeletePostAsync(post); await ps.DeletePostAsync(post);
als.CreateActionLogFromRequest(
ActionLogType.PostDelete,
new Dictionary<string, object> { { "post_id", post.Id } }, Request
);
return NoContent(); return NoContent();
} }
} }

View File

@ -148,11 +148,14 @@ builder.Services.AddSingleton(tusDiskStore);
builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>(); builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>();
// Services // Services
builder.Services.Configure<GeoIpOptions>(builder.Configuration.GetSection("GeoIP"));
builder.Services.AddScoped<GeoIpService>();
builder.Services.AddScoped<WebSocketService>(); builder.Services.AddScoped<WebSocketService>();
builder.Services.AddScoped<EmailService>(); builder.Services.AddScoped<EmailService>();
builder.Services.AddScoped<PermissionService>(); builder.Services.AddScoped<PermissionService>();
builder.Services.AddScoped<AccountService>(); builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<AccountEventService>(); builder.Services.AddScoped<AccountEventService>();
builder.Services.AddSingleton<ActionLogService>();
builder.Services.AddScoped<RelationshipService>(); builder.Services.AddScoped<RelationshipService>();
builder.Services.AddScoped<MagicSpellService>(); builder.Services.AddScoped<MagicSpellService>();
builder.Services.AddScoped<NotificationService>(); builder.Services.AddScoped<NotificationService>();
@ -188,6 +191,16 @@ builder.Services.AddQuartz(q =>
.WithIdentity("CloudFilesUnusedRecyclingTrigger") .WithIdentity("CloudFilesUnusedRecyclingTrigger")
.WithSimpleSchedule(o => o.WithIntervalInHours(1).RepeatForever()) .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); builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Realm; using DysonNetwork.Sphere.Realm;
@ -12,11 +13,11 @@ namespace DysonNetwork.Sphere.Publisher;
[ApiController] [ApiController]
[Route("/publishers")] [Route("/publishers")]
public class PublisherController(AppDatabase db, PublisherService ps, FileService fs) public class PublisherController(AppDatabase db, PublisherService ps, FileService fs, ActionLogService als)
: ControllerBase : ControllerBase
{ {
[HttpGet("{name}")] [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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
@ -38,7 +39,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
[HttpGet] [HttpGet]
[Authorize] [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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id; var userId = currentUser.Id;
@ -96,15 +97,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (publisher is null) return NotFound(); if (publisher is null) return NotFound();
var member = await db.PublisherMembers if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, request.Role))
.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)
return StatusCode(403, "You cannot invite member has higher permission than yours."); return StatusCode(403, "You cannot invite member has higher permission than yours.");
var newMember = new PublisherMember var newMember = new PublisherMember
@ -117,12 +110,21 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
db.PublisherMembers.Add(newMember); db.PublisherMembers.Add(newMember);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.PublisherMemberInvite,
new Dictionary<string, object>
{
{ "publisher_id", publisher.Id },
{ "account_id", relatedUser.Id }
}, Request
);
return Ok(newMember); return Ok(newMember);
} }
[HttpPost("invites/{name}/accept")] [HttpPost("invites/{name}/accept")]
[Authorize] [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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id; var userId = currentUser.Id;
@ -138,6 +140,11 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
db.Update(member); db.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.PublisherMemberJoin,
new Dictionary<string, object> { { "account_id", member.AccountId } }, Request
);
return Ok(member); return Ok(member);
} }
@ -158,6 +165,45 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
db.PublisherMembers.Remove(member); db.PublisherMembers.Remove(member);
await db.SaveChangesAsync(); 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(); return NoContent();
} }
@ -170,11 +216,11 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
public string? PictureId { get; set; } public string? PictureId { get; set; }
public string? BackgroundId { get; set; } public string? BackgroundId { get; set; }
} }
[HttpPost("individual")] [HttpPost("individual")]
[Authorize] [Authorize]
[RequiredPermission("global", "publishers.create")] [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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
@ -212,43 +258,51 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
background background
); );
als.CreateActionLogFromRequest(
ActionLogType.PublisherCreate,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
);
return Ok(publisher); return Ok(publisher);
} }
[HttpPost("organization/{realmSlug}")] [HttpPost("organization/{realmSlug}")]
[Authorize] [Authorize]
[RequiredPermission("global", "publishers.create")] [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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var realm = await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmSlug); var realm = await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmSlug);
if (realm == null) return NotFound("Realm not found"); if (realm == null) return NotFound("Realm not found");
var isAdmin = await db.RealmMembers var isAdmin = await db.RealmMembers
.AnyAsync(m => m.RealmId == realm.Id && m.AccountId == currentUser.Id && m.Role >= RealmMemberRole.Moderator); .AnyAsync(m =>
if (!isAdmin) return StatusCode(403, "You need to be a moderator of the realm to create an organization publisher"); 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 takenName = request.Name ?? realm.Slug;
var duplicateNameCount = await db.Publishers var duplicateNameCount = await db.Publishers
.Where(p => p.Name == takenName) .Where(p => p.Name == takenName)
.CountAsync(); .CountAsync();
if (duplicateNameCount > 0) if (duplicateNameCount > 0)
return BadRequest("The name you requested has already been taken"); return BadRequest("The name you requested has already been taken");
CloudFile? picture = null, background = null; CloudFile? picture = null, background = null;
if (request.PictureId is not null) if (request.PictureId is not null)
{ {
picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); 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.");
} }
if (request.BackgroundId is not null) if (request.BackgroundId is not null)
{ {
background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); 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 publisher = await ps.CreateOrganizationPublisher( var publisher = await ps.CreateOrganizationPublisher(
realm, realm,
currentUser, currentUser,
@ -258,15 +312,19 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
picture, picture,
background background
); );
als.CreateActionLogFromRequest(
ActionLogType.PublisherCreate,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
);
return Ok(publisher); return Ok(publisher);
} }
[HttpPatch("{name}")] [HttpPatch("{name}")]
[Authorize] [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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id; var userId = currentUser.Id;
@ -312,12 +370,17 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
db.Update(publisher); db.Update(publisher);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.PublisherUpdate,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
);
return Ok(publisher); return Ok(publisher);
} }
[HttpDelete("{name}")] [HttpDelete("{name}")]
[Authorize] [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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id; var userId = currentUser.Id;
@ -345,6 +408,11 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
db.Publishers.Remove(publisher); db.Publishers.Remove(publisher);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.PublisherDelete,
new Dictionary<string, object> { { "publisher_id", publisher.Id } }, Request
);
return NoContent(); return NoContent();
} }
} }

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -8,7 +9,7 @@ namespace DysonNetwork.Sphere.Realm;
[ApiController] [ApiController]
[Route("/realms")] [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}")] [HttpGet("{slug}")]
public async Task<ActionResult<Realm>> GetRealm(string 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); db.RealmMembers.Add(newMember);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmInvite,
new Dictionary<string, object> { { "realm_id", realm.Id }, { "account_id", newMember.AccountId } }, Request
);
newMember.Account = relatedUser; newMember.Account = relatedUser;
await rs.SendInviteNotify(newMember); await rs.SendInviteNotify(newMember);
@ -115,6 +121,12 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
db.Update(member); db.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmJoin,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
return Ok(member); return Ok(member);
} }
@ -135,6 +147,12 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
db.RealmMembers.Remove(member); db.RealmMembers.Remove(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
return NoContent(); return NoContent();
} }
@ -214,6 +232,12 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
db.RealmMembers.Remove(member); db.RealmMembers.Remove(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmLeave,
new Dictionary<string, object> { { "realm_id", member.RealmId }, { "account_id", member.AccountId } },
Request
);
return NoContent(); return NoContent();
} }
@ -273,6 +297,11 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
db.Realms.Add(realm); db.Realms.Add(realm);
await db.SaveChangesAsync(); 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.Picture is not null) await fs.MarkUsageAsync(realm.Picture, 1);
if (realm.Background is not null) await fs.MarkUsageAsync(realm.Background, 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); db.Realms.Update(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmUpdate,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
);
return Ok(realm); return Ok(realm);
} }
@ -359,6 +394,11 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
db.Realms.Remove(realm); db.Realms.Remove(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.RealmDelete,
new Dictionary<string, object> { { "realm_id", realm.Id } }, Request
);
if (realm.Picture is not null) if (realm.Picture is not null)
await fs.MarkUsageAsync(realm.Picture, -1); await fs.MarkUsageAsync(realm.Picture, -1);
if (realm.Background is not null) if (realm.Background is not null)