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

@ -1,5 +1,7 @@
root = true
# Rider complains that these keys aren't supported but they *are*
# noinspection EditorConfigKeyCorrectness
[*.cs]
# Responder classes are considered "unused" by ReSharper because they're only loaded through reflection.
resharper_unused_type_global_highlighting = none
@ -7,3 +9,9 @@ resharper_unused_type_global_highlighting = none
resharper_unused_member_global_highlighting = none
# Command classes are generally only referred to in type parameters.
resharper_class_never_instantiated_global_highlighting = none
# We use PostgresSQL which doesn't recommend more specific string types
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
# This is raised for every single property of records returned by endpoints
resharper_not_accessed_positional_property_local_highlighting = none
# ReSharper yells at us for the name "GuildCache", for some reason
resharper_inconsistent_naming_highlighting = none

View file

@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<attachedFolders>
<Path>Catalogger.Frontend</Path>
</attachedFolders>
<explicitIncludes />
<explicitExcludes />
</component>

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,
}

View file

@ -15,6 +15,7 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
@ -26,6 +27,11 @@ public class GuildCache
public int Size => _guilds.Count;
public bool Contains(Snowflake id) => _guilds.ContainsKey(id);
public bool Contains(string id) =>
DiscordSnowflake.TryParse(id, out var sf) && _guilds.ContainsKey(sf.Value);
public void Set(IGuild guild) => _guilds[guild.ID] = guild;
public bool Remove(Snowflake id, [NotNullWhen(true)] out IGuild? guild) =>

View file

@ -52,6 +52,8 @@ public class Config
public ulong? CommandsGuildId { get; init; }
public ulong? GuildLogId { get; init; }
public int? ShardCount { get; init; }
public string ClientSecret { get; init; } = string.Empty;
}
public class WebConfig

View file

