// 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.Models; using Catalogger.Backend.Database.Repositories; 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 ApiTokenRepository _tokenRepository; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, }; public DiscordRequestService( ILogger logger, ApiCache apiCache, Config config, IClock clock, ApiTokenRepository tokenRepository ) { _logger = logger.ForContext(); _apiCache = apiCache; _config = config; _clock = clock; _tokenRepository = tokenRepository; _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 = await _tokenRepository.CreateAsync( ApiUtils.RandomToken(64), meUser.Id, token.AccessToken, token.RefreshToken, token.ExpiresIn ); 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); await _tokenRepository.UpdateAsync( token.Id, discordToken.AccessToken, discordToken.RefreshToken, discordToken.ExpiresIn ); } [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; }