Merge branch 'main' into feature/FalloutNewVegas-EpicGames

This commit is contained in:
Luca 2024-05-25 19:14:52 +02:00 committed by GitHub
commit 727f210dbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 489 additions and 152 deletions

View File

@ -7,7 +7,7 @@ on:
branches: [ main ] branches: [ main ]
env: env:
VERSION: 3.5.0.2 VERSION: 3.6.0.0
jobs: jobs:
build: build:
@ -23,7 +23,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -38,18 +38,6 @@ jobs:
dotnet-version: '8.0.x' dotnet-version: '8.0.x'
include-prerelease: true include-prerelease: true
- name: Setup .NET Core SDK 7.0.x
uses: actions/setup-dotnet@v1
with:
dotnet-version: '7.0.x'
include-prerelease: true
- name: Setup .NET Core SDK 6.0.x
uses: actions/setup-dotnet@v1
with:
dotnet-version: '6.0.x'
include-prerelease: true
- name: Test - name: Test
run: dotnet test /p:EnableWindowsTargeting=true --filter "Category!=FlakeyNetwork" run: dotnet test /p:EnableWindowsTargeting=true --filter "Category!=FlakeyNetwork"

View File

@ -1,5 +1,16 @@
### Changelog ### Changelog
#### Version - 3.6.1.0 - TBD
* Added Starfield meta data
#### Version - 3.6.0.0 - 5/25/2024
* Wabbajack now uses OAuth2 for Nexus Mods logins
* Support for DirectURL use with LL files
#### Version - 3.5.0.2 - 5/21/2024
* *HOTFIX* - change how we log into Nexus Mods. We still need to rewrite this on
Oauth2, but this should fix the current issues we have, and get people back up and running
#### Version - 3.5.0.1 - 1/15/2024 #### Version - 3.5.0.1 - 1/15/2024
* *HOTFIX* - change the cache file names so files will be auto-rehashed * *HOTFIX* - change the cache file names so files will be auto-rehashed

View File

