From 8154b1cea5c724128057084f22b9d198bca19e77 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 27 Dec 2023 00:40:27 +0200 Subject: [PATCH] Added GitHub linkage --- .../Models/DemoUser.cs | 1 + .../Models/GitHubUserInfo2.cs | 26 + .../Pages/Basic/Profile.razor | 33 + .../Pages/GitHub/LinkedRole.razor | 25 + .../Pages/GitHub/OAuthCallback.razor | 127 ++ src/BUTR.Site.NexusMods.Client/Program.cs | 1 + .../Contexts/BaseAppDbContext.cs | 4 + ...egrationGitHubTokensEntityConfiguration.cs | 34 + ...rToIntegrationGitHubEntityConfiguration.cs | 27 + .../Contexts/EntityFactory.cs | 28 + .../Contexts/IAppDbContextRead.cs | 2 + .../Controllers/AuthenticationController.cs | 2 + .../Controllers/GitHubController.cs | 100 ++ .../Extensions/HttpContextExtensions.cs | 11 + .../20231226222434_GitHub.Designer.cs | 1597 +++++++++++++++++ .../Migrations/20231226222434_GitHub.cs | 82 + .../BaseAppDbContextModelSnapshot.cs | 78 + .../Models/API/ProfileModel.cs | 1 + .../Database/IntegrationGitHubTokensEntity.cs | 16 + .../Models/Database/NexusModsUserEntity.cs | 1 + .../NexusModsUserToIntegrationGitHubEntity.cs | 14 + .../Models/UserTypedMetadata.cs | 5 + .../Options/GitHubOptions.cs | 24 + .../ExternalStorage/IGitHubStorage.cs | 58 + .../Services/HttpClients/GitHubAPIClient.cs | 36 + .../Services/HttpClients/GitHubClient.cs | 84 + src/BUTR.Site.NexusMods.Server/Startup.cs | 14 + .../Clients.cs | 8 + 28 files changed, 2439 insertions(+) create mode 100644 src/BUTR.Site.NexusMods.Client/Models/GitHubUserInfo2.cs create mode 100644 src/BUTR.Site.NexusMods.Client/Pages/GitHub/LinkedRole.razor create mode 100644 src/BUTR.Site.NexusMods.Client/Pages/GitHub/OAuthCallback.razor create mode 100644 src/BUTR.Site.NexusMods.Server/Contexts/Configs/IntegrationGitHubTokensEntityConfiguration.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Contexts/Configs/NexusModsUserToIntegrationGitHubEntityConfiguration.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Controllers/GitHubController.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.Designer.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Models/Database/IntegrationGitHubTokensEntity.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserToIntegrationGitHubEntity.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Options/GitHubOptions.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Services/ExternalStorage/IGitHubStorage.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubAPIClient.cs create mode 100644 src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubClient.cs diff --git a/src/BUTR.Site.NexusMods.Client/Models/DemoUser.cs b/src/BUTR.Site.NexusMods.Client/Models/DemoUser.cs index 53ec2cdf..9ebe6fa1 100644 --- a/src/BUTR.Site.NexusMods.Client/Models/DemoUser.cs +++ b/src/BUTR.Site.NexusMods.Client/Models/DemoUser.cs @@ -25,6 +25,7 @@ public static class DemoUser steamUserId: null, gogUserId: null, discordUserId: null, + gitHubUserId: null, hasTenantGame: true, availableTenants: new List { new(tenantId: 1, name: "Bannerlord") }); private static readonly List _mods = new() diff --git a/src/BUTR.Site.NexusMods.Client/Models/GitHubUserInfo2.cs b/src/BUTR.Site.NexusMods.Client/Models/GitHubUserInfo2.cs new file mode 100644 index 00000000..380a82d0 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Client/Models/GitHubUserInfo2.cs @@ -0,0 +1,26 @@ +using BUTR.Site.NexusMods.ServerClient; + +namespace BUTR.Site.NexusMods.Client.Models +{ + public sealed record GitHubUserInfo2 + { + public string Url { get; init; } + public string Name { get; init; } + public bool NeedsRelink { get; init; } + + public GitHubUserInfo2(GitHubUserInfo? userInfo) + { + if (userInfo is null) + { + NeedsRelink = true; + Url = string.Empty; + Name = string.Empty; + } + else + { + Url = $"https://github.com/{userInfo.Login}"; + Name = userInfo.Login; + } + } + }; +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Client/Pages/Basic/Profile.razor b/src/BUTR.Site.NexusMods.Client/Pages/Basic/Profile.razor index 81bfc5f3..7b929226 100644 --- a/src/BUTR.Site.NexusMods.Client/Pages/Basic/Profile.razor +++ b/src/BUTR.Site.NexusMods.Client/Pages/Basic/Profile.razor @@ -7,6 +7,7 @@ @inject IDiscordClient _discordClient @inject ISteamClient _steamClient @inject IGOGClient _gogClient +@inject IGitHubClient _gitHubClient @if (_user is null) { @@ -90,6 +91,28 @@ else @user.Email + + GitHub + + @if (_user!.GitHubUserId is not null && _gitHubUserInfo is null) + { + ...loading + } + else if (_user.GitHubUserId is not null && _gitHubUserInfo!.NeedsRelink) + { + Needs Relinking + } + else if (_user.GitHubUserId is not null) + { + @_gitHubUserInfo.Name + } + else + { + Not Linked + } + + + Discord @@ -159,6 +182,7 @@ else ; private ProfileModel? _user; + private GitHubUserInfo2? _gitHubUserInfo; private DiscordUserInfo2? _discordUser; private SteamUserInfo2? _steamUser; private GOGUserInfo2? _gogUser; @@ -172,6 +196,15 @@ else var userResponse = await _userClient.ProfileAsync(); _user = userResponse.Value; + + if (_user?.GitHubUserId is not null) + { + _ = _gitHubClient.GetUserInfoAsync().ContinueWith(x => + { + _gitHubUserInfo = new(x.Result.Value); + StateHasChanged(); + }); + } if (_user?.DiscordUserId is not null) { diff --git a/src/BUTR.Site.NexusMods.Client/Pages/GitHub/LinkedRole.razor b/src/BUTR.Site.NexusMods.Client/Pages/GitHub/LinkedRole.razor new file mode 100644 index 00000000..a3125096 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Client/Pages/GitHub/LinkedRole.razor @@ -0,0 +1,25 @@ +@attribute [Authorize] +@page "/github-linked-role" + +@inject IGitHubClient _gitHubClient; +@inject ILocalStorageService _localStorage; +@inject NavigationManager _navigationManager; + +@code { + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + var response = await _gitHubClient.GetOAuthUrlAsync(); + if (response.Value?.Url is null) + { + _navigationManager.NavigateTo("profile"); + return; + } + + await _localStorage.SetItemAsync("github_state", response.Value.State); + _navigationManager.NavigateTo(response.Value.Url); + } + +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Client/Pages/GitHub/OAuthCallback.razor b/src/BUTR.Site.NexusMods.Client/Pages/GitHub/OAuthCallback.razor new file mode 100644 index 00000000..379b5cf6 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Client/Pages/GitHub/OAuthCallback.razor @@ -0,0 +1,127 @@ +@attribute [Authorize] +@page "/github-oauth-callback" + +@inject NavigationManager _navigationManager; +@inject ILocalStorageService _localStorage; +@inject AuthenticationProvider _authenticationProvider; +@inject IGitHubClient _gitHubClient; + + + + + + + @_status + + @if (_userInfo is not null) + { + + @_userInfo.Name was successfully linked with the BUTR Site! + + } + else + { + + + + } + + @if (!string.IsNullOrEmpty(_message)) + { + + @_message + + } + + @if (!string.IsNullOrEmpty(_image)) + { + + + + } + + + Use the "Linked Roles" option in servers with the BUTR Discord bot to claim your roles. + + + + + + + + + + +@code { + + private const string Success = "images/success.gif"; + private const string Failure = "images/failure.gif"; + + private string _status = string.Empty; + private string _message = string.Empty; + private string _image = string.Empty; + private GitHubUserInfo2? _userInfo; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + if (!await _localStorage.ContainKeyAsync("github_state")) + { + _status = "FAILURE"; + _message = "State verification failed."; + _image = Failure; + return; + } + + var queries = _navigationManager.QueryString(); + var queryStatRaw = queries["state"]; + var queryCode = queries["code"]; + + try + { + var state = await _localStorage.GetItemAsync("github_state"); + if (!Guid.TryParse(queryStatRaw, out var queryState) || state != queryState) + { + _status = "FAILURE"; + _message = "State verification failed."; + _image = Failure; + return; + } + + await _gitHubClient.LinkAsync(code: queryCode); + _ = await _authenticationProvider.ValidateAsync(); + + if (await _gitHubClient.GetUserInfoAsync() is { Value: var userInfo }) + { + _userInfo = new GitHubUserInfo2(userInfo); + _status = "SUCCESS"; + _image = Success; + } + else + { + _status = "FAILURE"; + _message = "Failed to link!"; + _image = Failure; + } + } + finally + { + await _localStorage.RemoveItemAsync("github_state"); + } + } + +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Client/Program.cs b/src/BUTR.Site.NexusMods.Client/Program.cs index 36210a15..efd0a083 100644 --- a/src/BUTR.Site.NexusMods.Client/Program.cs +++ b/src/BUTR.Site.NexusMods.Client/Program.cs @@ -104,6 +104,7 @@ public static WebAssemblyHostBuilder CreateHostBuilder(string[] args) services.AddTransient(sp => ConfigureClient(sp, (http, opt) => new StatisticsClient(http, opt))); services.AddTransient(sp => ConfigureClient(sp, (http, opt) => new QuartzClient(http, opt))); services.AddTransient(sp => ConfigureClient(sp, (http, opt) => new RecreateStacktraceClient(http, opt))); + services.AddTransient(sp => ConfigureClient(sp, (http, opt) => new GitHubClient(http, opt))); services.AddScoped(); diff --git a/src/BUTR.Site.NexusMods.Server/Contexts/BaseAppDbContext.cs b/src/BUTR.Site.NexusMods.Server/Contexts/BaseAppDbContext.cs index 8c283a4b..c2d650cd 100644 --- a/src/BUTR.Site.NexusMods.Server/Contexts/BaseAppDbContext.cs +++ b/src/BUTR.Site.NexusMods.Server/Contexts/BaseAppDbContext.cs @@ -38,10 +38,12 @@ public class BaseAppDbContext : DbContext public DbSet NexusModsUserToNexusModsMods { get; set; } = default!; public DbSet NexusModsUserToModules { get; set; } = default!; + public DbSet NexusModsUserToGitHub { get; set; } = default!; public DbSet NexusModsUserToDiscord { get; set; } = default!; public DbSet NexusModsUserToGOG { get; set; } = default!; public DbSet NexusModsUserToSteam { get; set; } = default!; + public DbSet IntegrationGitHubTokens { get; set; } = default!; public DbSet IntegrationDiscordTokens { get; set; } = default!; public DbSet IntegrationGOGTokens { get; set; } = default!; public DbSet IntegrationGOGToOwnedTenants { get; set; } = default!; @@ -96,6 +98,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) _entityConfigurationFactory.ApplyConfigurationWithTenant(modelBuilder); + _entityConfigurationFactory.ApplyConfiguration(modelBuilder); _entityConfigurationFactory.ApplyConfiguration(modelBuilder); _entityConfigurationFactory.ApplyConfiguration(modelBuilder); _entityConfigurationFactory.ApplyConfiguration(modelBuilder); @@ -115,6 +118,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) _entityConfigurationFactory.ApplyConfiguration(modelBuilder); _entityConfigurationFactory.ApplyConfigurationWithTenant(modelBuilder); + _entityConfigurationFactory.ApplyConfiguration(modelBuilder); _entityConfigurationFactory.ApplyConfiguration(modelBuilder); _entityConfigurationFactory.ApplyConfiguration(modelBuilder); _entityConfigurationFactory.ApplyConfigurationWithTenant(modelBuilder); diff --git a/src/BUTR.Site.NexusMods.Server/Contexts/Configs/IntegrationGitHubTokensEntityConfiguration.cs b/src/BUTR.Site.NexusMods.Server/Contexts/Configs/IntegrationGitHubTokensEntityConfiguration.cs new file mode 100644 index 00000000..cc3e03b9 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Contexts/Configs/IntegrationGitHubTokensEntityConfiguration.cs @@ -0,0 +1,34 @@ +using BUTR.Site.NexusMods.Server.Models; +using BUTR.Site.NexusMods.Server.Models.Database; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BUTR.Site.NexusMods.Server.Contexts.Configs; + +public class IntegrationGitHubTokensEntityConfiguration : BaseEntityConfiguration +{ + protected override void ConfigureModel(EntityTypeBuilder builder) + { + builder.Property(nameof(NexusModsUserEntity.NexusModsUserId)).HasColumnName("integration_github_tokens_id").HasValueObjectConversion().ValueGeneratedNever(); + builder.Property(x => x.GitHubUserId).HasColumnName("github_user_id"); + builder.Property(x => x.AccessToken).HasColumnName("access_token"); + builder.ToTable("integration_github_tokens", "integration").HasKey(nameof(NexusModsUserEntity.NexusModsUserId)); + + builder.HasOne(x => x.NexusModsUser) + .WithOne() + .HasForeignKey(nameof(NexusModsUserEntity.NexusModsUserId)) + .HasPrincipalKey(x => x.NexusModsUserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(x => x.UserToGitHub) + .WithOne(x => x.ToTokens) + .HasForeignKey(x => x.GitHubUserId) + .HasPrincipalKey(x => x.GitHubUserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(x => x.NexusModsUser).AutoInclude(); + + base.ConfigureModel(builder); + } +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Contexts/Configs/NexusModsUserToIntegrationGitHubEntityConfiguration.cs b/src/BUTR.Site.NexusMods.Server/Contexts/Configs/NexusModsUserToIntegrationGitHubEntityConfiguration.cs new file mode 100644 index 00000000..9624b570 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Contexts/Configs/NexusModsUserToIntegrationGitHubEntityConfiguration.cs @@ -0,0 +1,27 @@ +using BUTR.Site.NexusMods.Server.Models; +using BUTR.Site.NexusMods.Server.Models.Database; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BUTR.Site.NexusMods.Server.Contexts.Configs; + +public class NexusModsUserToIntegrationGitHubEntityConfiguration : BaseEntityConfiguration +{ + protected override void ConfigureModel(EntityTypeBuilder builder) + { + builder.Property(nameof(NexusModsUserEntity.NexusModsUserId)).HasColumnName("nexusmods_user_to_github_id").HasValueObjectConversion().ValueGeneratedNever(); + builder.Property(x => x.GitHubUserId).HasColumnName("github_user_id"); + builder.ToTable("nexusmods_user_to_integration_github", "nexusmods_user").HasKey(nameof(NexusModsUserEntity.NexusModsUserId)); + + builder.HasOne(x => x.NexusModsUser) + .WithOne(x => x.ToGitHub) + .HasForeignKey(nameof(NexusModsUserEntity.NexusModsUserId)) + .HasPrincipalKey(x => x.NexusModsUserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(x => x.NexusModsUser).AutoInclude(); + + base.ConfigureModel(builder); + } +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Contexts/EntityFactory.cs b/src/BUTR.Site.NexusMods.Server/Contexts/EntityFactory.cs index 8fd180de..3a4f3c64 100644 --- a/src/BUTR.Site.NexusMods.Server/Contexts/EntityFactory.cs +++ b/src/BUTR.Site.NexusMods.Server/Contexts/EntityFactory.cs @@ -20,9 +20,11 @@ public sealed class EntityFactory private readonly ConcurrentDictionary _nexusModsMods = new(); private readonly ConcurrentDictionary _modules = new(); private readonly ConcurrentDictionary _exceptionTypes = new(); + private readonly ConcurrentDictionary _gitHubUsers = new(); private readonly ConcurrentDictionary _discordUsers = new(); private readonly ConcurrentDictionary _steamUsers = new(); private readonly ConcurrentDictionary _gogUsers = new(); + private readonly ConcurrentDictionary _gitHubTokens = new(); private readonly ConcurrentDictionary _discordTokens = new(); private readonly ConcurrentDictionary _gogTokens = new(); private readonly ConcurrentDictionary _steamTokens = new(); @@ -63,6 +65,17 @@ public NexusModsUserToIntegrationSteamEntity GetOrCreateNexusModsUserSteam(Nexus }; } + public NexusModsUserToIntegrationGitHubEntity GetOrCreateNexusModsUserGitHub(NexusModsUserId nexusModsUserId, string gitHubUserId) + { + return _gitHubUsers.GetOrAdd(gitHubUserId, ValueFactory, (this, nexusModsUserId)); + + static NexusModsUserToIntegrationGitHubEntity ValueFactory(string gitHubUserId_, (EntityFactory, NexusModsUserId) tuple) => new() + { + NexusModsUser = tuple.Item1.GetOrCreateNexusModsUser(tuple.Item2), + GitHubUserId = gitHubUserId_, + }; + } + public NexusModsUserToIntegrationDiscordEntity GetOrCreateNexusModsUserDiscord(NexusModsUserId nexusModsUserId, string discordUserId) { return _discordUsers.GetOrAdd(discordUserId, ValueFactory, (this, nexusModsUserId)); @@ -85,6 +98,19 @@ public NexusModsUserToIntegrationGOGEntity GetOrCreateNexusModsUserGOG(NexusMods }; } + public IntegrationGitHubTokensEntity GetOrCreateIntegrationGitHubTokens(NexusModsUserId nexusModsUserId, string gitHubUserId, string accessToken) + { + return _gitHubTokens.GetOrAdd(gitHubUserId, ValueFactory, (this, nexusModsUserId, accessToken)); + + static IntegrationGitHubTokensEntity ValueFactory(string gitHubUserId, (EntityFactory, NexusModsUserId, string) tuple) => new() + { + GitHubUserId = gitHubUserId, + NexusModsUser = tuple.Item1.GetOrCreateNexusModsUser(tuple.Item2), + AccessToken = tuple.Item3, + //UserToDiscord = GetOrCreateNexusModsUserDiscord(nexusModsUserId, discordUserId), + }; + } + public IntegrationDiscordTokensEntity GetOrCreateIntegrationDiscordTokens(NexusModsUserId nexusModsUserId, string discordUserId, string accessToken, string refreshToken, DateTimeOffset accessTokenExpiresAt) { return _discordTokens.GetOrAdd(discordUserId, ValueFactory, (this, nexusModsUserId, accessToken, refreshToken, accessTokenExpiresAt)); @@ -177,9 +203,11 @@ async Task DoChange(Func action) if (!_nexusModsMods.IsEmpty) await DoChange(() => _dbContextWrite.NexusModsMods.UpsertAsync(_nexusModsMods.Values)); if (!_modules.IsEmpty) await DoChange(() => _dbContextWrite.Modules.UpsertAsync(_modules.Values)); if (!_exceptionTypes.IsEmpty) await DoChange(() => _dbContextWrite.ExceptionTypes.UpsertAsync(_exceptionTypes.Values)); + if (!_gitHubUsers.IsEmpty) await DoChange(() => _dbContextWrite.NexusModsUserToGitHub.UpsertAsync(_gitHubUsers.Values)); if (!_discordUsers.IsEmpty) await DoChange(() => _dbContextWrite.NexusModsUserToDiscord.UpsertAsync(_discordUsers.Values)); if (!_steamUsers.IsEmpty) await DoChange(() => _dbContextWrite.NexusModsUserToSteam.UpsertAsync(_steamUsers.Values)); if (!_gogUsers.IsEmpty) await DoChange(() => _dbContextWrite.NexusModsUserToGOG.UpsertAsync(_gogUsers.Values)); + if (!_gitHubTokens.IsEmpty) await DoChange(() => _dbContextWrite.IntegrationGitHubTokens.UpsertAsync(_gitHubTokens.Values)); if (!_discordTokens.IsEmpty) await DoChange(() => _dbContextWrite.IntegrationDiscordTokens.UpsertAsync(_discordTokens.Values)); if (!_gogTokens.IsEmpty) await DoChange(() => _dbContextWrite.IntegrationGOGTokens.UpsertAsync(_gogTokens.Values)); if (!_steamTokens.IsEmpty) await DoChange(() => _dbContextWrite.IntegrationSteamTokens.UpsertAsync(_steamTokens.Values)); diff --git a/src/BUTR.Site.NexusMods.Server/Contexts/IAppDbContextRead.cs b/src/BUTR.Site.NexusMods.Server/Contexts/IAppDbContextRead.cs index 4b6771ce..95a5ca74 100644 --- a/src/BUTR.Site.NexusMods.Server/Contexts/IAppDbContextRead.cs +++ b/src/BUTR.Site.NexusMods.Server/Contexts/IAppDbContextRead.cs @@ -26,10 +26,12 @@ public interface IAppDbContextRead DbSet NexusModsUserToNexusModsMods { get; } DbSet NexusModsUserToModules { get; } + DbSet NexusModsUserToGitHub { get; } DbSet NexusModsUserToDiscord { get; } DbSet NexusModsUserToGOG { get; } DbSet NexusModsUserToSteam { get; } + DbSet IntegrationGitHubTokens { get; } DbSet IntegrationDiscordTokens { get; } DbSet IntegrationGOGTokens { get; } DbSet IntegrationGOGToOwnedTenants { get; } diff --git a/src/BUTR.Site.NexusMods.Server/Controllers/AuthenticationController.cs b/src/BUTR.Site.NexusMods.Server/Controllers/AuthenticationController.cs index 932d365b..ebb58d4b 100644 --- a/src/BUTR.Site.NexusMods.Server/Controllers/AuthenticationController.cs +++ b/src/BUTR.Site.NexusMods.Server/Controllers/AuthenticationController.cs @@ -59,6 +59,7 @@ public AuthenticationController( var userEntity = await _dbContextRead.NexusModsUsers .Include(x => x.ToRoles) + .Include(x => x.ToGitHub!).ThenInclude(x => x.ToTokens) .Include(x => x.ToDiscord!).ThenInclude(x => x.ToTokens) .Include(x => x.ToGOG!).ThenInclude(x => x.ToTokens) .Include(x => x.ToGOG!).ThenInclude(x => x.ToOwnedTenants) @@ -98,6 +99,7 @@ public AuthenticationController( var userEntity = await _dbContextRead.NexusModsUsers .Include(x => x.ToRoles) + .Include(x => x.ToGitHub!).ThenInclude(x => x.ToTokens) .Include(x => x.ToDiscord!).ThenInclude(x => x.ToTokens) .Include(x => x.ToGOG!).ThenInclude(x => x.ToTokens) .Include(x => x.ToGOG!).ThenInclude(x => x.ToOwnedTenants) diff --git a/src/BUTR.Site.NexusMods.Server/Controllers/GitHubController.cs b/src/BUTR.Site.NexusMods.Server/Controllers/GitHubController.cs new file mode 100644 index 00000000..c179c5ff --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Controllers/GitHubController.cs @@ -0,0 +1,100 @@ +using BUTR.Site.NexusMods.Server.Contexts; +using BUTR.Site.NexusMods.Server.Extensions; +using BUTR.Site.NexusMods.Server.Models; +using BUTR.Site.NexusMods.Server.Services; +using BUTR.Site.NexusMods.Server.Utils; +using BUTR.Site.NexusMods.Server.Utils.BindingSources; +using BUTR.Site.NexusMods.Server.Utils.Http.ApiResults; +using BUTR.Site.NexusMods.Shared.Helpers; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +using System; +using System.Linq; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace BUTR.Site.NexusMods.Server.Controllers; + +[ApiController, Route("api/v1/[controller]"), ButrNexusModsAuthorization, TenantNotRequired] +public sealed class GitHubController : ApiControllerBase +{ + public sealed record GitHubOAuthUrlModel(string Url, Guid State); + + private readonly GitHubClient _gitHubClient; + private readonly GitHubAPIClient _gitHubApiClient; + private readonly IGitHubStorage _gitHubStorage; + + public GitHubController(GitHubClient gitHubClient, GitHubAPIClient gitHubApiClient, IGitHubStorage gitHubStorage) + { + _gitHubClient = gitHubClient ?? throw new ArgumentNullException(nameof(gitHubClient)); + _gitHubApiClient = gitHubApiClient ?? throw new ArgumentNullException(nameof(gitHubApiClient)); + _gitHubStorage = gitHubStorage ?? throw new ArgumentNullException(nameof(gitHubStorage)); + } + + [HttpGet("GetOAuthUrl")] + [Produces("application/json")] + public ApiResult GetOAuthUrl() + { + var (url, state) = _gitHubClient.GetOAuthUrl(); + return ApiResult(new GitHubOAuthUrlModel(url, state)); + } + + [HttpGet("Link")] + public async Task> LinkAsync([FromQuery] string code, [BindRole] ApplicationRole role, [BindUserId] NexusModsUserId userId, CancellationToken ct) + { + var tokens = await _gitHubClient.CreateTokensAsync(code, ct); + if (tokens is null) + return ApiBadRequest("Failed to link!"); + + var userInfo = await _gitHubApiClient.GetUserInfoAsync(tokens, ct); + + if (userInfo is null || !await _gitHubStorage.UpsertAsync(userId, userInfo.Id.ToString(), tokens)) + return ApiBadRequest("Failed to link!"); + + return ApiResult("Linked successful!"); + } + + [HttpPost("Unlink")] + public async Task> UnlinkAsync([BindUserId] NexusModsUserId userId, CancellationToken ct) + { + var tokens = HttpContext.GetDiscordTokens(); + + if (tokens?.Data is null) + return ApiBadRequest("Unlinked successful!"); + + //var refreshed = await _gitHubClient.GetOrRefreshTokensAsync(tokens.Data, ct); + //if (refreshed is null) + // return ApiBadRequest("Failed to unlink!"); + + //if (tokens.Data.AccessToken != refreshed.AccessToken) + // await _discordStorage.UpsertAsync(userId, tokens.ExternalId, refreshed); + + if (!await _gitHubStorage.RemoveAsync(userId, tokens.ExternalId)) + return ApiBadRequest("Failed to unlink!"); + + return ApiResult("Unlinked successful!"); + } + + [HttpPost("GetUserInfo")] + public async Task> GetUserInfoByAccessTokenAsync([BindUserId] NexusModsUserId userId, CancellationToken ct) + { + var tokens = HttpContext.GetGitHubTokens(); + + if (tokens?.Data is null) + return ApiBadRequest("Failed to get the token!"); + + //var refreshed = await _gitHubClient.GetOrRefreshTokensAsync(tokens.Data, ct); + //if (refreshed is null) + // return ApiResult(null); + + //if (tokens.Data.AccessToken != refreshed.AccessToken) + // await _discordStorage.UpsertAsync(userId, tokens.ExternalId, refreshed); + + //var result = await _gitHubApiClient.GetUserInfoAsync(refreshed, ct); + var result = await _gitHubApiClient.GetUserInfoAsync(tokens.Data, ct); + return ApiResult(result); + } +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Extensions/HttpContextExtensions.cs b/src/BUTR.Site.NexusMods.Server/Extensions/HttpContextExtensions.cs index f0a3a51c..0c8428b2 100644 --- a/src/BUTR.Site.NexusMods.Server/Extensions/HttpContextExtensions.cs +++ b/src/BUTR.Site.NexusMods.Server/Extensions/HttpContextExtensions.cs @@ -30,6 +30,7 @@ public static ProfileModel GetProfile(this HttpContext context, NexusModsValidat IsPremium = validate.IsPremium, IsSupporter = validate.IsSupporter, Role = role, + GitHubUserId = GetGitHubId(metadata, jsonSerializerOptions), DiscordUserId = GetDiscordId(metadata, jsonSerializerOptions), GOGUserId = GetGOGId(metadata, jsonSerializerOptions), SteamUserId = GetSteamId(metadata, jsonSerializerOptions), @@ -55,6 +56,7 @@ public static ProfileModel GetProfile(this HttpContext context) IsPremium = context.GetIsPremium(), IsSupporter = context.GetIsSupporter(), Role = context.GetRole(), + GitHubUserId = GetGitHubId(context.GetMetadata(), jsonSerializerOptions), DiscordUserId = GetDiscordId(context.GetMetadata(), jsonSerializerOptions), GOGUserId = GetGOGId(context.GetMetadata(), jsonSerializerOptions), SteamUserId = GetSteamId(context.GetMetadata(), jsonSerializerOptions), @@ -125,6 +127,8 @@ public static bool OwnsTenantGame(this HttpContext context) return OwnsTenantGame(tenant, context.GetMetadata(), jsonSerializerOptions); } + public static string? GetGitHubId(Dictionary metadata, JsonSerializerOptions jsonSerializerOptions) => + GetTypedMetadata(metadata, jsonSerializerOptions).GitHub?.ExternalId; public static string? GetDiscordId(Dictionary metadata, JsonSerializerOptions jsonSerializerOptions) => GetTypedMetadata(metadata, jsonSerializerOptions).Discord?.ExternalId; public static string? GetGOGId(Dictionary metadata, JsonSerializerOptions jsonSerializerOptions) => @@ -134,6 +138,13 @@ public static bool OwnsTenantGame(this HttpContext context) public static bool OwnsTenantGame(TenantId tenant, Dictionary metadata, JsonSerializerOptions jsonSerializerOptions) => GetTypedMetadata(metadata, jsonSerializerOptions).OwnedTenants.Contains(tenant); + public static ExternalDataHolder? GetGitHubTokens(this HttpContext context) + { + var options = context.RequestServices.GetRequiredService>().Value; + var typedMetadata = GetTypedMetadata(context.GetMetadata(), options); + return typedMetadata.GitHub; + } + public static ExternalDataHolder? GetDiscordTokens(this HttpContext context) { var options = context.RequestServices.GetRequiredService>().Value; diff --git a/src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.Designer.cs b/src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.Designer.cs new file mode 100644 index 00000000..d41440af --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.Designer.cs @@ -0,0 +1,1597 @@ +// +using System; +using System.Collections.Generic; +using BUTR.Site.NexusMods.Server.Contexts; +using BUTR.Site.NexusMods.Server.Models.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BUTR.Site.NexusMods.Server.Migrations +{ + [DbContext(typeof(BaseAppDbContext))] + [Migration("20231226222434_GitHub")] + partial class GitHub + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.HasSequence("quartz_log_id_seq", "quartz"); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.AutocompleteEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("AutocompleteId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("autocomplete_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AutocompleteId")); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("TenantId", "AutocompleteId"); + + b.HasIndex("Type"); + + b.ToTable("autocomplete", "autocomplete"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("CrashReportId") + .HasColumnType("uuid") + .HasColumnName("crash_report_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("ExceptionTypeId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("exception_type_id"); + + b.Property("GameVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("game_version"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("smallint") + .HasColumnName("version"); + + b.HasKey("TenantId", "CrashReportId"); + + b.HasIndex("TenantId", "ExceptionTypeId"); + + b.ToTable("crash_report", "crashreport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportIgnoredFileEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("crash_report_file_ignored_id"); + + b.HasKey("TenantId", "Value"); + + b.ToTable("crash_report_file_ignored", "crashreport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToFileIdEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("CrashReportId") + .HasColumnType("uuid") + .HasColumnName("crash_report_file_id"); + + b.Property("FileId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_id"); + + b.HasKey("TenantId", "CrashReportId"); + + b.ToTable("crash_report_file", "crashreport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToMetadataEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("CrashReportId") + .HasColumnType("uuid") + .HasColumnName("crash_report_metadata_id"); + + b.Property("BLSEVersion") + .HasColumnType("text") + .HasColumnName("blse_version"); + + b.Property("BUTRLoaderVersion") + .HasColumnType("text") + .HasColumnName("butrloader_version"); + + b.Property("LauncherExVersion") + .HasColumnType("text") + .HasColumnName("launcherex_version"); + + b.Property("LauncherType") + .HasColumnType("text") + .HasColumnName("launcher_type"); + + b.Property("LauncherVersion") + .HasColumnType("text") + .HasColumnName("launcher_version"); + + b.Property("Runtime") + .HasColumnType("text") + .HasColumnName("runtime"); + + b.HasKey("TenantId", "CrashReportId"); + + b.ToTable("crash_report_metadata", "crashreport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToModuleMetadataEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("CrashReportId") + .HasColumnType("uuid") + .HasColumnName("crash_report_module_info_id"); + + b.Property("ModuleId") + .HasColumnType("text") + .HasColumnName("module_id"); + + b.Property("IsInvolved") + .HasColumnType("boolean") + .HasColumnName("is_involved"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_id"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text") + .HasColumnName("version"); + + b.HasKey("TenantId", "CrashReportId", "ModuleId"); + + b.HasIndex("TenantId", "ModuleId"); + + b.HasIndex("TenantId", "NexusModsModId"); + + b.ToTable("crash_report_module_info", "crashreport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.ExceptionTypeEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("ExceptionTypeId") + .HasColumnType("text") + .HasColumnName("exception_type_id"); + + b.HasKey("TenantId", "ExceptionTypeId"); + + b.ToTable("exception_type", "exception"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationDiscordTokensEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("integration_discord_tokens_id"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_at"); + + b.Property("DiscordUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("discord_user_id"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.HasKey("NexusModsUserId"); + + b.HasIndex("DiscordUserId") + .IsUnique(); + + b.ToTable("integration_discord_tokens", "integration"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGOGToOwnedTenantEntity", b => + { + b.Property("GOGUserId") + .HasColumnType("text") + .HasColumnName("integration_gog_owned_tenant_id"); + + b.Property("OwnedTenant") + .HasColumnType("smallint") + .HasColumnName("owned_tenant"); + + b.HasKey("GOGUserId", "OwnedTenant"); + + b.HasIndex("OwnedTenant"); + + b.ToTable("integration_gog_owned_tenant", "integration"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGOGTokensEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("integration_gog_tokens_id"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_at"); + + b.Property("GOGUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("gog_user_id"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.HasKey("NexusModsUserId"); + + b.HasIndex("GOGUserId") + .IsUnique(); + + b.ToTable("integration_gog_tokens", "integration"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("integration_github_tokens_id"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("GitHubUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("github_user_id"); + + b.HasKey("NexusModsUserId"); + + b.HasIndex("GitHubUserId") + .IsUnique(); + + b.ToTable("integration_github_tokens", "integration"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamToOwnedTenantEntity", b => + { + b.Property("SteamUserId") + .HasColumnType("text") + .HasColumnName("integration_steam_owned_tenant_id"); + + b.Property("OwnedTenant") + .HasColumnType("smallint") + .HasColumnName("owned_tenant"); + + b.HasKey("SteamUserId", "OwnedTenant"); + + b.HasIndex("OwnedTenant"); + + b.ToTable("integration_steam_owned_tenant", "integration"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamTokensEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("integration_steam_tokens_id"); + + b.Property>("Data") + .IsRequired() + .HasColumnType("hstore") + .HasColumnName("data"); + + b.Property("SteamUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("steam_user_id"); + + b.HasKey("NexusModsUserId"); + + b.HasIndex("SteamUserId") + .IsUnique(); + + b.ToTable("integration_steam_tokens", "integration"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("ModuleId") + .HasColumnType("text") + .HasColumnName("module_id"); + + b.HasKey("TenantId", "ModuleId"); + + b.ToTable("module", "module"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsArticleEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsArticleId") + .HasColumnType("integer") + .HasColumnName("nexusmods_article_entity_id"); + + b.Property("CreateDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("create_date"); + + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("author_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("TenantId", "NexusModsArticleId"); + + b.HasIndex("NexusModsUserId"); + + b.ToTable("nexusmods_article_entity", "nexusmods_article"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_id"); + + b.HasKey("TenantId", "NexusModsModId"); + + b.ToTable("nexusmods_mod", "nexusmods_mod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToFileUpdateEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_file_update_id"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_of_last_check"); + + b.HasKey("TenantId", "NexusModsModId"); + + b.ToTable("nexusmods_mod_file_update", "nexusmods_mod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_module_id"); + + b.Property("ModuleId") + .HasColumnType("text") + .HasColumnName("module_id"); + + b.Property("LinkType") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_module_link_type_id"); + + b.Property("LastUpdateDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_of_last_update"); + + b.HasKey("TenantId", "NexusModsModId", "ModuleId", "LinkType"); + + b.HasIndex("TenantId", "ModuleId"); + + b.ToTable("nexusmods_mod_module", "nexusmods_mod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleInfoHistoryEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsFileId") + .HasColumnType("integer") + .HasColumnName("nexusmods_file_id"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_module_info_history_id"); + + b.Property("ModuleId") + .HasColumnType("text") + .HasColumnName("module_id"); + + b.Property("ModuleVersion") + .HasColumnType("text") + .HasColumnName("module_version"); + + b.Property("ModuleInfo") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("module_info"); + + b.Property("UploadDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_of_upload"); + + b.HasKey("TenantId", "NexusModsFileId", "NexusModsModId", "ModuleId", "ModuleVersion"); + + b.HasIndex("TenantId", "ModuleId"); + + b.HasIndex("TenantId", "NexusModsModId"); + + b.ToTable("nexusmods_mod_module_info_history", "nexusmods_mod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleInfoHistoryGameVersionEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsFileId") + .HasColumnType("integer") + .HasColumnName("nexusmods_file_id"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_module_info_history_game_version_id"); + + b.Property("ModuleId") + .HasColumnType("text") + .HasColumnName("module_id"); + + b.Property("ModuleVersion") + .HasColumnType("text") + .HasColumnName("module_version"); + + b.Property("GameVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("game_version"); + + b.HasKey("TenantId", "NexusModsFileId", "NexusModsModId", "ModuleId", "ModuleVersion"); + + b.HasIndex("TenantId", "ModuleId"); + + b.HasIndex("TenantId", "NexusModsModId"); + + b.ToTable("nexusmods_mod_module_info_history_game_version", "nexusmods_mod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToNameEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_name_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("TenantId", "NexusModsModId"); + + b.ToTable("nexusmods_mod_name", "nexusmods_mod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_id"); + + b.HasKey("NexusModsUserId"); + + b.ToTable("nexusmods_user", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToCrashReportEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_crash_report_id"); + + b.Property("CrashReportId") + .HasColumnType("uuid") + .HasColumnName("crash_report_id"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text") + .HasColumnName("comment"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("TenantId", "NexusModsUserId", "CrashReportId"); + + b.HasIndex("NexusModsUserId"); + + b.HasIndex("TenantId", "CrashReportId"); + + b.ToTable("nexusmods_user_crash_report", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationDiscordEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_to_discord_id"); + + b.Property("DiscordUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("discord_user_id"); + + b.HasKey("NexusModsUserId"); + + b.ToTable("nexusmods_user_to_integration_discord", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGOGEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_to_gog_id"); + + b.Property("GOGUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("gog_user_id"); + + b.HasKey("NexusModsUserId"); + + b.ToTable("nexusmods_user_to_integration_gog", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_to_github_id"); + + b.Property("GitHubUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("github_user_id"); + + b.HasKey("NexusModsUserId"); + + b.ToTable("nexusmods_user_to_integration_github", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_to_steam_id"); + + b.Property("SteamUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("steam_user_id"); + + b.HasKey("NexusModsUserId"); + + b.ToTable("nexusmods_user_to_integration_steam", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToModuleEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_module_id"); + + b.Property("ModuleId") + .HasColumnType("text") + .HasColumnName("module_id"); + + b.Property("LinkType") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_module_link_type_id"); + + b.HasKey("TenantId", "NexusModsUserId", "ModuleId", "LinkType"); + + b.HasIndex("NexusModsUserId"); + + b.HasIndex("TenantId", "ModuleId"); + + b.ToTable("nexusmods_user_module", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToNameEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_name_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("NexusModsUserId"); + + b.ToTable("nexusmods_user_name", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToNexusModsModEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_nexusmods_mod_id"); + + b.Property("NexusModsModId") + .HasColumnType("integer") + .HasColumnName("nexusmods_mod_id"); + + b.Property("LinkType") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_nexusmods_mod_link_type_id"); + + b.HasKey("TenantId", "NexusModsUserId", "NexusModsModId", "LinkType"); + + b.HasIndex("NexusModsUserId"); + + b.HasIndex("TenantId", "NexusModsModId"); + + b.ToTable("nexusmods_user_nexusmods_mod", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToRoleEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_role_id"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("TenantId", "NexusModsUserId"); + + b.HasIndex("NexusModsUserId"); + + b.ToTable("nexusmods_user_role", "nexusmods_user"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.QuartzExecutionLogEntity", b => + { + b.Property("RunInstanceId") + .HasColumnType("text") + .HasColumnName("run_instance_id"); + + b.Property("JobName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("job_group"); + + b.Property("TriggerName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("trigger_group"); + + b.Property("FireTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("fire_time_utc"); + + b.Property("DateAddedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_added_utc"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("ExecutionLogDetail") + .HasColumnType("jsonb") + .HasColumnName("log_detail"); + + b.Property("IsException") + .HasColumnType("boolean") + .HasColumnName("is_exception"); + + b.Property("IsSuccess") + .HasColumnType("boolean") + .HasColumnName("is_success"); + + b.Property("IsVetoed") + .HasColumnType("boolean") + .HasColumnName("is_vetoed"); + + b.Property("JobRunTime") + .HasColumnType("interval") + .HasColumnName("job_run_time"); + + b.Property("LogId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("quartz_log_id") + .HasDefaultValueSql("nextval('\"quartz\".\"quartz_log_id_seq\"')"); + + b.Property("MachineName") + .HasColumnType("text") + .HasColumnName("machie_name"); + + b.Property("Result") + .HasColumnType("text") + .HasColumnName("result"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("ReturnCode") + .HasColumnType("text") + .HasColumnName("return_code"); + + b.Property("ScheduleFireTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("schedule_fire_time_utc"); + + b.HasKey("RunInstanceId", "JobName", "JobGroup", "TriggerName", "TriggerGroup", "FireTimeUtc"); + + b.ToTable("quartz_log", "quartz"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.StatisticsCrashScoreInvolvedEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("StatisticsCrashScoreInvolvedId") + .HasColumnType("uuid") + .HasColumnName("crash_score_involved_id"); + + b.Property("GameVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("game_version"); + + b.Property("InvolvedCount") + .HasColumnType("integer") + .HasColumnName("involved_count"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("module_id"); + + b.Property("ModuleVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("module_version"); + + b.Property("NotInvolvedCount") + .HasColumnType("integer") + .HasColumnName("not_involved_count"); + + b.Property("RawValue") + .HasColumnType("integer") + .HasColumnName("value"); + + b.Property("Score") + .HasColumnType("double precision") + .HasColumnName("crash_score"); + + b.Property("TotalCount") + .HasColumnType("integer") + .HasColumnName("total_count"); + + b.HasKey("TenantId", "StatisticsCrashScoreInvolvedId"); + + b.HasIndex("TenantId", "ModuleId"); + + b.ToTable("crash_score_involved", "statistics"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.StatisticsTopExceptionsTypeEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant"); + + b.Property("ExceptionTypeId") + .HasColumnType("text") + .HasColumnName("top_exceptions_type_id"); + + b.Property("ExceptionCount") + .HasColumnType("integer") + .HasColumnName("count"); + + b.HasKey("TenantId", "ExceptionTypeId"); + + b.ToTable("top_exceptions_type", "statistics"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", b => + { + b.Property("TenantId") + .HasColumnType("smallint") + .HasColumnName("tenant_id"); + + b.HasKey("TenantId"); + + b.ToTable("tenant", "tenant"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.AutocompleteEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ExceptionTypeEntity", "ExceptionType") + .WithMany("ToCrashReports") + .HasForeignKey("TenantId", "ExceptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExceptionType"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportIgnoredFileEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToFileIdEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.CrashReportEntity", "ToCrashReport") + .WithOne("FileId") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToFileIdEntity", "TenantId", "CrashReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ToCrashReport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToMetadataEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.CrashReportEntity", "ToCrashReport") + .WithOne("Metadata") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToMetadataEntity", "TenantId", "CrashReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ToCrashReport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportToModuleMetadataEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.CrashReportEntity", "ToCrashReport") + .WithMany("ModuleInfos") + .HasForeignKey("TenantId", "CrashReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", "Module") + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", "NexusModsMod") + .WithMany() + .HasForeignKey("TenantId", "NexusModsModId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Module"); + + b.Navigation("NexusModsMod"); + + b.Navigation("ToCrashReport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.ExceptionTypeEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationDiscordTokensEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationDiscordEntity", "UserToDiscord") + .WithOne("ToTokens") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationDiscordTokensEntity", "DiscordUserId") + .HasPrincipalKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationDiscordEntity", "DiscordUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne() + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationDiscordTokensEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + + b.Navigation("UserToDiscord"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGOGToOwnedTenantEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGOGEntity", null) + .WithMany("ToOwnedTenants") + .HasForeignKey("GOGUserId") + .HasPrincipalKey("GOGUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("OwnedTenant") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGOGTokensEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGOGEntity", "UserToGOG") + .WithOne("ToTokens") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGOGTokensEntity", "GOGUserId") + .HasPrincipalKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGOGEntity", "GOGUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne() + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGOGTokensEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + + b.Navigation("UserToGOG"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", "UserToGitHub") + .WithOne("ToTokens") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", "GitHubUserId") + .HasPrincipalKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", "GitHubUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne() + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + + b.Navigation("UserToGitHub"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamToOwnedTenantEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("OwnedTenant") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", null) + .WithMany("ToOwnedTenants") + .HasForeignKey("SteamUserId") + .HasPrincipalKey("SteamUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamTokensEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne() + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamTokensEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", "UserToSteam") + .WithOne("ToTokens") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamTokensEntity", "SteamUserId") + .HasPrincipalKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", "SteamUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + + b.Navigation("UserToSteam"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsArticleEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithMany("ToArticles") + .HasForeignKey("NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToFileUpdateEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", "NexusModsMod") + .WithOne("FileUpdate") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToFileUpdateEntity", "TenantId", "NexusModsModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsMod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", "Module") + .WithMany("ToNexusModsMods") + .HasForeignKey("TenantId", "ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", "NexusModsMod") + .WithMany("ModuleIds") + .HasForeignKey("TenantId", "NexusModsModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + + b.Navigation("NexusModsMod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleInfoHistoryEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", "Module") + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", "NexusModsMod") + .WithMany() + .HasForeignKey("TenantId", "NexusModsModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + + b.Navigation("NexusModsMod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleInfoHistoryGameVersionEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", "Module") + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", "NexusModsMod") + .WithMany() + .HasForeignKey("TenantId", "NexusModsModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleInfoHistoryEntity", "MainEntity") + .WithMany("GameVersions") + .HasForeignKey("TenantId", "NexusModsFileId", "NexusModsModId", "ModuleId", "ModuleVersion") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_nexusmods_mod_module_info_history_game_version_nexusmods_m~1"); + + b.Navigation("MainEntity"); + + b.Navigation("Module"); + + b.Navigation("NexusModsMod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToNameEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", "NexusModsMod") + .WithOne("Name") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToNameEntity", "TenantId", "NexusModsModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsMod"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToCrashReportEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithMany("ToCrashReports") + .HasForeignKey("NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.CrashReportEntity", "ToCrashReport") + .WithMany("ToUsers") + .HasForeignKey("TenantId", "CrashReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + + b.Navigation("ToCrashReport"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationDiscordEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne("ToDiscord") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationDiscordEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGOGEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne("ToGOG") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGOGEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne("ToGitHub") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne("ToSteam") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToModuleEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithMany("ToModules") + .HasForeignKey("NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", "Module") + .WithMany("ToNexusModsUsers") + .HasForeignKey("TenantId", "ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToNameEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne("Name") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToNameEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToNexusModsModEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithMany("ToNexusModsMods") + .HasForeignKey("NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", "NexusModsMod") + .WithMany("ToNexusModsUsers") + .HasForeignKey("TenantId", "NexusModsModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsMod"); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToRoleEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithMany("ToRoles") + .HasForeignKey("NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.StatisticsCrashScoreInvolvedEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", "Module") + .WithMany("ToCrashScore") + .HasForeignKey("TenantId", "ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.StatisticsTopExceptionsTypeEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.ExceptionTypeEntity", "ExceptionType") + .WithMany("ToTopExceptionsTypes") + .HasForeignKey("TenantId", "ExceptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExceptionType"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.CrashReportEntity", b => + { + b.Navigation("FileId"); + + b.Navigation("Metadata"); + + b.Navigation("ModuleInfos"); + + b.Navigation("ToUsers"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.ExceptionTypeEntity", b => + { + b.Navigation("ToCrashReports"); + + b.Navigation("ToTopExceptionsTypes"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.ModuleEntity", b => + { + b.Navigation("ToCrashScore"); + + b.Navigation("ToNexusModsMods"); + + b.Navigation("ToNexusModsUsers"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModEntity", b => + { + b.Navigation("FileUpdate"); + + b.Navigation("ModuleIds"); + + b.Navigation("Name"); + + b.Navigation("ToNexusModsUsers"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsModToModuleInfoHistoryEntity", b => + { + b.Navigation("GameVersions"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", b => + { + b.Navigation("Name"); + + b.Navigation("ToArticles"); + + b.Navigation("ToCrashReports"); + + b.Navigation("ToDiscord"); + + b.Navigation("ToGOG"); + + b.Navigation("ToGitHub"); + + b.Navigation("ToModules"); + + b.Navigation("ToNexusModsMods"); + + b.Navigation("ToRoles"); + + b.Navigation("ToSteam"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationDiscordEntity", b => + { + b.Navigation("ToTokens"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGOGEntity", b => + { + b.Navigation("ToOwnedTenants"); + + b.Navigation("ToTokens"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", b => + { + b.Navigation("ToTokens"); + }); + + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", b => + { + b.Navigation("ToOwnedTenants"); + + b.Navigation("ToTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.cs b/src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.cs new file mode 100644 index 00000000..d274658a --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Migrations/20231226222434_GitHub.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BUTR.Site.NexusMods.Server.Migrations +{ + /// + public partial class GitHub : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "nexusmods_user_to_integration_github", + schema: "nexusmods_user", + columns: table => new + { + nexusmods_user_to_github_id = table.Column(type: "integer", nullable: false), + github_user_id = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_nexusmods_user_to_integration_github", x => x.nexusmods_user_to_github_id); + table.UniqueConstraint("AK_nexusmods_user_to_integration_github_github_user_id", x => x.github_user_id); + table.ForeignKey( + name: "FK_nexusmods_user_to_integration_github_nexusmods_user_nexusmo~", + column: x => x.nexusmods_user_to_github_id, + principalSchema: "nexusmods_user", + principalTable: "nexusmods_user", + principalColumn: "nexusmods_user_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "integration_github_tokens", + schema: "integration", + columns: table => new + { + integration_github_tokens_id = table.Column(type: "integer", nullable: false), + github_user_id = table.Column(type: "text", nullable: false), + access_token = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_integration_github_tokens", x => x.integration_github_tokens_id); + table.ForeignKey( + name: "FK_integration_github_tokens_nexusmods_user_integration_github~", + column: x => x.integration_github_tokens_id, + principalSchema: "nexusmods_user", + principalTable: "nexusmods_user", + principalColumn: "nexusmods_user_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_integration_github_tokens_nexusmods_user_to_integration_git~", + column: x => x.github_user_id, + principalSchema: "nexusmods_user", + principalTable: "nexusmods_user_to_integration_github", + principalColumn: "github_user_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_integration_github_tokens_github_user_id", + schema: "integration", + table: "integration_github_tokens", + column: "github_user_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "integration_github_tokens", + schema: "integration"); + + migrationBuilder.DropTable( + name: "nexusmods_user_to_integration_github", + schema: "nexusmods_user"); + } + } +} diff --git a/src/BUTR.Site.NexusMods.Server/Migrations/BaseAppDbContextModelSnapshot.cs b/src/BUTR.Site.NexusMods.Server/Migrations/BaseAppDbContextModelSnapshot.cs index 8868d61e..59d286ea 100644 --- a/src/BUTR.Site.NexusMods.Server/Migrations/BaseAppDbContextModelSnapshot.cs +++ b/src/BUTR.Site.NexusMods.Server/Migrations/BaseAppDbContextModelSnapshot.cs @@ -310,6 +310,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("integration_gog_tokens", "integration"); }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("integration_github_tokens_id"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("GitHubUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("github_user_id"); + + b.HasKey("NexusModsUserId"); + + b.HasIndex("GitHubUserId") + .IsUnique(); + + b.ToTable("integration_github_tokens", "integration"); + }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamToOwnedTenantEntity", b => { b.Property("SteamUserId") @@ -630,6 +654,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("nexusmods_user_to_integration_gog", "nexusmods_user"); }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", b => + { + b.Property("NexusModsUserId") + .HasColumnType("integer") + .HasColumnName("nexusmods_user_to_github_id"); + + b.Property("GitHubUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("github_user_id"); + + b.HasKey("NexusModsUserId"); + + b.ToTable("nexusmods_user_to_integration_github", "nexusmods_user"); + }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", b => { b.Property("NexusModsUserId") @@ -1075,6 +1115,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserToGOG"); }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", "UserToGitHub") + .WithOne("ToTokens") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", "GitHubUserId") + .HasPrincipalKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", "GitHubUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne() + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.IntegrationGitHubTokensEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + + b.Navigation("UserToGitHub"); + }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.IntegrationSteamToOwnedTenantEntity", b => { b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.TenantEntity", null) @@ -1311,6 +1371,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("NexusModsUser"); }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", b => + { + b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") + .WithOne("ToGitHub") + .HasForeignKey("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", "NexusModsUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NexusModsUser"); + }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", b => { b.HasOne("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserEntity", "NexusModsUser") @@ -1483,6 +1554,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ToGOG"); + b.Navigation("ToGitHub"); + b.Navigation("ToModules"); b.Navigation("ToNexusModsMods"); @@ -1504,6 +1577,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ToTokens"); }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationGitHubEntity", b => + { + b.Navigation("ToTokens"); + }); + modelBuilder.Entity("BUTR.Site.NexusMods.Server.Models.Database.NexusModsUserToIntegrationSteamEntity", b => { b.Navigation("ToOwnedTenants"); diff --git a/src/BUTR.Site.NexusMods.Server/Models/API/ProfileModel.cs b/src/BUTR.Site.NexusMods.Server/Models/API/ProfileModel.cs index 276e1828..596b2499 100644 --- a/src/BUTR.Site.NexusMods.Server/Models/API/ProfileModel.cs +++ b/src/BUTR.Site.NexusMods.Server/Models/API/ProfileModel.cs @@ -12,6 +12,7 @@ public sealed record ProfileModel public required bool IsSupporter { get; init; } public required ApplicationRole Role { get; init; } + public required string? GitHubUserId { get; init; } public required string? DiscordUserId { get; init; } public required string? SteamUserId { get; init; } public required string? GOGUserId { get; init; } diff --git a/src/BUTR.Site.NexusMods.Server/Models/Database/IntegrationGitHubTokensEntity.cs b/src/BUTR.Site.NexusMods.Server/Models/Database/IntegrationGitHubTokensEntity.cs new file mode 100644 index 00000000..299956cc --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Models/Database/IntegrationGitHubTokensEntity.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace BUTR.Site.NexusMods.Server.Models.Database; + +public sealed record IntegrationGitHubTokensEntity : IEntity +{ + public required NexusModsUserEntity NexusModsUser { get; init; } + + public required string GitHubUserId { get; init; } + public NexusModsUserToIntegrationGitHubEntity? UserToGitHub { get; init; } + + public required string AccessToken { get; init; } + + public override int GetHashCode() => HashCode.Combine(NexusModsUser.NexusModsUserId, GitHubUserId, AccessToken); +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserEntity.cs b/src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserEntity.cs index 751c615a..4cc80c5d 100644 --- a/src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserEntity.cs +++ b/src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserEntity.cs @@ -16,6 +16,7 @@ public sealed record NexusModsUserEntity : IEntity public ICollection ToNexusModsMods { get; init; } = new List(); public ICollection ToCrashReports { get; init; } = new List(); public ICollection ToArticles { get; init; } = new List(); + public NexusModsUserToIntegrationGitHubEntity? ToGitHub { get; init; } public NexusModsUserToIntegrationDiscordEntity? ToDiscord { get; init; } public NexusModsUserToIntegrationSteamEntity? ToSteam { get; init; } public NexusModsUserToIntegrationGOGEntity? ToGOG { get; init; } diff --git a/src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserToIntegrationGitHubEntity.cs b/src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserToIntegrationGitHubEntity.cs new file mode 100644 index 00000000..5be091a5 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Models/Database/NexusModsUserToIntegrationGitHubEntity.cs @@ -0,0 +1,14 @@ +using System; + +namespace BUTR.Site.NexusMods.Server.Models.Database; + +public sealed record NexusModsUserToIntegrationGitHubEntity : IEntity +{ + public required NexusModsUserEntity NexusModsUser { get; init; } + public IntegrationGitHubTokensEntity? ToTokens { get; init; } + + public required string GitHubUserId { get; init; } + + + public override int GetHashCode() => HashCode.Combine(NexusModsUser.NexusModsUserId, GitHubUserId); +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Models/UserTypedMetadata.cs b/src/BUTR.Site.NexusMods.Server/Models/UserTypedMetadata.cs index 29f70ae9..4074b2a2 100644 --- a/src/BUTR.Site.NexusMods.Server/Models/UserTypedMetadata.cs +++ b/src/BUTR.Site.NexusMods.Server/Models/UserTypedMetadata.cs @@ -11,6 +11,7 @@ namespace BUTR.Site.NexusMods.Server.Models; public record UserTypedMetadata { public TenantId[] OwnedTenants { get; init; } = Array.Empty(); + public ExternalDataHolder? GitHub { get; init; } public ExternalDataHolder? Discord { get; init; } public ExternalDataHolder? GOG { get; init; } public ExternalDataHolder>? Steam { get; init; } @@ -20,6 +21,10 @@ public UserTypedMetadata() { } private UserTypedMetadata(NexusModsUserEntity? userEntity) { var ownedTenants = new HashSet(); + if (userEntity?.ToGitHub is { ToTokens: { } tokensGitHub } gitHub) + { + GitHub = new ExternalDataHolder(gitHub.GitHubUserId, new(tokensGitHub.AccessToken)); + } if (userEntity?.ToDiscord is { ToTokens: { } tokensDiscord } discord) { Discord = new ExternalDataHolder(discord.DiscordUserId, new(tokensDiscord.AccessToken, tokensDiscord.RefreshToken, tokensDiscord.AccessTokenExpiresAt)); diff --git a/src/BUTR.Site.NexusMods.Server/Options/GitHubOptions.cs b/src/BUTR.Site.NexusMods.Server/Options/GitHubOptions.cs new file mode 100644 index 00000000..1d892a4d --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Options/GitHubOptions.cs @@ -0,0 +1,24 @@ +using Aragas.Extensions.Options.FluentValidation.Extensions; + +using FluentValidation; + +using System.Net.Http; + +namespace BUTR.Site.NexusMods.Server.Options; + +public sealed class GitHubOptionsValidator : AbstractValidator +{ + public GitHubOptionsValidator(HttpClient client) + { + RuleFor(x => x.ClientId).NotEmpty(); + RuleFor(x => x.ClientSecret).NotEmpty(); + RuleFor(x => x.RedirectUri).NotEmpty().IsUri(); + } +} + +public sealed record GitHubOptions +{ + public required string ClientId { get; init; } + public required string ClientSecret { get; init; } + public required string RedirectUri { get; init; } +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Services/ExternalStorage/IGitHubStorage.cs b/src/BUTR.Site.NexusMods.Server/Services/ExternalStorage/IGitHubStorage.cs new file mode 100644 index 00000000..056f8745 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Services/ExternalStorage/IGitHubStorage.cs @@ -0,0 +1,58 @@ +using BUTR.Site.NexusMods.Server.Contexts; +using BUTR.Site.NexusMods.Server.Extensions; +using BUTR.Site.NexusMods.Server.Models; + +using Microsoft.EntityFrameworkCore; + +using System.Linq; +using System.Threading.Tasks; + +namespace BUTR.Site.NexusMods.Server.Services; + +public sealed record GitHubOAuthTokens(string AccessToken); + +public interface IGitHubStorage +{ + Task GetAsync(string userId); + Task UpsertAsync(NexusModsUserId nexusModsUserId, string gitHubUserId, GitHubOAuthTokens tokens); + Task RemoveAsync(NexusModsUserId nexusModsUserId, string gitHubUserId); +} + +public sealed class DatabaseGitHubStorage : IGitHubStorage +{ + private readonly IAppDbContextRead _dbContextRead; + private readonly IAppDbContextWrite _dbContextWrite; + + public DatabaseGitHubStorage(IAppDbContextRead dbContextRead, IAppDbContextWrite dbContextWrite) + { + _dbContextRead = dbContextRead; + _dbContextWrite = dbContextWrite; + } + + public async Task GetAsync(string gitHubUserId) + { + var entity = await _dbContextRead.IntegrationGitHubTokens.FirstOrDefaultAsync(x => x.GitHubUserId.Equals(gitHubUserId)); + if (entity is null) return null; + return new(entity.AccessToken); + } + + public async Task UpsertAsync(NexusModsUserId nexusModsUserId, string gitHubUserId, GitHubOAuthTokens tokens) + { + var entityFactory = _dbContextWrite.GetEntityFactory(); + await using var _ = await _dbContextWrite.CreateSaveScopeAsync(); + + var nexusModsUserToIntegrationGitHub = entityFactory.GetOrCreateNexusModsUserGitHub(nexusModsUserId, gitHubUserId); + var tokensGitHub = entityFactory.GetOrCreateIntegrationGitHubTokens(nexusModsUserId, gitHubUserId, tokens.AccessToken); + + await _dbContextWrite.NexusModsUserToGitHub.UpsertOnSaveAsync(nexusModsUserToIntegrationGitHub); + await _dbContextWrite.IntegrationGitHubTokens.UpsertOnSaveAsync(tokensGitHub); + return true; + } + + public async Task RemoveAsync(NexusModsUserId nexusModsUserId, string gitHubUserId) + { + await _dbContextWrite.NexusModsUserToGitHub.Where(x => x.NexusModsUser.NexusModsUserId == nexusModsUserId && x.GitHubUserId == gitHubUserId).ExecuteDeleteAsync(); + await _dbContextWrite.IntegrationGitHubTokens.Where(x => x.GitHubUserId == gitHubUserId).ExecuteDeleteAsync(); + return true; + } +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubAPIClient.cs b/src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubAPIClient.cs new file mode 100644 index 00000000..0fbfa700 --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubAPIClient.cs @@ -0,0 +1,36 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace BUTR.Site.NexusMods.Server.Services; + +public sealed record GitHubUserInfo( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("login")] string Login); + +public sealed class GitHubAPIClient +{ + private readonly HttpClient _httpClient; + + public GitHubAPIClient(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task GetUserInfoAsync(GitHubOAuthTokens tokens, CancellationToken ct) + { + using var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "user") + { + Headers = + { + Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken) + } + }, ct); + return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct); + } +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubClient.cs b/src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubClient.cs new file mode 100644 index 00000000..53d471ab --- /dev/null +++ b/src/BUTR.Site.NexusMods.Server/Services/HttpClients/GitHubClient.cs @@ -0,0 +1,84 @@ +using BUTR.Site.NexusMods.Server.Options; + +using Microsoft.Extensions.Options; + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +namespace BUTR.Site.NexusMods.Server.Services; + +public sealed class GitHubClient +{ + public sealed record GitHubOAuthTokensResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("scope")] string Scope, + [property: JsonPropertyName("token_type")] string TokenType); + + private readonly HttpClient _httpClient; + private readonly GitHubOptions _options; + + public GitHubClient(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public (string Url, Guid State) GetOAuthUrl() + { + var state = Guid.NewGuid(); + + var url = new UriBuilder($"{_httpClient.BaseAddress}login/oauth/authorize"); + var query = HttpUtility.ParseQueryString(url.Query); + query["client_id"] = _options.ClientId; + query["redirect_uri"] = _options.RedirectUri; + query["state"] = state.ToString(); + url.Query = query.ToString(); + return (url.ToString(), state); + } + + public async Task CreateTokensAsync(string code, CancellationToken ct) + { + var data = new List> + { + new("client_id", _options.ClientId), + new("client_secret", _options.ClientSecret), + new("redirect_uri", _options.RedirectUri), + new("code", code), + }; + var post = new HttpRequestMessage(HttpMethod.Post, "login/oauth/access_token"); + post.Content = new FormUrlEncodedContent(data); + post.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var response = await _httpClient.SendAsync(post, ct); + var tokens = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct); + return tokens is not null ? new GitHubOAuthTokens(tokens.AccessToken) : null; + } + + /* + public async Task GetOrRefreshTokensAsync(DiscordOAuthTokens tokens, CancellationToken ct) + { + if (DateTimeOffset.UtcNow <= tokens.ExpiresAt) + return tokens; + + var data = new List> + { + new("client_id", _options.ClientId), + new("redirect_uri", _options.RedirectUri), + new("grant_type", "refresh_token"), + new("refresh_token", tokens.RefreshToken), + }; + using var response = await _httpClient.PostAsync("v10/oauth2/token", new FormUrlEncodedContent(data), ct); + if (!response.IsSuccessStatusCode) return null; + var responseData = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct); + if (responseData is null) return null; + + return new DiscordOAuthTokens(responseData.AccessToken, responseData.RefreshToken, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(responseData.ExpiresIn)); + } + */ +} \ No newline at end of file diff --git a/src/BUTR.Site.NexusMods.Server/Startup.cs b/src/BUTR.Site.NexusMods.Server/Startup.cs index 7cd2e722..5a70ad41 100644 --- a/src/BUTR.Site.NexusMods.Server/Startup.cs +++ b/src/BUTR.Site.NexusMods.Server/Startup.cs @@ -57,6 +57,7 @@ public sealed class Startup private const string CrashReporterSectionName = "CrashReporter"; private const string NexusModsSectionName = "NexusMods"; private const string JwtSectionName = "Jwt"; + private const string GitHubSectionName = "GitHub"; private const string DiscordSectionName = "Discord"; private const string SteamAPISectionName = "SteamAPI"; private const string DepotDownloaderSectionName = "DepotDownloader"; @@ -95,6 +96,7 @@ public void ConfigureServices(IServiceCollection services) var crashReporterSection = _configuration.GetSection(CrashReporterSectionName); var nexusModsSection = _configuration.GetSection(NexusModsSectionName); var jwtSection = _configuration.GetSection(JwtSectionName); + var gitHubSection = _configuration.GetSection(GitHubSectionName); var discordSection = _configuration.GetSection(DiscordSectionName); var steamAPISection = _configuration.GetSection(SteamAPISectionName); var depotDownloaderSection = _configuration.GetSection(DepotDownloaderSectionName); @@ -104,6 +106,7 @@ public void ConfigureServices(IServiceCollection services) services.AddValidatedOptionsWithHttp().Bind(crashReporterSection); services.AddValidatedOptionsWithHttp().Bind(nexusModsSection); services.AddValidatedOptions().Bind(jwtSection); + services.AddValidatedOptions().Bind(gitHubSection); services.AddValidatedOptions().Bind(discordSection); services.AddValidatedOptions().Bind(steamAPISection); services.AddValidatedOptions().Bind(depotDownloaderSection); @@ -131,6 +134,16 @@ public void ConfigureServices(IServiceCollection services) "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{opts.Username}:{opts.Password}"))); }).AddPolicyHandler(GetRetryPolicy()); + services.AddHttpClient().ConfigureHttpClient((_, client) => + { + client.BaseAddress = new Uri("https://github.com/"); + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + }).AddPolicyHandler(GetRetryPolicy()); + services.AddHttpClient().ConfigureHttpClient((_, client) => + { + client.BaseAddress = new Uri("https://api.github.com/"); + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + }).AddPolicyHandler(GetRetryPolicy()); services.AddHttpClient().ConfigureHttpClient((_, client) => { client.BaseAddress = new Uri("https://discord.com/api/"); @@ -221,6 +234,7 @@ public void ConfigureServices(IServiceCollection services) services.AddNexusModsDefaultServices(); services.AddHostedService(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/BUTR.Site.NexusMods.ServerClient/Clients.cs b/src/BUTR.Site.NexusMods.ServerClient/Clients.cs index 4712f845..813acc82 100644 --- a/src/BUTR.Site.NexusMods.ServerClient/Clients.cs +++ b/src/BUTR.Site.NexusMods.ServerClient/Clients.cs @@ -226,6 +226,14 @@ public RecreateStacktraceClient(HttpClient client, JsonSerializerOptions options } } +public partial class GitHubClient +{ + public GitHubClient(HttpClient client, JsonSerializerOptions options) : this(client) + { + _settings = new Lazy(options); + } +} + public partial record NexusModsModModel { public string Url(string gameDomain) => $"https://nexusmods.com/{gameDomain}/mods/{NexusModsModId}";