From be1f09f5f9d3318a4bbdf593f671f4eacdd4395d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 18 Jul 2019 15:13:29 -0400 Subject: update obsolete code --- .../Framework/Clients/Pastebin/PastebinClient.cs | 32 ++++++---------------- 1 file changed, 8 insertions(+), 24 deletions(-) (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 12c3e83f..1e46f2dc 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; -using System.Text; using System.Threading.Tasks; -using System.Web; using Pathoschild.Http.Client; namespace StardewModdingAPI.Web.Framework.Clients.Pastebin @@ -82,15 +79,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin // post to API string response = await this.Client .PostAsync("api/api_post.php") - .WithBodyContent(this.GetFormUrlEncodedContent(new Dictionary + .WithBody(p => p.FormUrlEncoded(new { - ["api_option"] = "paste", - ["api_user_key"] = this.UserKey, - ["api_dev_key"] = this.DevKey, - ["api_paste_private"] = "1", // unlisted - ["api_paste_name"] = $"SMAPI log {DateTime.UtcNow:s}", - ["api_paste_expire_date"] = "N", // never expire - ["api_paste_code"] = content + api_option = "paste", + api_user_key = this.UserKey, + api_dev_key = this.DevKey, + api_paste_private = 1, // unlisted + api_paste_name = $"SMAPI log {DateTime.UtcNow:s}", + api_paste_expire_date = "N", // never expire + api_paste_code = content })) .AsString(); @@ -117,18 +114,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { this.Client.Dispose(); } - - - /********* - ** Private methods - *********/ - /// Build an HTTP content body with form-url-encoded content. - /// The content to encode. - /// This bypasses an issue where restricts the body length to the maximum size of a URL, which isn't applicable here. - private HttpContent GetFormUrlEncodedContent(IDictionary data) - { - string body = string.Join("&", from arg in data select $"{HttpUtility.UrlEncode(arg.Key)}={HttpUtility.UrlEncode(arg.Value)}"); - return new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"); - } } } -- cgit From e856d5efebe12b3aa65d5868ea7baa59cc54863d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 24 Jul 2019 18:29:50 -0400 Subject: add remote mod status to update check info (#651) --- src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs | 3 --- 1 file changed, 3 deletions(-) (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs index 291fb353..def79106 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs @@ -17,8 +17,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// The mod's web URL. public string Url { get; set; } - - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - public string Error { get; set; } } } -- cgit From 08d83aa039dd57efcc6ab50595fe5c0ea1003527 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 24 Jul 2019 21:28:33 -0400 Subject: treat hidden/unpublished Nexus mods as not found (#651) --- src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs | 4 ++++ .../Framework/Clients/Nexus/NexusModStatus.cs | 18 ++++++++++++++++++ .../Framework/Clients/Nexus/NexusWebScrapeClient.cs | 8 +++++++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs index f4909155..0f1b29d5 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs @@ -21,6 +21,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus [JsonProperty("mod_page_uri")] public string Url { get; set; } + /// The mod's publication status. + [JsonIgnore] + public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). [JsonIgnore] public string Error { get; set; } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs new file mode 100644 index 00000000..c5723093 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// The status of a Nexus mod. + internal enum NexusModStatus + { + /// The mod is published and valid. + Ok, + + /// The mod is hidden by the author. + Hidden, + + /// The mod hasn't been published yet. + NotPublished, + + /// The Nexus API returned an unhandled error. + Other + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs index e83a6041..061de5de 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs @@ -74,8 +74,14 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus case "not found": return null; + case "hidden mod": + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Hidden }; + + case "not published": + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.NotPublished }; + default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText})." }; + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Other }; } } -- cgit From 890c6b3ea7c4aedf4a9130aceff8d80c78bd6e0f Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 24 Jul 2019 22:08:15 -0400 Subject: rename Nexus API client for upcoming API usage (#651) --- .../Framework/Clients/Nexus/NexusClient.cs | 152 +++++++++++++++++++++ .../Clients/Nexus/NexusWebScrapeClient.cs | 152 --------------------- 2 files changed, 152 insertions(+), 152 deletions(-) create mode 100644 src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs new file mode 100644 index 00000000..87393367 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using HtmlAgilityPack; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// An HTTP client for fetching mod metadata from the Nexus website. + internal class NexusClient : INexusClient + { + /********* + ** Fields + *********/ + /// The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID. + private readonly string ModUrlFormat; + + /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. + public string ModScrapeUrlFormat { get; set; } + + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the Nexus Mods API client. + /// The base URL for the Nexus Mods site. + /// The URL for a Nexus Mods mod page for the user, excluding the , where {0} is the mod ID. + /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. + public NexusClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat) + { + this.ModUrlFormat = modUrlFormat; + this.ModScrapeUrlFormat = modScrapeUrlFormat; + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// Get metadata about a mod. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + public async Task GetModAsync(uint id) + { + // fetch HTML + string html; + try + { + html = await this.Client + .GetAsync(string.Format(this.ModScrapeUrlFormat, id)) + .AsString(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // handle Nexus error message + HtmlNode node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); + if (node != null) + { + string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries); + string errorCode = errorParts[0]; + string errorText = errorParts.Length > 1 ? errorParts[1] : null; + switch (errorCode.Trim().ToLower()) + { + case "not found": + return null; + + case "hidden mod": + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Hidden }; + + case "not published": + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.NotPublished }; + + default: + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Other }; + } + } + + // extract mod info + string url = this.GetModUrl(id); + string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); + string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); + SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); + + // extract file versions + List rawVersions = new List(); + foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) + { + string sectionName = fileSection.Descendants("h2").First().InnerText; + if (sectionName != "Main files" && sectionName != "Optional files") + continue; + + rawVersions.AddRange( + from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) + from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) + select versionStat.InnerText.Trim() + ); + } + + // choose latest file version + ISemanticVersion latestFileVersion = null; + foreach (string rawVersion in rawVersions) + { + if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) + continue; + if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) + continue; + if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) + continue; + + latestFileVersion = cur; + } + + // yield info + return new NexusMod + { + Name = name, + Version = parsedVersion?.ToString() ?? version, + LatestFileVersion = latestFileVersion, + Url = url + }; + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Get the full mod page URL for a given ID. + /// The mod ID. + private string GetModUrl(uint id) + { + UriBuilder builder = new UriBuilder(this.Client.BaseClient.BaseAddress); + builder.Path += string.Format(this.ModUrlFormat, id); + return builder.Uri.ToString(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs deleted file mode 100644 index 061de5de..00000000 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using HtmlAgilityPack; -using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; - -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// An HTTP client for fetching mod metadata from the Nexus website. - internal class NexusWebScrapeClient : INexusClient - { - /********* - ** Fields - *********/ - /// The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID. - private readonly string ModUrlFormat; - - /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. - public string ModScrapeUrlFormat { get; set; } - - /// The underlying HTTP client. - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the Nexus Mods API client. - /// The base URL for the Nexus Mods site. - /// The URL for a Nexus Mods mod page for the user, excluding the , where {0} is the mod ID. - /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. - public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat) - { - this.ModUrlFormat = modUrlFormat; - this.ModScrapeUrlFormat = modScrapeUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// Get metadata about a mod. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(uint id) - { - // fetch HTML - string html; - try - { - html = await this.Client - .GetAsync(string.Format(this.ModScrapeUrlFormat, id)) - .AsString(); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return null; - } - - // parse HTML - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // handle Nexus error message - HtmlNode node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); - if (node != null) - { - string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries); - string errorCode = errorParts[0]; - string errorText = errorParts.Length > 1 ? errorParts[1] : null; - switch (errorCode.Trim().ToLower()) - { - case "not found": - return null; - - case "hidden mod": - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Hidden }; - - case "not published": - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.NotPublished }; - - default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Other }; - } - } - - // extract mod info - string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); - string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); - SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); - - // extract file versions - List rawVersions = new List(); - foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) - { - string sectionName = fileSection.Descendants("h2").First().InnerText; - if (sectionName != "Main files" && sectionName != "Optional files") - continue; - - rawVersions.AddRange( - from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) - from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) - select versionStat.InnerText.Trim() - ); - } - - // choose latest file version - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in rawVersions) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; - - latestFileVersion = cur; - } - - // yield info - return new NexusMod - { - Name = name, - Version = parsedVersion?.ToString() ?? version, - LatestFileVersion = latestFileVersion, - Url = url - }; - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() - { - this.Client?.Dispose(); - } - - - /********* - ** Private methods - *********/ - /// Get the full mod page URL for a given ID. - /// The mod ID. - private string GetModUrl(uint id) - { - UriBuilder builder = new UriBuilder(this.Client.BaseClient.BaseAddress); - builder.Path += string.Format(this.ModUrlFormat, id); - return builder.Uri.ToString(); - } - } -} -- cgit From 95f261b1f30d8c5ad6c179cd75a220dcca3c6395 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 24 Jul 2019 22:11:51 -0400 Subject: fetch mod info from Nexus API if the web page is hidden due to adult content (#651) --- .../Framework/Clients/Nexus/NexusClient.cs | 133 ++++++++++++++++----- .../Framework/Clients/Nexus/NexusModStatus.cs | 3 + 2 files changed, 106 insertions(+), 30 deletions(-) (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 87393367..753d3b4f 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -4,8 +4,10 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using HtmlAgilityPack; +using Pathoschild.FluentNexus.Models; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit; +using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; namespace StardewModdingAPI.Web.Framework.Clients.Nexus { @@ -16,41 +18,73 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus ** Fields *********/ /// The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID. - private readonly string ModUrlFormat; + private readonly string WebModUrlFormat; /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. - public string ModScrapeUrlFormat { get; set; } + public string WebModScrapeUrlFormat { get; set; } - /// The underlying HTTP client. - private readonly IClient Client; + /// The underlying HTTP client for the Nexus Mods website. + private readonly IClient WebClient; + + /// The underlying HTTP client for the Nexus API. + private readonly FluentNexusClient ApiClient; /********* ** Public methods *********/ /// Construct an instance. - /// The user agent for the Nexus Mods API client. - /// The base URL for the Nexus Mods site. - /// The URL for a Nexus Mods mod page for the user, excluding the , where {0} is the mod ID. - /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. - public NexusClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat) + /// The user agent for the Nexus Mods web client. + /// The base URL for the Nexus Mods site. + /// The URL for a Nexus Mods mod page for the user, excluding the , where {0} is the mod ID. + /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. + /// The app version to show in API user agents. + /// The Nexus API authentication key. + public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlFormat, string webModScrapeUrlFormat, string apiAppVersion, string apiKey) { - this.ModUrlFormat = modUrlFormat; - this.ModScrapeUrlFormat = modScrapeUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + this.WebModUrlFormat = webModUrlFormat; + this.WebModScrapeUrlFormat = webModScrapeUrlFormat; + this.WebClient = new FluentClient(webBaseUrl).SetUserAgent(webUserAgent); + this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); } /// Get metadata about a mod. /// The Nexus mod ID. /// Returns the mod info if found, else null. public async Task GetModAsync(uint id) + { + // Fetch from the Nexus website when possible, since it has no rate limits. Mods with + // adult content are hidden for anonymous users, so fall back to the API in that case. + // Note that the API has very restrictive rate limits which means we can't just use it + // for all cases. + NexusMod mod = await this.GetModFromWebsiteAsync(id); + if (mod?.Status == NexusModStatus.AdultContentForbidden) + mod = await this.GetModFromApiAsync(id); + + return mod; + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.WebClient?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Get metadata about a mod by scraping the Nexus website. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + private async Task GetModFromWebsiteAsync(uint id) { // fetch HTML string html; try { - html = await this.Client - .GetAsync(string.Format(this.ModScrapeUrlFormat, id)) + html = await this.WebClient + .GetAsync(string.Format(this.WebModScrapeUrlFormat, id)) .AsString(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) @@ -74,14 +108,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus case "not found": return null; - case "hidden mod": - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Hidden }; - - case "not published": - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.NotPublished }; - default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = NexusModStatus.Other }; + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = this.GetWebStatus(errorCode) }; } } @@ -130,23 +158,68 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus }; } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() + /// Get metadata about a mod from the Nexus API. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + private async Task GetModFromApiAsync(uint id) { - this.Client?.Dispose(); - } + // fetch mod + Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); + ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); + + // get versions + if (!SemanticVersion.TryParse(mod.Version, out ISemanticVersion mainVersion)) + mainVersion = null; + ISemanticVersion latestFileVersion = null; + foreach (string rawVersion in files.Files.Select(p => p.FileVersion)) + { + if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) + continue; + if (mainVersion != null && !cur.IsNewerThan(mainVersion)) + continue; + if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) + continue; + latestFileVersion = cur; + } + + // yield info + return new NexusMod + { + Name = mod.Name, + Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, + LatestFileVersion = latestFileVersion, + Url = this.GetModUrl(id) + }; + } - /********* - ** Private methods - *********/ /// Get the full mod page URL for a given ID. /// The mod ID. private string GetModUrl(uint id) { - UriBuilder builder = new UriBuilder(this.Client.BaseClient.BaseAddress); - builder.Path += string.Format(this.ModUrlFormat, id); + UriBuilder builder = new UriBuilder(this.WebClient.BaseClient.BaseAddress); + builder.Path += string.Format(this.WebModUrlFormat, id); return builder.Uri.ToString(); } + + /// Get the mod status for a web error code. + /// The Nexus error code. + private NexusModStatus GetWebStatus(string errorCode) + { + switch (errorCode.Trim().ToLower()) + { + case "adult content": + return NexusModStatus.AdultContentForbidden; + + case "hidden mod": + return NexusModStatus.Hidden; + + case "not published": + return NexusModStatus.NotPublished; + + default: + return NexusModStatus.Other; + } + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs index c5723093..9ef314cd 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs @@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// The mod hasn't been published yet. NotPublished, + /// The mod contains adult content which is hidden for anonymous web users. + AdultContentForbidden, + /// The Nexus API returned an unhandled error. Other } -- cgit From 1d085df5b796e02b3e9e6874bd4e5684e840cb92 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 29 Jul 2019 16:43:25 -0400 Subject: track license info for mod GitHub repos (#651) --- .../Framework/Clients/GitHub/GitHubClient.cs | 39 +++++++++++++--------- .../Framework/Clients/GitHub/GitLicense.cs | 20 +++++++++++ src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs | 20 +++++++++++ .../Framework/Clients/GitHub/IGitHubClient.cs | 5 +++ 4 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs create mode 100644 src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 22950db9..84c20957 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -12,12 +12,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* ** Fields *********/ - /// The URL for a GitHub API query for the latest stable release, excluding the base URL, where {0} is the organisation and project name. - private readonly string StableReleaseUrlFormat; - - /// The URL for a GitHub API query for the latest release (including prerelease), excluding the base URL, where {0} is the organisation and project name. - private readonly string AnyReleaseUrlFormat; - /// The underlying HTTP client. private readonly IClient Client; @@ -27,17 +21,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// Construct an instance. /// The base URL for the GitHub API. - /// The URL for a GitHub API query for the latest stable release, excluding the , where {0} is the organisation and project name. - /// The URL for a GitHub API query for the latest release (including prerelease), excluding the , where {0} is the organisation and project name. /// The user agent for the API client. /// The Accept header value expected by the GitHub API. /// The username with which to authenticate to the GitHub API. /// The password with which to authenticate to the GitHub API. - public GitHubClient(string baseUrl, string stableReleaseUrlFormat, string anyReleaseUrlFormat, string userAgent, string acceptHeader, string username, string password) + public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string username, string password) { - this.StableReleaseUrlFormat = stableReleaseUrlFormat; - this.AnyReleaseUrlFormat = anyReleaseUrlFormat; - this.Client = new FluentClient(baseUrl) .SetUserAgent(userAgent) .AddDefault(req => req.WithHeader("Accept", acceptHeader)); @@ -45,25 +34,43 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub this.Client = this.Client.SetBasicAuthentication(username, password); } + /// Get basic metadata for a GitHub repository, if available. + /// The repository key (like Pathoschild/SMAPI). + /// Returns the repository info if it exists, else null. + public async Task GetRepositoryAsync(string repo) + { + this.AssertKeyFormat(repo); + try + { + return await this.Client + .GetAsync($"repos/{repo}") + .As(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + } + /// Get the latest release for a GitHub repository. /// The repository key (like Pathoschild/SMAPI). /// Whether to return a prerelease version if it's latest. /// Returns the release if found, else null. public async Task GetLatestReleaseAsync(string repo, bool includePrerelease = false) { - this.AssetKeyFormat(repo); + this.AssertKeyFormat(repo); try { if (includePrerelease) { GitRelease[] results = await this.Client - .GetAsync(string.Format(this.AnyReleaseUrlFormat, repo)) + .GetAsync($"repos/{repo}/releases?per_page=2") // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials) .AsArray(); return results.FirstOrDefault(p => !p.IsDraft); } return await this.Client - .GetAsync(string.Format(this.StableReleaseUrlFormat, repo)) + .GetAsync($"repos/{repo}/releases/latest") .As(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) @@ -85,7 +92,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// Assert that a repository key is formatted correctly. /// The repository key (like Pathoschild/SMAPI). /// The repository key is invalid. - private void AssetKeyFormat(string repo) + private void AssertKeyFormat(string repo) { if (repo == null || !repo.Contains("/") || repo.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != repo.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo)); diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs new file mode 100644 index 00000000..736efbe6 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// The license info for a GitHub project. + internal class GitLicense + { + /// The license display name. + [JsonProperty("name")] + public string Name { get; set; } + + /// The SPDX ID for the license. + [JsonProperty("spdx_id")] + public string SpdxId { get; set; } + + /// The URL for the license info. + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs new file mode 100644 index 00000000..7d80576e --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// Basic metadata about a GitHub project. + internal class GitRepo + { + /// The full repository name, including the owner. + [JsonProperty("full_name")] + public string FullName { get; set; } + + /// The URL to the repository web page, if any. + [JsonProperty("html_url")] + public string WebUrl { get; set; } + + /// The code license, if any. + [JsonProperty("license")] + public GitLicense License { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index 9519c26f..a34f03bd 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -9,6 +9,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* ** Methods *********/ + /// Get basic metadata for a GitHub repository, if available. + /// The repository key (like Pathoschild/SMAPI). + /// Returns the repository info if it exists, else null. + Task GetRepositoryAsync(string repo); + /// Get the latest release for a GitHub repository. /// The repository key (like Pathoschild/SMAPI). /// Whether to return a prerelease version if it's latest. -- cgit From 85715988f987a2bf1e7016e9ad82e76ec44d4f94 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 31 Jul 2019 14:49:54 -0400 Subject: fix error when Chucklefish page doesn't exist for update checks --- src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index 2753e33a..939c32c6 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish .GetAsync(string.Format(this.ModPageUrlFormat, id)) .AsString(); } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) { return null; } -- cgit From 3ba567eaddeaa0bb2bdd749b56e0601d1cf65a25 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 4 Aug 2019 03:28:34 -0400 Subject: add JSON validator with initial support for manifest format (#654) --- src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs | 3 ++- src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs index 630dfb76..a635abe3 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin Task GetAsync(string id); /// Save a paste to Pastebin. + /// The paste name. /// The paste content. - Task PostAsync(string content); + Task PostAsync(string name, string content); } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 1e46f2dc..2e8a8c68 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -67,8 +67,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin } /// Save a paste to Pastebin. + /// The paste name. /// The paste content. - public async Task PostAsync(string content) + public async Task PostAsync(string name, string content) { try { @@ -85,7 +86,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin api_user_key = this.UserKey, api_dev_key = this.DevKey, api_paste_private = 1, // unlisted - api_paste_name = $"SMAPI log {DateTime.UtcNow:s}", + api_paste_name = name, api_paste_expire_date = "N", // never expire api_paste_code = content })) -- cgit From 8b09a2776d9c0faf96fa90c923952033ce659477 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 7 Nov 2019 13:51:45 -0500 Subject: add support for CurseForge update keys (#605) --- .../Clients/CurseForge/CurseForgeClient.cs | 113 +++++++++++++++++++++ .../Framework/Clients/CurseForge/CurseForgeMod.cs | 23 +++++ .../Clients/CurseForge/ICurseForgeClient.cs | 17 ++++ .../CurseForge/ResponseModels/ModFileModel.cs | 12 +++ .../Clients/CurseForge/ResponseModels/ModModel.cs | 18 ++++ 5 files changed, 183 insertions(+) create mode 100644 src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs create mode 100644 src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs create mode 100644 src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs create mode 100644 src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs create mode 100644 src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs (limited to 'src/SMAPI.Web/Framework/Clients') diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs new file mode 100644 index 00000000..140b854e --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -0,0 +1,113 @@ +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// An HTTP client for fetching mod metadata from the CurseForge API. + internal class CurseForgeClient : ICurseForgeClient + { + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + /// A regex pattern which matches a version number in a CurseForge mod file name. + private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + /// The base URL for the CurseForge API. + public CurseForgeClient(string userAgent, string apiUrl) + { + this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); + } + + /// Get metadata about a mod. + /// The CurseForge mod ID. + /// Returns the mod info if found, else null. + public async Task GetModAsync(long id) + { + // get raw data + ModModel mod = await this.Client + .GetAsync($"addon/{id}") + .As(); + if (mod == null) + return null; + + // get latest versions + string invalidVersion = null; + ISemanticVersion latest = null; + foreach (ModFileModel file in mod.LatestFiles) + { + // extract version + ISemanticVersion version; + { + string raw = this.GetRawVersion(file); + if (raw == null) + continue; + + if (!SemanticVersion.TryParse(raw, out version)) + { + if (invalidVersion == null) + invalidVersion = raw; + continue; + } + } + + // track latest version + if (latest == null || version.IsNewerThan(latest)) + latest = version; + } + + // get error + string error = null; + if (latest == null && invalidVersion == null) + { + error = mod.LatestFiles.Any() + ? $"CurseForge mod {id} has no downloads which specify the version in a recognised format." + : $"CurseForge mod {id} has no downloads."; + } + + // generate result + return new CurseForgeMod + { + Name = mod.Name, + LatestVersion = latest?.ToString() ?? invalidVersion, + Url = mod.WebsiteUrl, + Error = error + }; + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Get a raw version string for a mod file, if available. + /// The file whose version to get. + private string GetRawVersion(ModFileModel file) + { + Match match = this.VersionInNamePattern.Match(file.DisplayName); + if (!match.Success) + match = this.VersionInNamePattern.Match(file.FileName); + + return match.Success + ? match.Groups[1].Value + : null; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs new file mode 100644 index 00000000..e5bb8cf1 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// Mod metadata from the CurseForge API. + internal class CurseForgeMod + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// The latest file version. + public string LatestVersion { get; set; } + + /// The mod's web URL. + public string Url { get; set; } + + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs new file mode 100644 index 00000000..907b4087 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// An HTTP client for fetching mod metadata from the CurseForge API. + internal interface ICurseForgeClient : IDisposable + { + /********* + ** Methods + *********/ + /// Get metadata about a mod. + /// The CurseForge mod ID. + /// Returns the mod info if found, else null. + Task GetModAsync(long id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs new file mode 100644 index 00000000..9de74847 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels +{ + /// Metadata from the CurseForge API about a mod file. + public class ModFileModel + { + /// The file name as downloaded. + public string FileName { get; set; } + + /// The file display name. + public string DisplayName { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs new file mode 100644 index 00000000..48cd185b --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels +{ + /// An mod from the CurseForge API. + public class ModModel + { + /// The mod's unique ID on CurseForge. + public int ID { get; set; } + + /// The mod name. + public string Name { get; set; } + + /// The web URL for the mod page. + public string WebsiteUrl { get; set; } + + /// The available file downloads. + public ModFileModel[] LatestFiles { get; set; } + } +} -- cgit