Admin api changes

This commit is contained in:
Rory&
2025-10-14 09:07:55 +02:00
parent 05285114cd
commit 58d92080d5
28 changed files with 1276 additions and 33 deletions
@@ -0,0 +1,316 @@
using System.Diagnostics;
using ArcaneLibs;
using ArcaneLibs.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RabbitMQ.Client;
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 authService) : ControllerBase {
private readonly ILogger<GuildController> _logger = logger;
[HttpGet]
public IAsyncEnumerable<GuildModel> Get() {
return 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();
}
[HttpPost("{id}/force_join")]
public async Task<IActionResult> ForceJoinGuild([FromBody] ForceJoinRequest request, string id) {
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 authService.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) {
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
// }
}
@@ -13,7 +13,7 @@ using Spacebar.RabbitMqUtilities;
namespace Spacebar.AdminAPI.Controllers;
[ApiController]
[Route("/Users")]
[Route("/users")]
public class UserController(ILogger<UserController> logger, Configuration config, RabbitMQConfiguration amqpConfig, SpacebarDbContext db, RabbitMQService mq, IServiceProvider sp) : ControllerBase {
private readonly ILogger<UserController> _logger = logger;
+18 -9
View File
@@ -45,20 +45,29 @@ builder.Services.AddRequestTimeouts(x => {
}
};
});
builder.Services.AddCors(options => {
options.AddPolicy(
"Open",
policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
});
// 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");
// app.UseCors("Open");
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) {
app.MapOpenApi();
}
app.MapOpenApi();
app.UseMiddleware<AuthenticationMiddleware>();
app.UseAuthorization();
@@ -13,6 +13,7 @@ public class AuthenticationService(SpacebarDbContext db, Configuration config) {
public async Task<User> GetCurrentUser(HttpRequest request) {
if (!request.Headers.ContainsKey("Authorization")) {
Console.WriteLine(string.Join(", ", request.Headers.Keys));
throw new UnauthorizedAccessException();
}
@@ -25,7 +26,7 @@ public class AuthenticationService(SpacebarDbContext db, Configuration config) {
var res = await handler.ValidateTokenAsync(token, new TokenValidationParameters {
IssuerSigningKey = new ECDsaSecurityKey(key),
ValidAlgorithms = new[] { "ES512" },
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,
@@ -33,7 +34,7 @@ public class AuthenticationService(SpacebarDbContext db, Configuration config) {
ValidateAudience = false,
});
if (!res.IsValid) {
if (!res.IsValid && !config.DisableAuthentication) {
throw new UnauthorizedAccessException();
}
@@ -7,11 +7,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20241210-161342" />
<PackageReference Include="ArcaneLibs.StringNormalisation" Version="1.0.0-preview.20241210-161342" />
<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="9.0.0"/>
<PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.1.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
@@ -2,10 +2,26 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"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"
"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
}
}