summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2019-11-07 13:51:45 -0500
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2019-11-07 13:51:45 -0500
commit8b09a2776d9c0faf96fa90c923952033ce659477 (patch)
tree973b9da5f3205760eb7804ba4f2aa2ad07708b8b
parentfed71886a96dc85a0e93b36ab3016b82ba0cbe9f (diff)
downloadSMAPI-8b09a2776d9c0faf96fa90c923952033ce659477.tar.gz
SMAPI-8b09a2776d9c0faf96fa90c923952033ce659477.tar.bz2
SMAPI-8b09a2776d9c0faf96fa90c923952033ce659477.zip
add support for CurseForge update keys (#605)
-rw-r--r--docs/release-notes.md1
-rw-r--r--src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs3
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs5
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs113
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs23
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs17
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs18
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs7
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs63
-rw-r--r--src/SMAPI.Web/Startup.cs5
-rw-r--r--src/SMAPI.Web/appsettings.json2
12 files changed, 268 insertions, 1 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 1d933f96..5c12c4cc 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -47,6 +47,7 @@ For modders:
* Now ignores metadata files and folders (like `__MACOSX` and `__folder_managed_by_vortex`) and content files (like `.txt` or `.png`), which avoids missing-manifest errors in some common cases.
* Now detects XNB mods more accurately, and consolidates multi-folder XNB mods in logged messages.
* SMAPI now automatically removes invalid content when loading a save to prevent crashes. A warning is shown in-game when this happens. This applies for locations and NPCs.
+ * Added update checks for CurseForge mods.
* Added support for configuring console colors via `smapi-internal/config.json` (intended for players with unusual consoles).
* Added support for specifying SMAPI command-line arguments as environment variables for Linux/Mac compatibility.
* Improved launch script compatibility on Linux (thanks to kurumushi and toastal!).
diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs
index f6c402d5..765ca334 100644
--- a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs
+++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs
@@ -9,6 +9,9 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
/// <summary>The Chucklefish mod repository.</summary>
Chucklefish,
+ /// <summary>The CurseForge mod repository.</summary>
+ CurseForge,
+
/// <summary>A GitHub project containing releases.</summary>
GitHub,
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 8419b220..1412105a 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -14,6 +14,7 @@ using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.Wiki;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
+using StardewModdingAPI.Web.Framework.Clients.CurseForge;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
@@ -61,10 +62,11 @@ namespace StardewModdingAPI.Web.Controllers
/// <param name="modCache">The cache in which to store mod metadata.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
/// <param name="chucklefish">The Chucklefish API client.</param>
+ /// <param name="curseForge">The CurseForge API client.</param>
/// <param name="github">The GitHub API client.</param>
/// <param name="modDrop">The ModDrop API client.</param>
/// <param name="nexus">The Nexus API client.</param>
- public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, IModDropClient modDrop, INexusClient nexus)
+ public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus)
{
this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json"));
ModUpdateCheckConfig config = configProvider.Value;
@@ -78,6 +80,7 @@ namespace StardewModdingAPI.Web.Controllers
new IModRepository[]
{
new ChucklefishRepository(chucklefish),
+ new CurseForgeRepository(curseForge),
new GitHubRepository(github),
new ModDropRepository(modDrop),
new NexusRepository(nexus)
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
+{
+ /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary>
+ internal class CurseForgeClient : ICurseForgeClient
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+ /// <summary>A regex pattern which matches a version number in a CurseForge mod file name.</summary>
+ private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="userAgent">The user agent for the API client.</param>
+ /// <param name="apiUrl">The base URL for the CurseForge API.</param>
+ public CurseForgeClient(string userAgent, string apiUrl)
+ {
+ this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent);
+ }
+
+ /// <summary>Get metadata about a mod.</summary>
+ /// <param name="id">The CurseForge mod ID.</param>
+ /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
+ public async Task<CurseForgeMod> GetModAsync(long id)
+ {
+ // get raw data
+ ModModel mod = await this.Client
+ .GetAsync($"addon/{id}")
+ .As<ModModel>();
+ 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
+ };
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client?.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get a raw version string for a mod file, if available.</summary>
+ /// <param name="file">The file whose version to get.</param>
+ 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
+{
+ /// <summary>Mod metadata from the CurseForge API.</summary>
+ internal class CurseForgeMod
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The latest file version.</summary>
+ public string LatestVersion { get; set; }
+
+ /// <summary>The mod's web URL.</summary>
+ public string Url { get; set; }
+
+ /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary>
+ 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
+{
+ /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary>
+ internal interface ICurseForgeClient : IDisposable
+ {
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Get metadata about a mod.</summary>
+ /// <param name="id">The CurseForge mod ID.</param>
+ /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
+ Task<CurseForgeMod> 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
+{
+ /// <summary>Metadata from the CurseForge API about a mod file.</summary>
+ public class ModFileModel
+ {
+ /// <summary>The file name as downloaded.</summary>
+ public string FileName { get; set; }
+
+ /// <summary>The file display name.</summary>
+ 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
+{
+ /// <summary>An mod from the CurseForge API.</summary>
+ public class ModModel
+ {
+ /// <summary>The mod's unique ID on CurseForge.</summary>
+ public int ID { get; set; }
+
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The web URL for the mod page.</summary>
+ public string WebsiteUrl { get; set; }
+
+ /// <summary>The available file downloads.</summary>
+ public ModFileModel[] LatestFiles { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
index a0a1f42a..121690c5 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
@@ -24,6 +24,13 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/****
+ ** CurseForge
+ ****/
+ /// <summary>The base URL for the CurseForge API.</summary>
+ public string CurseForgeBaseUrl { get; set; }
+
+
+ /****
** GitHub
****/
/// <summary>The base URL for the GitHub API.</summary>
diff --git a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs
new file mode 100644
index 00000000..93ddc1eb
--- /dev/null
+++ b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Threading.Tasks;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+using StardewModdingAPI.Web.Framework.Clients.CurseForge;
+
+namespace StardewModdingAPI.Web.Framework.ModRepositories
+{
+ /// <summary>An HTTP client for fetching mod metadata from CurseForge.</summary>
+ internal class CurseForgeRepository : RepositoryBase
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying CurseForge API client.</summary>
+ private readonly ICurseForgeClient Client;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="client">The underlying CurseForge API client.</param>
+ public CurseForgeRepository(ICurseForgeClient client)
+ : base(ModRepositoryKey.CurseForge)
+ {
+ this.Client = client;
+ }
+
+ /// <summary>Get metadata about a mod in the repository.</summary>
+ /// <param name="id">The mod ID in this repository.</param>
+ public override async Task<ModInfoModel> GetModInfoAsync(string id)
+ {
+ // validate ID format
+ if (!uint.TryParse(id, out uint curseID))
+ return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID.");
+
+ // fetch info
+ try
+ {
+ CurseForgeMod mod = await this.Client.GetModAsync(curseID);
+ if (mod == null)
+ return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID.");
+ if (mod.Error != null)
+ {
+ RemoteModStatus remoteStatus = RemoteModStatus.InvalidData;
+ return new ModInfoModel().SetError(remoteStatus, mod.Error);
+ }
+
+ return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.LatestVersion), url: mod.Url);
+ }
+ catch (Exception ex)
+ {
+ return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString());
+ }
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public override void Dispose()
+ {
+ this.Client.Dispose();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index bf69d543..8110b696 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -16,6 +16,7 @@ using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.Wiki;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
+using StardewModdingAPI.Web.Framework.Clients.CurseForge;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
@@ -119,6 +120,10 @@ namespace StardewModdingAPI.Web
baseUrl: api.ChucklefishBaseUrl,
modPageUrlFormat: api.ChucklefishModPageUrlFormat
));
+ services.AddSingleton<ICurseForgeClient>(new CurseForgeClient(
+ userAgent: userAgent,
+ apiUrl: api.CurseForgeBaseUrl
+ ));
services.AddSingleton<IGitHubClient>(new GitHubClient(
baseUrl: api.GitHubBaseUrl,
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index a440cf42..674bb672 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -30,6 +30,8 @@
"ChucklefishBaseUrl": "https://community.playstarbound.com",
"ChucklefishModPageUrlFormat": "resources/{0}",
+ "CurseForgeBaseUrl": "https://addons-ecs.forgesvc.net/api/v2/",
+
"GitHubBaseUrl": "https://api.github.com",
"GitHubAcceptHeader": "application/vnd.github.v3+json",
"GitHubUsername": null, // see top note