2024-10-18 22:13:23 +02:00
|
|
|
// 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.Models;
|
2024-10-28 14:04:55 +01:00
|
|
|
using Catalogger.Backend.Database.Repositories;
|
2024-10-18 22:13:23 +02:00
|
|
|
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;
|
2024-10-28 14:04:55 +01:00
|
|
|
private readonly ApiTokenRepository _tokenRepository;
|
2024-10-18 22:13:23 +02:00
|
|
|
|
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions =
|
|
|
|
|
new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
|
|
|
|
|
|
|
|
|
|
public DiscordRequestService(
|
|
|
|
|
ILogger logger,
|
|
|
|
|
ApiCache apiCache,
|
|
|
|
|
Config config,
|
|
|
|
|
IClock clock,
|
2024-10-28 14:04:55 +01:00
|
|
|
ApiTokenRepository tokenRepository
|
2024-10-18 22:13:23 +02:00
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
_logger = logger.ForContext<DiscordRequestService>();
|
|
|
|
|
_apiCache = apiCache;
|
|
|
|
|
_config = config;
|
|
|
|
|
_clock = clock;
|
2024-10-28 14:04:55 +01:00
|
|
|
_tokenRepository = tokenRepository;
|
2024-10-18 22:13:23 +02:00
|
|
|
|
|
|
|
|
_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}");
|
|
|
|
|
|
2024-10-28 14:04:55 +01:00
|
|
|
var apiToken = await _tokenRepository.CreateAsync(
|
|
|
|
|
ApiUtils.RandomToken(64),
|
|
|
|
|
meUser.Id,
|
|
|
|
|
token.AccessToken,
|
|
|
|
|
token.RefreshToken,
|
|
|
|
|
token.ExpiresIn
|
|
|
|
|
);
|
2024-10-18 22:13:23 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2024-10-28 14:04:55 +01:00
|
|
|
await _tokenRepository.UpdateAsync(
|
|
|
|
|
token.Id,
|
|
|
|
|
discordToken.AccessToken,
|
|
|
|
|
discordToken.RefreshToken,
|
|
|
|
|
discordToken.ExpiresIn
|
|
|
|
|
);
|
2024-10-18 22:13:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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;
|
|
|
|
|
}
|