feat: start dashboard

This commit is contained in:
sam 2024-10-18 22:13:23 +02:00
parent bacbc6db0e
commit ec7aa9faba
Signed by: sam
GPG key ID: 5F3C3C1B3166639D
50 changed files with 3624 additions and 18 deletions

View file

@ -0,0 +1,40 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Database.Redis;
namespace Catalogger.Backend.Api;
public class ApiCache(RedisService redisService)
{
private static string UserKey(string id) => $"api-user:{id}";
private static string GuildsKey(string userId) => $"api-user-guilds:{userId}";
public async Task<User?> GetUserAsync(string id) =>
await redisService.GetAsync<User>(UserKey(id));
public async Task SetUserAsync(User user) =>
await redisService.SetAsync(UserKey(user.Id), user, expiry: TimeSpan.FromHours(1));
public async Task ExpireUserAsync(string id) =>
await redisService.GetDatabase().KeyDeleteAsync([UserKey(id), GuildsKey(id)]);
public async Task<List<Guild>?> GetGuildsAsync(string userId) =>
await redisService.GetAsync<List<Guild>>(GuildsKey(userId));
public async Task SetGuildsAsync(string userId, List<Guild> guilds) =>
await redisService.SetAsync(GuildsKey(userId), guilds, expiry: TimeSpan.FromHours(1));
}

View file

@ -0,0 +1,27 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Database.Models;
using Microsoft.AspNetCore.Mvc;
namespace Catalogger.Backend.Api;
[ApiController]
[Authenticate]
public class ApiControllerBase : ControllerBase
{
public ApiToken CurrentToken => HttpContext.GetTokenOrThrow();
}

View file

@ -0,0 +1,28 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Catalogger.Backend.Api;
public record ApiUser(string Id, string Tag, string AvatarUrl)
{
public ApiUser(User baseUser)
: this(baseUser.Id, baseUser.Tag, baseUser.AvatarUrl) { }
}
public record ApiGuild(string Id, string Name, string IconUrl, bool BotInGuild)
{
public ApiGuild(Guild baseGuild, bool botInGuild)
: this(baseGuild.Id, baseGuild.Name, baseGuild.IconUrl, botInGuild) { }
}

View file

@ -0,0 +1,28 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Security.Cryptography;
namespace Catalogger.Backend.Api;
public static class ApiUtils
{
public static string RandomToken(int bytes = 48) =>
Convert
.ToBase64String(RandomNumberGenerator.GetBytes(bytes))
.Trim('=')
.Replace('+', '-')
.Replace('/', '_');
}

View file

@ -0,0 +1,85 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Web;
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database.Redis;
using Microsoft.AspNetCore.Mvc;
namespace Catalogger.Backend.Api;
[Route("/api")]
public class AuthController(
Config config,
RedisService redisService,
GuildCache guildCache,
ApiCache apiCache,
DiscordRequestService discordRequestService
) : ApiControllerBase
{
private static string StateKey(string state) => $"state:{state}";
[HttpGet("authorize")]
public async Task<IActionResult> GenerateAuthUrlAsync()
{
var state = ApiUtils.RandomToken();
await redisService.SetStringAsync(StateKey(state), state, TimeSpan.FromMinutes(30));
var url =
$"https://discord.com/oauth2/authorize?response_type=code"
+ $"&client_id={config.Discord.ApplicationId}&scope=identify+guilds"
+ $"&prompt=none&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.Web.BaseUrl}/callback")}";
return Redirect(url);
}
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req)
{
var redisState = await redisService.GetStringAsync(StateKey(req.State), delete: true);
if (redisState != req.State)
throw new ApiError(
HttpStatusCode.BadRequest,
ErrorCode.BadRequest,
"Invalid OAuth state"
);
var (token, user, guilds) = await discordRequestService.RequestDiscordTokenAsync(req.Code);
await apiCache.SetUserAsync(user);
await apiCache.SetGuildsAsync(user.Id, guilds);
return Ok(
new CallbackResponse(
new ApiUser(user),
guilds
.Where(g => g.CanManage)
.Select(g => new ApiGuild(g, guildCache.Contains(g.Id))),
token.DashboardToken
)
);
}
public record CallbackRequest(string Code, string State);
private record CallbackResponse(ApiUser User, IEnumerable<ApiGuild> Guilds, string Token);
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
private record DiscordTokenResponse(string AccessToken, string? RefreshToken, int ExpiresIn);
}