@ -19,7 +19,7 @@ namespace Wabbajack.LoginManagers;
public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader> public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader>
{ {
private readonly ILogger<NexusLoginManager> _logger; private readonly ILogger<NexusLoginManager> _logger;
private readonly ITokenProvider<NexusApiState> _token; private readonly ITokenProvider<NexusOAuthState> _token;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
public string SiteName { get; } = "Nexus Mods"; public string SiteName { get; } = "Nexus Mods";
@ -35,7 +35,7 @@ public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader>
[Reactive] [Reactive]
public bool HaveLogin { get; set; } public bool HaveLogin { get; set; }
public NexusLoginManager(ILogger<NexusLoginManager> logger, ITokenProvider<NexusApiState> token, IServiceProvider serviceProvider) public NexusLoginManager(ILogger<NexusLoginManager> logger, ITokenProvider<NexusOAuthState> token, IServiceProvider serviceProvider)
{ {
_logger = logger; _logger = logger;
_token = token; _token = token;

View File

@ -1,92 +1,143 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web;
using Fizzler.Systems.HtmlAgilityPack; using Fizzler.Systems.HtmlAgilityPack;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Wabbajack.DTOs.Logins; using Wabbajack.DTOs.Logins;
using Wabbajack.DTOs.OAuth;
using Wabbajack.Messages; using Wabbajack.Messages;
using Wabbajack.Models; using Wabbajack.Models;
using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.Http.Interfaces;
using Wabbajack.Services.OSIntegrated; using Wabbajack.Services.OSIntegrated;
using Cookie = Wabbajack.DTOs.Logins.Cookie;
namespace Wabbajack.UserIntervention; namespace Wabbajack.UserIntervention;
public class NexusLoginHandler : BrowserWindowViewModel public class NexusLoginHandler : BrowserWindowViewModel
{ {
private readonly EncryptedJsonTokenProvider<NexusApiState> _tokenProvider; private static Uri OAuthUrl = new Uri("https://users.nexusmods.com/oauth");
private static string OAuthRedirectUrl = "https://127.0.0.1:1234";
private static string OAuthClientId = "wabbajack";
public NexusLoginHandler(EncryptedJsonTokenProvider<NexusApiState> tokenProvider) private readonly EncryptedJsonTokenProvider<NexusOAuthState> _tokenProvider;
private readonly ILogger<NexusLoginHandler> _logger;
private readonly HttpClient _client;
public NexusLoginHandler(ILogger<NexusLoginHandler> logger, HttpClient client, EncryptedJsonTokenProvider<NexusOAuthState> tokenProvider)
{ {
_logger = logger;
_client = client;
HeaderText = "Nexus Login"; HeaderText = "Nexus Login";
_tokenProvider = tokenProvider; _tokenProvider = tokenProvider;
} }
private string Base64Id()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes);
}
protected override async Task Run(CancellationToken token) protected override async Task Run(CancellationToken token)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
// see https://www.rfc-editor.org/rfc/rfc7636#section-4.1
var codeVerifier = Guid.NewGuid().ToString("N").ToBase64();
// see https://www.rfc-editor.org/rfc/rfc7636#section-4.2
var codeChallengeBytes = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
var codeChallenge = StringBase64Extensions.Base64UrlEncode(codeChallengeBytes);
Instructions = "Please log into the Nexus"; Instructions = "Please log into the Nexus";
await NavigateTo(new Uri( var state = Guid.NewGuid().ToString();
"https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=//www.nexusmods.com"));
await NavigateTo(new Uri("https://nexusmods.com"));
var codeCompletionSource = new TaskCompletionSource<Dictionary<string, StringValues>>();
Cookie[] cookies = { }; Browser!.Browser.CoreWebView2.NewWindowRequested += (sender, args) =>
while (true)
{ {
cookies = await GetCookies("nexusmods.com", token); var uri = new Uri(args.Uri);
if (cookies.Any(c => c.Name == "member_id")) _logger.LogInformation("New Window Requested {Uri}", args.Uri);
break; if (uri.Host != "127.0.0.1") return;
token.ThrowIfCancellationRequested(); codeCompletionSource.TrySetResult(QueryHelpers.ParseQuery(uri.Query));
await Task.Delay(500, token); args.Handled = true;
};
var uri = GenerateAuthorizeUrl(codeChallenge, state);
await NavigateTo(uri);
var ctx = await codeCompletionSource.Task;
if (ctx["state"].FirstOrDefault() != state)
{
throw new Exception("State mismatch");
} }
Instructions = "Getting API Key..."; var code = ctx["code"].FirstOrDefault();
await NavigateTo(new Uri("https://next.nexusmods.com/settings/api-keys")); var result = await AuthorizeToken(codeVerifier, code, token);
var key = ""; if (result != null)
result.ReceivedAt = DateTime.UtcNow.ToFileTimeUtc();
while (true) await _tokenProvider.SetToken(new NexusOAuthState()
{ {
try OAuth = result!
{
key = (await GetDom(token)).DocumentNode.QuerySelectorAll("img[alt='Wabbajack']").SelectMany(p => p.ParentNode.ParentNode.QuerySelectorAll("input[aria-label='api key']")).Select(node => node.Attributes["value"]).FirstOrDefault()?.Value;
}
catch (Exception)
{
// ignored
}
if (!string.IsNullOrEmpty(key))
break;
try
{
await EvaluateJavaScript(
"var found = document.querySelector(\"img[alt='Wabbajack']\").parentElement.parentElement.querySelector(\"button[aria-label='Request Api Key']\");" +
"found.onclick= function() {return true;};" +
"found.class = \" \"; " +
"found.click();"
);
Instructions = "Generating API Key, Please Wait...";
}
catch (Exception)
{
// ignored
}
token.ThrowIfCancellationRequested();
await Task.Delay(500, token);
}
Instructions = "Success, saving information...";
await _tokenProvider.SetToken(new NexusApiState
{
Cookies = cookies,
ApiKey = key
}); });
} }
private async Task<JwtTokenReply?> AuthorizeToken(string verifier, string code, CancellationToken cancel)
{
var request = new Dictionary<string, string> {
{ "grant_type", "authorization_code" },
{ "client_id", OAuthClientId },
{ "redirect_uri", OAuthRedirectUrl },
{ "code", code },
{ "code_verifier", verifier },
};
var content = new FormUrlEncodedContent(request);
var response = await _client.PostAsync($"{OAuthUrl}/token", content, cancel);
if (!response.IsSuccessStatusCode)
{
_logger.LogCritical("Failed to get token {code} - {message}", response.StatusCode,
response.ReasonPhrase);
return null;
}
var responseString = await response.Content.ReadAsStringAsync(cancel);
return JsonSerializer.Deserialize<JwtTokenReply>(responseString);
}
internal static Uri GenerateAuthorizeUrl(string challenge, string state)
{
var request = new Dictionary<string, string>
{
{ "response_type", "code" },
{ "scope", "public openid profile" },
{ "code_challenge_method", "S256" },
{ "client_id", OAuthClientId },
{ "redirect_uri", OAuthRedirectUrl },
{ "code_challenge", challenge },
{ "state", state },
};
return new Uri(QueryHelpers.AddQueryString($"{OAuthUrl}/authorize", request));
}
} }

