diff options
Diffstat (limited to 'Jellyfin.Server')
| -rw-r--r-- | Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs | 35 | ||||
| -rw-r--r-- | Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 78 | ||||
| -rw-r--r-- | Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs | 4 | ||||
| -rw-r--r-- | Jellyfin.Server/Formatters/CssOutputFormatter.cs | 36 | ||||
| -rw-r--r-- | Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs | 4 | ||||
| -rw-r--r-- | Jellyfin.Server/Middleware/ExceptionMiddleware.cs | 155 | ||||
| -rw-r--r-- | Jellyfin.Server/Models/JsonOptions.cs | 41 | ||||
| -rw-r--r-- | Jellyfin.Server/Models/ServerCorsPolicy.cs | 30 | ||||
| -rw-r--r-- | Jellyfin.Server/Program.cs | 2 | ||||
| -rw-r--r-- | Jellyfin.Server/Startup.cs | 9 |
10 files changed, 336 insertions, 58 deletions
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index db06eb455..745567703 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,3 +1,4 @@ +using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; namespace Jellyfin.Server.Extensions @@ -11,17 +12,39 @@ namespace Jellyfin.Server.Extensions /// Adds swagger and swagger UI to the application pipeline. /// </summary> /// <param name="applicationBuilder">The application builder.</param> + /// <param name="serverConfigurationManager">The server configuration.</param> /// <returns>The updated application builder.</returns> - public static IApplicationBuilder UseJellyfinApiSwagger(this IApplicationBuilder applicationBuilder) + public static IApplicationBuilder UseJellyfinApiSwagger( + this IApplicationBuilder applicationBuilder, + IServerConfigurationManager serverConfigurationManager) { - applicationBuilder.UseSwagger(); - // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), // specifying the Swagger JSON endpoint. - return applicationBuilder.UseSwaggerUI(c => + + var baseUrl = serverConfigurationManager.Configuration.BaseUrl.Trim('/'); + if (!string.IsNullOrEmpty(baseUrl)) { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Jellyfin API V1"); - }); + baseUrl += '/'; + } + + return applicationBuilder + .UseSwagger(c => + { + // Custom path requires {documentName}, SwaggerDoc documentName is 'api-docs' + c.RouteTemplate = $"/{baseUrl}{{documentName}}/openapi.json"; + }) + .UseSwaggerUI(c => + { + c.DocumentTitle = "Jellyfin API"; + c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API"); + c.RoutePrefix = $"{baseUrl}api-docs/swagger"; + }) + .UseReDoc(c => + { + c.DocumentTitle = "Jellyfin API"; + c.SpecUrl($"/{baseUrl}api-docs/openapi.json"); + c.RoutePrefix = $"{baseUrl}api-docs/redoc"; + }); } } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 00688074f..387977ec4 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; using Jellyfin.Api; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; @@ -5,10 +10,14 @@ using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Server.Formatters; +using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; +using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Extensions { @@ -64,11 +73,18 @@ namespace Jellyfin.Server.Extensions /// <returns>The MVC builder.</returns> public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl) { - return serviceCollection.AddMvc(opts => + return serviceCollection + .AddCors(options => + { + options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy); + }) + .AddMvc(opts => { opts.UseGeneralRoutePrefix(baseUrl); opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter()); opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter()); + + opts.OutputFormatters.Add(new CssOutputFormatter()); }) // Clear app parts to avoid other assemblies being picked up @@ -76,8 +92,20 @@ namespace Jellyfin.Server.Extensions .AddApplicationPart(typeof(StartupController).Assembly) .AddJsonOptions(options => { - // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON. - options.JsonSerializerOptions.PropertyNamingPolicy = null; + // Update all properties that are set in JsonDefaults + var jsonOptions = JsonDefaults.PascalCase; + + // From JsonDefaults + options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling; + options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented; + options.JsonSerializerOptions.Converters.Clear(); + foreach (var converter in jsonOptions.Converters) + { + options.JsonSerializerOptions.Converters.Add(converter); + } + + // From JsonDefaults.PascalCase + options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy; }) .AddControllersAsServices(); } @@ -91,8 +119,50 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + + // Add all xml doc files to swagger generator. + var xmlFiles = Directory.GetFiles( + AppContext.BaseDirectory, + "*.xml", + SearchOption.TopDirectoryOnly); + + foreach (var xmlFile in xmlFiles) + { + c.IncludeXmlComments(xmlFile); + } + + // Order actions by route path, then by http method. + c.OrderActionsBy(description => + $"{description.ActionDescriptor.RouteValues["controller"]}_{description.HttpMethod}"); + + // Use method name as operationId + c.CustomOperationIds(description => + description.TryGetMethodInfo(out MethodInfo methodInfo) ? methodInfo.Name : null); + + // TODO - remove when all types are supported in System.Text.Json + c.AddSwaggerTypeMappings(); }); } + + private static void AddSwaggerTypeMappings(this SwaggerGenOptions options) + { + /* + * TODO remove when System.Text.Json supports non-string keys. + * Used in Jellyfin.Api.Controller.GetChannels. + */ + options.MapType<Dictionary<ImageType, string>>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "string", + Format = "string" + }) + }); + } } } diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs index e6ad6dfb1..989c8ecea 100644 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -1,4 +1,4 @@ -using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// <summary> /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class. /// </summary> - public CamelCaseJsonProfileFormatter() : base(JsonOptions.CamelCase) + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCase) { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\"")); diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs new file mode 100644 index 000000000..b3771b7fe --- /dev/null +++ b/Jellyfin.Server/Formatters/CssOutputFormatter.cs @@ -0,0 +1,36 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Jellyfin.Server.Formatters +{ + /// <summary> + /// Css output formatter. + /// </summary> + public class CssOutputFormatter : TextOutputFormatter + { + /// <summary> + /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. + /// </summary> + public CssOutputFormatter() + { + SupportedMediaTypes.Add("text/css"); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + /// <summary> + /// Write context object to stream. + /// </summary> + /// <param name="context">Writer context.</param> + /// <param name="selectedEncoding">Unused. Writer encoding.</param> + /// <returns>Write stream task.</returns> + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + } + } +} diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs index 675f4c79e..69963b3fb 100644 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -1,4 +1,4 @@ -using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// <summary> /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class. /// </summary> - public PascalCaseJsonProfileFormatter() : base(JsonOptions.PascalCase) + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCase) { SupportedMediaTypes.Clear(); // Add application/json for default formatter diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs new file mode 100644 index 000000000..63effafc1 --- /dev/null +++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,155 @@ +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.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Exception Middleware. + /// </summary> + public class ExceptionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger<ExceptionMiddleware> _logger; + private readonly IServerConfigurationManager _configuration; + private readonly IWebHostEnvironment _hostEnvironment; + + /// <summary> + /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class. + /// </summary> + /// <param name="next">Next request delegate.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param> + public ExceptionMiddleware( + RequestDelegate next, + ILogger<ExceptionMiddleware> logger, + IServerConfigurationManager serverConfigurationManager, + IWebHostEnvironment hostEnvironment) + { + _next = next; + _logger = logger; + _configuration = serverConfigurationManager; + _hostEnvironment = hostEnvironment; + } + + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <returns>Task.</returns> + 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; + + // Don't send exception unless the server is in a Development environment + var errorContent = _hostEnvironment.IsDevelopment() + ? NormalizeExceptionMessage(ex.Message) + : "Error processing request."; + 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 deleted file mode 100644 index 2f0df3d2c..000000000 --- a/Jellyfin.Server/Models/JsonOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text.Json; - -namespace Jellyfin.Server.Models -{ - /// <summary> - /// Json Options. - /// </summary> - public static class JsonOptions - { - /// <summary> - /// Gets CamelCase json options. - /// </summary> - public static JsonSerializerOptions CamelCase - { - get - { - var options = DefaultJsonOptions; - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - return options; - } - } - - /// <summary> - /// Gets PascalCase json options. - /// </summary> - public static JsonSerializerOptions PascalCase - { - get - { - var options = DefaultJsonOptions; - options.PropertyNamingPolicy = null; - return options; - } - } - - /// <summary> - /// Gets base Json Serializer Options. - /// </summary> - private static JsonSerializerOptions DefaultJsonOptions => new JsonSerializerOptions(); - } -} diff --git a/Jellyfin.Server/Models/ServerCorsPolicy.cs b/Jellyfin.Server/Models/ServerCorsPolicy.cs new file mode 100644 index 000000000..ae010c042 --- /dev/null +++ b/Jellyfin.Server/Models/ServerCorsPolicy.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; + +namespace Jellyfin.Server.Models +{ + /// <summary> + /// Server Cors Policy. + /// </summary> + public static class ServerCorsPolicy + { + /// <summary> + /// Default policy name. + /// </summary> + public const string DefaultPolicyName = "DefaultCorsPolicy"; + + /// <summary> + /// Default Policy. Allow Everything. + /// </summary> + public static readonly CorsPolicy DefaultPolicy = new CorsPolicy + { + // Allow any origin + Origins = { "*" }, + + // Allow any method + Methods = { "*" }, + + // Allow any header + Headers = { "*" } + }; + } +}
\ No newline at end of file diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index e55b0d4ed..713580080 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -529,7 +529,7 @@ namespace Jellyfin.Server var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration; if (startupConfig != null && !startupConfig.HostWebClient()) { - inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "swagger/index.html"; + inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "api-docs/swagger"; } return config diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 4d7d56e9d..bd2887e4a 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,4 +1,6 @@ using Jellyfin.Server.Extensions; +using Jellyfin.Server.Middleware; +using Jellyfin.Server.Models; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; @@ -58,6 +60,8 @@ namespace Jellyfin.Server app.UseDeveloperExceptionPage(); } + app.UseMiddleware<ExceptionMiddleware>(); + app.UseWebSockets(); app.UseResponseCompression(); @@ -65,9 +69,10 @@ namespace Jellyfin.Server // TODO app.UseMiddleware<WebSocketMiddleware>(); app.Use(serverApplicationHost.ExecuteWebsocketHandlerAsync); - // TODO use when old API is removed: app.UseAuthentication(); - app.UseJellyfinApiSwagger(); + app.UseAuthentication(); + app.UseJellyfinApiSwagger(_serverConfigurationManager); app.UseRouting(); + app.UseCors(ServerCorsPolicy.DefaultPolicyName); app.UseAuthorization(); app.UseEndpoints(endpoints => { |
