diff options
55 files changed, 710 insertions, 314 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 74954cf4..c51d164b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -24,7 +24,7 @@ Exact steps which reproduce the bug, if possible. For example: 4. Error occurs. **Log file** -Upload your SMAPI log to https://log.smapi.io and post a link here. +Upload your SMAPI log to https://smapi.io/log and post a link here. **Screenshots** If applicable, add screenshots to help explain your problem. @@ -28,4 +28,7 @@ _ReSharper*/ appsettings.Development.json # AWS generated files -src/SMAPI.Web/aws-beanstalk-tools-defaults.json +src/SMAPI.Web.LegacyRedirects/aws-beanstalk-tools-defaults.json + +# Azure generated files +src/SMAPI.Web/Properties/PublishProfiles/smapi-web-release - Web Deploy.pubxml diff --git a/build/common.targets b/build/common.targets index 10cdbe2c..e738bab0 100644 --- a/build/common.targets +++ b/build/common.targets @@ -4,7 +4,7 @@ <!--set properties --> <PropertyGroup> - <Version>3.0.0</Version> + <Version>3.0.1</Version> <Product>SMAPI</Product> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> diff --git a/build/prepare-install-package.targets b/build/prepare-install-package.targets index e5286bf5..4297756d 100644 --- a/build/prepare-install-package.targets +++ b/build/prepare-install-package.targets @@ -63,7 +63,7 @@ <Copy SourceFiles="$(CompiledModsPath)\SaveBackup\SaveBackup.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" /> <Copy SourceFiles="$(CompiledModsPath)\SaveBackup\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" /> - <!-- fix errors on Linux/Mac (sample: https://log.smapi.io/mMdFUpgB) --> + <!-- fix errors on Linux/Mac (sample: https://smapi.io/log/mMdFUpgB) --> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(RootPath)\build\lib\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" /> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(RootPath)\build\lib\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" /> diff --git a/docs/README.md b/docs/README.md index ddde6b09..386259a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,7 +63,7 @@ contributing translations. locale | status ---------- | :---------------- default | ✓ [fully translated](../src/SMAPI/i18n/default.json) -Chinese | ❑ not translated +Chinese | ✓ [fully translated](../src/SMAPI/i18n/zh.json) French | ❑ not translated German | ✓ [fully translated](../src/SMAPI/i18n/de.json) Hungarian | ❑ not translated @@ -71,6 +71,6 @@ Italian | ❑ not translated Japanese | ❑ not translated Korean | ❑ not translated Portuguese | ❑ not translated -Russian | ❑ not translated +Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json) Spanish | ❑ not translated -Turkish | ❑ not translated +Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json) diff --git a/docs/release-notes.md b/docs/release-notes.md index a891c495..1c106542 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,22 @@ ← [README](README.md) # Release notes +## 3.0.1 +Released 02 December 2019 for Stardew Valley 1.4.0.1. + +* For players: + * Updated for Stardew Valley 1.4.0.1. + * Improved compatibility with some Linux terminals (thanks to archification and DanielHeath!). + * Updated translations. Thanks to berkayylmao (added Turkish), feathershine (added Chinese), and Osiris901 (added Russian)! + +* For the web UI: + * Rebuilt web infrastructure to handle higher traffic. + * If a log can't be uploaded to Pastebin (e.g. due to rate limits), it's now uploaded to Amazon S3 instead. Logs uploaded to S3 expire after one month. + * Fixed JSON validator not letting you drag & drop a file. + +* For modders: + * `SemanticVersion` now supports [semver 2.0](https://semver.org/) build metadata. + ## 3.0 Released 26 November 2019 for Stardew Valley 1.4. @@ -80,6 +96,7 @@ For modders: * Update checks are now faster in some cases. * Updated mod compatibility list. * Updated SMAPI/game version map. + * Updated translations. Thanks to eren-kemer (added German)! * Fixes: * Fixed some assets not updated when you switch language to English. * Fixed lag in some cases due to incorrect asset caching when playing in non-English. @@ -102,7 +119,7 @@ For modders: * Clicking a mod link now automatically adds it to the visible mods if the list is filtered. * JSON validator: - * Added JSON validator at [json.smapi.io](https://json.smapi.io), which lets you validate a JSON file against predefined mod formats. + * Added JSON validator at [smapi.io/json](https://smapi.io/json), which lets you validate a JSON file against predefined mod formats. * Added support for the `manifest.json` format. * Added support for the Content Patcher format (thanks to TehPers!). * Added support for referencing a schema in a JSON Schema-compatible text editor. @@ -365,7 +382,7 @@ Released 19 November 2018 for Stardew Valley 1.3.32. * Updated compatibility list. * For the web UI: - * Added a [mod compatibility page](https://mods.smapi.io) and [privacy page](https://smapi.io/privacy). + * Added a [mod compatibility page](https://smapi.io/mods) and [privacy page](https://smapi.io/privacy). * The log parser now has a separate filter for game messages. * The log parser now shows content pack authors (thanks to danvolchek!). * Tweaked log parser UI (thanks to danvolchek!). @@ -549,7 +566,7 @@ Released 11 April 2018 for Stardew Valley 1.2.30–1.2.33. * Fixed error when two content packs use different capitalization for the same required mod ID. * Fixed rare crash if the game duplicates an item. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Tweaked UI. ## 2.5.4 @@ -561,7 +578,7 @@ Released 26 March 2018 for Stardew Valley 1.2.30–1.2.33. * Fixed error when mods remove an asset editor/loader. * Fixed minimum game version incorrectly increased in SMAPI 2.5.3. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Fixed error when log text contains certain tokens. * For modders: @@ -583,7 +600,7 @@ Released 13 March 2018 for Stardew Valley ~~1.2.30~~–1.2.33. * Fixed Linux ["magic number is wrong" errors](https://github.com/mono/mono/issues/6752) by changing default terminal order. * Updated compatibility list and added update checks for more mods. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Fixed incorrect filtering in some cases. * Fixed error if mods have duplicate names. * Fixed parse bugs if a mod has no author name. @@ -597,7 +614,7 @@ Released 25 February 2018 for Stardew Valley 1.2.30–1.2.33. * For modders: * Fixed issue where replacing an asset through `asset.AsImage()` or `asset.AsDictionary()` didn't take effect. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Fixed blank page after uploading a log in some cases. ## 2.5.1 @@ -628,7 +645,7 @@ Released 24 February 2018 for Stardew Valley 1.2.30–1.2.33. * Fixed unhelpful error when a translation file has duplicate keys due to case-insensitivity. * Fixed some JSON field names being case-sensitive. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Added support for SMAPI 2.5 content packs. * Reduced download size when viewing a parsed log with repeated errors. * Improved parse error handling. @@ -649,7 +666,7 @@ Released 24 January 2018 for Stardew Valley 1.2.30–1.2.33. * Fixed intermittent errors (e.g. 'collection has been modified') with some mods when loading a save. * Fixed compatibility with Linux Terminator terminal. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Fixed error parsing logs with zero installed mods. * For modders: @@ -684,7 +701,7 @@ Released 26 December 2017 for Stardew Valley 1.2.30–1.2.33. * Fixed issue where a mod could change the cursor position reported to other mods. * Updated compatibility list. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Fixed broken favicon. ## 2.2 @@ -698,7 +715,7 @@ Released 02 December 2017 for Stardew Valley 1.2.30–1.2.33. * Improved error when a mod has an invalid `EntryDLL` filename format. * Updated compatibility list. -* For the [log parser](https://log.smapi.io): +* For the [log parser](https://smapi.io/log): * Logs no longer expire after a week. * Fixed error when uploading very large logs. * Slightly improved the UI. @@ -713,7 +730,7 @@ Released 02 December 2017 for Stardew Valley 1.2.30–1.2.33. Released 01 November 2017 for Stardew Valley 1.2.30–1.2.33. * For players: - * Added a [log parser](https://log.smapi.io) site. + * Added a [log parser](https://smapi.io/log) site. * Added better Steam instructions to the SMAPI installer. * Renamed the bundled _TrainerMod_ to _ConsoleCommands_ to make its purpose clearer. * Removed the game's test messages from the console log. diff --git a/docs/technical/web.md b/docs/technical/web.md index 78d93625..97e0704a 100644 --- a/docs/technical/web.md +++ b/docs/technical/web.md @@ -14,13 +14,13 @@ and update check API. ## Log parser The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are -persisted in a compressed form to Pastebin. The log parser lives at https://log.smapi.io. +persisted in a compressed form to Pastebin. The log parser lives at https://smapi.io/log. ## JSON validator ### Overview The JSON validator provides a web UI for uploading and sharing JSON files, and validating them as plain JSON or against a predefined format like `manifest.json` or Content Patcher's `content.json`. -The JSON validator lives at https://json.smapi.io. +The JSON validator lives at https://smapi.io/json. ### Schema file format Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/) @@ -111,7 +111,7 @@ format | schema URL ## Web API ### Overview -SMAPI provides a web API at `api.smapi.io` for use by SMAPI and external tools. The URL includes a +SMAPI provides a web API at `smapi.io/api` for use by SMAPI and external tools. The URL includes a `{version}` token, which is the SMAPI version for backwards compatibility. This API is publicly accessible but not officially released; it may change at any time. @@ -184,7 +184,7 @@ may be useful to external tools. Example request: ```js -POST https://api.smapi.io/v3.0/mods +POST https://smapi.io/api/v3.0/mods { "mods": [ { @@ -350,8 +350,7 @@ To launch the environment: mongod --dbpath C:\dev\smapi-cache ``` 2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site. - <small>(Local URLs will use HTTP instead of HTTPS, and subdomains will become routes, like - `log.smapi.io` → `localhost:59482/log`.)</small> + <small>(Local URLs will use HTTP instead of HTTPS.)</small> ### Production environment A production environment includes the web servers and cache database hosted online for public @@ -367,7 +366,6 @@ Initial setup: ------------------------------- | ----------------- `LogParser:PastebinDevKey` | The [Pastebin developer key](https://pastebin.com/api#1) used to authenticate with the Pastebin API. `LogParser:PastebinUserKey` | The [Pastebin user key](https://pastebin.com/api#8) used to authenticate with the Pastebin API. Can be left blank to post anonymously. - `LogParser:SectionUrl` | The root URL of the log page, like `https://log.smapi.io/`. `ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info. `ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info. `MongoDB:Host` | The hostname for the MongoDB instance. diff --git a/src/SMAPI.Installer/unix-install.sh b/src/SMAPI.Installer/unix-install.sh index e3a5d8cc..6d0c86ce 100644 --- a/src/SMAPI.Installer/unix-install.sh +++ b/src/SMAPI.Installer/unix-install.sh @@ -12,6 +12,9 @@ elif type type >/dev/null 2>&1; then COMMAND="type" fi +# if $TERM is not set to xterm, mono will bail out when attempting to write to the console. +export TERM=xterm + # validate Mono & run installer if $COMMAND mono >/dev/null 2>&1; then mono internal/unix-install.exe diff --git a/src/SMAPI.Installer/unix-launcher.sh b/src/SMAPI.Installer/unix-launcher.sh index f81828f0..bebba9fe 100644 --- a/src/SMAPI.Installer/unix-launcher.sh +++ b/src/SMAPI.Installer/unix-launcher.sh @@ -62,7 +62,7 @@ else fi # select terminal (prefer $TERMINAL for overrides and testing, then xterm for best compatibility, then known supported terminals) - for terminal in "$TERMINAL" xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite x-terminal-emulator; do + for terminal in "$TERMINAL" xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty x-terminal-emulator; do if $COMMAND "$terminal" 2>/dev/null; then # Find the true shell behind x-terminal-emulator if [ "$(basename "$(readlink -f $(which "$terminal"))")" != "x-terminal-emulator" ]; then @@ -100,6 +100,14 @@ else # Kitty overrides the TERM varible unless you set it explicitly kitty -o term=xterm $LAUNCHER ;; + alacritty) + # Alacritty doesn't like the double quotes or the variable + if [ "$ARCH" == "x86_64" ]; then + alacritty -e sh -c 'TERM=xterm ./StardewModdingAPI.bin.x86_64 $*' + else + alacritty -e sh -c 'TERM=xterm ./StardewModdingAPI.bin.x86 $*' + fi + ;; xterm|xfce4-terminal|gnome-terminal|terminal|termite) $LAUNCHTERM -e "sh -c 'TERM=xterm $LAUNCHER'" ;; diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 802de3a6..badea825 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.0.0", + "Version": "3.0.1", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.0.0" + "MinimumApiVersion": "3.0.1" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index eddd3443..252c359f 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.0.0", + "Version": "3.0.1", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.0.0" + "MinimumApiVersion": "3.0.1" } diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index c91ec27f..48afcaa2 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -25,44 +25,50 @@ namespace SMAPI.Tests.Utilities [TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] [TestCase("1.2.3-SoME-tAg.4", ExpectedResult = "1.2.3-SoME-tAg.4")] [TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] + [TestCase("1.2.3-some-tag.4+build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")] + [TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] public string Constructor_FromString(string input) { return new SemanticVersion(input).ToString(); } [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")] - [TestCase(1, 0, 0, null, ExpectedResult = "1.0.0")] - [TestCase(3000, 4000, 5000, null, ExpectedResult = "3000.4000.5000")] - [TestCase(1, 2, 3, "", ExpectedResult = "1.2.3")] - [TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")] - [TestCase(1, 2, 3, "0", ExpectedResult = "1.2.3-0")] - [TestCase(1, 2, 3, "some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] - [TestCase(1, 2, 3, "sOMe-TaG.4", ExpectedResult = "1.2.3-sOMe-TaG.4")] - [TestCase(1, 2, 3, "some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] - public string Constructor_FromParts(int major, int minor, int patch, string tag) + [TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")] + [TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")] + [TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, " ", null, ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")] + [TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")] + [TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")] + [TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")] + [TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")] + [TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] + public string Constructor_FromParts(int major, int minor, int patch, string prerelease, string build) { // act - ISemanticVersion version = new SemanticVersion(major, minor, patch, tag); + ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build); // assert Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); - Assert.AreEqual(string.IsNullOrWhiteSpace(tag) ? null : tag.Trim(), version.PrereleaseTag, "The tag doesn't match the given value."); + Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match the given value."); + Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match the given value."); return version.ToString(); } [Test(Description = "Assert that the constructor throws the expected exception for invalid versions when constructed from the individual numbers.")] - [TestCase(0, 0, 0, null)] - [TestCase(-1, 0, 0, null)] - [TestCase(0, -1, 0, null)] - [TestCase(0, 0, -1, null)] - [TestCase(1, 0, 0, "-tag")] - [TestCase(1, 0, 0, "tag spaces")] - [TestCase(1, 0, 0, "tag~")] - public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string tag) + [TestCase(0, 0, 0, null, null)] + [TestCase(-1, 0, 0, null, null)] + [TestCase(0, -1, 0, null, null)] + [TestCase(0, 0, -1, null, null)] + [TestCase(1, 0, 0, "-tag", null)] + [TestCase(1, 0, 0, "tag spaces", null)] + [TestCase(1, 0, 0, "tag~", null)] + [TestCase(1, 0, 0, null, "build~")] + public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build) { - this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, tag)); + this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build)); } [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")] @@ -98,6 +104,7 @@ namespace SMAPI.Tests.Utilities [TestCase("1.2.3--some-tag")] [TestCase("1.2.3-some-tag...")] [TestCase("1.2.3-some-tag...4")] + [TestCase("1.2.3-some-tag.4+build...4")] [TestCase("apple")] [TestCase("-apple")] [TestCase("-5")] @@ -119,6 +126,8 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta", "1.0-beta", ExpectedResult = 0)] [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = 0)] [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = 0)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = 0)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = 0)] // build metadata must not affect precedence // less than [TestCase("0.5.7", "0.5.8", ExpectedResult = -1)] @@ -156,6 +165,8 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence + [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence // less than [TestCase("0.5.7", "0.5.8", ExpectedResult = true)] @@ -192,6 +203,8 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence + [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence // less than [TestCase("0.5.7", "0.5.8", ExpectedResult = false)] @@ -279,6 +292,8 @@ namespace SMAPI.Tests.Utilities [TestCase("1.11")] [TestCase("1.2")] [TestCase("1.2.15")] + [TestCase("1.4.0.1")] + [TestCase("1.4.0.6")] public void GameVersion(string versionStr) { // act @@ -286,7 +301,6 @@ namespace SMAPI.Tests.Utilities // assert Assert.AreEqual(versionStr, version.ToString(), "The game version did not round-trip to the same value."); - Assert.IsTrue(version.IsOlderThan(new SemanticVersion("1.2.30")), "The game version should be considered older than the later semantic versions."); } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index 4ec87d7c..b8572d50 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -20,6 +20,9 @@ namespace StardewModdingAPI /// <summary>An optional prerelease tag.</summary> string PrereleaseTag { get; } + /// <summary>Optional build metadata. This is ignored when determining version precedence.</summary> + string BuildMetadata { get; } + /********* ** Accessors diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 2ce3578e..4955dcae 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -7,8 +7,7 @@ namespace StardewModdingAPI.Toolkit /// <remarks> /// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations: /// - short-form "x.y" versions are supported (equivalent to "x.y.0"); - /// - hyphens are synonymous with dots in prerelease tags (like "-unofficial.3-pathoschild"); - /// - +build suffixes are not supported; + /// - hyphens are synonymous with dots in prerelease tags and build metadata (like "-unofficial.3-pathoschild"); /// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial"). /// </remarks> public class SemanticVersion : ISemanticVersion @@ -16,11 +15,11 @@ namespace StardewModdingAPI.Toolkit /********* ** Fields *********/ - /// <summary>A regex pattern matching a valid prerelease tag.</summary> + /// <summary>A regex pattern matching a valid prerelease or build metadata tag.</summary> internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; /// <summary>A regex pattern matching a version within a larger string.</summary> - internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?"; + internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?(?:\\+(?<buildmetadata>" + SemanticVersion.TagPattern + "))?"; /// <summary>A regular expression matching a semantic version string.</summary> /// <remarks>This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, with deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks> @@ -42,6 +41,9 @@ namespace StardewModdingAPI.Toolkit /// <summary>An optional prerelease tag.</summary> public string PrereleaseTag { get; } + /// <summary>Optional build metadata. This is ignored when determining version precedence.</summary> + public string BuildMetadata { get; } + /********* ** Public methods @@ -51,12 +53,14 @@ namespace StardewModdingAPI.Toolkit /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> /// <param name="patch">The patch version for backwards-compatible fixes.</param> /// <param name="prereleaseTag">An optional prerelease tag.</param> - public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null) + /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> + public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null, string buildMetadata = null) { this.MajorVersion = major; this.MinorVersion = minor; this.PatchVersion = patch; this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag); + this.BuildMetadata = this.GetNormalizedTag(buildMetadata); this.AssertValid(); } @@ -94,6 +98,7 @@ namespace StardewModdingAPI.Toolkit this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalizedTag(match.Groups["prerelease"].Value) : null; + this.BuildMetadata = match.Groups["buildmetadata"].Success ? this.GetNormalizedTag(match.Groups["buildmetadata"].Value) : null; this.AssertValid(); } @@ -175,6 +180,8 @@ namespace StardewModdingAPI.Toolkit string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}"; if (this.PrereleaseTag != null) version += $"-{this.PrereleaseTag}"; + if (this.BuildMetadata != null) + version += $"+{this.BuildMetadata}"; return version; } @@ -200,7 +207,7 @@ namespace StardewModdingAPI.Toolkit /********* ** Private methods *********/ - /// <summary>Get a normalized build tag.</summary> + /// <summary>Get a normalized prerelease or build tag.</summary> /// <param name="tag">The tag to normalize.</param> private string GetNormalizedTag(string tag) { @@ -277,12 +284,21 @@ namespace StardewModdingAPI.Toolkit throw new FormatException($"{this} isn't a valid semantic version. The major, minor, and patch numbers can't be negative."); if (this.MajorVersion == 0 && this.MinorVersion == 0 && this.PatchVersion == 0) throw new FormatException($"{this} isn't a valid semantic version. At least one of the major, minor, and patch numbers must be more than zero."); + if (this.PrereleaseTag != null) { if (this.PrereleaseTag.Trim() == "") - throw new FormatException($"{this} isn't a valid semantic version. The tag cannot be a blank string (but may be omitted)."); + throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag cannot be a blank string (but may be omitted)."); if (!Regex.IsMatch(this.PrereleaseTag, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase)) - throw new FormatException($"{this} isn't a valid semantic version. The tag is invalid."); + throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag is invalid."); + } + + if (this.BuildMetadata != null) + { + if (this.BuildMetadata.Trim() == "") + throw new FormatException($"{this} isn't a valid semantic version. The build metadata cannot be a blank string (but may be omitted)."); + if (!Regex.IsMatch(this.BuildMetadata, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase)) + throw new FormatException($"{this} isn't a valid semantic version. The build metadata is invalid."); } } } diff --git a/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs b/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs new file mode 100644 index 00000000..44ed0b6b --- /dev/null +++ b/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs @@ -0,0 +1,33 @@ +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>(); + } + } +} diff --git a/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs b/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs new file mode 100644 index 00000000..e5138e5c --- /dev/null +++ b/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs @@ -0,0 +1,37 @@ +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); + } + } +} diff --git a/src/SMAPI.Web.LegacyRedirects/Program.cs b/src/SMAPI.Web.LegacyRedirects/Program.cs new file mode 100644 index 00000000..6adee877 --- /dev/null +++ b/src/SMAPI.Web.LegacyRedirects/Program.cs @@ -0,0 +1,23 @@ +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(); + } + } +} diff --git a/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json b/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json new file mode 100644 index 00000000..e9a1b210 --- /dev/null +++ b/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "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" + } + } +}
\ No newline at end of file diff --git a/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj b/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj new file mode 100644 index 00000000..a3d5c2b6 --- /dev/null +++ b/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj @@ -0,0 +1,21 @@ +<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.0" /> + <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> diff --git a/src/SMAPI.Web.LegacyRedirects/Startup.cs b/src/SMAPI.Web.LegacyRedirects/Startup.cs new file mode 100644 index 00000000..4af51575 --- /dev/null +++ b/src/SMAPI.Web.LegacyRedirects/Startup.cs @@ -0,0 +1,94 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Newtonsoft.Json; +using SMAPI.Web.LegacyRedirects.Framework; +using StardewModdingAPI.Toolkit.Serialization; + +namespace SMAPI.Web.LegacyRedirects +{ + /// <summary>The web app startup configuration.</summary> + public class Startup + { + /********* + ** Public methods + *********/ + /// <summary>The method called by the runtime to add services to the container.</summary> + /// <param name="services">The service injection container.</param> + public void ConfigureServices(IServiceCollection services) + { + services + .AddControllers() + .AddNewtonsoftJson(options => Startup.ConfigureJsonNet(options.SerializerSettings)); + } + + /// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary> + /// <param name="app">The application builder.</param> + /// <param name="env">The hosting environment.</param> + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + app.UseDeveloperExceptionPage(); + + app + .UseRewriter(this.GetRedirectRules()) + .UseRouting() + .UseAuthorization() + .UseEndpoints(endpoints => endpoints.MapControllers()); + } + + /// <summary>Configure a Json.NET serializer.</summary> + /// <param name="settings">The serializer settings to edit.</param> + internal static void ConfigureJsonNet(JsonSerializerSettings settings) + { + foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters) + settings.Converters.Add(converter); + + settings.Formatting = Formatting.Indented; + settings.NullValueHandling = NullValueHandling.Ignore; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the redirect rules to apply.</summary> + private RewriteOptions GetRedirectRules() + { + var redirects = new RewriteOptions(); + + redirects.Add( + new LambdaRewriteRule((context, request, response) => + { + string host = request.Host.Host; + + // map API requests to proxy + // This is needed because the low-level HTTP client SMAPI uses for Linux/Mac compatibility doesn't support redirects. + if (host == "api.smapi.io") + { + request.Path = $"/api{request.Path}"; + return; + } + + // redirect other requests to Azure + string newRoot = host switch + { + "api.smapi.io" => "smapi.io/api", + "json.smapi.io" => "smapi.io/json", + "log.smapi.io" => "smapi.io/log", + "mods.smapi.io" => "smapi.io/mods", + _ => "smapi.io" + }; + response.StatusCode = (int)HttpStatusCode.PermanentRedirect; + response.Headers["Location"] = $"{(request.IsHttps ? "https" : "http")}://{newRoot}{request.PathBase}{request.Path}{request.QueryString}"; + context.Result = RuleResult.EndResponse; + }) + ); + + return redirects; + } + } +} diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index b2eb9a87..40599abc 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -5,14 +5,12 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Schema; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.Compression; -using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels.JsonValidator; namespace StardewModdingAPI.Web.Controllers @@ -23,18 +21,12 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Fields *********/ - /// <summary>The site config settings.</summary> - private readonly SiteConfig Config; - /// <summary>The underlying Pastebin client.</summary> private readonly IPastebinClient Pastebin; /// <summary>The underlying text compression helper.</summary> private readonly IGzipHelper GzipHelper; - /// <summary>The section URL for the schema validator.</summary> - private string SectionUrl => this.Config.JsonValidatorUrl; - /// <summary>The supported JSON schemas (names indexed by ID).</summary> private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> { @@ -57,12 +49,10 @@ namespace StardewModdingAPI.Web.Controllers ** Constructor ***/ /// <summary>Construct an instance.</summary> - /// <param name="siteConfig">The context config settings.</param> /// <param name="pastebin">The Pastebin API client.</param> /// <param name="gzipHelper">The underlying text compression helper.</param> - public JsonValidatorController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + public JsonValidatorController(IPastebinClient pastebin, IGzipHelper gzipHelper) { - this.Config = siteConfig.Value; this.Pastebin = pastebin; this.GzipHelper = gzipHelper; } @@ -81,7 +71,7 @@ namespace StardewModdingAPI.Web.Controllers { schemaName = this.NormalizeSchemaName(schemaName); - var result = new JsonValidatorModel(this.SectionUrl, id, schemaName, this.SchemaFormats); + var result = new JsonValidatorModel(id, schemaName, this.SchemaFormats); if (string.IsNullOrWhiteSpace(id)) return this.View("Index", result); @@ -142,7 +132,7 @@ namespace StardewModdingAPI.Web.Controllers public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) { if (request == null) - return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid.")); + return this.View("Index", new JsonValidatorModel(null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid.")); // normalize schema name string schemaName = this.NormalizeSchemaName(request.SchemaName); @@ -150,7 +140,7 @@ namespace StardewModdingAPI.Web.Controllers // get raw log text string input = request.Content; if (string.IsNullOrWhiteSpace(input)) - return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty.")); + return this.View("Index", new JsonValidatorModel(null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty.")); // upload log input = this.GzipHelper.CompressString(input); @@ -158,12 +148,10 @@ namespace StardewModdingAPI.Web.Controllers // handle errors if (!result.Success) - return this.View("Index", new JsonValidatorModel(this.SectionUrl, result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}")); + return this.View("Index", new JsonValidatorModel(result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}")); // redirect to view - UriBuilder uri = new UriBuilder(new Uri(this.SectionUrl)); - uri.Path = $"{uri.Path.TrimEnd('/')}/{schemaName}/{result.ID}"; - return this.Redirect(uri.Uri.ToString()); + return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID })); } diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index f7f19cd8..318b34d0 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,6 +1,13 @@ using System; +using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Transfer; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit.Utilities; @@ -20,8 +27,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Fields *********/ - /// <summary>The site config settings.</summary> - private readonly SiteConfig Config; + /// <summary>The API client settings.</summary> + private readonly ApiClientsConfig ClientsConfig; /// <summary>The underlying Pastebin client.</summary> private readonly IPastebinClient Pastebin; @@ -37,12 +44,12 @@ namespace StardewModdingAPI.Web.Controllers ** Constructor ***/ /// <summary>Construct an instance.</summary> - /// <param name="siteConfig">The context config settings.</param> + /// <param name="clientsConfig">The API client settings.</param> /// <param name="pastebin">The Pastebin API client.</param> /// <param name="gzipHelper">The underlying text compression helper.</param> - public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + public LogParserController(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) { - this.Config = siteConfig.Value; + this.ClientsConfig = clientsConfig.Value; this.Pastebin = pastebin; this.GzipHelper = gzipHelper; } @@ -66,8 +73,9 @@ namespace StardewModdingAPI.Web.Controllers PasteInfo paste = await this.GetAsync(id); ParsedLog log = paste.Success ? new LogParser().Parse(paste.Content) - : new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error }; - return this.View("Index", this.GetModel(id).SetResult(log, raw)); + : new ParsedLog { IsValid = false, Error = paste.Error }; + + return this.View("Index", this.GetModel(id, uploadWarning: paste.Warning, expiry: paste.Expiry).SetResult(log, raw)); } /*** @@ -85,16 +93,12 @@ namespace StardewModdingAPI.Web.Controllers // upload log input = this.GzipHelper.CompressString(input); - SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", input); - - // handle errors - if (!result.Success) - return this.View("Index", this.GetModel(result.ID, uploadError: $"Pastebin error: {result.Error ?? "unknown error"}")); + var uploadResult = await this.TrySaveLog(input); + if (!uploadResult.Succeeded) + return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); // redirect to view - UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl)); - uri.Path = uri.Path.TrimEnd('/') + '/' + result.ID; - return this.Redirect(uri.Uri.ToString()); + return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })); } @@ -105,19 +109,115 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="id">The Pastebin paste ID.</param> private async Task<PasteInfo> GetAsync(string id) { - PasteInfo response = await this.Pastebin.GetAsync(id); - response.Content = this.GzipHelper.DecompressString(response.Content); - return response; + // get from Amazon S3 + if (Guid.TryParseExact(id, "N", out Guid _)) + { + var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey); + + using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion))) + { + try + { + using (GetObjectResponse response = await s3.GetObjectAsync(this.ClientsConfig.AmazonLogBucket, $"logs/{id}")) + using (Stream responseStream = response.ResponseStream) + using (StreamReader reader = new StreamReader(responseStream)) + { + DateTime expiry = response.Expiration.ExpiryDateUtc; + string pastebinError = response.Metadata["x-amz-meta-pastebin-error"]; + string content = this.GzipHelper.DecompressString(reader.ReadToEnd()); + + return new PasteInfo + { + Success = true, + Content = content, + Expiry = expiry, + Warning = pastebinError + }; + } + } + catch (AmazonServiceException ex) + { + return ex.ErrorCode == "NoSuchKey" + ? new PasteInfo { Error = "There's no log with that ID." } + : new PasteInfo { Error = $"Could not fetch that log from AWS S3 ({ex.ErrorCode}: {ex.Message})." }; + } + } + } + + // get from PasteBin + else + { + PasteInfo response = await this.Pastebin.GetAsync(id); + response.Content = this.GzipHelper.DecompressString(response.Content); + return response; + } + } + + /// <summary>Save a log to Pastebin or Amazon S3, if available.</summary> + /// <param name="content">The content to upload.</param> + /// <returns>Returns metadata about the save attempt.</returns> + private async Task<UploadResult> TrySaveLog(string content) + { + // save to PasteBin + string uploadError; + { + SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", content); + if (result.Success) + return new UploadResult(true, result.ID, null); + + uploadError = $"Pastebin error: {result.Error ?? "unknown error"}"; + } + + // fallback to S3 + try + { + var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey); + + using (Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content))) + using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion))) + using (TransferUtility uploader = new TransferUtility(s3)) + { + string id = Guid.NewGuid().ToString("N"); + + var uploadRequest = new TransferUtilityUploadRequest + { + BucketName = this.ClientsConfig.AmazonLogBucket, + Key = $"logs/{id}", + InputStream = stream, + Metadata = + { + // note: AWS will lowercase keys and prefix 'x-amz-meta-' + ["smapi-uploaded"] = DateTime.UtcNow.ToString("O"), + ["pastebin-error"] = uploadError + } + }; + + await uploader.UploadAsync(uploadRequest); + + return new UploadResult(true, id, uploadError); + } + } + catch (Exception ex) + { + return new UploadResult(false, null, $"{uploadError}\n{ex.Message}"); + } } /// <summary>Build a log parser model.</summary> /// <param name="pasteID">The paste ID.</param> - /// <param name="uploadError">An error which occurred while uploading the log to Pastebin.</param> - private LogParserModel GetModel(string pasteID, string uploadError = null) + /// <param name="expiry">When the uploaded file will no longer be available.</param> + /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param> + /// <param name="uploadError">An error which occurred while uploading the log.</param> + private LogParserModel GetModel(string pasteID, DateTime? expiry = null, string uploadWarning = null, string uploadError = null) { - string sectionUrl = this.Config.LogParserUrl; Platform? platform = this.DetectClientPlatform(); - return new LogParserModel(sectionUrl, pasteID, platform) { UploadError = uploadError }; + + return new LogParserModel(pasteID, platform) + { + UploadWarning = uploadWarning, + UploadError = uploadError, + Expiry = expiry + }; } /// <summary>Detect the viewer's OS.</summary> @@ -143,5 +243,36 @@ namespace StardewModdingAPI.Web.Controllers return null; } } + + /// <summary>The result of an attempt to upload a file.</summary> + private class UploadResult + { + /********* + ** Accessors + *********/ + /// <summary>Whether the file upload succeeded.</summary> + public bool Succeeded { get; } + + /// <summary>The file ID, if applicable.</summary> + public string ID { get; } + + /// <summary>The upload error, if any.</summary> + public string UploadError { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="succeeded">Whether the file upload succeeded.</param> + /// <param name="id">The file ID, if applicable.</param> + /// <param name="uploadError">The upload error, if any.</param> + public UploadResult(bool succeeded, string id, string uploadError) + { + this.Succeeded = succeeded; + this.ID = id; + this.UploadError = uploadError; + } + } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index fe220eb5..3e3b81c8 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -11,6 +11,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; @@ -49,9 +50,6 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The internal mod metadata list.</summary> private readonly ModDatabase ModDatabase; - /// <summary>The web URL for the compatibility list.</summary> - private readonly string CompatibilityPageUrl; - /********* ** Public methods @@ -70,7 +68,6 @@ namespace StardewModdingAPI.Web.Controllers { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); ModUpdateCheckConfig config = configProvider.Value; - this.CompatibilityPageUrl = config.CompatibilityPageUrl; this.WikiCache = wikiCache; this.ModCache = modCache; @@ -205,7 +202,7 @@ namespace StardewModdingAPI.Web.Controllers // get unofficial version if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, optional?.Version)) - unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}"); + unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods")}#{wikiEntry.Anchor}"); // get unofficial version for beta if (wikiEntry?.HasBetaInfo == true) @@ -215,7 +212,7 @@ namespace StardewModdingAPI.Web.Controllers if (wikiEntry.BetaCompatibility.UnofficialVersion != null) { unofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, optional?.Version)) - ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}") + ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods")}#{wikiEntry.Anchor}") : null; } else diff --git a/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs b/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs deleted file mode 100644 index fe27fe2f..00000000 --- a/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Microsoft.Extensions.Configuration; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace StardewModdingAPI.Web.Framework -{ - /// <summary>Reads configuration values from the AWS Beanstalk environment properties file (if present).</summary> - /// <remarks>This is a workaround for AWS Beanstalk injection not working with .NET Core apps.</remarks> - internal class BeanstalkEnvPropsConfigProvider : ConfigurationProvider, IConfigurationSource - { - /********* - ** Fields - *********/ - /// <summary>The absolute path to the container configuration file on an Amazon EC2 instance.</summary> - private const string ContainerConfigPath = @"C:\Program Files\Amazon\ElasticBeanstalk\config\containerconfiguration"; - - - /********* - ** Public methods - *********/ - /// <summary>Build the configuration provider for this source.</summary> - /// <param name="builder">The configuration builder.</param> - public IConfigurationProvider Build(IConfigurationBuilder builder) - { - return new BeanstalkEnvPropsConfigProvider(); - } - - /// <summary>Load the environment properties.</summary> - public override void Load() - { - this.Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - // get Beanstalk config file - FileInfo file = new FileInfo(BeanstalkEnvPropsConfigProvider.ContainerConfigPath); - if (!file.Exists) - return; - - // parse JSON - JObject jsonRoot = (JObject)JsonConvert.DeserializeObject(File.ReadAllText(file.FullName)); - if (jsonRoot["iis"]?["env"] is JArray jsonProps) - { - foreach (string prop in jsonProps.Values<string>()) - { - string[] parts = prop.Split('=', 2); // key=value - if (parts.Length == 2) - this.Data[parts[0]] = parts[1]; - } - } - } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index 955156eb..bb2de356 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// <summary>The response for a get-paste request.</summary> @@ -9,7 +11,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin /// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary> public string Content { get; set; } - /// <summary>The error message (if saving failed).</summary> + /// <summary>When the file will no longer be available.</summary> + public DateTime? Expiry { get; set; } + + /// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary> + public string Warning { get; set; } + + /// <summary>The error message if saving failed.</summary> public string Error { get; set; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 2e8a8c68..d695aab6 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -62,7 +62,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin } catch (Exception ex) { - return new PasteInfo { Error = ex.ToString() }; + return new PasteInfo { Error = $"Pastebin error: {ex}" }; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 121690c5..7119ef03 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -14,6 +14,22 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /**** + ** Amazon Web Services + ****/ + /// <summary>The access key for AWS authentication.</summary> + public string AmazonAccessKey { get; set; } + + /// <summary>The secret key for AWS authentication.</summary> + public string AmazonSecretKey { get; set; } + + /// <summary>The AWS region endpoint (like 'us-east-1').</summary> + public string AmazonRegion { get; set; } + + /// <summary>The AWS bucket in which to store temporary uploaded logs.</summary> + public string AmazonLogBucket { get; set; } + + + /**** ** Chucklefish ****/ /// <summary>The base URL for the Chucklefish mod site.</summary> diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index ab935bb3..46073eb8 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -11,8 +11,5 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> public int ErrorCacheMinutes { get; set; } - - /// <summary>The web URL for the wiki compatibility list.</summary> - public string CompatibilityPageUrl { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index bc6e868a..d379c423 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -6,18 +6,6 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Accessors *********/ - /// <summary>The root URL for the app.</summary> - public string RootUrl { get; set; } - - /// <summary>The root URL for the log parser.</summary> - public string LogParserUrl { get; set; } - - /// <summary>The root URL for the JSON validator.</summary> - public string JsonValidatorUrl { get; set; } - - /// <summary>The root URL for the mod list.</summary> - public string ModListUrl { get; set; } - /// <summary>Whether to show SMAPI beta versions on the main page, if any.</summary> public bool BetaEnabled { get; set; } diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs new file mode 100644 index 00000000..d7707924 --- /dev/null +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -0,0 +1,28 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Provides extensions on ASP.NET Core types.</summary> + public static class Extensions + { + /// <summary>Get a URL with the absolute path for an action method. Unlike <see cref="IUrlHelper.Action"/>, only the specified <paramref name="values"/> are added to the URL without merging values from the current HTTP request.</summary> + /// <param name="helper">The URL helper to extend.</param> + /// <param name="action">The name of the action method.</param> + /// <param name="controller">The name of the controller.</param> + /// <param name="values">An object that contains route values.</param> + /// <returns>The generated URL.</returns> + public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null) + { + RouteValueDictionary valuesDict = new RouteValueDictionary(values); + foreach (var value in helper.ActionContext.RouteData.Values) + { + if (!valuesDict.ContainsKey(value.Key)) + valuesDict[value.Key] = null; // explicitly remove it from the URL + } + + return helper.Action(action, controller, valuesDict); + } + } +} diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs deleted file mode 100644 index 920632ab..00000000 --- a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; - -namespace StardewModdingAPI.Web.Framework.RewriteRules -{ - /// <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 ConditionalRewriteSubdomainRule : IRule - { - /********* - ** Accessors - *********/ - /// <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 ConditionalRewriteSubdomainRule(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.ShouldRewrite(request)) - return; - - // get host parts - string host = request.Host.Host; - string[] parts = host.Split('.'); - if (parts.Length < 2) - return; - - // prepend to path - request.Path = $"/{parts[0]}{request.Path}"; - } - } -} diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 8a7ca741..2c56fa75 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -12,16 +12,17 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="AWSSDK.S3" Version="3.3.108.4" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" /> <PackageReference Include="Hangfire.Mongo" Version="0.6.5" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> <PackageReference Include="Humanizer.Core" Version="2.7.9" /> + <PackageReference Include="JetBrains.Annotations" Version="2019.1.3" /> <PackageReference Include="Markdig" Version="0.18.0" /> <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="3.0.1" /> <PackageReference Include="MongoDB.Driver" Version="2.9.3" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" /> <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" /> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 8110b696..53823771 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -48,7 +48,7 @@ namespace StardewModdingAPI.Web .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) - .Add(new BeanstalkEnvPropsConfigProvider()) + .AddEnvironmentVariables() .Build(); } @@ -58,6 +58,7 @@ namespace StardewModdingAPI.Web { // init basic services services + .Configure<ApiClientsConfig>(this.Configuration.GetSection("ApiClients")) .Configure<BackgroundServicesConfig>(this.Configuration.GetSection("BackgroundServices")) .Configure<ModCompatibilityListConfig>(this.Configuration.GetSection("ModCompatibilityList")) .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) @@ -172,8 +173,7 @@ namespace StardewModdingAPI.Web .UseCors(policy => policy .AllowAnyHeader() .AllowAnyMethod() - .WithOrigins("https://smapi.io", "https://*.smapi.io", "https://*.edge.smapi.io") - .SetIsOriginAllowedToAllowWildcardSubdomains() + .WithOrigins("https://smapi.io") ) .UseRewriter(this.GetRedirectRules()) .UseStaticFiles() // wwwroot folder @@ -201,23 +201,13 @@ namespace StardewModdingAPI.Web shouldRewrite: req => req.Host.Host != "localhost" && !req.Path.StartsWithSegments("/api") - && !req.Host.Host.StartsWith("api.") - )); - - // convert subdomain.smapi.io => smapi.io/subdomain for routing - redirects.Add(new ConditionalRewriteSubdomainRule( - shouldRewrite: req => - req.Host.Host != "localhost" - && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("json.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) - && !req.Path.StartsWithSegments("/content") - && !req.Path.StartsWithSegments("/favicon.ico") )); // shortcut redirects redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0")); redirects.Add(new RedirectToUrlRule(@"^/(?:buildmsg|package)(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1")); // buildmsg deprecated, remove when SDV 1.4 is released redirects.Add(new RedirectToUrlRule(@"^/community\.?$", "https://stardewvalleywiki.com/Modding:Community")); - redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://mods.smapi.io")); + redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://smapi.io/mods")); redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); redirects.Add(new RedirectToUrlRule(@"^/troubleshoot(.*)$", "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1")); diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs index 2d13bf23..5b18331f 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -9,9 +9,6 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator /********* ** Accessors *********/ - /// <summary>The root URL for the log parser controller.</summary> - public string SectionUrl { get; set; } - /// <summary>The paste ID.</summary> public string PasteID { get; set; } @@ -44,13 +41,11 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator public JsonValidatorModel() { } /// <summary>Construct an instance.</summary> - /// <param name="sectionUrl">The root URL for the log parser controller.</param> /// <param name="pasteID">The paste ID.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param> - public JsonValidatorModel(string sectionUrl, string pasteID, string schemaName, IDictionary<string, string> schemaFormats) + public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats) { - this.SectionUrl = sectionUrl; this.PasteID = pasteID; this.SchemaName = schemaName; this.SchemaFormats = schemaFormats; diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs index f4c5214b..bea79eae 100644 --- a/src/SMAPI.Web/ViewModels/LogParserModel.cs +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -19,9 +20,6 @@ namespace StardewModdingAPI.Web.ViewModels /********* ** Accessors *********/ - /// <summary>The root URL for the log parser controller.</summary> - public string SectionUrl { get; set; } - /// <summary>The paste ID.</summary> public string PasteID { get; set; } @@ -34,12 +32,18 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>Whether to show the raw unparsed log.</summary> public bool ShowRaw { get; set; } - /// <summary>An error which occurred while uploading the log to Pastebin.</summary> + /// <summary>A non-blocking warning while uploading the log.</summary> + public string UploadWarning { get; set; } + + /// <summary>An error which occurred while uploading the log.</summary> public string UploadError { get; set; } /// <summary>An error which occurred while parsing the log file.</summary> public string ParseError => this.ParsedLog?.Error; + /// <summary>When the uploaded file will no longer be available.</summary> + public DateTime? Expiry { get; set; } + /********* ** Public methods @@ -48,12 +52,10 @@ namespace StardewModdingAPI.Web.ViewModels public LogParserModel() { } /// <summary>Construct an instance.</summary> - /// <param name="sectionUrl">The root URL for the log parser controller.</param> /// <param name="pasteID">The paste ID.</param> /// <param name="platform">The viewer's detected OS, if known.</param> - public LogParserModel(string sectionUrl, string pasteID, Platform? platform) + public LogParserModel(string pasteID, Platform? platform) { - this.SectionUrl = sectionUrl; this.PasteID = pasteID; this.DetectedPlatform = platform; this.ParsedLog = null; diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index f42dde3b..ec9cfafe 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -1,4 +1,5 @@ @using Microsoft.Extensions.Options +@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.ConfigModels @inject IOptions<SiteConfig> SiteConfig @model StardewModdingAPI.Web.ViewModels.IndexModel @@ -44,14 +45,14 @@ } <div><a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a></div> <div class="sublinks"> - <a href="https://github.com/Pathoschild/SMAPI">source code</a> | <a href="@(new UriBuilder(SiteConfig.Value.RootUrl) { Path = "privacy" }.Uri)">privacy</a> + <a href="https://github.com/Pathoschild/SMAPI">source code</a> | <a href="@Url.PlainAction("Privacy", "Index")">privacy</a> </div> <img id="pufferchick" src="Content/images/pufferchick.png" /> </div> <h2 id="help">Get help</h2> <ul> - <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility list</a></li> + <li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility list</a></li> <li>Get help <a href="https://smapi.io/community">on Discord or in the forums</a></li> </ul> @@ -61,7 +62,7 @@ <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> } else { @@ -70,13 +71,13 @@ else <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> <h3>SMAPI @Model.BetaVersion.Version?</h3> <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description)) </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> } <h2 id="donate">Support SMAPI ♥</h2> @@ -105,10 +106,12 @@ else <p> Special thanks to + <a href="https://www.nexusmods.com/users/65566526?tab=user+files">bwdy</a>, hawkfalcon, <a href="https://twitter.com/iKeychain">iKeychain</a>, jwdred, <a href="https://www.nexusmods.com/users/12252523">Karmylla</a>, + <a href="https://www.nexusmods.com/stardewvalley/users/51777556">minervamaga</a>, Pucklynn, Renorien, Robby LaFarge, diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml index 914384a8..7327de3d 100644 --- a/src/SMAPI.Web/Views/Index/Privacy.cshtml +++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml @@ -1,4 +1,5 @@ @using Microsoft.Extensions.Options +@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.ConfigModels @inject IOptions<SiteConfig> SiteConfig @{ @@ -8,7 +9,7 @@ <link rel="stylesheet" href="~/Content/css/privacy.css" /> } -← <a href="@SiteConfig.Value.RootUrl">back to SMAPI page</a> +← <a href="@Url.PlainAction("Index", "Index")">back to SMAPI page</a> <p>SMAPI is an <a href="https://github.com/Pathoschild/SMAPI">open-source</a> and non-profit project. Your privacy is important, so this page explains what information SMAPI uses and transmits. <strong>This page is informational only, it's not a legal document.</strong></p> @@ -34,7 +35,7 @@ </ol> <h3>Log parser</h3> -<p>The <a href="https://log.smapi.io/">log parser page</a> lets you store a log file for analysis and sharing. The log data is stored indefinitely in an obfuscated form as unlisted pastes in <a href="https://pastebin.com/">Pastebin</a>. No personal information is stored by the log parser beyond what you choose to upload, but see <em><a href="#web-logging">web logging</a></em> and the <a href="https://pastebin.com/doc_privacy_statement">Pastebin Privacy Statement</a>.</p> +<p>The <a href="https://smapi.io/log">log parser page</a> lets you store a log file for analysis and sharing. The log data is stored indefinitely in an obfuscated form as unlisted pastes in <a href="https://pastebin.com/">Pastebin</a>. No personal information is stored by the log parser beyond what you choose to upload, but see <em><a href="#web-logging">web logging</a></em> and the <a href="https://pastebin.com/doc_privacy_statement">Pastebin Privacy Statement</a>.</p> <h3>Multiplayer sync</h3> <p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p> diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml index 3143fad9..de6b06a2 100644 --- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -1,10 +1,11 @@ +@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.ViewModels.JsonValidator @model JsonValidatorModel @{ // get view data - string curPageUrl = new Uri(new Uri(Model.SectionUrl), $"{Model.SchemaName}/{Model.PasteID}").ToString(); - string newUploadUrl = Model.SchemaName != null ? new Uri(new Uri(Model.SectionUrl), Model.SchemaName).ToString() : Model.SectionUrl; + string curPageUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName, id = Model.PasteID }); + string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName }); string schemaDisplayName = null; bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName != "None"; @@ -34,8 +35,8 @@ <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script> <script src="~/Content/js/json-validator.js"></script> <script> - $(function () { - smapi.jsonValidator(@Json.Serialize(Model.SectionUrl), @Json.Serialize(Model.PasteID)); + $(function() { + smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", values: null)), @Json.Serialize(Model.PasteID)); }); </script> } @@ -70,7 +71,7 @@ else if (Model.PasteID != null) @if (Model.Content == null) { <h2>Upload a JSON file</h2> - <form action="@Model.SectionUrl" method="post"> + <form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post"> <ol> <li> Choose the JSON format:<br /> diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index f98ffdf9..439167bc 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -1,5 +1,7 @@ +@using Humanizer @using Newtonsoft.Json @using StardewModdingAPI.Toolkit.Utilities +@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.LogParsing.Models @model StardewModdingAPI.Web.ViewModels.LogParserModel @@ -18,7 +20,7 @@ { <meta name="robots" content="noindex" /> } - <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20190515" /> + <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20191127" /> <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> <script src="~/Content/js/log-parser.js?r=20190515"></script> @@ -31,7 +33,7 @@ showSections: @Json.Serialize(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false), noFormatting), showLevels: @Json.Serialize(defaultFilters, noFormatting), enableFilters: @Json.Serialize(!Model.ShowRaw) - }, '@Model.SectionUrl'); + }, '@this.Url.PlainAction("Index", "LogParser", values: null)'); }); </script> } @@ -48,8 +50,8 @@ else if (Model.ParseError != null) { <div class="banner error" v-pre> <strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br /> - Share this URL when asking for help: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br /> - (Or <a href="@Model.SectionUrl">upload a new log</a>.)<br /> + Share this URL when asking for help: <code>https://@this.Context.Request.Host.ToUriComponent()@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }))</code><br /> + (Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)<br /> <br /> <small v-pre>Error details: @Model.ParseError</small> </div> @@ -57,8 +59,20 @@ else if (Model.ParseError != null) else if (Model.ParsedLog?.IsValid == true) { <div class="banner success" v-pre> - <strong>Share this link to let someone else see the log:</strong> <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br /> - (Or <a href="@Model.SectionUrl">upload a new log</a>.) + <strong>Share this link to let someone else see the log:</strong> <code>https://@this.Context.Request.Host.ToUriComponent()@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID })</code><br /> + (Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.) + </div> +} + +@* save warnings *@ +@if (Model.UploadWarning != null || Model.Expiry != null) +{ + <div class="save-metadata" v-pre> + @if (Model.Expiry != null) + { + <text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()). </text> + } + <!--@Model.UploadWarning--> </div> } @@ -71,7 +85,7 @@ else if (Model.ParsedLog?.IsValid == true) @foreach (Platform platform in new[] { Platform.Android, Platform.Linux, Platform.Mac, Platform.Windows }) { <li> - <input type="radio" name="os" value="@platform" id="os-@platform" checked="@(Model.DetectedPlatform == platform)"/> + <input type="radio" name="os" value="@platform" id="os-@platform" checked="@(Model.DetectedPlatform == platform)" /> <label for="os-@platform">@platform</label> </li> } @@ -114,7 +128,7 @@ else if (Model.ParsedLog?.IsValid == true) </div> <h2>How do I share my log?</h2> - <form action="@Model.SectionUrl" method="post"> + <form action="@this.Url.PlainAction("PostAsync", "LogParser")" method="post"> <ol> <li> Drag the file onto this textbox (or paste the text in):<br /> @@ -151,7 +165,7 @@ else if (Model.ParsedLog?.IsValid == true) <div class="content-packs"> @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) { - <text>+ @contentPack.Name</text><br/> + <text>+ @contentPack.Name</text><br /> } </div> } @@ -173,7 +187,7 @@ else if (Model.ParsedLog?.IsValid == true) <div> @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) { - <a href="@contentPack.UpdateLink" target="_blank">@contentPack.Version → @contentPack.UpdateVersion</a><br/> + <a href="@contentPack.UpdateLink" target="_blank">@contentPack.Version → @contentPack.UpdateVersion</a><br /> } </div> } @@ -309,12 +323,12 @@ else if (Model.ParsedLog?.IsValid == true) } </table> - <small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))?raw=true">view raw log</a></small> + <small><a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, raw = true })">view raw log</a></small> } else { <pre v-pre>@Model.ParsedLog.RawText</pre> - <small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))">view parsed log</a></small> + <small><a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID })">view parsed log</a></small> } </div> } diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 87a22f06..17f1f673 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -1,4 +1,5 @@ @using Microsoft.Extensions.Options +@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.ConfigModels @inject IOptions<SiteConfig> SiteConfig @@ -15,15 +16,15 @@ <div id="sidebar"> <h4>SMAPI</h4> <ul> - <li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li> + <li><a href="@Url.PlainAction("Index", "Index")">About SMAPI</a></li> <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding docs</a></li> </ul> <h4>Tools</h4> <ul> - <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility</a></li> - <li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li> - <li><a href="@SiteConfig.Value.JsonValidatorUrl">JSON validator</a></li> + <li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility</a></li> + <li><a href="@Url.PlainAction("Index", "LogParser", values: null)">Log parser</a></li> + <li><a href="@Url.PlainAction("Index", "JsonValidator", values: null)">JSON validator</a></li> </ul> </div> <div id="content-column"> diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index baf7efb7..6b32f4ab 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -9,15 +9,14 @@ */ { "Site": { - "RootUrl": "http://localhost:59482/", - "ModListUrl": "http://localhost:59482/mods/", - "LogParserUrl": "http://localhost:59482/log/", - "JsonValidatorUrl": "http://localhost:59482/json/", "BetaEnabled": false, "BetaBlurb": null }, "ApiClients": { + "AmazonAccessKey": null, + "AmazonSecretKey": null, + "GitHubUsername": null, "GitHubPassword": null, diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 674bb672..f81587ef 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -16,17 +16,18 @@ }, "Site": { - "RootUrl": null, // see top note - "ModListUrl": null, // see top note - "LogParserUrl": null, // see top note - "JsonValidatorUrl": null, // see top note - "BetaEnabled": null, // see top note - "BetaBlurb": null // see top note + "BetaEnabled": null, + "BetaBlurb": null }, "ApiClients": { "UserAgent": "SMAPI/{0} (+https://smapi.io)", + "AmazonAccessKey": null, + "AmazonSecretKey": null, + "AmazonRegion": "us-east-1", + "AmazonLogBucket": "smapi-log-parser", + "ChucklefishBaseUrl": "https://community.playstarbound.com", "ChucklefishModPageUrlFormat": "resources/{0}", @@ -34,27 +35,27 @@ "GitHubBaseUrl": "https://api.github.com", "GitHubAcceptHeader": "application/vnd.github.v3+json", - "GitHubUsername": null, // see top note - "GitHubPassword": null, // see top note + "GitHubUsername": null, + "GitHubPassword": null, "ModDropApiUrl": "https://www.moddrop.com/api/mods/data", "ModDropModPageUrl": "https://www.moddrop.com/sdv/mod/{0}", - "NexusApiKey": null, // see top note + "NexusApiKey": null, "NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/", "NexusModUrlFormat": "mods/{0}", "NexusModScrapeUrlFormat": "mods/{0}?tab=files", "PastebinBaseUrl": "https://pastebin.com/", - "PastebinUserKey": null, // see top note - "PastebinDevKey": null // see top note + "PastebinUserKey": null, + "PastebinDevKey": null }, "MongoDB": { - "Host": null, // see top note - "Username": null, // see top note - "Password": null, // see top note - "Database": null // see top note + "Host": null, + "Username": null, + "Password": null, + "Database": null }, "ModCompatibilityList": { @@ -67,7 +68,6 @@ "ModUpdateCheck": { "SuccessCacheMinutes": 60, - "ErrorCacheMinutes": 5, - "CompatibilityPageUrl": "https://mods.smapi.io" + "ErrorCacheMinutes": 5 } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index d5013207..4d4ab326 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -47,6 +47,12 @@ table caption { background: #FCC; } +.save-metadata { + margin-top: 1em; + font-size: 0.8em; + opacity: 0.3; +} + /********* ** Log metadata & filters *********/ diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js index 5499cef6..76b5f6d4 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js +++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js @@ -142,6 +142,7 @@ smapi.jsonValidator = function (sectionUrl, pasteID) { }); // upload form + var submit = $("#submit"); var input = $("#input"); if (input.length) { // disable submit if it's empty diff --git a/src/SMAPI.sln b/src/SMAPI.sln index 73852d30..62eaa777 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -28,7 +28,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5 ProjectSection(SolutionItems) = preProject ..\build\common.targets = ..\build\common.targets ..\build\find-game-folder.targets = ..\build\find-game-folder.targets - ..\build\GlobalAssemblyInfo.cs = ..\build\GlobalAssemblyInfo.cs ..\build\prepare-install-package.targets = ..\build\prepare-install-package.targets ..\build\prepare-nuget-package.targets = ..\build\prepare-nuget-package.targets EndProjectSection @@ -78,6 +77,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit.CoreInterface EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web", "SMAPI.Web\SMAPI.Web.csproj", "{80EFD92F-728F-41E0-8A5B-9F6F49A91899}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web.LegacyRedirects", "SMAPI.Web.LegacyRedirects\SMAPI.Web.LegacyRedirects.csproj", "{159AA5A5-35C2-488C-B23F-1613C80594AE}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{85208f8d-6fd1-4531-be05-7142490f59fe}*SharedItemsImports = 13 @@ -131,6 +132,10 @@ Global {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.ActiveCfg = Release|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = Release|Any CPU + {159AA5A5-35C2-488C-B23F-1613C80594AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {159AA5A5-35C2-488C-B23F-1613C80594AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {159AA5A5-35C2-488C-B23F-1613C80594AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {159AA5A5-35C2-488C-B23F-1613C80594AE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 9b113733..7fdfb8d0 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.0.0"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.0.1"); /// <summary>The minimum supported version of Stardew Valley.</summary> public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0"); diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index cd88895c..29cfbc39 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -52,20 +52,34 @@ namespace StardewModdingAPI.Framework /// <param name="gameVersion">The game version string.</param> private static string GetSemanticVersionString(string gameVersion) { - return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion) - ? semanticVersion - : gameVersion; + // mapped version + if (GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)) + return semanticVersion; + + // special case: four-part versions + string[] parts = gameVersion.Split('.'); + if (parts.Length == 4) + return $"{parts[0]}.{parts[1]}.{parts[2]}+{parts[3]}"; + + return gameVersion; } /// <summary>Convert a semantic version string to the equivalent game version string.</summary> /// <param name="semanticVersion">The semantic version string.</param> private static string GetGameVersionString(string semanticVersion) { + // mapped versions foreach (var mapping in GameVersion.VersionMap) { if (mapping.Value.Equals(semanticVersion, StringComparison.InvariantCultureIgnoreCase)) return mapping.Key; } + + // special case: four-part versions + string[] parts = semanticVersion.Split('.', '+'); + if (parts.Length == 4) + return $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}"; + return semanticVersion; } } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 5ea21710..e73bc47d 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -87,7 +87,7 @@ namespace StardewModdingAPI.Framework.ModLoading updateUrls.Add(mod.DataRecord.AlternativeUrl); // default update URL - updateUrls.Add("https://mods.smapi.io"); + updateUrls.Add("https://smapi.io/mods"); // build error string error = $"{reasonPhrase}. Please check for a "; diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index b778af5d..53939f8c 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -23,7 +23,7 @@ namespace StardewModdingAPI.Framework.Models #endif [nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(), [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", - [nameof(WebApiBaseUrl)] = "https://api.smapi.io", + [nameof(WebApiBaseUrl)] = "https://smapi.io/api/", [nameof(VerboseLogging)] = false, [nameof(LogNetworkTraffic)] = false, [nameof(DumpMetadata)] = false diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index afb82679..f1873391 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -299,7 +299,7 @@ namespace StardewModdingAPI.Framework if (File.Exists(Constants.FatalCrashMarker)) { this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error); - this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", LogLevel.Error); + this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://smapi.io/log.", LogLevel.Error); this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); Console.ReadKey(); File.Delete(Constants.FatalCrashLog); @@ -962,7 +962,7 @@ namespace StardewModdingAPI.Framework } catch (IncompatibleInstructionException) // details already in trace logs { - string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://mods.smapi.io" }.Where(p => p != null).ToArray(); + string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/mods" }.Where(p => p != null).ToArray(); errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}"; return false; } diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index bccac678..a7381b91 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -52,7 +52,7 @@ The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to * Note: the protocol will be changed to http:// on Linux/Mac due to OpenSSL issues with the * game's bundled Mono. */ - "WebApiBaseUrl": "https://api.smapi.io", + "WebApiBaseUrl": "https://smapi.io/api/", /** * Whether SMAPI should log network traffic (may be very verbose). Best combined with VerboseLogging, which includes network metadata. diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index 0db41673..2a33ecef 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -28,6 +28,9 @@ namespace StardewModdingAPI /// <summary>An optional prerelease tag.</summary> public string PrereleaseTag => this.Version.PrereleaseTag; + /// <summary>Optional build metadata. This is ignored when determining version precedence.</summary> + public string BuildMetadata => this.Version.BuildMetadata; + /********* ** Public methods @@ -36,10 +39,11 @@ namespace StardewModdingAPI /// <param name="majorVersion">The major version incremented for major API changes.</param> /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param> /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> - /// <param name="build">An optional build tag.</param> + /// <param name="prerelease">An optional prerelease tag.</param> + /// <param name="build">Optional build metadata. This is ignored when determining version precedence.</param> [JsonConstructor] - public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string build = null) - : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, build)) { } + public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prerelease = null, string build = null) + : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, prerelease, build)) { } /// <summary>Construct an instance.</summary> /// <param name="version">The semantic version string.</param> diff --git a/src/SMAPI/i18n/ru.json b/src/SMAPI/i18n/ru.json new file mode 100644 index 00000000..a6a242fa --- /dev/null +++ b/src/SMAPI/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)" +} diff --git a/src/SMAPI/i18n/tr.json b/src/SMAPI/i18n/tr.json new file mode 100644 index 00000000..34229f2b --- /dev/null +++ b/src/SMAPI/i18n/tr.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut)." +} diff --git a/src/SMAPI/i18n/zh.json b/src/SMAPI/i18n/zh.json new file mode 100644 index 00000000..bbd6a574 --- /dev/null +++ b/src/SMAPI/i18n/zh.json @@ -0,0 +1,3 @@ +{
+ "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)"
+}
|