Newer
Older
percord / extra / admin-api / Spacebar.AdminApi / Controllers / UserController.cs
using System.Diagnostics;
using ArcaneLibs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Spacebar.AdminApi.Extensions;
using Spacebar.Interop.Authentication;
using Spacebar.Interop.Authentication.AspNetCore;
using Spacebar.Interop.Replication.Abstractions;
using Spacebar.Models.AdminApi;
using Spacebar.Models.Db.Contexts;
using Spacebar.Models.Db.Models;

namespace Spacebar.AdminApi.Controllers;

[ApiController]
[Route("/users")]
public class UserController(
    ILogger<UserController> logger,
    SpacebarAuthenticationConfiguration config,
    SpacebarDbContext db,
    IServiceProvider sp,
    SpacebarAspNetAuthenticationService auth,
    ISpacebarReplication replication
) : ControllerBase {
    [HttpGet]
    public async IAsyncEnumerable<UserModel> Get() {
        (await auth.GetCurrentUserAsync(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);

        var results = db.Users
            .Include(user => user.ApplicationBotUser)
            .Include(user => user.MessageAuthors)
            .Include(user => user.Sessions)
            .Include(user => user.Templates)
            .Include(user => user.VoiceStates)
            .Include(user => user.Guilds)
            .AsAsyncEnumerable().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)
            });

        await foreach (var user in results) {
            yield return user;
        }
    }

    [HttpGet("{id}/delete")]
    public async IAsyncEnumerable<AsyncActionResult> DeleteUser(string id, [FromQuery] int messageDeleteChunkSize = 100) {
        (await auth.GetCurrentUserAsync(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 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, 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,
        // options
        int messageDeleteChunkSize = 100
    ) {
        {
            await using var ctx = sp.CreateAsyncScope();
            await using var _db = ctx.ServiceProvider.GetRequiredService<SpacebarDbContext>();
            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;
                }

                await replication.SendAsync(new() {
                    Event = "MESSAGE_BULK_DELETE",
                    ChannelId = channelId,
                    Payload = new {
                        channel_id = channelId,
                        guild_id = guildId,
                        ids = messageIds,
                    },
                    Origin = "AdminApi/DeleteMessagesForChannel"
                });

                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.GetCurrentUserAsync(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.GetCurrentUserAsync(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.GetCurrentUserAsync(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.GetCurrentUserAsync(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.GetCurrentUserAsync(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);

        var guildId = "1006649183970562092";
        // var roleId = "1006706520514028812"; //Administrator
        var roleId = "1391303296148639051"; //Spacebar Maintainer
        // int color = 16711680; //Administrator
        int color = 99839; //Spacebar Maintainer

        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;
            await replication.SendAsync(new() {
                Event = "GUILD_ROLE_UPDATE",
                GuildId = guildId,
                Origin = "Admin API (GET /users/test)",
                Payload = 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
                    }
                }
            });

            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();
        }
    }
}