View file

@ -0,0 +1,264 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Models;
using NodaTime;
namespace Catalogger.Backend.Api;
public class DiscordRequestService
{
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
private readonly ApiCache _apiCache;
private readonly Config _config;
private readonly IClock _clock;
private readonly DatabaseContext _db;
private static readonly JsonSerializerOptions JsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
public DiscordRequestService(
ILogger logger,
ApiCache apiCache,
Config config,
IClock clock,
DatabaseContext db
)
{
_logger = logger.ForContext<DiscordRequestService>();
_apiCache = apiCache;
_config = config;
_clock = clock;
_db = db;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add(
"User-Agent",
"DiscordBot (https://codeberg.org/starshine/catalogger, v1)"
);
_httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
}
private async Task<T> GetAsync<T>(Uri uri, string? token)
{
_logger.Information(
"Sending request to {Uri}, authenticated? {Authed}",
uri,
token != null
);
var req = new HttpRequestMessage(HttpMethod.Get, uri);
if (token != null)
req.Headers.Add("Authorization", token);
var resp = await _httpClient.SendAsync(req);
if (!resp.IsSuccessStatusCode)
{
var errorText = await resp.Content.ReadAsStringAsync();
_logger.Error("Error requesting {Uri} from Discord API: {Error}", uri, errorText);
}
resp.EnsureSuccessStatusCode();
var entity = await resp.Content.ReadFromJsonAsync<T>(JsonOptions);
if (entity == null)
throw new CataloggerError("Could not deserialize JSON from Discord API");
return entity;
}
private static readonly Uri DiscordUserUri = new("https://discord.com/api/v10/users/@me");
private static readonly Uri DiscordGuildsUri =
new("https://discord.com/api/v10/users/@me/guilds");
private static readonly Uri DiscordTokenUri = new("https://discord.com/api/oauth2/token");
public async Task<User> GetMeAsync(string token) => await GetAsync<User>(DiscordUserUri, token);
public async Task<List<Guild>> GetGuildsAsync(string token) =>
await GetAsync<List<Guild>>(DiscordGuildsUri, token);
public async Task<User> GetMeAsync(ApiToken token)
{
var user = await _apiCache.GetUserAsync(token.UserId);
if (user != null)
return user;
await MaybeRefreshDiscordTokenAsync(token);
user = await GetMeAsync($"Bearer {token.AccessToken}");
await _apiCache.SetUserAsync(user);
return user;
}
public async Task<List<Guild>> GetGuildsAsync(ApiToken token)
{
var guilds = await _apiCache.GetGuildsAsync(token.UserId);
if (guilds != null)
return guilds;
await MaybeRefreshDiscordTokenAsync(token);
guilds = await GetGuildsAsync($"Bearer {token.AccessToken}");
await _apiCache.SetGuildsAsync(token.UserId, guilds);
return guilds;
}
public async Task<(ApiToken Token, User MeUser, List<Guild> Guilds)> RequestDiscordTokenAsync(
string code,
CancellationToken ct = default
)
{
var redirectUri = $"{_config.Web.BaseUrl}/callback";
var resp = await _httpClient.PostAsync(
DiscordTokenUri,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "client_id", _config.Discord.ApplicationId.ToString() },
{ "client_secret", _config.Discord.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", redirectUri },
}
),
ct
);
if (!resp.IsSuccessStatusCode)
{
var respBody = await resp.Content.ReadAsStringAsync(ct);
_logger.Error(
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
(int)resp.StatusCode,
respBody
);
throw new CataloggerError("Invalid Discord OAuth response");
}
var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(JsonOptions, ct);
if (token == null)
throw new CataloggerError("Discord token response was null");
var meUser = await GetMeAsync($"Bearer {token.AccessToken}");
var meGuilds = await GetGuildsAsync($"Bearer {token.AccessToken}");
var apiToken = new ApiToken
{
DashboardToken = ApiUtils.RandomToken(64),
UserId = meUser.Id,
AccessToken = token.AccessToken,
RefreshToken = token.RefreshToken,
ExpiresAt = _clock.GetCurrentInstant() + Duration.FromSeconds(token.ExpiresIn),
};
_db.Add(apiToken);
await _db.SaveChangesAsync(ct);
return (apiToken, meUser, meGuilds);
}
public async Task MaybeRefreshDiscordTokenAsync(ApiToken token)
{
if (_clock.GetCurrentInstant() < token.ExpiresAt - Duration.FromDays(1))
{
_logger.Debug(
"Discord token {TokenId} expires at {ExpiresAt}, not refreshing",
token.Id,
token.ExpiresAt
);
return;
}
if (token.RefreshToken == null)
{
_logger.Warning(
"Discord token {TokenId} for user {UserId} is almost expired but has no refresh token, cannot refresh",
token.Id,
token.UserId
);
return;
}
var resp = await _httpClient.PostAsync(
DiscordTokenUri,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "client_id", _config.Discord.ApplicationId.ToString() },
{ "client_secret", _config.Discord.ClientSecret },
{ "grant_type", "refresh_token" },
{ "refresh_token", token.RefreshToken },
}
)
);
if (!resp.IsSuccessStatusCode)
{
var respBody = await resp.Content.ReadAsStringAsync();
_logger.Error(
"Received error status {StatusCode} when refreshing OAuth token: {ErrorBody}",
(int)resp.StatusCode,
respBody
);
return;
}
var discordToken = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(JsonOptions);
if (discordToken == null)
{
_logger.Warning("Discord response for refreshing {TokenId} was null", token.Id);
return;
}
_logger.Information(
"Updating token {TokenId} with new access token and refresh token, expiring in {ExpiresIn}",
token.Id,
Duration.FromSeconds(discordToken.ExpiresIn)
);
token.AccessToken = discordToken.AccessToken;
token.RefreshToken = discordToken.RefreshToken;
token.ExpiresAt = _clock.GetCurrentInstant() + Duration.FromSeconds(discordToken.ExpiresIn);
_db.Update(token);
await _db.SaveChangesAsync();
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
private record DiscordTokenResponse(string AccessToken, string? RefreshToken, int ExpiresIn);
}
public record User(string Id, string Username, string Discriminator, string? Avatar)
{
public string Tag => Discriminator != "0" ? $"{Username}#{Discriminator}" : Username;
public string AvatarUrl =>
Avatar == null
? "https://cdn.discordapp.com/embed/avatars/0.png?size=256"
: $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.webp?size=256";
}
public record Guild(string Id, string Name, string? Icon, string? Permissions)
{
public string IconUrl =>
Icon == null
? "https://cdn.discordapp.com/embed/avatars/0.png?size=256"
: $"https://cdn.discordapp.com/icons/{Id}/{Icon}.webp?size=256";
public bool CanManage =>
ulong.TryParse(Permissions, out var perms)
&& ((perms & Administrator) == Administrator || (perms & ManageGuild) == ManageGuild);
private const ulong Administrator = 1 << 3;
private const ulong ManageGuild = 1 << 5;
}

