From ec7aa9faba416dd4721189e4d825a29cbebe8614 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 18 Oct 2024 22:13:23 +0200 Subject: [PATCH] feat: start dashboard --- .editorconfig | 8 + .idea/.idea.catalogger/.idea/indexLayout.xml | 4 +- Catalogger.Backend/Api/ApiCache.cs | 40 + Catalogger.Backend/Api/ApiControllerBase.cs | 27 + Catalogger.Backend/Api/ApiModels.cs | 28 + Catalogger.Backend/Api/ApiUtils.cs | 28 + Catalogger.Backend/Api/AuthController.cs | 85 + .../Api/DiscordRequestService.cs | 264 +++ Catalogger.Backend/Api/GuildsController.cs | 322 ++++ Catalogger.Backend/Api/MetaController.cs | 54 + .../Middleware/AuthenticationMiddleware.cs | 87 + .../Api/Middleware/ErrorMiddleware.cs | 72 + .../Cache/InMemoryCache/GuildCache.cs | 6 + Catalogger.Backend/Config.cs | 2 + .../Database/DatabaseContext.cs | 1 + ...41017130936_AddDashboardTokens.Designer.cs | 218 +++ .../20241017130936_AddDashboardTokens.cs | 47 + .../DatabaseContextModelSnapshot.cs | 40 +- .../Database/Models/ApiToken.cs | 28 + .../Database/Redis/RedisService.cs | 9 + .../Extensions/StartupExtensions.cs | 49 + Catalogger.Backend/Program.cs | 28 +- .../Services/PluralkitApiService.cs | 2 +- Catalogger.Frontend/.gitignore | 21 + Catalogger.Frontend/.npmrc | 1 + Catalogger.Frontend/.prettierignore | 4 + Catalogger.Frontend/.prettierrc | 5 + Catalogger.Frontend/README.md | 38 + Catalogger.Frontend/eslint.config.js | 32 + Catalogger.Frontend/package.json | 35 + Catalogger.Frontend/src/app.d.ts | 13 + Catalogger.Frontend/src/app.html | 12 + Catalogger.Frontend/src/app.scss | 3 + Catalogger.Frontend/src/lib/api.ts | 55 + .../src/lib/components/Navbar.svelte | 51 + Catalogger.Frontend/src/lib/index.ts | 1 + Catalogger.Frontend/src/lib/toast.ts | 37 + Catalogger.Frontend/src/routes/+layout.svelte | 23 + Catalogger.Frontend/src/routes/+layout.ts | 21 + Catalogger.Frontend/src/routes/+page.svelte | 22 + Catalogger.Frontend/src/routes/+page.ts | 1 + .../src/routes/callback/+page.svelte | 54 + .../src/routes/dash/+layout.svelte | 1 + .../src/routes/dash/+layout.ts | 12 + .../src/routes/dash/+page.svelte | 48 + Catalogger.Frontend/static/favicon.png | Bin 0 -> 1571 bytes Catalogger.Frontend/svelte.config.js | 20 + Catalogger.Frontend/tsconfig.json | 19 + Catalogger.Frontend/vite.config.ts | 18 + Catalogger.Frontend/yarn.lock | 1646 +++++++++++++++++ 50 files changed, 3624 insertions(+), 18 deletions(-) create mode 100644 Catalogger.Backend/Api/ApiCache.cs create mode 100644 Catalogger.Backend/Api/ApiControllerBase.cs create mode 100644 Catalogger.Backend/Api/ApiModels.cs create mode 100644 Catalogger.Backend/Api/ApiUtils.cs create mode 100644 Catalogger.Backend/Api/AuthController.cs create mode 100644 Catalogger.Backend/Api/DiscordRequestService.cs create mode 100644 Catalogger.Backend/Api/GuildsController.cs create mode 100644 Catalogger.Backend/Api/MetaController.cs create mode 100644 Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs create mode 100644 Catalogger.Backend/Api/Middleware/ErrorMiddleware.cs create mode 100644 Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.Designer.cs create mode 100644 Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.cs create mode 100644 Catalogger.Backend/Database/Models/ApiToken.cs create mode 100644 Catalogger.Frontend/.gitignore create mode 100644 Catalogger.Frontend/.npmrc create mode 100644 Catalogger.Frontend/.prettierignore create mode 100644 Catalogger.Frontend/.prettierrc create mode 100644 Catalogger.Frontend/README.md create mode 100644 Catalogger.Frontend/eslint.config.js create mode 100644 Catalogger.Frontend/package.json create mode 100644 Catalogger.Frontend/src/app.d.ts create mode 100644 Catalogger.Frontend/src/app.html create mode 100644 Catalogger.Frontend/src/app.scss create mode 100644 Catalogger.Frontend/src/lib/api.ts create mode 100644 Catalogger.Frontend/src/lib/components/Navbar.svelte create mode 100644 Catalogger.Frontend/src/lib/index.ts create mode 100644 Catalogger.Frontend/src/lib/toast.ts create mode 100644 Catalogger.Frontend/src/routes/+layout.svelte create mode 100644 Catalogger.Frontend/src/routes/+layout.ts create mode 100644 Catalogger.Frontend/src/routes/+page.svelte create mode 100644 Catalogger.Frontend/src/routes/+page.ts create mode 100644 Catalogger.Frontend/src/routes/callback/+page.svelte create mode 100644 Catalogger.Frontend/src/routes/dash/+layout.svelte create mode 100644 Catalogger.Frontend/src/routes/dash/+layout.ts create mode 100644 Catalogger.Frontend/src/routes/dash/+page.svelte create mode 100644 Catalogger.Frontend/static/favicon.png create mode 100644 Catalogger.Frontend/svelte.config.js create mode 100644 Catalogger.Frontend/tsconfig.json create mode 100644 Catalogger.Frontend/vite.config.ts create mode 100644 Catalogger.Frontend/yarn.lock diff --git a/.editorconfig b/.editorconfig index 22852f7..d20e217 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 \ No newline at end of file diff --git a/.idea/.idea.catalogger/.idea/indexLayout.xml b/.idea/.idea.catalogger/.idea/indexLayout.xml index 7b08163..ac2a6be 100644 --- a/.idea/.idea.catalogger/.idea/indexLayout.xml +++ b/.idea/.idea.catalogger/.idea/indexLayout.xml @@ -1,7 +1,9 @@ - + + Catalogger.Frontend + diff --git a/Catalogger.Backend/Api/ApiCache.cs b/Catalogger.Backend/Api/ApiCache.cs new file mode 100644 index 0000000..c4161ad --- /dev/null +++ b/Catalogger.Backend/Api/ApiCache.cs @@ -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 . + +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 GetUserAsync(string id) => + await redisService.GetAsync(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?> GetGuildsAsync(string userId) => + await redisService.GetAsync>(GuildsKey(userId)); + + public async Task SetGuildsAsync(string userId, List guilds) => + await redisService.SetAsync(GuildsKey(userId), guilds, expiry: TimeSpan.FromHours(1)); +} diff --git a/Catalogger.Backend/Api/ApiControllerBase.cs b/Catalogger.Backend/Api/ApiControllerBase.cs new file mode 100644 index 0000000..0c00a71 --- /dev/null +++ b/Catalogger.Backend/Api/ApiControllerBase.cs @@ -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 . + +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(); +} diff --git a/Catalogger.Backend/Api/ApiModels.cs b/Catalogger.Backend/Api/ApiModels.cs new file mode 100644 index 0000000..8de3db9 --- /dev/null +++ b/Catalogger.Backend/Api/ApiModels.cs @@ -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 . + +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) { } +} diff --git a/Catalogger.Backend/Api/ApiUtils.cs b/Catalogger.Backend/Api/ApiUtils.cs new file mode 100644 index 0000000..e92d233 --- /dev/null +++ b/Catalogger.Backend/Api/ApiUtils.cs @@ -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 . + +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('/', '_'); +} diff --git a/Catalogger.Backend/Api/AuthController.cs b/Catalogger.Backend/Api/AuthController.cs new file mode 100644 index 0000000..9651bfa --- /dev/null +++ b/Catalogger.Backend/Api/AuthController.cs @@ -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 . + +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 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(statusCode: StatusCodes.Status200OK)] + public async Task 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 Guilds, string Token); + + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + private record DiscordTokenResponse(string AccessToken, string? RefreshToken, int ExpiresIn); +} diff --git a/Catalogger.Backend/Api/DiscordRequestService.cs b/Catalogger.Backend/Api/DiscordRequestService.cs new file mode 100644 index 0000000..c61a850 --- /dev/null +++ b/Catalogger.Backend/Api/DiscordRequestService.cs @@ -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 . + +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(); + _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 GetAsync(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(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 GetMeAsync(string token) => await GetAsync(DiscordUserUri, token); + + public async Task> GetGuildsAsync(string token) => + await GetAsync>(DiscordGuildsUri, token); + + public async Task 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> 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 Guilds)> RequestDiscordTokenAsync( + string code, + CancellationToken ct = default + ) + { + var redirectUri = $"{_config.Web.BaseUrl}/callback"; + var resp = await _httpClient.PostAsync( + DiscordTokenUri, + new FormUrlEncodedContent( + new Dictionary + { + { "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(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 + { + { "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(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; +} diff --git a/Catalogger.Backend/Api/GuildsController.cs b/Catalogger.Backend/Api/GuildsController.cs new file mode 100644 index 0000000..f225154 --- /dev/null +++ b/Catalogger.Backend/Api/GuildsController.cs @@ -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 . + +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 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 Categories, + IEnumerable ChannelsWithoutCategory, + Database.Models.Guild.ChannelConfig Config + ); + + private record GuildCategory(string Id, string Name, IEnumerable Channels); + + private record GuildChannel(string Id, string Name, bool CanLogTo, bool CanRedirectFrom); + + [Authorize] + [HttpPatch] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + public async Task 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 + ); +} diff --git a/Catalogger.Backend/Api/MetaController.cs b/Catalogger.Backend/Api/MetaController.cs new file mode 100644 index 0000000..5037c9a --- /dev/null +++ b/Catalogger.Backend/Api/MetaController.cs @@ -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 . + +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 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 Guilds); +} diff --git a/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs b/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs new file mode 100644 index 0000000..21d339a --- /dev/null +++ b/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs @@ -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 . + +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() != null; + var requireAuth = endpoint?.Metadata.GetMetadata() != 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; diff --git a/Catalogger.Backend/Api/Middleware/ErrorMiddleware.cs b/Catalogger.Backend/Api/Middleware/ErrorMiddleware.cs new file mode 100644 index 0000000..282617c --- /dev/null +++ b/Catalogger.Backend/Api/Middleware/ErrorMiddleware.cs @@ -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 . + +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 ?? ""; + 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, +} diff --git a/Catalogger.Backend/Cache/InMemoryCache/GuildCache.cs b/Catalogger.Backend/Cache/InMemoryCache/GuildCache.cs index 8c3b5f7..59fb61d 100644 --- a/Catalogger.Backend/Cache/InMemoryCache/GuildCache.cs +++ b/Catalogger.Backend/Cache/InMemoryCache/GuildCache.cs @@ -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) => diff --git a/Catalogger.Backend/Config.cs b/Catalogger.Backend/Config.cs index d98ece7..d3489bf 100644 --- a/Catalogger.Backend/Config.cs +++ b/Catalogger.Backend/Config.cs @@ -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 diff --git a/Catalogger.Backend/Database/DatabaseContext.cs b/Catalogger.Backend/Database/DatabaseContext.cs index 9f8decc..0e2c704 100644 --- a/Catalogger.Backend/Database/DatabaseContext.cs +++ b/Catalogger.Backend/Database/DatabaseContext.cs @@ -35,6 +35,7 @@ public class DatabaseContext : DbContext public DbSet IgnoredMessages { get; set; } public DbSet Invites { get; set; } public DbSet Watchlists { get; set; } + public DbSet ApiTokens { get; set; } public DatabaseContext(Config config, ILoggerFactory? loggerFactory) { diff --git a/Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.Designer.cs b/Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.Designer.cs new file mode 100644 index 0000000..2399bb8 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.Designer.cs @@ -0,0 +1,218 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("DashboardToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("dashboard_token"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property>("BannedSystems") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("banned_systems"); + + b.Property("Channels") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("channels"); + + b.Property>("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("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("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AttachmentSize") + .HasColumnType("integer") + .HasColumnName("attachment_size"); + + b.Property("ChannelId") + .HasColumnType("bigint") + .HasColumnName("channel_id"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("EncryptedMetadata") + .HasColumnType("bytea") + .HasColumnName("metadata"); + + b.Property("EncryptedUsername") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("username"); + + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("Member") + .HasColumnType("text") + .HasColumnName("member"); + + b.Property("OriginalId") + .HasColumnType("bigint") + .HasColumnName("original_id"); + + b.Property("System") + .HasColumnType("text") + .HasColumnName("system"); + + b.Property("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("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("now()"); + + b.Property("ModeratorId") + .HasColumnType("bigint") + .HasColumnName("moderator_id"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("GuildId", "UserId") + .HasName("pk_watchlists"); + + b.ToTable("watchlists", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.cs b/Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.cs new file mode 100644 index 0000000..a37ace5 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Catalogger.Backend.Database.Migrations +{ + /// + public partial class AddDashboardTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "api_tokens", + columns: table => new + { + id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + dashboard_token = table.Column(type: "text", nullable: false), + user_id = table.Column(type: "text", nullable: false), + access_token = table.Column(type: "text", nullable: false), + refresh_token = table.Column(type: "text", nullable: true), + expires_at = table.Column( + type: "timestamp with time zone", + nullable: false + ), + }, + constraints: table => + { + table.PrimaryKey("pk_api_tokens", x => x.id); + } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "api_tokens"); + } + } +} diff --git a/Catalogger.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Catalogger.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index c209770..2709829 100644 --- a/Catalogger.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Catalogger.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("DashboardToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("dashboard_token"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("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("Id") diff --git a/Catalogger.Backend/Database/Models/ApiToken.cs b/Catalogger.Backend/Database/Models/ApiToken.cs new file mode 100644 index 0000000..aa9946b --- /dev/null +++ b/Catalogger.Backend/Database/Models/ApiToken.cs @@ -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 . + +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; } +} diff --git a/Catalogger.Backend/Database/Redis/RedisService.cs b/Catalogger.Backend/Database/Redis/RedisService.cs index 85daa03..1ed24af 100644 --- a/Catalogger.Backend/Database/Redis/RedisService.cs +++ b/Catalogger.Backend/Database/Redis/RedisService.cs @@ -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 GetStringAsync(string key, bool delete = false) + { + var db = GetDatabase(); + return delete ? await db.StringGetDeleteAsync(key) : await db.StringGetAsync(key); + } + public async Task SetAsync(string key, T value, TimeSpan? expiry = null) { var json = JsonSerializer.Serialize(value, _options); diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index c040cf4..46cf25b 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -13,6 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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.Instance) .AddSingleton() .AddTransient() + // Background services // GuildFetchService is added as a separate singleton as it's also injected into other services. .AddHostedService(serviceProvider => serviceProvider.GetRequiredService() @@ -134,6 +138,26 @@ public static class StartupExtensions .AddHostedService() ); + /// + /// 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 + /// + public static IServiceCollection MaybeAddDashboardServices( + this IServiceCollection services, + Config config + ) + { + if (config.Database.Redis == null) + return services; + + return services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + } + 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().ForContext(); + var config = scope.ServiceProvider.GetRequiredService(); + + 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(); + app.UseMiddleware(); + app.MapControllers(); + } } diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index 988c06c..6695f1e 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -13,11 +13,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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 - { - NamingStrategy = new SnakeCaseNamingStrategy(), - } - ); + .AddJsonOptions(options => + { + 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() + .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); diff --git a/Catalogger.Backend/Services/PluralkitApiService.cs b/Catalogger.Backend/Services/PluralkitApiService.cs index bf39054..5dd0f0b 100644 --- a/Catalogger.Backend/Services/PluralkitApiService.cs +++ b/Catalogger.Backend/Services/PluralkitApiService.cs @@ -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, diff --git a/Catalogger.Frontend/.gitignore b/Catalogger.Frontend/.gitignore new file mode 100644 index 0000000..449610c --- /dev/null +++ b/Catalogger.Frontend/.gitignore @@ -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-* diff --git a/Catalogger.Frontend/.npmrc b/Catalogger.Frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/Catalogger.Frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/Catalogger.Frontend/.prettierignore b/Catalogger.Frontend/.prettierignore new file mode 100644 index 0000000..ab78a95 --- /dev/null +++ b/Catalogger.Frontend/.prettierignore @@ -0,0 +1,4 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/Catalogger.Frontend/.prettierrc b/Catalogger.Frontend/.prettierrc new file mode 100644 index 0000000..274bb40 --- /dev/null +++ b/Catalogger.Frontend/.prettierrc @@ -0,0 +1,5 @@ +{ + "useTabs": true, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/Catalogger.Frontend/README.md b/Catalogger.Frontend/README.md new file mode 100644 index 0000000..5ce6766 --- /dev/null +++ b/Catalogger.Frontend/README.md @@ -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. diff --git a/Catalogger.Frontend/eslint.config.js b/Catalogger.Frontend/eslint.config.js new file mode 100644 index 0000000..1e50d4a --- /dev/null +++ b/Catalogger.Frontend/eslint.config.js @@ -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/"], + }, +); diff --git a/Catalogger.Frontend/package.json b/Catalogger.Frontend/package.json new file mode 100644 index 0000000..31d8f84 --- /dev/null +++ b/Catalogger.Frontend/package.json @@ -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" +} diff --git a/Catalogger.Frontend/src/app.d.ts b/Catalogger.Frontend/src/app.d.ts new file mode 100644 index 0000000..743f07b --- /dev/null +++ b/Catalogger.Frontend/src/app.d.ts @@ -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 {}; diff --git a/Catalogger.Frontend/src/app.html b/Catalogger.Frontend/src/app.html new file mode 100644 index 0000000..77a5ff5 --- /dev/null +++ b/Catalogger.Frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/Catalogger.Frontend/src/app.scss b/Catalogger.Frontend/src/app.scss new file mode 100644 index 0000000..d8723b3 --- /dev/null +++ b/Catalogger.Frontend/src/app.scss @@ -0,0 +1,3 @@ +@use "bootstrap/scss/bootstrap" with ( + $color-mode-type: media-query +); diff --git a/Catalogger.Frontend/src/lib/api.ts b/Catalogger.Frontend/src/lib/api.ts new file mode 100644 index 0000000..ba05028 --- /dev/null +++ b/Catalogger.Frontend/src/lib/api.ts @@ -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( + 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; +} diff --git a/Catalogger.Frontend/src/lib/components/Navbar.svelte b/Catalogger.Frontend/src/lib/components/Navbar.svelte new file mode 100644 index 0000000..af4dcfe --- /dev/null +++ b/Catalogger.Frontend/src/lib/components/Navbar.svelte @@ -0,0 +1,51 @@ + + + + Catalogger + (isOpen = !isOpen)} /> + + + + diff --git a/Catalogger.Frontend/src/lib/index.ts b/Catalogger.Frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/Catalogger.Frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/Catalogger.Frontend/src/lib/toast.ts b/Catalogger.Frontend/src/lib/toast.ts new file mode 100644 index 0000000..08da9c0 --- /dev/null +++ b/Catalogger.Frontend/src/lib/toast.ts @@ -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([]); + +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)), + ); +}; diff --git a/Catalogger.Frontend/src/routes/+layout.svelte b/Catalogger.Frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..cf6ade6 --- /dev/null +++ b/Catalogger.Frontend/src/routes/+layout.svelte @@ -0,0 +1,23 @@ + + + + +
+ +
+ {#each $toastStore as toast} + + {#if toast.header}{toast.header}{/if} + {toast.body} + + {/each} +
+
diff --git a/Catalogger.Frontend/src/routes/+layout.ts b/Catalogger.Frontend/src/routes/+layout.ts new file mode 100644 index 0000000..153bdb8 --- /dev/null +++ b/Catalogger.Frontend/src/routes/+layout.ts @@ -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("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 }; +}; diff --git a/Catalogger.Frontend/src/routes/+page.svelte b/Catalogger.Frontend/src/routes/+page.svelte new file mode 100644 index 0000000..ee972ad --- /dev/null +++ b/Catalogger.Frontend/src/routes/+page.svelte @@ -0,0 +1,22 @@ + + +

Welcome to SvelteKit

+

+ Visit kit.svelte.dev to read the documentation +

+ +

+ In {guildCount ?? "(loading)"} servers! +

diff --git a/Catalogger.Frontend/src/routes/+page.ts b/Catalogger.Frontend/src/routes/+page.ts new file mode 100644 index 0000000..c8cacf0 --- /dev/null +++ b/Catalogger.Frontend/src/routes/+page.ts @@ -0,0 +1 @@ +export const prerender = true; \ No newline at end of file diff --git a/Catalogger.Frontend/src/routes/callback/+page.svelte b/Catalogger.Frontend/src/routes/callback/+page.svelte new file mode 100644 index 0000000..08e5502 --- /dev/null +++ b/Catalogger.Frontend/src/routes/callback/+page.svelte @@ -0,0 +1,54 @@ + + +{#if !error} +

Loading...

+

You should be redirected to the dashboard within a few seconds.

+{:else} +

Could not log in

+

An error occurred while logging in with Discord. Please try again.

+{/if} diff --git a/Catalogger.Frontend/src/routes/dash/+layout.svelte b/Catalogger.Frontend/src/routes/dash/+layout.svelte new file mode 100644 index 0000000..4fa864c --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/+layout.svelte @@ -0,0 +1 @@ + diff --git a/Catalogger.Frontend/src/routes/dash/+layout.ts b/Catalogger.Frontend/src/routes/dash/+layout.ts new file mode 100644 index 0000000..08eaf84 --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/+layout.ts @@ -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! }; +}; diff --git a/Catalogger.Frontend/src/routes/dash/+page.svelte b/Catalogger.Frontend/src/routes/dash/+page.svelte new file mode 100644 index 0000000..efe1736 --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/+page.svelte @@ -0,0 +1,48 @@ + + +

Manage your servers

+ +
+ {#if joinedGuilds.length > 0} +
+

Servers you can manage

+ + {#each joinedGuilds as guild (guild.id)} + + Icon for {guild.name} + {guild.name} + + {/each} + +
+ {/if} + {#if unjoinedGuilds.length > 0} +
+

Servers you can add Catalogger to

+ + {#each unjoinedGuilds as guild (guild.id)} + + Icon for {guild.name} + {guild.name} + + {/each} + +
+ {/if} +
diff --git a/Catalogger.Frontend/static/favicon.png b/Catalogger.Frontend/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..825b9e65af7c104cfb07089bb28659393b4f2097 GIT binary patch literal 1571 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH=0.6.2 <2.0.0" + +semver@^7.5.4, semver@^7.6.0, semver@^7.6.2: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +set-cookie-parser@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.0.tgz#ef5552b56dc01baae102acb5fc9fb8cd060c30f9" + integrity sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +sirv@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.0.tgz#f8d90fc528f65dff04cb597a88609d4e8a4361ce" + integrity sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg== + dependencies: + "@polka/url" "^1.0.0-next.24" + mrmime "^2.0.0" + totalist "^3.0.0" + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +svelte-check@^4.0.0: + version "4.0.5" + resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-4.0.5.tgz#5cd910c3b1d50f38159c17cc3bae127cbbb55c8d" + integrity sha512-icBTBZ3ibBaywbXUat3cK6hB5Du+Kq9Z8CRuyLmm64XIe2/r+lQcbuBx/IQgsbrC+kT2jQ0weVpZSSRIPwB6jQ== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + chokidar "^4.0.1" + fdir "^6.2.0" + picocolors "^1.0.0" + sade "^1.7.4" + +svelte-eslint-parser@^0.42.0: + version "0.42.0" + resolved "https://registry.yarnpkg.com/svelte-eslint-parser/-/svelte-eslint-parser-0.42.0.tgz#a4b28b14505194e7f0b1aec22b2724253941cf40" + integrity sha512-e7LyqFPTuF43ZYhKOf0Gq1lzP+G64iWVJXAIcwVxohGx5FFyqdUkw7DEXNjZ+Fm+TAA98zPmDqWvgD1OpyMi5A== + dependencies: + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + postcss "^8.4.39" + postcss-scss "^4.0.9" + +svelte-hmr@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.16.0.tgz#9f345b7d1c1662f1613747ed7e82507e376c1716" + integrity sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA== + +svelte@^4.2.7: + version "4.2.19" + resolved "https://registry.yarnpkg.com/svelte/-/svelte-4.2.19.tgz#4e6e84a8818e2cd04ae0255fcf395bc211e61d4c" + integrity sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw== + dependencies: + "@ampproject/remapping" "^2.2.1" + "@jridgewell/sourcemap-codec" "^1.4.15" + "@jridgewell/trace-mapping" "^0.3.18" + "@types/estree" "^1.0.1" + acorn "^8.9.0" + aria-query "^5.3.0" + axobject-query "^4.0.0" + code-red "^1.0.3" + css-tree "^2.3.1" + estree-walker "^3.0.3" + is-reference "^3.0.1" + locate-character "^3.0.0" + magic-string "^0.30.4" + periscopic "^3.1.0" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +tiny-glob@^0.2.9: + version "0.2.9" + resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" + integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg== + dependencies: + globalyzer "0.1.0" + globrex "^0.1.2" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +totalist@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" + integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== + +ts-api-utils@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" + integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typescript-eslint@^8.0.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.9.0.tgz#20a9b8125c57f3de962080ebebf366697f75bf79" + integrity sha512-AuD/FXGYRQyqyOBCpNLldMlsCGvmDNxptQ3Dp58/NXeB+FqyvTfXmMyba3PYa0Vi9ybnj7G8S/yd/4Cw8y47eA== + dependencies: + "@typescript-eslint/eslint-plugin" "8.9.0" + "@typescript-eslint/parser" "8.9.0" + "@typescript-eslint/utils" "8.9.0" + +typescript@^5.0.0: + version "5.6.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" + integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +vite@^5.0.3: + version "5.4.9" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.9.tgz#215c80cbebfd09ccbb9ceb8c0621391c9abdc19c" + integrity sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitefu@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-0.2.5.tgz#c1b93c377fbdd3e5ddd69840ea3aa70b40d90969" + integrity sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q== + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +yaml@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==