using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.Wiki;
using StardewModdingAPI.Web.Framework.Clients;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.CurseForge;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.Clients.UpdateManifest;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.Controllers
{
/// Provides an API to perform mod update checks.
[Produces("application/json")]
[Route("api/v{version:semanticVersion}/mods")]
internal class ModsApiController : Controller
{
/*********
** Fields
*********/
/// The mod sites which provide mod metadata.
private readonly ModSiteManager ModSites;
/// The cache in which to store wiki data.
private readonly IWikiCacheRepository WikiCache;
/// The cache in which to store mod data.
private readonly IModCacheRepository ModCache;
/// The config settings for mod update checks.
private readonly IOptions Config;
/// The internal mod metadata list.
private readonly ModDatabase ModDatabase;
/*********
** Public methods
*********/
/// Construct an instance.
/// The web hosting environment.
/// The cache in which to store wiki data.
/// The cache in which to store mod metadata.
/// The config settings for mod update checks.
/// The Chucklefish API client.
/// The CurseForge API client.
/// The GitHub API client.
/// The ModDrop API client.
/// The Nexus API client.
/// The API client for arbitrary update manifest URLs.
public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions 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, updateManifest });
}
/// Fetch version metadata for the given mods.
/// The mod search criteria.
/// The requested API version.
[HttpPost]
public async Task> PostAsync([FromBody] ModSearchModel? model, [FromRoute] string version)
{
if (model?.Mods == null)
return Array.Empty();
ModUpdateCheckConfig config = this.Config.Value;
// fetch wiki data
WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.Data).ToArray();
IDictionary mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase);
foreach (ModSearchEntryModel mod in model.Mods)
{
if (string.IsNullOrWhiteSpace(mod.ID))
continue;
// special case: if this is an update check for the official SMAPI repo, check the Nexus mod page for beta versions
if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys.Any(key => key == config.SmapiInfo.DefaultUpdateKey) && mod.InstalledVersion?.IsPrerelease() == true)
mod.AddUpdateKeys(config.SmapiInfo.AddBetaUpdateKeys);
// fetch result
ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion);
if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null))
{
result.Errors = result.Errors
.Concat(new[] { $"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage." })
.ToArray();
}
mods[mod.ID] = result;
}
// return data
return mods.Values;
}
/*********
** Private methods
*********/
/// Get the metadata for a mod.
/// The mod data to match.
/// The wiki data.
/// Whether to include extended metadata for each mod.
/// The SMAPI version installed by the player.
/// Returns the mod data if found, else null.
private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion)
{
// cross-reference data
ModDataRecord? record = this.ModDatabase.Get(search.ID);
WikiModEntry? wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase));
UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray();
ModOverrideConfig? overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID.Trim(), StringComparison.OrdinalIgnoreCase));
bool allowNonStandardVersions = overrides?.AllowNonStandardVersions ?? false;
// SMAPI versions with a '-beta' tag indicate major changes that may need beta mod versions.
// This doesn't apply to normal prerelease versions which have an '-alpha' tag.
bool isSmapiBeta = apiVersion != null && apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta");
// get latest versions
ModEntryModel result = new(search.ID);
IList errors = new List();
ModEntryVersionModel? main = null;
ModEntryVersionModel? optional = null;
ModEntryVersionModel? unofficial = null;
ModEntryVersionModel? unofficialForBeta = null;
foreach (UpdateKey updateKey in updateKeys)
{
// validate update key
if (!updateKey.LooksValid)
{
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;
}
// fetch data
ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions);
if (data.Status != RemoteModStatus.Ok)
{
errors.Add(data.Error ?? data.Status.ToString());
continue;
}
// 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, curMainUrl ?? data.Url!);
if (this.IsNewer(curPreview, optional?.Version))
optional = new ModEntryVersionModel(curPreview, curPreviewUrl ?? data.Url!);
}
// get unofficial version
if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, optional?.Version))
unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}");
// get unofficial version for beta
if (wikiEntry is { HasBetaInfo: true })
{
if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial)
{
if (wikiEntry.BetaCompatibility.UnofficialVersion != null)
{
unofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, optional?.Version))
? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}")
: null;
}
else
unofficialForBeta = unofficial;
}
}
// fallback to preview if latest is invalid
if (main == null && optional != null)
{
main = optional;
optional = null;
}
// special cases
if (overrides?.SetUrl != null)
{
if (main != null)
main = new(main.Version, overrides.SetUrl);
if (optional != null)
optional = new(optional.Version, overrides.SetUrl);
}
// get recommended update (if any)
ISemanticVersion? installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions);
if (apiVersion != null && installedVersion != null)
{
// get newer versions
List updates = new List();
if (this.IsRecommendedUpdate(installedVersion, main?.Version, useBetaChannel: true))
updates.Add(main);
if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: isSmapiBeta || installedVersion.IsPrerelease() || search.IsBroken))
updates.Add(optional);
if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: true))
updates.Add(unofficial);
if (this.IsRecommendedUpdate(installedVersion, unofficialForBeta?.Version, useBetaChannel: apiVersion.IsPrerelease()))
updates.Add(unofficialForBeta);
// get newest version
ModEntryVersionModel? newest = null;
foreach (ModEntryVersionModel update in updates)
{
if (newest == null || update.Version.IsNewerThan(newest.Version))
newest = update;
}
// set field
result.SuggestedUpdate = newest != null
? new ModEntryVersionModel(newest.Version, newest.Url)
: null;
}
// add extended metadata
if (includeExtendedMetadata)
result.Metadata = new ModExtendedMetadataModel(wikiEntry, record, main: main, optional: optional, unofficial: unofficial, unofficialForBeta: unofficialForBeta);
// add result
result.Errors = errors.ToArray();
return result;
}
/// Get whether a given version should be offered to the user as an update.
/// The current semantic version.
/// The target semantic version.
/// Whether the user enabled the beta channel and should be offered prerelease updates.
private bool IsRecommendedUpdate(ISemanticVersion currentVersion, [NotNullWhen(true)] ISemanticVersion? newVersion, bool useBetaChannel)
{
return
newVersion != null
&& newVersion.IsNewerThan(currentVersion)
&& (useBetaChannel || !newVersion.IsPrerelease());
}
/// Get whether a version is newer than an version.
/// The current version.
/// The other version.
private bool IsNewer([NotNullWhen(true)] ISemanticVersion? current, ISemanticVersion? other)
{
return current != null && (other == null || other.IsOlderThan(current));
}
/// Get the mod info for an update key.
/// The namespaced update key.
/// Whether to allow non-standard versions.
/// The changes to apply to remote versions for update checks.
private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions)
{
if (!updateKey.LooksValid)
return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'.");
// get mod page
IModPage page;
{
bool isCached =
this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached? cachedMod)
&& !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes);
if (isCached)
page = cachedMod!.Data;
else
{
page = await this.ModSites.GetModPageAsync(updateKey);
this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page);
}
}
// get version info
return this.ModSites.GetPageVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions);
}
/// Get update keys based on the available mod metadata, while maintaining the precedence order.
/// The specified update keys.
/// The mod's entry in SMAPI's internal database.
/// The mod's entry in the wiki list.
private IEnumerable GetUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry)
{
// get unique update keys
List updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry)
.Select(UpdateKey.Parse)
.Distinct()
.ToList();
// apply overrides from wiki
if (entry?.Overrides?.ChangeUpdateKeys?.HasChanges == true)
{
List newKeys = updateKeys.Select(p => p.ToString()).ToList();
entry.Overrides.ChangeUpdateKeys.Apply(newKeys);
updateKeys = newKeys.Select(UpdateKey.Parse).ToList();
}
// if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority
{
var removeKeys = new HashSet();
foreach (UpdateKey key in updateKeys)
{
if (key.Subkey != null)
removeKeys.Add(new UpdateKey(key.Site, key.ID, null));
}
if (removeKeys.Any())
updateKeys.RemoveAll(removeKeys.Contains);
}
return updateKeys;
}
/// Get every available update key based on the available mod metadata, including duplicates and keys which should be filtered.
/// The specified update keys.
/// The mod's entry in SMAPI's internal database.
/// The mod's entry in the wiki list.
private IEnumerable GetUnfilteredUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry)
{
// specified update keys
foreach (string key in specifiedKeys ?? Array.Empty())
{
if (!string.IsNullOrWhiteSpace(key))
yield return key.Trim();
}
// default update key
{
string? defaultKey = record?.GetDefaultUpdateKey();
if (!string.IsNullOrWhiteSpace(defaultKey))
yield return defaultKey;
}
// wiki metadata
if (entry != null)
{
if (entry.NexusID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString());
if (entry.ModDropID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString());
if (entry.CurseForgeID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString());
if (entry.ChucklefishID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString());
}
}
}
}