View file

@ -0,0 +1,322 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries;
using Microsoft.AspNetCore.Mvc;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Api;
[Route("/api/guilds/{id}")]
public class GuildsController(
Config config,
DatabaseContext db,
ChannelCache channelCache,
DiscordRequestService discordRequestService
) : ApiControllerBase
{
public IActionResult AddGuild(ulong id) =>
Redirect(
$"https://discord.com/oauth2/authorize?client_id={config.Discord.ApplicationId}"
+ "&permissions=537250993&scope=bot%20applications.commands"
+ $"&guild_id={id}"
);
private async Task<(Snowflake GuildId, Guild Guild)> ParseGuildAsync(string id)
{
var guilds = await discordRequestService.GetGuildsAsync(CurrentToken);
var guild = guilds.FirstOrDefault(g => g.CanManage && g.Id == id);
if (guild == null)
throw new ApiError(
HttpStatusCode.NotFound,
ErrorCode.UnknownGuild,
"Unknown server, or you're not in it, or you can't manage it."
);
if (!DiscordSnowflake.TryParse(guild.Id, out var guildId))
throw new CataloggerError("Invalid snowflake passed to GuildsController.ToResponse");
return (guildId.Value, guild);
}
[Authorize]
[HttpGet]
public async Task<IActionResult> GetGuildAsync(string id)
{
var (guildId, guild) = await ParseGuildAsync(id);
var guildConfig = await db.GetGuildAsync(guildId.Value);
var channels = channelCache
.GuildChannels(guildId)
.OrderBy(c => c.Position.OrDefault(0))
.ToList();
var channelsWithoutCategories = channels
.Where(c => !c.ParentID.IsDefined() && c.Type is not ChannelType.GuildCategory)
.Select(ToChannel);
var categories = channels
.Where(c => c.Type is ChannelType.GuildCategory)
.Select(c => new GuildCategory(
c.ID.ToString(),
c.Name.Value!,
channels
.Where(c2 => c2.ParentID.IsDefined(out var parentId) && parentId == c.ID)
.Select(ToChannel)
));
return Ok(
new GuildResponse(
guild.Id,
guild.Name,
guild.IconUrl,
categories,
channelsWithoutCategories,
guildConfig.Channels
)
);
}
private static GuildChannel ToChannel(IChannel channel) =>
new(
channel.ID.ToString(),
channel.Name.Value!,
channel.Type is ChannelType.GuildText,
channel.Type
is ChannelType.GuildText
or ChannelType.GuildAnnouncement
or ChannelType.GuildForum
or ChannelType.GuildMedia
or ChannelType.GuildVoice
);
private record GuildResponse(
string Id,
string Name,
string IconUrl,
IEnumerable<GuildCategory> Categories,
IEnumerable<GuildChannel> ChannelsWithoutCategory,
Database.Models.Guild.ChannelConfig Config
);
private record GuildCategory(string Id, string Name, IEnumerable<GuildChannel> Channels);
private record GuildChannel(string Id, string Name, bool CanLogTo, bool CanRedirectFrom);
[Authorize]
[HttpPatch]
[ProducesResponseType<Database.Models.Guild.ChannelConfig>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> PatchGuildAsync(string id, [FromBody] ChannelRequest req)
{
var (guildId, guild) = await ParseGuildAsync(id);
var guildChannels = channelCache
.GuildChannels(guildId)
.Where(c => c.Type is ChannelType.GuildText)
.ToList();
var guildConfig = await db.GetGuildAsync(guildId);
// i love repeating myself wheeeeee
if (
req.GuildUpdate != null
&& (req.GuildUpdate == 0 || guildChannels.Any(c => c.ID.Value == req.GuildUpdate))
)
guildConfig.Channels.GuildUpdate = req.GuildUpdate.Value;
if (
req.GuildEmojisUpdate != null
&& (
req.GuildEmojisUpdate == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildEmojisUpdate)
)
)
guildConfig.Channels.GuildEmojisUpdate = req.GuildEmojisUpdate.Value;
if (
req.GuildRoleCreate != null
&& (
req.GuildRoleCreate == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildRoleCreate)
)
)
guildConfig.Channels.GuildRoleCreate = req.GuildRoleCreate.Value;
if (
req.GuildRoleUpdate != null
&& (
req.GuildRoleUpdate == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildRoleUpdate)
)
)
guildConfig.Channels.GuildRoleUpdate = req.GuildRoleUpdate.Value;
if (
req.GuildRoleDelete != null
&& (
req.GuildRoleDelete == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildRoleDelete)
)
)
guildConfig.Channels.GuildRoleDelete = req.GuildRoleDelete.Value;
if (
req.ChannelCreate != null
&& (req.ChannelCreate == 0 || guildChannels.Any(c => c.ID.Value == req.ChannelCreate))
)
guildConfig.Channels.ChannelCreate = req.ChannelCreate.Value;
if (
req.ChannelUpdate != null
&& (req.ChannelUpdate == 0 || guildChannels.Any(c => c.ID.Value == req.ChannelUpdate))
)
guildConfig.Channels.ChannelUpdate = req.ChannelUpdate.Value;
if (
req.ChannelDelete != null
&& (req.ChannelDelete == 0 || guildChannels.Any(c => c.ID.Value == req.ChannelDelete))
)
guildConfig.Channels.ChannelDelete = req.ChannelDelete.Value;
if (
req.GuildMemberAdd != null
&& (req.GuildMemberAdd == 0 || guildChannels.Any(c => c.ID.Value == req.GuildMemberAdd))
)
guildConfig.Channels.GuildMemberAdd = req.GuildMemberAdd.Value;
if (
req.GuildMemberUpdate != null
&& (
req.GuildMemberUpdate == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildMemberUpdate)
)
)
guildConfig.Channels.GuildMemberUpdate = req.GuildMemberUpdate.Value;
if (
req.GuildKeyRoleUpdate != null
&& (
req.GuildKeyRoleUpdate == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildKeyRoleUpdate)
)
)
guildConfig.Channels.GuildKeyRoleUpdate = req.GuildKeyRoleUpdate.Value;
if (
req.GuildMemberNickUpdate != null
&& (
req.GuildMemberNickUpdate == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildMemberNickUpdate)
)
)
guildConfig.Channels.GuildMemberNickUpdate = req.GuildMemberNickUpdate.Value;
if (
req.GuildMemberAvatarUpdate != null
&& (
req.GuildMemberAvatarUpdate == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildMemberAvatarUpdate)
)
)
guildConfig.Channels.GuildMemberAvatarUpdate = req.GuildMemberAvatarUpdate.Value;
if (
req.GuildMemberTimeout != null
&& (
req.GuildMemberTimeout == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildMemberTimeout)
)
)
guildConfig.Channels.GuildMemberTimeout = req.GuildMemberTimeout.Value;
if (
req.GuildMemberRemove != null
&& (
req.GuildMemberRemove == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildMemberRemove)
)
)
guildConfig.Channels.GuildMemberRemove = req.GuildMemberRemove.Value;
if (
req.GuildMemberKick != null
&& (
req.GuildMemberKick == 0
|| guildChannels.Any(c => c.ID.Value == req.GuildMemberKick)
)
)
guildConfig.Channels.GuildMemberKick = req.GuildMemberKick.Value;
if (
req.GuildBanAdd != null
&& (req.GuildBanAdd == 0 || guildChannels.Any(c => c.ID.Value == req.GuildBanAdd))
)
guildConfig.Channels.GuildBanAdd = req.GuildBanAdd.Value;
if (
req.GuildBanRemove != null
&& (req.GuildBanRemove == 0 || guildChannels.Any(c => c.ID.Value == req.GuildBanRemove))
)
guildConfig.Channels.GuildBanRemove = req.GuildBanRemove.Value;
if (
req.InviteCreate != null
&& (req.InviteCreate == 0 || guildChannels.Any(c => c.ID.Value == req.InviteCreate))
)
guildConfig.Channels.InviteCreate = req.InviteCreate.Value;
if (
req.InviteDelete != null
&& (req.InviteDelete == 0 || guildChannels.Any(c => c.ID.Value == req.InviteDelete))
)
guildConfig.Channels.InviteDelete = req.InviteDelete.Value;
if (
req.MessageUpdate != null
&& (req.MessageUpdate == 0 || guildChannels.Any(c => c.ID.Value == req.MessageUpdate))
)
guildConfig.Channels.MessageUpdate = req.MessageUpdate.Value;
if (
req.MessageDelete != null
&& (req.MessageDelete == 0 || guildChannels.Any(c => c.ID.Value == req.MessageDelete))
)
guildConfig.Channels.MessageDelete = req.MessageDelete.Value;
if (
req.MessageDeleteBulk != null
&& (
req.MessageDeleteBulk == 0
|| guildChannels.Any(c => c.ID.Value == req.MessageDeleteBulk)
)
)
guildConfig.Channels.MessageDeleteBulk = req.MessageDeleteBulk.Value;
db.Update(guildConfig);
await db.SaveChangesAsync();
return Ok(guildConfig.Channels);
}
public record ChannelRequest(
ulong? GuildUpdate = null,
ulong? GuildEmojisUpdate = null,
ulong? GuildRoleCreate = null,
ulong? GuildRoleUpdate = null,
ulong? GuildRoleDelete = null,
ulong? ChannelCreate = null,
ulong? ChannelUpdate = null,
ulong? ChannelDelete = null,
ulong? GuildMemberAdd = null,
ulong? GuildMemberUpdate = null,
ulong? GuildKeyRoleUpdate = null,
ulong? GuildMemberNickUpdate = null,
ulong? GuildMemberAvatarUpdate = null,
ulong? GuildMemberTimeout = null,
ulong? GuildMemberRemove = null,
ulong? GuildMemberKick = null,
ulong? GuildBanAdd = null,
ulong? GuildBanRemove = null,
ulong? InviteCreate = null,
ulong? InviteDelete = null,
ulong? MessageUpdate = null,
ulong? MessageDelete = null,
ulong? MessageDeleteBulk = null
);
}

