feat: start dashboard
This commit is contained in:
parent
bacbc6db0e
commit
ec7aa9faba
50 changed files with 3624 additions and 18 deletions
|
|
@ -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
|
||||
4
.idea/.idea.catalogger/.idea/indexLayout.xml
generated
4
.idea/.idea.catalogger/.idea/indexLayout.xml
generated
|
|
@ -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>
|
||||
|
|
|
|||
40
Catalogger.Backend/Api/ApiCache.cs
Normal file
40
Catalogger.Backend/Api/ApiCache.cs
Normal 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));
|
||||
}
|
||||
27
Catalogger.Backend/Api/ApiControllerBase.cs
Normal file
27
Catalogger.Backend/Api/ApiControllerBase.cs
Normal 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();
|
||||
}
|
||||
28
Catalogger.Backend/Api/ApiModels.cs
Normal file
28
Catalogger.Backend/Api/ApiModels.cs
Normal 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) { }
|
||||
}
|
||||
28
Catalogger.Backend/Api/ApiUtils.cs
Normal file
28
Catalogger.Backend/Api/ApiUtils.cs
Normal 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('/', '_');
|
||||
}
|
||||
85
Catalogger.Backend/Api/AuthController.cs
Normal file
85
Catalogger.Backend/Api/AuthController.cs
Normal 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);
|
||||
}
|
||||
264
Catalogger.Backend/Api/DiscordRequestService.cs
Normal file
264
Catalogger.Backend/Api/DiscordRequestService.cs
Normal 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;
|
||||
}
|
||||
322
Catalogger.Backend/Api/GuildsController.cs
Normal file
322
Catalogger.Backend/Api/GuildsController.cs
Normal 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
|
||||
);
|
||||
}
|
||||
54
Catalogger.Backend/Api/MetaController.cs
Normal file
54
Catalogger.Backend/Api/MetaController.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
72
Catalogger.Backend/Api/Middleware/ErrorMiddleware.cs
Normal file
72
Catalogger.Backend/Api/Middleware/ErrorMiddleware.cs
Normal 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,
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
218
Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.Designer.cs
generated
Normal file
218
Catalogger.Backend/Database/Migrations/20241017130936_AddDashboardTokens.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
28
Catalogger.Backend/Database/Models/ApiToken.cs
Normal file
28
Catalogger.Backend/Database/Models/ApiToken.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
21
Catalogger.Frontend/.gitignore
vendored
Normal 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-*
|
||||
1
Catalogger.Frontend/.npmrc
Normal file
1
Catalogger.Frontend/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
4
Catalogger.Frontend/.prettierignore
Normal file
4
Catalogger.Frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
5
Catalogger.Frontend/.prettierrc
Normal file
5
Catalogger.Frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
38
Catalogger.Frontend/README.md
Normal file
38
Catalogger.Frontend/README.md
Normal 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.
|
||||
32
Catalogger.Frontend/eslint.config.js
Normal file
32
Catalogger.Frontend/eslint.config.js
Normal 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/"],
|
||||
},
|
||||
);
|
||||
35
Catalogger.Frontend/package.json
Normal file
35
Catalogger.Frontend/package.json
Normal 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
13
Catalogger.Frontend/src/app.d.ts
vendored
Normal 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 {};
|
||||
12
Catalogger.Frontend/src/app.html
Normal file
12
Catalogger.Frontend/src/app.html
Normal 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>
|
||||
3
Catalogger.Frontend/src/app.scss
Normal file
3
Catalogger.Frontend/src/app.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@use "bootstrap/scss/bootstrap" with (
|
||||
$color-mode-type: media-query
|
||||
);
|
||||
55
Catalogger.Frontend/src/lib/api.ts
Normal file
55
Catalogger.Frontend/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
51
Catalogger.Frontend/src/lib/components/Navbar.svelte
Normal file
51
Catalogger.Frontend/src/lib/components/Navbar.svelte
Normal 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>
|
||||
1
Catalogger.Frontend/src/lib/index.ts
Normal file
1
Catalogger.Frontend/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
37
Catalogger.Frontend/src/lib/toast.ts
Normal file
37
Catalogger.Frontend/src/lib/toast.ts
Normal 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)),
|
||||
);
|
||||
};
|
||||
23
Catalogger.Frontend/src/routes/+layout.svelte
Normal file
23
Catalogger.Frontend/src/routes/+layout.svelte
Normal 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>
|
||||
21
Catalogger.Frontend/src/routes/+layout.ts
Normal file
21
Catalogger.Frontend/src/routes/+layout.ts
Normal 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 };
|
||||
};
|
||||
22
Catalogger.Frontend/src/routes/+page.svelte
Normal file
22
Catalogger.Frontend/src/routes/+page.svelte
Normal 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>
|
||||
1
Catalogger.Frontend/src/routes/+page.ts
Normal file
1
Catalogger.Frontend/src/routes/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = true;
|
||||
54
Catalogger.Frontend/src/routes/callback/+page.svelte
Normal file
54
Catalogger.Frontend/src/routes/callback/+page.svelte
Normal 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}
|
||||
1
Catalogger.Frontend/src/routes/dash/+layout.svelte
Normal file
1
Catalogger.Frontend/src/routes/dash/+layout.svelte
Normal file
|
|
@ -0,0 +1 @@
|
|||
<slot />
|
||||
12
Catalogger.Frontend/src/routes/dash/+layout.ts
Normal file
12
Catalogger.Frontend/src/routes/dash/+layout.ts
Normal 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! };
|
||||
};
|
||||
48
Catalogger.Frontend/src/routes/dash/+page.svelte
Normal file
48
Catalogger.Frontend/src/routes/dash/+page.svelte
Normal 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>
|
||||
BIN
Catalogger.Frontend/static/favicon.png
Normal file
BIN
Catalogger.Frontend/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
20
Catalogger.Frontend/svelte.config.js
Normal file
20
Catalogger.Frontend/svelte.config.js
Normal 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;
|
||||
19
Catalogger.Frontend/tsconfig.json
Normal file
19
Catalogger.Frontend/tsconfig.json
Normal 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
|
||||
}
|
||||
18
Catalogger.Frontend/vite.config.ts
Normal file
18
Catalogger.Frontend/vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
1646
Catalogger.Frontend/yarn.lock
Normal file
1646
Catalogger.Frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue