wabbajack/Wabbajack.Lib/NexusApi/NexusApi.cs

433 lines
15 KiB
C#
Raw Normal View History

using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security.Authentication;
2019-11-16 04:02:37 +00:00
using System.Security.Cryptography;
2019-10-14 21:04:12 +00:00
using System.Text;
using System.Threading.Tasks;
2019-11-16 04:02:37 +00:00
using Syroot.Windows.IO;
using Wabbajack.Common;
using Wabbajack.Lib.Downloaders;
using WebSocketSharp;
using static Wabbajack.Lib.NexusApi.NexusApiUtils;
2019-12-07 03:50:50 +00:00
using System.Threading;
namespace Wabbajack.Lib.NexusApi
{
public class NexusApiClient : ViewModel
{
private static readonly string API_KEY_CACHE_FILE = "nexus.key_cache";
2019-11-16 04:02:37 +00:00
private static string _additionalEntropy = "vtP2HF6ezg";
private readonly HttpClient _httpClient;
2019-11-19 05:10:07 +00:00
public HttpClient HttpClient => _httpClient;
#region Authentication
private readonly string _apiKey;
2019-11-19 05:10:07 +00:00
public string ApiKey => _apiKey;
public bool IsAuthenticated => _apiKey != null;
2019-12-07 03:50:50 +00:00
private Task<UserStatus> _userStatus;
2019-12-07 03:50:50 +00:00
public Task<UserStatus> UserStatus
{
get
{
if (_userStatus == null)
2019-12-07 03:50:50 +00:00
_userStatus = GetUserStatus();
return _userStatus;
}
}
2019-12-07 03:50:50 +00:00
public async Task<bool> IsPremium()
{
return IsAuthenticated && (await UserStatus).is_premium;
}
2019-12-07 03:50:50 +00:00
public async Task<string> Username() => (await UserStatus).name;
2019-12-07 03:50:50 +00:00
private static SemaphoreSlim _getAPIKeyLock = new SemaphoreSlim(1, 1);
private static async Task<string> GetApiKey()
{
2019-12-07 03:50:50 +00:00
await _getAPIKeyLock.WaitAsync();
try
{
2019-11-16 04:02:37 +00:00
// Clean up old location
if (File.Exists(API_KEY_CACHE_FILE))
{
2019-11-16 04:02:37 +00:00
File.Delete(API_KEY_CACHE_FILE);
}
var cacheFolder = Path.Combine(new KnownFolder(KnownFolderType.LocalAppData).Path, "Wabbajack");
if (!Directory.Exists(cacheFolder))
{
Directory.CreateDirectory(cacheFolder);
}
var cacheFile = Path.Combine(cacheFolder, _additionalEntropy);
if (File.Exists(cacheFile))
{
try
{
return Encoding.UTF8.GetString(
ProtectedData.Unprotect(File.ReadAllBytes(cacheFile),
Encoding.UTF8.GetBytes(_additionalEntropy), DataProtectionScope.CurrentUser));
}
catch (CryptographicException)
{
File.Delete(cacheFile);
}
}
var env_key = Environment.GetEnvironmentVariable("NEXUSAPIKEY");
if (env_key != null)
{
return env_key;
}
// open a web socket to receive the api key
var guid = Guid.NewGuid();
var _websocket = new WebSocket("wss://sso.nexusmods.com")
{
SslConfiguration =
{
EnabledSslProtocols = SslProtocols.Tls12
}
};
var api_key = new TaskCompletionSource<string>();
_websocket.OnMessage += (sender, msg) => { api_key.SetResult(msg.Data); };
_websocket.Connect();
_websocket.Send("{\"id\": \"" + guid + "\", \"appid\": \"" + Consts.AppName + "\"}");
// open a web browser to get user permission
Process.Start($"https://www.nexusmods.com/sso?id={guid}&application=" + Consts.AppName);
// get the api key from the socket and cache it
2019-12-07 03:50:50 +00:00
var result = await api_key.Task;
File.WriteAllBytes(cacheFile, ProtectedData.Protect(Encoding.UTF8.GetBytes(result),
2019-11-16 04:02:37 +00:00
Encoding.UTF8.GetBytes(_additionalEntropy), DataProtectionScope.CurrentUser));
return result;
}
2019-12-07 03:50:50 +00:00
finally
{
_getAPIKeyLock.Release();
}
}
2019-12-07 02:45:13 +00:00
public async Task<UserStatus> GetUserStatus()
{
var url = "https://api.nexusmods.com/v1/users/validate.json";
2019-12-07 02:45:13 +00:00
return await Get<UserStatus>(url);
}
#endregion
#region Rate Tracking
private readonly object RemainingLock = new object();
private int _dailyRemaining;
public int DailyRemaining
{
get
{
lock (RemainingLock)
{
return _dailyRemaining;
}
}
}
private int _hourlyRemaining;
public int HourlyRemaining
{
get
{
lock (RemainingLock)
{
return _hourlyRemaining;
}
}
}
private void UpdateRemaining(HttpResponseMessage response)
{
try
{
2019-10-14 20:40:49 +00:00
var dailyRemaining = int.Parse(response.Headers.GetValues("x-rl-daily-remaining").First());
var hourlyRemaining = int.Parse(response.Headers.GetValues("x-rl-hourly-remaining").First());
lock (RemainingLock)
{
_dailyRemaining = Math.Min(dailyRemaining, hourlyRemaining);
_hourlyRemaining = Math.Min(dailyRemaining, hourlyRemaining);
}
this.RaisePropertyChanged(nameof(DailyRemaining));
this.RaisePropertyChanged(nameof(HourlyRemaining));
}
2019-10-14 20:38:19 +00:00
catch (Exception)
{
}
}
#endregion
2019-12-07 03:50:50 +00:00
private NexusApiClient(string apiKey)
{
2019-12-07 03:50:50 +00:00
_apiKey = apiKey;
_httpClient = new HttpClient();
// set default headers for all requests to the Nexus API
var headers = _httpClient.DefaultRequestHeaders;
headers.Add("User-Agent", Consts.UserAgent);
headers.Add("apikey", _apiKey);
headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
headers.Add("Application-Name", Consts.AppName);
headers.Add("Application-Version", $"{Assembly.GetEntryAssembly()?.GetName()?.Version ?? new Version(0, 1)}");
2019-11-04 23:21:58 +00:00
if (!Directory.Exists(Consts.NexusCacheDirectory))
Directory.CreateDirectory(Consts.NexusCacheDirectory);
}
2019-12-07 03:50:50 +00:00
public static async Task<NexusApiClient> Get(string apiKey = null)
{
apiKey = apiKey ?? await GetApiKey();
return new NexusApiClient(apiKey);
}
2019-12-07 02:45:13 +00:00
private async Task<T> Get<T>(string url)
{
2019-12-07 02:45:13 +00:00
HttpResponseMessage response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
UpdateRemaining(response);
2019-12-07 02:45:13 +00:00
using (var stream = await response.Content.ReadAsStreamAsync())
{
return stream.FromJSON<T>();
}
}
2019-12-07 02:45:13 +00:00
private async Task<T> GetCached<T>(string url)
2019-10-14 21:04:12 +00:00
{
var code = Encoding.UTF8.GetBytes(url).ToHex() + ".json";
if (UseLocalCache)
2019-10-14 21:04:12 +00:00
{
if (!Directory.Exists(LocalCacheDir))
Directory.CreateDirectory(LocalCacheDir);
var cache_file = Path.Combine(LocalCacheDir, code);
if (File.Exists(cache_file))
{
return cache_file.FromJSON<T>();
}
2019-12-07 02:45:13 +00:00
var result = await Get<T>(url);
if (result != null)
result.ToJSON(cache_file);
return result;
}
try
{
2019-12-07 02:45:13 +00:00
return await Get<T>(Consts.WabbajackCacheLocation + code);
}
catch (Exception)
{
2019-12-07 02:45:13 +00:00
return await Get<T>(url);
2019-10-14 21:04:12 +00:00
}
}
2019-12-07 02:45:13 +00:00
public async Task<string> GetNexusDownloadLink(NexusDownloader.State archive, bool cache = false)
{
2019-12-07 02:45:13 +00:00
if (cache)
{
var result = await TryGetCachedLink(archive);
if (result.Succeeded)
{
return result.Value;
}
}
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
var url = $"https://api.nexusmods.com/v1/games/{ConvertGameName(archive.GameName)}/mods/{archive.ModID}/files/{archive.FileID}/download_link.json";
2019-12-07 02:45:13 +00:00
return (await Get<List<DownloadLink>>(url)).First().URI;
}
2019-12-07 02:45:13 +00:00
private async Task<GetResponse<string>> TryGetCachedLink(NexusDownloader.State archive)
{
if (!Directory.Exists(Consts.NexusCacheDirectory))
Directory.CreateDirectory(Consts.NexusCacheDirectory);
var path = Path.Combine(Consts.NexusCacheDirectory, $"link-{archive.GameName}-{archive.ModID}-{archive.FileID}.txt");
if (!File.Exists(path) || (DateTime.Now - new FileInfo(path).LastWriteTime).TotalHours > 24)
{
File.Delete(path);
2019-12-07 02:45:13 +00:00
var result = await GetNexusDownloadLink(archive);
File.WriteAllText(path, result);
2019-12-07 02:45:13 +00:00
return GetResponse<string>.Succeed(result);
}
2019-12-07 02:45:13 +00:00
return GetResponse<string>.Succeed(File.ReadAllText(path));
}
2019-12-07 02:45:13 +00:00
public async Task<NexusFileInfo> GetFileInfo(NexusDownloader.State mod)
{
var url = $"https://api.nexusmods.com/v1/games/{ConvertGameName(mod.GameName)}/mods/{mod.ModID}/files/{mod.FileID}.json";
2019-12-07 02:45:13 +00:00
return await GetCached<NexusFileInfo>(url);
}
public class GetModFilesResponse
{
public List<NexusFileInfo> files;
}
2019-12-07 02:45:13 +00:00
public async Task<GetModFilesResponse> GetModFiles(Game game, int modid)
{
var url = $"https://api.nexusmods.com/v1/games/{GameRegistry.Games[game].NexusName}/mods/{modid}/files.json";
2019-12-07 02:45:13 +00:00
return await GetCached<GetModFilesResponse>(url);
}
2019-12-07 02:45:13 +00:00
public async Task<List<MD5Response>> GetModInfoFromMD5(Game game, string md5Hash)
2019-11-04 14:33:17 +00:00
{
var url = $"https://api.nexusmods.com/v1/games/{GameRegistry.Games[game].NexusName}/mods/md5_search/{md5Hash}.json";
2019-12-07 02:45:13 +00:00
return await Get<List<MD5Response>>(url);
2019-11-04 14:33:17 +00:00
}
2019-12-07 02:45:13 +00:00
public async Task<ModInfo> GetModInfo(Game game, string modId)
{
var url = $"https://api.nexusmods.com/v1/games/{game.MetaData().NexusName}/mods/{modId}.json";
2019-12-07 02:45:13 +00:00
return await GetCached<ModInfo>(url);
}
2019-12-06 05:59:57 +00:00
public async Task<EndorsementResponse> EndorseMod(NexusDownloader.State mod)
{
Utils.Status($"Endorsing ${mod.GameName} - ${mod.ModID}");
var url = $"https://api.nexusmods.com/v1/games/{ConvertGameName(mod.GameName)}/mods/{mod.ModID}/endorse.json";
var content = new FormUrlEncodedContent(new Dictionary<string, string> { { "version", mod.Version } });
2019-12-06 05:59:57 +00:00
using (var stream = await _httpClient.PostStream(url, content))
{
return stream.FromJSON<EndorsementResponse>();
}
}
private class DownloadLink
{
public string URI { get; set; }
}
2019-11-05 00:27:46 +00:00
private class UpdatedMod
{
public long mod_id;
public long latest_file_update;
public long latest_mod_activity;
}
private static bool? _useLocalCache;
public static bool UseLocalCache
2019-11-05 00:27:46 +00:00
{
get
{
if (_useLocalCache == null) return LocalCacheDir != null;
return _useLocalCache ?? false;
}
set => _useLocalCache = value;
}
private static string _localCacheDir;
public static string LocalCacheDir
{
get
{
if (_localCacheDir == null)
_localCacheDir = Environment.GetEnvironmentVariable("NEXUSCACHEDIR");
return _localCacheDir;
}
set => _localCacheDir = value;
2019-11-05 00:27:46 +00:00
}
public async Task ClearUpdatedModsInCache()
2019-11-05 00:27:46 +00:00
{
if (!UseLocalCache) return;
2019-12-07 02:45:13 +00:00
var gameTasks = GameRegistry.Games.Values
.Where(game => game.NexusName != null)
2019-12-07 02:45:13 +00:00
.Select(async game =>
{
2019-12-07 02:45:13 +00:00
return (game,
mods: await Get<List<UpdatedMod>>(
$"https://api.nexusmods.com/v1/games/{game.NexusName}/mods/updated.json?period=1m"));
})
2019-12-07 02:45:13 +00:00
.Select(async rTask =>
{
var (game, mods) = await rTask;
return mods.Select(mod => new { game = game, mod = mod });
});
var purge = (await Task.WhenAll(gameTasks))
.SelectMany(i => i)
.ToList();
Utils.Log($"Found {purge.Count} updated mods in the last month");
using (var queue = new WorkQueue())
{
var to_purge = (await Directory.EnumerateFiles(LocalCacheDir, "*.json")
.PMap(queue,f =>
2019-11-05 00:27:46 +00:00
{
Utils.Status("Cleaning Nexus cache for");
var uri = new Uri(Encoding.UTF8.GetString(Path.GetFileNameWithoutExtension(f).FromHex()));
var parts = uri.PathAndQuery.Split('/', '.').ToHashSet();
var found = purge.FirstOrDefault(p =>
parts.Contains(p.game.NexusName) && parts.Contains(p.mod.mod_id.ToString()));
if (found != null)
{
var should_remove =
File.GetLastWriteTimeUtc(f) <= found.mod.latest_file_update.AsUnixTime();
return (should_remove, f);
}
// ToDo
// Can improve to not read the entire file to see if it starts with null
if (File.ReadAllText(f).StartsWith("null"))
return (true, f);
return (false, f);
}))
.Where(p => p.Item1)
.ToList();
Utils.Log($"Purging {to_purge.Count} cache entries");
await to_purge.PMap(queue, f =>
{
var uri = new Uri(Encoding.UTF8.GetString(Path.GetFileNameWithoutExtension(f.f).FromHex()));
Utils.Log($"Purging {uri}");
File.Delete(f.f);
});
}
2019-11-05 00:27:46 +00:00
}
}
}