Merge branch 'develop' of https://github.com/Pathoschild/SMAPI into harmony2

 Conflicts:
	src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
	src/SMAPI.sln
	src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
	src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
	src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs
	src/SMAPI/Framework/Patching/GamePatcher.cs
	src/SMAPI/Framework/SGame.cs
	src/SMAPI/SMAPI.csproj
This commit is contained in:
ZaneYork 2020-05-28 18:32:28 +08:00
commit 797a8e5485
155 changed files with 3220 additions and 2924 deletions

View File

@ -1,6 +1,36 @@
← [README](README.md)
# Release notes
## Upcoming released
* For players:
* Mod warnings are now listed alphabetically.
* MacOS files starting with `._` are now ignored and can no longer cause skipped mods.
* Simplified paranoid warning logs and reduced their log level.
* Fixed `BadImageFormatException` error detection.
* For the web UI:
* Added GitHub licenses to mod compatibility list.
* Updated ModDrop URLs.
* Internal changes to improve performance and reliability.
* For modders:
* Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info).
* Added [update subkeys](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Update_checks#Update_subkeys).
* Added `Multiplayer.PeerConnected` event.
* Added ability to override update keys from the compatibility list.
* Added `harmony_summary` console command which lists all current Harmony patches, optionally with a search filter.
* Harmony mods which use the `[HarmonyPatch(type)]` attribute now work crossplatform. Previously SMAPI couldn't rewrite types in custom attributes for compatibility.
* Improved mod rewriting for compatibility:
* Fixed rewriting types in custom attributes.
* Fixed rewriting generic types to method references.
* Fixed asset propagation for Gil's portraits.
* Fixed `.pdb` files ignored for error stack traces for mods rewritten by SMAPI.
* For SMAPI developers:
* Eliminated MongoDB storage in the web services, which complicated the code unnecessarily. The app still uses an abstract interface for storage, so we can wrap a distributed cache in the future if needed.
* Overhauled update checks to simplify individual clients, centralize common logic, and enable upcoming features.
* Merged the separate legacy redirects app on AWS into the main app on Azure.
## 3.5
Released 27 April 2020 for Stardew Valley 1.4.1 or later.

View File

@ -340,9 +340,19 @@ short url | → | target page
A local environment lets you run a complete copy of the web project (including cache database) on
your machine, with no external dependencies aside from the actual mod sites.
1. Enter the Nexus credentials in `appsettings.Development.json` . You can leave the other
credentials empty to default to fetching data anonymously, and storing data in-memory and
on disk.
1. Edit `appsettings.Development.json` and set these options:
property name | description
------------- | -----------
`NexusApiKey` | [Your Nexus API key](https://www.nexusmods.com/users/myaccount?tab=api#personal_key).
Optional settings:
property name | description
--------------------------- | -----------
`AzureBlobConnectionString` | The connection string for the Azure Blob storage account. Defaults to using the system's temporary file folder if not specified.
`GitHubUsername`<br />`GitHubPassword` | The GitHub credentials with which to query GitHub release info. Defaults to anonymous requests if not specified.
2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
### Production environment
@ -355,19 +365,15 @@ accordingly.
Initial setup:
1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas))
for mod data.
2. Create an Azure Blob storage account for uploaded files.
3. Create an Azure App Services environment running the latest .NET Core on Linux or Windows.
4. Add these application settings in the new App Services environment:
1. Create an Azure Blob storage account for uploaded files.
2. Create an Azure App Services environment running the latest .NET Core on Linux or Windows.
3. Add these application settings in the new App Services environment:
property name | description
------------------------------- | -----------------
`ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2.
`ApiClients.GitHubUsername`<br />`ApiClients.GitHubPassword` | The login credentials for the GitHub account with which to fetch release info. If these are omitted, GitHub will impose much stricter rate limits.
`ApiClients:NexusApiKey` | The [Nexus API authentication key](https://github.com/Pathoschild/FluentNexus#init-a-client).
`MongoDB:ConnectionString` | The connection string for the MongoDB instance.
`MongoDB:Database` | The MongoDB database name (e.g. `smapi` in production or `smapi-edge` in testing environments).
Optional settings:
@ -378,6 +384,4 @@ Initial setup:
`Site:BetaBlurb` | If `Site:BetaEnabled` is true and there's a beta version of SMAPI in its GitHub releases, this is shown on the beta download button as explanatory subtext.
`Site:SupporterList` | A list of Patreon supports to credit on the download page.
To deploy updates:
1. [Deploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure).
2. If the MongoDB schema changed, delete the MongoDB database. (It'll be recreated automatically.)
To deploy updates, just [redeploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure).

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.Win32;
using StardewModdingApi.Installer.Enums;
using StardewModdingAPI.Installer.Framework;
@ -624,7 +623,7 @@ namespace StardewModdingApi.Installer
{
try
{
this.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)new FileInfo(path));
FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)new FileInfo(path));
break;
}
catch (Exception ex)
@ -665,41 +664,6 @@ namespace StardewModdingApi.Installer
}
}
/// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary>
/// <param name="entry">The file or folder to reset.</param>
/// <remarks>This method is mirrored from <c>FileUtilities.ForceDelete</c> in the toolkit.</remarks>
private void ForceDelete(FileSystemInfo entry)
{
// ignore if already deleted
entry.Refresh();
if (!entry.Exists)
return;
// delete children
if (entry is DirectoryInfo folder)
{
foreach (FileSystemInfo child in folder.GetFileSystemInfos())
this.ForceDelete(child);
}
// reset permissions & delete
entry.Attributes = FileAttributes.Normal;
entry.Delete();
// wait for deletion to finish
for (int i = 0; i < 10; i++)
{
entry.Refresh();
if (entry.Exists)
Thread.Sleep(500);
}
// throw exception if deletion didn't happen before timeout
entry.Refresh();
if (entry.Exists)
throw new IOException($"Timed out trying to delete {entry.FullName}");
}
/// <summary>Interactively ask the user to choose a value.</summary>
/// <param name="print">A callback which prints a message to the console.</param>
/// <param name="message">The message to print.</param>
@ -707,7 +671,7 @@ namespace StardewModdingApi.Installer
/// <param name="indent">The indentation to prefix to output.</param>
private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string> print = null)
{
print = print ?? this.PrintInfo;
print ??= this.PrintInfo;
while (true)
{

View File

@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" />
<PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.16.1">
<PrivateAssets>all</PrivateAssets>

View File

@ -153,9 +153,9 @@ namespace StardewModdingAPI.ModBuildConfig
// create zip file
Directory.CreateDirectory(outputFolderPath);
using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write))
using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create))
{
using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write);
using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create);
foreach (var fileEntry in files)
{
string relativePath = fileEntry.Key;
@ -166,12 +166,11 @@ namespace StardewModdingAPI.ModBuildConfig
string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/');
// add to zip
using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open())
using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
using Stream fileStreamInZip = archive.CreateEntry(entryName).Open();
fileStream.CopyTo(fileStreamInZip);
}
}
}
/// <summary>Get a copy of a filename with all invalid filename characters substituted.</summary>
/// <param name="name">The filename.</param>

View File

@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" Version="4.13.1" />
<PackageReference Include="Moq" Version="4.14.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NUnit" Version="3.12.0" />
</ItemGroup>

View File

@ -62,8 +62,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
private TResult Post<TBody, TResult>(string url, TBody content)
{
// note: avoid HttpClient for Mac compatibility
using (WebClient client = new WebClient())
{
using WebClient client = new WebClient();
Uri fullUrl = new Uri(this.BaseUrl, url);
string data = JsonConvert.SerializeObject(content);
@ -73,5 +73,4 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings);
}
}
}
}

View File

@ -105,6 +105,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
string pullRequestUrl = this.GetAttribute(node, "data-pr");
IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions");
IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions");
string[] changeUpdateKeys = this.GetAttributeAsCsv(node, "data-change-update-keys");
// parse stable compatibility
WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo
@ -153,6 +154,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
Warnings = warnings,
PullRequestUrl = pullRequestUrl,
DevNote = devNote,
ChangeUpdateKeys = changeUpdateKeys,
MapLocalVersions = mapLocalVersions,
MapRemoteVersions = mapRemoteVersions,
Anchor = anchor

View File

@ -3,25 +3,28 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
/// <summary>The compatibility status for a mod.</summary>
public enum WikiCompatibilityStatus
{
/// <summary>The status is unknown.</summary>
Unknown,
/// <summary>The mod is compatible.</summary>
Ok = 0,
Ok,
/// <summary>The mod is compatible if you use an optional official download.</summary>
Optional = 1,
Optional,
/// <summary>The mod is compatible if you use an unofficial update.</summary>
Unofficial = 2,
Unofficial,
/// <summary>The mod isn't compatible, but the player can fix it or there's a good alternative.</summary>
Workaround = 3,
Workaround,
/// <summary>The mod isn't compatible.</summary>
Broken = 4,
Broken,
/// <summary>The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely.</summary>
Abandoned = 5,
Abandoned,
/// <summary>The mod is no longer needed and should be removed.</summary>
Obsolete = 6
Obsolete
}
}

View File

@ -63,6 +63,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
public string DevNote { get; set; }
/// <summary>Update keys to add (optionally prefixed by '+') or remove (prefixed by '-').</summary>
public string[] ChangeUpdateKeys { get; set; }
/// <summary>Maps local versions to a semantic version for update checks.</summary>
public IDictionary<string, string> MapLocalVersions { get; set; }

View File

@ -124,7 +124,7 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
XElement root;
try
{
using (FileStream stream = file.OpenRead())
using FileStream stream = file.OpenRead();
root = XElement.Load(stream);
}
catch

View File

@ -22,7 +22,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
{
// OS metadata files
new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager
new Regex(@"^(?:__MACOSX|\._\.DS_Store|\.DS_Store|mcs)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS
new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS
new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows
new Regex(@"\.(?:url|lnk)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows shortcut files

View File

@ -1,7 +1,7 @@
namespace StardewModdingAPI.Toolkit.Framework.UpdateData
{
/// <summary>A mod repository which SMAPI can check for updates.</summary>
public enum ModRepositoryKey
/// <summary>A mod site which SMAPI can check for updates.</summary>
public enum ModSiteKey
{
/// <summary>An unknown or invalid mod repository.</summary>
Unknown,

View File

@ -11,12 +11,15 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
/// <summary>The raw update key text.</summary>
public string RawText { get; }
/// <summary>The mod repository containing the mod.</summary>
public ModRepositoryKey Repository { get; }
/// <summary>The mod site containing the mod.</summary>
public ModSiteKey Site { get; }
/// <summary>The mod ID within the repository.</summary>
public string ID { get; }
/// <summary>If specified, a substring in download names/descriptions to match.</summary>
public string Subkey { get; }
/// <summary>Whether the update key seems to be valid.</summary>
public bool LooksValid { get; }
@ -26,53 +29,71 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
*********/
/// <summary>Construct an instance.</summary>
/// <param name="rawText">The raw update key text.</param>
/// <param name="repository">The mod repository containing the mod.</param>
/// <param name="id">The mod ID within the repository.</param>
public UpdateKey(string rawText, ModRepositoryKey repository, string id)
/// <param name="site">The mod site containing the mod.</param>
/// <param name="id">The mod ID within the site.</param>
/// <param name="subkey">If specified, a substring in download names/descriptions to match.</param>
public UpdateKey(string rawText, ModSiteKey site, string id, string subkey)
{
this.RawText = rawText;
this.Repository = repository;
this.ID = id;
this.RawText = rawText?.Trim();
this.Site = site;
this.ID = id?.Trim();
this.Subkey = subkey?.Trim();
this.LooksValid =
repository != ModRepositoryKey.Unknown
site != ModSiteKey.Unknown
&& !string.IsNullOrWhiteSpace(id);
}
/// <summary>Construct an instance.</summary>
/// <param name="repository">The mod repository containing the mod.</param>
/// <param name="id">The mod ID within the repository.</param>
public UpdateKey(ModRepositoryKey repository, string id)
: this($"{repository}:{id}", repository, id) { }
/// <param name="site">The mod site containing the mod.</param>
/// <param name="id">The mod ID within the site.</param>
/// <param name="subkey">If specified, a substring in download names/descriptions to match.</param>
public UpdateKey(ModSiteKey site, string id, string subkey)
: this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { }
/// <summary>Parse a raw update key.</summary>
/// <param name="raw">The raw update key to parse.</param>
public static UpdateKey Parse(string raw)
{
// split parts
string[] parts = raw?.Split(':');
// extract site + ID
string rawSite;
string id;
{
string[] parts = raw?.Trim().Split(':');
if (parts == null || parts.Length != 2)
return new UpdateKey(raw, ModRepositoryKey.Unknown, null);
return new UpdateKey(raw, ModSiteKey.Unknown, null, null);
// extract parts
string repositoryKey = parts[0].Trim();
string id = parts[1].Trim();
rawSite = parts[0].Trim();
id = parts[1].Trim();
}
if (string.IsNullOrWhiteSpace(id))
id = null;
// parse
if (!Enum.TryParse(repositoryKey, true, out ModRepositoryKey repository))
return new UpdateKey(raw, ModRepositoryKey.Unknown, id);
if (id == null)
return new UpdateKey(raw, repository, null);
// extract subkey
string subkey = null;
if (id != null)
{
string[] parts = id.Split('@');
if (parts.Length == 2)
{
id = parts[0].Trim();
subkey = $"@{parts[1]}".Trim();
}
}
return new UpdateKey(raw, repository, id);
// parse
if (!Enum.TryParse(rawSite, true, out ModSiteKey site))
return new UpdateKey(raw, ModSiteKey.Unknown, id, subkey);
if (id == null)
return new UpdateKey(raw, site, null, subkey);
return new UpdateKey(raw, site, id, subkey);
}
/// <summary>Get a string that represents the current object.</summary>
public override string ToString()
{
return this.LooksValid
? $"{this.Repository}:{this.ID}"
? UpdateKey.GetString(this.Site, this.ID, this.Subkey)
: this.RawText;
}
@ -80,10 +101,18 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
/// <param name="other">An object to compare with this object.</param>
public bool Equals(UpdateKey other)
{
if (!this.LooksValid)
{
return
other?.LooksValid == false
&& this.RawText.Equals(other.RawText, StringComparison.OrdinalIgnoreCase);
}
return
other != null
&& this.Repository == other.Repository
&& string.Equals(this.ID, other.ID, StringComparison.InvariantCultureIgnoreCase);
&& this.Site == other.Site
&& string.Equals(this.ID, other.ID, StringComparison.OrdinalIgnoreCase)
&& string.Equals(this.Subkey, other.Subkey, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Determines whether the specified object is equal to the current object.</summary>
@ -97,7 +126,16 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData
/// <returns>A hash code for the current object.</returns>
public override int GetHashCode()
{
return $"{this.Repository}:{this.ID}".ToLower().GetHashCode();
return this.ToString().ToLower().GetHashCode();
}
/// <summary>Get the string representation of an update key.</summary>
/// <param name="site">The mod site containing the mod.</param>
/// <param name="id">The mod ID within the repository.</param>
/// <param name="subkey">If specified, a substring in download names/descriptions to match.</param>
public static string GetString(ModSiteKey site, string id, string subkey = null)
{
return $"{site}:{id}{subkey}".Trim();
}
}
}

View File

@ -30,10 +30,7 @@ namespace StardewModdingAPI.Toolkit.Utilities
/// <summary>Detect the current OS.</summary>
public static Platform DetectPlatform()
{
if (EnvironmentUtility.CachedPlatform == null)
EnvironmentUtility.CachedPlatform = EnvironmentUtility.DetectPlatformImpl();
return EnvironmentUtility.CachedPlatform.Value;
return EnvironmentUtility.CachedPlatform ??= EnvironmentUtility.DetectPlatformImpl();
}

View File

@ -1,33 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
namespace SMAPI.Web.LegacyRedirects.Controllers
{
/// <summary>Provides an API to perform mod update checks.</summary>
[ApiController]
[Produces("application/json")]
[Route("api/v{version}/mods")]
public class ModsApiController : Controller
{
/*********
** Public methods
*********/
/// <summary>Fetch version metadata for the given mods.</summary>
/// <param name="model">The mod search criteria.</param>
[HttpPost]
public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model)
{
using IClient client = new FluentClient("https://smapi.io/api");
Startup.ConfigureJsonNet(client.Formatters.JsonFormatter.SerializerSettings);
return await client
.PostAsync(this.Request.Path)
.WithBody(model)
.AsArray<ModEntryModel>();
}
}
}

View File

@ -1,37 +0,0 @@
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite;
namespace SMAPI.Web.LegacyRedirects.Framework
{
/// <summary>Rewrite requests to prepend the subdomain portion (if any) to the path.</summary>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" />.</remarks>
internal class LambdaRewriteRule : IRule
{
/*********
** Accessors
*********/
/// <summary>Rewrite an HTTP request if needed.</summary>
private readonly Action<RewriteContext, HttpRequest, HttpResponse> Rewrite;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="rewrite">Rewrite an HTTP request if needed.</param>
public LambdaRewriteRule(Action<RewriteContext, HttpRequest, HttpResponse> rewrite)
{
this.Rewrite = rewrite ?? throw new ArgumentNullException(nameof(rewrite));
}
/// <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;
HttpResponse response = context.HttpContext.Response;
this.Rewrite(context, request, response);
}
}
}

View File

@ -1,23 +0,0 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace SMAPI.Web.LegacyRedirects
{
/// <summary>The main app entry point.</summary>
public class Program
{
/*********
** Public methods
*********/
/// <summary>The main app entry point.</summary>
/// <param name="args">The command-line arguments.</param>
public static void Main(string[] args)
{
Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(builder => builder.UseStartup<Startup>())
.Build()
.Run();
}
}
}

View File

@ -1,29 +0,0 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:52756",
"sslPort": 0
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"SMAPI.Web.LegacyRedirects": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "/",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
}
}

View File

@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Content Remove="aws-beanstalk-tools-defaults.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" />
<ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" />
</ItemGroup>
</Project>

View File

@ -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>

View File

@ -275,21 +275,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 +303,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 +317,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"
};
}
}
}

View File

@ -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()
// get unique update keys
List<UpdateKey> updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry)
.Select(UpdateKey.Parse)
.Distinct()
.ToList();
// 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);
}
// if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority
{
var removeKeys = new HashSet<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);
}
return updateKeys;
}
/// <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)
{
// specified update keys
if (specifiedKeys != null)
foreach (string key in specifiedKeys ?? Array.Empty<string>())
{
foreach (string key in specifiedKeys)
yield return key?.Trim();
if (!string.IsNullOrWhiteSpace(key))
yield return key.Trim();
}
// default update key
{
string defaultKey = record?.GetDefaultUpdateKey();
if (defaultKey != null)
if (!string.IsNullOrWhiteSpace(defaultKey))
yield return defaultKey;
}
// wiki metadata
if (entry != null)
{
if (entry.NexusID.HasValue)
yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}";
yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString());
if (entry.ModDropID.HasValue)
yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}";
yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString());
if (entry.CurseForgeID.HasValue)
yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}";
yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString());
if (entry.ChucklefishID.HasValue)
yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}";
}
yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString());
}
HashSet<UpdateKey> seen = new HashSet<UpdateKey>();
foreach (string rawKey in GetRaw())
// overrides from wiki
foreach (string key in entry?.ChangeUpdateKeys ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(rawKey))
continue;
UpdateKey key = UpdateKey.Parse(rawKey);
if (seen.Add(key))
if (key.StartsWith('+'))
yield return key.Substring(1);
else if (!key.StartsWith("-"))
yield return key;
}
}
/// <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;
}
/// <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 (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();
}
}
return version;
}
}
}

View File

@ -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)

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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
{
@ -19,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
private readonly IClient Client;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.Chucklefish;
/*********
** Public methods
*********/
@ -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>

View File

@ -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; }
}
}

View File

@ -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 { }
}

View File

@ -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
@ -20,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled);
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.CurseForge;
/*********
** Public methods
*********/
@ -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;
}
downloads.Add(
new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file))
);
}
// 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.";
}
// 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>

View File

@ -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; }
}
}

View File

@ -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 { }
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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
{
@ -16,6 +17,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub
private readonly IClient Client;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.GitHub;
/*********
** Public methods
*********/
@ -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()
{

View File

@ -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

View File

@ -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);
}
}

View File

@ -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 { }
}

View File

@ -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
@ -18,6 +19,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
private readonly string ModUrlFormat;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.ModDrop;
/*********
** Public methods
*********/
@ -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;
downloads.Add(
new GenericModDownload(file.Name, file.Description, file.Version)
);
}
if (file.IsDefault)
{
if (latest == null || version.IsNewerThan(latest))
latest = version;
}
else if (optional == null || version.IsNewerThan(optional))
optional = 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>

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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 { }
}

View File

@ -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
@ -30,6 +32,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
private readonly FluentNexusClient ApiClient;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.Nexus;
/*********
** Public methods
*********/
@ -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);
return mod;
// 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 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()
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
downloads.Add(
new GenericModDownload(fileName, description, fileVersion)
);
}
// 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;
latestFileVersion = cur;
}
// 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()
};
}

View File

@ -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; }
}

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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; }
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View File

@ -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
};
}
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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;
}
}
}

View File

@ -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)
.ConfigureWebHostDefaults(builder => builder
.CaptureStartupErrors(true)
.UseSetting("detailedErrors", "true")
.UseKestrel().UseIISIntegration() // must be used together; fixes intermittent errors on Azure: https://stackoverflow.com/a/38312175/262123
.UseStartup<Startup>()
)
.Build()
.Run();
}

View File

@ -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" />

View File

@ -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,22 +62,33 @@ 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);
.AddMemoryCache();
options.SerializerSettings.Formatting = Formatting.Indented;
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
// init MVC
services
.AddControllers()
.AddNewtonsoftJson(options => this.ConfigureJsonNet(options.SerializerSettings))
.ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider()));
services
.AddRazorPages();
// init storage
services.AddSingleton<IModCacheRepository>(new ModCacheMemoryRepository());
services.AddSingleton<IWikiCacheRepository>(new WikiCacheMemoryRepository());
// init Hangfire
services
.AddHangfire((serv, config) =>
{
config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseMemoryStorage();
});
MongoDbConfig mongoConfig = this.Configuration.GetSection("MongoDB").Get<MongoDbConfig>();
// init background service
{
@ -91,46 +97,6 @@ namespace StardewModdingAPI.Web
services.AddHostedService<BackgroundService>();
}
// 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;
// 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 Hangfire
services
.AddHangfire(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();
});
// 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")
));
.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;
}
// redirect legacy canimod.com URLs
/// <summary>Get the redirects for legacy paths that have been moved elsewhere.</summary>
private IDictionary<string, string> GetLegacyPathRedirects()
{
var redirects = new Dictionary<string, string>();
// 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;

View File

@ -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;
/*********

View File

@ -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))
{

View File

@ -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>
}

View File

@ -32,7 +32,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 +40,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>
}

View File

@ -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>

View File

@ -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 = true; // Model.BetaVersion != null;
string betaLabel = "SMAPI 3.6 only"; //"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>

View File

@ -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>
}

View File

@ -17,11 +17,6 @@
"NexusApiKey": null
},
"MongoDB": {
"ConnectionString": null,
"Database": "smapi-edge"
},
"BackgroundServices": {
"Enabled": true
}

View File

@ -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"
},

View File

@ -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;
}

View File

@ -1,5 +1,3 @@
/* globals $ */
var smapi = smapi || {};
var app;
smapi.modList = function (mods, enableBeta) {

View File

@ -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": {

View File

@ -58,6 +58,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=stackable/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Stardew/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdomain/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=subkey/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=synchronised/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=textbox/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=thumbstick/@EntryIndexedValue">True</s:Boolean>

View File

@ -5,9 +5,12 @@ namespace StardewModdingAPI.Events
/// <summary>Events raised for multiplayer messages and connections.</summary>
public interface IMultiplayerEvents
{
/// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
/// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived;
/// <summary>Raised after a peer connection is approved by the game.</summary>
event EventHandler<PeerConnectedEventArgs> PeerConnected;
/// <summary>Raised after a mod message is received over the network.</summary>
event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived;

View File

@ -0,0 +1,25 @@
using System;
namespace StardewModdingAPI.Events
{
/// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerConnected"/> event.</summary>
public class PeerConnectedEventArgs : EventArgs
{
/*********
** Accessors
*********/
/// <summary>The peer whose metadata was received.</summary>
public IMultiplayerPeer Peer { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="peer">The peer whose metadata was received.</param>
internal PeerConnectedEventArgs(IMultiplayerPeer peer)
{
this.Peer = peer;
}
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using StardewModdingAPI.Framework.Commands;
namespace StardewModdingAPI.Framework
{
@ -27,7 +28,7 @@ namespace StardewModdingAPI.Framework
/// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception>
/// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception>
/// <exception cref="ArgumentException">There's already a command with that name.</exception>
public void Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false)
public CommandManager Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false)
{
name = this.GetNormalizedName(name);
@ -45,6 +46,16 @@ namespace StardewModdingAPI.Framework
// add command
this.Commands.Add(name, new Command(mod, name, documentation, callback));
return this;
}
/// <summary>Add a console command.</summary>
/// <param name="command">the SMAPI console command to add.</param>
/// <param name="monitor">Writes messages to the console.</param>
/// <exception cref="ArgumentException">There's already a command with that name.</exception>
public CommandManager Add(IInternalCommand command, IMonitor monitor)
{
return this.Add(null, command.Name, command.Description, (name, args) => command.HandleCommand(args, monitor));
}
/// <summary>Get a command by its unique name.</summary>

View File

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using HarmonyLib;
namespace StardewModdingAPI.Framework.Commands
{
/// <summary>The 'harmony_summary' SMAPI console command.</summary>
internal class HarmonySummaryCommand : IInternalCommand
{
/*********
** Accessors
*********/
/// <summary>The command name, which the user must type to trigger it.</summary>
public string Name { get; } = "harmony_summary";
/// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary>
public string Description { get; } = "Harmony is a library which rewrites game code, used by SMAPI and some mods. This command lists current Harmony patches.\n\nUsage: harmony_summary\nList all Harmony patches.\n\nUsage: harmony_summary <search>\n- search: one more more words to search. If any word matches a method name, the method and all its patchers will be listed; otherwise only matching patchers will be listed for the method.";
/*********
** Public methods
*********/
/// <summary>Handle the console command when it's entered by the user.</summary>
/// <param name="args">The command arguments.</param>
/// <param name="monitor">Writes messages to the console.</param>
public void HandleCommand(string[] args, IMonitor monitor)
{
SearchResult[] matches = this.FilterPatches(args).OrderBy(p => p.Method).ToArray();
StringBuilder result = new StringBuilder();
if (!matches.Any())
result.AppendLine("No current patches match your search.");
else
{
result.AppendLine(args.Any() ? "Harmony patches which match your search terms:" : "Current Harmony patches:");
result.AppendLine();
foreach (var match in matches)
{
result.AppendLine($" {match.Method}");
foreach (var ownerGroup in match.PatchTypesByOwner)
{
var sortedTypes = ownerGroup.Value
.OrderBy(p => p switch { PatchType.Prefix => 0, PatchType.Postfix => 1, PatchType.Finalizer => 2, PatchType.Transpiler => 3, _ => 4 });
result.AppendLine($" - {ownerGroup.Key} ({string.Join(", ", sortedTypes).ToLower()})");
}
}
}
monitor.Log(result.ToString(), LogLevel.Info);
}
/*********
** Private methods
*********/
/// <summary>Get all current Harmony patches matching any of the given search terms.</summary>
/// <param name="searchTerms">The search terms to match.</param>
private IEnumerable<SearchResult> FilterPatches(string[] searchTerms)
{
bool hasSearch = searchTerms.Any();
bool IsMatch(string target) => searchTerms.Any(search => target != null && target.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1);
foreach (var patch in this.GetAllPatches())
{
if (!hasSearch)
yield return patch;
// matches entire patch
if (IsMatch(patch.Method))
{
yield return patch;
continue;
}
// matches individual patchers
foreach (var pair in patch.PatchTypesByOwner.ToArray())
{
if (!IsMatch(pair.Key) && !pair.Value.Any(type => IsMatch(type.ToString())))
patch.PatchTypesByOwner.Remove(pair.Key);
}
if (patch.PatchTypesByOwner.Any())
yield return patch;
}
}
/// <summary>Get all current Harmony patches.</summary>
private IEnumerable<SearchResult> GetAllPatches()
{
foreach (MethodBase method in Harmony.GetAllPatchedMethods())
{
// get metadata for method
string methodLabel = method.FullDescription();
HarmonyLib.Patches patchInfo = Harmony.GetPatchInfo(method);
IDictionary<PatchType, IReadOnlyCollection<Patch>> patchGroups = new Dictionary<PatchType, IReadOnlyCollection<Patch>>
{
[PatchType.Prefix] = patchInfo.Prefixes,
[PatchType.Postfix] = patchInfo.Postfixes,
[PatchType.Finalizer] = patchInfo.Finalizers,
[PatchType.Transpiler] = patchInfo.Transpilers
};
// get patch types by owner
var typesByOwner = new Dictionary<string, ISet<PatchType>>();
foreach (var group in patchGroups)
{
foreach (var patch in group.Value)
{
if (!typesByOwner.TryGetValue(patch.owner, out ISet<PatchType> patchTypes))
typesByOwner[patch.owner] = patchTypes = new HashSet<PatchType>();
patchTypes.Add(group.Key);
}
}
// create search result
yield return new SearchResult(methodLabel, typesByOwner);
}
}
/// <summary>A Harmony patch type.</summary>
private enum PatchType
{
/// <summary>A prefix patch.</summary>
Prefix,
/// <summary>A postfix patch.</summary>
Postfix,
/// <summary>A finalizer patch.</summary>
Finalizer,
/// <summary>A transpiler patch.</summary>
Transpiler
}
/// <summary>A patch search result for a method.</summary>
private class SearchResult
{
/*********
** Accessors
*********/
/// <summary>A detailed human-readable label for the patched method.</summary>
public string Method { get; }
/// <summary>The patch types by the Harmony instance ID that added them.</summary>
public IDictionary<string, ISet<PatchType>> PatchTypesByOwner { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="method">A detailed human-readable label for the patched method.</param>
/// <param name="patchTypesByOwner">The patch types by the Harmony instance ID that added them.</param>
public SearchResult(string method, IDictionary<string, ISet<PatchType>> patchTypesByOwner)
{
this.Method = method;
this.PatchTypesByOwner = patchTypesByOwner;
}
}
}
}

View File

@ -0,0 +1,64 @@
using System.Linq;
namespace StardewModdingAPI.Framework.Commands
{
/// <summary>The 'help' SMAPI console command.</summary>
internal class HelpCommand : IInternalCommand
{
/*********
** Fields
*********/
/// <summary>Manages console commands.</summary>
private readonly CommandManager CommandManager;
/*********
** Accessors
*********/
/// <summary>The command name, which the user must type to trigger it.</summary>
public string Name { get; } = "help";
/// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary>
public string Description { get; } = "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.";
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="commandManager">Manages console commands.</param>
public HelpCommand(CommandManager commandManager)
{
this.CommandManager = commandManager;
}
/// <summary>Handle the console command when it's entered by the user.</summary>
/// <param name="args">The command arguments.</param>
/// <param name="monitor">Writes messages to the console.</param>
public void HandleCommand(string[] args, IMonitor monitor)
{
if (args.Any())
{
Command result = this.CommandManager.Get(args[0]);
if (result == null)
monitor.Log("There's no command with that name.", LogLevel.Error);
else
monitor.Log($"{result.Name}: {result.Documentation}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info);
}
else
{
string message = "The following commands are registered:\n";
IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray();
foreach (var group in groups)
{
string modName = group.Key ?? "SMAPI";
string[] commandNames = group.ToArray();
message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n";
}
message += "For more information about a command, type 'help command_name'.";
monitor.Log(message, LogLevel.Info);
}
}
}
}

View File

@ -0,0 +1,24 @@
namespace StardewModdingAPI.Framework.Commands
{
/// <summary>A core SMAPI console command.</summary>
interface IInternalCommand
{
/*********
** Accessors
*********/
/// <summary>The command name, which the user must type to trigger it.</summary>
string Name { get; }
/// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary>
string Description { get; }
/*********
** Methods
*********/
/// <summary>Handle the console command when it's entered by the user.</summary>
/// <param name="args">The command arguments.</param>
/// <param name="monitor">Writes messages to the console.</param>
void HandleCommand(string[] args, IMonitor monitor);
}
}

View File

@ -0,0 +1,44 @@
using System;
namespace StardewModdingAPI.Framework.Commands
{
/// <summary>The 'reload_i18n' SMAPI console command.</summary>
internal class ReloadI18nCommand : IInternalCommand
{
/*********
** Fields
*********/
/// <summary>Reload translations for all mods.</summary>
private readonly Action ReloadTranslations;
/*********
** Accessors
*********/
/// <summary>The command name, which the user must type to trigger it.</summary>
public string Name { get; } = "reload_i18n";
/// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary>
public string Description { get; } = "Reloads translation files for all mods.\n\nUsage: reload_i18n";
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="reloadTranslations">Reload translations for all mods..</param>
public ReloadI18nCommand(Action reloadTranslations)
{
this.ReloadTranslations = reloadTranslations;
}
/// <summary>Handle the console command when it's entered by the user.</summary>
/// <param name="args">The command arguments.</param>
/// <param name="monitor">Writes messages to the console.</param>
public void HandleCommand(string[] args, IMonitor monitor)
{
this.ReloadTranslations();
monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info);
}
}
}

View File

@ -109,9 +109,12 @@ namespace StardewModdingAPI.Framework.Events
/****
** Multiplayer
****/
/// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
/// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
public readonly ManagedEvent<PeerContextReceivedEventArgs> PeerContextReceived;
/// <summary>Raised after a peer connection is approved by the game.</summary>
public readonly ManagedEvent<PeerConnectedEventArgs> PeerConnected;
/// <summary>Raised after a mod message is received over the network.</summary>
public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived;
@ -218,6 +221,7 @@ namespace StardewModdingAPI.Framework.Events
this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled));
this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived));
this.PeerConnected = ManageEventOf<PeerConnectedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerConnected));
this.ModMessageReceived = ManageEventOf<ModMessageReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.ModMessageReceived));
this.PeerDisconnected = ManageEventOf<PeerDisconnectedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerDisconnected));

View File

@ -9,13 +9,20 @@ namespace StardewModdingAPI.Framework.Events
/*********
** Accessors
*********/
/// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
/// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
public event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived
{
add => this.EventManager.PeerContextReceived.Add(value);
remove => this.EventManager.PeerContextReceived.Remove(value);
}
/// <summary>Raised after a peer connection is approved by the game.</summary>
public event EventHandler<PeerConnectedEventArgs> PeerConnected
{
add => this.EventManager.PeerConnected.Add(value);
remove => this.EventManager.PeerConnected.Remove(value);
}
/// <summary>Raised after a mod message is received over the network.</summary>
public event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived
{

Some files were not shown because too many files have changed in this diff Show More