View File

@ -6,6 +6,7 @@ using System.Net.Http.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ReactiveUI; using ReactiveUI;
using Wabbajack.Common; using Wabbajack.Common;
@ -103,4 +104,5 @@ public abstract class OAuth2LoginHandler<TLoginType> : BrowserWindowViewModel
}); });
} }
} }

View File

@ -0,0 +1,126 @@
using System;
using System.Buffers;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
namespace Wabbajack.UserIntervention;
public static class StringBase64Extensions
{
/// <summary>
/// Convert string to base 64 encoding
/// </summary>
public static string ToBase64(this string input)
{
return ToBase64(Encoding.UTF8.GetBytes(input));
}
/// <summary>
/// Convert byte array to base 64 encoding
/// </summary>
public static string ToBase64(this byte[] input)
{
return Convert.ToBase64String(input);
}
/// <summary>
/// Encodes <paramref name="input"/> using base64url encoding.
/// </summary>
/// <param name="input">The binary input to encode.</param>
/// <returns>The base64url-encoded form of <paramref name="input"/>.</returns>
[SkipLocalsInit]
public static string Base64UrlEncode(ReadOnlySpan<byte> input)
{
// TODO: use Microsoft.AspNetCore.WebUtilities when .NET 8 is available
// Source: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/WebEncoders/WebEncoders.cs
// The MIT License (MIT)
//
// Copyright (c) .NET Foundation and Contributors
//
// All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
const int stackAllocThreshold = 128;
if (input.IsEmpty)
{
return string.Empty;
}
var bufferSize = GetArraySizeRequiredToEncode(input.Length);
char[]? bufferToReturnToPool = null;
var buffer = bufferSize <= stackAllocThreshold
? stackalloc char[stackAllocThreshold]
: bufferToReturnToPool = ArrayPool<char>.Shared.Rent(bufferSize);
var numBase64Chars = Base64UrlEncode(input, buffer);
var base64Url = new string(buffer[..numBase64Chars]);
if (bufferToReturnToPool != null)
{
ArrayPool<char>.Shared.Return(bufferToReturnToPool);
}
return base64Url;
}
private static int Base64UrlEncode(ReadOnlySpan<byte> input, Span<char> output)
{
Debug.Assert(output.Length >= GetArraySizeRequiredToEncode(input.Length));
if (input.IsEmpty)
{
return 0;
}
// Use base64url encoding with no padding characters. See RFC 4648, Sec. 5.
Convert.TryToBase64Chars(input, output, out int charsWritten);
// Fix up '+' -> '-' and '/' -> '_'. Drop padding characters.
for (var i = 0; i < charsWritten; i++)
{
var ch = output[i];
switch (ch)
{
case '+':
output[i] = '-';
break;
case '/':
output[i] = '_';
break;
case '=':
// We've reached a padding character; truncate the remainder.
return i;
}
}
return charsWritten;
}
private static int GetArraySizeRequiredToEncode(int count)
{
var numWholeOrPartialInputBlocks = checked(count + 2) / 3;
return checked(numWholeOrPartialInputBlocks * 4);
}
}

View File

