diff --git a/ArchiSteamFarm/Helpers/Json/JsonUtilities.cs b/ArchiSteamFarm/Helpers/Json/JsonUtilities.cs index 89003114e..3888075f6 100644 --- a/ArchiSteamFarm/Helpers/Json/JsonUtilities.cs +++ b/ArchiSteamFarm/Helpers/Json/JsonUtilities.cs @@ -42,6 +42,7 @@ public static class JsonUtilities { public static readonly JsonSerializerOptions IndentedJsonSerialierOptions = CreateDefaultJsonSerializerOptions(true); [PublicAPI] + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] public static JsonElement ToJsonElement(this T obj) where T : notnull { ArgumentNullException.ThrowIfNull(obj); @@ -49,9 +50,11 @@ public static class JsonUtilities { } [PublicAPI] + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] public static T? ToJsonObject(this JsonElement jsonElement) => jsonElement.Deserialize(DefaultJsonSerialierOptions); [PublicAPI] + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] public static async ValueTask ToJsonObject(this Stream stream, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(stream); @@ -59,6 +62,7 @@ public static class JsonUtilities { } [PublicAPI] + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] public static T? ToJsonObject([StringSyntax(StringSyntaxAttribute.Json)] this string json) { ArgumentException.ThrowIfNullOrEmpty(json); @@ -66,6 +70,7 @@ public static class JsonUtilities { } [PublicAPI] + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] public static string ToJsonText(this T obj, bool writeIndented = false) => JsonSerializer.Serialize(obj, writeIndented ? IndentedJsonSerialierOptions : DefaultJsonSerialierOptions); private static void ApplyCustomModifiers(JsonTypeInfo jsonTypeInfo) { @@ -102,6 +107,7 @@ public static class JsonUtilities { } } + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] private static JsonSerializerOptions CreateDefaultJsonSerializerOptions(bool writeIndented = false) => new() { AllowTrailingCommas = true, diff --git a/ArchiSteamFarm/IPC/ArchiKestrel.cs b/ArchiSteamFarm/IPC/ArchiKestrel.cs index 1bc14c8dc..6d3457620 100644 --- a/ArchiSteamFarm/IPC/ArchiKestrel.cs +++ b/ArchiSteamFarm/IPC/ArchiKestrel.cs @@ -20,29 +20,50 @@ // limitations under the License. using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Threading.Tasks; using ArchiSteamFarm.Core; using ArchiSteamFarm.Helpers.Json; using ArchiSteamFarm.IPC.Controllers.Api; +using ArchiSteamFarm.IPC.Integration; using ArchiSteamFarm.Localization; using ArchiSteamFarm.NLog; using ArchiSteamFarm.NLog.Targets; +using ArchiSteamFarm.Plugins; +using ArchiSteamFarm.Plugins.Interfaces; +using ArchiSteamFarm.Storage; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Headers; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi.Models; using NLog.Web; +using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace ArchiSteamFarm.IPC; internal static class ArchiKestrel { - internal static bool IsRunning => KestrelWebHost != null; + internal static bool IsRunning => WebApplication != null; internal static HistoryTarget? HistoryTarget { get; private set; } - private static IHost? KestrelWebHost; + private static WebApplication? WebApplication; internal static void OnNewHistoryTarget(HistoryTarget? historyTarget = null) { if (HistoryTarget != null) { @@ -57,102 +78,424 @@ internal static class ArchiKestrel { } internal static async Task Start() { - if (KestrelWebHost != null) { + if (WebApplication != null) { return; } ASF.ArchiLogger.LogGenericInfo(Strings.IPCStarting); - // The order of dependency injection matters, pay attention to it - HostBuilder builder = new(); - - string customDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.WebsiteDirectory); - string websiteDirectory = Directory.Exists(customDirectory) ? customDirectory : Path.Combine(AppContext.BaseDirectory, SharedInfo.WebsiteDirectory); - - // Set default content root - builder.UseContentRoot(SharedInfo.HomeDirectory); - - // Check if custom config is available - string absoluteConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.ConfigDirectory); - string customConfigPath = Path.Combine(absoluteConfigDirectory, SharedInfo.IPCConfigFile); - - bool customConfigExists = File.Exists(customConfigPath); - - if (customConfigExists && Debugging.IsDebugConfigured) { - try { - string json = await File.ReadAllTextAsync(customConfigPath).ConfigureAwait(false); - - if (!string.IsNullOrEmpty(json)) { - JsonNode? jsonNode = JsonNode.Parse(json); - - ASF.ArchiLogger.LogGenericDebug($"{SharedInfo.IPCConfigFile}: {jsonNode?.ToJsonText(true) ?? "null"}"); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - // Enable NLog integration for logging - builder.ConfigureLogging( - static logging => { - logging.ClearProviders(); - logging.SetMinimumLevel(Debugging.IsUserDebugging ? LogLevel.Trace : LogLevel.Warning); - } - ); - - builder.UseNLog(new NLogAspNetCoreOptions { ShutdownOnDispose = false }); - - builder.ConfigureWebHostDefaults( - webBuilder => { - // Set default web root - if (Directory.Exists(websiteDirectory)) { - webBuilder.UseWebRoot(websiteDirectory); - } - - // Now conditionally initialize settings that are not possible to override - if (customConfigExists) { - // Set up custom config to be used - webBuilder.UseConfiguration(new ConfigurationBuilder().SetBasePath(absoluteConfigDirectory).AddJsonFile(SharedInfo.IPCConfigFile, false, Program.ConfigWatch).Build()); - - // Use custom config for Kestrel configuration - webBuilder.UseKestrel(static (builderContext, options) => options.Configure(builderContext.Configuration.GetSection("Kestrel"))); - } else { - // Use ASF defaults for Kestrel - webBuilder.UseKestrel(static options => options.ListenLocalhost(1242)); - } - - // Specify Startup class for IPC - webBuilder.UseStartup(); - } - ); - // Init history logger for /Api/Log usage Logging.InitHistoryLogger(); - // Start the server - IHost? kestrelWebHost = null; + WebApplication webApplication = await CreateWebApplication().ConfigureAwait(false); try { - kestrelWebHost = builder.Build(); - await kestrelWebHost.StartAsync().ConfigureAwait(false); + // Start the server + await webApplication.StartAsync().ConfigureAwait(false); } catch (Exception e) { ASF.ArchiLogger.LogGenericException(e); - kestrelWebHost?.Dispose(); + + await webApplication.DisposeAsync().ConfigureAwait(false); return; } - KestrelWebHost = kestrelWebHost; + WebApplication = webApplication; + ASF.ArchiLogger.LogGenericInfo(Strings.IPCReady); } internal static async Task Stop() { - if (KestrelWebHost == null) { + if (WebApplication == null) { return; } - await KestrelWebHost.StopAsync().ConfigureAwait(false); - KestrelWebHost.Dispose(); - KestrelWebHost = null; + await WebApplication.StopAsync().ConfigureAwait(false); + await WebApplication.DisposeAsync().ConfigureAwait(false); + + WebApplication = null; + } + + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "PathString is a primitive, it's unlikely to be trimmed to the best of our knowledge")] + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3000", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] + private static void ConfigureApp([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] ConfigurationManager configuration, IApplicationBuilder app) { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(app); + + // 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("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(); + + Dictionary pluginPaths = new(StringComparer.Ordinal); + + if (PluginsCore.ActivePlugins.Count > 0) { + foreach (IWebInterface plugin in PluginsCore.ActivePlugins.OfType()) { + if (string.IsNullOrEmpty(plugin.PhysicalPath) || string.IsNullOrEmpty(plugin.WebPath)) { + // Invalid path provided + continue; + } + + string physicalPath = plugin.PhysicalPath; + + if (!Path.IsPathRooted(physicalPath)) { + // Relative path + string? assemblyDirectory = Path.GetDirectoryName(plugin.GetType().Assembly.Location); + + if (string.IsNullOrEmpty(assemblyDirectory)) { + // Invalid path provided + continue; + } + + physicalPath = Path.Combine(assemblyDirectory, plugin.PhysicalPath); + } + + if (!Directory.Exists(physicalPath)) { + // Non-existing path provided + continue; + } + + pluginPaths[physicalPath] = plugin.WebPath; + + if (plugin.WebPath != "/") { + app.UseDefaultFiles(plugin.WebPath); + } + } + } + + // Add support for static files from custom plugins (e.g. HTML, CSS and JS) + foreach ((string physicalPath, string webPath) in pluginPaths) { + app.UseStaticFiles( + new StaticFileOptions { + FileProvider = new PhysicalFileProvider(physicalPath), + OnPrepareResponse = OnPrepareResponse, + RequestPath = webPath + } + ); + } + + // Add support for static files (e.g. HTML, CSS and JS from IPC GUI) + app.UseStaticFiles( + new StaticFileOptions { + OnPrepareResponse = OnPrepareResponse + } + ); + + // Use routing for our API controllers, this should be called once we're done with all the static files mess + app.UseRouting(); + + // 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()); + + 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 + app.UseEndpoints(static endpoints => endpoints.MapControllers()); + + // 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 = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] + private static void ConfigureServices([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] ConfigurationManager configuration, IServiceCollection services) { + ArgumentNullException.ThrowIfNull(configuration); + 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? knownNetworksTexts = configuration.GetSection("Kestrel:KnownNetworks").Get>(); + + HashSet? knownNetworks = null; + + if (knownNetworksTexts?.Count > 0) { + // Use specified known networks + knownNetworks = new HashSet(); + + foreach (string knownNetworkText in knownNetworksTexts) { + string[] addressParts = knownNetworkText.Split('/', 3, 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( + 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(static options => options.EnableForHttps = true); + + 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() + } + } + ); + + // 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, also, the name must pass ^[a-zA-Z0-9.-_]+$ regex + options.CustomSchemaIds(static type => type.ToString().Replace('+', '-')); + + options.EnableAnnotations(true, true); + + options.SchemaFilter(); + options.SchemaFilter(); + options.SchemaFilter(); + + 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); + } + } + ); + + // 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? assemblies = PluginsCore.LoadAssemblies(); + + if (assemblies != null) { + foreach (Assembly assembly in assemblies) { + mvc.AddApplicationPart(assembly); + } + } + } + + mvc.AddControllersAsServices(); + + mvc.AddJsonOptions( + static options => { + JsonSerializerOptions jsonSerializerOptions = Debugging.IsUserDebugging ? JsonUtilities.IndentedJsonSerialierOptions : JsonUtilities.DefaultJsonSerialierOptions; + + foreach (JsonConverter converter in jsonSerializerOptions.Converters) { + options.JsonSerializerOptions.Converters.Add(converter); + } + + options.JsonSerializerOptions.PropertyNamingPolicy = jsonSerializerOptions.PropertyNamingPolicy; + options.JsonSerializerOptions.TypeInfoResolver = jsonSerializerOptions.TypeInfoResolver; + options.JsonSerializerOptions.WriteIndented = jsonSerializerOptions.WriteIndented; + } + ); + } + + private static async Task CreateWebApplication() { + string customDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.WebsiteDirectory); + string websiteDirectory = Directory.Exists(customDirectory) ? customDirectory : Path.Combine(AppContext.BaseDirectory, SharedInfo.WebsiteDirectory); + + // The order of dependency injection matters, pay attention to it + WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder( + new WebApplicationOptions { + ApplicationName = SharedInfo.AssemblyName, + ContentRootPath = SharedInfo.HomeDirectory, + WebRootPath = websiteDirectory + } + ); + + // Enable NLog integration for logging + builder.Logging.SetMinimumLevel(Debugging.IsUserDebugging ? LogLevel.Trace : LogLevel.Warning); + builder.Logging.AddNLogWeb(new NLogAspNetCoreOptions { ShutdownOnDispose = false }); + + // Check if custom config is available + string absoluteConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.ConfigDirectory); + string customConfigPath = Path.Combine(absoluteConfigDirectory, SharedInfo.IPCConfigFile); + bool customConfigExists = File.Exists(customConfigPath); + + if (customConfigExists) { + if (Debugging.IsDebugConfigured) { + try { + string json = await File.ReadAllTextAsync(customConfigPath).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(json)) { + JsonNode? jsonNode = JsonNode.Parse(json); + + ASF.ArchiLogger.LogGenericDebug($"{SharedInfo.IPCConfigFile}: {jsonNode?.ToJsonText(true) ?? "null"}"); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + // Set up custom config to be used + builder.WebHost.UseConfiguration(new ConfigurationBuilder().SetBasePath(absoluteConfigDirectory).AddJsonFile(SharedInfo.IPCConfigFile, false, true).Build()); + } + + builder.WebHost.ConfigureKestrel( + options => { + options.AddServerHeader = false; + + if (customConfigExists) { + // Use custom config for Kestrel configuration + options.Configure(builder.Configuration.GetSection("Kestrel")); + } else { + // Use ASFB defaults for Kestrel + options.ListenLocalhost(1242); + } + } + ); + + builder.WebHost.UseKestrelCore(); + + ConfigureServices(builder.Configuration, builder.Services); + + WebApplication result = builder.Build(); + + ConfigureApp(builder.Configuration, result); + + return result; + } + + private static void OnPrepareResponse(StaticFileResponseContext context) { + ArgumentNullException.ThrowIfNull(context); + + if (context.File is not { Exists: true, IsDirectory: false } || string.IsNullOrEmpty(context.File.Name)) { + return; + } + + 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 their cache is still up-to-date + // This is used to handle ASF and user updates to WWW root, we don't want 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; } } diff --git a/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs b/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs index 5d7c65e7d..9fe860870 100644 --- a/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs +++ b/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs @@ -67,6 +67,7 @@ internal sealed class ApiAuthenticationMiddleware { } } + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] [UsedImplicitly] public async Task InvokeAsync(HttpContext context, IOptions jsonOptions) { ArgumentNullException.ThrowIfNull(context); diff --git a/ArchiSteamFarm/IPC/Startup.cs b/ArchiSteamFarm/IPC/Startup.cs deleted file mode 100644 index efa553463..000000000 --- a/ArchiSteamFarm/IPC/Startup.cs +++ /dev/null @@ -1,380 +0,0 @@ -// _ _ _ ____ _ _____ -// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ -// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ -// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | -// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | -// Copyright 2015-2024 Ɓ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. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; -using ArchiSteamFarm.Core; -using ArchiSteamFarm.Helpers.Json; -using ArchiSteamFarm.IPC.Integration; -using ArchiSteamFarm.Localization; -using ArchiSteamFarm.Plugins; -using ArchiSteamFarm.Plugins.Interfaces; -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.AspNetCore.StaticFiles; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Net.Http.Headers; -using Microsoft.OpenApi.Models; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; - -namespace ArchiSteamFarm.IPC; - -internal sealed class Startup { - private readonly IConfiguration Configuration; - - public Startup(IConfiguration configuration) { - ArgumentNullException.ThrowIfNull(configuration); - - Configuration = configuration; - } - - [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "PathString is a primitive, it's unlikely to be trimmed to the best of our knowledge")] - [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3000", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] - [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("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(); - - Dictionary pluginPaths = new(StringComparer.Ordinal); - - if (PluginsCore.ActivePlugins.Count > 0) { - foreach (IWebInterface plugin in PluginsCore.ActivePlugins.OfType()) { - if (string.IsNullOrEmpty(plugin.PhysicalPath) || string.IsNullOrEmpty(plugin.WebPath)) { - // Invalid path provided - continue; - } - - string physicalPath = plugin.PhysicalPath; - - if (!Path.IsPathRooted(physicalPath)) { - // Relative path - string? assemblyDirectory = Path.GetDirectoryName(plugin.GetType().Assembly.Location); - - if (string.IsNullOrEmpty(assemblyDirectory)) { - // Invalid path provided - continue; - } - - physicalPath = Path.Combine(assemblyDirectory, plugin.PhysicalPath); - } - - if (!Directory.Exists(physicalPath)) { - // Non-existing path provided - continue; - } - - pluginPaths[physicalPath] = plugin.WebPath; - - if (plugin.WebPath != "/") { - app.UseDefaultFiles(plugin.WebPath); - } - } - } - - // Add support for static files from custom plugins (e.g. HTML, CSS and JS) - foreach ((string physicalPath, string webPath) in pluginPaths) { - app.UseStaticFiles( - new StaticFileOptions { - FileProvider = new PhysicalFileProvider(physicalPath), - OnPrepareResponse = OnPrepareResponse, - RequestPath = webPath - } - ); - } - - // Add support for static files (e.g. HTML, CSS and JS from IPC GUI) - app.UseStaticFiles( - new StaticFileOptions { - OnPrepareResponse = OnPrepareResponse - } - ); - - // Use routing for our API controllers, this should be called once we're done with all the static files mess - app.UseRouting(); - - // 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()); - - 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 - app.UseEndpoints(static endpoints => endpoints.MapControllers()); - - // 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 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? knownNetworksTexts = Configuration.GetSection("Kestrel:KnownNetworks").Get>(); - - HashSet? knownNetworks = null; - - if (knownNetworksTexts?.Count > 0) { - // Use specified known networks - knownNetworks = new HashSet(); - - foreach (string knownNetworkText in knownNetworksTexts) { - string[] addressParts = knownNetworkText.Split('/', 3, 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( - 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(static options => options.EnableForHttps = true); - - 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() - } - } - ); - - // 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, also, the name must pass ^[a-zA-Z0-9.-_]+$ regex - options.CustomSchemaIds(static type => type.ToString().Replace('+', '-')); - - options.EnableAnnotations(true, true); - - options.SchemaFilter(); - options.SchemaFilter(); - options.SchemaFilter(); - - 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); - } - } - ); - - // 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? assemblies = PluginsCore.LoadAssemblies(); - - if (assemblies != null) { - foreach (Assembly assembly in assemblies) { - mvc.AddApplicationPart(assembly); - } - } - } - - mvc.AddControllersAsServices(); - - mvc.AddJsonOptions( - static options => { - JsonSerializerOptions jsonSerializerOptions = Debugging.IsUserDebugging ? JsonUtilities.IndentedJsonSerialierOptions : JsonUtilities.DefaultJsonSerialierOptions; - - foreach (JsonConverter converter in jsonSerializerOptions.Converters) { - options.JsonSerializerOptions.Converters.Add(converter); - } - - options.JsonSerializerOptions.PropertyNamingPolicy = jsonSerializerOptions.PropertyNamingPolicy; - options.JsonSerializerOptions.TypeInfoResolver = jsonSerializerOptions.TypeInfoResolver; - options.JsonSerializerOptions.WriteIndented = jsonSerializerOptions.WriteIndented; - } - ); - } - - private static void OnPrepareResponse(StaticFileResponseContext context) { - ArgumentNullException.ThrowIfNull(context); - - if (context.File is not { Exists: true, IsDirectory: false } || string.IsNullOrEmpty(context.File.Name)) { - return; - } - - 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 their cache is still up-to-date - // This is used to handle ASF and user updates to WWW root, we don't want 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; - } -} diff --git a/ArchiSteamFarm/NLog/Logging.cs b/ArchiSteamFarm/NLog/Logging.cs index 8e9939826..ceab6f449 100644 --- a/ArchiSteamFarm/NLog/Logging.cs +++ b/ArchiSteamFarm/NLog/Logging.cs @@ -441,9 +441,9 @@ internal static class Logging { if (!Debugging.IsUserDebugging) { // Silence default ASP.NET logging - config.LoggingRules.Add(new LoggingRule("Microsoft*", target) { FinalMinLevel = LogLevel.Warn }); - config.LoggingRules.Add(new LoggingRule("Microsoft.Hosting.Lifetime*", target) { FinalMinLevel = LogLevel.Info }); - config.LoggingRules.Add(new LoggingRule("System*", target) { FinalMinLevel = LogLevel.Warn }); + config.LoggingRules.Add(new LoggingRule("Microsoft.*", target) { FinalMinLevel = LogLevel.Warn }); + config.LoggingRules.Add(new LoggingRule("Microsoft.Hosting.Lifetime", target) { FinalMinLevel = LogLevel.Info }); + config.LoggingRules.Add(new LoggingRule("System.*", target) { FinalMinLevel = LogLevel.Warn }); } config.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, target)); diff --git a/ArchiSteamFarm/Web/WebBrowser.cs b/ArchiSteamFarm/Web/WebBrowser.cs index f0fa3ebf8..ab50e12b3 100644 --- a/ArchiSteamFarm/Web/WebBrowser.cs +++ b/ArchiSteamFarm/Web/WebBrowser.cs @@ -22,6 +22,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -707,6 +708,7 @@ public sealed class WebBrowser : IDisposable { return await InternalRequest(request, HttpMethod.Post, headers, data, referer, requestOptions, httpCompletionOption, cancellationToken: cancellationToken).ConfigureAwait(false); } + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] private async Task InternalRequest(Uri request, HttpMethod httpMethod, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, byte maxRedirections = MaxTries, CancellationToken cancellationToken = default) where T : class { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(httpMethod);