Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2020-06-20 12:43:08 -04:00
commit e64ecc89f9
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
190 changed files with 3850 additions and 3157 deletions

3
.gitignore vendored
View File

@ -30,8 +30,5 @@ _ReSharper*/
# sensitive files # sensitive files
appsettings.Development.json appsettings.Development.json
# AWS generated files
src/SMAPI.Web.LegacyRedirects/aws-beanstalk-tools-defaults.json
# Azure generated files # Azure generated files
src/SMAPI.Web/Properties/PublishProfiles/*.pubxml src/SMAPI.Web/Properties/PublishProfiles/*.pubxml

BIN
build/0Harmony.dll Normal file

Binary file not shown.

View File

@ -4,9 +4,10 @@
<!--set properties --> <!--set properties -->
<PropertyGroup> <PropertyGroup>
<Version>3.5.0</Version> <Version>3.6.0</Version>
<Product>SMAPI</Product> <Product>SMAPI</Product>
<LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
<DefineConstants Condition="$(OS) == 'Windows_NT'">$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants> <DefineConstants Condition="$(OS) == 'Windows_NT'">$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants>
</PropertyGroup> </PropertyGroup>

View File

@ -1,6 +1,54 @@
&larr; [README](README.md) &larr; [README](README.md)
# Release notes # Release notes
<!--
## Future release
* 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).
-->
## 3.6
Released 20 June 2020 for Stardew Valley 1.4.1 or later.
* For players:
* Added crossplatform compatibility for mods which use the `[HarmonyPatch(type)]` attribute.
* Added experimental option to reduce startup time when loading mod DLLs (thanks to ZaneYork!). Enable `RewriteInParallel` in the `smapi-internal/config.json` to try it.
* Reduced processing time when a mod loads many unpacked images (thanks to Entoarox!).
* Mod load 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 black maps on Android for mods which use `.tmx` files.
* Fixed `BadImageFormatException` error detection.
* Fixed `reload_i18n` command not reloading content pack translations.
* For the web UI:
* Added GitHub licenses to mod compatibility list.
* Improved JSON validator:
* added SMAPI `i18n` schema;
* editing an uploaded file now remembers the selected schema;
* changed default schema to plain JSON.
* Updated ModDrop URLs.
* Internal changes to improve performance and reliability.
* For modders:
* Added [event priorities](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events#Custom_priority) (thanks to spacechase0!).
* Added [update subkeys](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Update_checks#Update_subkeys).
* Added [a custom build of Harmony](https://github.com/Pathoschild/Harmony#readme) to provide more useful stack traces in error logs.
* Added `harmony_summary` console command to list or search current Harmony patches.
* Added `Multiplayer.PeerConnected` event.
* Added support for overriding update keys from the wiki compatibility list.
* Improved mod rewriting for compatibility to support more cases (e.g. custom attributes and generic types).
* Fixed `helper.Reflection` blocking access to game methods/properties intercepted by SMAPI.
* Fixed asset propagation for Gil's portraits.
* Fixed `.pdb` files ignored for error stack traces when mods are rewritten by SMAPI.
* Fixed `ModMessageReceived` event handlers not tracked for performance monitoring.
* 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 mod site integrations, centralize common logic, and enable upcoming features.
* Merged the separate legacy redirects app on AWS into the main app on Azure.
* Changed SMAPI's Harmony ID from `io.smapi` to `SMAPI` for readability in Harmony summaries.
## 3.5 ## 3.5
Released 27 April 2020 for Stardew Valley 1.4.1 or later. Released 27 April 2020 for Stardew Valley 1.4.1 or later.

View File

@ -15,6 +15,7 @@ This document is about SMAPI itself; see also [mod build package](mod-package.md
* [Compiling from source](#compiling-from-source) * [Compiling from source](#compiling-from-source)
* [Debugging a local build](#debugging-a-local-build) * [Debugging a local build](#debugging-a-local-build)
* [Preparing a release](#preparing-a-release) * [Preparing a release](#preparing-a-release)
* [Using a custom Harmony build](#using-a-custom-harmony-build)
* [Release notes](#release-notes) * [Release notes](#release-notes)
## Customisation ## Customisation
@ -57,24 +58,22 @@ SMAPI uses a small number of conditional compilation constants, which you can se
flag | purpose flag | purpose
---- | ------- ---- | -------
`SMAPI_FOR_WINDOWS` | Whether SMAPI is being compiled on Windows for players on Windows. Set automatically in `crossplatform.targets`. `SMAPI_FOR_WINDOWS` | Whether SMAPI is being compiled on Windows for players on Windows. Set automatically in `crossplatform.targets`.
`HARMONY_2` | Whether to enable experimental Harmony 2.0 support and rewrite existing Harmony 1._x_ mods for compatibility. Note that you need to replace `build/0Harmony.dll` with a Harmony 2.0 build (or switch to a package reference) to use this flag.
## For SMAPI developers ## For SMAPI developers
### Compiling from source ### Compiling from source
Using an official SMAPI release is recommended for most users. Using an official SMAPI release is recommended for most users, but you can compile from source
directly if needed. There are no special steps (just open the project and compile), but SMAPI often
uses the latest C# syntax. You may need the latest version of your IDE to compile it.
SMAPI often uses the latest C# syntax. You may need the latest version of SMAPI uses build configuration derived from the [crossplatform mod config](https://smapi.io/package/readme)
[Visual Studio](https://www.visualstudio.com/vs/community/) on Windows, to detect your current OS automatically and load the correct references. Compile output will be
[MonoDevelop](https://www.monodevelop.com/) on Linux, placed in a `bin` folder at the root of the Git repository.
[Visual Studio for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent IDE
to compile it. It uses build configuration derived from the
[crossplatform mod config](https://smapi.io/package/readme) to detect your current OS automatically
and load the correct references. Compile output will be placed in a `bin` folder at the root of the
git repository.
### Debugging a local build ### Debugging a local build
Rebuilding the solution in debug mode will copy the SMAPI files into your game folder. Starting Rebuilding the solution in debug mode will copy the SMAPI files into your game folder. Starting
the `SMAPI` project with debugging from Visual Studio (on Mac or Windows) will launch SMAPI with the `SMAPI` project with debugging from Visual Studio (on Mac or Windows) will launch SMAPI with
the debugger attached, so you can intercept errors and step through the code being executed. This the debugger attached, so you can intercept errors and step through the code being executed. That
doesn't work in MonoDevelop on Linux, unfortunately. doesn't work in MonoDevelop on Linux, unfortunately.
### Preparing a release ### Preparing a release
@ -82,14 +81,14 @@ To prepare a crossplatform SMAPI release, you'll need to compile it on two platf
[crossplatforming info](https://stardewvalleywiki.com/Modding:Modder_Guide/Test_and_Troubleshoot#Testing_on_all_platforms) [crossplatforming info](https://stardewvalleywiki.com/Modding:Modder_Guide/Test_and_Troubleshoot#Testing_on_all_platforms)
on the wiki for the first-time setup. on the wiki for the first-time setup.
1. Update the version number in `.root/build/common.targets` and `Constants::Version`. Make sure 1. Update the version numbers in `build/common.targets`, `Constants`, and the `manifest.json` for
you use a [semantic version](https://semver.org). Recommended format: bundled mods. Make sure you use a [semantic version](https://semver.org). Recommended format:
build type | format | example build type | format | example
:--------- | :----------------------- | :------ :--------- | :----------------------- | :------
dev build | `<version>-alpha.<date>` | `3.0-alpha.20171230` dev build | `<version>-alpha.<date>` | `3.0.0-alpha.20171230`
prerelease | `<version>-beta.<count>` | `3.0-beta.2` prerelease | `<version>-beta.<count>` | `3.0.0-beta.2`
release | `<version>` | `3.0` release | `<version>` | `3.0.0`
2. In Windows: 2. In Windows:
1. Rebuild the solution in Release mode. 1. Rebuild the solution in Release mode.
@ -103,5 +102,10 @@ on the wiki for the first-time setup.
3. Rename the folders to `SMAPI <version> installer` and `SMAPI <version> installer for developers`. 3. Rename the folders to `SMAPI <version> installer` and `SMAPI <version> installer for developers`.
4. Zip the two folders. 4. Zip the two folders.
### Custom Harmony build
SMAPI uses [a custom build of Harmony](https://github.com/Pathoschild/Harmony#readme), which is
included in the `build` folder. To use a different build, just replace `0Harmony.dll` in that
folder.
## Release notes ## Release notes
See [release notes](../release-notes.md). See [release notes](../release-notes.md).

View File

@ -110,8 +110,9 @@ Available schemas:
format | schema URL format | schema URL
------ | ---------- ------ | ----------
[SMAPI `manifest.json`](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest) | https://smapi.io/schemas/manifest.json [SMAPI: `manifest.json`](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest) | https://smapi.io/schemas/manifest.json
[Content Patcher `content.json`](https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme) | https://smapi.io/schemas/content-patcher.json [SMAPI: translations (`i18n` folder)](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation) | https://smapi.io/schemas/i18n.json
[Content Patcher: `content.json`](https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme) | https://smapi.io/schemas/content-patcher.json
## Web API ## Web API
### Overview ### Overview
@ -340,9 +341,19 @@ short url | → | target page
A local environment lets you run a complete copy of the web project (including cache database) on 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. 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 1. Edit `appsettings.Development.json` and set these options:
credentials empty to default to fetching data anonymously, and storing data in-memory and
on disk. 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. 2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
### Production environment ### Production environment
@ -355,19 +366,15 @@ accordingly.
Initial setup: Initial setup:
1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)) 1. Create an Azure Blob storage account for uploaded files.
for mod data. 2. Create an Azure App Services environment running the latest .NET Core on Linux or Windows.
2. Create an Azure Blob storage account for uploaded files. 3. Add these application settings in the new App Services environment:
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:
property name | description property name | description
------------------------------- | ----------------- ------------------------------- | -----------------
`ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2. `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.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). `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: Optional settings:
@ -378,6 +385,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: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. `Site:SupporterList` | A list of Patreon supports to credit on the download page.
To deploy updates: To deploy updates, just [redeploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure).
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.)

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using Microsoft.Win32; using Microsoft.Win32;
using StardewModdingApi.Installer.Enums; using StardewModdingApi.Installer.Enums;
using StardewModdingAPI.Installer.Framework; using StardewModdingAPI.Installer.Framework;
@ -624,7 +623,7 @@ namespace StardewModdingApi.Installer
{ {
try 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; break;
} }
catch (Exception ex) 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> /// <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="print">A callback which prints a message to the console.</param>
/// <param name="message">The message to print.</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> /// <param name="indent">The indentation to prefix to output.</param>
private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string> print = null) private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string> print = null)
{ {
print = print ?? this.PrintInfo; print ??= this.PrintInfo;
while (true) while (true)
{ {

View File

@ -1,11 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>SMAPI.Installer</AssemblyName>
<RootNamespace>StardewModdingAPI.Installer</RootNamespace> <RootNamespace>StardewModdingAPI.Installer</RootNamespace>
<Description>The SMAPI installer for players.</Description> <Description>The SMAPI installer for players.</Description>
<TargetFramework>net45</TargetFramework> <TargetFramework>net45</TargetFramework>
<LangVersion>latest</LangVersion>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<PlatformTarget>x86</PlatformTarget> <PlatformTarget>x86</PlatformTarget>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@ -16,13 +13,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="assets\*"> <None Update="assets\*" CopyToOutputDirectory="PreserveNewest" />
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
<Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\common.targets" />
<Import Project="..\..\build\prepare-install-package.targets" /> <Import Project="..\..\build\prepare-install-package.targets" />
</Project> </Project>

View File

@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" /> <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="NUnit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.16.1"> <PackageReference Include="NUnit3TestAdapter" Version="3.16.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@ -1,11 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>SMAPI.ModBuildConfig.Analyzer</AssemblyName>
<RootNamespace>StardewModdingAPI.ModBuildConfig.Analyzer</RootNamespace> <RootNamespace>StardewModdingAPI.ModBuildConfig.Analyzer</RootNamespace>
<Version>3.0.0</Version> <Version>3.0.0</Version>
<TargetFramework>netstandard2.0</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<IncludeBuildOutput>false</IncludeBuildOutput> <IncludeBuildOutput>false</IncludeBuildOutput>
<OutputPath>bin</OutputPath> <OutputPath>bin</OutputPath>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
@ -19,5 +16,4 @@
<ItemGroup> <ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -153,23 +153,22 @@ namespace StardewModdingAPI.ModBuildConfig
// create zip file // create zip file
Directory.CreateDirectory(outputFolderPath); Directory.CreateDirectory(outputFolderPath);
using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write);
using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create);
foreach (var fileEntry in files)
{ {
foreach (var fileEntry in files) string relativePath = fileEntry.Key;
{ FileInfo file = fileEntry.Value;
string relativePath = fileEntry.Key;
FileInfo file = fileEntry.Value;
// get file info // get file info
string filePath = file.FullName; string filePath = file.FullName;
string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/');
// add to zip // add to zip
using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open()) using Stream fileStreamInZip = archive.CreateEntry(entryName).Open();
fileStream.CopyTo(fileStreamInZip); fileStream.CopyTo(fileStreamInZip);
}
} }
} }

View File

@ -1,24 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>SMAPI.ModBuildConfig</AssemblyName>
<RootNamespace>StardewModdingAPI.ModBuildConfig</RootNamespace> <RootNamespace>StardewModdingAPI.ModBuildConfig</RootNamespace>
<Version>3.0.0</Version> <Version>3.1.0</Version>
<TargetFramework>net45</TargetFramework> <TargetFramework>net45</TargetFramework>
<LangVersion>latest</LangVersion>
<PlatformTarget>x86</PlatformTarget> <PlatformTarget>x86</PlatformTarget>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\build\find-game-folder.targets" Link="build\find-game-folder.targets" />
<None Include="..\..\docs\technical\mod-package.md" Link="mod-build-config.md" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Reference Include="Microsoft.Build" /> <Reference Include="Microsoft.Build" />
<Reference Include="Microsoft.Build.Framework" /> <Reference Include="Microsoft.Build.Framework" />
@ -28,19 +16,16 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\docs\technical\mod-package.md"> <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" />
<Link>mod-package.md</Link>
</None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="assets\nuget-icon.png"> <None Include="..\..\build\find-game-folder.targets" Link="build\find-game-folder.targets" />
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <None Include="..\..\docs\technical\mod-package.md" Link="mod-package.md" />
</None> <None Update="assets\nuget-icon.png" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\common.targets" />
<Import Project="..\..\build\prepare-nuget-package.targets" /> <Import Project="..\..\build\prepare-nuget-package.targets" />
</Project> </Project>

View File

@ -38,58 +38,26 @@
**********************************************--> **********************************************-->
<!-- common --> <!-- common -->
<ItemGroup> <ItemGroup>
<Reference Include="$(GameExecutableName)"> <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="$(CopyModReferencesToBuildOutput)" />
<HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(CopyModReferencesToBuildOutput)" />
<Private>$(CopyModReferencesToBuildOutput)</Private> <Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.exe" Private="$(CopyModReferencesToBuildOutput)" />
</Reference> <Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(CopyModReferencesToBuildOutput)" />
<Reference Include="StardewValley.GameData"> <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(CopyModReferencesToBuildOutput)" />
<HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath> <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(CopyModReferencesToBuildOutput)" />
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
<Reference Include="StardewModdingAPI">
<HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath>
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
<Reference Include="SMAPI.Toolkit.CoreInterfaces">
<HintPath>$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll</HintPath>
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
<Reference Include="xTile">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
<Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'">
<HintPath>$(GamePath)\smapi-internal\0Harmony.dll</HintPath>
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
</ItemGroup> </ItemGroup>
<!-- Windows --> <!-- Windows -->
<ItemGroup Condition="$(OS) == 'Windows_NT'"> <ItemGroup Condition="$(OS) == 'Windows_NT'">
<Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" />
<Private>$(CopyModReferencesToBuildOutput)</Private> <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" />
</Reference> <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" />
<Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" />
<Private>$(CopyModReferencesToBuildOutput)</Private> <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="$(CopyModReferencesToBuildOutput)" />
</Reference>
<Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
<Reference Include="Netcode">
<HintPath>$(GamePath)\Netcode.dll</HintPath>
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
</ItemGroup> </ItemGroup>
<!-- Linux/Mac --> <!-- Linux/Mac -->
<ItemGroup Condition="$(OS) != 'Windows_NT'"> <ItemGroup Condition="$(OS) != 'Windows_NT'">
<Reference Include="MonoGame.Framework"> <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(CopyModReferencesToBuildOutput)" />
<HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
<Private>$(CopyModReferencesToBuildOutput)</Private>
</Reference>
</ItemGroup> </ItemGroup>

View File

@ -1,72 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>ConsoleCommands</AssemblyName> <AssemblyName>ConsoleCommands</AssemblyName>
<RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace> <RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace>
<TargetFramework>net45</TargetFramework> <TargetFramework>net45</TargetFramework>
<LangVersion>latest</LangVersion>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<PlatformTarget>x86</PlatformTarget> <PlatformTarget>x86</PlatformTarget>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SMAPI\SMAPI.csproj"> <ProjectReference Include="..\SMAPI\SMAPI.csproj" Private="False" />
<Private>False</Private>
</ProjectReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Reference Include="$(GameExecutableName)"> <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" />
<HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="False" />
<Private>False</Private>
</Reference>
<Reference Include="StardewValley.GameData">
<HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup> </ItemGroup>
<Choose> <Choose>
<!-- Windows --> <!-- Windows -->
<When Condition="$(OS) == 'Windows_NT'"> <When Condition="$(OS) == 'Windows_NT'">
<ItemGroup> <ItemGroup>
<Reference Include="Netcode"> <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="False" />
<HintPath>$(GamePath)\Netcode.dll</HintPath> <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
<Private>False</Private> <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
</Reference> <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
<Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
</ItemGroup> </ItemGroup>
</When> </When>
<!-- Linux/Mac --> <!-- Linux/Mac -->
<Otherwise> <Otherwise>
<ItemGroup> <ItemGroup>
<Reference Include="MonoGame.Framework"> <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="False" />
<HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup> </ItemGroup>
</Otherwise> </Otherwise>
</Choose> </Choose>
<ItemGroup> <ItemGroup>
<None Update="manifest.json"> <None Update="manifest.json" CopyToOutputDirectory="PreserveNewest" />
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
<Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\common.targets" />
</Project> </Project>

View File

@ -1,9 +1,9 @@
{ {
"Name": "Console Commands", "Name": "Console Commands",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.5.0", "Version": "3.6.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.", "Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands", "UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll", "EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.5.0" "MinimumApiVersion": "3.6.0"
} }

View File

@ -1,34 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>SaveBackup</AssemblyName> <AssemblyName>SaveBackup</AssemblyName>
<RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace> <RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace>
<TargetFramework>net45</TargetFramework> <TargetFramework>net45</TargetFramework>
<LangVersion>latest</LangVersion>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<PlatformTarget>x86</PlatformTarget> <PlatformTarget>x86</PlatformTarget>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SMAPI\SMAPI.csproj"> <ProjectReference Include="..\SMAPI\SMAPI.csproj" Private="False" />
<Private>False</Private>
</ProjectReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Reference Include="$(GameExecutableName)"> <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" />
<HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="manifest.json"> <None Update="manifest.json" CopyToOutputDirectory="PreserveNewest" />
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
<Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\common.targets" />
</Project> </Project>

View File

@ -1,9 +1,9 @@
{ {
"Name": "Save Backup", "Name": "Save Backup",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.5.0", "Version": "3.6.0",
"Description": "Automatically backs up all your saves once per day into its folder.", "Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup", "UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll", "EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.5.0" "MinimumApiVersion": "3.6.0"
} }

View File

@ -16,7 +16,7 @@
</ItemGroup> </ItemGroup>
<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="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit" Version="3.12.0" />
</ItemGroup> </ItemGroup>

View File

@ -1,15 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>SMAPI.Toolkit.CoreInterfaces</AssemblyName>
<RootNamespace>StardewModdingAPI</RootNamespace> <RootNamespace>StardewModdingAPI</RootNamespace>
<Description>Provides toolkit interfaces which are available to SMAPI mods.</Description> <Description>Provides toolkit interfaces which are available to SMAPI mods.</Description>
<TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.CoreInterfaces.xml</DocumentationFile>
<PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget>
</PropertyGroup> </PropertyGroup>
<Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\common.targets" />
</Project> </Project>

View File

@ -62,16 +62,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
private TResult Post<TBody, TResult>(string url, TBody content) private TResult Post<TBody, TResult>(string url, TBody content)
{ {
// note: avoid HttpClient for Mac compatibility // 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);
client.Headers["Content-Type"] = "application/json"; Uri fullUrl = new Uri(this.BaseUrl, url);
client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; string data = JsonConvert.SerializeObject(content);
string response = client.UploadString(fullUrl, data);
return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings); client.Headers["Content-Type"] = "application/json";
} client.Headers["User-Agent"] = $"SMAPI/{this.Version}";
string response = client.UploadString(fullUrl, data);
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"); string pullRequestUrl = this.GetAttribute(node, "data-pr");
IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions");
IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions");
string[] changeUpdateKeys = this.GetAttributeAsCsv(node, "data-change-update-keys");
// parse stable compatibility // parse stable compatibility
WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo
@ -153,6 +154,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
Warnings = warnings, Warnings = warnings,
PullRequestUrl = pullRequestUrl, PullRequestUrl = pullRequestUrl,
DevNote = devNote, DevNote = devNote,
ChangeUpdateKeys = changeUpdateKeys,
MapLocalVersions = mapLocalVersions, MapLocalVersions = mapLocalVersions,
MapRemoteVersions = mapRemoteVersions, MapRemoteVersions = mapRemoteVersions,
Anchor = anchor Anchor = anchor

View File

@ -3,25 +3,28 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
/// <summary>The compatibility status for a mod.</summary> /// <summary>The compatibility status for a mod.</summary>
public enum WikiCompatibilityStatus public enum WikiCompatibilityStatus
{ {
/// <summary>The status is unknown.</summary>
Unknown,
/// <summary>The mod is compatible.</summary> /// <summary>The mod is compatible.</summary>
Ok = 0, Ok,
/// <summary>The mod is compatible if you use an optional official download.</summary> /// <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> /// <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> /// <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> /// <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> /// <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> /// <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> /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
public string DevNote { get; set; } 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> /// <summary>Maps local versions to a semantic version for update checks.</summary>
public IDictionary<string, string> MapLocalVersions { get; set; } public IDictionary<string, string> MapLocalVersions { get; set; }

View File

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

View File

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

View File

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

View File

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

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json; using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
@ -11,8 +10,6 @@ using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Framework.ModScanning;
using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization;
[assembly: InternalsVisibleTo("StardewModdingAPI")]
[assembly: InternalsVisibleTo("SMAPI.Web")]
namespace StardewModdingAPI.Toolkit namespace StardewModdingAPI.Toolkit
{ {
/// <summary>A convenience wrapper for the various tools.</summary> /// <summary>A convenience wrapper for the various tools.</summary>

View File

@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StardewModdingAPI")]
[assembly: InternalsVisibleTo("SMAPI.Web")]

View File

@ -1,20 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>SMAPI.Toolkit</AssemblyName>
<RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace>
<Description>A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.</Description> <Description>A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.</Description>
<TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.xml</DocumentationFile>
<PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget>
<RootNamespace>StardewModdingAPI.Toolkit</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.23" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.23" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.0.0" />
<PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" /> <PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" />
<PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT' AND '$(TargetFramework)' == 'netstandard2.0'" /> <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT' AND '$(TargetFramework)' == 'netstandard2.0'" />
</ItemGroup> </ItemGroup>
@ -24,5 +20,4 @@
</ItemGroup> </ItemGroup>
<Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\common.targets" />
</Project> </Project>

View File

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

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;
using System.Diagnostics.CodeAnalysis;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Hangfire; using Hangfire;
@ -36,7 +37,9 @@ namespace StardewModdingAPI.Web
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="wikiCache">The cache in which to store wiki metadata.</param> /// <param name="wikiCache">The cache in which to store wiki metadata.</param>
/// <param name="modCache">The cache in which to store mod data.</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.WikiCache = wikiCache;
BackgroundService.ModCache = modCache; BackgroundService.ModCache = modCache;
@ -81,7 +84,7 @@ namespace StardewModdingAPI.Web
public static async Task UpdateWikiAsync() public static async Task UpdateWikiAsync()
{ {
WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); 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> /// <summary>Remove mods which haven't been requested in over 48 hours.</summary>

View File

@ -27,12 +27,13 @@ namespace StardewModdingAPI.Web.Controllers
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
{ {
["none"] = "None", ["none"] = "None",
["manifest"] = "Manifest", ["manifest"] = "SMAPI: manifest",
["i18n"] = "SMAPI: translations (i18n)",
["content-patcher"] = "Content Patcher" ["content-patcher"] = "Content Patcher"
}; };
/// <summary>The schema ID to use if none was specified.</summary> /// <summary>The schema ID to use if none was specified.</summary>
private string DefaultSchemaID = "manifest"; private string DefaultSchemaID = "none";
/// <summary>A token in an error message which indicates that the child errors should be displayed instead.</summary> /// <summary>A token in an error message which indicates that the child errors should be displayed instead.</summary>
private readonly string TransparentToken = "$transparent"; private readonly string TransparentToken = "$transparent";
@ -57,16 +58,22 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Render the schema validator UI.</summary> /// <summary>Render the schema validator UI.</summary>
/// <param name="schemaName">The schema name with which to validate the JSON, or 'edit' to return to the edit screen.</param> /// <param name="schemaName">The schema name with which to validate the JSON, or 'edit' to return to the edit screen.</param>
/// <param name="id">The stored file ID.</param> /// <param name="id">The stored file ID.</param>
/// <param name="operation">The operation to perform for the selected log ID. This can be 'edit', or any other value to view.</param>
[HttpGet] [HttpGet]
[Route("json")] [Route("json")]
[Route("json/{schemaName}")] [Route("json/{schemaName}")]
[Route("json/{schemaName}/{id}")] [Route("json/{schemaName}/{id}")]
public async Task<ViewResult> Index(string schemaName = null, string id = null) [Route("json/{schemaName}/{id}/{operation}")]
public async Task<ViewResult> Index(string schemaName = null, string id = null, string operation = null)
{ {
// parse arguments
schemaName = this.NormalizeSchemaName(schemaName); schemaName = this.NormalizeSchemaName(schemaName);
bool hasId = !string.IsNullOrWhiteSpace(id);
bool isEditView = !hasId || operation?.Trim().ToLower() == "edit";
var result = new JsonValidatorModel(id, schemaName, this.SchemaFormats); // build result model
if (string.IsNullOrWhiteSpace(id)) var result = this.GetModel(id, schemaName, isEditView);
if (!hasId)
return this.View("Index", result); return this.View("Index", result);
// fetch raw JSON // fetch raw JSON
@ -76,7 +83,7 @@ namespace StardewModdingAPI.Web.Controllers
result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning); result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning);
// skip parsing if we're going to the edit screen // skip parsing if we're going to the edit screen
if (schemaName?.ToLower() == "edit") if (isEditView)
return this.View("Index", result); return this.View("Index", result);
// parse JSON // parse JSON
@ -130,7 +137,7 @@ namespace StardewModdingAPI.Web.Controllers
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
{ {
if (request == null) if (request == null)
return this.View("Index", this.GetModel(null, null).SetUploadError("The request seems to be invalid.")); return this.View("Index", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid."));
// normalize schema name // normalize schema name
string schemaName = this.NormalizeSchemaName(request.SchemaName); string schemaName = this.NormalizeSchemaName(request.SchemaName);
@ -138,12 +145,12 @@ namespace StardewModdingAPI.Web.Controllers
// get raw text // get raw text
string input = request.Content; string input = request.Content;
if (string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(input))
return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty.")); return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty."));
// upload file // upload file
UploadResult result = await this.Storage.SaveAsync(input); UploadResult result = await this.Storage.SaveAsync(input);
if (!result.Succeeded) if (!result.Succeeded)
return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError)); return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null).SetUploadError(result.UploadError));
// redirect to view // redirect to view
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID })); return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
@ -156,9 +163,10 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Build a JSON validator model.</summary> /// <summary>Build a JSON validator model.</summary>
/// <param name="pasteID">The stored file ID.</param> /// <param name="pasteID">The stored file ID.</param>
/// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param>
private JsonValidatorModel GetModel(string pasteID, string schemaName) /// <param name="isEditView">Whether to show the edit view.</param>
private JsonValidatorModel GetModel(string pasteID, string schemaName, bool isEditView)
{ {
return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats); return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView);
} }
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary> /// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
@ -275,21 +283,20 @@ namespace StardewModdingAPI.Web.Controllers
errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase); errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase);
// match error by type and message // 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; 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])) 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 // match by type
if (errors.TryGetValue(error.ErrorType.ToString(), out string message)) return errors.TryGetValue(error.ErrorType.ToString(), out string message)
return message?.Trim(); ? message?.Trim()
: null;
return null;
} }
return GetRawOverrideError() return GetRawOverrideError()
@ -304,10 +311,10 @@ namespace StardewModdingAPI.Web.Controllers
{ {
if (schema.ExtensionData != null) 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)) if (curKey.Equals(key, StringComparison.InvariantCultureIgnoreCase))
return pair.Value.ToObject<T>(); return value.ToObject<T>();
} }
} }
@ -318,14 +325,11 @@ namespace StardewModdingAPI.Web.Controllers
/// <param name="value">The value to format.</param> /// <param name="value">The value to format.</param>
private string FormatValue(object value) private string FormatValue(object value)
{ {
switch (value) return value switch
{ {
case List<string> list: List<string> list => string.Join(", ", list),
return string.Join(", ", list); _ => value?.ToString() ?? "null"
};
default:
return value?.ToString() ?? "null";
}
} }
} }
} }

View File

@ -12,15 +12,16 @@ using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Caching.Wiki;
using StardewModdingAPI.Web.Framework.Clients;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.CurseForge;
using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.ModRepositories;
namespace StardewModdingAPI.Web.Controllers namespace StardewModdingAPI.Web.Controllers
{ {
@ -32,8 +33,8 @@ namespace StardewModdingAPI.Web.Controllers
/********* /*********
** Fields ** Fields
*********/ *********/
/// <summary>The mod repositories which provide mod metadata.</summary> /// <summary>The mod sites which provide mod metadata.</summary>
private readonly IDictionary<ModRepositoryKey, IModRepository> Repositories; private readonly ModSiteManager ModSites;
/// <summary>The cache in which to store wiki data.</summary> /// <summary>The cache in which to store wiki data.</summary>
private readonly IWikiCacheRepository WikiCache; private readonly IWikiCacheRepository WikiCache;
@ -61,23 +62,14 @@ namespace StardewModdingAPI.Web.Controllers
/// <param name="github">The GitHub API client.</param> /// <param name="github">The GitHub API client.</param>
/// <param name="modDrop">The ModDrop API client.</param> /// <param name="modDrop">The ModDrop API client.</param>
/// <param name="nexus">The Nexus 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.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json"));
this.WikiCache = wikiCache; this.WikiCache = wikiCache;
this.ModCache = modCache; this.ModCache = modCache;
this.Config = config; this.Config = config;
this.Repositories = this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus });
new IModRepository[]
{
new ChucklefishRepository(chucklefish),
new CurseForgeRepository(curseForge),
new GitHubRepository(github),
new ModDropRepository(modDrop),
new NexusRepository(nexus)
}
.ToDictionary(p => p.VendorKey);
} }
/// <summary>Fetch version metadata for the given mods.</summary> /// <summary>Fetch version metadata for the given mods.</summary>
@ -90,7 +82,7 @@ namespace StardewModdingAPI.Web.Controllers
return new ModEntryModel[0]; return new ModEntryModel[0];
// fetch wiki data // 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); IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase);
foreach (ModSearchEntryModel mod in model.Mods) foreach (ModSearchEntryModel mod in model.Mods)
{ {
@ -143,45 +135,23 @@ namespace StardewModdingAPI.Web.Controllers
// validate update key // validate update key
if (!updateKey.LooksValid) 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; continue;
} }
// fetch data // fetch data
ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions); ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.MapRemoteVersions);
if (data.Error != null) if (data.Status != RemoteModStatus.Ok)
{ {
errors.Add(data.Error); errors.Add(data.Error ?? data.Status.ToString());
continue; continue;
} }
// handle main version // handle versions
if (data.Version != null) if (this.IsNewer(data.Version, main?.Version))
{ main = new ModEntryVersionModel(data.Version, data.Url);
ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); if (this.IsNewer(data.PreviewVersion, optional?.Version))
if (version == null) optional = new ModEntryVersionModel(data.PreviewVersion, data.Url);
{
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);
}
} }
// get unofficial version // get unofficial version
@ -221,7 +191,7 @@ namespace StardewModdingAPI.Web.Controllers
} }
// get recommended update (if any) // 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) if (apiVersion != null && installedVersion != null)
{ {
// get newer versions // get newer versions
@ -281,29 +251,27 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Get the mod info for an update key.</summary> /// <summary>Get the mod info for an update key.</summary>
/// <param name="updateKey">The namespaced update key.</param> /// <param name="updateKey">The namespaced update key.</param>
/// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</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 // get mod page
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)) IModPage page;
{ {
// get site bool isCached =
if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached<IModPage> cachedMod)
return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes);
// fetch mod if (isCached)
ModInfoModel result = await repository.GetModInfoAsync(updateKey.ID); page = cachedMod.Data;
if (result.Error == null) else
{ {
if (result.Version == null) page = await this.ModSites.GetModPageAsync(updateKey);
result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page);
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}'.");
} }
// 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> /// <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> /// <param name="entry">The mod's entry in the wiki list.</param>
private IEnumerable<UpdateKey> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) 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
{ {
// specified update keys var removeKeys = new HashSet<UpdateKey>(
if (specifiedKeys != null) from key in entry?.ChangeUpdateKeys ?? new string[0]
{ where key.StartsWith('-')
foreach (string key in specifiedKeys) select UpdateKey.Parse(key.Substring(1))
yield return key?.Trim(); );
} if (removeKeys.Any())
updateKeys.RemoveAll(removeKeys.Contains);
// default update key
string defaultKey = record?.GetDefaultUpdateKey();
if (defaultKey != null)
yield return defaultKey;
// wiki metadata
if (entry != null)
{
if (entry.NexusID.HasValue)
yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}";
if (entry.ModDropID.HasValue)
yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}";
if (entry.CurseForgeID.HasValue)
yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}";
if (entry.ChucklefishID.HasValue)
yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}";
}
} }
HashSet<UpdateKey> seen = new HashSet<UpdateKey>(); // 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
foreach (string rawKey in GetRaw())
{ {
if (string.IsNullOrWhiteSpace(rawKey)) var removeKeys = new HashSet<UpdateKey>();
continue; 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);
}
UpdateKey key = UpdateKey.Parse(rawKey); return updateKeys;
if (seen.Add(key)) }
/// <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
foreach (string key in specifiedKeys ?? Array.Empty<string>())
{
if (!string.IsNullOrWhiteSpace(key))
yield return key.Trim();
}
// default update key
{
string defaultKey = record?.GetDefaultUpdateKey();
if (!string.IsNullOrWhiteSpace(defaultKey))
yield return defaultKey;
}
// wiki metadata
if (entry != null)
{
if (entry.NexusID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString());
if (entry.ModDropID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString());
if (entry.CurseForgeID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString());
if (entry.ChucklefishID.HasValue)
yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString());
}
// overrides from wiki
foreach (string key in entry?.ChangeUpdateKeys ?? Array.Empty<string>())
{
if (key.StartsWith('+'))
yield return key.Substring(1);
else if (!key.StartsWith("-"))
yield return key; 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 System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Caching.Wiki;
using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.ViewModels; using StardewModdingAPI.Web.ViewModels;
@ -51,16 +52,16 @@ namespace StardewModdingAPI.Web.Controllers
public ModListModel FetchData() public ModListModel FetchData()
{ {
// fetch cached data // fetch cached data
if (!this.Cache.TryGetWikiMetadata(out CachedWikiMetadata metadata)) if (!this.Cache.TryGetWikiMetadata(out Cached<WikiMetadata> metadata))
return new ModListModel(); return new ModListModel();
// build model // build model
return new ModListModel( return new ModListModel(
stableVersion: metadata.StableVersion, stableVersion: metadata.Data.StableVersion,
betaVersion: metadata.BetaVersion, betaVersion: metadata.Data.BetaVersion,
mods: this.Cache mods: this.Cache
.GetWikiMods() .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 .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting
lastUpdated: metadata.LastUpdated, lastUpdated: metadata.LastUpdated,
isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes) 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 System;
using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework.ModRepositories; using StardewModdingAPI.Web.Framework.Clients;
namespace StardewModdingAPI.Web.Framework.Caching.Mods 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 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="id">The mod's unique ID within the <paramref name="site"/>.</param>
/// <param name="mod">The fetched mod.</param> /// <param name="mod">The fetched mod.</param>
/// <param name="markRequested">Whether to update the mod's 'last requested' date.</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> /// <summary>Save data fetched for a mod.</summary>
/// <param name="site">The mod site on which the mod is found.</param> /// <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="id">The mod's unique ID within the <paramref name="site"/>.</param>
/// <param name="mod">The mod data.</param> /// <param name="mod">The mod data.</param>
/// <param name="cachedMod">The stored mod record.</param> void SaveMod(ModSiteKey site, string id, IModPage mod);
void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod);
/// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> /// <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> /// <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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq.Expressions;
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
namespace StardewModdingAPI.Web.Framework.Caching.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 internal interface IWikiCacheRepository : ICacheRepository
{ {
/********* /*********
@ -13,18 +12,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
*********/ *********/
/// <summary>Get the cached wiki metadata.</summary> /// <summary>Get the cached wiki metadata.</summary>
/// <param name="metadata">The fetched metadata.</param> /// <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> /// <summary>Get the cached wiki mods.</summary>
/// <param name="filter">A filter to apply, if any.</param> /// <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> /// <summary>Save data fetched from the wiki compatibility list.</summary>
/// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="stableVersion">The current stable Stardew Valley version.</param>
/// <param name="betaVersion">The current beta Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param>
/// <param name="mods">The mod data.</param> /// <param name="mods">The mod data.</param>
/// <param name="cachedMetadata">The stored metadata record.</param> void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods);
/// <param name="cachedMods">The stored mod records.</param>
void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods);
} }
} }

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 namespace StardewModdingAPI.Web.Framework.Caching.Wiki
{ {
/// <summary>The model for cached wiki metadata.</summary> /// <summary>The model for cached wiki metadata.</summary>
internal class CachedWikiMetadata internal class WikiMetadata
{ {
/********* /*********
** Accessors ** 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> /// <summary>The current stable Stardew Valley version.</summary>
public string StableVersion { get; set; } public string StableVersion { get; set; }
@ -28,16 +17,15 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
** Public methods ** Public methods
*********/ *********/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
public CachedWikiMetadata() { } public WikiMetadata() { }
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="stableVersion">The current stable Stardew Valley version.</param>
/// <param name="betaVersion">The current beta 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.StableVersion = stableVersion;
this.BetaVersion = betaVersion; this.BetaVersion = betaVersion;
this.LastUpdated = DateTimeOffset.UtcNow;
} }
} }
} }

