summary refs log tree commit diff
path: root/extra/admin-api/Spacebar.Offload/Controllers
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2026-03-19 08:38:20 +0100
committerRory& <root@rory.gay>2026-03-19 08:38:20 +0100
commitd0eac0b8e7738c645707b52e386ca1e2079b8da8 (patch)
treec8fc5b135ec94f6351e025ff7df472f83db4c6c5 /extra/admin-api/Spacebar.Offload/Controllers
parentCDN: use file contents for hashes, the fuck (diff)
downloadserver-ts-d0eac0b8e7738c645707b52e386ca1e2079b8da8.tar.xz
GatewayOffload: rename to Offload
Diffstat (limited to 'extra/admin-api/Spacebar.Offload/Controllers')
-rw-r--r--extra/admin-api/Spacebar.Offload/Controllers/ChannelStatusController.cs68
-rw-r--r--extra/admin-api/Spacebar.Offload/Controllers/IdentifyController.cs53
-rw-r--r--extra/admin-api/Spacebar.Offload/Controllers/Op12Controller.cs93
-rw-r--r--extra/admin-api/Spacebar.Offload/Controllers/Op14Controller.cs51
-rw-r--r--extra/admin-api/Spacebar.Offload/Controllers/Op8Controller.cs93
5 files changed, 358 insertions, 0 deletions
diff --git a/extra/admin-api/Spacebar.Offload/Controllers/ChannelStatusController.cs b/extra/admin-api/Spacebar.Offload/Controllers/ChannelStatusController.cs
new file mode 100644

