diff options
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 |