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 000000000..964656169
--- /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 000000000..05bd604aa
--- /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 000000000..f3415731b
--- /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 000000000..05b6d8760
--- /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 000000000..505f789e1
--- /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();
+ }
+ }
+}
|