Files
ArchiSteamFarm/ArchiSteamFarm/IPC/Startup.cs
2022-12-15 19:23:46 +01:00

344 lines
14 KiB
C#

// _ _ _ ____ _ _____
// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
// |
// Copyright 2015-2022 Łukasz "JustArchi" Domeradzki
// Contact: JustArchi@JustArchi.net
// |
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// |
// http://www.apache.org/licenses/LICENSE-2.0
// |
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#if NETFRAMEWORK
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Converters;
using IWebHostEnvironment = Microsoft.AspNetCore.Hosting.IHostingEnvironment;
using IMvcBuilder = Microsoft.Extensions.DependencyInjection.IMvcCoreBuilder;
#endif
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Net;
using System.Reflection;
using ArchiSteamFarm.Core;
using ArchiSteamFarm.IPC.Integration;
using ArchiSteamFarm.Localization;
using ArchiSteamFarm.Plugins;
using ArchiSteamFarm.Storage;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;
using Microsoft.OpenApi.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace ArchiSteamFarm.IPC;
internal sealed class Startup {
private readonly IConfiguration Configuration;
public Startup(IConfiguration configuration) => Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "PathString is a primitive, it's unlikely to be trimmed to the best of our knowledge")]
[UsedImplicitly]
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(env);
// The order of dependency injection is super important, doing things in wrong order will break everything
// https://docs.microsoft.com/aspnet/core/fundamentals/middleware
// This one is easy, it's always in the beginning
if (Debugging.IsUserDebugging) {
app.UseDeveloperExceptionPage();
}
// Add support for proxies, this one comes usually after developer exception page, but could be before
app.UseForwardedHeaders();
if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
// Add support for response caching - must be called before static files as we want to cache those as well
app.UseResponseCaching();
}
// Add support for response compression - must be called before static files as we want to compress those as well
app.UseResponseCompression();
// It's not apparent when UsePathBase() should be called, but definitely before we get down to static files
// TODO: Maybe eventually we can get rid of this, https://github.com/aspnet/AspNetCore/issues/5898
PathString pathBase = Configuration.GetSection("Kestrel").GetValue<PathString>("PathBase");
if (!string.IsNullOrEmpty(pathBase) && (pathBase != "/")) {
app.UsePathBase(pathBase);
}
// The default HTML file (usually index.html) is responsible for IPC GUI routing, so re-execute all non-API calls on /
// This must be called before default files, because we don't know the exact file name that will be used for index page
app.UseWhen(static context => !context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseStatusCodePagesWithReExecute("/"));
// Add support for default root path redirection (GET / -> GET /index.html), must come before static files
app.UseDefaultFiles();
// Add support for static files (e.g. HTML, CSS and JS from IPC GUI)
app.UseStaticFiles(
new StaticFileOptions {
OnPrepareResponse = static context => {
if (context.File is { Exists: true, IsDirectory: false } && !string.IsNullOrEmpty(context.File.Name)) {
string extension = Path.GetExtension(context.File.Name);
CacheControlHeaderValue cacheControl = new();
switch (extension.ToUpperInvariant()) {
case ".CSS" or ".JS":
// Add support for SRI-protected static files
// SRI requires from us to notify the caller (especially proxy) to avoid modifying the data
cacheControl.NoTransform = true;
goto default;
default:
// Instruct the caller to always ask us first about every file it requests
// Contrary to the name, this doesn't prevent client from caching, but rather informs it that it must verify with us first that his cache is still up-to-date
// This is used to handle ASF and user updates to WWW root, we don't want from the client to ever use outdated scripts
cacheControl.NoCache = true;
// All static files are public by definition, we don't have any authorization here
cacheControl.Public = true;
break;
}
ResponseHeaders headers = context.Context.Response.GetTypedHeaders();
headers.CacheControl = cacheControl;
}
}
}
);
// Use routing for our API controllers, this should be called once we're done with all the static files mess
#if !NETFRAMEWORK
app.UseRouting();
#endif
// We want to protect our API with IPCPassword and additional security, this should be called after routing, so the middleware won't have to deal with API endpoints that do not exist
app.UseWhen(static context => context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseMiddleware<ApiAuthenticationMiddleware>());
string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
if (!string.IsNullOrEmpty(ipcPassword)) {
// We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API, this should be called before response compression, but can't be due to how our flow works
// We apply CORS policy only with IPCPassword set as an extra authentication measure
app.UseCors();
}
// Add support for websockets that we use e.g. in /Api/NLog
app.UseWebSockets();
// Finally register proper API endpoints once we're done with routing
#if NETFRAMEWORK
app.UseMvcWithDefaultRoute();
#else
app.UseEndpoints(static endpoints => endpoints.MapControllers());
#endif
// Add support for swagger, responsible for automatic API documentation generation, this should be on the end, once we're done with API
app.UseSwagger();
// Add support for swagger UI, this should be after swagger, obviously
app.UseSwaggerUI(
static options => {
options.DisplayRequestDuration();
options.EnableDeepLinking();
options.ShowExtensions();
options.SwaggerEndpoint($"{SharedInfo.ASF}/swagger.json", $"{SharedInfo.ASF} API");
}
);
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "HashSet<string> isn't a primitive, but we widely use the required features everywhere and it's unlikely to be trimmed to the best of our knowledge")]
public void ConfigureServices(IServiceCollection services) {
ArgumentNullException.ThrowIfNull(services);
// The order of dependency injection is super important, doing things in wrong order will break everything
// Order in Configure() method is a good start
// Prepare knownNetworks that we'll use in a second
HashSet<string>? knownNetworksTexts = Configuration.GetSection("Kestrel:KnownNetworks").Get<HashSet<string>>();
HashSet<IPNetwork>? knownNetworks = null;
if (knownNetworksTexts?.Count > 0) {
// Use specified known networks
knownNetworks = new HashSet<IPNetwork>();
foreach (string knownNetworkText in knownNetworksTexts) {
string[] addressParts = knownNetworkText.Split('/', StringSplitOptions.RemoveEmptyEntries);
if ((addressParts.Length != 2) || !IPAddress.TryParse(addressParts[0], out IPAddress? ipAddress) || !byte.TryParse(addressParts[1], out byte prefixLength)) {
ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(knownNetworkText)));
ASF.ArchiLogger.LogGenericDebug($"{nameof(knownNetworkText)}: {knownNetworkText}");
continue;
}
knownNetworks.Add(new IPNetwork(ipAddress, prefixLength));
}
}
// Add support for proxies
services.Configure<ForwardedHeadersOptions>(
options => {
options.ForwardedHeaders = ForwardedHeaders.All;
if (knownNetworks != null) {
foreach (IPNetwork knownNetwork in knownNetworks) {
options.KnownNetworks.Add(knownNetwork);
}
}
}
);
if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
// Add support for response caching
services.AddResponseCaching();
}
// Add support for response compression
services.AddResponseCompression();
string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
if (!string.IsNullOrEmpty(ipcPassword)) {
// We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API
// We apply CORS policy only with IPCPassword set as an extra authentication measure
services.AddCors(static options => options.AddDefaultPolicy(static policyBuilder => policyBuilder.AllowAnyOrigin()));
}
// Add support for swagger, responsible for automatic API documentation generation
services.AddSwaggerGen(
static options => {
options.AddSecurityDefinition(
nameof(GlobalConfig.IPCPassword), new OpenApiSecurityScheme {
Description = $"{nameof(GlobalConfig.IPCPassword)} authentication using request headers. Check {SharedInfo.ProjectURL}/wiki/IPC#authentication for more info.",
In = ParameterLocation.Header,
Name = ApiAuthenticationMiddleware.HeadersField,
Type = SecuritySchemeType.ApiKey
}
);
options.AddSecurityRequirement(
new OpenApiSecurityRequirement {
{
new OpenApiSecurityScheme {
Reference = new OpenApiReference {
Id = nameof(GlobalConfig.IPCPassword),
Type = ReferenceType.SecurityScheme
}
},
Array.Empty<string>()
}
}
);
// We require custom schema IDs due to conflicting type names, choosing the proper one is tricky as there is no good answer and any kind of convention has a potential to create conflict
// FullName and Name both do, ToString() for unknown to me reason doesn't, and I don't have courage to call our WebUtilities.GetUnifiedName() better than what .NET ships with (because it isn't)
// Let's use ToString() until we find a good enough reason to change it
options.CustomSchemaIds(static type => type.ToString());
options.EnableAnnotations(true, true);
options.SchemaFilter<CustomAttributesSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
options.SwaggerDoc(
SharedInfo.ASF, new OpenApiInfo {
Contact = new OpenApiContact {
Name = SharedInfo.GithubRepo,
Url = new Uri(SharedInfo.ProjectURL)
},
License = new OpenApiLicense {
Name = SharedInfo.LicenseName,
Url = new Uri(SharedInfo.LicenseURL)
},
Title = $"{SharedInfo.AssemblyName} API",
Version = SharedInfo.Version.ToString()
}
);
string xmlDocumentationFile = Path.Combine(AppContext.BaseDirectory, SharedInfo.AssemblyDocumentation);
if (File.Exists(xmlDocumentationFile)) {
options.IncludeXmlComments(xmlDocumentationFile);
}
}
);
// Add support for Newtonsoft.Json in swagger, this one must be executed after AddSwaggerGen()
services.AddSwaggerGenNewtonsoftSupport();
// We need MVC for /Api, but we're going to use only a small subset of all available features
IMvcBuilder mvc = services.AddControllers();
// Add support for controllers declared in custom plugins
if (PluginsCore.ActivePlugins?.Count > 0) {
HashSet<Assembly>? assemblies = PluginsCore.LoadAssemblies();
if (assemblies != null) {
foreach (Assembly assembly in assemblies) {
mvc.AddApplicationPart(assembly);
}
}
}
mvc.AddControllersAsServices();
#if NETFRAMEWORK
// Use latest compatibility version for MVC
mvc.SetCompatibilityVersion(CompatibilityVersion.Latest);
// Add standard formatters
mvc.AddFormatterMappings();
// Add API explorer for swagger
mvc.AddApiExplorer();
#endif
mvc.AddNewtonsoftJson(
static options => {
// Fix default contract resolver to use original names and not a camel case
options.SerializerSettings.ContractResolver = new DefaultContractResolver();
if (Debugging.IsUserDebugging) {
options.SerializerSettings.Formatting = Formatting.Indented;
}
#if NETFRAMEWORK
// .NET Framework serializes Version as object by default, serialize it as string just like .NET Core
options.SerializerSettings.Converters.Add(new VersionConverter());
#endif
}
);
}
}