aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/Configuration/CorsPolicyProvider.cs49
-rw-r--r--Jellyfin.Server/CoreAppHost.cs51
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs95
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs244
-rw-r--r--Jellyfin.Server/Filters/FileResponseFilter.cs52
-rw-r--r--Jellyfin.Server/Filters/WebsocketModelFilter.cs30
-rw-r--r--Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs21
-rw-r--r--Jellyfin.Server/Formatters/CssOutputFormatter.cs36
-rw-r--r--Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs24
-rw-r--r--Jellyfin.Server/Formatters/XmlOutputFormatter.cs32
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj37
-rw-r--r--Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs62
-rw-r--r--Jellyfin.Server/Middleware/ExceptionMiddleware.cs156
-rw-r--r--Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs76
-rw-r--r--Jellyfin.Server/Middleware/LanFilteringMiddleware.cs76
-rw-r--r--Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs79
-rw-r--r--Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs51
-rw-r--r--Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs40
-rw-r--r--Jellyfin.Server/Migrations/IMigrationRoutine.cs6
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs9
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs45
-rw-r--r--Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs4
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs5
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs39
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs179
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs218
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs49
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs5
-rw-r--r--Jellyfin.Server/Program.cs75
-rw-r--r--Jellyfin.Server/Properties/launchSettings.json12
-rw-r--r--Jellyfin.Server/Startup.cs130
-rw-r--r--Jellyfin.Server/StartupOptions.cs18
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/banner-dark.svg34
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/redoc/custom.css0
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/swagger/custom.css15
35 files changed, 1935 insertions, 119 deletions
diff --git a/Jellyfin.Server/Configuration/CorsPolicyProvider.cs b/Jellyfin.Server/Configuration/CorsPolicyProvider.cs
new file mode 100644
index 000000000..0d04b6bb1
--- /dev/null
+++ b/Jellyfin.Server/Configuration/CorsPolicyProvider.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.AspNetCore.Cors.Infrastructure;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Server.Configuration
+{
+ /// <summary>
+ /// Cors policy provider.
+ /// </summary>
+ public class CorsPolicyProvider : ICorsPolicyProvider
+ {
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CorsPolicyProvider"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public CorsPolicyProvider(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc />
+ public Task<CorsPolicy> GetPolicyAsync(HttpContext context, string policyName)
+ {
+ var corsHosts = _serverConfigurationManager.Configuration.CorsHosts;
+ var builder = new CorsPolicyBuilder()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+
+ // No hosts configured or only default configured.
+ if (corsHosts.Length == 0
+ || (corsHosts.Length == 1
+ && string.Equals(corsHosts[0], CorsConstants.AnyOrigin, StringComparison.Ordinal)))
+ {
+ builder.AllowAnyOrigin();
+ }
+ else
+ {
+ builder.WithOrigins(corsHosts)
+ .AllowCredentials();
+ }
+
+ return Task.FromResult(builder.Build());
+ }
+ }
+}
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index 331a32c73..c44736447 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -4,11 +4,19 @@ using System.IO;
using System.Reflection;
using Emby.Drawing;
using Emby.Server.Implementations;
+using Emby.Server.Implementations.Session;
+using Jellyfin.Api.WebSocketListeners;
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.Controller.Net;
using MediaBrowser.Model.Activity;
using MediaBrowser.Model.IO;
using Microsoft.EntityFrameworkCore;
@@ -30,30 +38,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(
- ServerApplicationPaths applicationPaths,
+ IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
- StartupOptions options,
+ 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)
@@ -61,16 +72,26 @@ namespace Jellyfin.Server
Logger.LogWarning($"Skia not available. Will fallback to {nameof(NullImageEncoder)}.");
}
- // TODO: Set up scoping and use AddDbContextPool
- serviceCollection.AddDbContext<JellyfinDb>(
- options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"),
- ServiceLifetime.Transient);
+ ServiceCollection.AddDbContextPool<JellyfinDb>(
+ options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"));
- serviceCollection.AddSingleton<JellyfinDbProvider>();
+ ServiceCollection.AddEventServices();
+ ServiceCollection.AddSingleton<IEventManager, EventManager>();
+ ServiceCollection.AddSingleton<JellyfinDbProvider>();
- serviceCollection.AddSingleton<IActivityManager, ActivityManager>();
+ ServiceCollection.AddSingleton<IActivityManager, ActivityManager>();
+ ServiceCollection.AddSingleton<IUserManager, UserManager>();
+ ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
- base.RegisterServices(serviceCollection);
+ ServiceCollection.AddScoped<IWebSocketListener, SessionWebSocketListener>();
+ ServiceCollection.AddScoped<IWebSocketListener, ActivityLogWebSocketListener>();
+ ServiceCollection.AddScoped<IWebSocketListener, ScheduledTasksWebSocketListener>();
+ ServiceCollection.AddScoped<IWebSocketListener, SessionInfoWebSocketListener>();
+
+ // TODO fix circular dependency on IWebSocketManager
+ ServiceCollection.AddScoped(serviceProvider => new Lazy<IEnumerable<IWebSocketListener>>(serviceProvider.GetRequiredService<IEnumerable<IWebSocketListener>>));
+
+ base.RegisterServices();
}
/// <inheritdoc />
@@ -79,7 +100,11 @@ namespace Jellyfin.Server
/// <inheritdoc />
protected override IEnumerable<Assembly> GetAssembliesWithPartsInternal()
{
+ // Jellyfin.Server
yield return typeof(CoreAppHost).Assembly;
+
+ // Jellyfin.Server.Implementations
+ yield return typeof(JellyfinDb).Assembly;
}
/// <inheritdoc />
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index db06eb455..c7fbfa4d0 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -1,4 +1,8 @@
+using System.Collections.Generic;
+using Jellyfin.Server.Middleware;
+using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
+using Microsoft.OpenApi.Models;
namespace Jellyfin.Server.Extensions
{
@@ -11,17 +15,96 @@ 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('/');
+ var apiDocBaseUrl = serverConfigurationManager.Configuration.BaseUrl;
+ 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 = "{documentName}/openapi.json";
+ c.PreSerializeFilters.Add((swagger, httpReq) =>
+ {
+ swagger.Servers = new List<OpenApiServer> { new OpenApiServer { Url = $"{httpReq.Scheme}://{httpReq.Host.Value}{apiDocBaseUrl}" } };
+ });
+ })
+ .UseSwaggerUI(c =>
+ {
+ c.DocumentTitle = "Jellyfin API";
+ c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API");
+ c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css");
+ c.RoutePrefix = "api-docs/swagger";
+ })
+ .UseReDoc(c =>
+ {
+ c.DocumentTitle = "Jellyfin API";
+ c.SpecUrl($"/{baseUrl}api-docs/openapi.json");
+ c.InjectStylesheet($"/{baseUrl}api-docs/redoc/custom.css");
+ c.RoutePrefix = "api-docs/redoc";
+ });
+ }
+
+ /// <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 71ef9a69a..d7b9da5c2 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -1,13 +1,36 @@
-using Jellyfin.Api;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Reflection;
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.Api.ModelBinders;
+using Jellyfin.Server.Configuration;
+using Jellyfin.Server.Filters;
+using Jellyfin.Server.Formatters;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Cors.Infrastructure;
+using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
namespace Jellyfin.Server.Extensions
{
@@ -23,16 +46,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 +85,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());
+ });
});
}
@@ -59,13 +138,37 @@ namespace Jellyfin.Server.Extensions
/// Extension method for adding the jellyfin API to the service collection.
/// </summary>
/// <param name="serviceCollection">The service collection.</param>
- /// <param name="baseUrl">The base url for the API.</param>
+ /// <param name="pluginAssemblies">An IEnumerable containing all plugin assemblies with API controllers.</param>
+ /// <param name="knownProxies">A list of all known proxies to trust for X-Forwarded-For.</param>
/// <returns>The MVC builder.</returns>
- public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl)
+ public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, IEnumerable<Assembly> pluginAssemblies, IReadOnlyList<string> knownProxies)
{
- return serviceCollection.AddMvc(opts =>
+ IMvcBuilder mvcBuilder = serviceCollection
+ .AddCors()
+ .AddTransient<ICorsPolicyProvider, CorsPolicyProvider>()
+ .Configure<ForwardedHeadersOptions>(options =>
{
- opts.UseGeneralRoutePrefix(baseUrl);
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+ for (var i = 0; i < knownProxies.Count; i++)
+ {
+ if (IPAddress.TryParse(knownProxies[i], out var address))
+ {
+ options.KnownProxies.Add(address);
+ }
+ }
+ })
+ .AddMvc(opts =>
+ {
+ // Allow requester to change between camelCase and PascalCase
+ opts.RespectBrowserAcceptHeader = true;
+
+ opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter());
+ opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter());
+
+ opts.OutputFormatters.Add(new CssOutputFormatter());
+ opts.OutputFormatters.Add(new XmlOutputFormatter());
+
+ opts.ModelBinderProviders.Insert(0, new CommaDelimitedArrayModelBinderProvider());
})
// Clear app parts to avoid other assemblies being picked up
@@ -73,10 +176,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 +212,104 @@ 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();
+
+ c.OperationFilter<FileResponseFilter>();
+ c.DocumentFilter<WebsocketModelFilter>();
});
}
+
+ 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/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs
new file mode 100644
index 000000000..8ea35c281
--- /dev/null
+++ b/Jellyfin.Server/Filters/FileResponseFilter.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Attributes;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Jellyfin.Server.Filters
+{
+ /// <inheritdoc />
+ public class FileResponseFilter : IOperationFilter
+ {
+ private const string SuccessCode = "200";
+ private static readonly OpenApiMediaType _openApiMediaType = new OpenApiMediaType
+ {
+ Schema = new OpenApiSchema
+ {
+ Type = "file"
+ }
+ };
+
+ /// <inheritdoc />
+ public void Apply(OpenApiOperation operation, OperationFilterContext context)
+ {
+ foreach (var attribute in context.ApiDescription.ActionDescriptor.EndpointMetadata)
+ {
+ if (attribute is ProducesFileAttribute producesFileAttribute)
+ {
+ // Get operation response values.
+ var (_, value) = operation.Responses
+ .FirstOrDefault(o => o.Key.Equals(SuccessCode, StringComparison.Ordinal));
+
+ // Operation doesn't have a response.
+ if (value == null)
+ {
+ continue;
+ }
+
+ // Clear existing responses.
+ value.Content.Clear();
+
+ // Add all content-types as file.
+ foreach (var contentType in producesFileAttribute.GetContentTypes())
+ {
+ value.Content.Add(contentType, _openApiMediaType);
+ }
+
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Filters/WebsocketModelFilter.cs b/Jellyfin.Server/Filters/WebsocketModelFilter.cs
new file mode 100644
index 000000000..248802857
--- /dev/null
+++ b/Jellyfin.Server/Filters/WebsocketModelFilter.cs
@@ -0,0 +1,30 @@
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Jellyfin.Server.Filters
+{
+ /// <summary>
+ /// Add models used in websocket messaging.
+ /// </summary>
+ public class WebsocketModelFilter : IDocumentFilter
+ {
+ /// <inheritdoc />
+ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
+ {
+ context.SchemaGenerator.GenerateSchema(typeof(LibraryUpdateInfo), context.SchemaRepository);
+ context.SchemaGenerator.GenerateSchema(typeof(IPlugin), context.SchemaRepository);
+ context.SchemaGenerator.GenerateSchema(typeof(PlayRequest), context.SchemaRepository);
+ context.SchemaGenerator.GenerateSchema(typeof(PlaystateRequest), context.SchemaRepository);
+ context.SchemaGenerator.GenerateSchema(typeof(TimerEventInfo), context.SchemaRepository);
+ context.SchemaGenerator.GenerateSchema(typeof(SendCommand), context.SchemaRepository);
+ context.SchemaGenerator.GenerateSchema(typeof(GeneralCommandType), context.SchemaRepository);
+
+ context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<object>), context.SchemaRepository);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs
new file mode 100644
index 000000000..8043989b1
--- /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(JsonDefaults.CamelCaseMediaType));
+ }
+ }
+}
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
new file mode 100644
index 000000000..d0110b125
--- /dev/null
+++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs
@@ -0,0 +1,24 @@
+using System.Net.Mime;
+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(MediaTypeNames.Application.Json));
+ SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType));
+ }
+ }
+}
diff --git a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs
new file mode 100644
index 000000000..01d99d7c8
--- /dev/null
+++ b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs
@@ -0,0 +1,32 @@
+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.Clear();
+ SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
+
+ 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/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index c93aa837e..7de34f416 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
@@ -13,6 +13,7 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
+ <DisableImplicitAspNetCoreAnalyzers>true</DisableImplicitAspNetCoreAnalyzers>
</PropertyGroup>
<ItemGroup>
@@ -23,10 +24,6 @@
<EmbeddedResource Include="Resources/Configuration/*" />
</ItemGroup>
- <ItemGroup>
- <FrameworkReference Include="Microsoft.AspNetCore.App" />
- </ItemGroup>
-
<!-- Code Analyzers-->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
@@ -40,19 +37,21 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="CommandLineParser" Version="2.7.82" />
- <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.4" />
- <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.4" />
- <PackageReference Include="prometheus-net" Version="3.5.0" />
- <PackageReference Include="prometheus-net.AspNetCore" Version="3.5.0" />
- <PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
+ <PackageReference Include="CommandLineParser" Version="2.8.0" />
+ <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.9" />
+ <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.9" />
+ <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.9" />
+ <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="3.1.9" />
+ <PackageReference Include="prometheus-net" Version="3.6.0" />
+ <PackageReference Include="prometheus-net.AspNetCore" Version="3.6.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" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
- <PackageReference Include="Serilog.Sinks.Graylog" Version="2.1.2" />
- <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.2" />
+ <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.1" />
+ <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.4" />
<PackageReference Include="SQLitePCLRaw.provider.sqlite3.netstandard11" Version="1.1.14" />
</ItemGroup>
@@ -63,4 +62,16 @@
<ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
</ItemGroup>
+ <ItemGroup>
+ <None Update="wwwroot\api-docs\redoc\custom.css">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="wwwroot\api-docs\swagger\custom.css">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="wwwroot\api-docs\banner-dark.svg">
+ <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 000000000..9316737bd
--- /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 000000000..f6c76e4d9
--- /dev/null
+++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs
@@ -0,0 +1,156 @@
+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 AuthenticationException _: return StatusCodes.Status401Unauthorized;
+ case SecurityException _: return StatusCodes.Status403Forbidden;
+ 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 000000000..4bda8f273
--- /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.IsLocal())
+ {
+ await _next(httpContext).ConfigureAwait(false);
+ return;
+ }
+
+ var remoteIp = httpContext.GetNormalizedRemoteIp();
+
+ 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 000000000..9d795145a
--- /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 000000000..74874da1b
--- /dev/null
+++ b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs
@@ -0,0 +1,79 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
+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.GetNormalizedRemoteIp(),
+ watch.Elapsed,
+ context.Response.StatusCode);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs b/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs
new file mode 100644
index 000000000..2ec063392
--- /dev/null
+++ b/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs
@@ -0,0 +1,51 @@
+using System;
+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
+ || httpContext.Request.Path.Equals("/system/ping", StringComparison.OrdinalIgnoreCase))
+ {
+ 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 000000000..b7a5d2b34
--- /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/IMigrationRoutine.cs b/Jellyfin.Server/Migrations/IMigrationRoutine.cs
index b79fdeac0..c1000eede 100644
--- a/Jellyfin.Server/Migrations/IMigrationRoutine.cs
+++ b/Jellyfin.Server/Migrations/IMigrationRoutine.cs
@@ -1,5 +1,4 @@
using System;
-using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations
{
@@ -19,6 +18,11 @@ namespace Jellyfin.Server.Migrations
public string Name { get; }
/// <summary>
+ /// Gets a value indicating whether to perform migration on a new install.
+ /// </summary>
+ public bool PerformOnNewInstall { get; }
+
+ /// <summary>
/// Execute the migration routine.
/// </summary>
public void Perform();
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
index 473f62737..844dae61f 100644
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ b/Jellyfin.Server/Migrations/MigrationRunner.cs
@@ -19,7 +19,11 @@ namespace Jellyfin.Server.Migrations
typeof(Routines.DisableTranscodingThrottling),
typeof(Routines.CreateUserLoggingConfigFile),
typeof(Routines.MigrateActivityLogDb),
- typeof(Routines.RemoveDuplicateExtras)
+ typeof(Routines.RemoveDuplicateExtras),
+ typeof(Routines.AddDefaultPluginRepository),
+ typeof(Routines.MigrateUserDb),
+ typeof(Routines.ReaddDefaultPluginRepository),
+ typeof(Routines.MigrateDisplayPreferencesDb)
};
/// <summary>
@@ -41,9 +45,8 @@ namespace Jellyfin.Server.Migrations
// If startup wizard is not finished, this is a fresh install.
// Don't run any migrations, just mark all of them as applied.
logger.LogInformation("Marking all known migrations as applied because this is a fresh install");
- migrationOptions.Applied.AddRange(migrations.Select(m => (m.Id, m.Name)));
+ migrationOptions.Applied.AddRange(migrations.Where(m => !m.PerformOnNewInstall).Select(m => (m.Id, m.Name)));
host.ServerConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions);
- return;
}
var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet();
diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
new file mode 100644
index 000000000..f6d8c9cc0
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
@@ -0,0 +1,45 @@
+using System;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Updates;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// Migration to initialize system configuration with the default plugin repository.
+ /// </summary>
+ public class AddDefaultPluginRepository : IMigrationRoutine
+ {
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo
+ {
+ Name = "Jellyfin Stable",
+ Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"
+ };
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AddDefaultPluginRepository"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public AddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc/>
+ public Guid Id => Guid.Parse("EB58EBEE-9514-4B9B-8225-12E1A40020DF");
+
+ /// <inheritdoc/>
+ public string Name => "AddDefaultPluginRepository";
+
+ /// <inheritdoc/>
+ public bool PerformOnNewInstall => true;
+
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ _serverConfigurationManager.Configuration.PluginRepositories.Add(_defaultRepositoryInfo);
+ _serverConfigurationManager.SaveConfiguration();
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
index 89514c89b..6821630db 100644
--- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
+++ b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Common.Configuration;
-using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
namespace Jellyfin.Server.Migrations.Routines
@@ -50,6 +49,9 @@ namespace Jellyfin.Server.Migrations.Routines
public string Name => "CreateLoggingConfigHeirarchy";
/// <inheritdoc/>
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc/>
public void Perform()
{
var logDirectory = _appPaths.ConfigurationDirectoryPath;
diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
index b2e957d5b..0925a87b5 100644
--- a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
+++ b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
@@ -10,7 +10,7 @@ namespace Jellyfin.Server.Migrations.Routines
/// </summary>
internal class DisableTranscodingThrottling : IMigrationRoutine
{
- private readonly ILogger _logger;
+ private readonly ILogger<DisableTranscodingThrottling> _logger;
private readonly IConfigurationManager _configManager;
public DisableTranscodingThrottling(ILogger<DisableTranscodingThrottling> logger, IConfigurationManager configManager)
@@ -26,6 +26,9 @@ namespace Jellyfin.Server.Migrations.Routines
public string Name => "DisableTranscodingThrottling";
/// <inheritdoc/>
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc/>
public void Perform()
{
// Set EnableThrottling to false since it wasn't used before and may introduce issues
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
index b3cc29708..6048160c6 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Linq;
using Emby.Server.Implementations.Data;
using Jellyfin.Data.Entities;
using Jellyfin.Server.Implementations;
@@ -43,6 +42,9 @@ namespace Jellyfin.Server.Migrations.Routines
public string Name => "MigrateActivityLogDatabase";
/// <inheritdoc/>
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc/>
public void Perform()
{
var logLevelDictionary = new Dictionary<string, LogLevel>(StringComparer.OrdinalIgnoreCase)
@@ -64,10 +66,11 @@ namespace Jellyfin.Server.Migrations.Routines
ConnectionFlags.ReadOnly,
null))
{
+ using var userDbConnection = SQLite3.Open(Path.Combine(dataPath, "users.db"), ConnectionFlags.ReadOnly, null);
_logger.LogWarning("Migrating the activity database may take a while, do not stop Jellyfin.");
using var dbContext = _provider.CreateContext();
- var queryResult = connection.Query("SELECT * FROM ActivityLog ORDER BY Id ASC");
+ var queryResult = connection.Query("SELECT * FROM ActivityLog ORDER BY Id");
// Make sure that the database is empty in case of failed migration due to power outages, etc.
dbContext.ActivityLogs.RemoveRange(dbContext.ActivityLogs);
@@ -76,17 +79,35 @@ namespace Jellyfin.Server.Migrations.Routines
dbContext.Database.ExecuteSqlRaw("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'ActivityLog';");
dbContext.SaveChanges();
- var newEntries = queryResult.Select(entry =>
+ var newEntries = new List<ActivityLog>();
+
+ foreach (var entry in queryResult)
{
if (!logLevelDictionary.TryGetValue(entry[8].ToString(), out var severity))
{
severity = LogLevel.Trace;
}
- var newEntry = new ActivityLog(
- entry[1].ToString(),
- entry[4].ToString(),
- entry[6].SQLiteType == SQLiteType.Null ? Guid.Empty : Guid.Parse(entry[6].ToString()))
+ var guid = Guid.Empty;
+ if (entry[6].SQLiteType != SQLiteType.Null && !Guid.TryParse(entry[6].ToString(), out guid))
+ {
+ // This is not a valid Guid, see if it is an internal ID from an old Emby schema
+ _logger.LogWarning("Invalid Guid in UserId column: ", entry[6].ToString());
+
+ using var statement = userDbConnection.PrepareStatement("SELECT guid FROM LocalUsersv2 WHERE Id=@Id");
+ statement.TryBind("@Id", entry[6].ToString());
+
+ foreach (var row in statement.Query())
+ {
+ if (row.Count > 0 && Guid.TryParse(row[0].ToString(), out guid))
+ {
+ // Successfully parsed a Guid from the user table.
+ break;
+ }
+ }
+ }
+
+ var newEntry = new ActivityLog(entry[1].ToString(), entry[4].ToString(), guid)
{
DateCreated = entry[7].ReadDateTime(),
LogSeverity = severity
@@ -107,8 +128,8 @@ namespace Jellyfin.Server.Migrations.Routines
newEntry.ItemId = entry[5].ToString();
}
- return newEntry;
- });
+ newEntries.Add(newEntry);
+ }
dbContext.ActivityLogs.AddRange(newEntries);
dbContext.SaveChanges();
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
new file mode 100644
index 000000000..7f57358ec
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using Jellyfin.Server.Implementations;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+using SQLitePCL.pretty;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// The migration routine for migrating the display preferences database to EF Core.
+ /// </summary>
+ public class MigrateDisplayPreferencesDb : IMigrationRoutine
+ {
+ private const string DbFilename = "displaypreferences.db";
+
+ private readonly ILogger<MigrateDisplayPreferencesDb> _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly JellyfinDbProvider _provider;
+ private readonly JsonSerializerOptions _jsonOptions;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MigrateDisplayPreferencesDb"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="paths">The server application paths.</param>
+ /// <param name="provider">The database provider.</param>
+ public MigrateDisplayPreferencesDb(ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, JellyfinDbProvider provider)
+ {
+ _logger = logger;
+ _paths = paths;
+ _provider = provider;
+ _jsonOptions = new JsonSerializerOptions();
+ _jsonOptions.Converters.Add(new JsonStringEnumConverter());
+ }
+
+ /// <inheritdoc />
+ public Guid Id => Guid.Parse("06387815-C3CC-421F-A888-FB5F9992BEA8");
+
+ /// <inheritdoc />
+ public string Name => "MigrateDisplayPreferencesDatabase";
+
+ /// <inheritdoc />
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ HomeSectionType[] defaults =
+ {
+ HomeSectionType.SmallLibraryTiles,
+ HomeSectionType.Resume,
+ HomeSectionType.ResumeAudio,
+ HomeSectionType.LiveTv,
+ HomeSectionType.NextUp,
+ HomeSectionType.LatestMedia,
+ HomeSectionType.None,
+ };
+
+ var chromecastDict = new Dictionary<string, ChromecastVersion>(StringComparer.OrdinalIgnoreCase)
+ {
+ { "stable", ChromecastVersion.Stable },
+ { "nightly", ChromecastVersion.Unstable },
+ { "unstable", ChromecastVersion.Unstable }
+ };
+
+ var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
+ using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null))
+ {
+ using var dbContext = _provider.CreateContext();
+
+ var results = connection.Query("SELECT * FROM userdisplaypreferences");
+ 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;
+
+ var displayPreferences = new DisplayPreferences(new Guid(result[1].ToBlob()), result[2].ToString())
+ {
+ IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null,
+ ShowBackdrop = dto.ShowBackdrop,
+ ShowSidebar = dto.ShowSidebar,
+ ScrollDirection = dto.ScrollDirection,
+ ChromecastVersion = chromecastVersion,
+ SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length)
+ ? int.Parse(length, CultureInfo.InvariantCulture)
+ : 30000,
+ SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length)
+ ? int.Parse(length, CultureInfo.InvariantCulture)
+ : 10000,
+ EnableNextVideoInfoOverlay = dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled)
+ ? bool.Parse(enabled)
+ : true,
+ DashboardTheme = dto.CustomPrefs.TryGetValue("dashboardtheme", out var theme) ? theme : string.Empty,
+ TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty
+ };
+
+ for (int i = 0; i < 7; i++)
+ {
+ dto.CustomPrefs.TryGetValue("homesection" + i, out var homeSection);
+
+ displayPreferences.HomeSections.Add(new HomeSection
+ {
+ Order = i,
+ Type = Enum.TryParse<HomeSectionType>(homeSection, true, out var type) ? type : defaults[i]
+ });
+ }
+
+ var defaultLibraryPrefs = new ItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client)
+ {
+ SortBy = dto.SortBy ?? "SortName",
+ SortOrder = dto.SortOrder,
+ RememberIndexing = dto.RememberIndexing,
+ RememberSorting = dto.RememberSorting,
+ };
+
+ dbContext.Add(defaultLibraryPrefs);
+
+ foreach (var key in dto.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.Ordinal)))
+ {
+ if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var itemId))
+ {
+ continue;
+ }
+
+ var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client)
+ {
+ SortBy = dto.SortBy ?? "SortName",
+ SortOrder = dto.SortOrder,
+ RememberIndexing = dto.RememberIndexing,
+ RememberSorting = dto.RememberSorting,
+ };
+
+ if (Enum.TryParse<ViewType>(dto.ViewType, true, out var viewType))
+ {
+ libraryDisplayPreferences.ViewType = viewType;
+ }
+
+ dbContext.ItemDisplayPreferences.Add(libraryDisplayPreferences);
+ }
+
+ dbContext.Add(displayPreferences);
+ }
+
+ dbContext.SaveChanges();
+ }
+
+ try
+ {
+ File.Move(dbFilePath, dbFilePath + ".old");
+
+ var journalPath = dbFilePath + "-journal";
+ if (File.Exists(journalPath))
+ {
+ File.Move(journalPath, dbFilePath + ".old-journal");
+ }
+ }
+ catch (IOException e)
+ {
+ _logger.LogError(e, "Error renaming legacy display preferences database to 'displaypreferences.db.old'");
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
new file mode 100644
index 000000000..74c550331
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
@@ -0,0 +1,218 @@
+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;
+using Jellyfin.Data.Enums;
+using Jellyfin.Server.Implementations;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Users;
+using Microsoft.Extensions.Logging;
+using SQLitePCL.pretty;
+using JsonSerializer = System.Text.Json.JsonSerializer;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// The migration routine for migrating the user database to EF Core.
+ /// </summary>
+ public class MigrateUserDb : IMigrationRoutine
+ {
+ private const string DbFilename = "users.db";
+
+ private readonly ILogger<MigrateUserDb> _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly JellyfinDbProvider _provider;
+ private readonly MyXmlSerializer _xmlSerializer;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MigrateUserDb"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="paths">The server application paths.</param>
+ /// <param name="provider">The database provider.</param>
+ /// <param name="xmlSerializer">The xml serializer.</param>
+ public MigrateUserDb(
+ ILogger<MigrateUserDb> logger,
+ IServerApplicationPaths paths,
+ JellyfinDbProvider provider,
+ MyXmlSerializer xmlSerializer)
+ {
+ _logger = logger;
+ _paths = paths;
+ _provider = provider;
+ _xmlSerializer = xmlSerializer;
+ }
+
+ /// <inheritdoc/>
+ public Guid Id => Guid.Parse("5C4B82A2-F053-4009-BD05-B6FCAD82F14C");
+
+ /// <inheritdoc/>
+ public string Name => "MigrateUserDatabase";
+
+ /// <inheritdoc/>
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ var dataPath = _paths.DataPath;
+ _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
+
+ using (var connection = SQLite3.Open(Path.Combine(dataPath, DbFilename), ConnectionFlags.ReadOnly, null))
+ {
+ var dbContext = _provider.CreateContext();
+
+ var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
+
+ dbContext.RemoveRange(dbContext.Users);
+ dbContext.SaveChanges();
+
+ foreach (var entry in queryResult)
+ {
+ 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"))
+ ? (UserConfiguration)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), Path.Combine(userDataDir, "config.xml"))
+ : new UserConfiguration();
+ var policy = File.Exists(Path.Combine(userDataDir, "policy.xml"))
+ ? (UserPolicy)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), Path.Combine(userDataDir, "policy.xml"))
+ : new UserPolicy();
+ policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace(
+ "Emby.Server.Implementations.Library",
+ "Jellyfin.Server.Implementations.Users",
+ StringComparison.Ordinal)
+ ?? typeof(DefaultAuthenticationProvider).FullName;
+
+ policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName;
+ int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
+ {
+ -1 => null,
+ 0 => 3,
+ _ => policy.LoginAttemptsBeforeLockout
+ };
+
+ var user = new User(mockup.Name, policy.AuthenticationProviderId, policy.PasswordResetProviderId)
+ {
+ Id = entry[1].ReadGuidFromBlob(),
+ InternalId = entry[0].ToInt64(),
+ MaxParentalAgeRating = policy.MaxParentalRating,
+ EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess,
+ RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit,
+ InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount,
+ LoginAttemptsBeforeLockout = maxLoginAttempts,
+ SubtitleMode = config.SubtitleMode,
+ HidePlayedInLatest = config.HidePlayedInLatest,
+ EnableLocalPassword = config.EnableLocalPassword,
+ PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
+ DisplayCollectionsView = config.DisplayCollectionsView,
+ DisplayMissingEpisodes = config.DisplayMissingEpisodes,
+ AudioLanguagePreference = config.AudioLanguagePreference,
+ RememberAudioSelections = config.RememberAudioSelections,
+ EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay,
+ RememberSubtitleSelections = config.RememberSubtitleSelections,
+ SubtitleLanguagePreference = config.SubtitleLanguagePreference,
+ Password = mockup.Password,
+ EasyPassword = mockup.EasyPassword,
+ LastLoginDate = mockup.LastLoginDate,
+ LastActivityDate = mockup.LastActivityDate
+ };
+
+ if (mockup.ImageInfos.Length > 0)
+ {
+ ItemImageInfo info = mockup.ImageInfos[0];
+
+ user.ProfileImage = new ImageInfo(info.Path)
+ {
+ LastModified = info.DateModified
+ };
+ }
+
+ user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
+ user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
+ user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
+ user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
+ user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
+ user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
+ user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
+ user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
+ user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
+ user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
+ user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
+ user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
+ user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
+ user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
+ user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
+ user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
+ user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
+ user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
+ user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
+
+ foreach (var policyAccessSchedule in policy.AccessSchedules)
+ {
+ user.AccessSchedules.Add(policyAccessSchedule);
+ }
+
+ user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
+ 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?.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);
+ user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
+ user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
+
+ dbContext.Users.Add(user);
+ }
+
+ dbContext.SaveChanges();
+ }
+
+ try
+ {
+ File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old"));
+
+ var journalPath = Path.Combine(dataPath, DbFilename + "-journal");
+ if (File.Exists(journalPath))
+ {
+ File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal"));
+ }
+ }
+ catch (IOException e)
+ {
+ _logger.LogError(e, "Error renaming legacy user database to 'users.db.old'");
+ }
+ }
+
+#nullable disable
+ internal class UserMockup
+ {
+ public string Password { get; set; }
+
+ public string EasyPassword { get; set; }
+
+ public DateTime? LastLoginDate { get; set; }
+
+ public DateTime? LastActivityDate { get; set; }
+
+ public string Name { get; set; }
+
+ public ItemImageInfo[] ImageInfos { get; set; }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
new file mode 100644
index 000000000..b281b5cc0
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
@@ -0,0 +1,49 @@
+using System;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Updates;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// Migration to initialize system configuration with the default plugin repository.
+ /// </summary>
+ public class ReaddDefaultPluginRepository : IMigrationRoutine
+ {
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo
+ {
+ Name = "Jellyfin Stable",
+ Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"
+ };
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ReaddDefaultPluginRepository"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public ReaddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc/>
+ public Guid Id => Guid.Parse("5F86E7F6-D966-4C77-849D-7A7B40B68C4E");
+
+ /// <inheritdoc/>
+ public string Name => "ReaddDefaultPluginRepository";
+
+ /// <inheritdoc/>
+ public bool PerformOnNewInstall => true;
+
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ // Only add if repository list is empty
+ if (_serverConfigurationManager.Configuration.PluginRepositories.Count == 0)
+ {
+ _serverConfigurationManager.Configuration.PluginRepositories.Add(_defaultRepositoryInfo);
+ _serverConfigurationManager.SaveConfiguration();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
index e95536388..6c26e47e1 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
@@ -14,7 +14,7 @@ namespace Jellyfin.Server.Migrations.Routines
internal class RemoveDuplicateExtras : IMigrationRoutine
{
private const string DbFilename = "library.db";
- private readonly ILogger _logger;
+ private readonly ILogger<RemoveDuplicateExtras> _logger;
private readonly IServerApplicationPaths _paths;
public RemoveDuplicateExtras(ILogger<RemoveDuplicateExtras> logger, IServerApplicationPaths paths)
@@ -30,6 +30,9 @@ namespace Jellyfin.Server.Migrations.Routines
public string Name => "RemoveDuplicateExtras";
/// <inheritdoc/>
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc/>
public void Perform()
{
var dataPath = _paths.DataPath;
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index b9895386f..5573c0439 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
@@ -6,17 +7,15 @@ using System.Net;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
-using System.Text.RegularExpressions;
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
@@ -40,12 +40,12 @@ namespace Jellyfin.Server
/// <summary>
/// The name of logging configuration file containing application defaults.
/// </summary>
- public static readonly string LoggingConfigFileDefault = "logging.default.json";
+ public const string LoggingConfigFileDefault = "logging.default.json";
/// <summary>
/// The name of the logging configuration file containing the system-specific override settings.
/// </summary>
- public static readonly string LoggingConfigFileSystem = "logging.json";
+ public const string LoggingConfigFileSystem = "logging.json";
private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource();
private static readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory();
@@ -59,20 +59,15 @@ namespace Jellyfin.Server
/// <returns><see cref="Task" />.</returns>
public static Task Main(string[] args)
{
- // For backwards compatibility.
- // Modify any input arguments now which start with single-hyphen to POSIX standard
- // double-hyphen to allow parsing by CommandLineParser package.
- const string Pattern = @"^(-[^-\s]{2})"; // Match -xx, not -x, not --xx, not xx
- const string Substitution = @"-$1"; // Prepend with additional single-hyphen
- var regex = new Regex(Pattern);
- for (var i = 0; i < args.Length; i++)
+ static Task ErrorParsingArguments(IEnumerable<Error> errors)
{
- args[i] = regex.Replace(args[i], Substitution);
+ Environment.ExitCode = 1;
+ return Task.CompletedTask;
}
// Parse the command line arguments and either start the app or exit indicating error
return Parser.Default.ParseArguments<StartupOptions>(args)
- .MapResult(StartApp, _ => Task.CompletedTask);
+ .MapResult(StartApp, ErrorParsingArguments);
}
/// <summary>
@@ -159,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 = appHost.ServerConfigurationManager.ApplicationPaths.WebPath;
if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0)
{
throw new InvalidOperationException(
@@ -183,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();
@@ -279,10 +275,10 @@ namespace Jellyfin.Server
var addresses = appHost.ServerConfigurationManager
.Configuration
.LocalNetworkAddresses
- .Select(appHost.NormalizeConfiguredLocalAddress)
+ .Select(x => appHost.NormalizeConfiguredLocalAddress(x))
.Where(i => i != null)
.ToHashSet();
- if (addresses.Any() && !addresses.Contains(IPAddress.Any))
+ if (addresses.Count > 0 && !addresses.Contains(IPAddress.Any))
{
if (!addresses.Contains(IPAddress.Loopback))
{
@@ -348,13 +344,41 @@ namespace Jellyfin.Server
}
}
}
+
+ // Bind to unix socket (only on macOS and Linux)
+ if (startupConfig.UseUnixSocket() && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ 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))
+ {
+ File.Delete(socketPath);
+ }
+
+ options.ListenUnixSocket(socketPath);
+ _logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath);
+ }
})
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(commandLineOpts, appPaths, startupConfig))
.UseSerilog()
.ConfigureServices(services =>
{
// Merge the external ServiceCollection into ASP.NET DI
- services.TryAdd(serviceCollection);
+ services.Add(serviceCollection);
})
.UseStartup<Startup>();
}
@@ -503,6 +527,13 @@ namespace Jellyfin.Server
}
}
+ // Normalize paths. Only possible with GetFullPath for now - https://github.com/dotnet/runtime/issues/2162
+ dataDir = Path.GetFullPath(dataDir);
+ logDir = Path.GetFullPath(logDir);
+ configDir = Path.GetFullPath(configDir);
+ cacheDir = Path.GetFullPath(cacheDir);
+ webDir = Path.GetFullPath(webDir);
+
// Ensure the main folders exist before we continue
try
{
@@ -570,7 +601,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/Properties/launchSettings.json b/Jellyfin.Server/Properties/launchSettings.json
index b6e2bcf97..20d432afc 100644
--- a/Jellyfin.Server/Properties/launchSettings.json
+++ b/Jellyfin.Server/Properties/launchSettings.json
@@ -2,6 +2,8 @@
"profiles": {
"Jellyfin.Server": {
"commandName": "Project",
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:8096",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -12,6 +14,16 @@
"ASPNETCORE_ENVIRONMENT": "Development"
},
"commandLineArgs": "--nowebclient"
+ },
+ "Jellyfin.Server (API Docs)": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "launchUrl": "api-docs/swagger",
+ "applicationUrl": "http://localhost:8096",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "commandLineArgs": "--nowebclient"
}
}
}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 5f9a5c161..62ffe174c 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,9 +1,21 @@
+using System;
+using System.ComponentModel;
+using System.Net.Http.Headers;
+using System.Net.Mime;
+using Jellyfin.Api.TypeConverters;
using Jellyfin.Server.Extensions;
+using Jellyfin.Server.Implementations;
+using Jellyfin.Server.Middleware;
+using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Prometheus;
@@ -15,14 +27,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 +50,11 @@ namespace Jellyfin.Server
{
services.AddResponseCompression();
services.AddHttpContextAccessor();
- services.AddJellyfinApi(_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/'));
+ services.AddHttpsRedirection(options =>
+ {
+ options.HttpsPort = _serverApplicationHost.HttpsPort;
+ });
+ services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.Configuration.KnownProxies);
services.AddJellyfinApiSwagger();
@@ -41,6 +62,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()
+ .AddDbContextCheck<JellyfinDb>();
}
/// <summary>
@@ -48,43 +89,84 @@ 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>
+ /// <param name="appConfig">The application config.</param>
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
- IServerApplicationHost serverApplicationHost)
+ IConfiguration appConfig)
{
- if (env.IsDevelopment())
+ app.UseBaseUrlRedirection();
+
+ // Wrap rest of configuration so everything only listens on BaseUrl.
+ app.Map(_serverConfigurationManager.Configuration.BaseUrl, mainApp =>
{
- app.UseDeveloperExceptionPage();
- }
+ if (env.IsDevelopment())
+ {
+ mainApp.UseDeveloperExceptionPage();
+ }
- app.UseWebSockets();
+ mainApp.UseForwardedHeaders();
+ mainApp.UseMiddleware<ExceptionMiddleware>();
- app.UseResponseCompression();
+ mainApp.UseMiddleware<ResponseTimeMiddleware>();
- // TODO app.UseMiddleware<WebSocketMiddleware>();
+ mainApp.UseWebSockets();
- // TODO use when old API is removed: app.UseAuthentication();
- app.UseJellyfinApiSwagger();
- app.UseRouting();
- app.UseAuthorization();
- if (_serverConfigurationManager.Configuration.EnableMetrics)
- {
- // Must be registered after any middleware that could chagne HTTP response codes or the data will be bad
- app.UseHttpMetrics();
- }
+ mainApp.UseResponseCompression();
+
+ mainApp.UseCors();
+
+ if (_serverConfigurationManager.Configuration.RequireHttps
+ && _serverApplicationHost.ListenWithHttps)
+ {
+ mainApp.UseHttpsRedirection();
+ }
+
+ mainApp.UseStaticFiles();
+ if (appConfig.HostWebClient())
+ {
+ var extensionProvider = new FileExtensionContentTypeProvider();
+
+ // subtitles octopus requires .data files.
+ extensionProvider.Mappings.Add(".data", MediaTypeNames.Application.Octet);
+ mainApp.UseStaticFiles(new StaticFileOptions
+ {
+ FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath),
+ RequestPath = "/web",
+ ContentTypeProvider = extensionProvider
+ });
+ }
+
+ mainApp.UseAuthentication();
+ mainApp.UseJellyfinApiSwagger(_serverConfigurationManager);
+ mainApp.UseRouting();
+ mainApp.UseAuthorization();
+
+ mainApp.UseLanFiltering();
+ mainApp.UseIpBasedAccessValidation();
+ mainApp.UseWebSocketHandler();
+ mainApp.UseServerStartupMessage();
- app.UseEndpoints(endpoints =>
- {
- endpoints.MapControllers();
if (_serverConfigurationManager.Configuration.EnableMetrics)
{
- endpoints.MapMetrics(_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/') + "/metrics");
+ // Must be registered after any middleware that could change HTTP response codes or the data will be bad
+ mainApp.UseHttpMetrics();
}
+
+ mainApp.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ if (_serverConfigurationManager.Configuration.EnableMetrics)
+ {
+ endpoints.MapMetrics("/metrics");
+ }
+
+ endpoints.MapHealthChecks("/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/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs
index cc250b06e..b63434092 100644
--- a/Jellyfin.Server/StartupOptions.cs
+++ b/Jellyfin.Server/StartupOptions.cs
@@ -64,10 +64,6 @@ namespace Jellyfin.Server
public bool IsService { get; set; }
/// <inheritdoc />
- [Option("noautorunwebapp", Required = false, HelpText = "Run headless if startup wizard is complete.")]
- public bool NoAutoRunWebApp { get; set; }
-
- /// <inheritdoc />
[Option("package-name", Required = false, HelpText = "Used when packaging Jellyfin (example, synology).")]
public string? PackageName { get; set; }
@@ -80,10 +76,6 @@ namespace Jellyfin.Server
public string? RestartArgs { get; set; }
/// <inheritdoc />
- [Option("plugin-manifest-url", Required = false, HelpText = "A custom URL for the plugin repository JSON manifest")]
- public string? PluginManifestUrl { get; set; }
-
- /// <inheritdoc />
[Option("published-server-url", Required = false, HelpText = "Jellyfin Server URL to publish via auto discover process")]
public Uri? PublishedServerUrl { get; set; }
@@ -95,11 +87,6 @@ namespace Jellyfin.Server
{
var config = new Dictionary<string, string>();
- if (PluginManifestUrl != null)
- {
- config.Add(InstallationManager.PluginManifestUrlKey, PluginManifestUrl);
- }
-
if (NoWebClient)
{
config.Add(ConfigurationExtensions.HostWebClientKey, bool.FalseString);
@@ -110,6 +97,11 @@ namespace Jellyfin.Server
config.Add(UdpServer.AddressOverrideConfigKey, PublishedServerUrl.ToString());
}
+ if (FFmpegPath != null)
+ {
+ config.Add(ConfigurationExtensions.FfmpegPathKey, FFmpegPath);
+ }
+
return config;
}
}
diff --git a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg b/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
new file mode 100644
index 000000000..b62b7545c
--- /dev/null
+++ b/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- ***** BEGIN LICENSE BLOCK *****
+ - Part of the Jellyfin project (https://jellyfin.media)
+ -
+ - All copyright belongs to the Jellyfin contributors; a full list can
+ - be found in the file CONTRIBUTORS.md
+ -
+ - This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
+ - To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
+- ***** END LICENSE BLOCK ***** -->
+<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
+ <defs>
+ <linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#aa5cc3"/>
+ <stop offset="1" stop-color="#00a4dc"/>
+ </linearGradient>
+ </defs>
+ <title>banner-dark</title>
+ <g id="banner-dark">
+ <g id="banner-dark-icon">
+ <path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
+ <path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
+ </g>
+ <g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
+ <path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
+ <path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
+ <path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
+ <path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
+ <path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
+ <path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
+ <path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
+ </g>
+ </g>
+</svg> \ No newline at end of file
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 000000000..e69de29bb
--- /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 000000000..acb59888e
--- /dev/null
+++ b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
@@ -0,0 +1,15 @@
+/* logo */
+.topbar-wrapper img[alt="Swagger UI"], .topbar-wrapper span {
+ visibility: collapse;
+}
+
+.topbar-wrapper .link:after {
+ content: url(../banner-dark.svg);
+ display: block;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ max-width: 100%;
+ max-height: 100%;
+ width: 150px;
+}
+/* end logo */