@ -35,6 +35,7 @@ public class DatabaseContext : DbContext
public DbSet<IgnoredMessage> IgnoredMessages { get; set; }
public DbSet<Invite> Invites { get; set; }
public DbSet<Watchlist> Watchlists { get; set; }
public DbSet<ApiToken> ApiTokens { get; set; }
public DatabaseContext(Config config, ILoggerFactory? loggerFactory)
{

View file

@ -0,0 +1,218 @@
// <auto-generated />
using System.Collections.Generic;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Catalogger.Backend.Database.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20241017130936_AddDashboardTokens")]
partial class AddDashboardTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Catalogger.Backend.Database.Models.ApiToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("text")
.HasColumnName("access_token");
b.Property<string>("DashboardToken")
.IsRequired()
.HasColumnType("text")
.HasColumnName("dashboard_token");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("RefreshToken")
.HasColumnType("text")
.HasColumnName("refresh_token");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_api_tokens");
b.ToTable("api_tokens", (string)null);
});
modelBuilder.Entity("Catalogger.Backend.Database.Models.Guild", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<List<string>>("BannedSystems")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("banned_systems");
b.Property<Guild.ChannelConfig>("Channels")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("channels");
b.Property<List<long>>("KeyRoles")
.IsRequired()
.HasColumnType("bigint[]")
.HasColumnName("key_roles");
b.HasKey("Id")
.HasName("pk_guilds");
b.ToTable("guilds", (string)null);
});
modelBuilder.Entity("Catalogger.Backend.Database.Models.IgnoredMessage", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.HasKey("Id")
.HasName("pk_ignored_messages");
b.ToTable("ignored_messages", (string)null);
});
modelBuilder.Entity("Catalogger.Backend.Database.Models.Invite", b =>
{
b.Property<string>("Code")
.HasColumnType("text")
.HasColumnName("code");
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Code")
.HasName("pk_invites");
b.HasIndex("GuildId")
.HasDatabaseName("ix_invites_guild_id");
b.ToTable("invites", (string)null);
});
modelBuilder.Entity("Catalogger.Backend.Database.Models.Message", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AttachmentSize")
.HasColumnType("integer")
.HasColumnName("attachment_size");
b.Property<long>("ChannelId")
.HasColumnType("bigint")
.HasColumnName("channel_id");
b.Property<byte[]>("EncryptedContent")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("content");
b.Property<byte[]>("EncryptedMetadata")
.HasColumnType("bytea")
.HasColumnName("metadata");
b.Property<byte[]>("EncryptedUsername")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("username");
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<string>("Member")
.HasColumnType("text")
.HasColumnName("member");
b.Property<long?>("OriginalId")
.HasColumnType("bigint")
.HasColumnName("original_id");
b.Property<string>("System")
.HasColumnType("text")
.HasColumnName("system");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_messages");
b.ToTable("messages", (string)null);
});
modelBuilder.Entity("Catalogger.Backend.Database.Models.Watchlist", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<Instant>("AddedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("added_at")
.HasDefaultValueSql("now()");
b.Property<long>("ModeratorId")
.HasColumnType("bigint")
.HasColumnName("moderator_id");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reason");
b.HasKey("GuildId", "UserId")
.HasName("pk_watchlists");
b.ToTable("watchlists", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Catalogger.Backend.Database.Migrations
{
/// <inheritdoc />
public partial class AddDashboardTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "api_tokens",
columns: table => new
{
id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
dashboard_token = table.Column<string>(type: "text", nullable: false),
user_id = table.Column<string>(type: "text", nullable: false),
access_token = table.Column<string>(type: "text", nullable: false),
refresh_token = table.Column<string>(type: "text", nullable: true),
expires_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("pk_api_tokens", x => x.id);
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "api_tokens");
}
}
}

View file

@ -19,11 +19,49 @@ namespace Catalogger.Backend.Database.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Catalogger.Backend.Database.Models.ApiToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("text")
.HasColumnName("access_token");
b.Property<string>("DashboardToken")
.IsRequired()
.HasColumnType("text")
.HasColumnName("dashboard_token");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("RefreshToken")
.HasColumnType("text")
.HasColumnName("refresh_token");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_api_tokens");
b.ToTable("api_tokens", (string)null);
});
modelBuilder.Entity("Catalogger.Backend.Database.Models.Guild", b =>
{
b.Property<long>("Id")

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 NodaTime;
namespace Catalogger.Backend.Database.Models;
public class ApiToken
{
public int Id { get; init; }
public required string DashboardToken { get; init; }
public required string UserId { get; init; }
public required string AccessToken { get; set; }
public string? RefreshToken { get; set; }
public required Instant ExpiresAt { get; set; }
}

View file

@ -29,6 +29,15 @@ public class RedisService(Config config)
public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db);
public async Task SetStringAsync(string key, string value, TimeSpan? expiry = null) =>
await GetDatabase().StringSetAsync(key, value, expiry);
public async Task<string?> GetStringAsync(string key, bool delete = false)
{
var db = GetDatabase();
return delete ? await db.StringGetDeleteAsync(key) : await db.StringGetAsync(key);
}
public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
{
var json = JsonSerializer.Serialize(value, _options);

View file

@ -13,6 +13,8 @@
// 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;
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Bot;
using Catalogger.Backend.Bot.Commands;
using Catalogger.Backend.Bot.Responders.Messages;
@ -25,6 +27,7 @@ using Catalogger.Backend.Database.Redis;
using Catalogger.Backend.Services;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Prometheus;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Services;
@ -115,6 +118,7 @@ public static class StartupExtensions
.AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance)
.AddSingleton<GuildFetchService>()
.AddTransient<PermissionResolverService>()
// Background services
// GuildFetchService is added as a separate singleton as it's also injected into other services.
.AddHostedService(serviceProvider =>
serviceProvider.GetRequiredService<GuildFetchService>()
@ -134,6 +138,26 @@ public static class StartupExtensions
.AddHostedService<ShardedDiscordService>()
);
/// <summary>
/// The dashboard API is only enabled when Redis is configured, as it heavily relies on it.
/// This method only adds API-related services when Redis is found as otherwise we'll get missing dependency errors.
/// The actual API definition
/// </summary>
public static IServiceCollection MaybeAddDashboardServices(
this IServiceCollection services,
Config config
)
{
if (config.Database.Redis == null)
return services;
return services
.AddScoped<ApiCache>()
.AddScoped<DiscordRequestService>()
.AddScoped<AuthenticationMiddleware>()
.AddScoped<ErrorMiddleware>();
}
public static IServiceCollection MaybeAddRedisCaches(
this IServiceCollection services,
Config config
@ -216,4 +240,29 @@ public static class StartupExtensions
"Not syncing slash commands, Discord.SyncCommands is false or unset"
);
}
public static void MaybeAddDashboard(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<Program>();
var config = scope.ServiceProvider.GetRequiredService<Config>();
if (config.Database.Redis == null)
{
logger.Warning(
"Redis URL is not set. The dashboard relies on Redis, so it will not be usable."
);
return;
}
app.UseSerilogRequestLogging();
app.UseRouting();
app.UseHttpMetrics();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
app.UseMiddleware<ErrorMiddleware>();
app.UseMiddleware<AuthenticationMiddleware>();
app.MapControllers();
}
}

