diff options
Diffstat (limited to 'src/SMAPI.Web/Controllers')
-rw-r--r-- | src/SMAPI.Web/Controllers/IndexController.cs | 180 | ||||
-rw-r--r-- | src/SMAPI.Web/Controllers/LogParserController.cs | 48 | ||||
-rw-r--r-- | src/SMAPI.Web/Controllers/ModsApiController.cs | 248 |
3 files changed, 349 insertions, 127 deletions
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index 0464e50a..8c4a0332 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -1,10 +1,15 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using HtmlAgilityPack; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; -using StardewModdingAPI.Common; +using Microsoft.Extensions.Options; +using StardewModdingAPI.Toolkit; using StardewModdingAPI.Web.Framework.Clients.GitHub; +using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; namespace StardewModdingAPI.Web.Controllers @@ -17,6 +22,9 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Properties *********/ + /// <summary>The site config settings.</summary> + private readonly SiteConfig SiteConfig; + /// <summary>The cache in which to store release data.</summary> private readonly IMemoryCache Cache; @@ -24,7 +32,7 @@ namespace StardewModdingAPI.Web.Controllers private readonly IGitHubClient GitHub; /// <summary>The cache time for release info.</summary> - private readonly TimeSpan CacheTime = TimeSpan.FromSeconds(1); + private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10); /// <summary>The GitHub repository name to check for update.</summary> private readonly string RepositoryName = "Pathoschild/SMAPI"; @@ -36,34 +44,35 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Construct an instance.</summary> /// <param name="cache">The cache in which to store release data.</param> /// <param name="github">The GitHub API client.</param> - public IndexController(IMemoryCache cache, IGitHubClient github) + /// <param name="siteConfig">The context config settings.</param> + public IndexController(IMemoryCache cache, IGitHubClient github, IOptions<SiteConfig> siteConfig) { this.Cache = cache; this.GitHub = github; + this.SiteConfig = siteConfig.Value; } /// <summary>Display the index page.</summary> [HttpGet] public async Task<ViewResult> Index() { - // fetch SMAPI releases - IndexVersionModel stableVersion = await this.Cache.GetOrCreateAsync("stable-version", async entry => - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); - return new IndexVersionModel(release.Name, release.Body, this.GetMainDownloadUrl(release), this.GetDevDownloadUrl(release)); - }); - IndexVersionModel betaVersion = await this.Cache.GetOrCreateAsync("beta-version", async entry => - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true); - return release.IsPrerelease - ? this.GetBetaDownload(release) - : null; - }); + // choose versions + ReleaseVersion[] versions = await this.GetReleaseVersionsAsync(); + ReleaseVersion stableVersion = versions.LastOrDefault(version => !version.IsBeta && !version.IsForDevs); + ReleaseVersion stableVersionForDevs = versions.LastOrDefault(version => !version.IsBeta && version.IsForDevs); + ReleaseVersion betaVersion = versions.LastOrDefault(version => version.IsBeta && !version.IsForDevs); + ReleaseVersion betaVersionForDevs = versions.LastOrDefault(version => version.IsBeta && version.IsForDevs); // render view - var model = new IndexModel(stableVersion, betaVersion); + IndexVersionModel stableVersionModel = stableVersion != null + ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl) + : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong) + IndexVersionModel betaVersionModel = betaVersion != null && this.SiteConfig.EnableSmapiBeta + ? new IndexVersionModel(betaVersion.Version.ToString(), betaVersion.Release.Body, betaVersion.Asset.DownloadUrl, betaVersionForDevs?.Asset.DownloadUrl) + : null; + + // render view + var model = new IndexModel(stableVersionModel, betaVersionModel); return this.View(model); } @@ -71,62 +80,109 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Private methods *********/ - /// <summary>Get the main download URL for a SMAPI release.</summary> - /// <param name="release">The SMAPI release.</param> - private string GetMainDownloadUrl(GitRelease release) + /// <summary>Get a sorted, parsed list of SMAPI downloads for the latest releases.</summary> + private async Task<ReleaseVersion[]> GetReleaseVersionsAsync() { - // get main download URL - foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) + return await this.Cache.GetOrCreateAsync("available-versions", async entry => { - if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer.zip")) - return asset.DownloadUrl; - } + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - // fallback just in case - return "https://github.com/pathoschild/SMAPI/releases"; - } + // get latest release (whether preview or stable) + GitRelease stableRelease = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true); - /// <summary>Get the for-developers download URL for a SMAPI release.</summary> - /// <param name="release">The SMAPI release.</param> - private string GetDevDownloadUrl(GitRelease release) - { - // get dev download URL - foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) - { - if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer-for-developers.zip")) - return asset.DownloadUrl; - } + // split stable/prerelease if applicable + GitRelease betaRelease = null; + if (stableRelease.IsPrerelease) + { + GitRelease result = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); + if (result != null) + { + betaRelease = stableRelease; + stableRelease = result; + } + } + + // strip 'noinclude' blocks from release descriptions + foreach (GitRelease release in new[] { stableRelease, betaRelease }) + { + if (release == null) + continue; + + HtmlDocument doc = new HtmlDocument(); + doc.LoadHtml(release.Body); + foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0]) + node.Remove(); + release.Body = doc.DocumentNode.InnerHtml.Trim(); + } - // fallback just in case - return "https://github.com/pathoschild/SMAPI/releases"; + // get versions + ReleaseVersion[] stableVersions = this.ParseReleaseVersions(stableRelease).ToArray(); + ReleaseVersion[] betaVersions = this.ParseReleaseVersions(betaRelease).ToArray(); + return stableVersions + .Concat(betaVersions) + .OrderBy(p => p.Version) + .ToArray(); + }); } - /// <summary>Get the latest beta download for a SMAPI release.</summary> - /// <param name="release">The SMAPI release.</param> - private IndexVersionModel GetBetaDownload(GitRelease release) + /// <summary>Get a parsed list of SMAPI downloads for a release.</summary> + /// <param name="release">The GitHub release.</param> + private IEnumerable<ReleaseVersion> ParseReleaseVersions(GitRelease release) { - // get download with the latest version - SemanticVersionImpl latestVersion = null; - string latestUrl = null; - foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) + if (release?.Assets == null) + yield break; + + foreach (GitAsset asset in release.Assets) { - // parse version - Match versionMatch = Regex.Match(asset.FileName, @"SMAPI-([\d\.]+(?:-.+)?)-installer.zip"); - if (!versionMatch.Success || !SemanticVersionImpl.TryParse(versionMatch.Groups[1].Value, out SemanticVersionImpl version)) + Match match = Regex.Match(asset.FileName, @"SMAPI-(?<version>[\d\.]+(?:-.+)?)-installer(?<forDevs>-for-developers)?.zip"); + if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion version)) continue; + bool isBeta = version.IsPrerelease(); + bool isForDevs = match.Groups["forDevs"].Success; - // save latest version - if (latestVersion == null || latestVersion.CompareTo(version) < 0) - { - latestVersion = version; - latestUrl = asset.DownloadUrl; - } + yield return new ReleaseVersion(release, asset, version, isBeta, isForDevs); } + } - // return if prerelease - return latestVersion?.Tag != null - ? new IndexVersionModel(latestVersion.ToString(), release.Body, latestUrl, null) - : null; + /// <summary>A parsed release download.</summary> + private class ReleaseVersion + { + /********* + ** Accessors + *********/ + /// <summary>The underlying GitHub release.</summary> + public GitRelease Release { get; } + + /// <summary>The underlying download asset.</summary> + public GitAsset Asset { get; } + + /// <summary>The SMAPI version.</summary> + public ISemanticVersion Version { get; } + + /// <summary>Whether this is a beta download.</summary> + public bool IsBeta { get; } + + /// <summary>Whether this is a 'for developers' download.</summary> + public bool IsForDevs { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="release">The underlying GitHub release.</param> + /// <param name="asset">The underlying download asset.</param> + /// <param name="version">The SMAPI version.</param> + /// <param name="isBeta">Whether this is a beta download.</param> + /// <param name="isForDevs">Whether this is a 'for developers' download.</param> + public ReleaseVersion(GitRelease release, GitAsset asset, ISemanticVersion version, bool isBeta, bool isForDevs) + { + this.Release = release; + this.Asset = asset; + this.Version = version; + this.IsBeta = isBeta; + this.IsForDevs = isForDevs; + } } } } diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 62547deb..17f8d3aa 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -20,8 +21,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Properties *********/ - /// <summary>The log parser config settings.</summary> - private readonly ContextConfig Config; + /// <summary>The site config settings.</summary> + private readonly SiteConfig Config; /// <summary>The underlying Pastebin client.</summary> private readonly IPastebinClient Pastebin; @@ -38,11 +39,11 @@ namespace StardewModdingAPI.Web.Controllers ** Constructor ***/ /// <summary>Construct an instance.</summary> - /// <param name="contextProvider">The context config settings.</param> + /// <param name="siteConfig">The context config settings.</param> /// <param name="pastebin">The Pastebin API client.</param> - public LogParserController(IOptions<ContextConfig> contextProvider, IPastebinClient pastebin) + public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin) { - this.Config = contextProvider.Value; + this.Config = siteConfig.Value; this.Pastebin = pastebin; } @@ -51,34 +52,49 @@ namespace StardewModdingAPI.Web.Controllers ***/ /// <summary>Render the log parser UI.</summary> /// <param name="id">The paste ID.</param> + /// <param name="raw">Whether to display the raw unparsed log.</param> [HttpGet] [Route("log")] [Route("log/{id}")] - public async Task<ViewResult> Index(string id = null) + public async Task<ViewResult> Index(string id = null, bool raw = false) { // fresh page if (string.IsNullOrWhiteSpace(id)) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, null)); + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id)); // log page PasteInfo paste = await this.GetAsync(id); ParsedLog log = paste.Success ? new LogParser().Parse(paste.Content) : new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error }; - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log)); + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log, raw)); } /*** ** JSON ***/ /// <summary>Save raw log data.</summary> - /// <param name="content">The log content to save.</param> - [HttpPost, Produces("application/json"), AllowLargePosts] - [Route("log/save")] - public async Task<SavePasteResult> PostAsync([FromBody] string content) + [HttpPost, AllowLargePosts] + [Route("log")] + public async Task<ActionResult> PostAsync() { - content = this.CompressString(content); - return await this.Pastebin.PostAsync(content); + // get raw log text + string input = this.Request.Form["input"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(input)) + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." }); + + // upload log + input = this.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync(input); + + // handle errors + if (!result.Success) + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, result.ID) { UploadError = $"Pastebin error: {result.Error ?? "unknown error"}" }); + + // redirect to view + UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl)); + uri.Path = uri.Path.TrimEnd('/') + '/' + result.ID; + return this.Redirect(uri.Uri.ToString()); } @@ -115,7 +131,7 @@ namespace StardewModdingAPI.Web.Controllers } // prefix length - var zipBuffer = new byte[compressedData.Length + 4]; + byte[] zipBuffer = new byte[compressedData.Length + 4]; Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); @@ -151,7 +167,7 @@ namespace StardewModdingAPI.Web.Controllers memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); // read data - var buffer = new byte[dataLength]; + byte[] buffer = new byte[dataLength]; memoryStream.Position = 0; using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) gZipStream.Read(buffer, 0, buffer.Length); diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 24517263..b500e19d 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -1,12 +1,17 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.Nexus; @@ -38,19 +43,28 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>A regex which matches SMAPI-style semantic version.</summary> private readonly string VersionRegex; + /// <summary>The internal mod metadata list.</summary> + private readonly ModDatabase ModDatabase; + + /// <summary>The web URL for the wiki compatibility list.</summary> + private readonly string WikiCompatibilityPageUrl; + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <param name="environment">The web hosting environment.</param> /// <param name="cache">The cache in which to store mod metadata.</param> /// <param name="configProvider">The config settings for mod update checks.</param> /// <param name="chucklefish">The Chucklefish API client.</param> /// <param name="github">The GitHub API client.</param> /// <param name="nexus">The Nexus API client.</param> - public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus) + public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus) { + this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json")); ModUpdateCheckConfig config = configProvider.Value; + this.WikiCompatibilityPageUrl = config.WikiCompatibilityPageUrl; this.Cache = cache; this.SuccessCacheMinutes = config.SuccessCacheMinutes; @@ -67,76 +81,126 @@ namespace StardewModdingAPI.Web.Controllers } /// <summary>Fetch version metadata for the given mods.</summary> - /// <param name="modKeys">The namespaced mod keys to search as a comma-delimited array.</param> - /// <param name="allowInvalidVersions">Whether to allow non-semantic versions, instead of returning an error for those.</param> - [HttpGet] - public async Task<IDictionary<string, ModInfoModel>> GetAsync(string modKeys, bool allowInvalidVersions = false) + /// <param name="model">The mod search criteria.</param> + [HttpPost] + public async Task<object> PostAsync([FromBody] ModSearchModel model) { - string[] modKeysArray = modKeys?.Split(',').ToArray(); - if (modKeysArray == null || !modKeysArray.Any()) - return new Dictionary<string, ModInfoModel>(); + // parse request data + ISemanticVersion apiVersion = this.GetApiVersion(); + ModSearchEntryModel[] searchMods = this.GetSearchMods(model, apiVersion).ToArray(); + + // fetch wiki data + WikiCompatibilityEntry[] wikiData = await this.GetWikiDataAsync(); + + // fetch data + IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); + foreach (ModSearchEntryModel mod in searchMods) + { + if (string.IsNullOrWhiteSpace(mod.ID)) + continue; + + ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata); + result.SetBackwardsCompatibility(apiVersion); + mods[mod.ID] = result; + } - return await this.PostAsync(new ModSearchModel(modKeysArray, allowInvalidVersions)); + // return in expected structure + return apiVersion.IsNewerThan("2.6-beta.18") + ? mods.Values + : (object)mods; } - /// <summary>Fetch version metadata for the given mods.</summary> - /// <param name="search">The mod search criteria.</param> - [HttpPost] - public async Task<IDictionary<string, ModInfoModel>> PostAsync([FromBody] ModSearchModel search) + + /********* + ** Private methods + *********/ + /// <summary>Get the metadata for a mod.</summary> + /// <param name="search">The mod data to match.</param> + /// <param name="wikiData">The wiki data.</param> + /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> + /// <returns>Returns the mod data if found, else <c>null</c>.</returns> + private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiCompatibilityEntry[] wikiData, bool includeExtendedMetadata) { - // parse model - bool allowInvalidVersions = search?.AllowInvalidVersions ?? false; - string[] modKeys = (search?.ModKeys?.ToArray() ?? new string[0]) - .Distinct(StringComparer.CurrentCultureIgnoreCase) - .OrderBy(p => p, StringComparer.CurrentCultureIgnoreCase) - .ToArray(); + // resolve update keys + var updateKeys = new HashSet<string>(search.UpdateKeys ?? new string[0], StringComparer.InvariantCultureIgnoreCase); + ModDataRecord record = this.ModDatabase.Get(search.ID); + if (record?.Fields != null) + { + string defaultUpdateKey = record.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; + if (!string.IsNullOrWhiteSpace(defaultUpdateKey)) + updateKeys.Add(defaultUpdateKey); + } - // fetch mod info - IDictionary<string, ModInfoModel> result = new Dictionary<string, ModInfoModel>(StringComparer.CurrentCultureIgnoreCase); - foreach (string modKey in modKeys) + // get latest versions + ModEntryModel result = new ModEntryModel { ID = search.ID }; + IList<string> errors = new List<string>(); + foreach (string updateKey in updateKeys) { - // parse mod key - if (!this.TryParseModKey(modKey, out string vendorKey, out string modID)) + // fetch data + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey); + if (data.Error != null) { - result[modKey] = new ModInfoModel("The mod key isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + errors.Add(data.Error); continue; } - // get matching repository - if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository)) + // handle main version + if (data.Version != null) { - result[modKey] = new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); - continue; + if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version)) + { + errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); + continue; + } + + if (this.IsNewer(version, result.Main?.Version)) + result.Main = new ModEntryVersionModel(version, data.Url); } - // fetch mod info - result[modKey] = await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry => + // handle optional version + if (data.PreviewVersion != null) { - // fetch info - ModInfoModel info = await repository.GetModInfoAsync(modID); - - // validate - if (info.Error == null) + if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version)) { - if (info.Version == null) - info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: "Mod has no version number."); - if (!allowInvalidVersions && !Regex.IsMatch(info.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) - info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: $"Mod has invalid semantic version '{info.Version}'."); + errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); + continue; } - // cache & return - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(info.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); - return info; - }); + if (this.IsNewer(version, result.Optional?.Version)) + result.Optional = new ModEntryVersionModel(version, data.Url); + } } + // get unofficial version + WikiCompatibilityEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(result.ID.Trim(), StringComparer.InvariantCultureIgnoreCase)); + if (wikiEntry?.UnofficialVersion != null && this.IsNewer(wikiEntry.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.UnofficialVersion, result.Optional?.Version)) + result.Unofficial = new ModEntryVersionModel(wikiEntry.UnofficialVersion, this.WikiCompatibilityPageUrl); + + // fallback to preview if latest is invalid + if (result.Main == null && result.Optional != null) + { + result.Main = result.Optional; + result.Optional = null; + } + + // special cases + if (result.ID == "Pathoschild.SMAPI") + { + if (result.Main != null) + result.Main.Url = "https://smapi.io/"; + if (result.Optional != null) + result.Optional.Url = "https://smapi.io/"; + } + + // add extended metadata + if (includeExtendedMetadata && (wikiEntry != null || record != null)) + result.Metadata = new ModExtendedMetadataModel(wikiEntry, record); + + // add result + result.Errors = errors.ToArray(); return result; } - - /********* - ** Private methods - *********/ /// <summary>Parse a namespaced mod ID.</summary> /// <param name="raw">The raw mod ID to parse.</param> /// <param name="vendorKey">The parsed vendor key.</param> @@ -158,5 +222,91 @@ namespace StardewModdingAPI.Web.Controllers modID = parts[1].Trim(); return true; } + + /// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary> + /// <param name="current">The current version.</param> + /// <param name="other">The other version.</param> + private bool IsNewer(ISemanticVersion current, ISemanticVersion other) + { + return current != null && (other == null || other.IsOlderThan(current)); + } + + /// <summary>Get the mods for which the API should return data.</summary> + /// <param name="model">The search model.</param> + /// <param name="apiVersion">The requested API version.</param> + private IEnumerable<ModSearchEntryModel> GetSearchMods(ModSearchModel model, ISemanticVersion apiVersion) + { + if (model == null) + yield break; + + // yield standard entries + if (model.Mods != null) + { + foreach (ModSearchEntryModel mod in model.Mods) + yield return mod; + } + + // yield mod update keys if backwards compatible + if (model.ModKeys != null && model.ModKeys.Any() && !apiVersion.IsNewerThan("2.6-beta.17")) + { + foreach (string updateKey in model.ModKeys.Distinct()) + yield return new ModSearchEntryModel(updateKey, new[] { updateKey }); + } + } + + /// <summary>Get mod data from the wiki compatibility list.</summary> + private async Task<WikiCompatibilityEntry[]> GetWikiDataAsync() + { + ModToolkit toolkit = new ModToolkit(); + return await this.Cache.GetOrCreateAsync($"_wiki", async entry => + { + try + { + WikiCompatibilityEntry[] entries = await toolkit.GetWikiCompatibilityListAsync(); + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes); + return entries; + } + catch + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes); + return new WikiCompatibilityEntry[0]; + } + }); + } + + /// <summary>Get the mod info for an update key.</summary> + /// <param name="updateKey">The namespaced update key.</param> + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey) + { + // parse update key + if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID)) + return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + + // get matching repository + if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository)) + return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + + // fetch mod info + return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry => + { + ModInfoModel result = await repository.GetModInfoAsync(modID); + if (result.Error != null) + { + if (result.Version == null) + result.Error = $"The update key '{updateKey}' matches a mod with no version number."; + else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) + result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."; + } + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); + return result; + }); + } + + /// <summary>Get the requested API version.</summary> + private ISemanticVersion GetApiVersion() + { + string actualVersion = (string)this.RouteData.Values["version"]; + return new SemanticVersion(actualVersion); + } } } |