diff options
18 files changed, 359 insertions, 36 deletions
diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs index 47cd3f7e..d1dd9049 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 for an update manifest file.</summary> + UpdateManifest } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 71fb42c2..1c34f2af 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 UpdateManifest client.</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> @@ -161,18 +163,22 @@ 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; + string? curMainUrl = data.MainVersionUrl; ISemanticVersion? curPreview = data.PreviewVersion; + string? curPreviewUrl = data.PreviewVersionUrl; 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 diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index b37e5cda..5cc03aba 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -17,6 +17,10 @@ namespace StardewModdingAPI.Web.Framework.Clients /// <summary>The download's file version.</summary> public string? Version { get; } + /// <summary> + /// The URL for this download, if it has one distinct from the mod page's URL. + /// </summary> + public string? Url { get; } /********* ** Public methods @@ -25,21 +29,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="url">The download's URL (if different from the mod page's URL).</param> + public GenericModDownload(string name, string? description, string? version, string? url = null) { this.Name = name; this.Description = description; this.Version = version; + this.Url = url; } /// <summary> - /// Return true if the subkey matches this download. A subkey matches if it appears as + /// Return <see langword="true"/> if the subkey matches this download. A subkey matches if it appears as /// a substring in the name or description. /// </summary> /// <param name="subkey">the subkey</param> - /// <returns><c>true</c> if <paramref name="subkey"/> matches this download, otherwise <c>false</c></returns> - /// <exception cref="System.NotImplementedException"></exception> - public bool MatchesSubkey(string subkey) { + /// <returns><see langword="true"/> if <paramref name="subkey"/> matches this download, otherwise <see langword="false"/></returns> + public virtual bool MatchesSubkey(string subkey) { return this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true || 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..4c66e1a0 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -40,6 +40,8 @@ namespace StardewModdingAPI.Web.Framework.Clients [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] public bool IsValid => this.Status == RemoteModStatus.Ok; + /// <summary>Whether to use strict subkey matching or not.</summary> + public bool IsSubkeyStrict { get; set; } = false; /********* ** Public methods @@ -79,5 +81,19 @@ namespace StardewModdingAPI.Web.Framework.Clients return this; } + + /// <summary>Returns the mod page name.</summary> + /// <param name="subkey">ignored</param> + /// <returns>The mod page name.</returns> + public virtual string? GetName(string? subkey) { + return this.Name; + } + + /// <summary>Returns the mod page URL.</summary> + /// <param name="subkey">ignored</param> + /// <returns>The mod page URL.</returns> + 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..36d018c7 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs @@ -0,0 +1,9 @@ +// Copyright 2022 Jamie Taylor +using System; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// <summary>An HTTP client for fetching an update manifest from an arbitrary URL.</summary> + internal interface IUpdateManifestClient : IModSiteClient, IDisposable { } +} + diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs new file mode 100644 index 00000000..48e3c294 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs @@ -0,0 +1,15 @@ +// Copyright 2022 Jamie Taylor +using System.Net.Http.Formatting; +using System.Net.Http.Headers; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// <summary> + /// A <see cref="JsonMediaTypeFormatter"/> that can parse from content of type <c>text/plain</c>. + /// </summary> + internal class TextAsJsonMediaTypeFormatter : JsonMediaTypeFormatter { + /// <summary>Construct a new <see cref="JsonMediaTypeFormatter"/></summary> + public TextAsJsonMediaTypeFormatter() { + this.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain")); + } + } +} 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..d7cf4945 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs @@ -0,0 +1,55 @@ +// Copyright 2022 Jamie Taylor +using System; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using System.Threading.Tasks; +using System.Net; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// <summary>An HTTP client for fetching an update manifest from an arbitrary 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.Add(new TextAsJsonMediaTypeFormatter()); + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() { + this.Client.Dispose(); + } + + /// <inheritdoc/> + public async Task<IModPage?> GetModData(string id) { + UpdateManifestModel? manifest; + try { + manifest = await this.Client.GetAsync(id).As<UpdateManifestModel?>(); + } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { + return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}"); + } + if (manifest is null) { + return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"Error parsing manifest at {id}"); + } + + return new UpdateManifestModPage(id, manifest); + } + } +} 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..117ae15c --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs @@ -0,0 +1,27 @@ +// Copyright 2022 Jamie Taylor +using System; +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// <summary>Metadata about a mod download in an update manifest file.</summary> + internal class UpdateManifestModDownload : GenericModDownload { + /// <summary>The subkey for this mod download</summary> + private readonly string subkey; + /// <summary>Construct an instance.</summary> + /// <param name="subkey">The subkey for this download.</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 subkey, string name, string? version, string? url) : base(name, null, version, url) { + this.subkey = subkey; + } + + /// <summary> + /// Returns <see langword="true"/> iff the given subkey is the same as the subkey for this download. + /// </summary> + /// <param name="subkey">The subkey to match</param> + /// <returns><see langword="true"/> if <paramref name="subkey"/> is the same as the subkey for this download, <see langword="false"/> otherwise.</returns> + public override bool MatchesSubkey(string subkey) { + return this.subkey == subkey; + } + } +} + diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs new file mode 100644 index 00000000..4ec9c03d --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs @@ -0,0 +1,26 @@ +// Copyright 2022 Jamie Taylor +using System; +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// <summary>Data model for a mod in an update manifest.</summary> + internal class UpdateManifestModModel { + /// <summary>The mod's name.</summary> + public string Name { get; } + + /// <summary>The mod's URL.</summary> + public string? Url { get; } + + /// <summary>The versions for this mod.</summary> + public UpdateManifestVersionModel[] Versions { get; } + + /// <summary>Construct an instance.</summary> + /// <param name="name">The mod's name.</param> + /// <param name="url">The mod's URL.</param> + /// <param name="versions">The versions for this mod.</param> + public UpdateManifestModModel(string name, string? url, UpdateManifestVersionModel[] versions) { + this.Name = name; + this.Url = url; + this.Versions = versions; + } + } +} + 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..109175b5 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs @@ -0,0 +1,58 @@ +// Copyright 2022 Jamie Taylor +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// <summary>Metadata about an update manifest "page".</summary> + internal class UpdateManifestModPage : GenericModPage { + /// <summary>The update manifest model.</summary> + private UpdateManifestModel manifest; + + /// <summary>Constuct an instance.</summary> + /// <param name="id">The "id" (i.e., URL) of this update manifest.</param> + /// <param name="manifest">The manifest object model.</param> + public UpdateManifestModPage(string id, UpdateManifestModel manifest) : base(ModSiteKey.UpdateManifest, id) { + this.IsSubkeyStrict = true; + this.manifest = manifest; + this.SetInfo(name: id, url: id, version: null, downloads: TranslateDownloads(manifest).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) { + if (subkey is null) + return null; + this.manifest.Subkeys.TryGetValue(subkey, out UpdateManifestModModel? modModel); + return modModel?.Name; + } + + /// <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) { + if (subkey is null) + return null; + this.manifest.Subkeys.TryGetValue(subkey, out UpdateManifestModModel? modModel); + return modModel?.Url; + } + + + /********* + ** Private methods + *********/ + /// <summary>Translate the downloads from the manifest's object model into <see cref="IModDownload"/> objects.</summary> + /// <param name="manifest">The manifest object model.</param> + /// <returns>An <see cref="IModDownload"/> for each <see cref="UpdateManifestVersionModel"/> in the manifest.</returns> + private static IEnumerable<IModDownload> TranslateDownloads(UpdateManifestModel manifest) { + foreach (var entry in manifest.Subkeys) { + foreach (var version in entry.Value.Versions) { + yield return new UpdateManifestModDownload(entry.Key, entry.Value.Name, version.Version, version.DownloadFileUrl ?? version.DownloadPageUrl); + } + } + } + + } +}
\ No newline at end of file diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs new file mode 100644 index 00000000..03f89726 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs @@ -0,0 +1,23 @@ +// Copyright 2022 Jamie Taylor +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// <summary>Data model for an update manifest.</summary> + internal class UpdateManifestModel { + /// <summary>The manifest format version.</summary> + public string ManifestVersion { get; } + + /// <summary>The subkeys in this update manifest.</summary> + public IDictionary<string, UpdateManifestModModel> Subkeys { get; } + + /// <summary>Construct an instance.</summary> + /// <param name="manifestVersion">The manifest format version.</param> + /// <param name="subkeys">The subkeys in this update manifest.</param> + public UpdateManifestModel(string manifestVersion, IDictionary<string, UpdateManifestModModel> subkeys) { + this.ManifestVersion = manifestVersion; + this.Subkeys = subkeys; + } + } +} + diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs new file mode 100644 index 00000000..55b6db61 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs @@ -0,0 +1,26 @@ +// Copyright 2022 Jamie Taylor +using System; +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// <summary>Data model for a Version in an update manifest.</summary> + internal class UpdateManifestVersionModel { + /// <summary>The semantic version string.</summary> + public string Version { get; } + + /// <summary>The URL for this version's download page (if any).</summary> + public string? DownloadPageUrl { get; } + + /// <summary>The URL for this version's direct file download (if any).</summary> + public string? DownloadFileUrl { get; } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The semantic version string.</param> + /// <param name="downloadPageUrl">This version's download page URL (if any).</param> + /// <param name="downloadFileUrl">This version's direct file download URL (if any).</param> + public UpdateManifestVersionModel(string version, string? downloadPageUrl, string? downloadFileUrl) { + this.Version = version; + this.DownloadPageUrl = downloadPageUrl; + this.DownloadFileUrl = downloadFileUrl; + } + } +} + diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index 13739f8f..b0e9a664 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -15,9 +15,12 @@ namespace StardewModdingAPI.Web.Framework /// <summary>The download's file version.</summary> string? Version { get; } - /// <summary>Return <c>true</c> iff the subkey matches this download</summary> + /// <summary>This download's URL (if it has a URL that is different from the containing mod page's URL).</summary> + string? Url { get; } + + /// <summary>Return <see langword="true"/> iff the subkey matches this download</summary> /// <param name="subkey">the subkey</param> - /// <returns><c>true</c> if <paramref name="subkey"/> matches this download, otherwise <c>false</c></returns> + /// <returns><see langword="true"/> if <paramref name="subkey"/> matches this download, otherwise <see langword="false"/></returns> bool MatchesSubkey(string subkey); } } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index 4d0a8d61..3fc8bb81 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -39,10 +39,27 @@ namespace StardewModdingAPI.Web.Framework [MemberNotNullWhen(false, nameof(IModPage.Error))] bool IsValid { get; } + /// <summary> + /// Does this page use strict subkey matching. Pages that use string subkey matching do not fall back + /// to searching for versions without a subkey if there are no versions found when given a subkey. + /// Additionally, the leading <c>@</c> is stripped from the subkey value before searching for matches. + /// </summary> + bool IsSubkeyStrict { get; } /********* ** Methods *********/ + + /// <summary>Get the mod name associated with the given subkey, if any.</summary> + /// <param name="subkey">The subkey.</param> + /// <returns>The mod name associated with the given subkey (if any)</returns> + string? GetName(string? subkey); + + /// <summary>Get the URL for the mod associated with the given subkey, if any.</summary> + /// <param name="subkey">The subkey.</param> + /// <returns>The URL for the mod associated with the given subkey (if any)</returns> + 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..17415589 100644 --- a/src/SMAPI.Web/Framework/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -27,6 +27,11 @@ 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 URL associated with the mod's latest version (if distinct from the mod page's URL).</summary> + public string? MainVersionUrl { get; private set; } + + /// <summary>The URL associated with the mod's <see cref="PreviewVersion"/> (if distinct from the mod page's URL).</summary> + public string? PreviewVersionUrl { get; private set; } /********* ** Public methods @@ -65,11 +70,15 @@ namespace StardewModdingAPI.Web.Framework /// <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> + /// <param name="mainVersionUrl">The URL associated with <paramref name="version"/>, if different from the mod page's URL.</param> + /// <param name="previewVersionUrl">The URL associated with <paramref name="previewVersion"/>, if different from the mod page's URL.</param> [MemberNotNull(nameof(ModInfoModel.Version))] - public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null) + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null, string? mainVersionUrl = null, string? previewVersionUrl = null) { this.Version = version; this.PreviewVersion = previewVersion; + this.MainVersionUrl = mainVersionUrl; + this.PreviewVersionUrl = previewVersionUrl; return this; } diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index 70070d63..5581d799 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -66,23 +66,25 @@ namespace StardewModdingAPI.Web.Framework { // get base model ModInfoModel model = new(); - if (page.IsValid) - model.SetBasicInfo(page.Name, page.Url); - else - { + if (!page.IsValid) { model.SetError(page.Status, page.Error); return model; } + if (page.IsSubkeyStrict && subkey is not null) { + if (subkey.Length > 0 && subkey[0] == '@') { + subkey = subkey.Substring(1); + } + } + // 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, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainVersionUrl, out string? previewVersionUrl); 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 '{page.Id}{subkey ?? ""}' has no valid versions."); + model.SetBasicInfo(page.GetName(subkey) ?? page.Name, page.GetUrl(subkey) ?? page.Url); // return info - return model.SetVersions(mainVersion!, previewVersion); + return model.SetVersions(mainVersion!, previewVersion, mainVersionUrl, previewVersionUrl); } /// <summary>Get a semantic local version for update checks.</summary> @@ -113,10 +115,12 @@ 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) + private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview, out string? mainVersionUrl, out string? previewVersionUrl) { main = null; preview = null; + mainVersionUrl = null; + previewVersionUrl = null; // parse all versions from the mod page IEnumerable<(IModDownload? download, ISemanticVersion? version)> GetAllVersions() @@ -132,7 +136,7 @@ namespace StardewModdingAPI.Web.Framework // get mod version ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); if (modVersion != null) - yield return (download: null, version: ParseAndMapVersion(mod.Version)); + yield return (download: null, version: modVersion); // get file versions foreach (IModDownload download in mod.Downloads) @@ -148,10 +152,12 @@ namespace StardewModdingAPI.Web.Framework .ToArray(); // get main + preview versions - void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) + void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainVersionUrl, out string? previewVersionUrl, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; + mainVersionUrl = null; + previewVersionUrl = null; // get latest main + preview version foreach ((IModDownload? download, ISemanticVersion? version) entry in versions) @@ -159,28 +165,40 @@ namespace StardewModdingAPI.Web.Framework if (entry.version is null || filter?.Invoke(entry) == false) continue; - if (entry.version.IsPrerelease()) - previewVersion ??= entry.version; - else - mainVersion ??= entry.version; - - if (mainVersion != null) + if (entry.version.IsPrerelease()) { + if (previewVersion is null) { + previewVersion = entry.version; + previewVersionUrl = entry.download?.Url; + } + } else { + mainVersion = entry.version; + mainVersionUrl = entry.download?.Url; 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 (!previewVersion.IsNewerThan(mainVersion)) + if (mainVersion is null) { + // if every version is prerelease, latest one is the main version + mainVersion = previewVersion; + mainVersionUrl = previewVersionUrl; + } + if (!previewVersion.IsNewerThan(mainVersion)) { previewVersion = null; + previewVersionUrl = null; + } } } - if (subkey is not null) - TryGetVersions(out main, out preview, entry => entry.download?.MatchesSubkey(subkey) == true); + if (subkey is not null) { + TryGetVersions(out main, out preview, out mainVersionUrl, out previewVersionUrl, entry => entry.download?.MatchesSubkey(subkey) == true); + if (mod?.IsSubkeyStrict == true) + return main != null; + } if (main is null) - TryGetVersions(out main, out preview); + TryGetVersions(out main, out preview, out mainVersionUrl, out previewVersionUrl); return main != null; } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index d26cb6f8..cfec1d08 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -12,6 +12,7 @@ <None Remove="Properties\PublishProfiles\**" /> <None Remove="Properties\ServiceDependencies\**" /> <Content Remove="aws-beanstalk-tools-defaults.json" /> + <None Remove="Framework\Clients\UpdateManifest\" /> </ItemGroup> <ItemGroup> @@ -48,4 +49,7 @@ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup> + <ItemGroup> + <Folder Include="Framework\Clients\UpdateManifest\" /> + </ItemGroup> </Project> 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 |