@ -159,7 +159,6 @@ namespace Wabbajack
Filters.Connect().QueryWhenChanged(), Filters.Connect().QueryWhenChanged(),
resultSelector: (target, type, checkOption, query) => resultSelector: (target, type, checkOption, query) =>
{ {
Console.WriteLine("fff");
switch (type) switch (type)
{ {
case PathTypeOptions.Either: case PathTypeOptions.Either:

View File

@ -90,8 +90,9 @@
<PackageReference Include="MahApps.Metro" Version="2.4.10" /> <PackageReference Include="MahApps.Metro" Version="2.4.10" />
<PackageReference Include="MahApps.Metro.IconPacks" Version="4.11.0" /> <PackageReference Include="MahApps.Metro.IconPacks" Version="4.11.0" />
<PackageReference Include="Microsoft-WindowsAPICodePack-Shell" Version="1.1.5" /> <PackageReference Include="Microsoft-WindowsAPICodePack-Shell" Version="1.1.5" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2151.40" /> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2478.35" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.5" /> <PackageReference Include="NLog.Extensions.Logging" Version="5.3.5" />
<PackageReference Include="Orc.FileAssociation" Version="5.0.0-alpha0061" /> <PackageReference Include="Orc.FileAssociation" Version="5.0.0-alpha0061" />
<PackageReference Include="PInvoke.User32" Version="0.7.124" /> <PackageReference Include="PInvoke.User32" Version="0.7.124" />

View File

@ -611,7 +611,9 @@ public class ValidateLists
return (ArchiveStatus.InValid, archive); return (ArchiveStatus.InValid, archive);
} }
if (archive.State is Http http && http.Url.Host.EndsWith("github.com")) if (archive.State is Http http && (http.Url.Host.EndsWith("github.com")
//TODO: Find a better solution for the list validation of LoversLab files.
|| http.Url.Host.EndsWith("loverslab.com")))
return (ArchiveStatus.Valid, archive); return (ArchiveStatus.Valid, archive);
try try

View File

@ -54,5 +54,6 @@ public enum Game
[Description("Modding Tools")] ModdingTools, [Description("Modding Tools")] ModdingTools,
[Description("Final Fantasy VII Remake")] FinalFantasy7Remake, [Description("Final Fantasy VII Remake")] FinalFantasy7Remake,
[Description("Baldur's Gate 3")] BaldursGate3 [Description("Baldur's Gate 3")] BaldursGate3,
[Description("Starfield")] Starfield
} }

View File

