aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
authorNyanmisaka <nst799610810@gmail.com>2020-09-04 02:55:57 +0800
committerGitHub <noreply@github.com>2020-09-04 02:55:57 +0800
commit4cb0a57e4645aba8e5e65c7d086091b9161c6c09 (patch)
tree25e77817485d70cac8ec3e11a785b08b69d0c60b /Jellyfin.Server
parent54349fc94597824714f623b8c31583fc044274aa (diff)
parent53703566b5e1239bbab308031d94df34a4d168aa (diff)
Merge branch 'master' into tonemap
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/CoreAppHost.cs27
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs88
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs223
-rw-r--r--Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs21
-rw-r--r--Jellyfin.Server/Formatters/CssOutputFormatter.cs36
-rw-r--r--Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs23
-rw-r--r--Jellyfin.Server/Formatters/XmlOutputFormatter.cs31
-rw-r--r--Jellyfin.Server/HealthChecks/JellyfinDbHealthCheck.cs36
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj16
-rw-r--r--Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs62
-rw-r--r--Jellyfin.Server/Middleware/ExceptionMiddleware.cs155
-rw-r--r--Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs76
-rw-r--r--Jellyfin.Server/Middleware/LanFilteringMiddleware.cs76
-rw-r--r--Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs78
-rw-r--r--Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs49
-rw-r--r--Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs40
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs5
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs13
-rw-r--r--Jellyfin.Server/Models/ServerCorsPolicy.cs30
-rw-r--r--Jellyfin.Server/Program.cs36
-rw-r--r--Jellyfin.Server/Startup.cs75
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/redoc/custom.css0
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/swagger/custom.css0
23 files changed, 1144 insertions, 52 deletions
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index 29a59e1c8a..755844dd9e 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -1,20 +1,20 @@
using System;
using System.Collections.Generic;
-using System.IO;
using System.Reflection;
using Emby.Drawing;
using Emby.Server.Implementations;
using Jellyfin.Drawing.Skia;
using Jellyfin.Server.Implementations;
using Jellyfin.Server.Implementations.Activity;
+using Jellyfin.Server.Implementations.Events;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Activity;
using MediaBrowser.Model.IO;
-using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -33,30 +33,33 @@ namespace Jellyfin.Server
/// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param>
/// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param>
/// <param name="networkManager">The <see cref="INetworkManager" /> to be used by the <see cref="CoreAppHost" />.</param>
+ /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param>
public CoreAppHost(
IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
IStartupOptions options,
IFileSystem fileSystem,
- INetworkManager networkManager)
+ INetworkManager networkManager,
+ IServiceCollection collection)
: base(
applicationPaths,
loggerFactory,
options,
fileSystem,
- networkManager)
+ networkManager,
+ collection)
{
}
/// <inheritdoc/>
- protected override void RegisterServices(IServiceCollection serviceCollection)
+ protected override void RegisterServices()
{
// Register an image encoder
bool useSkiaEncoder = SkiaEncoder.IsNativeLibAvailable();
Type imageEncoderType = useSkiaEncoder
? typeof(SkiaEncoder)
: typeof(NullImageEncoder);
- serviceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType);
+ ServiceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType);
// Log a warning if the Skia encoder could not be used
if (!useSkiaEncoder)
@@ -71,13 +74,15 @@ namespace Jellyfin.Server
// .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"),
// ServiceLifetime.Transient);
- serviceCollection.AddSingleton<JellyfinDbProvider>();
+ ServiceCollection.AddEventServices();
+ ServiceCollection.AddSingleton<IEventManager, EventManager>();
+ ServiceCollection.AddSingleton<JellyfinDbProvider>();
- serviceCollection.AddSingleton<IActivityManager, ActivityManager>();
- serviceCollection.AddSingleton<IUserManager, UserManager>();
- serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
+ ServiceCollection.AddSingleton<IActivityManager, ActivityManager>();
+ ServiceCollection.AddSingleton<IUserManager, UserManager>();
+ ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
- base.RegisterServices(serviceCollection);
+ base.RegisterServices();
}
/// <inheritdoc />
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index db06eb4552..2b002c6f3c 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -1,3 +1,5 @@
+using Jellyfin.Server.Middleware;
+using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
namespace Jellyfin.Server.Extensions
@@ -11,17 +13,91 @@ 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";
+ c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css");
+ })
+ .UseReDoc(c =>
+ {
+ c.DocumentTitle = "Jellyfin API";
+ c.SpecUrl($"/{baseUrl}api-docs/openapi.json");
+ c.RoutePrefix = $"{baseUrl}api-docs/redoc";
+ c.InjectStylesheet($"/{baseUrl}api-docs/redoc/custom.css");
+ });
+ }
+
+ /// <summary>
+ /// Adds IP based access validation to the application pipeline.
+ /// </summary>
+ /// <param name="appBuilder">The application builder.</param>
+ /// <returns>The updated application builder.</returns>
+ public static IApplicationBuilder UseIpBasedAccessValidation(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware<IpBasedAccessValidationMiddleware>();
+ }
+
+ /// <summary>
+ /// Adds LAN based access filtering to the application pipeline.
+ /// </summary>
+ /// <param name="appBuilder">The application builder.</param>
+ /// <returns>The updated application builder.</returns>
+ public static IApplicationBuilder UseLanFiltering(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware<LanFilteringMiddleware>();
+ }
+
+ /// <summary>
+ /// Adds base url redirection to the application pipeline.
+ /// </summary>
+ /// <param name="appBuilder">The application builder.</param>
+ /// <returns>The updated application builder.</returns>
+ public static IApplicationBuilder UseBaseUrlRedirection(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware<BaseUrlRedirectionMiddleware>();
+ }
+
+ /// <summary>
+ /// Adds a custom message during server startup to the application pipeline.
+ /// </summary>
+ /// <param name="appBuilder">The application builder.</param>
+ /// <returns>The updated application builder.</returns>
+ public static IApplicationBuilder UseServerStartupMessage(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware<ServerStartupMessageMiddleware>();
+ }
+
+ /// <summary>
+ /// Adds a WebSocket request handler to the application pipeline.
+ /// </summary>
+ /// <param name="appBuilder">The application builder.</param>
+ /// <returns>The updated application builder.</returns>
+ public static IApplicationBuilder UseWebSocketHandler(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware<WebSocketHandlerMiddleware>();
}
}
}
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 71ef9a69a2..0160a05f92 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -1,13 +1,33 @@
+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.DefaultAuthorizationPolicy;
+using Jellyfin.Api.Auth.DownloadPolicy;
+using Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy;
+using Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy;
using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy;
+using Jellyfin.Api.Auth.IgnoreParentalControlPolicy;
+using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy;
+using Jellyfin.Api.Auth.LocalAccessPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
+using Jellyfin.Server.Formatters;
+using Jellyfin.Server.Models;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Extensions
{
@@ -23,16 +43,37 @@ namespace Jellyfin.Server.Extensions
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection)
{
+ serviceCollection.AddSingleton<IAuthorizationHandler, DefaultAuthorizationHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, DownloadHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupOrDefaultHandler>();
serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupOrElevatedHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, IgnoreParentalControlHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeOrIgnoreParentalControlSetupHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>();
serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>();
return serviceCollection.AddAuthorizationCore(options =>
{
options.AddPolicy(
- Policies.RequiresElevation,
+ Policies.DefaultAuthorization,
policy =>
{
policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
- policy.AddRequirements(new RequiresElevationRequirement());
+ policy.AddRequirements(new DefaultAuthorizationRequirement());
+ });
+ options.AddPolicy(
+ Policies.Download,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new DownloadRequirement());
+ });
+ options.AddPolicy(
+ Policies.FirstTimeSetupOrDefault,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new FirstTimeSetupOrDefaultRequirement());
});
options.AddPolicy(
Policies.FirstTimeSetupOrElevated,
@@ -41,6 +82,41 @@ namespace Jellyfin.Server.Extensions
policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
policy.AddRequirements(new FirstTimeSetupOrElevatedRequirement());
});
+ options.AddPolicy(
+ Policies.IgnoreParentalControl,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new IgnoreParentalControlRequirement());
+ });
+ options.AddPolicy(
+ Policies.FirstTimeSetupOrIgnoreParentalControl,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new FirstTimeOrIgnoreParentalControlSetupRequirement());
+ });
+ options.AddPolicy(
+ Policies.LocalAccessOnly,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new LocalAccessRequirement());
+ });
+ options.AddPolicy(
+ Policies.LocalAccessOrRequiresElevation,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new LocalAccessOrRequiresElevationRequirement());
+ });
+ options.AddPolicy(
+ Policies.RequiresElevation,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new RequiresElevationRequirement());
+ });
});
}
@@ -60,12 +136,27 @@ namespace Jellyfin.Server.Extensions
/// </summary>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="baseUrl">The base url for the API.</param>
+ /// <param name="pluginAssemblies">An IEnumberable containing all plugin assemblies with API controllers.</param>
/// <returns>The MVC builder.</returns>
- public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl)
+ public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl, IEnumerable<Assembly> pluginAssemblies)
{
- return serviceCollection.AddMvc(opts =>
+ IMvcBuilder mvcBuilder = serviceCollection
+ .AddCors(options =>
+ {
+ options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy);
+ })
+ .Configure<ForwardedHeadersOptions>(options =>
+ {
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+ })
+ .AddMvc(opts =>
{
opts.UseGeneralRoutePrefix(baseUrl);
+ opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter());
+ opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter());
+
+ opts.OutputFormatters.Add(new CssOutputFormatter());
+ opts.OutputFormatters.Add(new XmlOutputFormatter());
})
// Clear app parts to avoid other assemblies being picked up
@@ -73,10 +164,31 @@ 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;
- })
- .AddControllersAsServices();
+ // Update all properties that are set in JsonDefaults
+ var jsonOptions = JsonDefaults.GetPascalCaseOptions();
+
+ // From JsonDefaults
+ options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling;
+ options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented;
+ options.JsonSerializerOptions.DefaultIgnoreCondition = jsonOptions.DefaultIgnoreCondition;
+ options.JsonSerializerOptions.NumberHandling = jsonOptions.NumberHandling;
+
+ options.JsonSerializerOptions.Converters.Clear();
+ foreach (var converter in jsonOptions.Converters)
+ {
+ options.JsonSerializerOptions.Converters.Add(converter);
+ }
+
+ // From JsonDefaults.PascalCase
+ options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy;
+ });
+
+ foreach (Assembly pluginAssembly in pluginAssemblies)
+ {
+ mvcBuilder.AddApplicationPart(pluginAssembly);
+ }
+
+ return mvcBuilder.AddControllersAsServices();
}
/// <summary>
@@ -88,8 +200,101 @@ 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" });
+ c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.ApiKey,
+ In = ParameterLocation.Header,
+ Name = "X-Emby-Authorization",
+ Description = "API key header parameter"
+ });
+
+ var securitySchemeRef = new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = AuthenticationSchemes.CustomAuthentication },
+ };
+
+ // TODO: Apply this with an operation filter instead of globally
+ // https://github.com/domaindrivendev/Swashbuckle.AspNetCore#add-security-definitions-and-requirements
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ { securitySchemeRef, Array.Empty<string>() }
+ });
+
+ // 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.RelativePath}");
+
+ // Use method name as operationId
+ c.CustomOperationIds(
+ description =>
+ {
+ description.TryGetMethodInfo(out MethodInfo methodInfo);
+ // Attribute name, method name, none.
+ return description?.ActionDescriptor?.AttributeRouteInfo?.Name
+ ?? 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"
+ })
+ });
+
+ /*
+ * Support BlurHash dictionary
+ */
+ options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() =>
+ new OpenApiSchema
+ {
+ Type = "object",
+ Properties = typeof(ImageType).GetEnumNames().ToDictionary(
+ name => name,
+ name => new OpenApiSchema
+ {
+ Type = "object", Properties = new Dictionary<string, OpenApiSchema>
+ {
+ {
+ "string",
+ new OpenApiSchema
+ {
+ Type = "string",
+ Format = "string"
+ }
+ }
+ }
+ })
+ });
+ }
}
}
diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs
new file mode 100644
index 0000000000..9b347ae2c2
--- /dev/null
+++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs
@@ -0,0 +1,21 @@
+using MediaBrowser.Common.Json;
+using Microsoft.AspNetCore.Mvc.Formatters;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Server.Formatters
+{
+ /// <summary>
+ /// Camel Case Json Profile Formatter.
+ /// </summary>
+ public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class.
+ /// </summary>
+ public CamelCaseJsonProfileFormatter() : base(JsonDefaults.GetCamelCaseOptions())
+ {
+ 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 0000000000..b3771b7fe6
--- /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
new file mode 100644
index 0000000000..0024708bad
--- /dev/null
+++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs
@@ -0,0 +1,23 @@
+using MediaBrowser.Common.Json;
+using Microsoft.AspNetCore.Mvc.Formatters;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Server.Formatters
+{
+ /// <summary>
+ /// Pascal Case Json Profile Formatter.
+ /// </summary>
+ public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class.
+ /// </summary>
+ public PascalCaseJsonProfileFormatter() : base(JsonDefaults.GetPascalCaseOptions())
+ {
+ 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/Formatters/XmlOutputFormatter.cs b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs
new file mode 100644
index 0000000000..58319657d6
--- /dev/null
+++ b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs
@@ -0,0 +1,31 @@
+using System.Net.Mime;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Formatters;
+
+namespace Jellyfin.Server.Formatters
+{
+ /// <summary>
+ /// Xml output formatter.
+ /// </summary>
+ public class XmlOutputFormatter : TextOutputFormatter
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
+ /// </summary>
+ public XmlOutputFormatter()
+ {
+ SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
+ SupportedMediaTypes.Add("text/xml;charset=UTF-8");
+ SupportedEncodings.Add(Encoding.UTF8);
+ SupportedEncodings.Add(Encoding.Unicode);
+ }
+
+ /// <inheritdoc />
+ public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
+ {
+ return context.HttpContext.Response.WriteAsync(context.Object?.ToString());
+ }
+ }
+}
diff --git a/Jellyfin.Server/HealthChecks/JellyfinDbHealthCheck.cs b/Jellyfin.Server/HealthChecks/JellyfinDbHealthCheck.cs
new file mode 100644
index 0000000000..aea6844798
--- /dev/null
+++ b/Jellyfin.Server/HealthChecks/JellyfinDbHealthCheck.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.Implementations;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Jellyfin.Server.HealthChecks
+{
+ /// <summary>
+ /// Checks connectivity to the database.
+ /// </summary>
+ public class JellyfinDbHealthCheck : IHealthCheck
+ {
+ private readonly JellyfinDbProvider _dbProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinDbHealthCheck"/> class.
+ /// </summary>
+ /// <param name="dbProvider">The jellyfin db provider.</param>
+ public JellyfinDbHealthCheck(JellyfinDbProvider dbProvider)
+ {
+ _dbProvider = dbProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ await using var jellyfinDb = _dbProvider.CreateContext();
+ if (await jellyfinDb.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false))
+ {
+ return HealthCheckResult.Healthy("Database connection successful.");
+ }
+
+ return HealthCheckResult.Unhealthy("Unable to connect to the database.");
+ }
+ }
+}
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index b1bd38cffb..24ba8369a6 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -41,11 +41,12 @@
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
- <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.6" />
- <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.6" />
+ <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.7" />
+ <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.7" />
+ <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.7" />
<PackageReference Include="prometheus-net" Version="3.6.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="3.6.0" />
- <PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
+ <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" />
@@ -63,4 +64,13 @@
<ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
</ItemGroup>
+ <ItemGroup>
+ <None Update="wwwroot\api-docs\swagger\custom.css">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="wwwroot\api-docs\redoc\custom.css">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
</Project>
diff --git a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
new file mode 100644
index 0000000000..9316737bdf
--- /dev/null
+++ b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using ConfigurationExtensions = MediaBrowser.Controller.Extensions.ConfigurationExtensions;
+
+namespace Jellyfin.Server.Middleware
+{
+ /// <summary>
+ /// Redirect requests without baseurl prefix to the baseurl prefixed URL.
+ /// </summary>
+ public class BaseUrlRedirectionMiddleware
+ {
+ private readonly RequestDelegate _next;
+ private readonly ILogger<BaseUrlRedirectionMiddleware> _logger;
+ private readonly IConfiguration _configuration;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class.
+ /// </summary>
+ /// <param name="next">The next delegate in the pipeline.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="configuration">The application configuration.</param>
+ public BaseUrlRedirectionMiddleware(
+ RequestDelegate next,
+ ILogger<BaseUrlRedirectionMiddleware> logger,
+ IConfiguration configuration)
+ {
+ _next = next;
+ _logger = logger;
+ _configuration = configuration;
+ }
+
+ /// <summary>
+ /// Executes the middleware action.
+ /// </summary>
+ /// <param name="httpContext">The current HTTP context.</param>
+ /// <param name="serverConfigurationManager">The server configuration manager.</param>
+ /// <returns>The async task.</returns>
+ public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager)
+ {
+ var localPath = httpContext.Request.Path.ToString();
+ var baseUrlPrefix = serverConfigurationManager.Configuration.BaseUrl;
+
+ if (string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase)
+ || string.IsNullOrEmpty(localPath)
+ || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ // Always redirect back to the default path if the base prefix is invalid or missing
+ _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath);
+ httpContext.Response.Redirect(baseUrlPrefix + "/" + _configuration[ConfigurationExtensions.DefaultRedirectKey]);
+ return;
+ }
+
+ await _next(httpContext).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs
new file mode 100644
index 0000000000..63effafc19
--- /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/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
new file mode 100644
index 0000000000..59b5fb1ed2
--- /dev/null
+++ b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
@@ -0,0 +1,76 @@
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Server.Middleware
+{
+ /// <summary>
+ /// Validates the IP of requests coming from local networks wrt. remote access.
+ /// </summary>
+ public class IpBasedAccessValidationMiddleware
+ {
+ private readonly RequestDelegate _next;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class.
+ /// </summary>
+ /// <param name="next">The next delegate in the pipeline.</param>
+ public IpBasedAccessValidationMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ /// <summary>
+ /// Executes the middleware action.
+ /// </summary>
+ /// <param name="httpContext">The current HTTP context.</param>
+ /// <param name="networkManager">The network manager.</param>
+ /// <param name="serverConfigurationManager">The server configuration manager.</param>
+ /// <returns>The async task.</returns>
+ public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
+ {
+ if (httpContext.Request.IsLocal())
+ {
+ await _next(httpContext).ConfigureAwait(false);
+ return;
+ }
+
+ var remoteIp = httpContext.Request.RemoteIp();
+
+ if (serverConfigurationManager.Configuration.EnableRemoteAccess)
+ {
+ var addressFilter = serverConfigurationManager.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
+
+ if (addressFilter.Length > 0 && !networkManager.IsInLocalNetwork(remoteIp))
+ {
+ if (serverConfigurationManager.Configuration.IsRemoteIPFilterBlacklist)
+ {
+ if (networkManager.IsAddressInSubnets(remoteIp, addressFilter))
+ {
+ return;
+ }
+ }
+ else
+ {
+ if (!networkManager.IsAddressInSubnets(remoteIp, addressFilter))
+ {
+ return;
+ }
+ }
+ }
+ }
+ else
+ {
+ if (!networkManager.IsInLocalNetwork(remoteIp))
+ {
+ return;
+ }
+ }
+
+ await _next(httpContext).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
new file mode 100644
index 0000000000..9d795145aa
--- /dev/null
+++ b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Server.Middleware
+{
+ /// <summary>
+ /// Validates the LAN host IP based on application configuration.
+ /// </summary>
+ public class LanFilteringMiddleware
+ {
+ private readonly RequestDelegate _next;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class.
+ /// </summary>
+ /// <param name="next">The next delegate in the pipeline.</param>
+ public LanFilteringMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ /// <summary>
+ /// Executes the middleware action.
+ /// </summary>
+ /// <param name="httpContext">The current HTTP context.</param>
+ /// <param name="networkManager">The network manager.</param>
+ /// <param name="serverConfigurationManager">The server configuration manager.</param>
+ /// <returns>The async task.</returns>
+ public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
+ {
+ var currentHost = httpContext.Request.Host.ToString();
+ var hosts = serverConfigurationManager
+ .Configuration
+ .LocalNetworkAddresses
+ .Select(NormalizeConfiguredLocalAddress)
+ .ToList();
+
+ if (hosts.Count == 0)
+ {
+ await _next(httpContext).ConfigureAwait(false);
+ return;
+ }
+
+ currentHost ??= string.Empty;
+
+ if (networkManager.IsInPrivateAddressSpace(currentHost))
+ {
+ hosts.Add("localhost");
+ hosts.Add("127.0.0.1");
+
+ if (hosts.All(i => currentHost.IndexOf(i, StringComparison.OrdinalIgnoreCase) == -1))
+ {
+ return;
+ }
+ }
+
+ await _next(httpContext).ConfigureAwait(false);
+ }
+
+ private static string NormalizeConfiguredLocalAddress(string address)
+ {
+ var add = address.AsSpan().Trim('/');
+ int index = add.IndexOf('/');
+ if (index != -1)
+ {
+ add = add.Slice(index + 1);
+ }
+
+ return add.TrimStart('/').ToString();
+ }
+ }
+}
diff --git a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs
new file mode 100644
index 0000000000..3122d92cbc
--- /dev/null
+++ b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs
@@ -0,0 +1,78 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Middleware
+{
+ /// <summary>
+ /// Response time middleware.
+ /// </summary>
+ public class ResponseTimeMiddleware
+ {
+ private const string ResponseHeaderResponseTime = "X-Response-Time-ms";
+
+ private readonly RequestDelegate _next;
+ private readonly ILogger<ResponseTimeMiddleware> _logger;
+
+ private readonly bool _enableWarning;
+ private readonly long _warningThreshold;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> 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>
+ public ResponseTimeMiddleware(
+ RequestDelegate next,
+ ILogger<ResponseTimeMiddleware> logger,
+ IServerConfigurationManager serverConfigurationManager)
+ {
+ _next = next;
+ _logger = logger;
+
+ _enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning;
+ _warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs;
+ }
+
+ /// <summary>
+ /// Invoke request.
+ /// </summary>
+ /// <param name="context">Request context.</param>
+ /// <returns>Task.</returns>
+ public async Task Invoke(HttpContext context)
+ {
+ var watch = new Stopwatch();
+ watch.Start();
+
+ context.Response.OnStarting(() =>
+ {
+ watch.Stop();
+ LogWarning(context, watch);
+ var responseTimeForCompleteRequest = watch.ElapsedMilliseconds;
+ context.Response.Headers[ResponseHeaderResponseTime] = responseTimeForCompleteRequest.ToString(CultureInfo.InvariantCulture);
+ return Task.CompletedTask;
+ });
+
+ // Call the next delegate/middleware in the pipeline
+ await this._next(context).ConfigureAwait(false);
+ }
+
+ private void LogWarning(HttpContext context, Stopwatch watch)
+ {
+ if (_enableWarning && watch.ElapsedMilliseconds > _warningThreshold)
+ {
+ _logger.LogWarning(
+ "Slow HTTP Response from {url} to {remoteIp} in {elapsed:g} with Status Code {statusCode}",
+ context.Request.GetDisplayUrl(),
+ context.Connection.RemoteIpAddress,
+ watch.Elapsed,
+ context.Response.StatusCode);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs b/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs
new file mode 100644
index 0000000000..ea81c03a20
--- /dev/null
+++ b/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs
@@ -0,0 +1,49 @@
+using System.Net.Mime;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Globalization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Server.Middleware
+{
+ /// <summary>
+ /// Shows a custom message during server startup.
+ /// </summary>
+ public class ServerStartupMessageMiddleware
+ {
+ private readonly RequestDelegate _next;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class.
+ /// </summary>
+ /// <param name="next">The next delegate in the pipeline.</param>
+ public ServerStartupMessageMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ /// <summary>
+ /// Executes the middleware action.
+ /// </summary>
+ /// <param name="httpContext">The current HTTP context.</param>
+ /// <param name="serverApplicationHost">The server application host.</param>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <returns>The async task.</returns>
+ public async Task Invoke(
+ HttpContext httpContext,
+ IServerApplicationHost serverApplicationHost,
+ ILocalizationManager localizationManager)
+ {
+ if (serverApplicationHost.CoreStartupHasCompleted)
+ {
+ await _next(httpContext).ConfigureAwait(false);
+ return;
+ }
+
+ var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
+ httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
+ httpContext.Response.ContentType = MediaTypeNames.Text.Html;
+ await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs b/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs
new file mode 100644
index 0000000000..b7a5d2b346
--- /dev/null
+++ b/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs
@@ -0,0 +1,40 @@
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Net;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Server.Middleware
+{
+ /// <summary>
+ /// Handles WebSocket requests.
+ /// </summary>
+ public class WebSocketHandlerMiddleware
+ {
+ private readonly RequestDelegate _next;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class.
+ /// </summary>
+ /// <param name="next">The next delegate in the pipeline.</param>
+ public WebSocketHandlerMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ /// <summary>
+ /// Executes the middleware action.
+ /// </summary>
+ /// <param name="httpContext">The current HTTP context.</param>
+ /// <param name="webSocketManager">The WebSocket connection manager.</param>
+ /// <returns>The async task.</returns>
+ public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager)
+ {
+ if (!httpContext.WebSockets.IsWebSocketRequest)
+ {
+ await _next(httpContext).ConfigureAwait(false);
+ return;
+ }
+
+ await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
index b15ccf01eb..7f57358ec0 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
@@ -81,6 +81,11 @@ namespace Jellyfin.Server.Migrations.Routines
foreach (var result in results)
{
var dto = JsonSerializer.Deserialize<DisplayPreferencesDto>(result[3].ToString(), _jsonOptions);
+ if (dto == null)
+ {
+ continue;
+ }
+
var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version)
? chromecastDict[version]
: ChromecastVersion.Stable;
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
index 274e6ab736..74c5503314 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
@@ -1,5 +1,7 @@
using System;
+using System.Globalization;
using System.IO;
+using System.Linq;
using Emby.Server.Implementations.Data;
using Emby.Server.Implementations.Serialization;
using Jellyfin.Data.Entities;
@@ -74,7 +76,12 @@ namespace Jellyfin.Server.Migrations.Routines
foreach (var entry in queryResult)
{
- UserMockup mockup = JsonSerializer.Deserialize<UserMockup>(entry[2].ToBlob(), JsonDefaults.GetOptions());
+ UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry[2].ToBlob(), JsonDefaults.GetOptions());
+ if (mockup == null)
+ {
+ continue;
+ }
+
var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name);
var config = File.Exists(Path.Combine(userDataDir, "config.xml"))
@@ -161,9 +168,9 @@ namespace Jellyfin.Server.Migrations.Routines
}
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
- user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
+ user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
- user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
+ user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
diff --git a/Jellyfin.Server/Models/ServerCorsPolicy.cs b/Jellyfin.Server/Models/ServerCorsPolicy.cs
new file mode 100644
index 0000000000..ae010c042e
--- /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 444a91c024..b9a90f9dbf 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -11,12 +11,11 @@ using System.Threading;
using System.Threading.Tasks;
using CommandLine;
using Emby.Server.Implementations;
-using Emby.Server.Implementations.HttpServer;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Networking;
+using Jellyfin.Api.Controllers;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Extensions;
-using MediaBrowser.WebDashboard.Api;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
@@ -28,6 +27,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Serilog.Extensions.Logging;
using SQLitePCL;
+using ConfigurationExtensions = MediaBrowser.Controller.Extensions.ConfigurationExtensions;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Jellyfin.Server
@@ -154,20 +154,22 @@ namespace Jellyfin.Server
ApplicationHost.LogEnvironmentInfo(_logger, appPaths);
PerformStaticInitialization();
+ var serviceCollection = new ServiceCollection();
var appHost = new CoreAppHost(
appPaths,
_loggerFactory,
options,
new ManagedFileSystem(_loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths),
- new NetworkManager(_loggerFactory.CreateLogger<NetworkManager>()));
+ new NetworkManager(_loggerFactory.CreateLogger<NetworkManager>()),
+ serviceCollection);
try
{
// If hosting the web client, validate the client content path
if (startupConfig.HostWebClient())
{
- string webContentPath = DashboardService.GetDashboardUIPath(startupConfig, appHost.ServerConfigurationManager);
+ string? webContentPath = DashboardController.GetWebClientUiPath(startupConfig, appHost.ServerConfigurationManager);
if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0)
{
throw new InvalidOperationException(
@@ -178,8 +180,7 @@ namespace Jellyfin.Server
}
}
- ServiceCollection serviceCollection = new ServiceCollection();
- appHost.Init(serviceCollection);
+ appHost.Init();
var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build();
@@ -344,11 +345,24 @@ namespace Jellyfin.Server
}
}
- // Bind to unix socket (only on OSX and Linux)
- if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ // Bind to unix socket (only on macOS and Linux)
+ if (startupConfig.UseUnixSocket() && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- // TODO: allow configuration of socket path
- var socketPath = $"{appPaths.DataPath}/socket.sock";
+ var socketPath = startupConfig.GetUnixSocketPath();
+ if (string.IsNullOrEmpty(socketPath))
+ {
+ var xdgRuntimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
+ if (xdgRuntimeDir == null)
+ {
+ // Fall back to config dir
+ socketPath = Path.Join(appPaths.ConfigurationDirectoryPath, "socket.sock");
+ }
+ else
+ {
+ socketPath = Path.Join(xdgRuntimeDir, "jellyfin-socket");
+ }
+ }
+
// Workaround for https://github.com/aspnet/AspNetCore/issues/14134
if (File.Exists(socketPath))
{
@@ -580,7 +594,7 @@ namespace Jellyfin.Server
var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration;
if (startupConfig != null && !startupConfig.HostWebClient())
{
- inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "swagger/index.html";
+ inMemoryDefaultConfig[ConfigurationExtensions.DefaultRedirectKey] = "api-docs/swagger";
}
return config
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 5f9a5c1618..1a34de2695 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,4 +1,12 @@
+using System;
+using System.ComponentModel;
+using System.Net.Http.Headers;
+using Jellyfin.Api.TypeConverters;
using Jellyfin.Server.Extensions;
+using Jellyfin.Server.HealthChecks;
+using Jellyfin.Server.Middleware;
+using Jellyfin.Server.Models;
+using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
@@ -15,14 +23,19 @@ namespace Jellyfin.Server
public class Startup
{
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IServerApplicationHost _serverApplicationHost;
/// <summary>
/// Initializes a new instance of the <see cref="Startup" /> class.
/// </summary>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
- public Startup(IServerConfigurationManager serverConfigurationManager)
+ /// <param name="serverApplicationHost">The server application host.</param>
+ public Startup(
+ IServerConfigurationManager serverConfigurationManager,
+ IServerApplicationHost serverApplicationHost)
{
_serverConfigurationManager = serverConfigurationManager;
+ _serverApplicationHost = serverApplicationHost;
}
/// <summary>
@@ -33,7 +46,13 @@ namespace Jellyfin.Server
{
services.AddResponseCompression();
services.AddHttpContextAccessor();
- services.AddJellyfinApi(_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/'));
+ services.AddHttpsRedirection(options =>
+ {
+ options.HttpsPort = _serverApplicationHost.HttpsPort;
+ });
+ services.AddJellyfinApi(
+ _serverConfigurationManager.Configuration.BaseUrl.TrimStart('/'),
+ _serverApplicationHost.GetApiPluginAssemblies());
services.AddJellyfinApiSwagger();
@@ -41,6 +60,26 @@ namespace Jellyfin.Server
services.AddCustomAuthentication();
services.AddJellyfinApiAuthorization();
+
+ var productHeader = new ProductInfoHeaderValue(
+ _serverApplicationHost.Name.Replace(' ', '-'),
+ _serverApplicationHost.ApplicationVersionString);
+ services
+ .AddHttpClient(NamedClient.Default, c =>
+ {
+ c.DefaultRequestHeaders.UserAgent.Add(productHeader);
+ })
+ .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
+
+ services.AddHttpClient(NamedClient.MusicBrainz, c =>
+ {
+ c.DefaultRequestHeaders.UserAgent.Add(productHeader);
+ c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})"));
+ })
+ .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
+
+ services.AddHealthChecks()
+ .AddCheck<JellyfinDbHealthCheck>("JellyfinDb");
}
/// <summary>
@@ -48,25 +87,34 @@ namespace Jellyfin.Server
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="env">The webhost environment.</param>
- /// <param name="serverApplicationHost">The server application host.</param>
public void Configure(
IApplicationBuilder app,
- IWebHostEnvironment env,
- IServerApplicationHost serverApplicationHost)
+ IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
+ app.UseMiddleware<ExceptionMiddleware>();
+
+ app.UseMiddleware<ResponseTimeMiddleware>();
+
app.UseWebSockets();
app.UseResponseCompression();
- // TODO app.UseMiddleware<WebSocketMiddleware>();
+ app.UseCors(ServerCorsPolicy.DefaultPolicyName);
- // TODO use when old API is removed: app.UseAuthentication();
- app.UseJellyfinApiSwagger();
+ if (_serverConfigurationManager.Configuration.RequireHttps
+ && _serverApplicationHost.ListenWithHttps)
+ {
+ app.UseHttpsRedirection();
+ }
+
+ app.UseStaticFiles();
+ app.UseAuthentication();
+ app.UseJellyfinApiSwagger(_serverConfigurationManager);
app.UseRouting();
app.UseAuthorization();
if (_serverConfigurationManager.Configuration.EnableMetrics)
@@ -75,6 +123,12 @@ namespace Jellyfin.Server
app.UseHttpMetrics();
}
+ app.UseLanFiltering();
+ app.UseIpBasedAccessValidation();
+ app.UseBaseUrlRedirection();
+ app.UseWebSocketHandler();
+ app.UseServerStartupMessage();
+
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
@@ -82,9 +136,12 @@ namespace Jellyfin.Server
{
endpoints.MapMetrics(_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/') + "/metrics");
}
+
+ endpoints.MapHealthChecks(_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/') + "/health");
});
- app.Use(serverApplicationHost.ExecuteHttpHandlerAsync);
+ // Add type descriptor for legacy datetime parsing.
+ TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter)));
}
}
}
diff --git a/Jellyfin.Server/wwwroot/api-docs/redoc/custom.css b/Jellyfin.Server/wwwroot/api-docs/redoc/custom.css
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/Jellyfin.Server/wwwroot/api-docs/redoc/custom.css
diff --git a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css