View file

@ -0,0 +1,54 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Cache.InMemoryCache;
using Microsoft.AspNetCore.Mvc;
namespace Catalogger.Backend.Api;
[Route("/api")]
public class MetaController(GuildCache guildCache, DiscordRequestService discordRequestService)
: ApiControllerBase
{
[HttpGet("meta")]
public IActionResult GetMeta()
{
return Ok(new MetaResponse(Guilds: (int)CataloggerMetrics.GuildsCached.Value));
}
[HttpGet("current-user")]
[Authorize]
public async Task<IActionResult> GetCurrentUserAsync()
{
var token = HttpContext.GetTokenOrThrow();
var currentUser = await discordRequestService.GetMeAsync(token);
var guilds = await discordRequestService.GetGuildsAsync(token);
return Ok(
new CurrentUserResponse(
new ApiUser(currentUser),
guilds
.Where(g => g.CanManage)
.Select(g => new ApiGuild(g, guildCache.Contains(g.Id)))
)
);
}
private record MetaResponse(int Guilds);
private record CurrentUserResponse(ApiUser User, IEnumerable<ApiGuild> Guilds);
}

View file

@ -0,0 +1,87 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Catalogger.Backend.Api.Middleware;
public class AuthenticationMiddleware(DatabaseContext db, IClock clock) : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var endpoint = ctx.GetEndpoint();
var supportAuth = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>() != null;
var requireAuth = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>() != null;
if (!supportAuth)
{
await next(ctx);
return;
}
var token = ctx.Request.Headers.Authorization.ToString();
var apiToken = await db.ApiTokens.FirstOrDefaultAsync(t =>
t.DashboardToken == token && t.ExpiresAt > clock.GetCurrentInstant()
);
if (apiToken == null)
{
if (requireAuth)
throw new ApiError(
HttpStatusCode.Forbidden,
ErrorCode.AuthRequired,
"Authentication required"
);
await next(ctx);
return;
}
ctx.SetToken(apiToken);
await next(ctx);
}
}
public static class HttpContextExtensions
{
private const string Key = "token";
public static void SetToken(this HttpContext ctx, ApiToken token) => ctx.Items.Add(Key, token);
public static ApiToken GetTokenOrThrow(this HttpContext ctx) =>
ctx.GetToken()
?? throw new ApiError(
HttpStatusCode.Forbidden,
ErrorCode.AuthRequired,
"No token in HttpContext"
);
public static ApiToken? GetToken(this HttpContext ctx)
{
if (ctx.Items.TryGetValue(Key, out var token))
return token as ApiToken;
return null;
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticateAttribute : Attribute;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute : Attribute;

View file

@ -0,0 +1,72 @@
// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using System.Text.Json.Serialization;
namespace Catalogger.Backend.Api.Middleware;
public class ErrorMiddleware(ILogger baseLogger) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (ApiError ae)
{
context.Response.StatusCode = (int)ae.StatusCode;
await context.Response.WriteAsJsonAsync(ae.ToJson());
}
catch (Exception e)
{
var type = e.TargetSite?.DeclaringType ?? typeof(ErrorMiddleware);
var typeName = e.TargetSite?.DeclaringType?.FullName ?? "<unknown>";
var logger = baseLogger.ForContext(type);
logger.Error(e, "Error in {ClassName} ({Path})", typeName, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(
new ApiError(
HttpStatusCode.InternalServerError,
ErrorCode.InternalServerError,
"Internal server error"
).ToJson()
);
}
}
}
public class ApiError(HttpStatusCode statusCode, ErrorCode errorCode, string message)
: Exception(message)
{
public HttpStatusCode StatusCode { get; init; } = statusCode;
public ErrorCode ErrorCode { get; init; } = errorCode;
private record Json(ErrorCode ErrorCode, string Message);
public object ToJson() => new Json(ErrorCode, Message);
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ErrorCode
{
InternalServerError,
BadRequest,
AuthRequired,
UnknownGuild,
}