mirror of
https://github.com/wabbajack-tools/wabbajack.git
synced 2024-08-30 18:42:17 +00:00
Merge branch 'main' into feature/FalloutNewVegas-EpicGames
This commit is contained in:
commit
727f210dbb
16
.github/workflows/tests.yaml
vendored
16
.github/workflows/tests.yaml
vendored
@ -7,7 +7,7 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
VERSION: 3.5.0.2
|
||||
VERSION: 3.6.0.0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@ -38,18 +38,6 @@ jobs:
|
||||
dotnet-version: '8.0.x'
|
||||
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
|
||||
run: dotnet test /p:EnableWindowsTargeting=true --filter "Category!=FlakeyNetwork"
|
||||
|
||||
|
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,5 +1,16 @@
|
||||
### 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
|
||||
* *HOTFIX* - change the cache file names so files will be auto-rehashed
|
||||
|
||||
|
@ -19,7 +19,7 @@ namespace Wabbajack.LoginManagers;
|
||||
public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader>
|
||||
{
|
||||
private readonly ILogger<NexusLoginManager> _logger;
|
||||
private readonly ITokenProvider<NexusApiState> _token;
|
||||
private readonly ITokenProvider<NexusOAuthState> _token;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public string SiteName { get; } = "Nexus Mods";
|
||||
@ -35,7 +35,7 @@ public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader>
|
||||
[Reactive]
|
||||
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;
|
||||
_token = token;
|
||||
|
@ -1,92 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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.Tasks;
|
||||
using System.Web;
|
||||
using Fizzler.Systems.HtmlAgilityPack;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Wabbajack.DTOs.Logins;
|
||||
using Wabbajack.DTOs.OAuth;
|
||||
using Wabbajack.Messages;
|
||||
using Wabbajack.Models;
|
||||
using Wabbajack.Networking.Http.Interfaces;
|
||||
using Wabbajack.Services.OSIntegrated;
|
||||
using Cookie = Wabbajack.DTOs.Logins.Cookie;
|
||||
|
||||
namespace Wabbajack.UserIntervention;
|
||||
|
||||
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";
|
||||
|
||||
private readonly EncryptedJsonTokenProvider<NexusOAuthState> _tokenProvider;
|
||||
private readonly ILogger<NexusLoginHandler> _logger;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public NexusLoginHandler(EncryptedJsonTokenProvider<NexusApiState> tokenProvider)
|
||||
public NexusLoginHandler(ILogger<NexusLoginHandler> logger, HttpClient client, EncryptedJsonTokenProvider<NexusOAuthState> tokenProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_client = client;
|
||||
HeaderText = "Nexus Login";
|
||||
_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)
|
||||
{
|
||||
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";
|
||||
|
||||
await NavigateTo(new Uri(
|
||||
"https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=//www.nexusmods.com"));
|
||||
|
||||
|
||||
Cookie[] cookies = { };
|
||||
while (true)
|
||||
var state = Guid.NewGuid().ToString();
|
||||
|
||||
await NavigateTo(new Uri("https://nexusmods.com"));
|
||||
var codeCompletionSource = new TaskCompletionSource<Dictionary<string, StringValues>>();
|
||||
|
||||
Browser!.Browser.CoreWebView2.NewWindowRequested += (sender, args) =>
|
||||
{
|
||||
cookies = await GetCookies("nexusmods.com", token);
|
||||
if (cookies.Any(c => c.Name == "member_id"))
|
||||
break;
|
||||
var uri = new Uri(args.Uri);
|
||||
_logger.LogInformation("New Window Requested {Uri}", args.Uri);
|
||||
if (uri.Host != "127.0.0.1") return;
|
||||
|
||||
codeCompletionSource.TrySetResult(QueryHelpers.ParseQuery(uri.Query));
|
||||
args.Handled = true;
|
||||
};
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
await Task.Delay(500, token);
|
||||
var uri = GenerateAuthorizeUrl(codeChallenge, state);
|
||||
await NavigateTo(uri);
|
||||
|
||||
var ctx = await codeCompletionSource.Task;
|
||||
|
||||
if (ctx["state"].FirstOrDefault() != state)
|
||||
{
|
||||
throw new Exception("State mismatch");
|
||||
}
|
||||
|
||||
var code = ctx["code"].FirstOrDefault();
|
||||
|
||||
Instructions = "Getting API Key...";
|
||||
var result = await AuthorizeToken(codeVerifier, code, token);
|
||||
|
||||
if (result != null)
|
||||
result.ReceivedAt = DateTime.UtcNow.ToFileTimeUtc();
|
||||
|
||||
await NavigateTo(new Uri("https://next.nexusmods.com/settings/api-keys"));
|
||||
|
||||
var key = "";
|
||||
|
||||
while (true)
|
||||
await _tokenProvider.SetToken(new NexusOAuthState()
|
||||
{
|
||||
try
|
||||
{
|
||||
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
|
||||
OAuth = result!
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReactiveUI;
|
||||
using Wabbajack.Common;
|
||||
@ -103,4 +104,5 @@ public abstract class OAuth2LoginHandler<TLoginType> : BrowserWindowViewModel
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
126
Wabbajack.App.Wpf/UserIntervention/StringExtensions.cs
Normal file
126
Wabbajack.App.Wpf/UserIntervention/StringExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -159,7 +159,6 @@ namespace Wabbajack
|
||||
Filters.Connect().QueryWhenChanged(),
|
||||
resultSelector: (target, type, checkOption, query) =>
|
||||
{
|
||||
Console.WriteLine("fff");
|
||||
switch (type)
|
||||
{
|
||||
case PathTypeOptions.Either:
|
||||
|
@ -90,8 +90,9 @@
|
||||
<PackageReference Include="MahApps.Metro" Version="2.4.10" />
|
||||
<PackageReference Include="MahApps.Metro.IconPacks" Version="4.11.0" />
|
||||
<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.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="Orc.FileAssociation" Version="5.0.0-alpha0061" />
|
||||
<PackageReference Include="PInvoke.User32" Version="0.7.124" />
|
||||
|
@ -611,7 +611,9 @@ public class ValidateLists
|
||||
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);
|
||||
|
||||
try
|
||||
|
@ -54,5 +54,6 @@ public enum Game
|
||||
[Description("Modding Tools")] ModdingTools,
|
||||
|
||||
[Description("Final Fantasy VII Remake")] FinalFantasy7Remake,
|
||||
[Description("Baldur's Gate 3")] BaldursGate3
|
||||
[Description("Baldur's Gate 3")] BaldursGate3,
|
||||
[Description("Starfield")] Starfield
|
||||
}
|
||||
|
@ -603,6 +603,22 @@ public static class GameRegistry
|
||||
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
|
||||
{
|
||||
|
@ -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; }
|
||||
}
|
13
Wabbajack.DTOs/Logins/NexusOAuthState.cs
Normal file
13
Wabbajack.DTOs/Logins/NexusOAuthState.cs
Normal 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;
|
||||
}
|
52
Wabbajack.DTOs/OAuth/JWTTokenReply.cs
Normal file
52
Wabbajack.DTOs/OAuth/JWTTokenReply.cs
Normal 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; }
|
||||
}
|
@ -48,7 +48,7 @@ public class NexusDownloader : ADownloader<Nexus>, IUrlDownloader
|
||||
|
||||
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)
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -13,7 +13,6 @@ using Wabbajack.Downloaders.Http;
|
||||
using Wabbajack.DTOs;
|
||||
using Wabbajack.DTOs.JsonConverters;
|
||||
using Wabbajack.DTOs.Logins;
|
||||
using Wabbajack.Launcher.Models;
|
||||
using Wabbajack.Launcher.ViewModels;
|
||||
using Wabbajack.Networking.Http;
|
||||
using Wabbajack.Networking.Http.Interfaces;
|
||||
@ -57,7 +56,7 @@ internal class Program
|
||||
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
services.AddSingleton<HttpClient>();
|
||||
services.AddSingleton<ITokenProvider<NexusApiState>, NexusApiTokenProvider>();
|
||||
services.AddSingleton<ITokenProvider<NexusOAuthState>, NexusApiTokenProvider>();
|
||||
services.AddSingleton<HttpDownloader>();
|
||||
services.AddAllSingleton<IResource, IResource<HttpClient>>(s => new Resource<HttpClient>("Web Requests", 4));
|
||||
services.AddAllSingleton<IHttpDownloader, SingleThreadedDownloader>();
|
||||
|
@ -32,9 +32,9 @@ public class MainWindowViewModel : ViewModelBase
|
||||
public Uri GITHUB_REPO = new("https://api.github.com/repos/wabbajack-tools/wabbajack/releases");
|
||||
private readonly NexusApi _nexusApi;
|
||||
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;
|
||||
Status = "Checking for new versions";
|
||||
|
@ -40,4 +40,4 @@ public class NexusApiTests
|
||||
var (links, _) = await _api.DownloadLink("skyrimspecialedition", 12604, file.FileId);
|
||||
Assert.True(links.Length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,4 +17,4 @@ public class Startup
|
||||
{
|
||||
loggerFactory.AddProvider(new XunitTestOutputLoggerProvider(accessor, delegate { return true; }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,6 @@ using Wabbajack.Networking.Http.Interfaces;
|
||||
|
||||
namespace Wabbajack.Networking.NexusApi;
|
||||
|
||||
public interface ApiKey : ITokenProvider<NexusApiState>
|
||||
public interface AuthInfo : ITokenProvider<NexusOAuthState>
|
||||
{
|
||||
}
|
33
Wabbajack.Networking.NexusApi/DTOs/OAuthUserInfo.cs
Normal file
33
Wabbajack.Networking.NexusApi/DTOs/OAuthUserInfo.cs
Normal 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; } = [];
|
||||
}
|
@ -8,4 +8,6 @@ public static class Endpoints
|
||||
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 Updates = "v1/games/{0}/mods/updated.json?period={1}";
|
||||
|
||||
public const string OAuthValidate = "https://users.nexusmods.com/oauth/userinfo";
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@ -12,6 +13,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Wabbajack.Common;
|
||||
using Wabbajack.DTOs;
|
||||
using Wabbajack.DTOs.Logins;
|
||||
using Wabbajack.DTOs.OAuth;
|
||||
using Wabbajack.Networking.Http;
|
||||
using Wabbajack.Networking.Http.Interfaces;
|
||||
using Wabbajack.Networking.NexusApi.DTOs;
|
||||
@ -28,14 +30,14 @@ public class NexusApi
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly IResource<HttpClient> _limiter;
|
||||
private readonly ILogger<NexusApi> _logger;
|
||||
public readonly ITokenProvider<NexusApiState> ApiKey;
|
||||
public readonly ITokenProvider<NexusOAuthState> AuthInfo;
|
||||
private DateTime _lastValidated;
|
||||
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)
|
||||
{
|
||||
ApiKey = apiKey;
|
||||
AuthInfo = authInfo;
|
||||
_logger = logger;
|
||||
_client = client;
|
||||
_appInfo = appInfo;
|
||||
@ -48,8 +50,27 @@ public class NexusApi
|
||||
public virtual async Task<(ValidateInfo info, ResponseMetadata header)> Validate(
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate);
|
||||
return await Send<ValidateInfo>(msg, token);
|
||||
var (isApi, code) = await GetAuthInfo();
|
||||
|
||||
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(
|
||||
@ -60,8 +81,8 @@ public class NexusApi
|
||||
return _lastValidatedInfo;
|
||||
}
|
||||
|
||||
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate);
|
||||
_lastValidatedInfo = await Send<ValidateInfo>(msg, token);
|
||||
await Validate(token);
|
||||
|
||||
return _lastValidatedInfo;
|
||||
}
|
||||
|
||||
@ -172,19 +193,88 @@ public class NexusApi
|
||||
var userAgent =
|
||||
$"{_appInfo.ApplicationSlug}/{_appInfo.Version} ({_appInfo.OSVersion}; {_appInfo.Platform})";
|
||||
|
||||
if (!ApiKey.HaveToken())
|
||||
throw new Exception("Please log into the Nexus before attempting to use Wabbajack");
|
||||
await AddAuthHeaders(msg);
|
||||
|
||||
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("Application-Name", _appInfo.ApplicationSlug);
|
||||
msg.Headers.Add("Application-Version", _appInfo.Version);
|
||||
msg.Headers.Add("apikey", token.ApiKey);
|
||||
|
||||
|
||||
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
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)
|
||||
{
|
||||
@ -274,7 +364,7 @@ public class NexusApi
|
||||
private async Task CheckAccess()
|
||||
{
|
||||
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);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@ -292,7 +382,7 @@ public class NexusApi
|
||||
new Uri(
|
||||
$"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();
|
||||
form.Add(new StringContent(d.GameId.ToString()), "game_id");
|
||||
form.Add(new StringContent(d.Name), "name");
|
||||
@ -330,6 +420,7 @@ public class NexusApi
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> IsPremium(CancellationToken token)
|
||||
{
|
||||
|
@ -25,7 +25,7 @@ public class ProxiedNexusApi : NexusApi
|
||||
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,
|
||||
ApplicationInfo appInfo, JsonSerializerOptions jsonOptions, ITokenProvider<WabbajackApiState> apiState,
|
||||
ClientConfiguration wabbajackClientConfiguration)
|
||||
|
@ -166,7 +166,7 @@ public static class ServiceExtensions
|
||||
service.AddBethesdaNet();
|
||||
|
||||
// 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<BethesdaNetLoginState>, EncryptedJsonTokenProvider<BethesdaNetLoginState>, BethesdaNetTokenProvider>();
|
||||
|
@ -5,10 +5,10 @@ using Wabbajack.Networking.NexusApi;
|
||||
|
||||
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,
|
||||
"nexus-login")
|
||||
"nexus-oauth-info")
|
||||
{
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
Loading…
Reference in New Issue
Block a user