feat: start dashboard
This commit is contained in:
parent
bacbc6db0e
commit
ec7aa9faba
50 changed files with 3624 additions and 18 deletions
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue