summary refs log tree commit diff
path: root/extra/admin-api/Spacebar.AdminApi
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2025-12-14 23:18:35 +0100
committerRory& <root@rory.gay>2025-12-14 23:18:35 +0100
commit4534d4dbf5f5071fa71c5dec4444bf4b2bbd040c (patch)
tree614304bd951f09a73155a9bd9ee192cf946e854a /extra/admin-api/Spacebar.AdminApi
parentNo longer generate security_jwtSecret, switch webauthn to modern tokens, dont (diff)
downloadserver-ts-4534d4dbf5f5071fa71c5dec4444bf4b2bbd040c.tar.xz
admin api packaging
Diffstat (limited to 'extra/admin-api/Spacebar.AdminApi')
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Controllers/ConfigController.cs61
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs323
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Controllers/Media/UserMediaController.cs30
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Controllers/PingController.cs32
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs519
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Extensions/DbExtensions.cs10
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Middleware/AuthenticationMiddleware.cs79
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Program.cs77
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Properties/launchSettings.json23
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Services/AuthenticationService.cs42
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Services/Configuration.cs11
-rw-r--r--extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj24
-rw-r--r--extra/admin-api/Spacebar.AdminApi/appsettings.Development.json27
-rw-r--r--extra/admin-api/Spacebar.AdminApi/appsettings.json9
-rw-r--r--extra/admin-api/Spacebar.AdminApi/deps.json447
15 files changed, 1714 insertions, 0 deletions
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=" + } +]