More C# progress

This commit is contained in:
Rory& committed 2026-02-11 10:39:16 +01:00
1 parent f233fdb9de
commit c11629268b
20 files changed
+400 -35

No files matched your search

@@ -0,0 +1,13 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Spacebar.Models.Generic;
namespace Spacebar.DataMappings.Generic;
public static class Channel {
extension(Models.Db.Models.Channel channel) {
[JsonIgnore]
public IEnumerable<ChannelPermissionOverwrite>? MappedPermissionOverwrites =>
channel.PermissionOverwrites is null ? [] : JsonSerializer.Deserialize<List<ChannelPermissionOverwrite>>(channel.PermissionOverwrites);
}
}
@@ -23,4 +23,9 @@ public class SpacebarAspNetAuthenticationService(SpacebarAuthenticationService a
var token = GetTokenAsync(request);
return await authService.GetCurrentUserAsync(token);
}
public Task<Session> GetCurrentSessionAsync(HttpRequest request) {
var token = GetTokenAsync(request);
return authService.GetCurrentSessionAsync(token);
}
}
@@ -11,6 +11,7 @@ public class SpacebarAuthenticationConfiguration {
public required string PublicKeyPath { get; set; }
public string? OverrideUid { get; set; }
public string? OverrideDid { get; set; }
public bool DisableAuthentication { get; set; } = false;
public bool Enforce2FA { get; set; } = true;
public TimeSpan AuthCacheExpiry { get; set; } = TimeSpan.FromSeconds(30);
@@ -10,6 +10,7 @@ namespace Spacebar.Interop.Authentication;
public class SpacebarAuthenticationService(ILogger<SpacebarAuthenticationService> logger, SpacebarDbContext db, SpacebarAuthenticationConfiguration config) {
private static readonly ExpiringSemaphoreCache<User> UserCache = new();
private static readonly ExpiringSemaphoreCache<Session> SessionCache = new();
public async Task<TokenValidationResult?> ValidateTokenAsync(string token) {
var handler = new JwtSecurityTokenHandler();
@@ -47,13 +48,13 @@ public class SpacebarAuthenticationService(ILogger<SpacebarAuthenticationService
config.AuthCacheExpiry);
}
public async Task<User> GetCurrentSessionAsync(string token) {
public async Task<Session> GetCurrentSessionAsync(string token) {
var res = await ValidateTokenAsync(token);
return await UserCache.GetOrAdd(token,
return await SessionCache.GetOrAdd(token,
async () => {
var uid = config.OverrideUid ?? res?.ClaimsIdentity.Claims.First(x => x.Type == "id").Value;
if (string.IsNullOrWhiteSpace(uid)) throw new InvalidOperationException("No user ID specified, is the access token valid?");
return await db.Users.FindAsync(uid) ?? throw new InvalidOperationException();
var did = config.OverrideDid ?? res?.ClaimsIdentity.Claims.First(x => x.Type == "did").Value;
if (string.IsNullOrWhiteSpace(did)) throw new InvalidOperationException("No device ID specified, is the access token valid?");
return await db.Sessions.FindAsync(did) ?? throw new InvalidOperationException();
},
config.AuthCacheExpiry);
}
@@ -0,0 +1,19 @@
namespace Spacebar.Models.Gateway;
public enum CloseCode {
UnknownError = 4000,
UnknownOpcode = 4001,
DecodeError = 4002,
NotAuthenticated = 4003,
AuthenticationFailed = 4004,
AlreadyAuthenticated = 4005,
InvalidSession = 4006,
InvalidSeq = 4007,
RateLimited = 4008,
SessionTimedOut = 4009,
InvalidShard = 4010,
ShardingRequired = 4011,
InvalidApiVersion = 4012,
InvalidIntent = 4013,
DisallowedIntent = 4014,
}
@@ -0,0 +1,74 @@
using System.Text.Json.Serialization;
using Spacebar.Models.Generic;
public class GuildMemberListUpdate {
[JsonPropertyName("id")]
public string ListId { get; set; } = null!;
[JsonPropertyName("guild_id")]
public string GuildId { get; set; } = null!;
[JsonPropertyName("online_count")]
public int OnlineCount { get; set; }
[JsonPropertyName("member_count")]
public int MemberCount { get; set; }
[JsonPropertyName("ops")]
public List<BaseGuildMemberListUpdateOperation> Operations { get; set; } = null!;
[JsonPropertyName("groups")]
public List<GuildMemberListGroupCount> Groups { get; set; } = null!;
}
// i cba to write a dictionary converter for this...
public class GuildMemberListGroupCount {
/// <summary>
/// Role ID, "online" or "offline"
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
public static implicit operator KeyValuePair<string, int>(GuildMemberListGroupCount groupCount) => new(groupCount.Id, groupCount.Count);
public static implicit operator GuildMemberListGroupCount(KeyValuePair<string, int> kvp) => new() { Id = kvp.Key, Count = kvp.Value };
}
public enum GuildMemberListUpdateOperationType {
[JsonStringEnumMemberName("sync")] Sync = 0,
[JsonStringEnumMemberName("insert")] Insert = 1,
[JsonStringEnumMemberName("update")] Update = 2,
[JsonStringEnumMemberName("delete")] Delete = 3,
[JsonStringEnumMemberName("invalidate")]
Invalidate = 4
}
#region Operations
public class BaseGuildMemberListUpdateOperation {
[JsonPropertyName("op")]
public GuildMemberListUpdateOperationType Operation { get; set; }
}
public class GuildMemberListSyncOperation : BaseGuildMemberListUpdateOperation {
[JsonPropertyName("range")]
public int[] Range { get; set; } = null!;
[JsonPropertyName("items")]
public List<GuildMemberListSyncItem> Items { get; set; } = null!;
}
public class GuildMemberListSyncItem {
public class GuildMemberListMemberSyncItem : GuildMemberListSyncItem {
[JsonPropertyName("member")]
public Member Member { get; set; } = null!;
}
}
#endregion
// TODO: diff algo
// TODO: snapshots
@@ -8,7 +8,7 @@ public class GuildSyncResponse {
public string GuildId { get; set; }
[JsonPropertyName("presences")]
public List<PresenceResponse> Presences { get; set; }
public List<Presence> Presences { get; set; }
[JsonPropertyName("members")]
public List<Member> Members { get; set; }
@@ -3,8 +3,7 @@ using System.Text.Json.Serialization;
namespace Spacebar.Models.Gateway;
public class IdentifyRequest
{
public class IdentifyRequest {
[JsonPropertyName("token")]
public string Token { get; set; }
@@ -34,8 +33,7 @@ public class IdentifyRequest
}
[Flags]
public enum GatewayIntentFlags
{
public enum GatewayIntentFlags : ulong {
Guilds = 1,
GuildMembers = 1 << 1,
GuildModeration = 1 << 2,
@@ -68,8 +66,7 @@ public enum GatewayIntentFlags
}
[Flags]
public enum GatewayCapabilityFlags
{
public enum GatewayCapabilityFlags {
LazyUserNotes = 1,
NoAffineUserIds = 1 << 1,
VersionedReadStates = 1 << 2,
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace Spacebar.Models.Gateway;
public class QoSPayload {
[JsonPropertyName("ver")]
public int Version { get; set; }
[JsonPropertyName("active")]
public bool Active { get; set; }
[JsonPropertyName("reasons")]
public List<string> Reasons { get; set; } = [];
}
@@ -0,0 +1,3 @@
namespace Spacebar.Models.Gateway;
public class ReadyResponse { }
@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace Spacebar.Models.Gateway;
// TODO: move to interop
public class SbWebsocketMeta
{
[JsonPropertyName("user_id")]
public string UserId { get; set; } = string.Empty;
[JsonPropertyName("session_id")]
public string SessionId { get; set; } = string.Empty;
[JsonPropertyName("accessToken")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("encoding")]
public string Encoding { get; set; } = "json";
[JsonPropertyName("compress")]
public string? Compress { get; set; }
[JsonPropertyName("ipAddress")]
public string? IpAddress { get; set; }
[JsonPropertyName("userAgent")]
public string? UserAgent { get; set; }
[JsonPropertyName("fingerprint")]
public string? Fingerprint { get; set; }
[JsonPropertyName("shard_count")]
public int? ShardCount { get; set; }
[JsonPropertyName("shard_id")]
public int? ShardId { get; set; }
[JsonPropertyName("intents")]
public GatewayIntentFlags Intents { get; set; } = default!;
[JsonPropertyName("sequence")]
public long Sequence { get; set; }
[JsonPropertyName("capabilities")]
public GatewayCapabilityFlags? Capabilities { get; set; }
[JsonPropertyName("large_threshold")]
public int LargeThreshold { get; set; }
[JsonPropertyName("qos")]
public QoSPayload? Qos { get; set; }
}
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Spacebar.Models.Generic;
public class ChannelPermissionOverwrite {
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("type")]
public int Type { get; set; }
[JsonPropertyName("allow"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)]
public ulong Allow { get; set; }
[JsonPropertyName("deny"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)]
public ulong Deny { get; set; }
}
@@ -28,4 +28,10 @@ public class Member {
[JsonPropertyName("bio")]
public string? Bio { get; set; }
}
// Unsure if this is used anywhere outside of op14...?
public class MemberWithPresence : Member {
[JsonPropertyName("presence")]
public Presence? Presence { get; set; }
}
@@ -3,9 +3,9 @@ using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Spacebar.Models.Generic;
namespace Spacebar.Models.Gateway;
namespace Spacebar.Models.Generic;
public class PresenceResponse {
public class Presence {
[JsonPropertyName("user")]
public required PartialUser User { get; set; }
@@ -29,6 +29,14 @@ public class PresenceResponse {
[JsonPropertyName("has_played_game")]
public bool? HasPlayedGame { get; set; }
// Unsure if this is used outside of op14
[JsonPropertyName("game")]
public JsonObject? Game { get; set; }
// Unsure if used outside of op14
[JsonPropertyName("processed_at_timestamp")]
public ulong? ProcessedAtTimestamp { get; set; }
[SuppressMessage("ReSharper", "UnusedMember.Local")]
public class ClientStatuses {
[JsonPropertyName("desktop"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
@@ -0,0 +1,67 @@
using System.Diagnostics;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Spacebar.Models.Generic;
public class Trace {
[JsonPropertyName("micros")]
public long Micros { get; set; }
[JsonIgnore]
public string? Name { get; set; }
[JsonIgnore]
public List<Trace>? Calls { get; set; }
[JsonPropertyName("calls")]
// zipped array of [string, Trace]
public JsonArray? ZippedCalls {
get {
if (Calls == null) return null;
var arr = new JsonArray();
foreach (var t in Calls) {
arr.Add(t.Name);
arr.Add(t);
}
return arr;
}
}
public JsonArray AsRoot() {
return new() {
Name,
this
};
}
}
public static class TraceResult {
public static async Task<TraceResult<T>> TraceAsync<T>(string name, Func<Task<T>> func) {
var sw = Stopwatch.StartNew();
var result = await func();
sw.Stop();
return new TraceResult<T> {
Name = name,
Micros = sw.Elapsed.Microseconds,
Result = result
};
}
public static async Task<TraceResult<T>> Trace<T>(string name, Func<T> func) {
var sw = Stopwatch.StartNew();
var result = func();
sw.Stop();
return new TraceResult<T> {
Name = name,
Micros = sw.Elapsed.Microseconds,
Result = result
};
}
}
public class TraceResult<T> : Trace {
[JsonIgnore]
public T Result { get; set; }
}
@@ -1,13 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Spacebar.Interop.Authentication;
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/Identify")]
public class IdentifyController(ILogger<IdentifyController> logger, SpacebarAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase
{
public class IdentifyController(ILogger<IdentifyController> logger, SpacebarAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase {
[HttpPost("")]
public async IAsyncEnumerable<ReplicationMessage> 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() {
Payload = new ReadyResponse { },
};
}
private ReplicationMessage Close(CloseCode closeCode) => new() {
Origin = "IdentifyController",
Event = "SB_GW_CLOSE",
Payload = new {
code = closeCode,
}
};
}
@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -10,6 +11,7 @@ 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;
@@ -21,7 +23,7 @@ public class Op12Controller(ILogger<Op12Controller> logger, SpacebarAspNetAuthen
public async IAsyncEnumerable<ReplicationMessage> DoGuildSync(List<string> guildIds)
{
var user = await authService.GetCurrentUserAsync(Request);
guildIds = (await db.Members.Where(x => x.Id == user.Id).Select(x => x.GuildId).ToListAsync())
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();
@@ -41,44 +43,44 @@ public class Op12Controller(ILogger<Op12Controller> logger, SpacebarAspNetAuthen
}
// TODO: figure out how to abstract this to a function without EFCore complaining about not being translatable...
//private static Func<Session, bool> IsOnline = (Session session) => session.Status != "offline" && session.Status != "invisible" && session.Status != "unknown";
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.Where(x => x.GuildId == guildId).CountAsync();
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.Where(x => x.GuildId == guildId)
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
// 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).ToDictionary(x => x.Id, x => x.ToPartialUser());
var mappedMembers = members.ToDictionary(m => m.Id, m => m.ToPublicMember(mappedPartialUsers[m.Id]));
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).ToDictionary(x => x.Id, x =>
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 PresenceResponse()
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<PresenceResponse.ClientStatuses>(sortedSessions.First(s => !string.IsNullOrWhiteSpace(s.ClientStatus)).ClientStatus) ??
ClientStatus = JsonSerializer.Deserialize<Presence.ClientStatuses>(sortedSessions.First(s => !string.IsNullOrWhiteSpace(s.ClientStatus)).ClientStatus) ??
new()
};
}).Where(x => x.Value.Activities.Count > 0).ToDictionary();
}).Where(x => x.Value.Activities.Count > 0).ToFrozenDictionary();
var r = new GuildSyncResponse()
{
@@ -1,13 +1,50 @@
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 : ControllerBase {
public class Op14Controller(ILogger<Op12Controller> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase {
[HttpPost]
public async IAsyncEnumerable<ReplicationMessage> DoLazyRequest() {
yield break;
public async IAsyncEnumerable<ReplicationMessage> 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
}
}
@@ -0,0 +1,9 @@
using Spacebar.Models.Db.Models;
namespace Spacebar.GatewayOffload.Extensions.Db;
public static class UserExtensions {
extension(User user) {
public string Tag => $"{user.Username}#{user.Discriminator}";
}
}
@@ -1,8 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ArcaneLibs.Extensions;
using Microsoft.EntityFrameworkCore;
using Spacebar.Interop.Authentication;
using Spacebar.Interop.Authentication.AspNetCore;
using Spacebar.Models.Db.Contexts;
using Spacebar.Models.Generic;
var builder = WebApplication.CreateBuilder(args);
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("APPSETTINGS_PATH")))
@@ -18,9 +21,7 @@ builder.Services.AddControllers(options => {
options.JsonSerializerOptions.WriteIndented = true;
options.JsonSerializerOptions.MaxDepth = 100;
// options.JsonSerializerOptions.DefaultBufferSize = ;
}).AddMvcOptions(o=> {
o.SuppressOutputFormatterBuffering = true;
});
}).AddMvcOptions(o => { o.SuppressOutputFormatterBuffering = true; });
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();