View file

@ -13,11 +13,13 @@
// 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.Text.Json;
using System.Text.Json.Serialization;
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Bot.Commands;
using Catalogger.Backend.Database;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Newtonsoft.Json.Serialization;
using Prometheus;
using Remora.Commands.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Commands;
@ -39,12 +41,14 @@ builder.AddSerilog(config);
builder
.Services.AddControllers()
.AddNewtonsoftJson(o =>
o.SerializerSettings.ContractResolver = new DefaultContractResolver
.AddJsonOptions(options =>
{
NamingStrategy = new SnakeCaseNamingStrategy(),
}
);
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.IncludeFields = true;
options.JsonSerializerOptions.NumberHandling =
JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString;
});
builder
.Host.AddShardedDiscordService(_ => config.Discord.Token)
@ -106,6 +110,7 @@ if (!config.Logging.EnableMetrics)
builder
.Services.AddDbContext<DatabaseContext>()
.MaybeAddDashboardServices(config)
.MaybeAddRedisCaches(config)
.AddCustomServices()
.AddEndpointsApiExplorer()
@ -114,14 +119,7 @@ builder
var app = builder.Build();
await app.Initialize();
app.UseSerilogRequestLogging();
app.UseRouting();
app.UseHttpMetrics();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
app.MapControllers();
app.MaybeAddDashboard();
app.Urls.Clear();
app.Urls.Add(config.Web.Address);

View file

@ -44,7 +44,7 @@ public class PluralkitApiService(ILogger logger)
private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder()
.AddRateLimiter(
new FixedWindowRateLimiter(
new FixedWindowRateLimiterOptions()
new FixedWindowRateLimiterOptions
{
Window = 1.Seconds(),
PermitLimit = 2,

21
Catalogger.Frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
node_modules
# Output
.output
.vercel
.svelte-kit
build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

View file

@ -0,0 +1,5 @@
{
"useTabs": true,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View file

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

View file

@ -0,0 +1,32 @@
import eslint from "@eslint/js";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
...svelte.configs["flat/recommended"],
prettier,
...svelte.configs["flat/prettier"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
},
},
},
{
ignores: ["build/", ".svelte-kit/", "dist/"],
},
);

View file

@ -0,0 +1,35 @@
{
"name": "catalogger.frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltestrap/sveltestrap": "^6.2.7",
"@types/eslint": "^9.6.0",
"bootstrap": "^5.3.3",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"sass": "^1.80.1",
"svelte": "^4.2.7",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3"
},
"type": "module"
}

13
Catalogger.Frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,3 @@
@use "bootstrap/scss/bootstrap" with (
$color-mode-type: media-query
);

View file

