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