migrate subdomain redirects to Azure

This commit is contained in:
Jesse Plamondon-Willard 2020-05-16 20:01:52 -04:00
parent 5e6f1640dc
commit 9d86f20ca7
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
14 changed files with 257 additions and 287 deletions

View File

@ -17,6 +17,7 @@
* For SMAPI developers: * For SMAPI developers:
* When deploying web services to a single-instance app, the MongoDB server can now be replaced with in-memory storage. * When deploying web services to a single-instance app, the MongoDB server can now be replaced with in-memory storage.
* Merged the separate legacy redirects app on AWS into the main app on Azure.
## 3.5 ## 3.5
Released 27 April 2020 for Stardew Valley 1.4.1 or later. Released 27 April 2020 for Stardew Valley 1.4.1 or later.

View File

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

View File

@ -1,37 +0,0 @@
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite;
namespace SMAPI.Web.LegacyRedirects.Framework
{
/// <summary>Rewrite requests to prepend the subdomain portion (if any) to the path.</summary>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" />.</remarks>
internal class LambdaRewriteRule : IRule
{
/*********
** Accessors
*********/
/// <summary>Rewrite an HTTP request if needed.</summary>
private readonly Action<RewriteContext, HttpRequest, HttpResponse> Rewrite;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="rewrite">Rewrite an HTTP request if needed.</param>
public LambdaRewriteRule(Action<RewriteContext, HttpRequest, HttpResponse> rewrite)
{
this.Rewrite = rewrite ?? throw new ArgumentNullException(nameof(rewrite));
}
/// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
/// <param name="context">The rewrite context.</param>
public void ApplyRule(RewriteContext context)
{
HttpRequest request = context.HttpContext.Request;
HttpResponse response = context.HttpContext.Response;
this.Rewrite(context, request, response);
}
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,54 @@
using System;
using System.Net;
using Microsoft.AspNetCore.Rewrite;
namespace StardewModdingAPI.Web.Framework.RedirectRules
{
/// <summary>Redirect hostnames to a URL if they match a condition.</summary>
internal class RedirectHostsToUrlsRule : RedirectMatchRule
{
/*********
** Fields
*********/
/// <summary>Maps a lowercase hostname to the resulting redirect URL.</summary>
private readonly Func<string, string> Map;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="statusCode">The status code to use for redirects.</param>
/// <param name="map">Hostnames mapped to the resulting redirect URL.</param>
public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string> map)
{
this.StatusCode = statusCode;
this.Map = map ?? throw new ArgumentNullException(nameof(map));
}
/*********
** Private methods
*********/
/// <summary>Get the new redirect URL.</summary>
/// <param name="context">The rewrite context.</param>
/// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
protected override string GetNewUrl(RewriteContext context)
{
// get requested host
string host = context.HttpContext.Request.Host.Host;
if (host == null)
return null;
// get new host
host = this.Map(host);
if (host == null)
return null;
// rewrite URL
UriBuilder uri = this.GetUrl(context.HttpContext.Request);
uri.Host = host;
return uri.ToString();
}
}
}

View File

@ -0,0 +1,58 @@
using System;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite;
namespace StardewModdingAPI.Web.Framework.RedirectRules
{
/// <summary>Redirect matching requests to a URL.</summary>
internal abstract class RedirectMatchRule : IRule
{
/*********
** Fields
*********/
/// <summary>The status code to use for redirects.</summary>
protected HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Redirect;
/*********
** Public methods
*********/
/// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
/// <param name="context">The rewrite context.</param>
public void ApplyRule(RewriteContext context)
{
string newUrl = this.GetNewUrl(context);
if (newUrl == null)
return;
HttpResponse response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.Redirect;
response.Headers["Location"] = newUrl;
context.Result = RuleResult.EndResponse;
}
/*********
** Protected methods
*********/
/// <summary>Get the new redirect URL.</summary>
/// <param name="context">The rewrite context.</param>
/// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
protected abstract string GetNewUrl(RewriteContext context);
/// <summary>Get the full request URL.</summary>
/// <param name="request">The request.</param>
protected UriBuilder GetUrl(HttpRequest request)
{
return new UriBuilder
{
Scheme = request.Scheme,
Host = request.Host.Host,
Port = request.Host.Port ?? -1,
Path = request.PathBase + request.Path,
Query = request.QueryString.Value
};
}
}
}

View File

@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Rewrite;
namespace StardewModdingAPI.Web.Framework.RedirectRules
{
/// <summary>Redirect paths to URLs if they match a condition.</summary>
internal class RedirectPathsToUrlsRule : RedirectMatchRule
{
/*********
** Fields
*********/
/// <summary>Regex patterns matching the current URL mapped to the resulting redirect URL.</summary>
private readonly IDictionary<Regex, string> Map;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="map">Regex patterns matching the current URL mapped to the resulting redirect URL.</param>
public RedirectPathsToUrlsRule(IDictionary<string, string> map)
{
this.Map = map.ToDictionary(
p => new Regex(p.Key, RegexOptions.IgnoreCase | RegexOptions.Compiled),
p => p.Value
);
}
/*********
** Protected methods
*********/
/// <summary>Get the new redirect URL.</summary>
/// <param name="context">The rewrite context.</param>
/// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
protected override string GetNewUrl(RewriteContext context)
{
string path = context.HttpContext.Request.Path.Value;
if (!string.IsNullOrWhiteSpace(path))
{
foreach ((Regex pattern, string url) in this.Map)
{
if (pattern.IsMatch(path))
return pattern.Replace(path, url);
}
}
return null;
}
}
}

View File

@ -0,0 +1,47 @@
using System;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite;
namespace StardewModdingAPI.Web.Framework.RedirectRules
{
/// <summary>Redirect requests to HTTPS.</summary>
internal class RedirectToHttpsRule : RedirectMatchRule
{
/*********
** Fields
*********/
/// <summary>Matches requests which should be ignored.</summary>
private readonly Func<HttpRequest, bool> Except;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="except">Matches requests which should be ignored.</param>
public RedirectToHttpsRule(Func<HttpRequest, bool> except = null)
{
this.Except = except ?? (req => false);
this.StatusCode = HttpStatusCode.RedirectKeepVerb;
}
/*********
** Protected methods
*********/
/// <summary>Get the new redirect URL.</summary>
/// <param name="context">The rewrite context.</param>
/// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns>
protected override string GetNewUrl(RewriteContext context)
{
HttpRequest request = context.HttpContext.Request;
if (request.IsHttps || this.Except(request))
return null;
UriBuilder uri = this.GetUrl(request);
uri.Scheme = "https";
return uri.ToString();
}
}
}

View File

@ -1,62 +0,0 @@
using System;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite;
namespace StardewModdingAPI.Web.Framework.RewriteRules
{
/// <summary>Redirect requests to HTTPS.</summary>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" /> and <see cref="Microsoft.AspNetCore.Rewrite.Internal.RedirectToHttpsRule"/>.</remarks>
internal class ConditionalRedirectToHttpsRule : IRule
{
/*********
** Fields
*********/
/// <summary>A predicate which indicates when the rule should be applied.</summary>
private readonly Func<HttpRequest, bool> ShouldRewrite;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
public ConditionalRedirectToHttpsRule(Func<HttpRequest, bool> shouldRewrite = null)
{
this.ShouldRewrite = shouldRewrite ?? (req => true);
}
/// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
/// <param name="context">The rewrite context.</param>
public void ApplyRule(RewriteContext context)
{
HttpRequest request = context.HttpContext.Request;
// check condition
if (this.IsSecure(request) || !this.ShouldRewrite(request))
return;
// redirect request
HttpResponse response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.RedirectKeepVerb;
response.Headers["Location"] = new StringBuilder()
.Append("https://")
.Append(request.Host.Host)
.Append(request.PathBase)
.Append(request.Path)
.Append(request.QueryString)
.ToString();
context.Result = RuleResult.EndResponse;
}
/// <summary>Get whether the request was received over HTTPS.</summary>
/// <param name="request">The request to check.</param>
public bool IsSecure(HttpRequest request)
{
return
request.IsHttps // HTTPS to server
|| string.Equals(request.Headers["x-forwarded-proto"], "HTTPS", StringComparison.OrdinalIgnoreCase); // HTTPS to AWS load balancer
}
}
}

View File

@ -1,57 +0,0 @@
using System;
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite;
namespace StardewModdingAPI.Web.Framework.RewriteRules
{
/// <summary>Redirect requests to an external URL if they match a condition.</summary>
internal class RedirectToUrlRule : IRule
{
/*********
** Fields
*********/
/// <summary>Get the new URL to which to redirect (or <c>null</c> to skip).</summary>
private readonly Func<HttpRequest, string> NewUrl;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
/// <param name="url">The new URL to which to redirect.</param>
public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url)
{
this.NewUrl = req => shouldRewrite(req) ? url : null;
}
/// <summary>Construct an instance.</summary>
/// <param name="pathRegex">A case-insensitive regex to match against the path.</param>
/// <param name="url">The external URL.</param>
public RedirectToUrlRule(string pathRegex, string url)
{
Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled);
this.NewUrl = req => req.Path.HasValue ? regex.Replace(req.Path.Value, url) : null;
}
/// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
/// <param name="context">The rewrite context.</param>
public void ApplyRule(RewriteContext context)
{
HttpRequest request = context.HttpContext.Request;
// check rewrite
string newUrl = this.NewUrl(request);
if (newUrl == null || newUrl == request.Path.Value)
return;
// redirect request
HttpResponse response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.Redirect;
response.Headers["Location"] = newUrl;
context.Result = RuleResult.EndResponse;
}
}
}

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net;
using Hangfire; using Hangfire;
using Hangfire.MemoryStorage; using Hangfire.MemoryStorage;
using Hangfire.Mongo; using Hangfire.Mongo;
@ -27,7 +28,7 @@ using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.RewriteRules; using StardewModdingAPI.Web.Framework.RedirectRules;
using StardewModdingAPI.Web.Framework.Storage; using StardewModdingAPI.Web.Framework.Storage;
namespace StardewModdingAPI.Web namespace StardewModdingAPI.Web
@ -270,26 +271,49 @@ namespace StardewModdingAPI.Web
/// <summary>Get the redirect rules to apply.</summary> /// <summary>Get the redirect rules to apply.</summary>
private RewriteOptions GetRedirectRules() private RewriteOptions GetRedirectRules()
{ {
var redirects = new RewriteOptions(); var redirects = new RewriteOptions()
// shortcut paths
.Add(new RedirectPathsToUrlsRule(new Dictionary<string, string>
{
[@"^/3\.0\.?$"] = "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0",
[@"^/(?:buildmsg|package)(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", // buildmsg deprecated, remove when SDV 1.4 is released
[@"^/community\.?$"] = "https://stardewvalleywiki.com/Modding:Community",
[@"^/compat\.?$"] = "https://smapi.io/mods",
[@"^/docs\.?$"] = "https://stardewvalleywiki.com/Modding:Index",
[@"^/install\.?$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI",
[@"^/troubleshoot(.*)$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1",
[@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods"
}))
// redirect to HTTPS (except API for Linux/Mac Mono compatibility) // legacy paths
redirects.Add(new ConditionalRedirectToHttpsRule( .Add(new RedirectPathsToUrlsRule(this.GetLegacyPathRedirects()))
shouldRewrite: req =>
req.Host.Host != "localhost"
&& !req.Path.StartsWithSegments("/api")
));
// shortcut redirects // subdomains
redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0")); .Add(new RedirectHostsToUrlsRule(HttpStatusCode.PermanentRedirect, host => host switch
redirects.Add(new RedirectToUrlRule(@"^/(?:buildmsg|package)(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1")); // buildmsg deprecated, remove when SDV 1.4 is released {
redirects.Add(new RedirectToUrlRule(@"^/community\.?$", "https://stardewvalleywiki.com/Modding:Community")); "api.smapi.io" => "smapi.io/api",
redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://smapi.io/mods")); "json.smapi.io" => "smapi.io/json",
redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); "log.smapi.io" => "smapi.io/log",
redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); "mods.smapi.io" => "smapi.io/mods",
redirects.Add(new RedirectToUrlRule(@"^/troubleshoot(.*)$", "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1")); _ => host.EndsWith(".smapi.io")
redirects.Add(new RedirectToUrlRule(@"^/xnb\.?$", "https://stardewvalleywiki.com/Modding:Using_XNB_mods")); ? "smapi.io"
: null
}))
// redirect legacy canimod.com URLs // redirect to HTTPS (except API for Linux/Mac Mono compatibility)
.Add(
new RedirectToHttpsRule(except: req => req.Host.Host == "localhost" || req.Path.StartsWithSegments("/api"))
);
return redirects;
}
/// <summary>Get the redirects for legacy paths that have been moved elsewhere.</summary>
private IDictionary<string, string> GetLegacyPathRedirects()
{
var redirects = new Dictionary<string, string>();
// canimod.com => wiki
var wikiRedirects = new Dictionary<string, string[]> var wikiRedirects = new Dictionary<string, string[]>
{ {
["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" }, ["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" },
@ -306,7 +330,7 @@ namespace StardewModdingAPI.Web
foreach ((string page, string[] patterns) in wikiRedirects) foreach ((string page, string[] patterns) in wikiRedirects)
{ {
foreach (string pattern in patterns) foreach (string pattern in patterns)
redirects.Add(new RedirectToUrlRule(pattern, "https://stardewvalleywiki.com/" + page)); redirects.Add(pattern, "https://stardewvalleywiki.com/" + page);
} }
return redirects; return redirects;

View File

@ -77,8 +77,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit.CoreInterface
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web", "SMAPI.Web\SMAPI.Web.csproj", "{80EFD92F-728F-41E0-8A5B-9F6F49A91899}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web", "SMAPI.Web\SMAPI.Web.csproj", "{80EFD92F-728F-41E0-8A5B-9F6F49A91899}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web.LegacyRedirects", "SMAPI.Web.LegacyRedirects\SMAPI.Web.LegacyRedirects.csproj", "{159AA5A5-35C2-488C-B23F-1613C80594AE}"
EndProject
Global Global
GlobalSection(SharedMSBuildProjectFiles) = preSolution GlobalSection(SharedMSBuildProjectFiles) = preSolution
SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5
@ -138,10 +136,6 @@ Global
{80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU {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.ActiveCfg = Release|Any CPU
{80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE