diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index d094242259..33fd77d9c7 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,4 +1,5 @@ using MediaBrowser.Controller.Configuration; +using Jellyfin.Server.Middleware; using Microsoft.AspNetCore.Builder; namespace Jellyfin.Server.Extensions diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 00a73ade6f..a24785d57e 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; +using Jellyfin.Server.Formatters; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -71,6 +72,8 @@ namespace Jellyfin.Server.Extensions return serviceCollection.AddMvc(opts => { opts.UseGeneralRoutePrefix(baseUrl); + opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter()); + opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter()); }) // Clear app parts to avoid other assemblies being picked up diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs new file mode 100644 index 0000000000..e6ad6dfb13 --- /dev/null +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -0,0 +1,21 @@ +using Jellyfin.Server.Models; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Server.Formatters +{ + /// + /// Camel Case Json Profile Formatter. + /// + public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + { + /// + /// Initializes a new instance of the class. + /// + public CamelCaseJsonProfileFormatter() : base(JsonOptions.CamelCase) + { + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\"")); + } + } +} diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs new file mode 100644 index 0000000000..675f4c79ee --- /dev/null +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -0,0 +1,23 @@ +using Jellyfin.Server.Models; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Server.Formatters +{ + /// + /// Pascal Case Json Profile Formatter. + /// + public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + { + /// + /// Initializes a new instance of the class. + /// + public PascalCaseJsonProfileFormatter() : base(JsonOptions.PascalCase) + { + SupportedMediaTypes.Clear(); + // Add application/json for default formatter + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"PascalCase\"")); + } + } +} diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs new file mode 100644 index 0000000000..0d79bbfaff --- /dev/null +++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,145 @@ +using System; +using System.IO; +using System.Net.Mime; +using System.Net.Sockets; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// + /// Exception Middleware. + /// + public class ExceptionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IServerConfigurationManager _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// Next request delegate. + /// Instance of the interface. + /// Instance of the interface. + public ExceptionMiddleware( + RequestDelegate next, + ILogger logger, + IServerConfigurationManager serverConfigurationManager) + { + _next = next; + _logger = logger; + _configuration = serverConfigurationManager; + } + + /// + /// Invoke request. + /// + /// Request context. + /// Task. + public async Task Invoke(HttpContext context) + { + try + { + await _next(context).ConfigureAwait(false); + } + catch (Exception ex) + { + if (context.Response.HasStarted) + { + _logger.LogWarning("The response has already started, the exception middleware will not be executed."); + throw; + } + + ex = GetActualException(ex); + + bool ignoreStackTrace = + ex is SocketException + || ex is IOException + || ex is OperationCanceledException + || ex is SecurityException + || ex is AuthenticationException + || ex is FileNotFoundException; + + if (ignoreStackTrace) + { + _logger.LogError( + "Error processing request: {ExceptionMessage}. URL {Method} {Url}.", + ex.Message.TrimEnd('.'), + context.Request.Method, + context.Request.Path); + } + else + { + _logger.LogError( + ex, + "Error processing request. URL {Method} {Url}.", + context.Request.Method, + context.Request.Path); + } + + context.Response.StatusCode = GetStatusCode(ex); + context.Response.ContentType = MediaTypeNames.Text.Plain; + var errorContent = NormalizeExceptionMessage(ex.Message); + await context.Response.WriteAsync(errorContent).ConfigureAwait(false); + } + } + + private static Exception GetActualException(Exception ex) + { + if (ex is AggregateException agg) + { + var inner = agg.InnerException; + if (inner != null) + { + return GetActualException(inner); + } + + var inners = agg.InnerExceptions; + if (inners.Count > 0) + { + return GetActualException(inners[0]); + } + } + + return ex; + } + + private static int GetStatusCode(Exception ex) + { + switch (ex) + { + case ArgumentException _: return StatusCodes.Status400BadRequest; + case SecurityException _: return StatusCodes.Status401Unauthorized; + case DirectoryNotFoundException _: + case FileNotFoundException _: + case ResourceNotFoundException _: return StatusCodes.Status404NotFound; + case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; + default: return StatusCodes.Status500InternalServerError; + } + } + + private string NormalizeExceptionMessage(string msg) + { + if (msg == null) + { + return string.Empty; + } + + // Strip any information we don't want to reveal + return msg.Replace( + _configuration.ApplicationPaths.ProgramSystemPath, + string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace( + _configuration.ApplicationPaths.ProgramDataPath, + string.Empty, + StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Jellyfin.Server/Models/JsonOptions.cs b/Jellyfin.Server/Models/JsonOptions.cs new file mode 100644 index 0000000000..2f0df3d2c7 --- /dev/null +++ b/Jellyfin.Server/Models/JsonOptions.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace Jellyfin.Server.Models +{ + /// + /// Json Options. + /// + public static class JsonOptions + { + /// + /// Gets CamelCase json options. + /// + public static JsonSerializerOptions CamelCase + { + get + { + var options = DefaultJsonOptions; + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + return options; + } + } + + /// + /// Gets PascalCase json options. + /// + public static JsonSerializerOptions PascalCase + { + get + { + var options = DefaultJsonOptions; + options.PropertyNamingPolicy = null; + return options; + } + } + + /// + /// Gets base Json Serializer Options. + /// + private static JsonSerializerOptions DefaultJsonOptions => new JsonSerializerOptions(); + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index ee08d2580a..7c49afbfc6 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,4 +1,5 @@ using Jellyfin.Server.Extensions; +using Jellyfin.Server.Middleware; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; @@ -58,6 +59,8 @@ namespace Jellyfin.Server app.UseDeveloperExceptionPage(); } + app.UseMiddleware(); + app.UseWebSockets(); app.UseResponseCompression();