View File

@ -3,6 +3,7 @@ using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using HtmlAgilityPack; using HtmlAgilityPack;
using Pathoschild.Http.Client; using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
{ {
@ -19,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
private readonly IClient Client; private readonly IClient Client;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.Chucklefish;
/********* /*********
** Public methods ** Public methods
*********/ *********/
@ -32,42 +40,40 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
} }
/// <summary>Get metadata about a mod.</summary> /// <summary>Get update check info about a mod.</summary>
/// <param name="id">The Chucklefish mod ID.</param> /// <param name="id">The mod ID.</param>
/// <returns>Returns the mod info if found, else <c>null</c>.</returns> public async Task<IModPage> GetModData(string id)
public async Task<ChucklefishMod> GetModAsync(uint 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 // fetch HTML
string html; string html;
try try
{ {
html = await this.Client html = await this.Client
.GetAsync(string.Format(this.ModPageUrlFormat, id)) .GetAsync(string.Format(this.ModPageUrlFormat, parsedId))
.AsString(); .AsString();
} }
catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) 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(); var doc = new HtmlDocument();
doc.LoadHtml(html); doc.LoadHtml(html);
// extract mod info // 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; string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value;
if (name.StartsWith("[SMAPI] ")) if (name.StartsWith("[SMAPI] "))
name = name.Substring("[SMAPI] ".Length); name = name.Substring("[SMAPI] ".Length);
string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText;
// create model // return info
return new ChucklefishMod return page.SetInfo(name: name, version: version, url: url, downloads: Array.Empty<IModDownload>());
{
Name = name,
Version = version,
Url = url
};
} }
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> /// <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;
using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
{ {
/// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary> /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary>
internal interface IChucklefishClient : IDisposable internal interface IChucklefishClient : IModSiteClient, 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);
}
} }

