summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2023-04-09 13:12:30 -0400
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2023-04-09 13:12:30 -0400
commit5486146a05dec8a5c09e9585e5b610b89feaf731 (patch)
tree52c91cb3c4349976ec77877b963d444cadd4587c
parent53e0e8cd2410e63c7725206db4a8156cc460d111 (diff)
parenta4b193b920879b7db255f387af8e3c9b4fa2b46a (diff)
downloadSMAPI-5486146a05dec8a5c09e9585e5b610b89feaf731.tar.gz
SMAPI-5486146a05dec8a5c09e9585e5b610b89feaf731.tar.bz2
SMAPI-5486146a05dec8a5c09e9585e5b610b89feaf731.zip
Merge pull request #876 from jltaylor-us/update-manifest
Add `UpdateManifest` update keys
-rw-r--r--src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs5
-rw-r--r--src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs42
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs23
-rw-r--r--src/SMAPI.Web/Framework/Clients/GenericModDownload.cs18
-rw-r--r--src/SMAPI.Web/Framework/Clients/GenericModPage.cs17
-rw-r--r--src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs7
-rw-r--r--src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs35
-rw-r--r--src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs30
-rw-r--r--src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs28
-rw-r--r--src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs107
-rw-r--r--src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs34
-rw-r--r--src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs71
-rw-r--r--src/SMAPI.Web/Framework/IModDownload.cs11
-rw-r--r--src/SMAPI.Web/Framework/IModPage.cs11
-rw-r--r--src/SMAPI.Web/Framework/ModInfoModel.cs30
-rw-r--r--src/SMAPI.Web/Framework/ModSiteManager.cs120
-rw-r--r--src/SMAPI.Web/Framework/RemoteModStatus.cs3
-rw-r--r--src/SMAPI.Web/Startup.cs3
18 files changed, 519 insertions, 76 deletions
diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs
index 47cd3f7e..195b0367 100644
--- a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs
+++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs
@@ -19,6 +19,9 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
ModDrop,
/// <summary>The Nexus Mods mod repository.</summary>
- Nexus
+ Nexus,
+
+ /// <summary>An arbitrary URL to a JSON file containing update data.</summary>
+ UpdateManifest
}
}
diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs
index 960caf96..3e8064fd 100644
--- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs
+++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs
@@ -58,31 +58,17 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
/// <param name="raw">The raw update key to parse.</param>
public static UpdateKey Parse(string? raw)
{
+ if (raw is null)
+ return new UpdateKey(raw, ModSiteKey.Unknown, null, null);
// extract site + ID
- string? rawSite;
- string? id;
- {
- string[]? parts = raw?.Trim().Split(':');
- if (parts?.Length != 2)
- return new UpdateKey(raw, ModSiteKey.Unknown, null, null);
-
- rawSite = parts[0].Trim();
- id = parts[1].Trim();
- }
- if (string.IsNullOrWhiteSpace(id))
+ (string rawSite, string? id) = UpdateKey.SplitTwoParts(raw, ':');
+ if (string.IsNullOrEmpty(id))
id = null;
// extract subkey
string? subkey = null;
if (id != null)
- {
- string[] parts = id.Split('@');
- if (parts.Length == 2)
- {
- id = parts[0].Trim();
- subkey = $"@{parts[1]}".Trim();
- }
- }
+ (id, subkey) = UpdateKey.SplitTwoParts(id, '@', true);
// parse
if (!Enum.TryParse(rawSite, true, out ModSiteKey site))
@@ -151,5 +137,23 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
{
return $"{site}:{id}{subkey}".Trim();
}
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Split a string into two parts at a delimiter and trim whitespace.</summary>
+ /// <param name="str">The string to split.</param>
+ /// <param name="delimiter">The character on which to split.</param>
+ /// <param name="keepDelimiter">Whether to include the delimiter in the second string.</param>
+ /// <returns>Returns a tuple containing the two strings, with the second value <c>null</c> if the delimiter wasn't found.</returns>
+ private static (string, string?) SplitTwoParts(string str, char delimiter, bool keepDelimiter = false)
+ {
+ int splitIndex = str.IndexOf(delimiter);
+
+ return splitIndex >= 0
+ ? (str.Substring(0, splitIndex).Trim(), str.Substring(splitIndex + (keepDelimiter ? 0 : 1)).Trim())
+ : (str.Trim(), null);
+ }
}
}
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 71fb42c2..f687c7dd 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -22,6 +22,7 @@ using StardewModdingAPI.Web.Framework.Clients.CurseForge;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
+using StardewModdingAPI.Web.Framework.Clients.UpdateManifest;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.Controllers
@@ -63,14 +64,15 @@ namespace StardewModdingAPI.Web.Controllers
/// <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(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus)
+ /// <param name="updateManifest">The API client for arbitrary update manifest URLs.</param>
+ public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus, IUpdateManifestClient updateManifest)
{
this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json"));
this.WikiCache = wikiCache;
this.ModCache = modCache;
this.Config = config;
- this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus });
+ this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus, updateManifest });
}
/// <summary>Fetch version metadata for the given mods.</summary>
@@ -145,7 +147,12 @@ namespace StardewModdingAPI.Web.Controllers
foreach (UpdateKey updateKey in updateKeys)
{
// validate update key
- if (!updateKey.LooksValid)
+ if (
+ !updateKey.LooksValid
+#if SMAPI_DEPRECATED
+ || (updateKey.Site == ModSiteKey.UpdateManifest && apiVersion?.IsNewerThan("4.0.0-alpha") != true) // 4.0-alpha feature, don't make available to released mods in case it changes before release
+#endif
+ )
{
errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541', with an optional subkey like 'Nexus:541@subkey'.");
continue;
@@ -162,17 +169,21 @@ namespace StardewModdingAPI.Web.Controllers
// if there's only a prerelease version (e.g. from GitHub), don't override the main version
ISemanticVersion? curMain = data.Version;
ISemanticVersion? curPreview = data.PreviewVersion;
+ string? curMainUrl = data.MainModPageUrl;
+ string? curPreviewUrl = data.PreviewModPageUrl;
if (curPreview == null && curMain?.IsPrerelease() == true)
{
curPreview = curMain;
+ curPreviewUrl = curMainUrl;
curMain = null;
+ curMainUrl = null;
}
// handle versions
if (this.IsNewer(curMain, main?.Version))
- main = new ModEntryVersionModel(curMain, data.Url!);
+ main = new ModEntryVersionModel(curMain, curMainUrl ?? data.Url!);
if (this.IsNewer(curPreview, optional?.Version))
- optional = new ModEntryVersionModel(curPreview, data.Url!);
+ optional = new ModEntryVersionModel(curPreview, curPreviewUrl ?? data.Url!);
}
// get unofficial version
@@ -295,7 +306,7 @@ namespace StardewModdingAPI.Web.Controllers
}
// get version info
- return this.ModSites.GetPageVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions);
+ return this.ModSites.GetPageVersions(page, updateKey, allowNonStandardVersions, mapRemoteVersions);
}
/// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary>
diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs
index 548f17c3..6c9c08ef 100644
--- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs
+++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace StardewModdingAPI.Web.Framework.Clients
{
/// <summary>Generic metadata about a file download on a mod page.</summary>
@@ -15,6 +17,9 @@ namespace StardewModdingAPI.Web.Framework.Clients
/// <summary>The download's file version.</summary>
public string? Version { get; }
+ /// <summary>The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from.</summary>
+ public string? ModPageUrl { get; }
+
/*********
** Public methods
@@ -23,11 +28,22 @@ namespace StardewModdingAPI.Web.Framework.Clients
/// <param name="name">The download's display name.</param>
/// <param name="description">The download's description.</param>
/// <param name="version">The download's file version.</param>
- public GenericModDownload(string name, string? description, string? version)
+ /// <param name="modPageUrl">The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from.</param>
+ public GenericModDownload(string name, string? description, string? version, string? modPageUrl = null)
{
this.Name = name;
this.Description = description;
this.Version = version;
+ this.ModPageUrl = modPageUrl;
+ }
+
+ /// <summary>Get whether the subkey matches this download.</summary>
+ /// <param name="subkey">The update subkey to check.</param>
+ public virtual bool MatchesSubkey(string subkey)
+ {
+ return
+ this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase)
+ || this.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true;
}
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
index 5353c7e1..63ca5a95 100644
--- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
+++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
@@ -40,6 +40,9 @@ namespace StardewModdingAPI.Web.Framework.Clients
[MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))]
public bool IsValid => this.Status == RemoteModStatus.Ok;
+ /// <summary>Whether this mod page requires update subkeys and does not allow matching downloads without them.</summary>
+ public bool RequireSubkey { get; set; } = false;
+
/*********
** Public methods
@@ -79,5 +82,19 @@ namespace StardewModdingAPI.Web.Framework.Clients
return this;
}
+
+ /// <summary>Get the mod name for an update subkey, if different from the mod page name.</summary>
+ /// <param name="subkey">The update subkey.</param>
+ public virtual string? GetName(string? subkey)
+ {
+ return this.Name;
+ }
+
+ /// <summary>Get the mod page URL for an update subkey, if different from the mod page it was fetched from.</summary>
+ /// <param name="subkey">The update subkey.</param>
+ public virtual string? GetUrl(string? subkey)
+ {
+ return this.Url;
+ }
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs
new file mode 100644
index 00000000..bf1edd3f
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs
@@ -0,0 +1,7 @@
+using System;
+
+namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest
+{
+ /// <summary>An API client for fetching update metadata from an arbitrary JSON URL.</summary>
+ internal interface IUpdateManifestClient : IModSiteClient, IDisposable { }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs
new file mode 100644
index 00000000..ead5c229
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels
+{
+ /// <summary>The data model for a mod in an update manifest file.</summary>
+ internal class UpdateManifestModModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's name.</summary>
+ public string? Name { get; }
+
+ /// <summary>The mod page URL from which to download updates.</summary>
+ public string? ModPageUrl { get; }
+
+ /// <summary>The available versions for this mod.</summary>
+ public UpdateManifestVersionModel[] Versions { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="name">The mod's name.</param>
+ /// <param name="modPageUrl">The mod page URL from which to download updates.</param>
+ /// <param name="versions">The available versions for this mod.</param>
+ public UpdateManifestModModel(string? name, string? modPageUrl, UpdateManifestVersionModel[]? versions)
+ {
+ this.Name = name;
+ this.ModPageUrl = modPageUrl;
+ this.Versions = versions ?? Array.Empty<UpdateManifestVersionModel>();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs
new file mode 100644
index 00000000..5ccd31b0
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels
+{
+ /// <summary>The data model for an update manifest file.</summary>
+ internal class UpdateManifestModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The manifest format version. This is equivalent to the SMAPI version, and is used to parse older manifests correctly if later versions of SMAPI change the expected format.</summary>
+ public string Format { get; }
+
+ /// <summary>The mod info in this update manifest.</summary>
+ public IDictionary<string, UpdateManifestModModel> Mods { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="format">The manifest format version.</param>
+ /// <param name="mods">The mod info in this update manifest.</param>
+ public UpdateManifestModel(string format, IDictionary<string, UpdateManifestModModel>? mods)
+ {
+ this.Format = format;
+ this.Mods = mods ?? new Dictionary<string, UpdateManifestModModel>();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs
new file mode 100644
index 00000000..6678f5eb
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs
@@ -0,0 +1,28 @@
+namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels
+{
+ /// <summary>Data model for a Version in an update manifest.</summary>
+ internal class UpdateManifestVersionModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's semantic version.</summary>
+ public string? Version { get; }
+
+ /// <summary>The mod page URL from which to download updates, if different from <see cref="UpdateManifestModModel.ModPageUrl"/>.</summary>
+ public string? ModPageUrl { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The mod's semantic version.</param>
+ /// <param name="modPageUrl">The mod page URL from which to download updates, if different from <see cref="UpdateManifestModModel.ModPageUrl"/>.</param>
+ public UpdateManifestVersionModel(string version, string? modPageUrl)
+ {
+ this.Version = version;
+ this.ModPageUrl = modPageUrl;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs
new file mode 100644
index 00000000..1614beab
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs
@@ -0,0 +1,107 @@
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using Pathoschild.Http.Client;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+using StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels;
+
+namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest
+{
+ /// <summary>An API client for fetching update metadata from an arbitrary JSON URL.</summary>
+ internal class UpdateManifestClient : IUpdateManifestClient
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique key for the mod site.</summary>
+ public ModSiteKey SiteKey => ModSiteKey.UpdateManifest;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="userAgent">The user agent for the API client.</param>
+ public UpdateManifestClient(string userAgent)
+ {
+ this.Client = new FluentClient()
+ .SetUserAgent(userAgent);
+
+ this.Client.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain"));
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client.Dispose();
+ }
+
+ /// <inheritdoc/>
+ [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method which ensures the annotations are correct.")]
+ public async Task<IModPage?> GetModData(string id)
+ {
+ // get raw update manifest
+ UpdateManifestModel? manifest;
+ try
+ {
+ manifest = await this.Client.GetAsync(id).As<UpdateManifestModel?>();
+ if (manifest is null)
+ return this.GetFormatError(id, "manifest can't be empty");
+ }
+ catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
+ {
+ return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}");
+ }
+ catch (Exception ex)
+ {
+ return this.GetFormatError(id, ex.Message);
+ }
+
+ // validate
+ if (!SemanticVersion.TryParse(manifest.Format, out _))
+ return this.GetFormatError(id, $"invalid format version '{manifest.Format}'");
+ foreach (UpdateManifestModModel mod in manifest.Mods.Values)
+ {
+ if (mod is null)
+ return this.GetFormatError(id, "a mod record can't be null");
+ if (string.IsNullOrWhiteSpace(mod.ModPageUrl))
+ return this.GetFormatError(id, $"all mods must have a {nameof(mod.ModPageUrl)} value");
+ foreach (UpdateManifestVersionModel? version in mod.Versions)
+ {
+ if (version is null)
+ return this.GetFormatError(id, "a version record can't be null");
+ if (string.IsNullOrWhiteSpace(version.Version))
+ return this.GetFormatError(id, $"all version records must have a {nameof(version.Version)} field");
+ if (!SemanticVersion.TryParse(version.Version, out _))
+ return this.GetFormatError(id, $"invalid mod version '{version.Version}'");
+ }
+ }
+
+ // build model
+ return new UpdateManifestModPage(id, manifest);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get a mod page instance with an error indicating the update manifest is invalid.</summary>
+ /// <param name="url">The full URL to the update manifest.</param>
+ /// <param name="reason">A human-readable reason phrase indicating why it's invalid.</param>
+ private IModPage GetFormatError(string url, string reason)
+ {
+ return new GenericModPage(this.SiteKey, url).SetError(RemoteModStatus.InvalidData, $"The update manifest at {url} is invalid ({reason})");
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs
new file mode 100644
index 00000000..f8cb760a
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs
@@ -0,0 +1,34 @@
+namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest
+{
+ /// <summary>Metadata about a mod download in an update manifest file.</summary>
+ internal class UpdateManifestModDownload : GenericModDownload
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The update subkey for this mod download.</summary>
+ private readonly string Subkey;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="fieldName">The field name for this mod download in the manifest.</param>
+ /// <param name="name">The mod name for this download.</param>
+ /// <param name="version">The download's version.</param>
+ /// <param name="url">The download's URL.</param>
+ public UpdateManifestModDownload(string fieldName, string name, string? version, string? url)
+ : base(name, null, version, url)
+ {
+ this.Subkey = '@' + fieldName;
+ }
+
+ /// <summary>Get whether the subkey matches this download.</summary>
+ /// <param name="subkey">The update subkey to check.</param>
+ public override bool MatchesSubkey(string subkey)
+ {
+ return subkey == this.Subkey;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs
new file mode 100644
index 00000000..df752713
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs
@@ -0,0 +1,71 @@
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+using StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels;
+
+namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest
+{
+ /// <summary>Metadata about an update manifest "page".</summary>
+ internal class UpdateManifestModPage : GenericModPage
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The mods from the update manifest.</summary>
+ private readonly IDictionary<string, UpdateManifestModModel> Mods;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="url">The URL of the update manifest file.</param>
+ /// <param name="manifest">The parsed update manifest.</param>
+ public UpdateManifestModPage(string url, UpdateManifestModel manifest)
+ : base(ModSiteKey.UpdateManifest, url)
+ {
+ this.RequireSubkey = true;
+ this.Mods = manifest.Mods;
+ this.SetInfo(name: url, url: url, version: null, downloads: this.ParseDownloads(manifest.Mods).ToArray());
+ }
+
+ /// <summary>Return the mod name for the given subkey, if it exists in this update manifest.</summary>
+ /// <param name="subkey">The subkey.</param>
+ /// <returns>The mod name for the given subkey, or <see langword="null"/> if this manifest does not contain the given subkey.</returns>
+ public override string? GetName(string? subkey)
+ {
+ return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod)
+ ? mod.Name
+ : null;
+ }
+
+ /// <summary>Return the mod URL for the given subkey, if it exists in this update manifest.</summary>
+ /// <param name="subkey">The subkey.</param>
+ /// <returns>The mod URL for the given subkey, or <see langword="null"/> if this manifest does not contain the given subkey.</returns>
+ public override string? GetUrl(string? subkey)
+ {
+ return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod)
+ ? mod.ModPageUrl
+ : null;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Convert the raw download info from an update manifest to <see cref="IModDownload"/>.</summary>
+ /// <param name="mods">The mods from the update manifest.</param>
+ private IEnumerable<IModDownload> ParseDownloads(IDictionary<string, UpdateManifestModModel>? mods)
+ {
+ if (mods is null)
+ yield break;
+
+ foreach ((string modKey, UpdateManifestModModel mod) in mods)
+ {
+ foreach (UpdateManifestVersionModel version in mod.Versions)
+ yield return new UpdateManifestModDownload(modKey, mod.Name ?? modKey, version.Version, version.ModPageUrl);
+ }
+ }
+
+ }
+}
diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs
index fe171785..8cb82989 100644
--- a/src/SMAPI.Web/Framework/IModDownload.cs
+++ b/src/SMAPI.Web/Framework/IModDownload.cs
@@ -14,5 +14,16 @@ namespace StardewModdingAPI.Web.Framework
/// <summary>The download's file version.</summary>
string? Version { get; }
+
+ /// <summary>The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from.</summary>
+ string? ModPageUrl { get; }
+
+
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Get whether the subkey matches this download.</summary>
+ /// <param name="subkey">The update subkey to check.</param>
+ bool MatchesSubkey(string subkey);
}
}
diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs
index 4d0a8d61..85be41e2 100644
--- a/src/SMAPI.Web/Framework/IModPage.cs
+++ b/src/SMAPI.Web/Framework/IModPage.cs
@@ -39,10 +39,21 @@ namespace StardewModdingAPI.Web.Framework
[MemberNotNullWhen(false, nameof(IModPage.Error))]
bool IsValid { get; }
+ /// <summary>Whether this mod page requires update subkeys and does not allow matching downloads without them.</summary>
+ bool RequireSubkey { get; }
+
/*********
** Methods
*********/
+ /// <summary>Get the mod name for an update subkey, if different from the mod page name.</summary>
+ /// <param name="subkey">The update subkey.</param>
+ string? GetName(string? subkey);
+
+ /// <summary>Get the mod page URL for an update subkey, if different from the mod page it was fetched from.</summary>
+ /// <param name="subkey">The update subkey.</param>
+ string? GetUrl(string? subkey);
+
/// <summary>Set the fetched mod info.</summary>
/// <param name="name">The mod name.</param>
/// <param name="version">The mod's semantic version number.</param>
diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs
index e70b60bf..502c0827 100644
--- a/src/SMAPI.Web/Framework/ModInfoModel.cs
+++ b/src/SMAPI.Web/Framework/ModInfoModel.cs
@@ -27,6 +27,12 @@ namespace StardewModdingAPI.Web.Framework
/// <summary>The error message indicating why the mod is invalid (if applicable).</summary>
public string? Error { get; private set; }
+ /// <summary>The mod page URL from which <see cref="Version"/> can be downloaded, if different from the <see cref="Url"/>.</summary>
+ public string? MainModPageUrl { get; private set; }
+
+ /// <summary>The mod page URL from which <see cref="PreviewVersion"/> can be downloaded, if different from the <see cref="Url"/>.</summary>
+ public string? PreviewModPageUrl { get; private set; }
+
/*********
** Public methods
@@ -46,7 +52,8 @@ namespace StardewModdingAPI.Web.Framework
{
this
.SetBasicInfo(name, url)
- .SetVersions(version!, previewVersion)
+ .SetMainVersion(version!)
+ .SetPreviewVersion(previewVersion)
.SetError(status, error!);
}
@@ -62,14 +69,25 @@ namespace StardewModdingAPI.Web.Framework
return this;
}
- /// <summary>Set the mod version info.</summary>
- /// <param name="version">The semantic version for the mod's latest release.</param>
- /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param>
+ /// <summary>Set the mod's main version info.</summary>
+ /// <param name="version">The semantic version for the mod's latest stable release.</param>
+ /// <param name="modPageUrl">The mod page URL from which <paramref name="version"/> can be downloaded, if different from the <see cref="Url"/>.</param>
[MemberNotNull(nameof(ModInfoModel.Version))]
- public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null)
+ public ModInfoModel SetMainVersion(ISemanticVersion version, string? modPageUrl = null)
{
this.Version = version;
- this.PreviewVersion = previewVersion;
+ this.MainModPageUrl = modPageUrl;
+
+ return this;
+ }
+
+ /// <summary>Set the mod's preview version info.</summary>
+ /// <param name="version">The semantic version for the mod's latest preview release.</param>
+ /// <param name="modPageUrl">The mod page URL from which <paramref name="version"/> can be downloaded, if different from the <see cref="Url"/>.</param>
+ public ModInfoModel SetPreviewVersion(ISemanticVersion? version, string? modPageUrl = null)
+ {
+ this.PreviewVersion = version;
+ this.PreviewModPageUrl = modPageUrl;
return this;
}
diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs
index 674b9ffc..4bb72f78 100644
--- a/src/SMAPI.Web/Framework/ModSiteManager.cs
+++ b/src/SMAPI.Web/Framework/ModSiteManager.cs
@@ -59,30 +59,42 @@ namespace StardewModdingAPI.Web.Framework
/// <summary>Parse version info for the given mod page info.</summary>
/// <param name="page">The mod page info.</param>
- /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param>
+ /// <param name="updateKey">The update key to match in available files.</param>
/// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param>
/// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param>
- public ModInfoModel GetPageVersions(IModPage page, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions)
+ public ModInfoModel GetPageVersions(IModPage page, UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions)
{
- // get base model
+ // get ID to show in errors
+ string displayId = page.RequireSubkey
+ ? page.Id + updateKey.Subkey
+ : page.Id;
+
+ // validate
ModInfoModel model = new();
- if (page.IsValid)
+ if (!page.IsValid)
+ return model.SetError(page.Status, page.Error);
+ if (page.RequireSubkey && updateKey.Subkey is null)
+ return model.SetError(RemoteModStatus.RequiredSubkeyMissing, $"The {page.Site} mod with ID '{displayId}' requires an update subkey indicating which mod to fetch.");
+
+ // add basic info (unless it's a manifest, in which case the 'mod page' is the JSON file)
+ if (updateKey.Site != ModSiteKey.UpdateManifest)
model.SetBasicInfo(page.Name, page.Url);
- else
- {
- model.SetError(page.Status, page.Error);
- return model;
- }
// fetch versions
- bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion);
- if (!hasVersions && subkey != null)
- hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion);
+ bool hasVersions = this.TryGetLatestVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainModPageUrl, out string? previewModPageUrl);
if (!hasVersions)
- return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions.");
+ return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{displayId}' has no valid versions.");
+
+ // apply mod page info
+ model.SetBasicInfo(
+ name: page.GetName(updateKey.Subkey) ?? page.Name,
+ url: page.GetUrl(updateKey.Subkey) ?? page.Url
+ );
// return info
- return model.SetVersions(mainVersion!, previewVersion);
+ return model
+ .SetMainVersion(mainVersion!, mainModPageUrl)
+ .SetPreviewVersion(previewVersion, previewModPageUrl);
}
/// <summary>Get a semantic local version for update checks.</summary>
@@ -113,34 +125,37 @@ namespace StardewModdingAPI.Web.Framework
/// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param>
/// <param name="main">The main mod version.</param>
/// <param name="preview">The latest prerelease version, if newer than <paramref name="main"/>.</param>
- private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview)
+ /// <param name="mainModPageUrl">The mod page URL from which <paramref name="main"/> can be downloaded, if different from the <see cref="mod"/>'s URL.</param>
+ /// <param name="previewModPageUrl">The mod page URL from which <paramref name="preview"/> can be downloaded, if different from the <see cref="mod"/>'s URL.</param>
+ private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview, out string? mainModPageUrl, out string? previewModPageUrl)
{
main = null;
preview = null;
+ mainModPageUrl = null;
+ previewModPageUrl = null;
+ if (mod is null)
+ return false;
// parse all versions from the mod page
- IEnumerable<(string? name, string? description, ISemanticVersion? version)> GetAllVersions()
+ IEnumerable<(IModDownload? download, ISemanticVersion? version)> GetAllVersions()
{
- if (mod != null)
+ ISemanticVersion? ParseAndMapVersion(string? raw)
{
- ISemanticVersion? ParseAndMapVersion(string? raw)
- {
- raw = this.NormalizeVersion(raw);
- return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions);
- }
+ raw = this.NormalizeVersion(raw);
+ return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions);
+ }
- // get mod version
- ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version);
- if (modVersion != null)
- yield return (name: null, description: null, version: ParseAndMapVersion(mod.Version));
+ // get mod version
+ ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version);
+ if (modVersion != null)
+ yield return (download: null, version: modVersion);
- // get file versions
- foreach (IModDownload download in mod.Downloads)
- {
- ISemanticVersion? cur = ParseAndMapVersion(download.Version);
- if (cur != null)
- yield return (download.Name, download.Description, cur);
- }
+ // get file versions
+ foreach (IModDownload download in mod.Downloads)
+ {
+ ISemanticVersion? cur = ParseAndMapVersion(download.Version);
+ if (cur != null)
+ yield return (download, cur);
}
}
var versions = GetAllVersions()
@@ -148,40 +163,59 @@ namespace StardewModdingAPI.Web.Framework
.ToArray();
// get main + preview versions
- void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(string? name, string? description, ISemanticVersion? version), bool>? filter = null)
+ void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainUrl, out string? previewUrl, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null)
{
mainVersion = null;
previewVersion = null;
+ mainUrl = null;
+ previewUrl = null;
// get latest main + preview version
- foreach ((string? name, string? description, ISemanticVersion? version) entry in versions)
+ foreach ((IModDownload? download, ISemanticVersion? version) entry in versions)
{
if (entry.version is null || filter?.Invoke(entry) == false)
continue;
if (entry.version.IsPrerelease())
- previewVersion ??= entry.version;
+ {
+ if (previewVersion is null)
+ {
+ previewVersion = entry.version;
+ previewUrl = entry.download?.ModPageUrl;
+ }
+ }
else
- mainVersion ??= entry.version;
-
- if (mainVersion != null)
+ {
+ mainVersion = entry.version;
+ mainUrl = entry.download?.ModPageUrl;
break; // any others will be older since entries are sorted by version
+ }
}
// normalize values
if (previewVersion is not null)
{
- mainVersion ??= previewVersion; // if every version is prerelease, latest one is the main version
+ if (mainVersion is null)
+ {
+ // if every version is prerelease, latest one is the main version
+ mainVersion = previewVersion;
+ mainUrl = previewUrl;
+ }
if (!previewVersion.IsNewerThan(mainVersion))
+ {
previewVersion = null;
+ previewUrl = null;
+ }
}
}
+ // get versions for subkey
if (subkey is not null)
- TryGetVersions(out main, out preview, entry => entry.name?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true || entry.description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true);
- if (main is null)
- TryGetVersions(out main, out preview);
+ TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl, filter: entry => entry.download?.MatchesSubkey(subkey) == true);
+ // fallback to non-subkey versions
+ if (main is null && !mod.RequireSubkey)
+ TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl);
return main != null;
}
diff --git a/src/SMAPI.Web/Framework/RemoteModStatus.cs b/src/SMAPI.Web/Framework/RemoteModStatus.cs
index 139ecfd3..235bcec4 100644
--- a/src/SMAPI.Web/Framework/RemoteModStatus.cs
+++ b/src/SMAPI.Web/Framework/RemoteModStatus.cs
@@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework
/// <summary>The mod does not exist.</summary>
DoesNotExist,
+ /// <summary>The mod page exists, but it requires a subkey and none was provided.</summary>
+ RequiredSubkeyMissing,
+
/// <summary>The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred).</summary>
TemporaryError
}
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 54c25979..a068a998 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -20,6 +20,7 @@ using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
+using StardewModdingAPI.Web.Framework.Clients.UpdateManifest;
using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.RedirectRules;
@@ -149,6 +150,8 @@ namespace StardewModdingAPI.Web
baseUrl: api.PastebinBaseUrl,
userAgent: userAgent
));
+
+ services.AddSingleton<IUpdateManifestClient>(new UpdateManifestClient(userAgent: userAgent));
}
// init helpers