index 00000000..0843218e --- /dev/null +++ b/extra/admin-api/Spacebar.Offload/Controllers/ChannelStatusController.cs
@@ -0,0 +1,68 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Spacebar.Interop.Authentication.AspNetCore; +using Spacebar.Interop.Replication.Abstractions; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Gateway; + +namespace Spacebar.GatewayOffload.Controllers; + +[ApiController] +[Route("/_spacebar/offload/gateway")] +public class ChannelStatusController(ILogger<ChannelStatusController> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) + : ControllerBase { + [HttpPost("ChannelStatuses")] + public async IAsyncEnumerable<ReplicationMessage<ChannelStatusesResponse>> GetChannelStatuses([FromBody] ChannelStatusesRequest req) { + await foreach (var res in GetChannelInfos(new() { + Fields = ["status"], + GuildIdRawValue = req.GuildIdRawValue, + })) { + yield return new() { + Payload = new() { + GuildId = res.Payload.GuildId, + Channels = res.Payload.Channels.Select(c => new ChannelStatus { + ChannelId = c.ChannelId, + Status = c.Status!, + }).ToList(), + } + }; + } + } + + [HttpPost("ChannelInfo")] + public async IAsyncEnumerable<ReplicationMessage<ChannelInfoResponse>> GetChannelInfos([FromBody] ChannelInfoRequest req) { + var user = await authService.GetCurrentUserAsync(Request); + string[] statusOptions = [ + "Vibing ✨", + "Hanging out: 12%...", + "Communicating...", + // idk, i cant come up with more stuff, maybe suggestions welcome, or actually storing some data? + ]; + + foreach (var guildId in req.GuildIds ?? [req.GuildId!]) { + var channels = (await db.Channels.Include(x => x.VoiceStates).Where(x => x.Type == 2 && x.GuildId == guildId && x.VoiceStates.Count > 0) + .Select(x => x.Id) + .ToListAsync()) + .Select(x => new { + id = x, + status = statusOptions[new Random().Next(statusOptions.Length)], // TODO: We don't currently store channel statuses, so make some stuff up + voiceStartTime = DateTime.Now.Subtract(TimeSpan.FromMinutes(new Random().Next(1, 120))), // TODO: We also don't store voice start times, so make some stuff up + }).ToList(); + + yield return new() { + Payload = new() { + GuildId = guildId, + Channels = channels.Select(c => new ChannelInfo { + ChannelId = c.id, + Status = req.Fields.Contains("status") ? c.status : null, + VoiceStartTime = req.Fields.Contains("voice_start_time") ? c.voiceStartTime : null, + }).ToList(), + }, + }; + } + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Offload/Controllers/IdentifyController.cs b/extra/admin-api/Spacebar.Offload/Controllers/IdentifyController.cs new file mode 100644
index 00000000..02bad394 --- /dev/null +++ b/extra/admin-api/Spacebar.Offload/Controllers/IdentifyController.cs
@@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using Spacebar.Interop.Authentication; +using Spacebar.Interop.Replication.Abstractions; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Gateway; +using Spacebar.Models.Generic; + +namespace Spacebar.GatewayOffload.Controllers; + +[ApiController] +[Route("/_spacebar/offload/gateway/Identify")] +public class IdentifyController(ILogger<IdentifyController> logger, SpacebarAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase { + [HttpPost("")] + public async IAsyncEnumerable<ContentlessReplicationMessage> DoIdentify(IdentifyRequest payload) { + var user = await TraceResult.TraceAsync("getAuthUser", () => authService.GetCurrentUserAsync(payload.Token)); + var session = await TraceResult.TraceAsync("getAuthSession", () => authService.GetCurrentSessionAsync(payload.Token)); + + var socketMeta = new SbWebsocketMeta() { + // Auth data + AccessToken = payload.Token, + UserId = user.Result.Id, + SessionId = session.Result.SessionId, + // Client capabilities + Capabilities = payload.Capabilities ??= 0, + LargeThreshold = payload.LargeTreshold ??= user.Result.Bot ? 20 : 250, + Intents = payload.Intents ??= (GatewayIntentFlags)0b_110_11111111_11111111_11111111_11111111, + // Sharding info + ShardId = payload.Shard?[0], + ShardCount = payload.Shard?[1], + }; + + if (socketMeta is { ShardId: not null, ShardCount: not null }) { + if (socketMeta.ShardId < 0 || socketMeta.ShardCount <= 0 || socketMeta.ShardId >= socketMeta.ShardCount) { + logger.LogWarning("Invalid sharding from {userId}: {shardId}/{shardCount}", user.Result.Id, socketMeta.ShardId, socketMeta.ShardCount); + yield return this.Close(CloseCode.InvalidShard); + yield break; + } + } + + yield return new ReplicationMessage<ReadyResponse>() { + Payload = new() { }, + }; + } + + // TODO: type? also, implement this in gateway lol + private ReplicationMessage<object?> Close(CloseCode closeCode) => new() { + Origin = "IdentifyController", + Event = "SB_GW_CLOSE", + Payload = new { + code = closeCode, + } + }; +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Offload/Controllers/Op12Controller.cs b/extra/admin-api/Spacebar.Offload/Controllers/Op12Controller.cs new file mode 100644
index 00000000..884a9de8 --- /dev/null +++ b/extra/admin-api/Spacebar.Offload/Controllers/Op12Controller.cs
@@ -0,0 +1,93 @@ +using System.Collections.Frozen; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Spacebar.DataMappings.Generic; +using Spacebar.Interop.Authentication.AspNetCore; +using Spacebar.Interop.Replication.Abstractions; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Db.Models; +using Spacebar.Models.Gateway; +using Spacebar.Models.Generic; + +namespace Spacebar.GatewayOffload.Controllers; + +[ApiController] +[Route("/_spacebar/offload/gateway/GuildSync")] +public class Op12Controller(ILogger<Op12Controller> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase +{ + [HttpPost("")] + public async IAsyncEnumerable<ReplicationMessage<GuildSyncResponse>> DoGuildSync(List<string> guildIds) + { + var user = await authService.GetCurrentUserAsync(Request); + guildIds = (await db.Members.AsNoTracking().Where(x => x.Id == user.Id).Select(x => x.GuildId).ToListAsync()) + .Intersect(guildIds) + .OrderByDescending(gi => db.Members.Count(m => m.GuildId == gi)) + .ToList(); + + var syncs = guildIds.Select(GetGuildSyncAsync).ToList().ToAsyncResultEnumerable(); + await foreach (var res in syncs) + { + yield return new() + { + Origin = "OFFLOAD_GUILD_SYNC", + UserId = user.Id, + Event = "GUILD_SYNC", + CreatedAt = DateTime.Now, + Payload = res + }; + } + } + + // TODO: figure out how to abstract this to a function without EFCore complaining about not being translatable... + private static Expression<Func<Session, bool>> IsOnline = (Session session) => session.Status != "offline" && session.Status != "invisible" && session.Status != "unknown"; + + private async Task<GuildSyncResponse> GetGuildSyncAsync(string guildId) + { + await using var sc = sp.CreateAsyncScope(); + var _db = sc.ServiceProvider.GetRequiredService<SpacebarDbContext>(); + var memberCount = await _db.Members.AsNoTracking().Where(x => x.GuildId == guildId).CountAsync(); + + var offlineTreshold = DateTime.Now.Subtract(TimeSpan.FromDays(14)); + var isLargeGuild = memberCount > 10000; + + var members = await _db.Members.AsNoTracking().Where(x => x.GuildId == guildId) + .Include(x => x.IdNavigation) + .ThenInclude(x => x.Sessions.Where(s => + !s.IsAdminSession && ( + // see TODO on IsOnline - somehow need to replicate `IsOnline(s)` + s.Status != "offline" && s.Status != "invisible" && s.Status != "unknown" + ) && (!isLargeGuild || s.LastSeen >= offlineTreshold))) + .Where(x => x.IdNavigation.Sessions.Count > 0) // ignore members without sessions + .ToListAsync(); + + var mappedPartialUsers = members.Select(x => x.IdNavigation).ToFrozenDictionary(x => x.Id, x => x.ToPartialUser()); + var mappedMembers = members.ToFrozenDictionary(m => m.Id, m => m.ToPublicMember(mappedPartialUsers[m.Id])); + + var presences = members.Select(x => x.IdNavigation).Where(x => x.Sessions.Count > 0).ToFrozenDictionary(x => x.Id, x => + { + var sortedSessions = x.Sessions.OrderByDescending(s => s.LastSeen).ToList(); + return new Presence() + { + GuildId = guildId, + User = mappedPartialUsers[x.Id], + Activities = x.Sessions.Where(s => s.Status is not ("offline" or "invisible" or "unknown")) + .SelectMany(s => JsonSerializer.Deserialize<JsonObject[]>(s.Activities) ?? []).ToList(), + Status = sortedSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Status))?.Status ?? "offline", + ClientStatus = JsonSerializer.Deserialize<Presence.ClientStatuses>(sortedSessions.First(s => !string.IsNullOrWhiteSpace(s.ClientStatus)).ClientStatus) ?? + new() + }; + }).Where(x => x.Value.Activities.Count > 0).ToFrozenDictionary(); + + var r = new GuildSyncResponse() + { + GuildId = guildId, + Members = mappedMembers.Values.ToList(), + Presences = presences.Values.ToList() + }; + return r; + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Offload/Controllers/Op14Controller.cs b/extra/admin-api/Spacebar.Offload/Controllers/Op14Controller.cs new file mode 100644
index 00000000..57f1390c --- /dev/null +++ b/extra/admin-api/Spacebar.Offload/Controllers/Op14Controller.cs
@@ -0,0 +1,51 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Spacebar.GatewayOffload.Extensions.Db; +using Spacebar.Interop.Authentication.AspNetCore; +using Spacebar.Interop.Replication.Abstractions; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Gateway; +using Spacebar.Models.Generic; + +namespace Spacebar.GatewayOffload.Controllers; + +[ApiController] +[Route("/_spacebar/offload/gateway/LazyRequest")] +public class Op14Controller(ILogger<Op12Controller> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase { + [HttpPost] + // TODO: actually return something? + public async IAsyncEnumerable<ContentlessReplicationMessage> DoLazyRequest([FromBody] LazyRequest payload) { + var user = await TraceResult.TraceAsync("getAuthUser", () => authService.GetCurrentUserAsync(Request)); + var session = await TraceResult.TraceAsync("getAuthSession", () => authService.GetCurrentSessionAsync(Request)); + + if (!await db.Members.AsNoTracking().AnyAsync(m => m.GuildId == payload.GuildId && m.Id == user.Result.Id)) { + logger.LogWarning("User {user} requested lazy member list for guild {guildId}, but is not a member", user.Result.Id, payload.GuildId); + yield break; + } + + if (payload.Channels.Count == 0) { + logger.LogWarning("User {user} requested lazy member list for guild {guildId}, but is not a member", user.Result.Tag, payload.GuildId); + yield break; + } + + // Fetch hoisted roles for the guild to define groups + var hoistedRoles = await db.Roles + .AsNoTracking() + .Where(r => r.GuildId == payload.GuildId && r.Hoist) + .OrderByDescending(r => r.Position) + .Select(r => new { r.Id }) + .ToListAsync(); + } + + private async Task<string?> GetMemberListIdAsync(SpacebarDbContext db, string guildId, string channelId) { + var channel = await db.Channels.AsNoTracking().FirstOrDefaultAsync(c => c.Id == channelId && c.GuildId == guildId); + if (channel == null) return null; + + if (string.IsNullOrWhiteSpace(channel.PermissionOverwrites) || channel.PermissionOverwrites == "[]") { + return "everyone"; + } + + return null; // TODO + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Offload/Controllers/Op8Controller.cs b/extra/admin-api/Spacebar.Offload/Controllers/Op8Controller.cs new file mode 100644
index 00000000..ac68bb22 --- /dev/null +++ b/extra/admin-api/Spacebar.Offload/Controllers/Op8Controller.cs
@@ -0,0 +1,93 @@ +using System.Collections.Frozen; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Spacebar.DataMappings.Generic; +using Spacebar.Interop.Authentication.AspNetCore; +using Spacebar.Interop.Replication.Abstractions; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Db.Models; +using Spacebar.Models.Gateway; +using Spacebar.Models.Generic; + +namespace Spacebar.GatewayOffload.Controllers; + +[ApiController] +[Route("/_spacebar/offload/gateway/GuildMembers")] +public class Op8Controller(ILogger<Op8Controller> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase +{ + [HttpPost("")] + public async IAsyncEnumerable<ReplicationMessage<GuildSyncResponse>> DoGuildSync(List<string> guildIds) + { + var user = await authService.GetCurrentUserAsync(Request); + guildIds = (await db.Members.AsNoTracking().Where(x => x.Id == user.Id).Select(x => x.GuildId).ToListAsync()) + .Intersect(guildIds) + .OrderByDescending(gi => db.Members.Count(m => m.GuildId == gi)) + .ToList(); + + var syncs = guildIds.Select(GetGuildSyncAsync).ToList().ToAsyncResultEnumerable(); + await foreach (var res in syncs) + { + yield return new() + { + Origin = "OFFLOAD_GUILD_SYNC", + UserId = user.Id, + Event = "GUILD_SYNC", + CreatedAt = DateTime.Now, + Payload = res + }; + } + } + + // TODO: figure out how to abstract this to a function without EFCore complaining about not being translatable... + private static Expression<Func<Session, bool>> IsOnline = (Session session) => session.Status != "offline" && session.Status != "invisible" && session.Status != "unknown"; + + private async Task<GuildSyncResponse> GetGuildSyncAsync(string guildId) + { + await using var sc = sp.CreateAsyncScope(); + var _db = sc.ServiceProvider.GetRequiredService<SpacebarDbContext>(); + var memberCount = await _db.Members.AsNoTracking().Where(x => x.GuildId == guildId).CountAsync(); + + var offlineTreshold = DateTime.Now.Subtract(TimeSpan.FromDays(14)); + var isLargeGuild = memberCount > 10000; + + var members = await _db.Members.AsNoTracking().Where(x => x.GuildId == guildId) + .Include(x => x.IdNavigation) + .ThenInclude(x => x.Sessions.Where(s => + !s.IsAdminSession && ( + // see TODO on IsOnline - somehow need to replicate `IsOnline(s)` + s.Status != "offline" && s.Status != "invisible" && s.Status != "unknown" + ) && (!isLargeGuild || s.LastSeen >= offlineTreshold))) + .Where(x => x.IdNavigation.Sessions.Count > 0) // ignore members without sessions + .ToListAsync(); + + var mappedPartialUsers = members.Select(x => x.IdNavigation).ToFrozenDictionary(x => x.Id, x => x.ToPartialUser()); + var mappedMembers = members.ToFrozenDictionary(m => m.Id, m => m.ToPublicMember(mappedPartialUsers[m.Id])); + + var presences = members.Select(x => x.IdNavigation).Where(x => x.Sessions.Count > 0).ToFrozenDictionary(x => x.Id, x => + { + var sortedSessions = x.Sessions.OrderByDescending(s => s.LastSeen).ToList(); + return new Presence() + { + GuildId = guildId, + User = mappedPartialUsers[x.Id], + Activities = x.Sessions.Where(s => s.Status is not ("offline" or "invisible" or "unknown")) + .SelectMany(s => JsonSerializer.Deserialize<JsonObject[]>(s.Activities) ?? []).ToList(), + Status = sortedSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Status))?.Status ?? "offline", + ClientStatus = JsonSerializer.Deserialize<Presence.ClientStatuses>(sortedSessions.First(s => !string.IsNullOrWhiteSpace(s.ClientStatus)).ClientStatus) ?? + new() + }; + }).Where(x => x.Value.Activities.Count > 0).ToFrozenDictionary(); + + var r = new GuildSyncResponse() + { + GuildId = guildId, + Members = mappedMembers.Values.ToList(), + Presences = presences.Values.ToList() + }; + return r; + } +} \ No newline at end of file