View File

@ -1,8 +1,8 @@
using System.Linq; using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Pathoschild.Http.Client; using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels;
namespace StardewModdingAPI.Web.Framework.Clients.CurseForge 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); 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 ** Public methods
*********/ *********/
@ -31,60 +38,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent);
} }
/// <summary>Get metadata about a mod.</summary> /// <summary>Get update check info about a mod.</summary>
/// <param name="id">The CurseForge mod ID.</param> /// <param name="id">The mod ID.</param>
/// <returns>Returns the mod info if found, else <c>null</c>.</returns> public async Task<IModPage> GetModData(string id)
public async Task<CurseForgeMod> GetModAsync(long 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 // get raw data
ModModel mod = await this.Client ModModel mod = await this.Client
.GetAsync($"addon/{id}") .GetAsync($"addon/{parsedId}")
.As<ModModel>(); .As<ModModel>();
if (mod == null) if (mod == null)
return null; return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID.");
// get latest versions // get downloads
string invalidVersion = null; List<IModDownload> downloads = new List<IModDownload>();
ISemanticVersion latest = null;
foreach (ModFileModel file in mod.LatestFiles) foreach (ModFileModel file in mod.LatestFiles)
{ {
// extract version downloads.Add(
ISemanticVersion version; new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file))
{ );
string raw = this.GetRawVersion(file);
if (raw == null)
continue;
if (!SemanticVersion.TryParse(raw, out version))
{
if (invalidVersion == null)
invalidVersion = raw;
continue;
}
}
// track latest version
if (latest == null || version.IsNewerThan(latest))
latest = version;
} }
// get error // return info
string error = null; return page.SetInfo(name: mod.Name, version: null, url: mod.WebsiteUrl, downloads: downloads);
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
};
} }
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> /// <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;
using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.CurseForge namespace StardewModdingAPI.Web.Framework.Clients.CurseForge
{ {
/// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary> /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary>
internal interface ICurseForgeClient : IDisposable internal interface ICurseForgeClient : IModSiteClient, 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);
}
} }

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.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using Pathoschild.Http.Client; using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
namespace StardewModdingAPI.Web.Framework.Clients.GitHub namespace StardewModdingAPI.Web.Framework.Clients.GitHub
{ {
@ -16,6 +17,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub
private readonly IClient Client; private readonly IClient Client;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.GitHub;
/********* /*********
** Public methods ** 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> /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
public void Dispose() public void Dispose()
{ {

View File

@ -4,7 +4,7 @@ using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.GitHub namespace StardewModdingAPI.Web.Framework.Clients.GitHub
{ {
/// <summary>An HTTP client for fetching metadata from GitHub.</summary> /// <summary>An HTTP client for fetching metadata from GitHub.</summary>
internal interface IGitHubClient : IDisposable internal interface IGitHubClient : IModSiteClient, IDisposable
{ {
/********* /*********
** Methods ** 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;
using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.ModDrop namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
{ {
/// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary> /// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary>
internal interface IModDropClient : IDisposable internal interface IModDropClient : IDisposable, IModSiteClient { }
{
/*********
** 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);
}
} }

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Pathoschild.Http.Client; using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels;
namespace StardewModdingAPI.Web.Framework.Clients.ModDrop namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
@ -18,6 +19,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
private readonly string ModUrlFormat; private readonly string ModUrlFormat;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.ModDrop;
/********* /*********
** Public methods ** Public methods
*********/ *********/
@ -31,60 +39,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop
this.ModUrlFormat = modUrlFormat; this.ModUrlFormat = modUrlFormat;
} }
/// <summary>Get metadata about a mod.</summary> /// <summary>Get update check info about a mod.</summary>
/// <param name="id">The ModDrop mod ID.</param> /// <param name="id">The mod ID.</param>
/// <returns>Returns the mod info if found, else <c>null</c>.</returns> public async Task<IModPage> GetModData(string id)
public async Task<ModDropMod> GetModAsync(long 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 // get raw data
ModListModel response = await this.Client ModListModel response = await this.Client
.PostAsync("") .PostAsync("")
.WithBody(new .WithBody(new
{ {
ModIDs = new[] { id }, ModIDs = new[] { parsedId },
Files = true, Files = true,
Mods = true Mods = true
}) })
.As<ModListModel>(); .As<ModListModel>();
ModModel mod = response.Mods[id]; ModModel mod = response.Mods[parsedId];
if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue) if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue)
return null; return null;
// get latest versions // get files
ISemanticVersion latest = null; var downloads = new List<IModDownload>();
ISemanticVersion optional = null;
foreach (FileDataModel file in mod.Files) foreach (FileDataModel file in mod.Files)
{ {
if (file.IsOld || file.IsDeleted || file.IsHidden) if (file.IsOld || file.IsDeleted || file.IsHidden)
continue; continue;
if (!SemanticVersion.TryParse(file.Version, out ISemanticVersion version)) downloads.Add(
continue; 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 info
return new ModDropMod string name = mod.Mod?.Title;
{ string url = string.Format(this.ModUrlFormat, id);
Name = mod.Mod?.Title, return page.SetInfo(name: name, version: null, url: url, downloads: downloads);
LatestDefaultVersion = latest,
LatestOptionalVersion = optional,
Url = string.Format(this.ModUrlFormat, id)
};
} }
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> /// <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 namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels
{ {
/// <summary>Metadata from the ModDrop API about a mod file.</summary> /// <summary>Metadata from the ModDrop API about a mod file.</summary>
public class FileDataModel 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> /// <summary>Whether the file is deleted.</summary>
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
@ -14,8 +27,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels
/// <summary>Whether this is an archived file.</summary> /// <summary>Whether this is an archived file.</summary>
public bool IsOld { get; set; } 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;
using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{ {
/// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary> /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary>
internal interface INexusClient : IDisposable internal interface INexusClient : IModSiteClient, 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);
}
} }

View File

@ -7,6 +7,8 @@ using HtmlAgilityPack;
using Pathoschild.FluentNexus.Models; using Pathoschild.FluentNexus.Models;
using Pathoschild.Http.Client; using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels;
using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; using FluentNexusClient = Pathoschild.FluentNexus.NexusClient;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus namespace StardewModdingAPI.Web.Framework.Clients.Nexus
@ -30,6 +32,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
private readonly FluentNexusClient ApiClient; private readonly FluentNexusClient ApiClient;
/*********
** Accessors
*********/
/// <summary>The unique key for the mod site.</summary>
public ModSiteKey SiteKey => ModSiteKey.Nexus;
/********* /*********
** Public methods ** Public methods
*********/ *********/
@ -48,20 +57,32 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion);
} }
/// <summary>Get metadata about a mod.</summary> /// <summary>Get update check info about a mod.</summary>
/// <param name="id">The Nexus mod ID.</param> /// <param name="id">The mod ID.</param>
/// <returns>Returns the mod info if found, else <c>null</c>.</returns> public async Task<IModPage> GetModData(string id)
public async Task<NexusMod> GetModAsync(uint 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 // 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. // 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 // Note that the API has very restrictive rate limits which means we can't just use it
// for all cases. // for all cases.
NexusMod mod = await this.GetModFromWebsiteAsync(id); NexusMod mod = await this.GetModFromWebsiteAsync(parsedId);
if (mod?.Status == NexusModStatus.AdultContentForbidden) 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> /// <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 // extract mod info
string url = this.GetModUrl(id); 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(); string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim();
SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion);
// extract file versions // extract files
List<string> rawVersions = new List<string>(); var downloads = new List<IModDownload>();
foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]"))
{ {
string sectionName = fileSection.Descendants("h2").First().InnerText; string sectionName = fileSection.Descendants("h2").First().InnerText;
if (sectionName != "Main files" && sectionName != "Optional files") if (sectionName != "Main files" && sectionName != "Optional files")
continue; continue;
rawVersions.AddRange( foreach (var container in fileSection.Descendants("dt"))
from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) {
from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) string fileName = container.GetDataAttribute("name").Value;
select versionStat.InnerText.Trim() 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
}
// choose latest file version downloads.Add(
ISemanticVersion latestFileVersion = null; new GenericModDownload(fileName, description, fileVersion)
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 // yield info
@ -153,8 +165,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{ {
Name = name, Name = name,
Version = parsedVersion?.ToString() ?? version, 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); Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id);
ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); 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 // yield info
return new NexusMod return new NexusMod
{ {
Name = mod.Name, Name = mod.Name,
Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, 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; using Newtonsoft.Json;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels
{ {
/// <summary>Mod metadata from Nexus Mods.</summary> /// <summary>Mod metadata from Nexus Mods.</summary>
internal class NexusMod internal class NexusMod
@ -14,9 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/// <summary>The mod's semantic version number.</summary> /// <summary>The mod's semantic version number.</summary>
public string Version { get; set; } public string Version { get; set; }
/// <summary>The latest file version.</summary>
public ISemanticVersion LatestFileVersion { get; set; }
/// <summary>The mod's web URL.</summary> /// <summary>The mod's web URL.</summary>
[JsonProperty("mod_page_uri")] [JsonProperty("mod_page_uri")]
public string Url { get; set; } public string Url { get; set; }
@ -25,7 +22,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
[JsonIgnore] [JsonIgnore]
public NexusModStatus Status { get; set; } = NexusModStatus.Ok; 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] [JsonIgnore]
public string Error { get; set; } public string Error { get; set; }
} }

View File

@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Framework.Compression
return rawText; return rawText;
// decompress // decompress
using (MemoryStream memoryStream = new MemoryStream()) using MemoryStream memoryStream = new MemoryStream();
{ {
// read length prefix // read length prefix
int dataLength = BitConverter.ToInt32(zipBuffer, 0); 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 System;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json;
namespace StardewModdingAPI.Web.Framework namespace StardewModdingAPI.Web.Framework
{ {
/// <summary>Provides extensions on ASP.NET Core types.</summary> /// <summary>Provides extensions on ASP.NET Core types.</summary>
public static class Extensions 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> /// <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="helper">The URL helper to extend.</param>
/// <param name="action">The name of the action method.</param> /// <param name="action">The name of the action method.</param>
@ -18,6 +28,7 @@ namespace StardewModdingAPI.Web.Framework
/// <returns>The generated URL.</returns> /// <returns>The generated URL.</returns>
public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false) 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); RouteValueDictionary valuesDict = new RouteValueDictionary(values);
foreach (var value in helper.ActionContext.RouteData.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 valuesDict[value.Key] = null; // explicitly remove it from the URL
} }
// get relative URL
string url = helper.Action(action, controller, valuesDict); 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) if (absoluteUrl)
{ {
HttpRequest request = helper.ActionContext.HttpContext.Request; HttpRequest request = helper.ActionContext.HttpContext.Request;
Uri baseUri = new Uri($"{request.Scheme}://{request.Host}"); Uri baseUri = new Uri($"{request.Scheme}://{request.Host}");
url = new Uri(baseUri, url).ToString(); url = new Uri(baseUri, url).ToString();
} }
return url; 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); private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching SMAPI's update line.</summary> /// <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; 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 version = match.Groups["version"].Value;
string link = match.Groups["link"].Value; string link = match.Groups["link"].Value;
smapiMod.UpdateVersion = version; 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> /// <summary>Generic metadata about a mod.</summary>
internal class ModInfoModel internal class ModInfoModel
@ -10,20 +12,14 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
public string Name { get; set; } public string Name { get; set; }
/// <summary>The mod's latest version.</summary> /// <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> /// <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> /// <summary>The mod's web URL.</summary>
public string Url { get; set; } 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> /// <summary>The mod availability status on the remote site.</summary>
public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; 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="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="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> /// <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 this
.SetBasicInfo(name, url) .SetBasicInfo(name, url)
@ -63,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
/// <summary>Set the mod version info.</summary> /// <summary>Set the mod version info.</summary>
/// <param name="version">The semantic version for the mod's latest release.</param> /// <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="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.Version = version;
this.PreviewVersion = previewVersion; this.PreviewVersion = previewVersion;
@ -71,17 +67,6 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
return this; 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> /// <summary>Set a mod error.</summary>
/// <param name="status">The mod availability status on the remote site.</param> /// <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> /// <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> /// <summary>The mod availability status on a remote site.</summary>
internal enum RemoteModStatus 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.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace StardewModdingAPI.Web namespace StardewModdingAPI.Web
{ {
@ -13,13 +13,13 @@ namespace StardewModdingAPI.Web
/// <param name="args">The command-line arguments.</param> /// <param name="args">The command-line arguments.</param>
public static void Main(string[] args) public static void Main(string[] args)
{ {
// configure web server Host
WebHost
.CreateDefaultBuilder(args) .CreateDefaultBuilder(args)
.CaptureStartupErrors(true) .ConfigureWebHostDefaults(builder => builder
.UseSetting("detailedErrors", "true") .CaptureStartupErrors(true)
.UseKestrel().UseIISIntegration() // must be used together; fixes intermittent errors on Azure: https://stackoverflow.com/a/38312175/262123 .UseSetting("detailedErrors", "true")
.UseStartup<Startup>() .UseStartup<Startup>()
)
.Build() .Build()
.Run(); .Run();
} }

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<AssemblyName>SMAPI.Web</AssemblyName> <AssemblyName>SMAPI.Web</AssemblyName>
<RootNamespace>StardewModdingAPI.Web</RootNamespace> <RootNamespace>StardewModdingAPI.Web</RootNamespace>
<TargetFramework>netcoreapp2.0</TargetFramework> <TargetFramework>netcoreapp3.1</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
@ -12,23 +12,17 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.4.0" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.4.2" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.7.11" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" /> <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="HtmlAgilityPack" Version="1.11.23" />
<PackageReference Include="Humanizer.Core" Version="2.7.9" /> <PackageReference Include="Humanizer.Core" Version="2.8.11" />
<PackageReference Include="JetBrains.Annotations" Version="2019.1.3" /> <PackageReference Include="JetBrains.Annotations" Version="2020.1.0" />
<PackageReference Include="Markdig" Version="0.18.3" /> <PackageReference Include="Markdig" Version="0.20.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" />
<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="Newtonsoft.Json.Schema" Version="3.0.13" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" />
<PackageReference Include="Pathoschild.FluentNexus" Version="1.0.0" /> <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> <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.Collections.Generic;
using System.Net;
using Hangfire; using Hangfire;
using Hangfire.MemoryStorage; using Hangfire.MemoryStorage;
using Hangfire.Mongo;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Rewrite; using Microsoft.AspNetCore.Rewrite;
@ -10,13 +9,9 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Newtonsoft.Json; using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Caching.Wiki;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish; 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.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.RewriteRules; using StardewModdingAPI.Web.Framework.RedirectRules;
using StardewModdingAPI.Web.Framework.Storage; using StardewModdingAPI.Web.Framework.Storage;
namespace StardewModdingAPI.Web namespace StardewModdingAPI.Web
@ -47,7 +42,7 @@ namespace StardewModdingAPI.Web
*********/ *********/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="env">The hosting environment.</param> /// <param name="env">The hosting environment.</param>
public Startup(IHostingEnvironment env) public Startup(IWebHostEnvironment env)
{ {
this.Configuration = new ConfigurationBuilder() this.Configuration = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath) .SetBasePath(env.ContentRootPath)
@ -67,22 +62,33 @@ namespace StardewModdingAPI.Web
.Configure<BackgroundServicesConfig>(this.Configuration.GetSection("BackgroundServices")) .Configure<BackgroundServicesConfig>(this.Configuration.GetSection("BackgroundServices"))
.Configure<ModCompatibilityListConfig>(this.Configuration.GetSection("ModCompatibilityList")) .Configure<ModCompatibilityListConfig>(this.Configuration.GetSection("ModCompatibilityList"))
.Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck"))
.Configure<MongoDbConfig>(this.Configuration.GetSection("MongoDB"))
.Configure<SiteConfig>(this.Configuration.GetSection("Site")) .Configure<SiteConfig>(this.Configuration.GetSection("Site"))
.Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint)))
.AddLogging() .AddLogging()
.AddMemoryCache() .AddMemoryCache();
.AddMvc()
.ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider()))
.AddJsonOptions(options =>
{
foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters)
options.SerializerSettings.Converters.Add(converter);
options.SerializerSettings.Formatting = Formatting.Indented; // init MVC
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; 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 // init background service
{ {
@ -91,46 +97,6 @@ namespace StardewModdingAPI.Web
services.AddHostedService<BackgroundService>(); 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 // init API clients
{ {
ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get<ApiClientsConfig>(); ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get<ApiClientsConfig>();
@ -142,6 +108,7 @@ namespace StardewModdingAPI.Web
baseUrl: api.ChucklefishBaseUrl, baseUrl: api.ChucklefishBaseUrl,
modPageUrlFormat: api.ChucklefishModPageUrlFormat modPageUrlFormat: api.ChucklefishModPageUrlFormat
)); ));
services.AddSingleton<ICurseForgeClient>(new CurseForgeClient( services.AddSingleton<ICurseForgeClient>(new CurseForgeClient(
userAgent: userAgent, userAgent: userAgent,
apiUrl: api.CurseForgeBaseUrl apiUrl: api.CurseForgeBaseUrl
@ -188,8 +155,7 @@ namespace StardewModdingAPI.Web
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary> /// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
/// <param name="app">The application builder.</param> /// <param name="app">The application builder.</param>
/// <param name="env">The hosting environment.</param> public void Configure(IApplicationBuilder app)
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{ {
// basic config // basic config
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
@ -201,7 +167,13 @@ namespace StardewModdingAPI.Web
) )
.UseRewriter(this.GetRedirectRules()) .UseRewriter(this.GetRedirectRules())
.UseStaticFiles() // wwwroot folder .UseStaticFiles() // wwwroot folder
.UseMvc(); .UseRouting()
.UseAuthorization()
.UseEndpoints(p =>
{
p.MapControllers();
p.MapRazorPages();
});
// enable Hangfire dashboard // enable Hangfire dashboard
app.UseHangfireDashboard("/tasks", new DashboardOptions app.UseHangfireDashboard("/tasks", new DashboardOptions
@ -215,29 +187,63 @@ namespace StardewModdingAPI.Web
/********* /*********
** Private methods ** 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> /// <summary>Get the redirect rules to apply.</summary>
private RewriteOptions GetRedirectRules() 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"
}))
// redirect to HTTPS (except API for Linux/Mac Mono compatibility) // legacy paths
redirects.Add(new ConditionalRedirectToHttpsRule( .Add(new RedirectPathsToUrlsRule(this.GetLegacyPathRedirects()))
shouldRewrite: req =>
req.Host.Host != "localhost"
&& !req.Path.StartsWithSegments("/api")
));
// shortcut redirects // subdomains
redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0")); .Add(new RedirectHostsToUrlsRule(HttpStatusCode.PermanentRedirect, host => host switch
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")); "api.smapi.io" => "smapi.io/api",
redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://smapi.io/mods")); "json.smapi.io" => "smapi.io/json",
redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); "log.smapi.io" => "smapi.io/log",
redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); "mods.smapi.io" => "smapi.io/mods",
redirects.Add(new RedirectToUrlRule(@"^/troubleshoot(.*)$", "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1")); _ => host.EndsWith(".smapi.io")
redirects.Add(new RedirectToUrlRule(@"^/xnb\.?$", "https://stardewvalleywiki.com/Modding:Using_XNB_mods")); ? "smapi.io"
: null
}))
// redirect legacy canimod.com URLs // redirect to HTTPS (except API for Linux/Mac Mono compatibility)
.Add(
new RedirectToHttpsRule(except: req => req.Host.Host == "localhost" || req.Path.StartsWithSegments("/api"))
);
return redirects;
}
/// <summary>Get the redirects for legacy paths that have been moved elsewhere.</summary>
private IDictionary<string, string> GetLegacyPathRedirects()
{
var redirects = new Dictionary<string, string>();
// canimod.com => wiki
var wikiRedirects = new Dictionary<string, string[]> var wikiRedirects = new Dictionary<string, string[]>
{ {
["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" }, ["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:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" },
["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" } ["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) foreach (string pattern in patterns)
redirects.Add(new RedirectToUrlRule(pattern, "https://stardewvalleywiki.com/" + pair.Key)); redirects.Add(pattern, "https://stardewvalleywiki.com/" + page);
} }
return redirects; return redirects;

View File

@ -10,6 +10,9 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
/********* /*********
** Accessors ** Accessors
*********/ *********/
/// <summary>Whether to show the edit view.</summary>
public bool IsEditView { get; set; }
/// <summary>The paste ID.</summary> /// <summary>The paste ID.</summary>
public string PasteID { get; set; } public string PasteID { get; set; }
@ -51,11 +54,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
/// <param name="pasteID">The stored file ID.</param> /// <param name="pasteID">The stored file ID.</param>
/// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param>
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param> /// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats) /// <param name="isEditView">Whether to show the edit view.</param>
public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats, bool isEditView)
{ {
this.PasteID = pasteID; this.PasteID = pasteID;
this.SchemaName = schemaName; this.SchemaName = schemaName;
this.SchemaFormats = schemaFormats; this.SchemaFormats = schemaFormats;
this.IsEditView = isEditView;
} }
/// <summary>Set the validated content.</summary> /// <summary>Set the validated content.</summary>

View File

@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.ViewModels
public bool IsStale { get; set; } public bool IsStale { get; set; }
/// <summary>Whether the mod metadata is available.</summary> /// <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> /// <summary>The mod author's alternative names, if any.</summary>
public string AlternateAuthors { get; set; } 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> /// <summary>The URL to the mod's source code, if any.</summary>
public string SourceUrl { get; set; } public string SourceUrl { get; set; }
@ -62,6 +65,7 @@ namespace StardewModdingAPI.Web.ViewModels
this.AlternateNames = string.Join(", ", entry.Name.Skip(1).ToArray()); this.AlternateNames = string.Join(", ", entry.Name.Skip(1).ToArray());
this.Author = entry.Author.FirstOrDefault(); this.Author = entry.Author.FirstOrDefault();
this.AlternateAuthors = string.Join(", ", entry.Author.Skip(1).ToArray()); this.AlternateAuthors = string.Join(", ", entry.Author.Skip(1).ToArray());
this.GitHubRepo = entry.GitHubRepo;
this.SourceUrl = this.GetSourceUrl(entry); this.SourceUrl = this.GetSourceUrl(entry);
this.Compatibility = new ModCompatibilityModel(entry.Compatibility); this.Compatibility = new ModCompatibilityModel(entry.Compatibility);
this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null; this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null;
@ -102,7 +106,7 @@ namespace StardewModdingAPI.Web.ViewModels
if (entry.ModDropID.HasValue) if (entry.ModDropID.HasValue)
{ {
anyFound = true; 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)) if (!string.IsNullOrWhiteSpace(entry.CurseForgeKey))
{ {

View File

@ -9,7 +9,7 @@
} }
@section Head { @section Head {
<link rel="stylesheet" href="~/Content/css/index.css?r=20200105" /> <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> <script src="~/Content/js/index.js?r=20200105"></script>
} }

View File

@ -9,7 +9,6 @@
string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName }); string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName });
string schemaDisplayName = null; string schemaDisplayName = null;
bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName?.ToLower() != "none"; bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName?.ToLower() != "none";
bool isEditView = Model.Content == null || Model.SchemaName?.ToLower() == "edit";
// build title // build title
ViewData["Title"] = "JSON validator"; ViewData["Title"] = "JSON validator";
@ -32,7 +31,7 @@
<link rel="stylesheet" href="~/Content/css/json-validator.css?r=202002" /> <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" /> <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/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/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> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
@ -40,7 +39,7 @@
<script src="~/Content/js/json-validator.js?r=202002"></script> <script src="~/Content/js/json-validator.js?r=202002"></script>
<script> <script>
$(function() { $(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> </script>
} }
@ -63,7 +62,7 @@ else if (Model.ParseError != null)
<small v-pre>Error details: @Model.ParseError</small> <small v-pre>Error details: @Model.ParseError</small>
</div> </div>
} }
else if (!isEditView && Model.PasteID != null) else if (!Model.IsEditView && Model.PasteID != null)
{ {
<div class="banner success"> <div class="banner success">
<strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br /> <strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br />
@ -84,7 +83,7 @@ else if (!isEditView && Model.PasteID != null)
} }
@* upload new file *@ @* upload new file *@
@if (isEditView) @if (Model.IsEditView)
{ {
<h2>Upload a JSON file</h2> <h2>Upload a JSON file</h2>
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post"> <form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
@ -112,7 +111,7 @@ else if (!isEditView && Model.PasteID != null)
} }
@* validation results *@ @* validation results *@
@if (!isEditView) @if (!Model.IsEditView)
{ {
<div id="output"> <div id="output">
@if (Model.UploadError == null) @if (Model.UploadError == null)
@ -158,7 +157,7 @@ else if (!isEditView && Model.PasteID != null)
{ {
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option> <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
} }
</select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = "edit" }))">edit this file</a>. </select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = this.Model.SchemaName, operation = "edit" }))">edit this file</a>.
</div> </div>
<pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre> <pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre>