@ -603,6 +603,22 @@ public static class GameRegistry
MainExecutable = @"bin/bg3.exe".ToRelativePath() MainExecutable = @"bin/bg3.exe".ToRelativePath()
} }
}, },
{
Game.Starfield, new GameMetaData
{
Game = Game.Starfield,
NexusName = "starfield",
NexusGameId = 4187,
MO2Name = "Starfield",
MO2ArchiveName = "Starfield",
SteamIDs = [1716740],
RequiredFiles = new []
{
@"Starfield.exe".ToRelativePath()
},
MainExecutable = @"Starfield.exe".ToRelativePath()
}
},
{ {
Game.ModdingTools, new GameMetaData Game.ModdingTools, new GameMetaData
{ {

View File

@ -1,10 +0,0 @@
using System.Text.Json.Serialization;
namespace Wabbajack.DTOs.Logins;
public class NexusApiState
{
[JsonPropertyName("api-key")] public string ApiKey { get; set; }
[JsonPropertyName("cookies")] public Cookie[] Cookies { get; set; }
}

View File

@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using Wabbajack.DTOs.OAuth;
namespace Wabbajack.DTOs.Logins;
public class NexusOAuthState
{
[JsonPropertyName("oauth")]
public JwtTokenReply? OAuth { get; set; } = new();
[JsonPropertyName("api_key")]
public string ApiKey { get; set; } = string.Empty;
}

View File

@ -0,0 +1,52 @@
using System;
using System.Text.Json.Serialization;
namespace Wabbajack.DTOs.OAuth;
/// <summary>
/// JWT Token info as provided by the OAuth server
/// </summary>
public class JwtTokenReply
{
/// <summary>
/// the token to use for authentication
/// </summary>
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("_received_at")]
public long ReceivedAt { get; set; }
public bool IsExpired => DateTime.FromFileTimeUtc(ReceivedAt) + TimeSpan.FromSeconds(ExpiresIn) - TimeSpan.FromMinutes(5) <= DateTimeOffset.UtcNow;
/// <summary>
/// token type, e.g. "Bearer"
/// </summary>
[JsonPropertyName("token_type")]
public string? Type { get; set; }
/// <summary>
/// when the access token expires in seconds
/// </summary>
[JsonPropertyName("expires_in")]
public ulong ExpiresIn { get; set; }
/// <summary>
/// token to use to refresh once this one has expired
/// </summary>
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
/// <summary>
/// space separated list of scopes. defined by the server, currently always "public"?
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; set; }
/// <summary>
/// unix timestamp (seconds resolution) of when the token was created
/// </summary>
[JsonPropertyName("created_at")]
public long CreatedAt { get; set; }
}

View File

@ -48,7 +48,7 @@ public class NexusDownloader : ADownloader<Nexus>, IUrlDownloader
public override Task<bool> Prepare() public override Task<bool> Prepare()
{ {
return Task.FromResult(_api.ApiKey.HaveToken()); return Task.FromResult(_api.AuthInfo.HaveToken());
} }
public override bool IsAllowed(ServerAllowList allowList, IDownloadState state) public override bool IsAllowed(ServerAllowList allowList, IDownloadState state)

View File

@ -1,40 +0,0 @@
using System.Text;
using System.Threading.Tasks;
using System.Security.Cryptography;
using System.Text.Json;
using Wabbajack.DTOs.Logins;
using Wabbajack.Networking.Http.Interfaces;
using Wabbajack.Paths;
using Wabbajack.Paths.IO;
namespace Wabbajack.Launcher.Models;
public class LegacyNexusApiKey : ITokenProvider<NexusApiState>
{
private AbsolutePath TokenPath => KnownFolders.WabbajackAppLocal.Combine("nexusapikey");
public async ValueTask<NexusApiState?> Get()
{
var data = await TokenPath.ReadAllBytesAsync();
var decoded = ProtectedData.Unprotect(data, Encoding.UTF8.GetBytes("nexusapikey"), DataProtectionScope.LocalMachine);
var apiKey = JsonSerializer.Deserialize<string>(decoded)!;
return new NexusApiState()
{
ApiKey = apiKey
};
}
public ValueTask SetToken(NexusApiState val)
{
throw new System.NotImplementedException();
}
public ValueTask<bool> Delete()
{
throw new System.NotImplementedException();
}
public bool HaveToken()
{
return TokenPath.FileExists();
}
}

View File

@ -13,7 +13,6 @@ using Wabbajack.Downloaders.Http;
using Wabbajack.DTOs; using Wabbajack.DTOs;
using Wabbajack.DTOs.JsonConverters; using Wabbajack.DTOs.JsonConverters;
using Wabbajack.DTOs.Logins; using Wabbajack.DTOs.Logins;
using Wabbajack.Launcher.Models;
using Wabbajack.Launcher.ViewModels; using Wabbajack.Launcher.ViewModels;
using Wabbajack.Networking.Http; using Wabbajack.Networking.Http;
using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.Http.Interfaces;
@ -57,7 +56,7 @@ internal class Program
services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<HttpClient>(); services.AddSingleton<HttpClient>();
services.AddSingleton<ITokenProvider<NexusApiState>, NexusApiTokenProvider>(); services.AddSingleton<ITokenProvider<NexusOAuthState>, NexusApiTokenProvider>();
services.AddSingleton<HttpDownloader>(); services.AddSingleton<HttpDownloader>();
services.AddAllSingleton<IResource, IResource<HttpClient>>(s => new Resource<HttpClient>("Web Requests", 4)); services.AddAllSingleton<IResource, IResource<HttpClient>>(s => new Resource<HttpClient>("Web Requests", 4));
services.AddAllSingleton<IHttpDownloader, SingleThreadedDownloader>(); services.AddAllSingleton<IHttpDownloader, SingleThreadedDownloader>();

View File

@ -32,9 +32,9 @@ public class MainWindowViewModel : ViewModelBase
public Uri GITHUB_REPO = new("https://api.github.com/repos/wabbajack-tools/wabbajack/releases"); public Uri GITHUB_REPO = new("https://api.github.com/repos/wabbajack-tools/wabbajack/releases");
private readonly NexusApi _nexusApi; private readonly NexusApi _nexusApi;
private readonly HttpDownloader _downloader; private readonly HttpDownloader _downloader;
private readonly ITokenProvider<NexusApiState> _tokenProvider; private readonly ITokenProvider<NexusOAuthState> _tokenProvider;
public MainWindowViewModel(NexusApi nexusApi, HttpDownloader downloader, ITokenProvider<NexusApiState> tokenProvider) public MainWindowViewModel(NexusApi nexusApi, HttpDownloader downloader, ITokenProvider<NexusOAuthState> tokenProvider)
{ {
_nexusApi = nexusApi; _nexusApi = nexusApi;
Status = "Checking for new versions"; Status = "Checking for new versions";

View File

@ -3,6 +3,6 @@ using Wabbajack.Networking.Http.Interfaces;
namespace Wabbajack.Networking.NexusApi; namespace Wabbajack.Networking.NexusApi;
public interface ApiKey : ITokenProvider<NexusApiState> public interface AuthInfo : ITokenProvider<NexusOAuthState>
{ {
} }

View File

@ -0,0 +1,33 @@
using System;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Octokit;
namespace Wabbajack.Networking.NexusApi.DTOs;
public record OAuthUserInfo
{
/// <summary>
/// Gets the User ID.
/// </summary>
[JsonPropertyName("sub")]
public string Sub { get; set; } = string.Empty;
/// <summary>
/// Gets the User Name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets the avatar url.
/// </summary>
[JsonPropertyName("avatar")]
public Uri? Avatar { get; set; }
/// <summary>
/// Gets an array of membership roles.
/// </summary>
[JsonPropertyName("membership_roles")]
public string[] MembershipRoles { get; set; } = [];
}

View File

@ -8,4 +8,6 @@ public static class Endpoints
public const string ModFile = "v1/games/{0}/mods/{1}/files/{2}.json"; public const string ModFile = "v1/games/{0}/mods/{1}/files/{2}.json";
public const string DownloadLink = "v1/games/{0}/mods/{1}/files/{2}/download_link.json"; public const string DownloadLink = "v1/games/{0}/mods/{1}/files/{2}/download_link.json";
public const string Updates = "v1/games/{0}/mods/updated.json?period={1}"; public const string Updates = "v1/games/{0}/mods/updated.json?period={1}";
public const string OAuthValidate = "https://users.nexusmods.com/oauth/userinfo";
} }

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -12,6 +13,7 @@ using Microsoft.Extensions.Logging;
using Wabbajack.Common; using Wabbajack.Common;
using Wabbajack.DTOs; using Wabbajack.DTOs;
using Wabbajack.DTOs.Logins; using Wabbajack.DTOs.Logins;
using Wabbajack.DTOs.OAuth;
using Wabbajack.Networking.Http; using Wabbajack.Networking.Http;
using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.Http.Interfaces;
using Wabbajack.Networking.NexusApi.DTOs; using Wabbajack.Networking.NexusApi.DTOs;
@ -28,14 +30,14 @@ public class NexusApi
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
private readonly IResource<HttpClient> _limiter; private readonly IResource<HttpClient> _limiter;
private readonly ILogger<NexusApi> _logger; private readonly ILogger<NexusApi> _logger;
public readonly ITokenProvider<NexusApiState> ApiKey; public readonly ITokenProvider<NexusOAuthState> AuthInfo;
private DateTime _lastValidated; private DateTime _lastValidated;
private (ValidateInfo info, ResponseMetadata header) _lastValidatedInfo; private (ValidateInfo info, ResponseMetadata header) _lastValidatedInfo;
public NexusApi(ITokenProvider<NexusApiState> apiKey, ILogger<NexusApi> logger, HttpClient client, public NexusApi(ITokenProvider<NexusOAuthState> authInfo, ILogger<NexusApi> logger, HttpClient client,
IResource<HttpClient> limiter, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions) IResource<HttpClient> limiter, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions)
{ {
ApiKey = apiKey; AuthInfo = authInfo;
_logger = logger; _logger = logger;
_client = client; _client = client;
_appInfo = appInfo; _appInfo = appInfo;
@ -48,8 +50,27 @@ public class NexusApi
public virtual async Task<(ValidateInfo info, ResponseMetadata header)> Validate( public virtual async Task<(ValidateInfo info, ResponseMetadata header)> Validate(
CancellationToken token = default) CancellationToken token = default)
{ {
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); var (isApi, code) = await GetAuthInfo();
return await Send<ValidateInfo>(msg, token);
if (isApi)
{
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate);
_lastValidatedInfo = await Send<ValidateInfo>(msg, token);
}
else
{
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.OAuthValidate);
var (data, header) = await Send<OAuthUserInfo>(msg, token);
var validateInfo = new ValidateInfo
{
IsPremium = data.MembershipRoles.Contains("premium"),
Name = data.Name,
};
_lastValidatedInfo = (validateInfo, header);
}
_lastValidated = DateTime.Now;
return _lastValidatedInfo;
} }
public async Task<(ValidateInfo info, ResponseMetadata header)> ValidateCached( public async Task<(ValidateInfo info, ResponseMetadata header)> ValidateCached(
@ -60,8 +81,8 @@ public class NexusApi
return _lastValidatedInfo; return _lastValidatedInfo;
} }
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); await Validate(token);
_lastValidatedInfo = await Send<ValidateInfo>(msg, token);
return _lastValidatedInfo; return _lastValidatedInfo;
} }
@ -172,20 +193,89 @@ public class NexusApi
var userAgent = var userAgent =
$"{_appInfo.ApplicationSlug}/{_appInfo.Version} ({_appInfo.OSVersion}; {_appInfo.Platform})"; $"{_appInfo.ApplicationSlug}/{_appInfo.Version} ({_appInfo.OSVersion}; {_appInfo.Platform})";
if (!ApiKey.HaveToken()) await AddAuthHeaders(msg);
throw new Exception("Please log into the Nexus before attempting to use Wabbajack");
var token = (await ApiKey.Get())!; if (uri.StartsWith("http"))
{
msg.RequestUri = new Uri($"{string.Format(uri, parameters)}");
}
else
{
msg.RequestUri = new Uri($"https://api.nexusmods.com/{string.Format(uri, parameters)}");
}
msg.RequestUri = new Uri($"https://api.nexusmods.com/{string.Format(uri, parameters)}");
msg.Headers.Add("User-Agent", userAgent); msg.Headers.Add("User-Agent", userAgent);
msg.Headers.Add("Application-Name", _appInfo.ApplicationSlug); msg.Headers.Add("Application-Name", _appInfo.ApplicationSlug);
msg.Headers.Add("Application-Version", _appInfo.Version); msg.Headers.Add("Application-Version", _appInfo.Version);
msg.Headers.Add("apikey", token.ApiKey);
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return msg; return msg;
} }
private async ValueTask AddAuthHeaders(HttpRequestMessage msg)
{
var (isApi, code) = await GetAuthInfo();
if (string.IsNullOrWhiteSpace(code))
throw new Exception("No API Key or OAuth Token found for NexusMods");
if (isApi)
msg.Headers.Add("apikey", code);
else
{
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", code);
}
}
private async ValueTask<(bool IsApiKey, string code)> GetAuthInfo()
{
if (AuthInfo.HaveToken())
{
var info = await AuthInfo.Get();
if (info!.OAuth != null)
{
if (info!.OAuth.IsExpired)
info = await RefreshToken(info, CancellationToken.None);
return (false, info.OAuth!.AccessToken!);
}
if (!string.IsNullOrWhiteSpace(info.ApiKey))
{
return (true, info.ApiKey);
}
}
else
{
if (Environment.GetEnvironmentVariable("NEXUS_API_KEY") is { } apiKey)
{
return (true, apiKey);
}
}
return default;
}
private async Task<NexusOAuthState> RefreshToken(NexusOAuthState state, CancellationToken cancel)
{
_logger.LogInformation("Refreshing OAuth Token");
var request = new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "client_id", "wabbajack" },
{ "refresh_token", state.OAuth!.RefreshToken },
};
var content = new FormUrlEncodedContent(request);
var response = await _client.PostAsync($"https://users.nexusmods.com/oauth/token", content, cancel);
var responseString = await response.Content.ReadAsStringAsync(cancel);
var newJwt = JsonSerializer.Deserialize<JwtTokenReply>(responseString);
state.OAuth = newJwt;
await AuthInfo.SetToken(state);
return state;
}
public async Task<(UpdateEntry[], ResponseMetadata headers)> GetUpdates(Game game, CancellationToken token) public async Task<(UpdateEntry[], ResponseMetadata headers)> GetUpdates(Game game, CancellationToken token)
{ {
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Updates, game.MetaData().NexusName, "1m"); var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Updates, game.MetaData().NexusName, "1m");
@ -274,7 +364,7 @@ public class NexusApi
private async Task CheckAccess() private async Task CheckAccess()
{ {
var msg = new HttpRequestMessage(HttpMethod.Get, "https://www.nexusmods.com/users/myaccount"); var msg = new HttpRequestMessage(HttpMethod.Get, "https://www.nexusmods.com/users/myaccount");
msg.AddCookies((await ApiKey.Get())!.Cookies); throw new NotSupportedException("Uploading to NexusMods is currently disabled");
using var response = await _client.SendAsync(msg); using var response = await _client.SendAsync(msg);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
@ -292,7 +382,7 @@ public class NexusApi
new Uri( new Uri(
$"https://www.nexusmods.com/{d.Game.MetaData().NexusName}/mods/edit/?id={d.ModId}&game_id={d.GameId}&step=files"); $"https://www.nexusmods.com/{d.Game.MetaData().NexusName}/mods/edit/?id={d.ModId}&game_id={d.GameId}&step=files");
msg.AddCookies((await ApiKey.Get())!.Cookies); throw new NotSupportedException("Uploading to NexusMods is currently disabled");
var form = new MultipartFormDataContent(); var form = new MultipartFormDataContent();
form.Add(new StringContent(d.GameId.ToString()), "game_id"); form.Add(new StringContent(d.GameId.ToString()), "game_id");
form.Add(new StringContent(d.Name), "name"); form.Add(new StringContent(d.Name), "name");
@ -331,6 +421,7 @@ public class NexusApi
} }
} }
public async Task<bool> IsPremium(CancellationToken token) public async Task<bool> IsPremium(CancellationToken token)
{ {
var validated = await ValidateCached(token); var validated = await ValidateCached(token);

View File

@ -25,7 +25,7 @@ public class ProxiedNexusApi : NexusApi
Endpoints.ModFile Endpoints.ModFile
}; };
public ProxiedNexusApi(ITokenProvider<NexusApiState> apiKey, ILogger<ProxiedNexusApi> logger, HttpClient client, public ProxiedNexusApi(ITokenProvider<NexusOAuthState> apiKey, ILogger<ProxiedNexusApi> logger, HttpClient client,
IResource<HttpClient> limiter, IResource<HttpClient> limiter,
ApplicationInfo appInfo, JsonSerializerOptions jsonOptions, ITokenProvider<WabbajackApiState> apiState, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions, ITokenProvider<WabbajackApiState> apiState,
ClientConfiguration wabbajackClientConfiguration) ClientConfiguration wabbajackClientConfiguration)

