diff options
Diffstat (limited to 'Jellyfin.Server')
25 files changed, 737 insertions, 103 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 755844dd9..8d569a779 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Reflection; using Emby.Drawing; using Emby.Server.Implementations; @@ -15,6 +16,7 @@ using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Activity; using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -67,12 +69,8 @@ namespace Jellyfin.Server Logger.LogWarning($"Skia not available. Will fallback to {nameof(NullImageEncoder)}."); } - // TODO: Set up scoping and use AddDbContextPool, - // can't register as Transient since tracking transient in GC is funky - // 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.AddEventServices(); ServiceCollection.AddSingleton<IEventManager, EventManager>(); diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 745567703..c7fbfa4d0 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,5 +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 { @@ -22,6 +25,7 @@ namespace Jellyfin.Server.Extensions // specifying the Swagger JSON endpoint. var baseUrl = serverConfigurationManager.Configuration.BaseUrl.Trim('/'); + var apiDocBaseUrl = serverConfigurationManager.Configuration.BaseUrl; if (!string.IsNullOrEmpty(baseUrl)) { baseUrl += '/'; @@ -31,20 +35,76 @@ namespace Jellyfin.Server.Extensions .UseSwagger(c => { // Custom path requires {documentName}, SwaggerDoc documentName is 'api-docs' - c.RouteTemplate = $"/{baseUrl}{{documentName}}/openapi.json"; + 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.RoutePrefix = $"{baseUrl}api-docs/swagger"; + 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.RoutePrefix = $"{baseUrl}api-docs/redoc"; + 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 f0ae9fbfc..d7b9da5c2 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Reflection; -using Jellyfin.Api; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.DownloadPolicy; @@ -16,17 +16,21 @@ 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 Jellyfin.Server.Models; 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 { @@ -134,27 +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 - .AddCors(options => - { - options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy); - }) + IMvcBuilder mvcBuilder = serviceCollection + .AddCors() + .AddTransient<ICorsPolicyProvider, CorsPolicyProvider>() .Configure<ForwardedHeadersOptions>(options => { 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 => { - opts.UseGeneralRoutePrefix(baseUrl); + // 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 @@ -179,8 +193,14 @@ namespace Jellyfin.Server.Extensions // From JsonDefaults.PascalCase options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy; - }) - .AddControllersAsServices(); + }); + + foreach (Assembly pluginAssembly in pluginAssemblies) + { + mvcBuilder.AddApplicationPart(pluginAssembly); + } + + return mvcBuilder.AddControllersAsServices(); } /// <summary> @@ -241,6 +261,9 @@ namespace Jellyfin.Server.Extensions // TODO - remove when all types are supported in System.Text.Json c.AddSwaggerTypeMappings(); + + c.OperationFilter<FileResponseFilter>(); + c.DocumentFilter<WebsocketModelFilter>(); }); } 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 index 9b347ae2c..8043989b1 100644 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Formatters public CamelCaseJsonProfileFormatter() : base(JsonDefaults.GetCamelCaseOptions()) { SupportedMediaTypes.Clear(); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\"")); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType)); } } } diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs index 0024708ba..d0110b125 100644 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -1,3 +1,4 @@ +using System.Net.Mime; using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -16,8 +17,8 @@ namespace Jellyfin.Server.Formatters { SupportedMediaTypes.Clear(); // Add application/json for default formatter - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"PascalCase\"")); + 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 index 58319657d..01d99d7c8 100644 --- a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs @@ -16,8 +16,9 @@ namespace Jellyfin.Server.Formatters /// </summary> public XmlOutputFormatter() { + SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); - SupportedMediaTypes.Add("text/xml;charset=UTF-8"); + SupportedEncodings.Add(Encoding.UTF8); SupportedEncodings.Add(Encoding.Unicode); } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 7541707d9..761a92f6d 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" /> @@ -41,8 +38,10 @@ <ItemGroup> <PackageReference Include="CommandLineParser" Version="2.8.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.6" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.6" /> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.8" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.8" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="3.1.8" /> <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" /> @@ -51,8 +50,8 @@ <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.3" /> - <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.3" /> + <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 index 63effafc1..f6c76e4d9 100644 --- a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs @@ -125,7 +125,8 @@ namespace Jellyfin.Server.Middleware switch (ex) { case ArgumentException _: return StatusCodes.Status400BadRequest; - case SecurityException _: return StatusCodes.Status401Unauthorized; + case AuthenticationException _: return StatusCodes.Status401Unauthorized; + case SecurityException _: return StatusCodes.Status403Forbidden; case DirectoryNotFoundException _: case FileNotFoundException _: case ResourceNotFoundException _: return StatusCodes.Status404NotFound; 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 index 3122d92cb..74874da1b 100644 --- a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs +++ b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs @@ -1,6 +1,7 @@ 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; @@ -69,7 +70,7 @@ namespace Jellyfin.Server.Middleware _logger.LogWarning( "Slow HTTP Response from {url} to {remoteIp} in {elapsed:g} with Status Code {statusCode}", context.Request.GetDisplayUrl(), - context.Connection.RemoteIpAddress, + 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/Models/ServerCorsPolicy.cs b/Jellyfin.Server/Models/ServerCorsPolicy.cs deleted file mode 100644 index ae010c042..000000000 --- a/Jellyfin.Server/Models/ServerCorsPolicy.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Cors.Infrastructure; - -namespace Jellyfin.Server.Models -{ - /// <summary> - /// Server Cors Policy. - /// </summary> - public static class ServerCorsPolicy - { - /// <summary> - /// Default policy name. - /// </summary> - public const string DefaultPolicyName = "DefaultCorsPolicy"; - - /// <summary> - /// Default Policy. Allow Everything. - /// </summary> - public static readonly CorsPolicy DefaultPolicy = new CorsPolicy - { - // Allow any origin - Origins = { "*" }, - - // Allow any method - Methods = { "*" }, - - // Allow any header - Headers = { "*" } - }; - } -}
\ No newline at end of file diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 14cc5f4c2..c933d679f 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -11,7 +11,6 @@ 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; @@ -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 @@ -169,7 +169,7 @@ namespace Jellyfin.Server // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) { - string? webContentPath = DashboardController.GetWebClientUiPath(startupConfig, appHost.ServerConfigurationManager); + string? webContentPath = appHost.ServerConfigurationManager.ApplicationPaths.WebPath; if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) { throw new InvalidOperationException( @@ -527,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 { @@ -594,7 +601,7 @@ namespace Jellyfin.Server var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration; if (startupConfig != null && !startupConfig.HostWebClient()) { - inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "api-docs/swagger"; + 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 d0dd183c6..62ffe174c 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,14 +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 Jellyfin.Server.Models; +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; @@ -20,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> @@ -38,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(); @@ -46,7 +62,26 @@ namespace Jellyfin.Server services.AddCustomAuthentication(); services.AddJellyfinApiAuthorization(); - services.AddHttpClient(); + + 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> @@ -54,48 +89,81 @@ 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.UseMiddleware<ExceptionMiddleware>(); + mainApp.UseForwardedHeaders(); + mainApp.UseMiddleware<ExceptionMiddleware>(); - app.UseMiddleware<ResponseTimeMiddleware>(); + mainApp.UseMiddleware<ResponseTimeMiddleware>(); - app.UseWebSockets(); + mainApp.UseWebSockets(); - app.UseResponseCompression(); + mainApp.UseResponseCompression(); - // TODO app.UseMiddleware<WebSocketMiddleware>(); + mainApp.UseCors(); - app.UseAuthentication(); - app.UseJellyfinApiSwagger(_serverConfigurationManager); - app.UseRouting(); - app.UseCors(ServerCorsPolicy.DefaultPolicyName); - 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(); - } + 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(); } - }); - app.Use(serverApplicationHost.ExecuteHttpHandlerAsync); + mainApp.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + if (_serverConfigurationManager.Configuration.EnableMetrics) + { + endpoints.MapMetrics("/metrics"); + } + + endpoints.MapHealthChecks("/health"); + }); + }); // 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 41a1430d2..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; } 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 */ |