View File

@ -1,5 +1,4 @@
@using Humanizer @using Humanizer
@using Newtonsoft.Json
@using StardewModdingAPI.Toolkit.Utilities @using StardewModdingAPI.Toolkit.Utilities
@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework
@using StardewModdingAPI.Web.Framework.LogParsing.Models @using StardewModdingAPI.Web.Framework.LogParsing.Models
@ -12,7 +11,6 @@
.GetValues(typeof(LogLevel)) .GetValues(typeof(LogLevel))
.Cast<LogLevel>() .Cast<LogLevel>()
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); .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); 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/file-upload.css?r=202002" />
<link rel="stylesheet" href="~/Content/css/log-parser.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/vue@2.6.11" 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/jquery@3.5.1" crossorigin="anonymous"></script>
<script src="~/Content/js/file-upload.js?r=202002"></script> <script src="~/Content/js/file-upload.js?r=202002"></script>
<script src="~/Content/js/log-parser.js?r=202002"></script> <script src="~/Content/js/log-parser.js?r=202002"></script>
<script> <script>
$(function() { $(function() {
smapi.logParser({ smapi.logParser({
logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)), logStarted: new Date(@this.ForJson(Model.ParsedLog?.Timestamp)),
showPopup: @Json.Serialize(Model.ParsedLog == null), showPopup: @this.ForJson(Model.ParsedLog == null),
showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), noFormatting), showMods: @this.ForJson(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)),
showSections: @Json.Serialize(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false), noFormatting), showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false)),
showLevels: @Json.Serialize(defaultFilters, noFormatting), showLevels: @this.ForJson(defaultFilters),
enableFilters: @Json.Serialize(!Model.ShowRaw) enableFilters: @this.ForJson(!Model.ShowRaw)
}, '@this.Url.PlainAction("Index", "LogParser", values: null)'); }, '@this.Url.PlainAction("Index", "LogParser", values: null)');
}); });
</script> </script>

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