summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Web')
-rw-r--r--src/SMAPI.Web/BackgroundService.cs7
-rw-r--r--src/SMAPI.Web/Controllers/JsonValidatorController.cs62
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs225
-rw-r--r--src/SMAPI.Web/Controllers/ModsController.cs9
-rw-r--r--src/SMAPI.Web/Framework/Caching/Cached.cs37
-rw-r--r--src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs107
-rw-r--r--src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs9
-rw-r--r--src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs81
-rw-r--r--src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs104
-rw-r--r--src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs40
-rw-r--r--src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs230
-rw-r--r--src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs11
-rw-r--r--src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs53
-rw-r--r--src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs73
-rw-r--r--src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs (renamed from src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs)18
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs38
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs18
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs73
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs23
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/GenericModDownload.cs36
-rw-r--r--src/SMAPI.Web/Framework/Clients/GenericModPage.cs79
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs56
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs2
-rw-r--r--src/SMAPI.Web/Framework/Clients/IModSiteClient.cs23
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs63
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs21
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs16
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs94
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs (renamed from src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs)11
-rw-r--r--src/SMAPI.Web/Framework/Compression/GzipHelper.cs2
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs25
-rw-r--r--src/SMAPI.Web/Framework/Extensions.cs28
-rw-r--r--src/SMAPI.Web/Framework/IModDownload.cs15
-rw-r--r--src/SMAPI.Web/Framework/IModPage.cs52
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs6
-rw-r--r--src/SMAPI.Web/Framework/ModInfoModel.cs (renamed from src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs)29
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs51
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs57
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs63
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs82
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs24
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs57
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs65
-rw-r--r--src/SMAPI.Web/Framework/ModSiteManager.cs194
-rw-r--r--src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs54
-rw-r--r--src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs58
-rw-r--r--src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs56
-rw-r--r--src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs47
-rw-r--r--src/SMAPI.Web/Framework/RemoteModStatus.cs (renamed from src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs)2
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs62
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs57
-rw-r--r--src/SMAPI.Web/Program.cs14
-rw-r--r--src/SMAPI.Web/SMAPI.Web.csproj24
-rw-r--r--src/SMAPI.Web/Startup.cs168
-rw-r--r--src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs7
-rw-r--r--src/SMAPI.Web/ViewModels/ModListModel.cs2
-rw-r--r--src/SMAPI.Web/ViewModels/ModModel.cs6
-rw-r--r--src/SMAPI.Web/Views/Index/Index.cshtml2
-rw-r--r--src/SMAPI.Web/Views/JsonValidator/Index.cshtml13
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml18
-rw-r--r--src/SMAPI.Web/Views/Mods/Index.cshtml34
-rw-r--r--src/SMAPI.Web/Views/Shared/_Layout.cshtml2
-rw-r--r--src/SMAPI.Web/appsettings.Development.json5
-rw-r--r--src/SMAPI.Web/appsettings.json5
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/mods.css4
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/mods.js2
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/content-patcher.json4
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/i18n.json24
72 files changed, 1359 insertions, 1728 deletions
diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs
index ee7a60f3..64bd5ca5 100644
--- a/src/SMAPI.Web/BackgroundService.cs
+++ b/src/SMAPI.Web/BackgroundService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Hangfire;
@@ -36,7 +37,9 @@ namespace StardewModdingAPI.Web
/// <summary>Construct an instance.</summary>
/// <param name="wikiCache">The cache in which to store wiki metadata.</param>
/// <param name="modCache">The cache in which to store mod data.</param>
- public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache)
+ /// <param name="hangfireStorage">The Hangfire storage implementation.</param>
+ [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")]
+ public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, JobStorage hangfireStorage)
{
BackgroundService.WikiCache = wikiCache;
BackgroundService.ModCache = modCache;
@@ -81,7 +84,7 @@ namespace StardewModdingAPI.Web
public static async Task UpdateWikiAsync()
{
WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync();
- BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods, out _, out _);
+ BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods);
}
/// <summary>Remove mods which haven't been requested in over 48 hours.</summary>
diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
index 2ade3e3d..5f83eafd 100644
--- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs
+++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
@@ -27,12 +27,13 @@ namespace StardewModdingAPI.Web.Controllers
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
{
["none"] = "None",
- ["manifest"] = "Manifest",
+ ["manifest"] = "SMAPI: manifest",
+ ["i18n"] = "SMAPI: translations (i18n)",
["content-patcher"] = "Content Patcher"
};
/// <summary>The schema ID to use if none was specified.</summary>
- private string DefaultSchemaID = "manifest";
+ private string DefaultSchemaID = "none";
/// <summary>A token in an error message which indicates that the child errors should be displayed instead.</summary>
private readonly string TransparentToken = "$transparent";
@@ -57,16 +58,22 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Render the schema validator UI.</summary>
/// <param name="schemaName">The schema name with which to validate the JSON, or 'edit' to return to the edit screen.</param>
/// <param name="id">The stored file ID.</param>
+ /// <param name="operation">The operation to perform for the selected log ID. This can be 'edit', or any other value to view.</param>
[HttpGet]
[Route("json")]
[Route("json/{schemaName}")]
[Route("json/{schemaName}/{id}")]
- public async Task<ViewResult> Index(string schemaName = null, string id = null)
+ [Route("json/{schemaName}/{id}/{operation}")]
+ public async Task<ViewResult> Index(string schemaName = null, string id = null, string operation = null)
{
+ // parse arguments
schemaName = this.NormalizeSchemaName(schemaName);
+ bool hasId = !string.IsNullOrWhiteSpace(id);
+ bool isEditView = !hasId || operation?.Trim().ToLower() == "edit";
- var result = new JsonValidatorModel(id, schemaName, this.SchemaFormats);
- if (string.IsNullOrWhiteSpace(id))
+ // build result model
+ var result = this.GetModel(id, schemaName, isEditView);
+ if (!hasId)
return this.View("Index", result);
// fetch raw JSON
@@ -76,7 +83,7 @@ namespace StardewModdingAPI.Web.Controllers
result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning);
// skip parsing if we're going to the edit screen
- if (schemaName?.ToLower() == "edit")
+ if (isEditView)
return this.View("Index", result);
// parse JSON
@@ -130,7 +137,7 @@ namespace StardewModdingAPI.Web.Controllers
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
{
if (request == null)
- return this.View("Index", this.GetModel(null, null).SetUploadError("The request seems to be invalid."));
+ return this.View("Index", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid."));
// normalize schema name
string schemaName = this.NormalizeSchemaName(request.SchemaName);
@@ -138,12 +145,12 @@ namespace StardewModdingAPI.Web.Controllers
// get raw text
string input = request.Content;
if (string.IsNullOrWhiteSpace(input))
- return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty."));
+ return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty."));
// upload file
UploadResult result = await this.Storage.SaveAsync(input);
if (!result.Succeeded)
- return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError));
+ return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null).SetUploadError(result.UploadError));
// redirect to view
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
@@ -156,9 +163,10 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Build a JSON validator model.</summary>
/// <param name="pasteID">The stored file ID.</param>
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
- private JsonValidatorModel GetModel(string pasteID, string schemaName)
+ /// <param name="isEditView">Whether to show the edit view.</param>
+ private JsonValidatorModel GetModel(string pasteID, string schemaName, bool isEditView)
{
- return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats);
+ return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView);
}
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
@@ -275,21 +283,20 @@ namespace StardewModdingAPI.Web.Controllers
errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase);
// match error by type and message
- foreach (var pair in errors)
+ foreach ((string target, string errorMessage) in errors)
{
- if (!pair.Key.Contains(":"))
+ if (!target.Contains(":"))
continue;
- string[] parts = pair.Key.Split(':', 2);
+ string[] parts = target.Split(':', 2);
if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.InvariantCultureIgnoreCase) && Regex.IsMatch(error.Message, parts[1]))
- return pair.Value?.Trim();
+ return errorMessage?.Trim();
}
// match by type
- if (errors.TryGetValue(error.ErrorType.ToString(), out string message))
- return message?.Trim();
-
- return null;
+ return errors.TryGetValue(error.ErrorType.ToString(), out string message)
+ ? message?.Trim()
+ : null;
}
return GetRawOverrideError()
@@ -304,10 +311,10 @@ namespace StardewModdingAPI.Web.Controllers
{
if (schema.ExtensionData != null)
{
- foreach (var pair in schema.ExtensionData)
+ foreach ((string curKey, JToken value) in schema.ExtensionData)
{
- if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase))
- return pair.Value.ToObject<T>();
+ if (curKey.Equals(key, StringComparison.InvariantCultureIgnoreCase))
+ return value.ToObject<T>();
}
}
@@ -318,14 +325,11 @@ namespace StardewModdingAPI.Web.Controllers
/// <param name="value">The value to format.</param>
private string FormatValue(object value)
{
- switch (value)
+ return value switch
{
- case List<string> list:
- return string.Join(", ", list);
-
- default:
- return value?.ToString() ?? "null";
- }
+ List<string> list => string.Join(", ", list),
+ _ => value?.ToString() ?? "null"
+ };
}
}
}
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 06768f03..db669bf9 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -12,15 +12,16 @@ 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.ConfigModels;
-using StardewModdingAPI.Web.Framework.ModRepositories;
namespace StardewModdingAPI.Web.Controllers
{
@@ -32,8 +33,8 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Fields
*********/
- /// <summary>The mod repositories which provide mod metadata.</summary>
- private readonly IDictionary<ModRepositoryKey, IModRepository> Repositories;
+ /// <summary>The mod sites which provide mod metadata.</summary>
+ private readonly ModSiteManager ModSites;
/// <summary>The cache in which to store wiki data.</summary>
private readonly IWikiCacheRepository WikiCache;
@@ -61,23 +62,14 @@ 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(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus)
+ public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus)
{
this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json"));
this.WikiCache = wikiCache;
this.ModCache = modCache;
this.Config = config;
- this.Repositories =
- new IModRepository[]
- {
- new ChucklefishRepository(chucklefish),
- new CurseForgeRepository(curseForge),
- new GitHubRepository(github),
- new ModDropRepository(modDrop),
- new NexusRepository(nexus)
- }
- .ToDictionary(p => p.VendorKey);
+ this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus });
}
/// <summary>Fetch version metadata for the given mods.</summary>
@@ -90,7 +82,7 @@ namespace StardewModdingAPI.Web.Controllers
return new ModEntryModel[0];
// fetch wiki data
- WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray();
+ WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.Data).ToArray();
IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase);
foreach (ModSearchEntryModel mod in model.Mods)
{
@@ -143,45 +135,23 @@ namespace StardewModdingAPI.Web.Controllers
// 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'.");
+ 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);
- if (data.Error != null)
+ ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.MapRemoteVersions);
+ if (data.Status != RemoteModStatus.Ok)
{
- errors.Add(data.Error);
+ errors.Add(data.Error ?? data.Status.ToString());
continue;
}
- // handle main version
- if (data.Version != null)
- {
- ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions, allowNonStandardVersions);
- if (version == null)
- {
- errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'.");
- continue;
- }
-
- if (this.IsNewer(version, main?.Version))
- main = new ModEntryVersionModel(version, data.Url);
- }
-
- // handle optional version
- if (data.PreviewVersion != null)
- {
- ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions, allowNonStandardVersions);
- if (version == null)
- {
- errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'.");
- continue;
- }
-
- if (this.IsNewer(version, optional?.Version))
- optional = new ModEntryVersionModel(version, data.Url);
- }
+ // handle versions
+ if (this.IsNewer(data.Version, main?.Version))
+ main = new ModEntryVersionModel(data.Version, data.Url);
+ if (this.IsNewer(data.PreviewVersion, optional?.Version))
+ optional = new ModEntryVersionModel(data.PreviewVersion, data.Url);
}
// get unofficial version
@@ -221,7 +191,7 @@ namespace StardewModdingAPI.Web.Controllers
}
// get recommended update (if any)
- ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions);
+ ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions);
if (apiVersion != null && installedVersion != null)
{
// get newer versions
@@ -281,29 +251,27 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Get the mod info for an update key.</summary>
/// <param name="updateKey">The namespaced update key.</param>
/// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param>
- private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions)
+ /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param>
+ private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions)
{
- // get mod
- if (!this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes))
+ // get mod page
+ IModPage page;
{
- // get site
- if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository))
- return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
+ bool isCached =
+ this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached<IModPage> cachedMod)
+ && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes);
- // fetch mod
- ModInfoModel result = await repository.GetModInfoAsync(updateKey.ID);
- if (result.Error == null)
+ if (isCached)
+ page = cachedMod.Data;
+ else
{
- if (result.Version == null)
- result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number.");
- else if (!SemanticVersion.TryParse(result.Version, allowNonStandardVersions, out _))
- result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'.");
+ page = await this.ModSites.GetModPageAsync(updateKey);
+ this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page);
}
-
- // cache mod
- this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, result, out mod);
}
- return mod.GetModel();
+
+ // get version info
+ return this.ModSites.GetPageVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions);
}
/// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary>
@@ -312,90 +280,79 @@ namespace StardewModdingAPI.Web.Controllers
/// <param name="entry">The mod's entry in the wiki list.</param>
private IEnumerable<UpdateKey> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry)
{
- IEnumerable<string> GetRaw()
- {
- // specified update keys
- if (specifiedKeys != null)
- {
- foreach (string key in specifiedKeys)
- yield return key?.Trim();
- }
+ // get unique update keys
+ List<UpdateKey> updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry)
+ .Select(UpdateKey.Parse)
+ .Distinct()
+ .ToList();
- // default update key
- string defaultKey = record?.GetDefaultUpdateKey();
- if (defaultKey != null)
- yield return defaultKey;
-
- // wiki metadata
- if (entry != null)
- {
- if (entry.NexusID.HasValue)
- yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}";
- if (entry.ModDropID.HasValue)
- yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}";
- if (entry.CurseForgeID.HasValue)
- yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}";
- if (entry.ChucklefishID.HasValue)
- yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}";
- }
+ // apply remove overrides from wiki
+ {
+ var removeKeys = new HashSet<UpdateKey>(
+ from key in entry?.ChangeUpdateKeys ?? new string[0]
+ where key.StartsWith('-')
+ select UpdateKey.Parse(key.Substring(1))
+ );
+ if (removeKeys.Any())
+ updateKeys.RemoveAll(removeKeys.Contains);
}
- HashSet<UpdateKey> seen = new HashSet<UpdateKey>();
- foreach (string rawKey in GetRaw())
+ // 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
{
- if (string.IsNullOrWhiteSpace(rawKey))
- continue;
-
- UpdateKey key = UpdateKey.Parse(rawKey);
- if (seen.Add(key))
- yield return key;
+ var removeKeys = new HashSet<UpdateKey>();
+ foreach (var key in updateKeys)
+ {
+ if (key.Subkey != null)
+ removeKeys.Add(new UpdateKey(key.Site, key.ID, null));
+ }
+ if (removeKeys.Any())
+ updateKeys.RemoveAll(removeKeys.Contains);
}
- }
- /// <summary>Get a semantic local version for update checks.</summary>
- /// <param name="version">The version to parse.</param>
- /// <param name="map">A map of version replacements.</param>
- /// <param name="allowNonStandard">Whether to allow non-standard versions.</param>
- private ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard)
- {
- // try mapped version
- string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard);
- if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew))
- return parsedNew;
-
- // return original version
- return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld)
- ? parsedOld
- : null;
+ return updateKeys;
}
- /// <summary>Get a semantic local version for update checks.</summary>
- /// <param name="version">The version to map.</param>
- /// <param name="map">A map of version replacements.</param>
- /// <param name="allowNonStandard">Whether to allow non-standard versions.</param>
- private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard)
+ /// <summary>Get every available update key based on the available mod metadata, including duplicates and keys which should be filtered.</summary>
+ /// <param name="specifiedKeys">The specified update keys.</param>
+ /// <param name="record">The mod's entry in SMAPI's internal database.</param>
+ /// <param name="entry">The mod's entry in the wiki list.</param>
+ private IEnumerable<string> GetUnfilteredUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry)
{
- if (version == null || map == null || !map.Any())
- return version;
-
- // match exact raw version
- if (map.ContainsKey(version))
- return map[version];
+ // specified update keys
+ foreach (string key in specifiedKeys ?? Array.Empty<string>())
+ {
+ if (!string.IsNullOrWhiteSpace(key))
+ yield return key.Trim();
+ }
- // match parsed version
- if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed))
+ // default update key
{
- if (map.ContainsKey(parsed.ToString()))
- return map[parsed.ToString()];
+ string defaultKey = record?.GetDefaultUpdateKey();
+ if (!string.IsNullOrWhiteSpace(defaultKey))
+ yield return defaultKey;
+ }
- foreach (var pair in map)
- {
- if (SemanticVersion.TryParse(pair.Key, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, allowNonStandard, out ISemanticVersion newVersion))
- return newVersion.ToString();
- }
+ // 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());
}
- return version;
+ // overrides from wiki
+ foreach (string key in entry?.ChangeUpdateKeys ?? Array.Empty<string>())
+ {
+ if (key.StartsWith('+'))
+ yield return key.Substring(1);
+ else if (!key.StartsWith("-"))
+ yield return key;
+ }
}
}
}
diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs
index b621ded0..24e36709 100644
--- a/src/SMAPI.Web/Controllers/ModsController.cs
+++ b/src/SMAPI.Web/Controllers/ModsController.cs
@@ -2,6 +2,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
+using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.Wiki;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.ViewModels;
@@ -51,16 +52,16 @@ namespace StardewModdingAPI.Web.Controllers
public ModListModel FetchData()
{
// fetch cached data
- if (!this.Cache.TryGetWikiMetadata(out CachedWikiMetadata metadata))
+ if (!this.Cache.TryGetWikiMetadata(out Cached<WikiMetadata> metadata))
return new ModListModel();
// build model
return new ModListModel(
- stableVersion: metadata.StableVersion,
- betaVersion: metadata.BetaVersion,
+ stableVersion: metadata.Data.StableVersion,
+ betaVersion: metadata.Data.BetaVersion,
mods: this.Cache
.GetWikiMods()
- .Select(mod => new ModModel(mod.GetModel()))
+ .Select(mod => new ModModel(mod.Data))
.OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting
lastUpdated: metadata.LastUpdated,
isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes)
diff --git a/src/SMAPI.Web/Framework/Caching/Cached.cs b/src/SMAPI.Web/Framework/Caching/Cached.cs
new file mode 100644
index 00000000..52041a16
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Caching/Cached.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace StardewModdingAPI.Web.Framework.Caching
+{
+ /// <summary>A cache entry.</summary>
+ /// <typeparam name="T">The cached value type.</typeparam>
+ internal class Cached<T>
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The cached data.</summary>
+ public T Data { get; set; }
+
+ /// <summary>When the data was last updated.</summary>
+ public DateTimeOffset LastUpdated { get; set; }
+
+ /// <summary>When the data was last requested through the mod API.</summary>
+ public DateTimeOffset LastRequested { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public Cached() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="data">The cached data.</param>
+ public Cached(T data)
+ {
+ this.Data = data;
+ this.LastUpdated = DateTimeOffset.UtcNow;
+ this.LastRequested = DateTimeOffset.UtcNow;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs
deleted file mode 100644
index 96eca847..00000000
--- a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs
+++ /dev/null
@@ -1,107 +0,0 @@
-using System;
-using System.Diagnostics.CodeAnalysis;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.ModRepositories;
-
-namespace StardewModdingAPI.Web.Framework.Caching.Mods
-{
- /// <summary>The model for cached mod data.</summary>
- internal class CachedMod
- {
- /*********
- ** Accessors
- *********/
- /****
- ** Tracking
- ****/
- /// <summary>The internal MongoDB ID.</summary>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")]
- [BsonIgnoreIfDefault]
- public ObjectId _id { get; set; }
-
- /// <summary>When the data was last updated.</summary>
- public DateTimeOffset LastUpdated { get; set; }
-
- /// <summary>When the data was last requested through the web API.</summary>
- public DateTimeOffset LastRequested { get; set; }
-
- /****
- ** Metadata
- ****/
- /// <summary>The mod site on which the mod is found.</summary>
- public ModRepositoryKey Site { get; set; }
-
- /// <summary>The mod's unique ID within the <see cref="Site"/>.</summary>
- public string ID { get; set; }
-
- /// <summary>The mod availability status on the remote site.</summary>
- public RemoteModStatus FetchStatus { get; set; }
-
- /// <summary>The error message providing more info for the <see cref="FetchStatus"/>, if applicable.</summary>
- public string FetchError { get; set; }
-
-
- /****
- ** Mod info
- ****/
- /// <summary>The mod's display name.</summary>
- public string Name { get; set; }
-
- /// <summary>The mod's latest version.</summary>
- public string MainVersion { get; set; }
-
- /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="MainVersion"/>.</summary>
- public string PreviewVersion { get; set; }
-
- /// <summary>The URL for the mod page.</summary>
- public string Url { get; set; }
-
- /// <summary>The license URL, if available.</summary>
- public string LicenseUrl { get; set; }
-
- /// <summary>The license name, if available.</summary>
- public string LicenseName { get; set; }
-
-
- /*********
- ** Accessors
- *********/
- /// <summary>Construct an instance.</summary>
- public CachedMod() { }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="site">The mod site on which the mod is found.</param>
- /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param>
- /// <param name="mod">The mod data.</param>
- public CachedMod(ModRepositoryKey site, string id, ModInfoModel mod)
- {
- // tracking
- this.LastUpdated = DateTimeOffset.UtcNow;
- this.LastRequested = DateTimeOffset.UtcNow;
-
- // metadata
- this.Site = site;
- this.ID = id;
- this.FetchStatus = mod.Status;
- this.FetchError = mod.Error;
-
- // mod info
- this.Name = mod.Name;
- this.MainVersion = mod.Version;
- this.PreviewVersion = mod.PreviewVersion;
- this.Url = mod.Url;
- this.LicenseUrl = mod.LicenseUrl;
- this.LicenseName = mod.LicenseName;
- }
-
- /// <summary>Get the API model for the cached data.</summary>
- public ModInfoModel GetModel()
- {
- return new ModInfoModel(name: this.Name, version: this.MainVersion, url: this.Url, previewVersion: this.PreviewVersion)
- .SetLicense(this.LicenseUrl, this.LicenseName)
- .SetError(this.FetchStatus, this.FetchError);
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs
index bcec8b36..0d912c7b 100644
--- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs
+++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs
@@ -1,10 +1,10 @@
using System;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.ModRepositories;
+using StardewModdingAPI.Web.Framework.Clients;
namespace StardewModdingAPI.Web.Framework.Caching.Mods
{
- /// <summary>Encapsulates logic for accessing the mod data cache.</summary>
+ /// <summary>Manages cached mod data.</summary>
internal interface IModCacheRepository : ICacheRepository
{
/*********
@@ -15,14 +15,13 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods
/// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param>
/// <param name="mod">The fetched mod.</param>
/// <param name="markRequested">Whether to update the mod's 'last requested' date.</param>
- bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true);
+ bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true);
/// <summary>Save data fetched for a mod.</summary>
/// <param name="site">The mod site on which the mod is found.</param>
/// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param>
/// <param name="mod">The mod data.</param>
- /// <param name="cachedMod">The stored mod record.</param>
- void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod);
+ void SaveMod(ModSiteKey site, string id, IModPage mod);
/// <summary>Delete data for mods which haven't been requested within a given time limit.</summary>
/// <param name="age">The minimum age for which to remove mods.</param>
diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs
new file mode 100644
index 00000000..6b0ec1ec
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+using StardewModdingAPI.Web.Framework.Clients;
+
+namespace StardewModdingAPI.Web.Framework.Caching.Mods
+{
+ /// <summary>Manages cached mod data in-memory.</summary>
+ internal class ModCacheMemoryRepository : BaseCacheRepository, IModCacheRepository
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The cached mod data indexed by <c>{site key}:{ID}</c>.</summary>
+ private readonly IDictionary<string, Cached<IModPage>> Mods = new Dictionary<string, Cached<IModPage>>(StringComparer.InvariantCultureIgnoreCase);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get the cached mod data.</summary>
+ /// <param name="site">The mod site to search.</param>
+ /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param>
+ /// <param name="mod">The fetched mod.</param>
+ /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param>
+ public bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true)
+ {
+ // get mod
+ if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod))
+ {
+ mod = null;
+ return false;
+ }
+
+ // bump 'last requested'
+ if (markRequested)
+ cachedMod.LastRequested = DateTimeOffset.UtcNow;
+
+ mod = cachedMod;
+ return true;
+ }
+
+ /// <summary>Save data fetched for a mod.</summary>
+ /// <param name="site">The mod site on which the mod is found.</param>
+ /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param>
+ /// <param name="mod">The mod data.</param>
+ public void SaveMod(ModSiteKey site, string id, IModPage mod)
+ {
+ string key = this.GetKey(site, id);
+ this.Mods[key] = new Cached<IModPage>(mod);
+ }
+
+ /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary>
+ /// <param name="age">The minimum age for which to remove mods.</param>
+ public void RemoveStaleMods(TimeSpan age)
+ {
+ DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age);
+
+ string[] staleKeys = this.Mods
+ .Where(p => p.Value.LastRequested < minDate)
+ .Select(p => p.Key)
+ .ToArray();
+
+ foreach (string key in staleKeys)
+ this.Mods.Remove(key);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get a cache key.</summary>
+ /// <param name="site">The mod site.</param>
+ /// <param name="id">The mod ID.</param>
+ private string GetKey(ModSiteKey site, string id)
+ {
+ return $"{site}:{id.Trim()}".ToLower();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs
deleted file mode 100644
index 2e7804a7..00000000
--- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using System;
-using MongoDB.Driver;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.ModRepositories;
-
-namespace StardewModdingAPI.Web.Framework.Caching.Mods
-{
- /// <summary>Encapsulates logic for accessing the mod data cache.</summary>
- internal class ModCacheRepository : BaseCacheRepository, IModCacheRepository
- {
- /*********
- ** Fields
- *********/
- /// <summary>The collection for cached mod data.</summary>
- private readonly IMongoCollection<CachedMod> Mods;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="database">The authenticated MongoDB database.</param>
- public ModCacheRepository(IMongoDatabase database)
- {
- // get collections
- this.Mods = database.GetCollection<CachedMod>("mods");
-
- // add indexes if needed
- this.Mods.Indexes.CreateOne(new CreateIndexModel<CachedMod>(Builders<CachedMod>.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site)));
- }
-
- /*********
- ** Public methods
- *********/
- /// <summary>Get the cached mod data.</summary>
- /// <param name="site">The mod site to search.</param>
- /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param>
- /// <param name="mod">The fetched mod.</param>
- /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param>
- public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true)
- {
- // get mod
- id = this.NormalizeId(id);
- mod = this.Mods.Find(entry => entry.ID == id && entry.Site == site).FirstOrDefault();
- if (mod == null)
- return false;
-
- // bump 'last requested'
- if (markRequested)
- {
- mod.LastRequested = DateTimeOffset.UtcNow;
- mod = this.SaveMod(mod);
- }
-
- return true;
- }
-
- /// <summary>Save data fetched for a mod.</summary>
- /// <param name="site">The mod site on which the mod is found.</param>
- /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param>
- /// <param name="mod">The mod data.</param>
- /// <param name="cachedMod">The stored mod record.</param>
- public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod)
- {
- id = this.NormalizeId(id);
-
- cachedMod = this.SaveMod(new CachedMod(site, id, mod));
- }
-
- /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary>
- /// <param name="age">The minimum age for which to remove mods.</param>
- public void RemoveStaleMods(TimeSpan age)
- {
- DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age);
- var result = this.Mods.DeleteMany(p => p.LastRequested < minDate);
- }
-
-
- /*********
- ** Private methods
- *********/
- /// <summary>Save data fetched for a mod.</summary>
- /// <param name="mod">The mod data.</param>
- public CachedMod SaveMod(CachedMod mod)
- {
- string id = this.NormalizeId(mod.ID);
-
- this.Mods.ReplaceOne(
- entry => entry.ID == id && entry.Site == mod.Site,
- mod,
- new UpdateOptions { IsUpsert = true }
- );
-
- return mod;
- }
-
- /// <summary>Normalize a mod ID for case-insensitive search.</summary>
- /// <param name="id">The mod ID.</param>
- public string NormalizeId(string id)
- {
- return id.Trim().ToLower();
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs b/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs
deleted file mode 100644
index 6a103e37..00000000
--- a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization;
-using MongoDB.Bson.Serialization.Serializers;
-
-namespace StardewModdingAPI.Web.Framework.Caching
-{
- /// <summary>Serializes <see cref="DateTimeOffset"/> to a UTC date field instead of the default array.</summary>
- public class UtcDateTimeOffsetSerializer : StructSerializerBase<DateTimeOffset>
- {
- /*********
- ** Fields
- *********/
- /// <summary>The underlying date serializer.</summary>
- private static readonly DateTimeSerializer DateTimeSerializer = new DateTimeSerializer(DateTimeKind.Utc, BsonType.DateTime);
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Deserializes a value.</summary>
- /// <param name="context">The deserialization context.</param>
- /// <param name="args">The deserialization args.</param>
- /// <returns>A deserialized value.</returns>
- public override DateTimeOffset Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
- {
- DateTime date = UtcDateTimeOffsetSerializer.DateTimeSerializer.Deserialize(context, args);
- return new DateTimeOffset(date, TimeSpan.Zero);
- }
-
- /// <summary>Serializes a value.</summary>
- /// <param name="context">The serialization context.</param>
- /// <param name="args">The serialization args.</param>
- /// <param name="value">The object.</param>
- public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, DateTimeOffset value)
- {
- UtcDateTimeOffsetSerializer.DateTimeSerializer.Serialize(context, args, value.UtcDateTime);
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs
deleted file mode 100644
index 7e7c99bc..00000000
--- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs
+++ /dev/null
@@ -1,230 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-using MongoDB.Bson.Serialization.Options;
-using StardewModdingAPI.Toolkit;
-using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
-
-namespace StardewModdingAPI.Web.Framework.Caching.Wiki
-{
- /// <summary>The model for cached wiki mods.</summary>
- internal class CachedWikiMod
- {
- /*********
- ** Accessors
- *********/
- /****
- ** Tracking
- ****/
- /// <summary>The internal MongoDB ID.</summary>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")]
- public ObjectId _id { get; set; }
-
- /// <summary>When the data was last updated.</summary>
- public DateTimeOffset LastUpdated { get; set; }
-
- /****
- ** Mod info
- ****/
- /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary>
- public string[] ID { get; set; }
-
- /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary>
- public string[] Name { get; set; }
-
- /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary>
- public string[] Author { get; set; }
-
- /// <summary>The mod ID on Nexus.</summary>
- public int? NexusID { get; set; }
-
- /// <summary>The mod ID in the Chucklefish mod repo.</summary>
- public int? ChucklefishID { get; set; }
-
- /// <summary>The mod ID in the CurseForge mod repo.</summary>
- public int? CurseForgeID { get; set; }
-
- /// <summary>The mod key in the CurseForge mod repo (used in mod page URLs).</summary>
- public string CurseForgeKey { get; set; }
-
- /// <summary>The mod ID in the ModDrop mod repo.</summary>
- public int? ModDropID { get; set; }
-
- /// <summary>The GitHub repository in the form 'owner/repo'.</summary>
- public string GitHubRepo { get; set; }
-
- /// <summary>The URL to a non-GitHub source repo.</summary>
- public string CustomSourceUrl { get; set; }
-
- /// <summary>The custom mod page URL (if applicable).</summary>
- public string CustomUrl { get; set; }
-
- /// <summary>The name of the mod which loads this content pack, if applicable.</summary>
- public string ContentPackFor { get; set; }
-
- /// <summary>The human-readable warnings for players about this mod.</summary>
- public string[] Warnings { get; set; }
-
- /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary>
- public string PullRequestUrl { get; set; }
-
- /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
- public string DevNote { get; set; }
-
- /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary>
- public string Anchor { get; set; }
-
- /****
- ** Stable compatibility
- ****/
- /// <summary>The compatibility status.</summary>
- public WikiCompatibilityStatus MainStatus { get; set; }
-
- /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary>
- public string MainSummary { get; set; }
-
- /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary>
- public string MainBrokeIn { get; set; }
-
- /// <summary>The version of the latest unofficial update, if applicable.</summary>
- public string MainUnofficialVersion { get; set; }
-
- /// <summary>The URL to the latest unofficial update, if applicable.</summary>
- public string MainUnofficialUrl { get; set; }
-
- /****
- ** Beta compatibility
- ****/
- /// <summary>The compatibility status.</summary>
- public WikiCompatibilityStatus? BetaStatus { get; set; }
-
- /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary>
- public string BetaSummary { get; set; }
-
- /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary>
- public string BetaBrokeIn { get; set; }
-
- /// <summary>The version of the latest unofficial update, if applicable.</summary>
- public string BetaUnofficialVersion { get; set; }
-
- /// <summary>The URL to the latest unofficial update, if applicable.</summary>
- public string BetaUnofficialUrl { get; set; }
-
- /****
- ** Version maps
- ****/
- /// <summary>Maps local versions to a semantic version for update checks.</summary>
- [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)]
- public IDictionary<string, string> MapLocalVersions { get; set; }
-
- /// <summary>Maps remote versions to a semantic version for update checks.</summary>
- [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)]
- public IDictionary<string, string> MapRemoteVersions { get; set; }
-
-
- /*********
- ** Accessors
- *********/
- /// <summary>Construct an instance.</summary>
- public CachedWikiMod() { }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="mod">The mod data.</param>
- public CachedWikiMod(WikiModEntry mod)
- {
- // tracking
- this.LastUpdated = DateTimeOffset.UtcNow;
-
- // mod info
- this.ID = mod.ID;
- this.Name = mod.Name;
- this.Author = mod.Author;
- this.NexusID = mod.NexusID;
- this.ChucklefishID = mod.ChucklefishID;
- this.CurseForgeID = mod.CurseForgeID;
- this.CurseForgeKey = mod.CurseForgeKey;
- this.ModDropID = mod.ModDropID;
- this.GitHubRepo = mod.GitHubRepo;
- this.CustomSourceUrl = mod.CustomSourceUrl;
- this.CustomUrl = mod.CustomUrl;
- this.ContentPackFor = mod.ContentPackFor;
- this.PullRequestUrl = mod.PullRequestUrl;
- this.Warnings = mod.Warnings;
- this.DevNote = mod.DevNote;
- this.Anchor = mod.Anchor;
-
- // stable compatibility
- this.MainStatus = mod.Compatibility.Status;
- this.MainSummary = mod.Compatibility.Summary;
- this.MainBrokeIn = mod.Compatibility.BrokeIn;
- this.MainUnofficialVersion = mod.Compatibility.UnofficialVersion?.ToString();
- this.MainUnofficialUrl = mod.Compatibility.UnofficialUrl;
-
- // beta compatibility
- this.BetaStatus = mod.BetaCompatibility?.Status;
- this.BetaSummary = mod.BetaCompatibility?.Summary;
- this.BetaBrokeIn = mod.BetaCompatibility?.BrokeIn;
- this.BetaUnofficialVersion = mod.BetaCompatibility?.UnofficialVersion?.ToString();
- this.BetaUnofficialUrl = mod.BetaCompatibility?.UnofficialUrl;
-
- // version maps
- this.MapLocalVersions = mod.MapLocalVersions;
- this.MapRemoteVersions = mod.MapRemoteVersions;
- }
-
- /// <summary>Reconstruct the original model.</summary>
- public WikiModEntry GetModel()
- {
- var mod = new WikiModEntry
- {
- ID = this.ID,
- Name = this.Name,
- Author = this.Author,
- NexusID = this.NexusID,
- ChucklefishID = this.ChucklefishID,
- CurseForgeID = this.CurseForgeID,
- CurseForgeKey = this.CurseForgeKey,
- ModDropID = this.ModDropID,
- GitHubRepo = this.GitHubRepo,
- CustomSourceUrl = this.CustomSourceUrl,
- CustomUrl = this.CustomUrl,
- ContentPackFor = this.ContentPackFor,
- Warnings = this.Warnings,
- PullRequestUrl = this.PullRequestUrl,
- DevNote = this.DevNote,
- Anchor = this.Anchor,
-
- // stable compatibility
- Compatibility = new WikiCompatibilityInfo
- {
- Status = this.MainStatus,
- Summary = this.MainSummary,
- BrokeIn = this.MainBrokeIn,
- UnofficialVersion = this.MainUnofficialVersion != null ? new SemanticVersion(this.MainUnofficialVersion) : null,
- UnofficialUrl = this.MainUnofficialUrl
- },
-
- // version maps
- MapLocalVersions = this.MapLocalVersions,
- MapRemoteVersions = this.MapRemoteVersions
- };
-
- // beta compatibility
- if (this.BetaStatus != null)
- {
- mod.BetaCompatibility = new WikiCompatibilityInfo
- {
- Status = this.BetaStatus.Value,
- Summary = this.BetaSummary,
- BrokeIn = this.BetaBrokeIn,
- UnofficialVersion = this.BetaUnofficialVersion != null ? new SemanticVersion(this.BetaUnofficialVersion) : null,
- UnofficialUrl = this.BetaUnofficialUrl
- };
- }
-
- return mod;
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs
index b54c8a2f..2ab7ea5a 100644
--- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs
+++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs
@@ -1,11 +1,10 @@
using System;
using System.Collections.Generic;
-using System.Linq.Expressions;
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
namespace StardewModdingAPI.Web.Framework.Caching.Wiki
{
- /// <summary>Encapsulates logic for accessing the wiki data cache.</summary>
+ /// <summary>Manages cached wiki data.</summary>
internal interface IWikiCacheRepository : ICacheRepository
{
/*********
@@ -13,18 +12,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
*********/
/// <summary>Get the cached wiki metadata.</summary>
/// <param name="metadata">The fetched metadata.</param>
- bool TryGetWikiMetadata(out CachedWikiMetadata metadata);
+ bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata);
/// <summary>Get the cached wiki mods.</summary>
/// <param name="filter">A filter to apply, if any.</param>
- IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null);
+ IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null);
/// <summary>Save data fetched from the wiki compatibility list.</summary>
/// <param name="stableVersion">The current stable Stardew Valley version.</param>
/// <param name="betaVersion">The current beta Stardew Valley version.</param>
/// <param name="mods">The mod data.</param>
- /// <param name="cachedMetadata">The stored metadata record.</param>
- /// <param name="cachedMods">The stored mod records.</param>
- void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods);
+ void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods);
}
}
diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs
new file mode 100644
index 00000000..064a7c3c
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
+
+namespace StardewModdingAPI.Web.Framework.Caching.Wiki
+{
+ /// <summary>Manages cached wiki data in-memory.</summary>
+ internal class WikiCacheMemoryRepository : BaseCacheRepository, IWikiCacheRepository
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The saved wiki metadata.</summary>
+ private Cached<WikiMetadata> Metadata;
+
+ /// <summary>The cached wiki data.</summary>
+ private Cached<WikiModEntry>[] Mods = new Cached<WikiModEntry>[0];
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get the cached wiki metadata.</summary>
+ /// <param name="metadata">The fetched metadata.</param>
+ public bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata)
+ {
+ metadata = this.Metadata;
+ return metadata != null;
+ }
+
+ /// <summary>Get the cached wiki mods.</summary>
+ /// <param name="filter">A filter to apply, if any.</param>
+ public IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null)
+ {
+ foreach (var mod in this.Mods)
+ {
+ if (filter == null || filter(mod.Data))
+ yield return mod;
+ }
+ }
+
+ /// <summary>Save data fetched from the wiki compatibility list.</summary>
+ /// <param name="stableVersion">The current stable Stardew Valley version.</param>
+ /// <param name="betaVersion">The current beta Stardew Valley version.</param>
+ /// <param name="mods">The mod data.</param>
+ public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods)
+ {
+ this.Metadata = new Cached<WikiMetadata>(new WikiMetadata(stableVersion, betaVersion));
+ this.Mods = mods.Select(mod => new Cached<WikiModEntry>(mod)).ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs
deleted file mode 100644
index 1ae9d38f..00000000
--- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Linq.Expressions;
-using MongoDB.Driver;
-using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
-
-namespace StardewModdingAPI.Web.Framework.Caching.Wiki
-{
- /// <summary>Encapsulates logic for accessing the wiki data cache.</summary>
- internal class WikiCacheRepository : BaseCacheRepository, IWikiCacheRepository
- {
- /*********
- ** Fields
- *********/
- /// <summary>The collection for wiki metadata.</summary>
- private readonly IMongoCollection<CachedWikiMetadata> WikiMetadata;
-
- /// <summary>The collection for wiki mod data.</summary>
- private readonly IMongoCollection<CachedWikiMod> WikiMods;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="database">The authenticated MongoDB database.</param>
- public WikiCacheRepository(IMongoDatabase database)
- {
- // get collections
- this.WikiMetadata = database.GetCollection<CachedWikiMetadata>("wiki-metadata");
- this.WikiMods = database.GetCollection<CachedWikiMod>("wiki-mods");
-
- // add indexes if needed
- this.WikiMods.Indexes.CreateOne(new CreateIndexModel<CachedWikiMod>(Builders<CachedWikiMod>.IndexKeys.Ascending(p => p.ID)));
- }
-
- /// <summary>Get the cached wiki metadata.</summary>
- /// <param name="metadata">The fetched metadata.</param>
- public bool TryGetWikiMetadata(out CachedWikiMetadata metadata)
- {
- metadata = this.WikiMetadata.Find("{}").FirstOrDefault();
- return metadata != null;
- }
-
- /// <summary>Get the cached wiki mods.</summary>
- /// <param name="filter">A filter to apply, if any.</param>
- public IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null)
- {
- return filter != null
- ? this.WikiMods.Find(filter).ToList()
- : this.WikiMods.Find("{}").ToList();
- }
-
- /// <summary>Save data fetched from the wiki compatibility list.</summary>
- /// <param name="stableVersion">The current stable Stardew Valley version.</param>
- /// <param name="betaVersion">The current beta Stardew Valley version.</param>
- /// <param name="mods">The mod data.</param>
- /// <param name="cachedMetadata">The stored metadata record.</param>
- /// <param name="cachedMods">The stored mod records.</param>
- public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods)
- {
- cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion);
- cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray();
-
- this.WikiMods.DeleteMany("{}");
- this.WikiMods.InsertMany(cachedMods);
-
- this.WikiMetadata.DeleteMany("{}");
- this.WikiMetadata.InsertOne(cachedMetadata);
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs
index 6a560eb4..c04de4a5 100644
--- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs
+++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs
@@ -1,22 +1,11 @@
-using System;
-using System.Diagnostics.CodeAnalysis;
-using MongoDB.Bson;
-
namespace StardewModdingAPI.Web.Framework.Caching.Wiki
{
/// <summary>The model for cached wiki metadata.</summary>
- internal class CachedWikiMetadata
+ internal class WikiMetadata
{
/*********
** Accessors
*********/
- /// <summary>The internal MongoDB ID.</summary>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")]
- public ObjectId _id { get; set; }
-
- /// <summary>When the data was last updated.</summary>
- public DateTimeOffset LastUpdated { get; set; }
-
/// <summary>The current stable Stardew Valley version.</summary>
public string StableVersion { get; set; }
@@ -28,16 +17,15 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- public CachedWikiMetadata() { }
+ public WikiMetadata() { }
/// <summary>Construct an instance.</summary>
/// <param name="stableVersion">The current stable Stardew Valley version.</param>
/// <param name="betaVersion">The current beta Stardew Valley version.</param>
- public CachedWikiMetadata(string stableVersion, string betaVersion)
+ public WikiMetadata(string stableVersion, string betaVersion)
{
this.StableVersion = stableVersion;
this.BetaVersion = betaVersion;
- this.LastUpdated = DateTimeOffset.UtcNow;
}
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
index cdb281e2..ca156da4 100644
--- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
@@ -3,6 +3,7 @@ using System.Net;
using System.Threading.Tasks;
using HtmlAgilityPack;
using Pathoschild.Http.Client;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
{
@@ -20,6 +21,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
/*********
+ ** Accessors
+ *********/
+ /// <summary>The unique key for the mod site.</summary>
+ public ModSiteKey SiteKey => ModSiteKey.Chucklefish;
+
+
+ /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
@@ -32,42 +40,40 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
}
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The Chucklefish mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- public async Task<ChucklefishMod> GetModAsync(uint id)
+ /// <summary>Get update check info about a mod.</summary>
+ /// <param name="id">The mod ID.</param>
+ public async Task<IModPage> GetModData(string id)
{
+ IModPage page = new GenericModPage(this.SiteKey, id);
+
+ // get mod ID
+ if (!uint.TryParse(id, out uint parsedId))
+ return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID.");
+
// fetch HTML
string html;
try
{
html = await this.Client
- .GetAsync(string.Format(this.ModPageUrlFormat, id))
+ .GetAsync(string.Format(this.ModPageUrlFormat, parsedId))
.AsString();
}
catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden)
{
- return null;
+ return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID.");
}
-
- // parse HTML
var doc = new HtmlDocument();
doc.LoadHtml(html);
// extract mod info
- string url = this.GetModUrl(id);
+ string url = this.GetModUrl(parsedId);
string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value;
if (name.StartsWith("[SMAPI] "))
name = name.Substring("[SMAPI] ".Length);
string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText;
- // create model
- return new ChucklefishMod
- {
- Name = name,
- Version = version,
- Url = url
- };
+ // return info
+ return page.SetInfo(name: name, version: version, url: url, downloads: Array.Empty<IModDownload>());
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs
deleted file mode 100644
index fd0101d4..00000000
--- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
-{
- /// <summary>Mod metadata from the Chucklefish mod site.</summary>
- internal class ChucklefishMod
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The mod name.</summary>
- public string Name { get; set; }
-
- /// <summary>The mod's semantic version number.</summary>
- public string Version { get; set; }
-
- /// <summary>The mod's web URL.</summary>
- public string Url { get; set; }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs
index 1d8b256e..836d43f7 100644
--- a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs
@@ -1,17 +1,7 @@
using System;
-using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
{
/// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary>
- internal interface IChucklefishClient : IDisposable
- {
- /*********
- ** Methods
- *********/
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The Chucklefish mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- Task<ChucklefishMod> GetModAsync(uint id);
- }
+ internal interface IChucklefishClient : IModSiteClient, IDisposable { }
}
diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs
index 140b854e..d8008721 100644
--- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs
@@ -1,8 +1,8 @@
-using System.Linq;
+using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Pathoschild.Http.Client;
-using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels;
namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
@@ -21,6 +21,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
/*********
+ ** Accessors
+ *********/
+ /// <summary>The unique key for the mod site.</summary>
+ public ModSiteKey SiteKey => ModSiteKey.CurseForge;
+
+
+ /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
@@ -31,60 +38,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent);
}
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The CurseForge mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- public async Task<CurseForgeMod> GetModAsync(long id)
+ /// <summary>Get update check info about a mod.</summary>
+ /// <param name="id">The mod ID.</param>
+ public async Task<IModPage> GetModData(string id)
{
+ IModPage page = new GenericModPage(this.SiteKey, id);
+
+ // get ID
+ if (!uint.TryParse(id, out uint parsedId))
+ return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID.");
+
// get raw data
ModModel mod = await this.Client
- .GetAsync($"addon/{id}")
+ .GetAsync($"addon/{parsedId}")
.As<ModModel>();
if (mod == null)
- return null;
+ return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID.");
- // get latest versions
- string invalidVersion = null;
- ISemanticVersion latest = null;
+ // get downloads
+ List<IModDownload> downloads = new List<IModDownload>();
foreach (ModFileModel file in mod.LatestFiles)
{
- // extract version
- ISemanticVersion version;
- {
- string raw = this.GetRawVersion(file);
- if (raw == null)
- continue;
-
- if (!SemanticVersion.TryParse(raw, out version))
- {
- if (invalidVersion == null)
- invalidVersion = raw;
- continue;
- }
- }
-
- // track latest version
- if (latest == null || version.IsNewerThan(latest))
- latest = version;
- }
-
- // get error
- string error = null;
- if (latest == null && invalidVersion == null)
- {
- error = mod.LatestFiles.Any()
- ? $"CurseForge mod {id} has no downloads which specify the version in a recognised format."
- : $"CurseForge mod {id} has no downloads.";
+ downloads.Add(
+ new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file))
+ );
}
- // generate result
- return new CurseForgeMod
- {
- Name = mod.Name,
- LatestVersion = latest?.ToString() ?? invalidVersion,
- Url = mod.WebsiteUrl,
- Error = error
- };
+ // return info
+ return page.SetInfo(name: mod.Name, version: null, url: mod.WebsiteUrl, downloads: downloads);
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs
deleted file mode 100644
index e5bb8cf1..00000000
--- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using Newtonsoft.Json;
-
-namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
-{
- /// <summary>Mod metadata from the CurseForge API.</summary>
- internal class CurseForgeMod
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The mod name.</summary>
- public string Name { get; set; }
-
- /// <summary>The latest file version.</summary>
- public string LatestVersion { get; set; }
-
- /// <summary>The mod's web URL.</summary>
- public string Url { get; set; }
-
- /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary>
- public string Error { get; set; }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs
index 907b4087..2018c230 100644
--- a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs
@@ -1,17 +1,7 @@
using System;
-using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
{
/// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary>
- internal interface ICurseForgeClient : IDisposable
- {
- /*********
- ** Methods
- *********/
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The CurseForge mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- Task<CurseForgeMod> GetModAsync(long id);
- }
+ internal interface ICurseForgeClient : IModSiteClient, IDisposable { }
}
diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs
new file mode 100644
index 00000000..f08b471c
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs
@@ -0,0 +1,36 @@
+namespace StardewModdingAPI.Web.Framework.Clients
+{
+ /// <summary>Generic metadata about a file download on a mod page.</summary>
+ internal class GenericModDownload : IModDownload
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The download's display name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The download's description.</summary>
+ public string Description { get; set; }
+
+ /// <summary>The download's file version.</summary>
+ public string Version { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public GenericModDownload() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <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)
+ {
+ this.Name = name;
+ this.Description = description;
+ this.Version = version;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
new file mode 100644
index 00000000..622e6c56
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
@@ -0,0 +1,79 @@
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+
+namespace StardewModdingAPI.Web.Framework.Clients
+{
+ /// <summary>Generic metadata about a mod page.</summary>
+ internal class GenericModPage : IModPage
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod site containing the mod.</summary>
+ public ModSiteKey Site { get; set; }
+
+ /// <summary>The mod's unique ID within the site.</summary>
+ public string Id { get; set; }
+
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The mod's semantic version number.</summary>
+ public string Version { get; set; }
+
+ /// <summary>The mod's web URL.</summary>
+ public string Url { get; set; }
+
+ /// <summary>The mod downloads.</summary>
+ public IModDownload[] Downloads { get; set; } = new IModDownload[0];
+
+ /// <summary>The mod availability status on the remote site.</summary>
+ public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok;
+
+ /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary>
+ public string Error { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public GenericModPage() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="site">The mod site containing the mod.</param>
+ /// <param name="id">The mod's unique ID within the site.</param>
+ public GenericModPage(ModSiteKey site, string id)
+ {
+ this.Site = site;
+ this.Id = id;
+ }
+
+ /// <summary>Set the fetched mod info.</summary>
+ /// <param name="name">The mod name.</param>
+ /// <param name="version">The mod's semantic version number.</param>
+ /// <param name="url">The mod's web URL.</param>
+ /// <param name="downloads">The mod downloads.</param>
+ public IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads)
+ {
+ this.Name = name;
+ this.Version = version;
+ this.Url = url;
+ this.Downloads = downloads.ToArray();
+
+ return this;
+ }
+
+ /// <summary>Set a mod fetch error.</summary>
+ /// <param name="status">The mod availability status on the remote site.</param>
+ /// <param name="error">A user-friendly error which indicates why fetching the mod info failed (if applicable).</param>
+ public IModPage SetError(RemoteModStatus status, string error)
+ {
+ this.Status = status;
+ this.Error = error;
+
+ return this;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
index 84c20957..2f1eb854 100644
--- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
@@ -3,6 +3,7 @@ using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Pathoschild.Http.Client;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
namespace StardewModdingAPI.Web.Framework.Clients.GitHub
{
@@ -17,6 +18,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub
/*********
+ ** Accessors
+ *********/
+ /// <summary>The unique key for the mod site.</summary>
+ public ModSiteKey SiteKey => ModSiteKey.GitHub;
+
+
+ /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
@@ -79,6 +87,54 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub
}
}
+ /// <summary>Get update check info about a mod.</summary>
+ /// <param name="id">The mod ID.</param>
+ public async Task<IModPage> GetModData(string id)
+ {
+ IModPage page = new GenericModPage(this.SiteKey, id);
+
+ if (!id.Contains("/") || id.IndexOf("/", StringComparison.OrdinalIgnoreCase) != id.LastIndexOf("/", StringComparison.OrdinalIgnoreCase))
+ return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'.");
+
+ // fetch repo info
+ GitRepo repository = await this.GetRepositoryAsync(id);
+ if (repository == null)
+ return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID.");
+ string name = repository.FullName;
+ string url = $"{repository.WebUrl}/releases";
+
+ // get releases
+ GitRelease latest;
+ GitRelease preview;
+ {
+ // get latest release (whether preview or stable)
+ latest = await this.GetLatestReleaseAsync(id, includePrerelease: true);
+ if (latest == null)
+ return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID.");
+
+ // get stable version if different
+ preview = null;
+ if (latest.IsPrerelease)
+ {
+ GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false);
+ if (release != null)
+ {
+ preview = latest;
+ latest = release;
+ }
+ }
+ }
+
+ // get downloads
+ IModDownload[] downloads = new[] { latest, preview }
+ .Where(release => release != null)
+ .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag))
+ .ToArray();
+
+ // return info
+ return page.SetInfo(name: name, url: url, version: null, downloads: downloads);
+ }
+
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
public void Dispose()
{
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs
index a34f03bd..0d6f4643 100644
--- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs
@@ -4,7 +4,7 @@ using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.GitHub
{
/// <summary>An HTTP client for fetching metadata from GitHub.</summary>
- internal interface IGitHubClient : IDisposable
+ internal interface IGitHubClient : IModSiteClient, IDisposable
{
/*********
** Methods
diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs
new file mode 100644
index 00000000..33277711
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs
@@ -0,0 +1,23 @@
+using System.Threading.Tasks;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+
+namespace StardewModdingAPI.Web.Framework.Clients
+{
+ /// <summary>A client for fetching update check info from a mod site.</summary>
+ internal interface IModSiteClient
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique key for the mod site.</summary>
+ public ModSiteKey SiteKey { get; }
+
+
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Get update check info about a mod.</summary>
+ /// <param name="id">The mod ID.</param>
+ Task<IModPage> GetModData(string id);
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs
index 3ede46e2..468b72b1 100644
--- a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs
@@ -1,17 +1,7 @@
using System;
-using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
{
/// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary>
- internal interface IModDropClient : IDisposable
- {
- /*********
- ** Methods
- *********/
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The ModDrop mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- Task<ModDropMod> GetModAsync(long id);
- }
+ internal interface IModDropClient : IDisposable, IModSiteClient { }
}
diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs
index 5ad2d2f8..3a1c5b9d 100644
--- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs
@@ -1,6 +1,7 @@
+using System.Collections.Generic;
using System.Threading.Tasks;
using Pathoschild.Http.Client;
-using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels;
namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
@@ -19,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
/*********
+ ** Accessors
+ *********/
+ /// <summary>The unique key for the mod site.</summary>
+ public ModSiteKey SiteKey => ModSiteKey.ModDrop;
+
+
+ /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
@@ -31,60 +39,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
this.ModUrlFormat = modUrlFormat;
}
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The ModDrop mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- public async Task<ModDropMod> GetModAsync(long id)
+ /// <summary>Get update check info about a mod.</summary>
+ /// <param name="id">The mod ID.</param>
+ public async Task<IModPage> GetModData(string id)
{
+ var page = new GenericModPage(this.SiteKey, id);
+
+ if (!long.TryParse(id, out long parsedId))
+ return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID.");
+
// get raw data
ModListModel response = await this.Client
.PostAsync("")
.WithBody(new
{
- ModIDs = new[] { id },
+ ModIDs = new[] { parsedId },
Files = true,
Mods = true
})
.As<ModListModel>();
- ModModel mod = response.Mods[id];
+ ModModel mod = response.Mods[parsedId];
if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue)
return null;
- // get latest versions
- ISemanticVersion latest = null;
- ISemanticVersion optional = null;
+ // get files
+ var downloads = new List<IModDownload>();
foreach (FileDataModel file in mod.Files)
{
if (file.IsOld || file.IsDeleted || file.IsHidden)
continue;
- if (!SemanticVersion.TryParse(file.Version, out ISemanticVersion version))
- continue;
-
- if (file.IsDefault)
- {
- if (latest == null || version.IsNewerThan(latest))
- latest = version;
- }
- else if (optional == null || version.IsNewerThan(optional))
- optional = version;
+ downloads.Add(
+ new GenericModDownload(file.Name, file.Description, file.Version)
+ );
}
- if (latest == null)
- {
- latest = optional;
- optional = null;
- }
- if (optional != null && latest.IsNewerThan(optional))
- optional = null;
- // generate result
- return new ModDropMod
- {
- Name = mod.Mod?.Title,
- LatestDefaultVersion = latest,
- LatestOptionalVersion = optional,
- Url = string.Format(this.ModUrlFormat, id)
- };
+ // return info
+ string name = mod.Mod?.Title;
+ string url = string.Format(this.ModUrlFormat, id);
+ return page.SetInfo(name: name, version: null, url: url, downloads: downloads);
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs
deleted file mode 100644
index def79106..00000000
--- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
-{
- /// <summary>Mod metadata from the ModDrop API.</summary>
- internal class ModDropMod
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The mod name.</summary>
- public string Name { get; set; }
-
- /// <summary>The latest default file version.</summary>
- public ISemanticVersion LatestDefaultVersion { get; set; }
-
- /// <summary>The latest optional file version.</summary>
- public ISemanticVersion LatestOptionalVersion { get; set; }
-
- /// <summary>The mod's web URL.</summary>
- public string Url { get; set; }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs
index fa84b287..b01196f4 100644
--- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs
+++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs
@@ -1,8 +1,21 @@
+using Newtonsoft.Json;
+
namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels
{
/// <summary>Metadata from the ModDrop API about a mod file.</summary>
public class FileDataModel
{
+ /// <summary>The file title.</summary>
+ [JsonProperty("title")]
+ public string Name { get; set; }
+
+ /// <summary>The file description.</summary>
+ [JsonProperty("desc")]
+ public string Description { get; set; }
+
+ /// <summary>The file version.</summary>
+ public string Version { get; set; }
+
/// <summary>Whether the file is deleted.</summary>
public bool IsDeleted { get; set; }
@@ -14,8 +27,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels
/// <summary>Whether this is an archived file.</summary>
public bool IsOld { get; set; }
-
- /// <summary>The file version.</summary>
- public string Version { get; set; }
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs
index e56e7af4..a44b8c66 100644
--- a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs
@@ -1,17 +1,7 @@
using System;
-using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{
/// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary>
- internal interface INexusClient : IDisposable
- {
- /*********
- ** Methods
- *********/
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The Nexus mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- Task<NexusMod> GetModAsync(uint id);
- }
+ internal interface INexusClient : IModSiteClient, IDisposable { }
}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
index 753d3b4f..ef3ef22e 100644
--- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
@@ -7,6 +7,8 @@ using HtmlAgilityPack;
using Pathoschild.FluentNexus.Models;
using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+using StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels;
using FluentNexusClient = Pathoschild.FluentNexus.NexusClient;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus
@@ -31,6 +33,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/*********
+ ** Accessors
+ *********/
+ /// <summary>The unique key for the mod site.</summary>
+ public ModSiteKey SiteKey => ModSiteKey.Nexus;
+
+
+ /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
@@ -48,20 +57,32 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion);
}
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The Nexus mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- public async Task<NexusMod> GetModAsync(uint id)
+ /// <summary>Get update check info about a mod.</summary>
+ /// <param name="id">The mod ID.</param>
+ public async Task<IModPage> GetModData(string id)
{
+ IModPage page = new GenericModPage(this.SiteKey, id);
+
+ if (!uint.TryParse(id, out uint parsedId))
+ return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID.");
+
// Fetch from the Nexus website when possible, since it has no rate limits. Mods with
// adult content are hidden for anonymous users, so fall back to the API in that case.
// Note that the API has very restrictive rate limits which means we can't just use it
// for all cases.
- NexusMod mod = await this.GetModFromWebsiteAsync(id);
+ NexusMod mod = await this.GetModFromWebsiteAsync(parsedId);
if (mod?.Status == NexusModStatus.AdultContentForbidden)
- mod = await this.GetModFromApiAsync(id);
+ mod = await this.GetModFromApiAsync(parsedId);
+
+ // page doesn't exist
+ if (mod == null || mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished)
+ return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID.");
- return mod;
+ // return info
+ page.SetInfo(name: mod.Name, url: mod.Url, version: mod.Version, downloads: mod.Downloads);
+ if (mod.Status != NexusModStatus.Ok)
+ page.SetError(RemoteModStatus.TemporaryError, mod.Error);
+ return page;
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
@@ -115,37 +136,28 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
// extract mod info
string url = this.GetModUrl(id);
- string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim();
+ string name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim();
string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim();
SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion);
- // extract file versions
- List<string> rawVersions = new List<string>();
+ // extract files
+ var downloads = new List<IModDownload>();
foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]"))
{
string sectionName = fileSection.Descendants("h2").First().InnerText;
if (sectionName != "Main files" && sectionName != "Optional files")
continue;
- rawVersions.AddRange(
- from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version"))
- from versionStat in statBox.Descendants().Where(p => p.HasClass("stat"))
- select versionStat.InnerText.Trim()
- );
- }
-
- // choose latest file version
- ISemanticVersion latestFileVersion = null;
- foreach (string rawVersion in rawVersions)
- {
- if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur))
- continue;
- if (parsedVersion != null && !cur.IsNewerThan(parsedVersion))
- continue;
- if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion))
- continue;
+ foreach (var container in fileSection.Descendants("dt"))
+ {
+ string fileName = container.GetDataAttribute("name").Value;
+ string fileVersion = container.GetDataAttribute("version").Value;
+ string description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next <dd> tag; derived from https://stackoverflow.com/a/25535623/262123
- latestFileVersion = cur;
+ downloads.Add(
+ new GenericModDownload(fileName, description, fileVersion)
+ );
+ }
}
// yield info
@@ -153,8 +165,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{
Name = name,
Version = parsedVersion?.ToString() ?? version,
- LatestFileVersion = latestFileVersion,
- Url = url
+ Url = url,
+ Downloads = downloads.ToArray()
};
}
@@ -167,29 +179,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id);
ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional);
- // get versions
- if (!SemanticVersion.TryParse(mod.Version, out ISemanticVersion mainVersion))
- mainVersion = null;
- ISemanticVersion latestFileVersion = null;
- foreach (string rawVersion in files.Files.Select(p => p.FileVersion))
- {
- if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur))
- continue;
- if (mainVersion != null && !cur.IsNewerThan(mainVersion))
- continue;
- if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion))
- continue;
-
- latestFileVersion = cur;
- }
-
// yield info
return new NexusMod
{
Name = mod.Name,
Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version,
- LatestFileVersion = latestFileVersion,
- Url = this.GetModUrl(id)
+ Url = this.GetModUrl(id),
+ Downloads = files.Files
+ .Select(file => (IModDownload)new GenericModDownload(file.Name, null, file.FileVersion))
+ .ToArray()
};
}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs
index 0f1b29d5..aef90ede 100644
--- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs
@@ -1,6 +1,6 @@
using Newtonsoft.Json;
-namespace StardewModdingAPI.Web.Framework.Clients.Nexus
+namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels
{
/// <summary>Mod metadata from Nexus Mods.</summary>
internal class NexusMod
@@ -14,9 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/// <summary>The mod's semantic version number.</summary>
public string Version { get; set; }
- /// <summary>The latest file version.</summary>
- public ISemanticVersion LatestFileVersion { get; set; }
-
/// <summary>The mod's web URL.</summary>
[JsonProperty("mod_page_uri")]
public string Url { get; set; }
@@ -25,7 +22,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
[JsonIgnore]
public NexusModStatus Status { get; set; } = NexusModStatus.Ok;
- /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary>
+ /// <summary>The files available to download.</summary>
+ [JsonIgnore]
+ public IModDownload[] Downloads { get; set; }
+
+ /// <summary>A custom user-friendly error which indicates why fetching the mod info failed (if applicable).</summary>
[JsonIgnore]
public string Error { get; set; }
}
diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs
index cc8f4737..676d660d 100644
--- a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs
+++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs
@@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Framework.Compression
return rawText;
// decompress
- using (MemoryStream memoryStream = new MemoryStream())
+ using MemoryStream memoryStream = new MemoryStream();
{
// read length prefix
int dataLength = BitConverter.ToInt32(zipBuffer, 0);
diff --git a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs
deleted file mode 100644
index c7b6cb00..00000000
--- a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-namespace StardewModdingAPI.Web.Framework.ConfigModels
-{
- /// <summary>The config settings for mod compatibility list.</summary>
- internal class MongoDbConfig
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The MongoDB connection string.</summary>
- public string ConnectionString { get; set; }
-
- /// <summary>The database name.</summary>
- public string Database { get; set; }
-
-
- /*********
- ** Public method
- *********/
- /// <summary>Get whether a MongoDB instance is configured.</summary>
- public bool IsConfigured()
- {
- return !string.IsNullOrWhiteSpace(this.ConnectionString);
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs
index e0da1424..3a246245 100644
--- a/src/SMAPI.Web/Framework/Extensions.cs
+++ b/src/SMAPI.Web/Framework/Extensions.cs
@@ -1,14 +1,24 @@
using System;
using JetBrains.Annotations;
+using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Razor;
+using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing;
+using Newtonsoft.Json;
namespace StardewModdingAPI.Web.Framework
{
/// <summary>Provides extensions on ASP.NET Core types.</summary>
public static class Extensions
{
+ /*********
+ ** Public methods
+ *********/
+ /****
+ ** View helpers
+ ****/
/// <summary>Get a URL with the absolute path for an action method. Unlike <see cref="IUrlHelper.Action"/>, only the specified <paramref name="values"/> are added to the URL without merging values from the current HTTP request.</summary>
/// <param name="helper">The URL helper to extend.</param>
/// <param name="action">The name of the action method.</param>
@@ -18,6 +28,7 @@ namespace StardewModdingAPI.Web.Framework
/// <returns>The generated URL.</returns>
public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false)
{
+ // get route values
RouteValueDictionary valuesDict = new RouteValueDictionary(values);
foreach (var value in helper.ActionContext.RouteData.Values)
{
@@ -25,14 +36,31 @@ namespace StardewModdingAPI.Web.Framework
valuesDict[value.Key] = null; // explicitly remove it from the URL
}
+ // get relative URL
string url = helper.Action(action, controller, valuesDict);
+ if (url == null && action.EndsWith("Async"))
+ url = helper.Action(action[..^"Async".Length], controller, valuesDict);
+
+ // get absolute URL
if (absoluteUrl)
{
HttpRequest request = helper.ActionContext.HttpContext.Request;
Uri baseUri = new Uri($"{request.Scheme}://{request.Host}");
url = new Uri(baseUri, url).ToString();
}
+
return url;
}
+
+ /// <summary>Get a serialized JSON representation of the value.</summary>
+ /// <param name="page">The page to extend.</param>
+ /// <param name="value">The value to serialize.</param>
+ /// <returns>The serialized JSON.</returns>
+ /// <remarks>This bypasses unnecessary validation (e.g. not allowing null values) in <see cref="IJsonHelper.Serialize"/>.</remarks>
+ public static IHtmlContent ForJson(this RazorPageBase page, object value)
+ {
+ string json = JsonConvert.SerializeObject(value);
+ return new HtmlString(json);
+ }
}
}
diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs
new file mode 100644
index 00000000..dc058bcb
--- /dev/null
+++ b/src/SMAPI.Web/Framework/IModDownload.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Web.Framework
+{
+ /// <summary>Generic metadata about a file download on a mod page.</summary>
+ internal interface IModDownload
+ {
+ /// <summary>The download's display name.</summary>
+ string Name { get; }
+
+ /// <summary>The download's description.</summary>
+ string Description { get; }
+
+ /// <summary>The download's file version.</summary>
+ string Version { get; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs
new file mode 100644
index 00000000..e66d401f
--- /dev/null
+++ b/src/SMAPI.Web/Framework/IModPage.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+
+namespace StardewModdingAPI.Web.Framework
+{
+ /// <summary>Generic metadata about a mod page.</summary>
+ internal interface IModPage
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod site containing the mod.</summary>
+ ModSiteKey Site { get; }
+
+ /// <summary>The mod's unique ID within the site.</summary>
+ string Id { get; }
+
+ /// <summary>The mod name.</summary>
+ string Name { get; }
+
+ /// <summary>The mod's semantic version number.</summary>
+ string Version { get; }
+
+ /// <summary>The mod's web URL.</summary>
+ string Url { get; }
+
+ /// <summary>The mod downloads.</summary>
+ IModDownload[] Downloads { get; }
+
+ /// <summary>The mod page status.</summary>
+ RemoteModStatus Status { get; }
+
+ /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary>
+ string Error { get; }
+
+
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Set the fetched mod info.</summary>
+ /// <param name="name">The mod name.</param>
+ /// <param name="version">The mod's semantic version number.</param>
+ /// <param name="url">The mod's web URL.</param>
+ /// <param name="downloads">The mod downloads.</param>
+ IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads);
+
+ /// <summary>Set a mod fetch error.</summary>
+ /// <param name="status">The mod availability status on the remote site.</param>
+ /// <param name="error">A user-friendly error which indicates why fetching the mod info failed (if applicable).</param>
+ IModPage SetError(RemoteModStatus status, string error);
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index cce80816..227dcd89 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching SMAPI's update line.</summary>
- private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex SmapiUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/*********
@@ -181,9 +181,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
message.Section = LogSection.ModUpdateList;
}
- else if (message.Level == LogLevel.Alert && this.SMAPIUpdatePattern.IsMatch(message.Text))
+ else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text))
{
- Match match = this.SMAPIUpdatePattern.Match(message.Text);
+ Match match = this.SmapiUpdatePattern.Match(message.Text);
string version = match.Groups["version"].Value;
string link = match.Groups["link"].Value;
smapiMod.UpdateVersion = version;
diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs
index 46b98860..7845b8c5 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs
+++ b/src/SMAPI.Web/Framework/ModInfoModel.cs
@@ -1,4 +1,6 @@
-namespace StardewModdingAPI.Web.Framework.ModRepositories
+using StardewModdingAPI.Web.Framework.Clients;
+
+namespace StardewModdingAPI.Web.Framework
{
/// <summary>Generic metadata about a mod.</summary>
internal class ModInfoModel
@@ -10,20 +12,14 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
public string Name { get; set; }
/// <summary>The mod's latest version.</summary>
- public string Version { get; set; }
+ public ISemanticVersion Version { get; set; }
/// <summary>The mod's latest optional or prerelease version, if newer than <see cref="Version"/>.</summary>
- public string PreviewVersion { get; set; }
+ public ISemanticVersion PreviewVersion { get; set; }
/// <summary>The mod's web URL.</summary>
public string Url { get; set; }
- /// <summary>The license URL, if available.</summary>
- public string LicenseUrl { get; set; }
-
- /// <summary>The license name, if available.</summary>
- public string LicenseName { get; set; }
-
/// <summary>The mod availability status on the remote site.</summary>
public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok;
@@ -42,7 +38,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
/// <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="url">The mod's web URL.</param>
- public ModInfoModel(string name, string version, string url, string previewVersion = null)
+ public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null)
{
this
.SetBasicInfo(name, url)
@@ -63,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
/// <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>
- public ModInfoModel SetVersions(string version, string previewVersion = null)
+ public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null)
{
this.Version = version;
this.PreviewVersion = previewVersion;
@@ -71,17 +67,6 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
return this;
}
- /// <summary>Set the license info, if available.</summary>
- /// <param name="url">The license URL.</param>
- /// <param name="name">The license name.</param>
- public ModInfoModel SetLicense(string url, string name)
- {
- this.LicenseUrl = url;
- this.LicenseName = name;
-
- return this;
- }
-
/// <summary>Set a mod error.</summary>
/// <param name="status">The mod availability status on the remote site.</param>
/// <param name="error">The error message indicating why the mod is invalid (if applicable).</param>
diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs
deleted file mode 100644
index f9f9f47d..00000000
--- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-
-namespace StardewModdingAPI.Web.Framework.ModRepositories
-{
- internal abstract class RepositoryBase : IModRepository
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The unique key for this vendor.</summary>
- public ModRepositoryKey VendorKey { get; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- public abstract void Dispose();
-
- /// <summary>Get metadata about a mod in the repository.</summary>
- /// <param name="id">The mod ID in this repository.</param>
- public abstract Task<ModInfoModel> GetModInfoAsync(string id);
-
-
- /*********
- ** Protected methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="vendorKey">The unique key for this vendor.</param>
- protected RepositoryBase(ModRepositoryKey vendorKey)
- {
- this.VendorKey = vendorKey;
- }
-
- /// <summary>Normalize a version string.</summary>
- /// <param name="version">The version to normalize.</param>
- protected string NormalizeVersion(string version)
- {
- if (string.IsNullOrWhiteSpace(version))
- return null;
-
- version = version.Trim();
- if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix
- version = version.Substring(1);
-
- return version;
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
deleted file mode 100644
index 0945735a..00000000
--- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
-
-namespace StardewModdingAPI.Web.Framework.ModRepositories
-{
- /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary>
- internal class ChucklefishRepository : RepositoryBase
- {
- /*********
- ** Fields
- *********/
- /// <summary>The underlying HTTP client.</summary>
- private readonly IChucklefishClient Client;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="client">The underlying HTTP client.</param>
- public ChucklefishRepository(IChucklefishClient client)
- : base(ModRepositoryKey.Chucklefish)
- {
- this.Client = client;
- }
-
- /// <summary>Get metadata about a mod in the repository.</summary>
- /// <param name="id">The mod ID in this repository.</param>
- public override async Task<ModInfoModel> GetModInfoAsync(string id)
- {
- // validate ID format
- if (!uint.TryParse(id, out uint realID))
- return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID.");
-
- // fetch info
- try
- {
- var mod = await this.Client.GetModAsync(realID);
- return mod != null
- ? new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), url: mod.Url)
- : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID.");
- }
- catch (Exception ex)
- {
- return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString());
- }
- }
-
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- public override void Dispose()
- {
- this.Client.Dispose();
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs
deleted file mode 100644
index 93ddc1eb..00000000
--- a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.Clients.CurseForge;
-
-namespace StardewModdingAPI.Web.Framework.ModRepositories
-{
- /// <summary>An HTTP client for fetching mod metadata from CurseForge.</summary>
- internal class CurseForgeRepository : RepositoryBase
- {
- /*********
- ** Fields
- *********/
- /// <summary>The underlying CurseForge API client.</summary>
- private readonly ICurseForgeClient Client;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="client">The underlying CurseForge API client.</param>
- public CurseForgeRepository(ICurseForgeClient client)
- : base(ModRepositoryKey.CurseForge)
- {
- this.Client = client;
- }
-
- /// <summary>Get metadata about a mod in the repository.</summary>
- /// <param name="id">The mod ID in this repository.</param>
- public override async Task<ModInfoModel> GetModInfoAsync(string id)
- {
- // validate ID format
- if (!uint.TryParse(id, out uint curseID))
- return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID.");
-
- // fetch info
- try
- {
- CurseForgeMod mod = await this.Client.GetModAsync(curseID);
- if (mod == null)
- return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID.");
- if (mod.Error != null)
- {
- RemoteModStatus remoteStatus = RemoteModStatus.InvalidData;
- return new ModInfoModel().SetError(remoteStatus, mod.Error);
- }
-
- return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.LatestVersion), url: mod.Url);
- }
- catch (Exception ex)
- {
- return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString());
- }
- }
-
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- public override void Dispose()
- {
- this.Client.Dispose();
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
deleted file mode 100644
index c62cb73f..00000000
--- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.Clients.GitHub;
-
-namespace StardewModdingAPI.Web.Framework.ModRepositories
-{
- /// <summary>An HTTP client for fetching mod metadata from GitHub project releases.</summary>
- internal class GitHubRepository : RepositoryBase
- {
- /*********
- ** Fields
- *********/
- /// <summary>The underlying GitHub API client.</summary>
- private readonly IGitHubClient Client;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="client">The underlying GitHub API client.</param>
- public GitHubRepository(IGitHubClient client)
- : base(ModRepositoryKey.GitHub)
- {
- this.Client = client;
- }
-
- /// <summary>Get metadata about a mod in the repository.</summary>
- /// <param name="id">The mod ID in this repository.</param>
- public override async Task<ModInfoModel> GetModInfoAsync(string id)
- {
- ModInfoModel result = new ModInfoModel().SetBasicInfo(id, $"https://github.com/{id}/releases");
-
- // validate ID format
- if (!id.Contains("/") || id.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != id.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase))
- return result.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'.");
-
- // fetch info
- try
- {
- // fetch repo info
- GitRepo repository = await this.Client.GetRepositoryAsync(id);
- if (repository == null)
- return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID.");
- result
- .SetBasicInfo(repository.FullName, $"{repository.WebUrl}/releases")
- .SetLicense(url: repository.License?.Url, name: repository.License?.SpdxId ?? repository.License?.Name);
-
- // get latest release (whether preview or stable)
- GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true);
- if (latest == null)
- return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID.");
-
- // split stable/prerelease if applicable
- GitRelease preview = null;
- if (latest.IsPrerelease)
- {
- GitRelease release = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false);
- if (release != null)
- {
- preview = latest;
- latest = release;
- }
- }
-
- // return data
- return result.SetVersions(version: this.NormalizeVersion(latest.Tag), previewVersion: this.NormalizeVersion(preview?.Tag));
- }
- catch (Exception ex)
- {
- return result.SetError(RemoteModStatus.TemporaryError, ex.ToString());
- }
- }
-
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- public override void Dispose()
- {
- this.Client.Dispose();
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs
deleted file mode 100644
index 68f754ae..00000000
--- a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-
-namespace StardewModdingAPI.Web.Framework.ModRepositories
-{
- /// <summary>A repository which provides mod metadata.</summary>
- internal interface IModRepository : IDisposable
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The unique key for this vendor.</summary>
- ModRepositoryKey VendorKey { get; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Get metadata about a mod in the repository.</summary>
- /// <param name="id">The mod ID in this repository.</param>
- Task<ModInfoModel> GetModInfoAsync(string id);
- }
-}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs
deleted file mode 100644
index 62142668..00000000
--- a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.Clients.ModDrop;
-
-namespace StardewModdingAPI.Web.Framework.ModRepositories
-{
- /// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary>
- internal class ModDropRepository : RepositoryBase
- {
- /*********
- ** Fields
- *********/
- /// <summary>The underlying ModDrop API client.</summary>
- private readonly IModDropClient Client;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="client">The underlying Nexus Mods API client.</param>
- public ModDropRepository(IModDropClient client)
- : base(ModRepositoryKey.ModDrop)
- {
- this.Client = client;
- }
-
- /// <summary>Get metadata about a mod in the repository.</summary>
- /// <param name="id">The mod ID in this repository.</param>
- public override async Task<ModInfoModel> GetModInfoAsync(string id)
- {
- // validate ID format
- if (!long.TryParse(id, out long modDropID))
- return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID.");
-
- // fetch info
- try
- {
- ModDropMod mod = await this.Client.GetModAsync(modDropID);
- return mod != null
- ? new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url)
- : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop mod with this ID.");
- }
- catch (Exception ex)
- {
- return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString());
- }
- }
-
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- public override void Dispose()
- {
- this.Client.Dispose();
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
deleted file mode 100644
index 9551258c..00000000
--- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using StardewModdingAPI.Toolkit.Framework.UpdateData;
-using StardewModdingAPI.Web.Framework.Clients.Nexus;
-
-namespace StardewModdingAPI.Web.Framework.ModRepositories
-{
- /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary>
- internal class NexusRepository : RepositoryBase
- {
- /*********
- ** Fields
- *********/
- /// <summary>The underlying Nexus Mods API client.</summary>
- private readonly INexusClient Client;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="client">The underlying Nexus Mods API client.</param>
- public NexusRepository(INexusClient client)
- : base(ModRepositoryKey.Nexus)
- {
- this.Client = client;
- }
-
- /// <summary>Get metadata about a mod in the repository.</summary>
- /// <param name="id">The mod ID in this repository.</param>
- public override async Task<ModInfoModel> GetModInfoAsync(string id)
- {
- // validate ID format
- if (!uint.TryParse(id, out uint nexusID))
- return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID.");
-
- // fetch info
- try
- {
- NexusMod mod = await this.Client.GetModAsync(nexusID);
- if (mod == null)
- return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID.");
- if (mod.Error != null)
- {
- RemoteModStatus remoteStatus = mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished
- ? RemoteModStatus.DoesNotExist
- : RemoteModStatus.TemporaryError;
- return new ModInfoModel().SetError(remoteStatus, mod.Error);
- }
-
- return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url);
- }
- catch (Exception ex)
- {
- return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString());
- }
- }
-
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- public override void Dispose()
- {
- this.Client.Dispose();
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs
new file mode 100644
index 00000000..68b4c6ac
--- /dev/null
+++ b/src/SMAPI.Web/Framework/ModSiteManager.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.UpdateData;
+using StardewModdingAPI.Web.Framework.Clients;
+
+namespace StardewModdingAPI.Web.Framework
+{
+ /// <summary>Handles fetching data from mod sites.</summary>
+ internal class ModSiteManager
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The mod sites which provide mod metadata.</summary>
+ private readonly IDictionary<ModSiteKey, IModSiteClient> ModSites;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="modSites">The mod sites which provide mod metadata.</param>
+ public ModSiteManager(IModSiteClient[] modSites)
+ {
+ this.ModSites = modSites.ToDictionary(p => p.SiteKey);
+ }
+
+ /// <summary>Get the mod info for an update key.</summary>
+ /// <param name="updateKey">The namespaced update key.</param>
+ public async Task<IModPage> GetModPageAsync(UpdateKey updateKey)
+ {
+ // get site
+ if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient client))
+ return new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Site}'. Expected one of [{string.Join(", ", this.ModSites.Keys)}].");
+
+ // fetch mod
+ IModPage mod;
+ try
+ {
+ mod = await client.GetModData(updateKey.ID);
+ }
+ catch (Exception ex)
+ {
+ mod = new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.TemporaryError, ex.ToString());
+ }
+
+ // handle errors
+ return mod ?? new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"Found no {updateKey.Site} mod with ID '{updateKey.ID}'.");
+ }
+
+ /// <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="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param>
+ /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param>
+ public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions)
+ {
+ // get base model
+ ModInfoModel model = new ModInfoModel()
+ .SetBasicInfo(page.Name, page.Url)
+ .SetError(page.Status, page.Error);
+ if (page.Status != RemoteModStatus.Ok)
+ 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);
+ if (!hasVersions)
+ return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions.");
+
+ // return info
+ return model.SetVersions(mainVersion, previewVersion);
+ }
+
+ /// <summary>Get a semantic local version for update checks.</summary>
+ /// <param name="version">The version to parse.</param>
+ /// <param name="map">A map of version replacements.</param>
+ /// <param name="allowNonStandard">Whether to allow non-standard versions.</param>
+ public ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard)
+ {
+ // try mapped version
+ string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard);
+ if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew))
+ return parsedNew;
+
+ // return original version
+ return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld)
+ ? parsedOld
+ : null;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the mod version numbers for the given mod.</summary>
+ /// <param name="mod">The mod to check.</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="allowNonStandardVersions">Whether to allow non-standard versions.</param>
+ /// <param name="mapRemoteVersions">Maps remote versions to a semantic version 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, IDictionary<string, string> mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview)
+ {
+ main = null;
+ preview = null;
+
+ ISemanticVersion ParseVersion(string raw)
+ {
+ raw = this.NormalizeVersion(raw);
+ return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions);
+ }
+
+ if (mod != null)
+ {
+ // get mod version
+ if (subkey == null)
+ main = ParseVersion(mod.Version);
+
+ // get file versions
+ foreach (IModDownload download in mod.Downloads)
+ {
+ // check for subkey if specified
+ if (subkey != null && download.Name?.Contains(subkey, StringComparison.OrdinalIgnoreCase) != true && download.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) != true)
+ continue;
+
+ // parse version
+ ISemanticVersion cur = ParseVersion(download.Version);
+ if (cur == null)
+ continue;
+
+ // track highest versions
+ if (main == null || cur.IsNewerThan(main))
+ main = cur;
+ if (cur.IsPrerelease() && (preview == null || cur.IsNewerThan(preview)))
+ preview = cur;
+ }
+
+ if (preview != null && !preview.IsNewerThan(main))
+ preview = null;
+ }
+
+ return main != null;
+ }
+
+ /// <summary>Get a semantic local version for update checks.</summary>
+ /// <param name="version">The version to map.</param>
+ /// <param name="map">A map of version replacements.</param>
+ /// <param name="allowNonStandard">Whether to allow non-standard versions.</param>
+ private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard)
+ {
+ if (version == null || map == null || !map.Any())
+ return version;
+
+ // match exact raw version
+ if (map.ContainsKey(version))
+ return map[version];
+
+ // match parsed version
+ if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed))
+ {
+ if (map.ContainsKey(parsed.ToString()))
+ return map[parsed.ToString()];
+
+ foreach ((string fromRaw, string toRaw) in map)
+ {
+ if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion))
+ return newVersion.ToString();
+ }
+ }
+
+ return version;
+ }
+
+ /// <summary>Normalize a version string.</summary>
+ /// <param name="version">The version to normalize.</param>
+ private string NormalizeVersion(string version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ return null;
+
+ version = version.Trim();
+ if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix
+ version = version.Substring(1);
+
+ return version;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs
new file mode 100644
index 00000000..d75ee791
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Net;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RedirectRules
+{
+ /// <summary>Redirect hostnames to a URL if they match a condition.</summary>
+ internal class RedirectHostsToUrlsRule : RedirectMatchRule
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Maps a lowercase hostname to the resulting redirect URL.</summary>
+ private readonly Func<string, string> Map;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="statusCode">The status code to use for redirects.</param>
+ /// <param name="map">Hostnames mapped to the resulting redirect URL.</param>
+ public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string> map)
+ {
+ this.StatusCode = statusCode;
+ this.Map = map ?? throw new ArgumentNullException(nameof(map));
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the new redirect URL.</summary>
+ /// <param name="context">The rewrite context.</param>
+ /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
+ protected override string GetNewUrl(RewriteContext context)
+ {
+ // get requested host
+ string host = context.HttpContext.Request.Host.Host;
+ if (host == null)
+ return null;
+
+ // get new host
+ host = this.Map(host);
+ if (host == null)
+ return null;
+
+ // rewrite URL
+ UriBuilder uri = this.GetUrl(context.HttpContext.Request);
+ uri.Host = host;
+ return uri.ToString();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs
new file mode 100644
index 00000000..6e81c4ca
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RedirectRules
+{
+ /// <summary>Redirect matching requests to a URL.</summary>
+ internal abstract class RedirectMatchRule : IRule
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The status code to use for redirects.</summary>
+ protected HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Redirect;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
+ /// <param name="context">The rewrite context.</param>
+ public void ApplyRule(RewriteContext context)
+ {
+ string newUrl = this.GetNewUrl(context);
+ if (newUrl == null)
+ return;
+
+ HttpResponse response = context.HttpContext.Response;
+ response.StatusCode = (int)HttpStatusCode.Redirect;
+ response.Headers["Location"] = newUrl;
+ context.Result = RuleResult.EndResponse;
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Get the new redirect URL.</summary>
+ /// <param name="context">The rewrite context.</param>
+ /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
+ protected abstract string GetNewUrl(RewriteContext context);
+
+ /// <summary>Get the full request URL.</summary>
+ /// <param name="request">The request.</param>
+ protected UriBuilder GetUrl(HttpRequest request)
+ {
+ return new UriBuilder
+ {
+ Scheme = request.Scheme,
+ Host = request.Host.Host,
+ Port = request.Host.Port ?? -1,
+ Path = request.PathBase + request.Path,
+ Query = request.QueryString.Value
+ };
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs
new file mode 100644
index 00000000..d9d44641
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RedirectRules
+{
+ /// <summary>Redirect paths to URLs if they match a condition.</summary>
+ internal class RedirectPathsToUrlsRule : RedirectMatchRule
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Regex patterns matching the current URL mapped to the resulting redirect URL.</summary>
+ private readonly IDictionary<Regex, string> Map;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="map">Regex patterns matching the current URL mapped to the resulting redirect URL.</param>
+ public RedirectPathsToUrlsRule(IDictionary<string, string> map)
+ {
+ this.StatusCode = HttpStatusCode.RedirectKeepVerb;
+ this.Map = map.ToDictionary(
+ p => new Regex(p.Key, RegexOptions.IgnoreCase | RegexOptions.Compiled),
+ p => p.Value
+ );
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Get the new redirect URL.</summary>
+ /// <param name="context">The rewrite context.</param>
+ /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
+ protected override string GetNewUrl(RewriteContext context)
+ {
+ string path = context.HttpContext.Request.Path.Value;
+
+ if (!string.IsNullOrWhiteSpace(path))
+ {
+ foreach ((Regex pattern, string url) in this.Map)
+ {
+ if (pattern.IsMatch(path))
+ return pattern.Replace(path, url);
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs
new file mode 100644
index 00000000..2a503ae3
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RedirectRules
+{
+ /// <summary>Redirect requests to HTTPS.</summary>
+ internal class RedirectToHttpsRule : RedirectMatchRule
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Matches requests which should be ignored.</summary>
+ private readonly Func<HttpRequest, bool> Except;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="except">Matches requests which should be ignored.</param>
+ public RedirectToHttpsRule(Func<HttpRequest, bool> except = null)
+ {
+ this.Except = except ?? (req => false);
+ this.StatusCode = HttpStatusCode.RedirectKeepVerb;
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Get the new redirect URL.</summary>
+ /// <param name="context">The rewrite context.</param>
+ /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
+ protected override string GetNewUrl(RewriteContext context)
+ {
+ HttpRequest request = context.HttpContext.Request;
+ if (request.IsHttps || this.Except(request))
+ return null;
+
+ UriBuilder uri = this.GetUrl(request);
+ uri.Scheme = "https";
+ return uri.ToString();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs b/src/SMAPI.Web/Framework/RemoteModStatus.cs
index 02876556..139ecfd3 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs
+++ b/src/SMAPI.Web/Framework/RemoteModStatus.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI.Web.Framework.ModRepositories
+namespace StardewModdingAPI.Web.Framework
{
/// <summary>The mod availability status on a remote site.</summary>
internal enum RemoteModStatus
diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs
deleted file mode 100644
index 36effd82..00000000
--- a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using System;
-using System.Net;
-using System.Text;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Rewrite;
-
-namespace StardewModdingAPI.Web.Framework.RewriteRules
-{
- /// <summary>Redirect requests to HTTPS.</summary>
- /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" /> and <see cref="Microsoft.AspNetCore.Rewrite.Internal.RedirectToHttpsRule"/>.</remarks>
- internal class ConditionalRedirectToHttpsRule : IRule
- {
- /*********
- ** Fields
- *********/
- /// <summary>A predicate which indicates when the rule should be applied.</summary>
- private readonly Func<HttpRequest, bool> ShouldRewrite;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
- public ConditionalRedirectToHttpsRule(Func<HttpRequest, bool> shouldRewrite = null)
- {
- this.ShouldRewrite = shouldRewrite ?? (req => true);
- }
-
- /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
- /// <param name="context">The rewrite context.</param>
- public void ApplyRule(RewriteContext context)
- {
- HttpRequest request = context.HttpContext.Request;
-
- // check condition
- if (this.IsSecure(request) || !this.ShouldRewrite(request))
- return;
-
- // redirect request
- HttpResponse response = context.HttpContext.Response;
- response.StatusCode = (int)HttpStatusCode.RedirectKeepVerb;
- response.Headers["Location"] = new StringBuilder()
- .Append("https://")
- .Append(request.Host.Host)
- .Append(request.PathBase)
- .Append(request.Path)
- .Append(request.QueryString)
- .ToString();
- context.Result = RuleResult.EndResponse;
- }
-
- /// <summary>Get whether the request was received over HTTPS.</summary>
- /// <param name="request">The request to check.</param>
- public bool IsSecure(HttpRequest request)
- {
- return
- request.IsHttps // HTTPS to server
- || string.Equals(request.Headers["x-forwarded-proto"], "HTTPS", StringComparison.OrdinalIgnoreCase); // HTTPS to AWS load balancer
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
deleted file mode 100644
index ab9e019c..00000000
--- a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Net;
-using System.Text.RegularExpressions;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Rewrite;
-
-namespace StardewModdingAPI.Web.Framework.RewriteRules
-{
- /// <summary>Redirect requests to an external URL if they match a condition.</summary>
- internal class RedirectToUrlRule : IRule
- {
- /*********
- ** Fields
- *********/
- /// <summary>Get the new URL to which to redirect (or <c>null</c> to skip).</summary>
- private readonly Func<HttpRequest, string> NewUrl;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
- /// <param name="url">The new URL to which to redirect.</param>
- public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url)
- {
- this.NewUrl = req => shouldRewrite(req) ? url : null;
- }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="pathRegex">A case-insensitive regex to match against the path.</param>
- /// <param name="url">The external URL.</param>
- public RedirectToUrlRule(string pathRegex, string url)
- {
- Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled);
- this.NewUrl = req => req.Path.HasValue ? regex.Replace(req.Path.Value, url) : null;
- }
-
- /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
- /// <param name="context">The rewrite context.</param>
- public void ApplyRule(RewriteContext context)
- {
- HttpRequest request = context.HttpContext.Request;
-
- // check rewrite
- string newUrl = this.NewUrl(request);
- if (newUrl == null || newUrl == request.Path.Value)
- return;
-
- // redirect request
- HttpResponse response = context.HttpContext.Response;
- response.StatusCode = (int)HttpStatusCode.Redirect;
- response.Headers["Location"] = newUrl;
- context.Result = RuleResult.EndResponse;
- }
- }
-}
diff --git a/src/SMAPI.Web/Program.cs b/src/SMAPI.Web/Program.cs
index 70082160..1fdd3185 100644
--- a/src/SMAPI.Web/Program.cs
+++ b/src/SMAPI.Web/Program.cs
@@ -1,5 +1,5 @@
-using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Hosting;
namespace StardewModdingAPI.Web
{
@@ -13,13 +13,13 @@ namespace StardewModdingAPI.Web
/// <param name="args">The command-line arguments.</param>
public static void Main(string[] args)
{
- // configure web server
- WebHost
+ Host
.CreateDefaultBuilder(args)
- .CaptureStartupErrors(true)
- .UseSetting("detailedErrors", "true")
- .UseKestrel().UseIISIntegration() // must be used together; fixes intermittent errors on Azure: https://stackoverflow.com/a/38312175/262123
- .UseStartup<Startup>()
+ .ConfigureWebHostDefaults(builder => builder
+ .CaptureStartupErrors(true)
+ .UseSetting("detailedErrors", "true")
+ .UseStartup<Startup>()
+ )
.Build()
.Run();
}
diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj
index 0a978b30..c6c0f774 100644
--- a/src/SMAPI.Web/SMAPI.Web.csproj
+++ b/src/SMAPI.Web/SMAPI.Web.csproj
@@ -3,7 +3,7 @@
<PropertyGroup>
<AssemblyName>SMAPI.Web</AssemblyName>
<RootNamespace>StardewModdingAPI.Web</RootNamespace>
- <TargetFramework>netcoreapp2.0</TargetFramework>
+ <TargetFramework>netcoreapp3.1</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
@@ -12,23 +12,17 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Azure.Storage.Blobs" Version="12.4.0" />
- <PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" />
+ <PackageReference Include="Azure.Storage.Blobs" Version="12.4.2" />
+ <PackageReference Include="Hangfire.AspNetCore" Version="1.7.11" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" />
- <PackageReference Include="Hangfire.Mongo" Version="0.6.7" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.23" />
- <PackageReference Include="Humanizer.Core" Version="2.7.9" />
- <PackageReference Include="JetBrains.Annotations" Version="2019.1.3" />
- <PackageReference Include="Markdig" Version="0.18.3" />
- <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
- <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
- <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" />
- <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
- <PackageReference Include="Mongo2Go" Version="2.2.12" />
- <PackageReference Include="MongoDB.Driver" Version="2.10.2" />
+ <PackageReference Include="Humanizer.Core" Version="2.8.11" />
+ <PackageReference Include="JetBrains.Annotations" Version="2020.1.0" />
+ <PackageReference Include="Markdig" Version="0.20.0" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" />
- <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.0" />
- <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
+ <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.1" />
+ <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" />
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 56ef9a79..586b0c3c 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -1,8 +1,7 @@
-using System;
using System.Collections.Generic;
+using System.Net;
using Hangfire;
using Hangfire.MemoryStorage;
-using Hangfire.Mongo;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Rewrite;
@@ -10,13 +9,9 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
-using Mongo2Go;
-using MongoDB.Bson.Serialization;
-using MongoDB.Driver;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization;
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.Chucklefish;
@@ -27,7 +22,7 @@ using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels;
-using StardewModdingAPI.Web.Framework.RewriteRules;
+using StardewModdingAPI.Web.Framework.RedirectRules;
using StardewModdingAPI.Web.Framework.Storage;
namespace StardewModdingAPI.Web
@@ -47,7 +42,7 @@ namespace StardewModdingAPI.Web
*********/
/// <summary>Construct an instance.</summary>
/// <param name="env">The hosting environment.</param>
- public Startup(IHostingEnvironment env)
+ public Startup(IWebHostEnvironment env)
{
this.Configuration = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
@@ -67,70 +62,41 @@ namespace StardewModdingAPI.Web
.Configure<BackgroundServicesConfig>(this.Configuration.GetSection("BackgroundServices"))
.Configure<ModCompatibilityListConfig>(this.Configuration.GetSection("ModCompatibilityList"))
.Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck"))
- .Configure<MongoDbConfig>(this.Configuration.GetSection("MongoDB"))
.Configure<SiteConfig>(this.Configuration.GetSection("Site"))
.Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint)))
.AddLogging()
- .AddMemoryCache()
- .AddMvc()
- .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider()))
- .AddJsonOptions(options =>
- {
- foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters)
- options.SerializerSettings.Converters.Add(converter);
-
- options.SerializerSettings.Formatting = Formatting.Indented;
- options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
- });
- MongoDbConfig mongoConfig = this.Configuration.GetSection("MongoDB").Get<MongoDbConfig>();
-
- // init background service
- {
- BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>();
- if (config.Enabled)
- services.AddHostedService<BackgroundService>();
- }
+ .AddMemoryCache();
- // init MongoDB
- services.AddSingleton<MongoDbRunner>(serv => !mongoConfig.IsConfigured()
- ? MongoDbRunner.Start()
- : throw new InvalidOperationException("The MongoDB connection is configured, so the local development version should not be used.")
- );
- services.AddSingleton<IMongoDatabase>(serv =>
- {
- // get connection string
- string connectionString = mongoConfig.IsConfigured()
- ? mongoConfig.ConnectionString
- : serv.GetRequiredService<MongoDbRunner>().ConnectionString;
+ // init MVC
+ services
+ .AddControllers()
+ .AddNewtonsoftJson(options => this.ConfigureJsonNet(options.SerializerSettings))
+ .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider()));
+ services
+ .AddRazorPages();
- // get client
- BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer());
- return new MongoClient(connectionString).GetDatabase(mongoConfig.Database);
- });
- services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
- services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
+ // init storage
+ services.AddSingleton<IModCacheRepository>(new ModCacheMemoryRepository());
+ services.AddSingleton<IWikiCacheRepository>(new WikiCacheMemoryRepository());
// init Hangfire
services
- .AddHangfire(config =>
+ .AddHangfire((serv, config) =>
{
config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer()
- .UseRecommendedSerializerSettings();
-
- if (mongoConfig.IsConfigured())
- {
- config.UseMongoStorage(mongoConfig.ConnectionString, $"{mongoConfig.Database}-hangfire", new MongoStorageOptions
- {
- MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop),
- CheckConnection = false // error on startup takes down entire process
- });
- }
- else
- config.UseMemoryStorage();
+ .UseRecommendedSerializerSettings()
+ .UseMemoryStorage();
});
+ // init background service
+ {
+ BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>();
+ if (config.Enabled)
+ services.AddHostedService<BackgroundService>();
+ }
+
// init API clients
{
ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get<ApiClientsConfig>();
@@ -142,6 +108,7 @@ namespace StardewModdingAPI.Web
baseUrl: api.ChucklefishBaseUrl,
modPageUrlFormat: api.ChucklefishModPageUrlFormat
));
+
services.AddSingleton<ICurseForgeClient>(new CurseForgeClient(
userAgent: userAgent,
apiUrl: api.CurseForgeBaseUrl
@@ -188,8 +155,7 @@ namespace StardewModdingAPI.Web
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
/// <param name="app">The application builder.</param>
- /// <param name="env">The hosting environment.</param>
- public void Configure(IApplicationBuilder app, IHostingEnvironment env)
+ public void Configure(IApplicationBuilder app)
{
// basic config
app.UseDeveloperExceptionPage();
@@ -201,7 +167,13 @@ namespace StardewModdingAPI.Web
)
.UseRewriter(this.GetRedirectRules())
.UseStaticFiles() // wwwroot folder
- .UseMvc();
+ .UseRouting()
+ .UseAuthorization()
+ .UseEndpoints(p =>
+ {
+ p.MapControllers();
+ p.MapRazorPages();
+ });
// enable Hangfire dashboard
app.UseHangfireDashboard("/tasks", new DashboardOptions
@@ -215,29 +187,63 @@ namespace StardewModdingAPI.Web
/*********
** Private methods
*********/
+ /// <summary>Configure a Json.NET serializer.</summary>
+ /// <param name="settings">The serializer settings to edit.</param>
+ private void ConfigureJsonNet(JsonSerializerSettings settings)
+ {
+ foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters)
+ settings.Converters.Add(converter);
+
+ settings.Formatting = Formatting.Indented;
+ settings.NullValueHandling = NullValueHandling.Ignore;
+ }
+
/// <summary>Get the redirect rules to apply.</summary>
private RewriteOptions GetRedirectRules()
{
- var redirects = new RewriteOptions();
+ var redirects = new RewriteOptions()
+ // shortcut paths
+ .Add(new RedirectPathsToUrlsRule(new Dictionary<string, string>
+ {
+ [@"^/3\.0\.?$"] = "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0",
+ [@"^/(?:buildmsg|package)(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", // buildmsg deprecated, remove when SDV 1.4 is released
+ [@"^/community\.?$"] = "https://stardewvalleywiki.com/Modding:Community",
+ [@"^/compat\.?$"] = "https://smapi.io/mods",
+ [@"^/docs\.?$"] = "https://stardewvalleywiki.com/Modding:Index",
+ [@"^/install\.?$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI",
+ [@"^/troubleshoot(.*)$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1",
+ [@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods"
+ }))
+
+ // legacy paths
+ .Add(new RedirectPathsToUrlsRule(this.GetLegacyPathRedirects()))
+
+ // subdomains
+ .Add(new RedirectHostsToUrlsRule(HttpStatusCode.PermanentRedirect, host => host switch
+ {
+ "api.smapi.io" => "smapi.io/api",
+ "json.smapi.io" => "smapi.io/json",
+ "log.smapi.io" => "smapi.io/log",
+ "mods.smapi.io" => "smapi.io/mods",
+ _ => host.EndsWith(".smapi.io")
+ ? "smapi.io"
+ : null
+ }))
- // redirect to HTTPS (except API for Linux/Mac Mono compatibility)
- redirects.Add(new ConditionalRedirectToHttpsRule(
- shouldRewrite: req =>
- req.Host.Host != "localhost"
- && !req.Path.StartsWithSegments("/api")
- ));
+ // redirect to HTTPS (except API for Linux/Mac Mono compatibility)
+ .Add(
+ new RedirectToHttpsRule(except: req => req.Host.Host == "localhost" || req.Path.StartsWithSegments("/api"))
+ );
- // shortcut redirects
- redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0"));
- redirects.Add(new RedirectToUrlRule(@"^/(?:buildmsg|package)(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1")); // buildmsg deprecated, remove when SDV 1.4 is released
- redirects.Add(new RedirectToUrlRule(@"^/community\.?$", "https://stardewvalleywiki.com/Modding:Community"));
- redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://smapi.io/mods"));
- redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index"));
- redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI"));
- redirects.Add(new RedirectToUrlRule(@"^/troubleshoot(.*)$", "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1"));
- redirects.Add(new RedirectToUrlRule(@"^/xnb\.?$", "https://stardewvalleywiki.com/Modding:Using_XNB_mods"));
+ return redirects;
+ }
+
+ /// <summary>Get the redirects for legacy paths that have been moved elsewhere.</summary>
+ private IDictionary<string, string> GetLegacyPathRedirects()
+ {
+ var redirects = new Dictionary<string, string>();
- // redirect legacy canimod.com URLs
+ // canimod.com => wiki
var wikiRedirects = new Dictionary<string, string[]>
{
["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" },
@@ -251,10 +257,10 @@ namespace StardewModdingAPI.Web
["Modding:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" },
["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" }
};
- foreach (KeyValuePair<string, string[]> pair in wikiRedirects)
+ foreach ((string page, string[] patterns) in wikiRedirects)
{
- foreach (string pattern in pair.Value)
- redirects.Add(new RedirectToUrlRule(pattern, "https://stardewvalleywiki.com/" + pair.Key));
+ foreach (string pattern in patterns)
+ redirects.Add(pattern, "https://stardewvalleywiki.com/" + page);
}
return redirects;
diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
index c0dd7184..0ea69911 100644
--- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
+++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
@@ -10,6 +10,9 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
/*********
** Accessors
*********/
+ /// <summary>Whether to show the edit view.</summary>
+ public bool IsEditView { get; set; }
+
/// <summary>The paste ID.</summary>
public string PasteID { get; set; }
@@ -51,11 +54,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
/// <param name="pasteID">The stored file ID.</param>
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
- public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
+ /// <param name="isEditView">Whether to show the edit view.</param>
+ public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats, bool isEditView)
{
this.PasteID = pasteID;
this.SchemaName = schemaName;
this.SchemaFormats = schemaFormats;
+ this.IsEditView = isEditView;
}
/// <summary>Set the validated content.</summary>
diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs
index ff7513bc..6b8279c1 100644
--- a/src/SMAPI.Web/ViewModels/ModListModel.cs
+++ b/src/SMAPI.Web/ViewModels/ModListModel.cs
@@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.ViewModels
public bool IsStale { get; set; }
/// <summary>Whether the mod metadata is available.</summary>
- public bool HasData => this.Mods != null;
+ public bool HasData => this.Mods?.Any() == true;
/*********
diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs
index 56316ab7..575d596a 100644
--- a/src/SMAPI.Web/ViewModels/ModModel.cs
+++ b/src/SMAPI.Web/ViewModels/ModModel.cs
@@ -22,6 +22,9 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>The mod author's alternative names, if any.</summary>
public string AlternateAuthors { get; set; }
+ /// <summary>The GitHub repo, if any.</summary>
+ public string GitHubRepo { get; set; }
+
/// <summary>The URL to the mod's source code, if any.</summary>
public string SourceUrl { get; set; }
@@ -62,6 +65,7 @@ namespace StardewModdingAPI.Web.ViewModels
this.AlternateNames = string.Join(", ", entry.Name.Skip(1).ToArray());
this.Author = entry.Author.FirstOrDefault();
this.AlternateAuthors = string.Join(", ", entry.Author.Skip(1).ToArray());
+ this.GitHubRepo = entry.GitHubRepo;
this.SourceUrl = this.GetSourceUrl(entry);
this.Compatibility = new ModCompatibilityModel(entry.Compatibility);
this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null;
@@ -102,7 +106,7 @@ namespace StardewModdingAPI.Web.ViewModels
if (entry.ModDropID.HasValue)
{
anyFound = true;
- yield return new ModLinkModel($"https://www.moddrop.com/sdv/mod/{entry.ModDropID}", "ModDrop");
+ yield return new ModLinkModel($"https://www.moddrop.com/stardew-valley/mod/{entry.ModDropID}", "ModDrop");
}
if (!string.IsNullOrWhiteSpace(entry.CurseForgeKey))
{
diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml
index eded9df3..d78a155e 100644
--- a/src/SMAPI.Web/Views/Index/Index.cshtml
+++ b/src/SMAPI.Web/Views/Index/Index.cshtml
@@ -9,7 +9,7 @@
}
@section Head {
<link rel="stylesheet" href="~/Content/css/index.css?r=20200105" />
- <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script>
<script src="~/Content/js/index.js?r=20200105"></script>
}
diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
index 7287e00b..7b89a23d 100644
--- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
+++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
@@ -9,7 +9,6 @@
string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName });
string schemaDisplayName = null;
bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName?.ToLower() != "none";
- bool isEditView = Model.Content == null || Model.SchemaName?.ToLower() == "edit";
// build title
ViewData["Title"] = "JSON validator";
@@ -32,7 +31,7 @@
<link rel="stylesheet" href="~/Content/css/json-validator.css?r=202002" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
- <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
@@ -40,7 +39,7 @@
<script src="~/Content/js/json-validator.js?r=202002"></script>
<script>
$(function() {
- smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @Json.Serialize(Model.PasteID));
+ smapi.jsonValidator(@this.ForJson(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @this.ForJson(Model.PasteID));
});
</script>
}
@@ -63,7 +62,7 @@ else if (Model.ParseError != null)
<small v-pre>Error details: @Model.ParseError</small>
</div>
}
-else if (!isEditView && Model.PasteID != null)
+else if (!Model.IsEditView && Model.PasteID != null)
{
<div class="banner success">
<strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br />
@@ -84,7 +83,7 @@ else if (!isEditView && Model.PasteID != null)
}
@* upload new file *@
-@if (isEditView)
+@if (Model.IsEditView)
{
<h2>Upload a JSON file</h2>
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
@@ -112,7 +111,7 @@ else if (!isEditView && Model.PasteID != null)
}
@* validation results *@
-@if (!isEditView)
+@if (!Model.IsEditView)
{
<div id="output">
@if (Model.UploadError == null)
@@ -158,7 +157,7 @@ else if (!isEditView && Model.PasteID != null)
{
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
}
- </select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = "edit" }))">edit this file</a>.
+ </select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = this.Model.SchemaName, operation = "edit" }))">edit this file</a>.
</div>
<pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre>
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 2183992b..71e12d47 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -1,5 +1,4 @@
@using Humanizer
-@using Newtonsoft.Json
@using StardewModdingAPI.Toolkit.Utilities
@using StardewModdingAPI.Web.Framework
@using StardewModdingAPI.Web.Framework.LogParsing.Models
@@ -12,7 +11,6 @@
.GetValues(typeof(LogLevel))
.Cast<LogLevel>()
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
- JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None };
string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true);
}
@@ -25,19 +23,19 @@
<link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" />
<link rel="stylesheet" href="~/Content/css/log-parser.css?r=202002" />
- <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script>
- <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script>
<script src="~/Content/js/file-upload.js?r=202002"></script>
<script src="~/Content/js/log-parser.js?r=202002"></script>
<script>
$(function() {
smapi.logParser({
- logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)),
- showPopup: @Json.Serialize(Model.ParsedLog == null),
- showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), noFormatting),
- showSections: @Json.Serialize(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false), noFormatting),
- showLevels: @Json.Serialize(defaultFilters, noFormatting),
- enableFilters: @Json.Serialize(!Model.ShowRaw)
+ logStarted: new Date(@this.ForJson(Model.ParsedLog?.Timestamp)),
+ showPopup: @this.ForJson(Model.ParsedLog == null),
+ showMods: @this.ForJson(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)),
+ showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false)),
+ showLevels: @this.ForJson(defaultFilters),
+ enableFilters: @this.ForJson(!Model.ShowRaw)
}, '@this.Url.PlainAction("Index", "LogParser", values: null)');
});
</script>
diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml
index b1d9ae2c..fa77c220 100644
--- a/src/SMAPI.Web/Views/Mods/Index.cshtml
+++ b/src/SMAPI.Web/Views/Mods/Index.cshtml
@@ -1,22 +1,26 @@
@using Humanizer
@using Humanizer.Localisation
-@using Newtonsoft.Json
+@using StardewModdingAPI.Web.Framework
+@using StardewModdingAPI.Web.ViewModels
@model StardewModdingAPI.Web.ViewModels.ModListModel
@{
ViewData["Title"] = "Mod compatibility";
TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated;
+
+ bool hasBeta = Model.BetaVersion != null;
+ string betaLabel = "SDV @Model.BetaVersion only";
}
@section Head {
<link rel="stylesheet" href="~/Content/css/mods.css?r=20200218" />
- <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script>
- <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
- <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.0/dist/js/jquery.tablesorter.combined.min.js" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.3" crossorigin="anonymous"></script>
<script src="~/Content/js/mods.js?r=20200218"></script>
<script>
$(function() {
- var data = @Json.Serialize(Model.Mods, new JsonSerializerSettings { Formatting = Formatting.None });
- var enableBeta = @Json.Serialize(Model.BetaVersion != null);
+ var data = @this.ForJson(Model.Mods ?? new ModModel[0]);
+ var enableBeta = @this.ForJson(hasBeta);
smapi.modList(data, enableBeta);
});
</script>
@@ -39,9 +43,9 @@ else
<p>The list is updated every few days (you can help <a href="https://stardewvalleywiki.com/Modding:Mod_compatibility">update it</a>!). It doesn't include XNB mods (see <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods"><em>using XNB mods</em> on the wiki</a> instead) or compatible content packs.</p>
- @if (Model.BetaVersion != null)
+ @if (hasBeta)
{
- <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "SDV @Model.BetaVersion only" lines are for an unreleased version of the game, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of the game.</p>
+ <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "@betaLabel" lines are for an unreleased version of SMAPI, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of SMAPI.</p>
}
</div>
@@ -79,14 +83,14 @@ else
</tr>
</thead>
<tbody>
- <tr v-for="mod in mods" :key="mod.Name" v-bind:id="mod.Slug" :key="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible">
+ <tr v-for="mod in mods" :key="mod.Slug" v-bind:id="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible">
<td>
{{mod.Name}}
<small class="mod-alt-names" v-if="mod.AlternateNames">(aka {{mod.AlternateNames}})</small>
</td>
<td class="mod-page-links">
<span v-for="(link, i) in mod.ModPages">
- <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}}
+ <a v-bind:href="link.Url">{{link.Text}}</a>{{i &lt; mod.ModPages.length - 1 ? ', ' : ''}}
</span>
</td>
<td>
@@ -96,14 +100,20 @@ else
<td>
<div v-html="mod.Compatibility.Summary"></div>
<div v-if="mod.BetaCompatibility" v-show="showAdvanced">
- <strong v-if="mod.BetaCompatibility">SDV @Model.BetaVersion only:</strong>
+ <strong v-if="mod.BetaCompatibility">@betaLabel:</strong>
<span v-html="mod.BetaCompatibility.Summary"></span>
</div>
<div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div>
</td>
<td class="mod-broke-in" v-html="mod.LatestCompatibility.BrokeIn" v-show="showAdvanced"></td>
<td v-show="showAdvanced">
- <span v-if="mod.SourceUrl"><a v-bind:href="mod.SourceUrl">source</a></span>
+ <span v-if="mod.SourceUrl">
+ <a v-bind:href="mod.SourceUrl">source</a>
+ <span v-if="mod.GitHubRepo">
+ @* see https://shields.io/category/license *@
+ (<img v-bind:src="'https://img.shields.io/github/license/' + mod.GitHubRepo + '?style=flat-square.png&label='" class="license-badge" alt="source" />)
+ </span>
+ </span>
<span v-else class="mod-closed-source">no source</span>
</td>
<td>
diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
index 2d06ceb1..67dcd3b3 100644
--- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml
+++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
@@ -29,7 +29,7 @@
</div>
<div id="content-column">
<div id="content">
- @if (ViewData["ViewTitle"] != string.Empty)
+ @if (ViewData["ViewTitle"] as string != string.Empty)
{
<h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1>
}
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json
index 54460c46..3aa69285 100644
--- a/src/SMAPI.Web/appsettings.Development.json
+++ b/src/SMAPI.Web/appsettings.Development.json
@@ -17,11 +17,6 @@
"NexusApiKey": null
},
- "MongoDB": {
- "ConnectionString": null,
- "Database": "smapi-edge"
- },
-
"BackgroundServices": {
"Enabled": true
}
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index 9cd1efc8..22fd7396 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -39,7 +39,7 @@
"GitHubPassword": null,
"ModDropApiUrl": "https://www.moddrop.com/api/mods/data",
- "ModDropModPageUrl": "https://www.moddrop.com/sdv/mod/{0}",
+ "ModDropModPageUrl": "https://www.moddrop.com/stardew-valley/mod/{0}",
"NexusApiKey": null,
"NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/",
@@ -49,7 +49,8 @@
"PastebinBaseUrl": "https://pastebin.com/"
},
- "MongoDB": {
+ "Storage": {
+ "Mode": "InMemory",
"ConnectionString": null,
"Database": "smapi"
},
diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css
index 697ba514..4f6578cb 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/mods.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css
@@ -153,3 +153,7 @@ table.wikitable > caption {
#mod-list td.smapi-3-col span {
border-bottom: 1px dashed gray;
}
+
+#mod-list .license-badge {
+ vertical-align: middle;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js
index 35098b60..ac2754a4 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/mods.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js
@@ -1,5 +1,3 @@
-/* globals $ */
-
var smapi = smapi || {};
var app;
smapi.modList = function (mods, enableBeta) {
diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
index f627ab95..726b50be 100644
--- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
+++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
@@ -11,9 +11,9 @@
"title": "Format version",
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
"type": "string",
- "const": "1.13.0",
+ "const": "1.14.0",
"@errorMessages": {
- "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.13.0'."
+ "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.14.0'."
}
},
"ConfigSchema": {
diff --git a/src/SMAPI.Web/wwwroot/schemas/i18n.json b/src/SMAPI.Web/wwwroot/schemas/i18n.json
new file mode 100644
index 00000000..493ad213
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/schemas/i18n.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://smapi.io/schemas/i18n.json",
+ "title": "SMAPI i18n file",
+ "description": "A translation file for a SMAPI mod or content pack.",
+ "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation",
+ "type": "object",
+
+ "properties": {
+ "$schema": {
+ "title": "Schema",
+ "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.",
+ "type": "string",
+ "const": "https://smapi.io/schemas/manifest.json"
+ }
+ },
+
+ "additionalProperties": {
+ "type": "string",
+ "@errorMessages": {
+ "type": "Invalid property. Translation files can only contain text property values."
+ }
+ }
+}