diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/ConfigController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/ConfigController.cs
new file mode 100644
index 00000000..96465616
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Controllers/ConfigController.cs
@@ -0,0 +1,61 @@
+using System.Text.Json.Nodes;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Spacebar.AdminApi.Extensions;
+using Spacebar.AdminApi.Models;
+using Spacebar.AdminApi.Services;
+using Spacebar.Db.Contexts;
+using Spacebar.Db.Models;
+using Spacebar.RabbitMqUtilities;
+using Spacebar.ConfigModel.Extensions;
+
+namespace Spacebar.AdminApi.Controllers;
+
+[ApiController]
+[Route("/Configuration")]
+public class ConfigController(ILogger<ConfigController> logger, SpacebarDbContext db, RabbitMQService mq, IServiceProvider sp, AuthenticationService auth) : ControllerBase {
+ private readonly ILogger<ConfigController> _logger = logger;
+
+ [HttpGet]
+ public async Task<JsonObject> Get() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var config = (await db.Configs.AsNoTracking().ToDictionaryAsync(x => x.Key, x => x.Value)).ToNestedJsonObject();
+ return config;
+ }
+
+ [HttpPost]
+ public async Task<IActionResult> Post([FromBody] JsonObject newConfig) {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var flatConfig = newConfig.ToFlatKv();
+ var tasks = flatConfig.Select(async x => {
+ await using var scope = sp.CreateAsyncScope();
+ var scopedDb = scope.ServiceProvider.GetRequiredService<SpacebarDbContext>();
+ var existingConfig = await scopedDb.Configs.FindAsync(x.Key);
+ if (existingConfig != null) {
+ existingConfig.Value = x.Value;
+ scopedDb.Configs.Update(existingConfig);
+ }
+ else {
+ await scopedDb.Configs.AddAsync(new Config
+ { Key = x.Key, Value = x.Value });
+ }
+
+ await scopedDb.SaveChangesAsync();
+ });
+ await Task.WhenAll(tasks);
+ // TODO: rabbitmq
+
+ return Ok();
+ }
+
+ [HttpPost]
+ public async Task<IActionResult> ReloadConfig() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ // TODO: rabbitmq
+
+ return Ok();
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs
new file mode 100644
index 00000000..05bd604a
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs
@@ -0,0 +1,323 @@
+using ArcaneLibs.Extensions;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using RabbitMQ.Client;
+using Spacebar.AdminApi.Extensions;
+using Spacebar.AdminApi.Models;
+using Spacebar.AdminApi.Services;
+using Spacebar.Db.Contexts;
+using Spacebar.Db.Models;
+using Spacebar.RabbitMqUtilities;
+
+namespace Spacebar.AdminApi.Controllers;
+
+[ApiController]
+[Route("/Guilds")]
+public class GuildController(ILogger<GuildController> logger, Configuration config, RabbitMQConfiguration amqpConfig, SpacebarDbContext db, RabbitMQService mq, IServiceProvider sp, AuthenticationService auth) : ControllerBase {
+ private readonly ILogger<GuildController> _logger = logger;
+
+ [HttpGet]
+ public async IAsyncEnumerable<GuildModel> Get() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var results = db.Guilds.Select(x => new GuildModel {
+ Id = x.Id,
+ AfkChannelId = x.AfkChannelId,
+ AfkTimeout = x.AfkTimeout,
+ Banner = x.Banner,
+ DefaultMessageNotifications = x.DefaultMessageNotifications,
+ Description = x.Description,
+ DiscoverySplash = x.DiscoverySplash,
+ ExplicitContentFilter = x.ExplicitContentFilter,
+ Features = x.Features,
+ PrimaryCategoryId = x.PrimaryCategoryId,
+ Icon = x.Icon,
+ Large = x.Large,
+ MaxMembers = x.MaxMembers,
+ MaxPresences = x.MaxPresences,
+ MaxVideoChannelUsers = x.MaxVideoChannelUsers,
+ MemberCount = x.MemberCount,
+ PresenceCount = x.PresenceCount,
+ TemplateId = x.TemplateId,
+ MfaLevel = x.MfaLevel,
+ Name = x.Name,
+ OwnerId = x.OwnerId,
+ PreferredLocale = x.PreferredLocale,
+ PremiumSubscriptionCount = x.PremiumSubscriptionCount,
+ PremiumTier = x.PremiumTier,
+ PublicUpdatesChannelId = x.PublicUpdatesChannelId,
+ RulesChannelId = x.RulesChannelId,
+ Region = x.Region,
+ Splash = x.Splash,
+ SystemChannelId = x.SystemChannelId,
+ SystemChannelFlags = x.SystemChannelFlags,
+ Unavailable = x.Unavailable,
+ VerificationLevel = x.VerificationLevel,
+ WelcomeScreen = x.WelcomeScreen,
+ WidgetChannelId = x.WidgetChannelId,
+ WidgetEnabled = x.WidgetEnabled,
+ NsfwLevel = x.NsfwLevel,
+ Nsfw = x.Nsfw,
+ Parent = x.Parent,
+ PremiumProgressBarEnabled = x.PremiumProgressBarEnabled,
+ ChannelOrdering = x.ChannelOrdering,
+ ChannelCount = x.Channels.Count(),
+ RoleCount = x.Roles.Count(),
+ EmojiCount = x.Emojis.Count(),
+ StickerCount = x.Stickers.Count(),
+ InviteCount = x.Invites.Count(),
+ MessageCount = x.Messages.Count(),
+ BanCount = x.Bans.Count(),
+ VoiceStateCount = x.VoiceStates.Count(),
+ }).AsAsyncEnumerable();
+ await foreach (var result in results) {
+ yield return result;
+ }
+ }
+
+ [HttpPost("{id}/force_join")]
+ public async Task<IActionResult> ForceJoinGuild([FromBody] ForceJoinRequest request, string id) {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var guild = await db.Guilds.FindAsync(id);
+ if (guild == null) {
+ return NotFound(new { entity = "Guild", id, message = "Guild not found" });
+ }
+
+ var userId = request.UserId ?? config.OverrideUid ?? (await auth.GetCurrentUser(Request)).Id;
+ var user = await db.Users.FindAsync(userId);
+ if (user == null) {
+ return NotFound(new { entity = "User", id = userId, message = "User not found" });
+ }
+
+ var member = await db.Members.SingleOrDefaultAsync(m => m.GuildId == id && m.Id == userId);
+ if (member is null) {
+ member = new Member {
+ Id = userId,
+ GuildId = id,
+ JoinedAt = DateTime.UtcNow,
+ PremiumSince = 0,
+ Roles = [await db.Roles.SingleAsync(r => r.Id == id)],
+ Pending = false
+ };
+ await db.Members.AddAsync(member);
+ guild.MemberCount++;
+ db.Guilds.Update(guild);
+ await db.SaveChangesAsync();
+ }
+
+ if (request.MakeOwner) {
+ guild.OwnerId = userId;
+ db.Guilds.Update(guild);
+ await db.SaveChangesAsync();
+ } else if (request.MakeAdmin) {
+ var roles = await db.Roles.Where(r => r.GuildId == id).OrderBy(x=>x.Position).ToListAsync();
+ var adminRole = roles.FirstOrDefault(r => r.Permissions == "8" || r.Permissions == "9"); // Administrator
+ if (adminRole == null) {
+ adminRole = new Role {
+ Id = Guid.NewGuid().ToString(),
+ GuildId = id,
+ Name = "Instance administrator",
+ Color = 0,
+ Hoist = false,
+ Position = roles.Max(x=>x.Position) + 1,
+ Permissions = "8", // Administrator
+ Managed = false,
+ Mentionable = false
+ };
+ await db.Roles.AddAsync(adminRole);
+ await db.SaveChangesAsync();
+ }
+
+ if (!member.Roles.Any(r => r.Id == adminRole.Id)) {
+ member.Roles.Add(adminRole);
+ db.Members.Update(member);
+ await db.SaveChangesAsync();
+ }
+ }
+
+ // TODO: gateway events
+
+ return Ok(new { entity = "Guild", id, message = "Guild join forced" });
+ }
+
+ [HttpGet("{id}/delete")]
+ public async IAsyncEnumerable<AsyncActionResult> DeleteUser(string id, [FromQuery] int messageDeleteChunkSize = 100) {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var user = await db.Users.FindAsync(id);
+ if (user == null) {
+ Console.WriteLine($"User {id} not found");
+ yield return new AsyncActionResult("ERROR", new { entity = "User", id, message = "User not found" });
+ yield break;
+ }
+
+ user.Data = "{}";
+ user.Deleted = true;
+ user.Disabled = true;
+ user.Rights = 0;
+ db.Users.Update(user);
+ await db.SaveChangesAsync();
+
+ var factory = new ConnectionFactory {
+ Uri = new Uri("amqp://guest:guest@127.0.0.1/")
+ };
+ await using var mqConnection = await factory.CreateConnectionAsync();
+ await using var mqChannel = await mqConnection.CreateChannelAsync();
+
+ var messages = db.Messages
+ .AsNoTracking()
+ .Where(m => m.AuthorId == id);
+ var channels = messages
+ .Select(m => new { m.ChannelId, m.GuildId })
+ .Distinct()
+ .ToList();
+ yield return new("STATS",
+ new {
+ total_messages = messages.Count(), total_channels = channels.Count,
+ messages_per_channel = channels.ToDictionary(c => c.ChannelId, c => messages.Count(m => m.ChannelId == c.ChannelId))
+ });
+ var results = channels
+ .Select(ctx => DeleteMessagesForChannel(ctx.GuildId, ctx.ChannelId!, id, mqChannel, messageDeleteChunkSize))
+ .ToList();
+ var a = AggregateAsyncEnumerablesWithoutOrder(results);
+ await foreach (var result in a) {
+ yield return result;
+ }
+
+ await db.Database.ExecuteSqlRawAsync("VACUUM FULL messages");
+ await db.Database.ExecuteSqlRawAsync("REINDEX TABLE messages");
+ }
+
+ private async IAsyncEnumerable<AsyncActionResult> DeleteMessagesForChannel(
+ // context
+ string? guildId, string channelId, string authorId,
+ // connections
+ IChannel mqChannel,
+ // options
+ int messageDeleteChunkSize = 100
+ ) {
+ {
+ await using var ctx = sp.CreateAsyncScope();
+ await using var _db = ctx.ServiceProvider.GetRequiredService<SpacebarDbContext>();
+ await mqChannel.ExchangeDeclareAsync(exchange: channelId!, type: ExchangeType.Fanout, durable: false);
+ var messagesInChannel = _db.Messages.AsNoTracking().Count(m => m.AuthorId == authorId && m.ChannelId == channelId && m.GuildId == guildId);
+ var remaining = messagesInChannel;
+ while (true) {
+ var messageIds = _db.Database.SqlQuery<string>($"""
+ DELETE FROM messages
+ WHERE id IN (
+ SELECT id FROM messages
+ WHERE author_id = {authorId}
+ AND channel_id = {channelId}
+ AND guild_id = {guildId}
+ LIMIT {messageDeleteChunkSize}
+ ) RETURNING id;
+ """).ToList();
+ if (messageIds.Count == 0) {
+ break;
+ }
+
+ var props = new BasicProperties() { Type = "MESSAGE_BULK_DELETE" };
+ var publishSuccess = false;
+ do {
+ try {
+ await mqChannel.BasicPublishAsync(exchange: channelId!, routingKey: "", mandatory: true, basicProperties: props, body: new {
+ ids = messageIds,
+ channel_id = channelId,
+ guild_id = guildId,
+ }.ToJson().AsBytes().ToArray());
+ publishSuccess = true;
+ }
+ catch (Exception e) {
+ Console.WriteLine($"[RabbitMQ] Error publishing bulk delete: {e.Message}");
+ await Task.Delay(10);
+ }
+ } while (!publishSuccess);
+
+ yield return new("BULK_DELETED", new {
+ channel_id = channelId,
+ total = messagesInChannel,
+ deleted = messageIds.Count,
+ remaining = remaining -= messageIds.Count,
+ });
+ await Task.Yield();
+ }
+ }
+ }
+
+ private async IAsyncEnumerable<T> AggregateAsyncEnumerablesWithoutOrder<T>(params IEnumerable<IAsyncEnumerable<T>> enumerables) {
+ var enumerators = enumerables.Select(e => e.GetAsyncEnumerator()).ToList();
+ var tasks = enumerators.Select(e => e.MoveNextAsync().AsTask()).ToList();
+
+ try {
+ while (tasks.Count > 0) {
+ var completedTask = await Task.WhenAny(tasks);
+ var completedTaskIndex = tasks.IndexOf(completedTask);
+
+ if (completedTask.IsCanceled) {
+ try {
+ await enumerators[completedTaskIndex].DisposeAsync();
+ }
+ catch {
+ // ignored
+ }
+
+ enumerators.RemoveAt(completedTaskIndex);
+ tasks.RemoveAt(completedTaskIndex);
+ continue;
+ }
+
+ if (await completedTask) {
+ var enumerator = enumerators[completedTaskIndex];
+ yield return enumerator.Current;
+ tasks[completedTaskIndex] = enumerator.MoveNextAsync().AsTask();
+ }
+ else {
+ try {
+ await enumerators[completedTaskIndex].DisposeAsync();
+ }
+ catch {
+ // ignored
+ }
+
+ enumerators.RemoveAt(completedTaskIndex);
+ tasks.RemoveAt(completedTaskIndex);
+ }
+ }
+ }
+ finally {
+ foreach (var enumerator in enumerators) {
+ try {
+ await enumerator.DisposeAsync();
+ }
+ catch {
+ // ignored
+ }
+ }
+ }
+ }
+
+ // {
+ // "op": 0,
+ // "t": "GUILD_ROLE_UPDATE",
+ // "d": {
+ // "guild_id": "1006649183970562092",
+ // "role": {
+ // "id": "1006706520514028812",
+ // "guild_id": "1006649183970562092",
+ // "color": 16711680,
+ // "hoist": true,
+ // "managed": false,
+ // "mentionable": true,
+ // "name": "Adminstrator",
+ // "permissions": "9",
+ // "position": 5,
+ // "unicode_emoji": "💖",
+ // "flags": 0
+ // }
+ // },
+ // "s": 38
+ // }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/Media/UserMediaController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/Media/UserMediaController.cs
new file mode 100644
index 00000000..f3415731
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Controllers/Media/UserMediaController.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Spacebar.AdminApi.Extensions;
+using Spacebar.AdminApi.Models;
+using Spacebar.AdminApi.Services;
+using Spacebar.Db.Contexts;
+using Spacebar.Db.Models;
+using Spacebar.RabbitMqUtilities;
+
+namespace Spacebar.AdminApi.Controllers.Media;
+
+[ApiController]
+[Route("/media/user")]
+public class UserMediaController(ILogger<UserMediaController> logger, SpacebarDbContext db, RabbitMQService mq, AuthenticationService auth, IServiceProvider sp) : ControllerBase {
+ [HttpGet("{userId}/attachments")]
+ public async IAsyncEnumerable<Attachment> GetAttachmentsByUser(string userId) {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var db2 = sp.CreateScope().ServiceProvider.GetService<SpacebarDbContext>();
+ var attachments = db.Attachments
+ // .IgnoreAutoIncludes()
+ .Where(x => x.Message!.AuthorId == userId)
+ .AsAsyncEnumerable();
+ await foreach (var attachment in attachments) {
+ attachment.Message = await db2.Messages.FindAsync(attachment.MessageId);
+ // attachment.Message.Author = await db2.Users.FindAsync(attachment.Message.AuthorId);
+ yield return attachment;
+ }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/PingController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/PingController.cs
new file mode 100644
index 00000000..05b6d876
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Controllers/PingController.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Mvc;
+using Spacebar.AdminApi.Services;
+
+namespace Spacebar.AdminApi.Controllers;
+
+[ApiController]
+[Route("/")]
+public class PingController(ILogger<PingController> logger, IServiceProvider sp, AuthenticationService auth) : ControllerBase {
+ private readonly ILogger<PingController> _logger = logger;
+
+ [HttpGet("ping")]
+ public async Task<object> Ping() {
+ return new {
+ ok = true
+ };
+ }
+
+ [HttpGet("whoami")]
+ public async Task<object> WhoAmI() {
+ var user = await auth.GetCurrentUser(Request);
+ return new {
+ user.Id,
+ user.Username,
+ user.Discriminator,
+ user.Bot,
+ user.Flags,
+ user.Rights,
+ user.MfaEnabled,
+ user.WebauthnEnabled,
+ };
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs
new file mode 100644
index 00000000..505f789e
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs
@@ -0,0 +1,519 @@
+using System.Diagnostics;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using RabbitMQ.Client;
+using Spacebar.AdminApi.Extensions;
+using Spacebar.AdminApi.Models;
+using Spacebar.AdminApi.Services;
+using Spacebar.Db.Contexts;
+using Spacebar.Db.Models;
+using Spacebar.RabbitMqUtilities;
+
+namespace Spacebar.AdminApi.Controllers;
+
+[ApiController]
+[Route("/users")]
+public class UserController(ILogger<UserController> logger, Configuration config, RabbitMQConfiguration amqpConfig, SpacebarDbContext db, RabbitMQService mq, IServiceProvider sp, AuthenticationService auth) : ControllerBase {
+ private readonly ILogger<UserController> _logger = logger;
+
+ [HttpGet]
+ public async IAsyncEnumerable<UserModel> Get() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var results = db.Users.Select(x => new UserModel {
+ Id = x.Id,
+ Username = x.Username,
+ Discriminator = x.Discriminator,
+ Avatar = x.Avatar,
+ AccentColor = x.AccentColor,
+ Banner = x.Banner,
+ ThemeColors = x.ThemeColors,
+ Pronouns = x.Pronouns,
+ Phone = x.Phone,
+ Desktop = x.Desktop,
+ Mobile = x.Mobile,
+ Premium = x.Premium,
+ PremiumType = x.PremiumType,
+ Bot = x.Bot,
+ Bio = x.Bio,
+ System = x.System,
+ NsfwAllowed = x.NsfwAllowed,
+ MfaEnabled = x.MfaEnabled,
+ WebauthnEnabled = x.WebauthnEnabled,
+ CreatedAt = x.CreatedAt,
+ PremiumSince = x.PremiumSince,
+ Verified = x.Verified,
+ Disabled = x.Disabled,
+ Deleted = x.Deleted,
+ Email = x.Email,
+ Flags = x.Flags,
+ PublicFlags = x.PublicFlags,
+ Rights = x.Rights,
+ ApplicationBotUser = x.ApplicationBotUser == null ? null : new(),
+ ConnectedAccounts = new List<UserModel.ConnectedAccountModel>(),
+ MessageCount = x.MessageAuthors.Count, // This property is weirdly named due to scaffolding, might patch later
+ SessionCount = x.Sessions.Count,
+ TemplateCount = x.Templates.Count,
+ VoiceStateCount = x.VoiceStates.Count,
+ GuildCount = x.Guilds.Count,
+ OwnedGuildCount = x.Guilds.Count(g => g.OwnerId == x.Id)
+ }).AsAsyncEnumerable();
+
+ await foreach (var user in results) {
+ yield return user;
+ }
+ }
+
+ [HttpGet("meow")]
+ public async Task Meow() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+ Console.WriteLine("meow");
+
+ ConnectionFactory factory = new ConnectionFactory();
+ factory.Uri = new Uri("amqp://guest:guest@127.0.0.1/");
+ using var connection = await factory.CreateConnectionAsync();
+ using var channel = await connection.CreateChannelAsync();
+
+ // await using var channel = mq.CreateChannel();
+ // var channel2 = await channel.CreateChannelAsync();
+
+ var body =
+ $$"""
+ {
+ "id": "{{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}}",
+ "channel_id": "1322343566206308390",
+ "guild_id": "1322343566084673571",
+ "author": {
+ "username": "test",
+ "discriminator": "9177",
+ "id": "1322329228934500382",
+ "public_flags": 0,
+ "avatar": null,
+ "accent_color": null,
+ "banner": null,
+ "bio": "",
+ "bot": false,
+ "premium_since": "2024-12-27T22:24:15.867Z",
+ "premium_type": 2,
+ "theme_colors": null,
+ "pronouns": null,
+ "badge_ids": null
+ },
+ "member": {
+ "index": 2,
+ "id": "1322329228934500382",
+ "guild_id": "1322343566084673571",
+ "nick": null,
+ "joined_at": "2024-12-27T23:21:14.396Z",
+ "premium_since": null,
+ "deaf": false,
+ "mute": false,
+ "pending": false,
+ "last_message_id": "1322346635635753061",
+ "joined_by": null,
+ "avatar": null,
+ "banner": null,
+ "bio": "",
+ "theme_colors": null,
+ "pronouns": null,
+ "communication_disabled_until": null,
+ "roles": []
+ },
+ "content": "{{Random.Shared.NextInt64()}}",
+ "timestamp": "{{DateTime.UtcNow:O}}",
+ "edited_timestamp": null,
+ "tts": false,
+ "mention_everyone": false,
+ "mentions": [],
+ "mention_roles": [],
+ "attachments": [],
+ "embeds": [],
+ "reactions": [],
+ "nonce": "{{Random.Shared.NextInt64()}}",
+ "pinned": false,
+ "type": 0
+ }
+ """
+ .AsBytes().ToArray();
+
+ await channel.ExchangeDeclareAsync(exchange: "1322343566206308390", type: ExchangeType.Fanout, durable: false);
+ var props = new BasicProperties() { Type = "MESSAGE_CREATE" };
+ await channel.BasicPublishAsync(exchange: "1322343566206308390", routingKey: "", mandatory: true, basicProperties: props, body: body);
+
+ await channel.CloseAsync();
+ await connection.CloseAsync();
+ Console.WriteLine("meowww");
+ }
+
+ [HttpGet("{id}/delete")]
+ public async IAsyncEnumerable<AsyncActionResult> DeleteUser(string id, [FromQuery] int messageDeleteChunkSize = 100) {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var user = await db.Users.FindAsync(id);
+ if (user == null) {
+ Console.WriteLine($"User {id} not found");
+ yield return new AsyncActionResult("ERROR", new { entity = "User", id, message = "User not found" });
+ yield break;
+ }
+
+ user.Data = "{}";
+ user.Deleted = true;
+ user.Disabled = true;
+ user.Rights = 0;
+ db.Users.Update(user);
+ await db.SaveChangesAsync();
+
+ var factory = new ConnectionFactory {
+ Uri = new Uri("amqp://guest:guest@127.0.0.1/")
+ };
+ await using var mqConnection = await factory.CreateConnectionAsync();
+ await using var mqChannel = await mqConnection.CreateChannelAsync();
+
+ var messages = db.Messages
+ .AsNoTracking()
+ .Where(m => m.AuthorId == id);
+ var channels = messages
+ .Select(m => new { m.ChannelId, m.GuildId })
+ .Distinct()
+ .ToList();
+ yield return new("STATS",
+ new {
+ total_messages = messages.Count(), total_channels = channels.Count,
+ messages_per_channel = channels.ToDictionary(c => c.ChannelId, c => messages.Count(m => m.ChannelId == c.ChannelId))
+ });
+ if (messages.Any()) {
+ var results = channels
+ .Select(ctx => DeleteMessagesForChannel(ctx.GuildId, ctx.ChannelId!, id, mqChannel, messageDeleteChunkSize))
+ .ToList();
+ var a = AggregateAsyncEnumerablesWithoutOrder(results);
+ await foreach (var result in a) {
+ yield return result;
+ }
+
+ if (messages.Count() >= 100) {
+ await db.Database.ExecuteSqlRawAsync("VACUUM FULL messages");
+ await db.Database.ExecuteSqlRawAsync("REINDEX TABLE messages");
+ }
+ }
+ }
+
+ private async IAsyncEnumerable<AsyncActionResult> DeleteMessagesForChannel(
+ // context
+ string? guildId, string channelId, string authorId,
+ // connections
+ IChannel mqChannel,
+ // options
+ int messageDeleteChunkSize = 100
+ ) {
+ {
+ await using var ctx = sp.CreateAsyncScope();
+ await using var _db = ctx.ServiceProvider.GetRequiredService<SpacebarDbContext>();
+ await mqChannel.ExchangeDeclareAsync(exchange: channelId!, type: ExchangeType.Fanout, durable: false);
+ var messagesInChannel = _db.Messages.AsNoTracking().Count(m => m.AuthorId == authorId && m.ChannelId == channelId && m.GuildId == guildId);
+ var remaining = messagesInChannel;
+ while (true) {
+ var messageIds = _db.Database.SqlQuery<string>($"""
+ DELETE FROM messages
+ WHERE id IN (
+ SELECT id FROM messages
+ WHERE author_id = {authorId}
+ AND channel_id = {channelId}
+ AND guild_id = {guildId}
+ LIMIT {messageDeleteChunkSize}
+ ) RETURNING id;
+ """).ToList();
+ if (messageIds.Count == 0) {
+ break;
+ }
+
+ var props = new BasicProperties() { Type = "MESSAGE_BULK_DELETE" };
+ var publishSuccess = false;
+ do {
+ try {
+ await mqChannel.BasicPublishAsync(exchange: channelId!, routingKey: "", mandatory: true, basicProperties: props, body: new {
+ ids = messageIds,
+ channel_id = channelId,
+ guild_id = guildId,
+ }.ToJson().AsBytes().ToArray());
+ publishSuccess = true;
+ }
+ catch (Exception e) {
+ Console.WriteLine($"[RabbitMQ] Error publishing bulk delete: {e.Message}");
+ await Task.Delay(10);
+ }
+ } while (!publishSuccess);
+
+ yield return new("BULK_DELETED", new {
+ channel_id = channelId,
+ total = messagesInChannel,
+ deleted = messageIds.Count,
+ remaining = remaining -= messageIds.Count,
+ });
+ await Task.Yield();
+ }
+ }
+ }
+
+ [HttpGet("duplicate")]
+ public async Task<IActionResult> Duplicate() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var msg = db.Messages.First();
+ var channels = db.Channels.Select(x => new { x.Id, x.GuildId }).ToList();
+ int count = 1;
+ while (true) {
+ foreach (var channel in channels) {
+ var newMsg = new Message {
+ Id = $"{Random.Shared.NextInt64()}",
+ ChannelId = channel.Id,
+ GuildId = channel.GuildId,
+ AuthorId = msg.AuthorId,
+ Content = msg.Content,
+ MemberId = msg.MemberId,
+ Timestamp = msg.Timestamp,
+ EditedTimestamp = msg.EditedTimestamp,
+ Tts = msg.Tts,
+ MentionEveryone = msg.MentionEveryone,
+ Attachments = msg.Attachments,
+ Embeds = msg.Embeds,
+ Reactions = msg.Reactions,
+ Nonce = msg.Nonce,
+ PinnedAt = msg.PinnedAt,
+ Type = msg.Type,
+ };
+ db.Messages.Add(newMsg);
+ count++;
+ }
+
+ if (count % 100 == 0) {
+ await db.SaveChangesAsync();
+ await db.Database.ExecuteSqlRawAsync("VACUUM FULL messages");
+ }
+
+ if (count >= 100_000) {
+ await db.SaveChangesAsync();
+ await db.Database.ExecuteSqlRawAsync("VACUUM FULL messages");
+ await db.Database.ExecuteSqlRawAsync("REINDEX TABLE messages");
+ return Ok();
+ }
+ }
+ }
+
+ [HttpGet("duplicate/{id}")]
+ public async Task<IActionResult> DuplicateMessage(ulong id, [FromQuery] int count = 100) {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var msg = await db.Messages.FindAsync(id.ToString());
+ int createdCount = 1;
+ while (true) {
+ var newMsg = new Message {
+ Id = $"{Random.Shared.NextInt64()}",
+ ChannelId = msg.ChannelId,
+ GuildId = msg.GuildId,
+ AuthorId = msg.AuthorId,
+ Content = msg.Content,
+ MemberId = msg.MemberId,
+ Timestamp = msg.Timestamp,
+ EditedTimestamp = msg.EditedTimestamp,
+ Tts = msg.Tts,
+ MentionEveryone = msg.MentionEveryone,
+ Attachments = msg.Attachments,
+ Embeds = msg.Embeds,
+ Reactions = msg.Reactions,
+ Nonce = msg.Nonce,
+ PinnedAt = msg.PinnedAt,
+ Type = msg.Type,
+ };
+ db.Messages.Add(newMsg);
+ createdCount++;
+
+ if (createdCount % 100 == 0) {
+ await db.SaveChangesAsync();
+ }
+
+ if (createdCount >= count) {
+ await db.SaveChangesAsync();
+ await db.Database.ExecuteSqlRawAsync("VACUUM FULL messages");
+ await db.Database.ExecuteSqlRawAsync("REINDEX TABLE messages");
+ return Ok();
+ }
+ }
+
+ await db.SaveChangesAsync();
+ await db.Database.ExecuteSqlRawAsync("VACUUM FULL messages");
+
+ return Ok();
+ }
+
+ [HttpGet("truncate_messages")]
+ public async Task TruncateMessages() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var channels = db.Channels.Select(x => new { x.Id, x.GuildId }).ToList();
+
+ var ss = new SemaphoreSlim(12, 12);
+
+ async Task TruncateChannelMessages(string channelId, string guildId) {
+ await ss.WaitAsync();
+ var tasks = Enumerable.Range(0, 99).Select(i => Task.Run(async () => {
+ await using var scope = sp.CreateAsyncScope();
+ await using var _db = scope.ServiceProvider.GetRequiredService<SpacebarDbContext>();
+ // set timeout
+ _db.Database.SetCommandTimeout(6000);
+ await _db.Database.ExecuteSqlAsync($"""
+ DELETE FROM messages
+ WHERE channel_id = '{channelId}'
+ AND guild_id = '{guildId}'
+ AND id LIKE '%{i:00}';
+ """);
+
+ Console.WriteLine($"Truncated messages for {channelId} in {guildId} ending with {i}");
+ })).ToList();
+ await Task.WhenAll(tasks);
+ ss.Release();
+ }
+
+ var tasks = channels.Select(c => TruncateChannelMessages(c.Id, c.GuildId)).ToList();
+ await Task.WhenAll(tasks);
+ }
+
+ private async IAsyncEnumerable<T> AggregateAsyncEnumerablesWithoutOrder<T>(params IEnumerable<IAsyncEnumerable<T>> enumerables) {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var enumerators = enumerables.Select(e => e.GetAsyncEnumerator()).ToList();
+ var tasks = enumerators.Select(e => e.MoveNextAsync().AsTask()).ToList();
+
+ try {
+ while (tasks.Count > 0) {
+ var completedTask = await Task.WhenAny(tasks);
+ var completedTaskIndex = tasks.IndexOf(completedTask);
+
+ if (completedTask.IsCanceled) {
+ try {
+ await enumerators[completedTaskIndex].DisposeAsync();
+ }
+ catch {
+ // ignored
+ }
+
+ enumerators.RemoveAt(completedTaskIndex);
+ tasks.RemoveAt(completedTaskIndex);
+ continue;
+ }
+
+ if (await completedTask) {
+ var enumerator = enumerators[completedTaskIndex];
+ yield return enumerator.Current;
+ tasks[completedTaskIndex] = enumerator.MoveNextAsync().AsTask();
+ }
+ else {
+ try {
+ await enumerators[completedTaskIndex].DisposeAsync();
+ }
+ catch {
+ // ignored
+ }
+
+ enumerators.RemoveAt(completedTaskIndex);
+ tasks.RemoveAt(completedTaskIndex);
+ }
+ }
+ }
+ finally {
+ foreach (var enumerator in enumerators) {
+ try {
+ await enumerator.DisposeAsync();
+ }
+ catch {
+ // ignored
+ }
+ }
+ }
+ }
+
+ // {
+ // "op": 0,
+ // "t": "GUILD_ROLE_UPDATE",
+ // "d": {
+ // "guild_id": "1006649183970562092",
+ // "role": {
+ // "id": "1006706520514028812",
+ // "guild_id": "1006649183970562092",
+ // "color": 16711680,
+ // "hoist": true,
+ // "managed": false,
+ // "mentionable": true,
+ // "name": "Adminstrator",
+ // "permissions": "9",
+ // "position": 5,
+ // "unicode_emoji": "💖",
+ // "flags": 0
+ // }
+ // },
+ // "s": 38
+ // }
+
+ [HttpGet("test")]
+ public async IAsyncEnumerable<string> Test() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ var factory = new ConnectionFactory {
+ Uri = new Uri(amqpConfig.ToConnectionString())
+ };
+ await using var mqConnection = await factory.CreateConnectionAsync();
+ await using var mqChannel = await mqConnection.CreateChannelAsync();
+
+ var guildId = "1006649183970562092";
+ // var roleId = "1006706520514028812"; //Administrator
+ var roleId = "1391303296148639051"; //Spacebar Maintainer
+ // int color = 16711680; //Administrator
+ int color = 99839; //Spacebar Maintainer
+
+ await mqChannel.ExchangeDeclareAsync(exchange: guildId, type: ExchangeType.Fanout, durable: false);
+
+ var props = new BasicProperties() { Type = "GUILD_ROLE_UPDATE" };
+ int framerate = 30;
+ float delay = 1000f / framerate;
+ var secondsPerRotation = 6.243f;
+ // use delay, 255f = one rotation, lengthFactor = iterations to make a full rotation
+ var lengthFactor = (secondsPerRotation * 1000f / delay);
+ Console.WriteLine("Length factor: {0}, RPS: {1}", lengthFactor, 0);
+ var re = new RainbowEnumerator(lengthFactor: lengthFactor, offset: color, skip: 1);
+ var sw = Stopwatch.StartNew();
+ while (true) {
+ var clr = re.Next();
+ color = clr.r << 16 | clr.g << 8 | clr.b;
+ var publishSuccess = false;
+ do {
+ try {
+ await mqChannel.BasicPublishAsync(exchange: guildId, routingKey: "", mandatory: false, basicProperties: props, body: new {
+ guild_id = guildId,
+ role = new {
+ id = roleId,
+ guild_id = guildId,
+ color,
+ hoist = false,
+ managed = false,
+ mentionable = true,
+ name = "Spacebar Maintainer",
+ permissions = "8",
+ position = 5,
+ unicode_emoji = "",
+ flags = 0
+ }
+ }.ToJson().AsBytes().ToArray());
+ publishSuccess = true;
+ }
+ catch (Exception e) {
+ Console.WriteLine($"[RabbitMQ] Error publishing bulk delete: {e.Message}");
+ await Task.Delay(10);
+ }
+ } while (!publishSuccess);
+ yield return $"{clr.r:X2} {clr.g:X2} {clr.b:X2} | {color:X8} | {sw.Elapsed} (waiting {Math.Max(0, (int)delay - (int)sw.ElapsedMilliseconds)} out of {delay} ms)";
+ await Task.Delay(Math.Max(0, (int)delay - (int)sw.ElapsedMilliseconds));
+ sw.Restart();
+ }
+ }
+}
diff --git a/extra/admin-api/Spacebar.AdminApi/Extensions/DbExtensions.cs b/extra/admin-api/Spacebar.AdminApi/Extensions/DbExtensions.cs
new file mode 100644
index 00000000..1220de91
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Extensions/DbExtensions.cs
@@ -0,0 +1,10 @@
+using Microsoft.EntityFrameworkCore;
+using Spacebar.AdminApi.Models;
+using Spacebar.Db.Models;
+
+namespace Spacebar.AdminApi.Extensions;
+
+public static class DbExtensions {
+ public static string? GetString(this DbSet<Config> config, string key) => config.Find(key)?.Value;
+ public static SpacebarRights.Rights GetRights(this User user) => (SpacebarRights.Rights)user.Rights;
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Middleware/AuthenticationMiddleware.cs b/extra/admin-api/Spacebar.AdminApi/Middleware/AuthenticationMiddleware.cs
new file mode 100644
index 00000000..a5144add
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Middleware/AuthenticationMiddleware.cs
@@ -0,0 +1,79 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Cryptography;
+using Microsoft.IdentityModel.Tokens;
+using Spacebar.AdminApi.Services;
+using Spacebar.Db.Contexts;
+using Spacebar.Db.Models;
+
+namespace Spacebar.AdminApi.Middleware;
+
+public class AuthenticationMiddleware(RequestDelegate next) {
+ private static Dictionary<string, User> _userCache = new();
+ private static Dictionary<string, DateTime> _userCacheExpiry = new();
+
+ public async Task InvokeAsync(HttpContext context, IServiceProvider sp) {
+ var config = sp.GetRequiredService<Configuration>();
+ if (context.Request.Path.StartsWithSegments("/ping") || config.DisableAuthentication) {
+ await next(context);
+ return;
+ }
+
+ if (!context.Request.Headers.ContainsKey("Authorization")) {
+ context.Response.StatusCode = 401;
+ await context.Response.WriteAsync("Authorization header is missing");
+ return;
+ }
+
+ var token = context.Request.Headers["Authorization"].ToString().Split(' ').Last();
+
+ var handler = new JwtSecurityTokenHandler();
+ var secretFile = File.ReadAllText("../../../jwt.key.pub");
+ var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ key.ImportFromPem(secretFile);
+
+ var res = await handler.ValidateTokenAsync(token, new TokenValidationParameters {
+ IssuerSigningKey = new ECDsaSecurityKey(key),
+ ValidAlgorithms = new[] { "ES512" },
+ LogValidationExceptions = true,
+ // These are required to be false for the token to be valid as they aren't provided by the token
+ ValidateIssuer = false,
+ ValidateLifetime = false,
+ ValidateAudience = false,
+ });
+
+ if (!res.IsValid) {
+ context.Response.StatusCode = 401;
+ await context.Response.WriteAsync("Invalid token");
+ return;
+ }
+
+ User user;
+ if (_userCacheExpiry.ContainsKey(token) && _userCacheExpiry[token] < DateTime.Now) {
+ _userCache.Remove(token);
+ _userCacheExpiry.Remove(token);
+ }
+
+ if (!_userCache.ContainsKey(token)) {
+ var db = sp.GetRequiredService<SpacebarDbContext>();
+ user = await db.Users.FindAsync(config.OverrideUid ?? res.ClaimsIdentity.Claims.First(x => x.Type == "id").Value)
+ ?? throw new InvalidOperationException();
+ _userCache[token] = user;
+ _userCacheExpiry[token] = DateTime.Now.AddMinutes(5);
+ }
+
+ user = _userCache[token];
+ if (user.Disabled) {
+ context.Response.StatusCode = 403;
+ await context.Response.WriteAsync("User is disabled");
+ return;
+ }
+
+ if (user.Deleted) {
+ context.Response.StatusCode = 403;
+ await context.Response.WriteAsync("User is deleted");
+ return;
+ }
+
+ await next(context);
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Program.cs b/extra/admin-api/Spacebar.AdminApi/Program.cs
new file mode 100644
index 00000000..f3e977cb
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Program.cs
@@ -0,0 +1,77 @@
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Http.Timeouts;
+using Microsoft.EntityFrameworkCore;
+using Spacebar.AdminApi.Middleware;
+using Spacebar.AdminApi.Services;
+using Spacebar.Db.Contexts;
+using Spacebar.RabbitMqUtilities;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddControllers(options => {
+ options.MaxValidationDepth = null;
+ // options.MaxIAsyncEnumerableBufferLimit = 1;
+}).AddJsonOptions(options => {
+ options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+ options.JsonSerializerOptions.WriteIndented = true;
+ // options.JsonSerializerOptions.DefaultBufferSize = ;
+}).AddMvcOptions(o=> {
+ o.SuppressOutputFormatterBuffering = true;
+});
+
+// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
+builder.Services.AddOpenApi();
+builder.Services.AddDbContextPool<SpacebarDbContext>(options => {
+ options
+ .UseNpgsql(builder.Configuration.GetConnectionString("Spacebar"))
+ .EnableDetailedErrors();
+});
+builder.Services.AddScoped<AuthenticationService>();
+builder.Services.AddScoped<Configuration>();
+builder.Services.AddSingleton<RabbitMQConfiguration>();
+builder.Services.AddSingleton<RabbitMQService>();
+
+builder.Services.AddRequestTimeouts(x => {
+ x.DefaultPolicy = new RequestTimeoutPolicy {
+ Timeout = TimeSpan.FromMinutes(10),
+ WriteTimeoutResponse = async context => {
+ context.Response.StatusCode = 504;
+ context.Response.ContentType = "application/json";
+ await context.Response.StartAsync();
+ await context.Response.WriteAsJsonAsync(new { error = "Unknown error" });
+ await context.Response.CompleteAsync();
+ }
+ };
+});
+// builder.Services.AddCors(options => {
+// options.AddPolicy(
+// "Open",
+// policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
+// });
+
+var app = builder.Build();
+app.Use((context, next) => {
+ context.Response.Headers["Access-Control-Allow-Origin"] = "*";
+ context.Response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
+ context.Response.Headers["Access-Control-Allow-Headers"] = "*, Authorization";
+ if (context.Request.Method == "OPTIONS") {
+ context.Response.StatusCode = 200;
+ return Task.CompletedTask;
+ }
+
+ return next();
+});
+app.UsePathBase("/_spacebar/admin");
+// app.UseCors("Open");
+
+// Configure the HTTP request pipeline.
+app.MapOpenApi();
+
+app.UseMiddleware<AuthenticationMiddleware>();
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Properties/launchSettings.json b/extra/admin-api/Spacebar.AdminApi/Properties/launchSettings.json
new file mode 100644
index 00000000..d362517c
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "Development": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5112",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "Local": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5112",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Local"
+ }
+ }
+ }
+}
diff --git a/extra/admin-api/Spacebar.AdminApi/Services/AuthenticationService.cs b/extra/admin-api/Spacebar.AdminApi/Services/AuthenticationService.cs
new file mode 100644
index 00000000..788c8eb9
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Services/AuthenticationService.cs
@@ -0,0 +1,42 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Cryptography;
+using Microsoft.IdentityModel.Tokens;
+using Spacebar.Db.Contexts;
+using Spacebar.Db.Models;
+
+namespace Spacebar.AdminApi.Services;
+
+public class AuthenticationService(SpacebarDbContext db, Configuration config) {
+ private static Dictionary<string, User> _userCache = new();
+ private static Dictionary<string, DateTime> _userCacheExpiry = new();
+
+ public async Task<User> GetCurrentUser(HttpRequest request) {
+ if (!request.Headers.ContainsKey("Authorization")) {
+ Console.WriteLine(string.Join(", ", request.Headers.Keys));
+ throw new UnauthorizedAccessException();
+ }
+
+ var token = request.Headers["Authorization"].ToString().Split(' ').Last();
+
+ var handler = new JwtSecurityTokenHandler();
+ var secretFile = File.ReadAllText("../../../jwt.key.pub");
+ var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ key.ImportFromPem(secretFile);
+
+ var res = await handler.ValidateTokenAsync(token, new TokenValidationParameters {
+ IssuerSigningKey = new ECDsaSecurityKey(key),
+ ValidAlgorithms = ["ES512"],
+ LogValidationExceptions = true,
+ // These are required to be false for the token to be valid as they aren't provided by the token
+ ValidateIssuer = false,
+ ValidateLifetime = false,
+ ValidateAudience = false,
+ });
+
+ if (!res.IsValid && !config.DisableAuthentication) {
+ throw new UnauthorizedAccessException();
+ }
+
+ return await db.Users.FindAsync(config.OverrideUid ?? res.ClaimsIdentity.Claims.First(x => x.Type == "id").Value) ?? throw new InvalidOperationException();
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Services/Configuration.cs b/extra/admin-api/Spacebar.AdminApi/Services/Configuration.cs
new file mode 100644
index 00000000..f6b2c029
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Services/Configuration.cs
@@ -0,0 +1,11 @@
+namespace Spacebar.AdminApi.Services;
+
+public class Configuration {
+ public Configuration(IConfiguration configuration) {
+ configuration.GetRequiredSection("SpacebarAdminApi").Bind(this);
+ }
+
+ public string? OverrideUid { get; set; }
+ public bool DisableAuthentication { get; set; } = false;
+ public bool Enforce2FA { get; set; } = true;
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj b/extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj
new file mode 100644
index 00000000..950f8a1f
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj
@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20251005-232225" />
+ <PackageReference Include="ArcaneLibs.StringNormalisation" Version="1.0.0-preview.20251005-232225" />
+ <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
+ <PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
+ <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Spacebar.AdminApi.Models\Spacebar.AdminApi.Models.csproj" />
+ <ProjectReference Include="..\Spacebar.ConfigModel\Spacebar.ConfigModel.csproj" />
+ <ProjectReference Include="..\Spacebar.Db\Spacebar.Db.csproj" />
+ <ProjectReference Include="..\Utilities\Spacebar.RabbitMqUtilities\Spacebar.RabbitMqUtilities.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/extra/admin-api/Spacebar.AdminApi/appsettings.Development.json b/extra/admin-api/Spacebar.AdminApi/appsettings.Development.json
new file mode 100644
index 00000000..03245773
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/appsettings.Development.json
@@ -0,0 +1,27 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Trace", //Warning
+ "Microsoft.AspNetCore.Mvc": "Warning", //Warning
+ "Microsoft.AspNetCore.HostFiltering": "Warning", //Warning
+ "Microsoft.AspNetCore.Cors": "Warning", //Warning
+ // "Microsoft.EntityFrameworkCore": "Warning"
+ "Microsoft.EntityFrameworkCore.Database.Command": "Debug"
+ }
+ },
+ "ConnectionStrings": {
+ "Spacebar": "Host=127.0.0.1; Username=postgres; Database=spacebar; Port=5432; Include Error Detail=true; Maximum Pool Size=1000; Command Timeout=6000; Timeout=600;",
+ },
+ "RabbitMQ": {
+ "Host": "127.0.0.1",
+ "Port": 5673,
+ "Username": "guest",
+ "Password": "guest"
+ },
+ "SpacebarAdminApi": {
+ "Enforce2FA": true,
+ "OverrideUid": null,
+ "DisableAuthentication": false
+ }
+}
diff --git a/extra/admin-api/Spacebar.AdminApi/appsettings.json b/extra/admin-api/Spacebar.AdminApi/appsettings.json
new file mode 100644
index 00000000..10f68b8c
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/extra/admin-api/Spacebar.AdminApi/deps.json b/extra/admin-api/Spacebar.AdminApi/deps.json
new file mode 100644
index 00000000..f1ddcb25
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/deps.json
@@ -0,0 +1,447 @@
+[
+ {
+ "pname": "ArcaneLibs",
+ "version": "1.0.0-preview.20251005-232225",
+ "hash": "sha256-EsYLSiyX5Nj+ZpFb6FOcAYqDsQFSbvgm9NKaarJjK/0="
+ },
+ {
+ "pname": "ArcaneLibs.StringNormalisation",
+ "version": "1.0.0-preview.20251005-232225",
+ "hash": "sha256-CdftHRV6idRMojLMgnDRxgSOvfP1DU+9KTKyBGlwoSI="
+ },
+ {
+ "pname": "Humanizer.Core",
+ "version": "2.14.1",
+ "hash": "sha256-EXvojddPu+9JKgOG9NSQgUTfWq1RpOYw7adxDPKDJ6o="
+ },
+ {
+ "pname": "Microsoft.AspNetCore.OpenApi",
+ "version": "10.0.0",
+ "hash": "sha256-mf3kOdkliLF4OD+CrmEhMNNDGOPKWGBOBWtX2EV5YEU="
+ },
+ {
+ "pname": "Microsoft.Build",
+ "version": "17.7.2",
+ "hash": "sha256-k35nFdPxC8t0zAltVSmAJtsepp/ubNIjPOsJ6k8jSqM="
+ },
+ {
+ "pname": "Microsoft.Build.Framework",
+ "version": "17.14.28",
+ "hash": "sha256-7RzEyIipumafwLW1xN1q23114NafG6PT0+RADElNsiM="
+ },
+ {
+ "pname": "Microsoft.Build.Framework",
+ "version": "17.7.2",
+ "hash": "sha256-fNWmVQYFTJDveAGmxEdNqJRAczV6+Ep8RA8clKBJFqw="
+ },
+ {
+ "pname": "Microsoft.Build.Tasks.Core",
+ "version": "17.14.28",
+ "hash": "sha256-M9zRXYijH2HtLlRXbrUK1a1LQ9zkT+DC9ZmMiiVZwv0="
+ },
+ {
+ "pname": "Microsoft.Build.Tasks.Core",
+ "version": "17.7.2",
+ "hash": "sha256-OrV/qWgZHzGlNUmaSfX5wDBcmg1aQeF3/OUHpSH+uZU="
+ },
+ {
+ "pname": "Microsoft.Build.Utilities.Core",
+ "version": "17.14.28",
+ "hash": "sha256-VFfO+UpyTpw2X/qiCCOCYzvMLuu7B+XVSSpJZQLkPzU="
+ },
+ {
+ "pname": "Microsoft.Build.Utilities.Core",
+ "version": "17.7.2",
+ "hash": "sha256-oatF0KfuP1nb4+OLNKg2/R/ZLO4EiACaO5leaxMEY4A="
+ },
+ {
+ "pname": "Microsoft.CodeAnalysis.Analyzers",
+ "version": "3.11.0",
+ "hash": "sha256-hQ2l6E6PO4m7i+ZsfFlEx+93UsLPo4IY3wDkNG11/Sw="
+ },
+ {
+ "pname": "Microsoft.CodeAnalysis.Common",
+ "version": "4.14.0",
+ "hash": "sha256-ne/zxH3GqoGB4OemnE8oJElG5mai+/67ASaKqwmL2BE="
+ },
+ {
+ "pname": "Microsoft.CodeAnalysis.CSharp",
+ "version": "4.14.0",
+ "hash": "sha256-5Mzj3XkYYLkwDWh17r1NEXSbXwwWYQPiOmkSMlgo1JY="
+ },
+ {
+ "pname": "Microsoft.CodeAnalysis.CSharp.Workspaces",
+ "version": "4.14.0",
+ "hash": "sha256-aNbV1a0yYBs0fpQawG6LXcbyoE8en+YFSpV5vcYE4J4="
+ },
+ {
+ "pname": "Microsoft.CodeAnalysis.Workspaces.Common",
+ "version": "4.14.0",
+ "hash": "sha256-0YfeaJe01WBUm9avy4a8FacQJXA1NkpnDpiXu4yz88I="
+ },
+ {
+ "pname": "Microsoft.CodeAnalysis.Workspaces.MSBuild",
+ "version": "4.14.0",
+ "hash": "sha256-5SJfpRqzqCK0UbkmAaJpA/r1XJb0YAriMMeQHYC4d+o="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore",
+ "version": "10.0.0",
+ "hash": "sha256-xfgrlxhtOkQwF5Q7j8gSm41URJiH8IuJ/T/Dh88++hE="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore",
+ "version": "10.0.1",
+ "hash": "sha256-IxA+/nDA6hZkUm9bDT6bRu8+9qeHA3gbTvIhR9Ncg4w="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore.Abstractions",
+ "version": "10.0.0",
+ "hash": "sha256-UDgZbRQcGPaKsE53EH6bvJiv+Q4KSxAbnsVhTVFGG4Q="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore.Abstractions",
+ "version": "10.0.1",
+ "hash": "sha256-FMGXhAAgcVSedV0/GmUVqAwoiRzFJil9mQYr6eNgowg="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore.Analyzers",
+ "version": "10.0.0",
+ "hash": "sha256-7Q0jYJO50cqGI+u6gLpootbB8GZvgsgtg0F9FZI1jig="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore.Analyzers",
+ "version": "10.0.1",
+ "hash": "sha256-O949vr98WoSRXtiIo0ZI6dz2cibIBKbiuC+mQOe9bV4="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore.Design",
+ "version": "10.0.1",
+ "hash": "sha256-GGNZIGNEMhSGaMRFkRN4bOuCUBs5YVnX8klXarm319U="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore.Relational",
+ "version": "10.0.0",
+ "hash": "sha256-vOP2CE5YA551BlpbOuIy6RuAiAEPEpCVS1cEE33/zN4="
+ },
+ {
+ "pname": "Microsoft.EntityFrameworkCore.Relational",
+ "version": "10.0.1",
+ "hash": "sha256-zLgxr/iW9HP8Fip1IDgr7X0Ar8OWKDvVmoEt65gG6VY="
+ },
+ {
+ "pname": "Microsoft.Extensions.Caching.Abstractions",
+ "version": "10.0.1",
+ "hash": "sha256-qVLAEqxPK/dNq+z1a6D9NqdcSg/18sfzZhlBWMkqV/A="
+ },
+ {
+ "pname": "Microsoft.Extensions.Caching.Memory",
+ "version": "10.0.0",
+ "hash": "sha256-AMgDSm1k6q0s17spGtyR5q8nAqUFDOxl/Fe38f9M+d4="
+ },
+ {
+ "pname": "Microsoft.Extensions.Caching.Memory",
+ "version": "10.0.1",
+ "hash": "sha256-Qb7xK6VEZDas0lJFaW1suKdFjtkSYwLHHxkQEfWIU2A="
+ },
+ {
+ "pname": "Microsoft.Extensions.Configuration",
+ "version": "10.0.0",
+ "hash": "sha256-MsLskVPpkCvov5+DWIaALCt1qfRRX4u228eHxvpE0dg="
+ },
+ {
+ "pname": "Microsoft.Extensions.Configuration.Abstractions",
+ "version": "10.0.0",
+ "hash": "sha256-GcgrnTAieCV7AVT13zyOjfwwL86e99iiO/MiMOxPGG0="
+ },
+ {
+ "pname": "Microsoft.Extensions.Configuration.Abstractions",
+ "version": "10.0.1",
+ "hash": "sha256-s4PDp+vtzdxKIxnOT3+dDRoTDopyl8kqmmw4KDnkOtQ="
+ },
+ {
+ "pname": "Microsoft.Extensions.Configuration.Binder",
+ "version": "10.0.0",
+ "hash": "sha256-YSiWoA3VQR22k6+bSEAUqeG7UDzZlJfHWDTubUO5V8U="
+ },
+ {
+ "pname": "Microsoft.Extensions.DependencyInjection",
+ "version": "10.0.1",
+ "hash": "sha256-RKOB+zPrtQNUbJY/1jR54rKOM8KHPgynPExxugku3I8="
+ },
+ {
+ "pname": "Microsoft.Extensions.DependencyInjection",
+ "version": "9.0.0",
+ "hash": "sha256-dAH52PPlTLn7X+1aI/7npdrDzMEFPMXRv4isV1a+14k="
+ },
+ {
+ "pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
+ "version": "10.0.0",
+ "hash": "sha256-9iodXP39YqgxomnOPOxd/mzbG0JfOSXzFoNU3omT2Ps="
+ },
+ {
+ "pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
+ "version": "10.0.1",
+ "hash": "sha256-zNUpau51ds7iQTaSUTFtyTHIUoinYc129W50CnufMdQ="
+ },
+ {
+ "pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
+ "version": "9.0.0",
+ "hash": "sha256-CncVwkKZ5CsIG2O0+OM9qXuYXh3p6UGyueTHSLDVL+c="
+ },
+ {
+ "pname": "Microsoft.Extensions.DependencyModel",
+ "version": "10.0.1",
+ "hash": "sha256-XIj2jEURe25YA4RhBSuCqQpic0YP+TZaO/dbBPCjad8="
+ },
+ {
+ "pname": "Microsoft.Extensions.Logging",
+ "version": "10.0.0",
+ "hash": "sha256-P+zPAadLL63k/GqK34/qChqQjY9aIRxZfxlB9lqsSrs="
+ },
+ {
+ "pname": "Microsoft.Extensions.Logging",
+ "version": "10.0.1",
+ "hash": "sha256-zuLP3SIpCToMOlIPOEv3Kq8y/minecd8k8GSkxFo13E="
+ },
+ {
+ "pname": "Microsoft.Extensions.Logging",
+ "version": "9.0.0",
+ "hash": "sha256-kR16c+N8nQrWeYLajqnXPg7RiXjZMSFLnKLEs4VfjcM="
+ },
+ {
+ "pname": "Microsoft.Extensions.Logging.Abstractions",
+ "version": "10.0.0",
+ "hash": "sha256-BnhgGZc01HwTSxogavq7Ueq4V7iMA3wPnbfRwQ4RhGk="
+ },
+ {
+ "pname": "Microsoft.Extensions.Logging.Abstractions",
+ "version": "10.0.1",
+ "hash": "sha256-NRk0feNE1fgi/hyO0AVDbSGJQRT+9yte6Lpm4Hz/2Bs="
+ },
+ {
+ "pname": "Microsoft.Extensions.Logging.Abstractions",
+ "version": "9.0.0",
+ "hash": "sha256-iBTs9twjWXFeERt4CErkIIcoJZU1jrd1RWCI8V5j7KU="
+ },
+ {
+ "pname": "Microsoft.Extensions.Options",
+ "version": "10.0.1",
+ "hash": "sha256-vBiSS1vqAC7eDrpJNT4H3A9qLikCSAepnNRbry0mKnk="
+ },
+ {
+ "pname": "Microsoft.Extensions.Options",
+ "version": "9.0.0",
+ "hash": "sha256-DT5euAQY/ItB5LPI8WIp6Dnd0lSvBRP35vFkOXC68ck="
+ },
+ {
+ "pname": "Microsoft.Extensions.Primitives",
+ "version": "10.0.0",
+ "hash": "sha256-Dup08KcptLjlnpN5t5//+p4n8FUTgRAq4n/w1s6us+I="
+ },
+ {
+ "pname": "Microsoft.Extensions.Primitives",
+ "version": "10.0.1",
+ "hash": "sha256-EXmukq09erT4s+miQpBSYy3IY4HxxKlwEPL43/KoyEc="
+ },
+ {
+ "pname": "Microsoft.Extensions.Primitives",
+ "version": "9.0.0",
+ "hash": "sha256-ZNLusK1CRuq5BZYZMDqaz04PIKScE2Z7sS2tehU7EJs="
+ },
+ {
+ "pname": "Microsoft.IdentityModel.Abstractions",
+ "version": "8.15.0",
+ "hash": "sha256-LKTvERNUTMCEF7xs377tCMwOMRki93OS4kh6Yv0uXJ4="
+ },
+ {
+ "pname": "Microsoft.IdentityModel.JsonWebTokens",
+ "version": "8.15.0",
+ "hash": "sha256-LwzKiGjcnORvmQ9tim6lomXpfVlPpd/fE8FKTFWKlpM="
+ },
+ {
+ "pname": "Microsoft.IdentityModel.Logging",
+ "version": "8.15.0",
+ "hash": "sha256-mMXwsjGcrrmHR1mG7BLTKg/30mX+m93QVX17/ynOOd4="
+ },
+ {
+ "pname": "Microsoft.IdentityModel.Tokens",
+ "version": "8.15.0",
+ "hash": "sha256-7Lo/TsvqgNCEMyFssO3Om233521Pqgb9K9lUeHc5HMk="
+ },
+ {
+ "pname": "Microsoft.NET.StringTools",
+ "version": "17.14.28",
+ "hash": "sha256-UzREyvDxkiOQ4cEOQ5UCjkwXGrldIDCcbefECTPGjXI="
+ },
+ {
+ "pname": "Microsoft.NET.StringTools",
+ "version": "17.7.2",
+ "hash": "sha256-hQE07TCgcQuyu9ZHVq2gPDb0+xe8ECJUdrgh17bJP4o="
+ },
+ {
+ "pname": "Microsoft.OpenApi",
+ "version": "2.0.0",
+ "hash": "sha256-8eiM3Mx4Hx1etx52RlczoHG2z9XIpWgu2LQtWSt086k="
+ },
+ {
+ "pname": "Mono.TextTemplating",
+ "version": "3.0.0",
+ "hash": "sha256-VlgGDvgNZb7MeBbIZ4DE2Nn/j2aD9k6XqNHnASUSDr0="
+ },
+ {
+ "pname": "Newtonsoft.Json",
+ "version": "13.0.3",
+ "hash": "sha256-hy/BieY4qxBWVVsDqqOPaLy1QobiIapkbrESm6v2PHc="
+ },
+ {
+ "pname": "Npgsql",
+ "version": "10.0.0",
+ "hash": "sha256-UVKz9dH/rVCCbMyFdqA31RYpht1XgDRLIqUy0Dp9ACQ="
+ },
+ {
+ "pname": "Npgsql.EntityFrameworkCore.PostgreSQL",
+ "version": "10.0.0",
+ "hash": "sha256-XIJxnTMektQVP1qtslEIGbmBGrIQsvjQjCMRTs9UIbg="
+ },
+ {
+ "pname": "RabbitMQ.Client",
+ "version": "7.2.0",
+ "hash": "sha256-NkEb2ey0jo/OAeaVOUwIeSbeplqkOsgv1+ys8ZQgemQ="
+ },
+ {
+ "pname": "System.CodeDom",
+ "version": "6.0.0",
+ "hash": "sha256-uPetUFZyHfxjScu5x4agjk9pIhbCkt5rG4Axj25npcQ="
+ },
+ {
+ "pname": "System.CodeDom",
+ "version": "7.0.0",
+ "hash": "sha256-7IPt39cY+0j0ZcRr/J45xPtEjnSXdUJ/5ai3ebaYQiE="
+ },
+ {
+ "pname": "System.CodeDom",
+ "version": "9.0.0",
+ "hash": "sha256-578lcBgswW0eM16r0EnJzfGodPx86RxxFoZHc2PSzsw="
+ },
+ {
+ "pname": "System.Composition",
+ "version": "9.0.0",
+ "hash": "sha256-FehOkQ2u1p8mQ0/wn3cZ+24HjhTLdck8VZYWA1CcgbM="
+ },
+ {
+ "pname": "System.Composition.AttributedModel",
+ "version": "9.0.0",
+ "hash": "sha256-a7y7H6zj+kmYkllNHA402DoVfY9IaqC3Ooys8Vzl24M="
+ },
+ {
+ "pname": "System.Composition.Convention",
+ "version": "9.0.0",
+ "hash": "sha256-tw4vE5JRQ60ubTZBbxoMPhtjOQCC3XoDFUH7NHO7o8U="
+ },
+ {
+ "pname": "System.Composition.Hosting",
+ "version": "9.0.0",
+ "hash": "sha256-oOxU+DPEEfMCuNLgW6wSkZp0JY5gYt44FJNnWt+967s="
+ },
+ {
+ "pname": "System.Composition.Runtime",
+ "version": "9.0.0",
+ "hash": "sha256-AyIe+di1TqwUBbSJ/sJ8Q8tzsnTN+VBdJw4K8xZz43s="
+ },
+ {
+ "pname": "System.Composition.TypedParts",
+ "version": "9.0.0",
+ "hash": "sha256-F5fpTUs3Rr7yP/NyIzr+Xn5NdTXXp8rrjBnF9UBBUog="
+ },
+ {
+ "pname": "System.Configuration.ConfigurationManager",
+ "version": "7.0.0",
+ "hash": "sha256-SgBexTTjRn23uuXvkzO0mz0qOfA23MiS4Wv+qepMLZE="
+ },
+ {
+ "pname": "System.Configuration.ConfigurationManager",
+ "version": "9.0.0",
+ "hash": "sha256-+pLnTC0YDP6Kjw5DVBiFrV/Q3x5is/+6N6vAtjvhVWk="
+ },
+ {
+ "pname": "System.Diagnostics.EventLog",
+ "version": "9.0.0",
+ "hash": "sha256-tPvt6yoAp56sK/fe+/ei8M65eavY2UUhRnbrREj/Ems="
+ },
+ {
+ "pname": "System.Formats.Nrbf",
+ "version": "9.0.0",
+ "hash": "sha256-c4qf6CocQUZB0ySGQd8s15PXY7xfrjQqMGXxkwytKyw="
+ },
+ {
+ "pname": "System.IdentityModel.Tokens.Jwt",
+ "version": "8.15.0",
+ "hash": "sha256-5O0wbGp0gWnukK+0mWBjMnP1bZc6N0xuNcO2qmFiUX8="
+ },
+ {
+ "pname": "System.Reflection.MetadataLoadContext",
+ "version": "7.0.0",
+ "hash": "sha256-VYl6SFD130K9Aw4eJH16ApJ9Sau4Xu0dcxEip2veuTI="
+ },
+ {
+ "pname": "System.Resources.Extensions",
+ "version": "9.0.0",
+ "hash": "sha256-y2gLEMuAy6QfEyNJxABC/ayMWGnwlpX735jsUQLktho="
+ },
+ {
+ "pname": "System.Security.Cryptography.Pkcs",
+ "version": "7.0.0",
+ "hash": "sha256-3J3vL9hcKSuZjT2GKappa2A9p2xJm1nH2asTNAl8ZCA="
+ },
+ {
+ "pname": "System.Security.Cryptography.Pkcs",
+ "version": "7.0.2",
+ "hash": "sha256-qS5Z/Yo8J+f3ExVX5Qkcpj1Z57oUZqz5rWa1h5bVpl8="
+ },
+ {
+ "pname": "System.Security.Cryptography.Pkcs",
+ "version": "9.0.0",
+ "hash": "sha256-AjG14mGeSc2Ka4QSelGBM1LrGBW3VJX60lnihKyJjGY="
+ },
+ {
+ "pname": "System.Security.Cryptography.ProtectedData",
+ "version": "9.0.0",
+ "hash": "sha256-gPgPU7k/InTqmXoRzQfUMEKL3QuTnOKowFqmXTnWaBQ="
+ },
+ {
+ "pname": "System.Security.Cryptography.Xml",
+ "version": "7.0.1",
+ "hash": "sha256-CH8+JVC8LyCSW75/6ZQ7ecMbSOAE1c16z4dG8JTp01w="
+ },
+ {
+ "pname": "System.Security.Cryptography.Xml",
+ "version": "9.0.0",
+ "hash": "sha256-SQJWwAFrJUddEU6JiZB52FM9tGjRlJAYH8oYVzG5IJU="
+ },
+ {
+ "pname": "System.Security.Permissions",
+ "version": "7.0.0",
+ "hash": "sha256-DOFoX+AKRmrkllykHheR8FfUXYx/Ph+I/HYuReQydXI="
+ },
+ {
+ "pname": "System.Security.Permissions",
+ "version": "9.0.0",
+ "hash": "sha256-BFrA9ottmQtLIAiKiGRbfSUpzNJwuaOCeFRDN4Z0ku0="
+ },
+ {
+ "pname": "System.Threading.RateLimiting",
+ "version": "8.0.0",
+ "hash": "sha256-KOEWEt6ZthvZHJ2Wp70d9nBhBrPqobGQDi2twlKYh/w="
+ },
+ {
+ "pname": "System.Windows.Extensions",
+ "version": "9.0.0",
+ "hash": "sha256-RErD+Ju15qtnwdwB7E0SjjJGAnhXwJyC7UPcl24Z3Vs="
+ },
+ {
+ "pname": "Unidecode.NET",
+ "version": "2.1.0",
+ "hash": "sha256-nfxHxG/YwRftOP+wiSW8NL5hIVwt+wnfRb0lURjrZoc="
+ }
+]
|