View File

@ -166,7 +166,7 @@ public static class ServiceExtensions
service.AddBethesdaNet(); service.AddBethesdaNet();
// Token Providers // Token Providers
service.AddAllSingleton<ITokenProvider<NexusApiState>, EncryptedJsonTokenProvider<NexusApiState>, NexusApiTokenProvider>(); service.AddAllSingleton<ITokenProvider<NexusOAuthState>, EncryptedJsonTokenProvider<NexusOAuthState>, NexusApiTokenProvider>();
service.AddAllSingleton<ITokenProvider<MegaToken>, EncryptedJsonTokenProvider<MegaToken>, MegaTokenProvider>(); service.AddAllSingleton<ITokenProvider<MegaToken>, EncryptedJsonTokenProvider<MegaToken>, MegaTokenProvider>();
service.AddAllSingleton<ITokenProvider<BethesdaNetLoginState>, EncryptedJsonTokenProvider<BethesdaNetLoginState>, BethesdaNetTokenProvider>(); service.AddAllSingleton<ITokenProvider<BethesdaNetLoginState>, EncryptedJsonTokenProvider<BethesdaNetLoginState>, BethesdaNetTokenProvider>();

View File

@ -5,10 +5,10 @@ using Wabbajack.Networking.NexusApi;
namespace Wabbajack.Services.OSIntegrated.TokenProviders; namespace Wabbajack.Services.OSIntegrated.TokenProviders;
public class NexusApiTokenProvider : EncryptedJsonTokenProvider<NexusApiState>, ApiKey public class NexusApiTokenProvider : EncryptedJsonTokenProvider<NexusOAuthState>, AuthInfo
{ {
public NexusApiTokenProvider(ILogger<NexusApiTokenProvider> logger, DTOSerializer dtos) : base(logger, dtos, public NexusApiTokenProvider(ILogger<NexusApiTokenProvider> logger, DTOSerializer dtos) : base(logger, dtos,
"nexus-login") "nexus-oauth-info")
{ {
} }
} }

View File

@ -13,7 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.118" /> <PackageReference Include="System.Data.SQLite.Core" Version="1.0.118" PrivateAssets="None" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>