// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Models;
using NodaTime;
namespace Catalogger.Backend.Api;
public class DiscordRequestService
{
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
private readonly ApiCache _apiCache;
private readonly Config _config;
private readonly IClock _clock;
private readonly DatabaseContext _db;
private static readonly JsonSerializerOptions JsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
public DiscordRequestService(
ILogger logger,
ApiCache apiCache,
Config config,
IClock clock,
DatabaseContext db
)
{
_logger = logger.ForContext();
_apiCache = apiCache;
_config = config;
_clock = clock;
_db = db;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add(
"User-Agent",
"DiscordBot (https://codeberg.org/starshine/catalogger, v1)"
);
_httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
}
private async Task GetAsync(Uri uri, string? token)
{
_logger.Information(
"Sending request to {Uri}, authenticated? {Authed}",
uri,
token != null
);
var req = new HttpRequestMessage(HttpMethod.Get, uri);
if (token != null)
req.Headers.Add("Authorization", token);
var resp = await _httpClient.SendAsync(req);
if (!resp.IsSuccessStatusCode)
{
var errorText = await resp.Content.ReadAsStringAsync();
_logger.Error("Error requesting {Uri} from Discord API: {Error}", uri, errorText);
}
resp.EnsureSuccessStatusCode();
var entity = await resp.Content.ReadFromJsonAsync(JsonOptions);
if (entity == null)
throw new CataloggerError("Could not deserialize JSON from Discord API");
return entity;
}
private static readonly Uri DiscordUserUri = new("https://discord.com/api/v10/users/@me");
private static readonly Uri DiscordGuildsUri =
new("https://discord.com/api/v10/users/@me/guilds");
private static readonly Uri DiscordTokenUri = new("https://discord.com/api/oauth2/token");
public async Task GetMeAsync(string token) => await GetAsync(DiscordUserUri, token);
public async Task> GetGuildsAsync(string token) =>
await GetAsync>(DiscordGuildsUri, token);
public async Task GetMeAsync(ApiToken token)
{
var user = await _apiCache.GetUserAsync(token.UserId);
if (user != null)
return user;
await MaybeRefreshDiscordTokenAsync(token);
user = await GetMeAsync($"Bearer {token.AccessToken}");
await _apiCache.SetUserAsync(user);
return user;
}
public async Task> GetGuildsAsync(ApiToken token)
{
var guilds = await _apiCache.GetGuildsAsync(token.UserId);
if (guilds != null)
return guilds;
await MaybeRefreshDiscordTokenAsync(token);
guilds = await GetGuildsAsync($"Bearer {token.AccessToken}");
await _apiCache.SetGuildsAsync(token.UserId, guilds);
return guilds;
}
public async Task<(ApiToken Token, User MeUser, List Guilds)> RequestDiscordTokenAsync(
string code,
CancellationToken ct = default
)
{
var redirectUri = $"{_config.Web.BaseUrl}/callback";
var resp = await _httpClient.PostAsync(
DiscordTokenUri,
new FormUrlEncodedContent(
new Dictionary
{
{ "client_id", _config.Discord.ApplicationId.ToString() },
{ "client_secret", _config.Discord.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", redirectUri },
}
),
ct
);
if (!resp.IsSuccessStatusCode)
{
var respBody = await resp.Content.ReadAsStringAsync(ct);
_logger.Error(
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
(int)resp.StatusCode,
respBody
);
throw new CataloggerError("Invalid Discord OAuth response");
}
var token = await resp.Content.ReadFromJsonAsync(JsonOptions, ct);
if (token == null)
throw new CataloggerError("Discord token response was null");
var meUser = await GetMeAsync($"Bearer {token.AccessToken}");
var meGuilds = await GetGuildsAsync($"Bearer {token.AccessToken}");
var apiToken = new ApiToken
{
DashboardToken = ApiUtils.RandomToken(64),
UserId = meUser.Id,
AccessToken = token.AccessToken,
RefreshToken = token.RefreshToken,
ExpiresAt = _clock.GetCurrentInstant() + Duration.FromSeconds(token.ExpiresIn),
};
_db.Add(apiToken);
await _db.SaveChangesAsync(ct);
return (apiToken, meUser, meGuilds);
}
public async Task MaybeRefreshDiscordTokenAsync(ApiToken token)
{
if (_clock.GetCurrentInstant() < token.ExpiresAt - Duration.FromDays(1))
{
_logger.Debug(
"Discord token {TokenId} expires at {ExpiresAt}, not refreshing",
token.Id,
token.ExpiresAt
);
return;
}
if (token.RefreshToken == null)
{
_logger.Warning(
"Discord token {TokenId} for user {UserId} is almost expired but has no refresh token, cannot refresh",
token.Id,
token.UserId
);
return;
}
var resp = await _httpClient.PostAsync(
DiscordTokenUri,
new FormUrlEncodedContent(
new Dictionary
{
{ "client_id", _config.Discord.ApplicationId.ToString() },
{ "client_secret", _config.Discord.ClientSecret },
{ "grant_type", "refresh_token" },
{ "refresh_token", token.RefreshToken },
}
)
);
if (!resp.IsSuccessStatusCode)
{
var respBody = await resp.Content.ReadAsStringAsync();
_logger.Error(
"Received error status {StatusCode} when refreshing OAuth token: {ErrorBody}",
(int)resp.StatusCode,
respBody
);
return;
}
var discordToken = await resp.Content.ReadFromJsonAsync(JsonOptions);
if (discordToken == null)
{
_logger.Warning("Discord response for refreshing {TokenId} was null", token.Id);
return;
}
_logger.Information(
"Updating token {TokenId} with new access token and refresh token, expiring in {ExpiresIn}",
token.Id,
Duration.FromSeconds(discordToken.ExpiresIn)
);
token.AccessToken = discordToken.AccessToken;
token.RefreshToken = discordToken.RefreshToken;
token.ExpiresAt = _clock.GetCurrentInstant() + Duration.FromSeconds(discordToken.ExpiresIn);
_db.Update(token);
await _db.SaveChangesAsync();
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
private record DiscordTokenResponse(string AccessToken, string? RefreshToken, int ExpiresIn);
}
public record User(string Id, string Username, string Discriminator, string? Avatar)
{
public string Tag => Discriminator != "0" ? $"{Username}#{Discriminator}" : Username;
public string AvatarUrl =>
Avatar == null
? "https://cdn.discordapp.com/embed/avatars/0.png?size=256"
: $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.webp?size=256";
}
public record Guild(string Id, string Name, string? Icon, string? Permissions)
{
public string IconUrl =>
Icon == null
? "https://cdn.discordapp.com/embed/avatars/0.png?size=256"
: $"https://cdn.discordapp.com/icons/{Id}/{Icon}.webp?size=256";
public bool CanManage =>
ulong.TryParse(Permissions, out var perms)
&& ((perms & Administrator) == Administrator || (perms & ManageGuild) == ManageGuild);
private const ulong Administrator = 1 << 3;
private const ulong ManageGuild = 1 << 5;
}