@ -0,0 +1,55 @@
export type User = {
id: string;
tag: string;
avatar_url: string;
};
export type PartialGuild = {
id: string;
name: string;
icon_url: string;
bot_in_guild: boolean;
};
export type CurrentUser = {
user: User;
guilds: PartialGuild[];
};
export type AuthCallback = CurrentUser & { token: string };
export type ApiError = {
error_code: string;
message: string;
};
export const TOKEN_KEY = "catalogger-token";
export default async function apiFetch<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: any = null,
) {
const token = localStorage.getItem(TOKEN_KEY);
const headers = {
...(body != null
? { "Content-Type": "application/json; charset=utf-8" }
: {}),
...(token ? { Authorization: token } : {}),
};
const reqBody = body ? JSON.stringify(body) : undefined;
console.debug("Sending", method, "request to", path, "with body", reqBody);
const resp = await fetch(path, {
method,
body: body ? JSON.stringify(body) : undefined,
headers,
});
if (resp.status < 200 || resp.status > 299)
throw (await resp.json()) as ApiError;
return (await resp.json()) as T;
}

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { TOKEN_KEY, type User } from "$lib/api";
import { addToast } from "$lib/toast";
import {
Button,
Navbar,
NavbarBrand,
NavbarToggler,
Collapse,
Nav,
NavItem,
NavLink,
} from "@sveltestrap/sveltestrap";
export let user: User | null;
let isOpen = false;
const logOut = async () => {
localStorage.removeItem(TOKEN_KEY);
addToast({ header: "Logged out", body: "Successfully logged out." });
await goto("/", { invalidateAll: true });
};
</script>
<Navbar expand="lg">
<NavbarBrand href="/">Catalogger</NavbarBrand>
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
<Collapse {isOpen} navbar expand="lg">
<Nav class="ms-auto" navbar>
<NavItem>
<NavLink href="/">Home</NavLink>
</NavItem>
{#if user}
<NavItem>
<NavLink href="/dash">Dashboard</NavLink>
</NavItem>
<NavItem>
<NavLink on:click={logOut}>Log out</NavLink>
</NavItem>
{:else}
<NavItem>
<NavLink href="/api/authorize">Log in with Discord</NavLink>
</NavItem>
{/if}
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,37 @@
import { writable } from "svelte/store";
export interface ToastData {
header?: string;
body: string;
duration?: number;
}
interface IdToastData extends ToastData {
id: number;
}
export const toastStore = writable<IdToastData[]>([]);
let maxId = 0;
export const addToast = (data: ToastData) => {
const id = maxId++;
toastStore.update((toasts) => (toasts = [...toasts, { ...data, id }]));
if (data.duration !== -1) {
setTimeout(() => {
toastStore.update(
(toasts) => (toasts = toasts.filter((toast) => toast.id !== id)),
);
}, data.duration ?? 5000);
}
return id;
};
export const delToast = (id: number) => {
toastStore.update(
(toasts) => (toasts = toasts.filter((toast) => toast.id !== id)),
);
};

View file

@ -0,0 +1,23 @@
<script lang="ts">
import Navbar from "$lib/components/Navbar.svelte";
import { toastStore } from "$lib/toast";
import { Toast, ToastHeader, ToastBody } from "@sveltestrap/sveltestrap";
import "../app.scss";
import type { LayoutData } from "./$types";
export let data: LayoutData;
</script>
<Navbar user={data.user} />
<div class="container">
<slot />
<div class="position-absolute top-0 start-50 translate-middle-x">
{#each $toastStore as toast}
<Toast>
{#if toast.header}<ToastHeader>{toast.header}</ToastHeader>{/if}
<ToastBody>{toast.body}</ToastBody>
</Toast>
{/each}
</div>
</div>

View file

@ -0,0 +1,21 @@
import { building } from "$app/environment";
import apiFetch, { TOKEN_KEY, type CurrentUser } from "$lib/api";
export const ssr = false;
export const csr = true;
export const prerender = true;
export const load = async () => {
const token = localStorage.getItem(TOKEN_KEY);
let user: CurrentUser | null = null;
if (token && !building) {
try {
user = await apiFetch<CurrentUser>("GET", "/api/current-user");
} catch (e) {
console.error("Could not fetch user from API: ", e);
localStorage.removeItem(TOKEN_KEY);
}
}
return { user: user?.user || null, guilds: user?.guilds || null };
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import apiFetch from "$lib/api";
import { onMount } from "svelte";
let guildCount: number | null = 0;
type MetaResponse = { guilds: number };
onMount(async () => {
const meta = await apiFetch<MetaResponse>("GET", "/api/meta");
guildCount = meta.guilds;
});
</script>
<h1>Welcome to SvelteKit</h1>
<p>
Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation
</p>
<p>
In {guildCount ?? "(loading)"} servers!
</p>

View file

@ -0,0 +1 @@
export const prerender = true;

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { goto } from "$app/navigation";
import apiFetch, { TOKEN_KEY, type AuthCallback } from "$lib/api";
import { addToast } from "$lib/toast";
import { onMount } from "svelte";
import type { PageData } from "./$types";
let error = false;
export let data: PageData;
onMount(async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
if (data.user) {
addToast({ header: "Cannot log in", body: "You are already logged in." });
await goto("/dash");
return;
}
if (!code || !state) {
addToast({
header: "Cannot log in",
body: "Missing 'code' or 'state' parameters.",
});
await goto("/");
return;
}
try {
const resp = await apiFetch<AuthCallback>("POST", "/api/callback", {
code,
state,
});
localStorage.setItem(TOKEN_KEY, resp.token);
await goto("/dash", { invalidateAll: true });
} catch (e) {
console.error("Callback request failed: ", e);
error = true;
}
});
</script>
{#if !error}
<h1>Loading...</h1>
<p>You should be redirected to the dashboard within a few seconds.</p>
{:else}
<h1>Could not log in</h1>
<p>An error occurred while logging in with Discord. Please try again.</p>
{/if}

View file

@ -0,0 +1 @@
<slot />

View file

@ -0,0 +1,12 @@
import { addToast } from "$lib/toast";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const data = await parent();
if (!data.user) {
addToast({ body: "You are not logged in." });
redirect(303, "/");
}
return { user: data.user!, guilds: data.guilds! };
};

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { ListGroup, ListGroupItem } from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types";
export let data: PageData;
$: joinedGuilds = data.guilds.filter((g) => g.bot_in_guild);
$: unjoinedGuilds = data.guilds.filter((g) => !g.bot_in_guild);
</script>
<h1>Manage your servers</h1>
<div class="row">
{#if joinedGuilds.length > 0}
<div class="col-lg">
<h2>Servers you can manage</h2>
<ListGroup>
{#each joinedGuilds as guild (guild.id)}
<ListGroupItem tag="a" href="/dash/{guild.id}">
<img
src={guild.icon_url}
alt="Icon for {guild.name}"
style="border-radius: 0.75em; height: 1.5em;"
/>
{guild.name}
</ListGroupItem>
{/each}
</ListGroup>
</div>
{/if}
{#if unjoinedGuilds.length > 0}
<div class="col-lg">
<h2>Servers you can add Catalogger to</h2>
<ListGroup>
{#each unjoinedGuilds as guild (guild.id)}
<ListGroupItem tag="a" href="/api/add-guild/{guild.id}">
<img
src={guild.icon_url}
alt="Icon for {guild.name}"
style="border-radius: 0.75em; height: 1.5em;"
/>
{guild.name}
</ListGroupItem>
{/each}
</ListGroup>
</div>
{/if}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,20 @@
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
fallback: "index.html",
}),
},
};
export default config;

View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View file

@ -0,0 +1,18 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
"/api": {
target: "http://localhost:5005",
changeOrigin: true,
},
},
hmr: {
host: "localhost",
protocol: "ws",
},
},
});

File diff suppressed because it is too large Load diff