Merge remote-tracking branch 'upstream/master' into 3.1.7

This commit is contained in:
crobibero 2020-09-03 09:29:07 -06:00
commit 5ad81f7fe6
91 changed files with 1692 additions and 5416 deletions

View File

@ -62,7 +62,6 @@ jobs:
- task: DownloadPipelineArtifact@2
displayName: 'Download Reference Assembly Build Artifact'
enabled: false
inputs:
source: "specific"
artifact: "$(NugetPackageName)"
@ -74,7 +73,6 @@ jobs:
- task: CopyFiles@2
displayName: 'Copy Reference Assembly Build Artifact'
enabled: false
inputs:
sourceFolder: $(System.ArtifactsDirectory)/current-artifacts
contents: '**/*.dll'
@ -85,7 +83,6 @@ jobs:
- task: DotNetCoreCLI@2
displayName: 'Execute ABI Compatibility Check Tool'
enabled: false
inputs:
command: custom
custom: compat

View File

@ -42,7 +42,7 @@ jobs:
- script: 'docker image ls -a && docker run -v $(pwd)/deployment/dist:/dist -v $(pwd):/jellyfin -e IS_UNSTABLE="no" -e BUILD_ID=$(Build.BuildNumber) jellyfin-server-$(BuildConfiguration)'
displayName: 'Run Dockerfile (stable)'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
- task: PublishPipelineArtifact@1
displayName: 'Publish Release'
@ -87,7 +87,7 @@ jobs:
steps:
- script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
displayName: Set release version (stable)
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
- task: Docker@2
displayName: 'Push Unstable Image'
@ -104,7 +104,7 @@ jobs:
- task: Docker@2
displayName: 'Push Stable Image'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
inputs:
repository: 'jellyfin/jellyfin-server'
command: buildAndPush
@ -116,8 +116,9 @@ jobs:
$(JellyfinVersion)-$(BuildConfiguration)
- job: CollectArtifacts
timeoutInMinutes: 10
timeoutInMinutes: 20
displayName: 'Collect Artifacts'
continueOnError: true
dependsOn:
- BuildPackage
- BuildDocker
@ -129,19 +130,21 @@ jobs:
steps:
- task: SSH@0
displayName: 'Update Unstable Repository'
continueOnError: true
condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
inputs:
sshEndpoint: repository
runOptions: 'commands'
commands: sudo -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable
commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
- task: SSH@0
displayName: 'Update Stable Repository'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
continueOnError: true
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
inputs:
sshEndpoint: repository
runOptions: 'commands'
commands: sudo -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber)
commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) &
- job: PublishNuget
displayName: 'Publish NuGet packages'
@ -155,7 +158,7 @@ jobs:
steps:
- task: DotNetCoreCLI@2
displayName: 'Build Stable Nuget packages'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
inputs:
command: 'pack'
packagesToPack: 'Jellyfin.Data/Jellyfin.Data.csproj;MediaBrowser.Common/MediaBrowser.Common.csproj;MediaBrowser.Controller/MediaBrowser.Controller.csproj;MediaBrowser.Model/MediaBrowser.Model.csproj;Emby.Naming/Emby.Naming.csproj'
@ -172,7 +175,7 @@ jobs:
MediaBrowser.Model/MediaBrowser.Model.csproj
Emby.Naming/Emby.Naming.csproj
custom: 'pack'
arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory)'
arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable'
- task: PublishBuildArtifacts@1
displayName: 'Publish Nuget packages'
@ -180,10 +183,32 @@ jobs:
pathToPublish: $(Build.ArtifactStagingDirectory)
artifactName: Jellyfin Nuget Packages
- task: NuGetAuthenticate@0
displayName: 'Authenticate to stable Nuget feed'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
inputs:
nuGetServiceConnections: 'NugetOrg'
- task: NuGetCommand@2
displayName: 'Push Nuget packages to feed'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
displayName: 'Push Nuget packages to stable feed'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
inputs:
command: 'push'
packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg'
includeNugetOrg: 'true'
packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg;$(Build.ArtifactStagingDirectory)/**/*.snupkg'
nuGetFeedType: 'external'
publishFeedCredentials: 'NugetOrg'
allowPackageConflicts: true # This ignores an error if the version already exists
- task: NuGetAuthenticate@0
displayName: 'Authenticate to unstable Nuget feed'
condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
- task: NuGetCommand@2
displayName: 'Push Nuget packages to unstable feed'
condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
inputs:
command: 'push'
packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg;!$(Build.ArtifactStagingDirectory)/**/*.symbols.nupkg' # No symbols since Azure Artifact does not support it
nuGetFeedType: 'internal'
publishVstsFeed: '7cce6c46-d610-45e3-9fb7-65a6bfd1b671/a5746b79-f369-42db-93ff-59cd066f9327'
allowPackageConflicts: true # This ignores an error if the version already exists

View File

@ -13,15 +13,21 @@ pr:
trigger:
batch: true
branches:
include:
- '*'
tags:
include:
- 'v*'
jobs:
- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))) }}:
- ${{ if not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) }}:
- template: azure-pipelines-main.yml
parameters:
LinuxImage: 'ubuntu-latest'
RestoreBuildProjects: $(RestoreBuildProjects)
- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))) }}:
- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))) }}:
- template: azure-pipelines-test.yml
parameters:
ImageNames:
@ -29,7 +35,7 @@ jobs:
Windows: 'windows-latest'
macOS: 'macos-latest'
- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))) }}:
- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))) }}:
- template: azure-pipelines-abi.yml
parameters:
Packages:
@ -47,5 +53,5 @@ jobs:
AssemblyFileName: MediaBrowser.Common.dll
LinuxImage: 'ubuntu-latest'
- ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
- ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
- template: azure-pipelines-package.yml

View File

@ -16,6 +16,7 @@
- [bugfixin](https://github.com/bugfixin)
- [chaosinnovator](https://github.com/chaosinnovator)
- [ckcr4lyf](https://github.com/ckcr4lyf)
- [ConfusedPolarBear](https://github.com/ConfusedPolarBear)
- [crankdoofus](https://github.com/crankdoofus)
- [crobibero](https://github.com/crobibero)
- [cromefire](https://github.com/cromefire)

View File

@ -10,6 +10,15 @@
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
<ItemGroup>
@ -28,6 +37,10 @@
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
</ItemGroup>
<!-- Code Analyzers-->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<!-- TODO: <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> -->

View File

@ -37,10 +37,10 @@ using Emby.Server.Implementations.LiveTv;
using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Net;
using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.QuickConnect;
using Emby.Server.Implementations.ScheduledTasks;
using Emby.Server.Implementations.Security;
using Emby.Server.Implementations.Serialization;
using Emby.Server.Implementations.Services;
using Emby.Server.Implementations.Session;
using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
@ -71,6 +71,7 @@ using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Security;
using MediaBrowser.Controller.Session;
@ -88,7 +89,6 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Services;
using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks;
using MediaBrowser.Providers.Chapters;
@ -96,11 +96,12 @@ using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Plugins.TheTvdb;
using MediaBrowser.Providers.Subtitles;
using MediaBrowser.XbmcMetadata.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Prometheus.DotNetRuntime;
using OperatingSystem = MediaBrowser.Common.System.OperatingSystem;
using WebSocketManager = Emby.Server.Implementations.HttpServer.WebSocketManager;
namespace Emby.Server.Implementations
{
@ -121,14 +122,18 @@ namespace Emby.Server.Implementations
private IMediaEncoder _mediaEncoder;
private ISessionManager _sessionManager;
private IHttpServer _httpServer;
private IWebSocketManager _webSocketManager;
private IHttpClient _httpClient;
private string[] _urlPrefixes;
/// <summary>
/// Gets a value indicating whether this instance can self restart.
/// </summary>
public bool CanSelfRestart => _startupOptions.RestartPath != null;
public bool CoreStartupHasCompleted { get; private set; }
public virtual bool CanLaunchWebBrowser
{
get
@ -443,8 +448,7 @@ namespace Emby.Server.Implementations
Logger.LogInformation("Executed all pre-startup entry points in {Elapsed:g}", stopWatch.Elapsed);
Logger.LogInformation("Core startup complete");
_httpServer.GlobalResponse = null;
CoreStartupHasCompleted = true;
stopWatch.Restart();
await Task.WhenAll(StartEntryPoints(entryPoints, false)).ConfigureAwait(false);
Logger.LogInformation("Executed all post-startup entry points in {Elapsed:g}", stopWatch.Elapsed);
@ -499,9 +503,6 @@ namespace Emby.Server.Implementations
RegisterServices();
}
public Task ExecuteHttpHandlerAsync(HttpContext context, Func<Task> next)
=> _httpServer.RequestHandler(context);
/// <summary>
/// Registers services/resources with the service collection that will be available via DI.
/// </summary>
@ -541,8 +542,6 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton<IZipClient, ZipClient>();
ServiceCollection.AddSingleton<IHttpResultFactory, HttpResultFactory>();
ServiceCollection.AddSingleton<IServerApplicationHost>(this);
ServiceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths);
@ -578,8 +577,7 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton<ISearchEngine, SearchEngine>();
ServiceCollection.AddSingleton<ServiceController>();
ServiceCollection.AddSingleton<IHttpServer, HttpListenerHost>();
ServiceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
ServiceCollection.AddSingleton<IImageProcessor, ImageProcessor>();
@ -626,6 +624,7 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton<ISessionContext, SessionContext>();
ServiceCollection.AddSingleton<IAuthService, AuthService>();
ServiceCollection.AddSingleton<IQuickConnect, QuickConnectManager>();
ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>();
@ -651,7 +650,7 @@ namespace Emby.Server.Implementations
_mediaEncoder = Resolve<IMediaEncoder>();
_sessionManager = Resolve<ISessionManager>();
_httpServer = Resolve<IHttpServer>();
_webSocketManager = Resolve<IWebSocketManager>();
_httpClient = Resolve<IHttpClient>();
((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize();
@ -753,7 +752,6 @@ namespace Emby.Server.Implementations
CollectionFolder.XmlSerializer = _xmlSerializer;
CollectionFolder.JsonSerializer = Resolve<IJsonSerializer>();
CollectionFolder.ApplicationHost = this;
AuthenticatedAttribute.AuthService = Resolve<IAuthService>();
}
/// <summary>
@ -773,7 +771,8 @@ namespace Emby.Server.Implementations
.Where(i => i != null)
.ToArray();
_httpServer.Init(GetExportTypes<IService>(), GetExports<IWebSocketListener>(), GetUrlPrefixes());
_urlPrefixes = GetUrlPrefixes().ToArray();
_webSocketManager.Init(GetExports<IWebSocketListener>());
Resolve<ILibraryManager>().AddParts(
GetExports<IResolverIgnoreRule>(),
@ -939,7 +938,7 @@ namespace Emby.Server.Implementations
}
}
if (!_httpServer.UrlPrefixes.SequenceEqual(GetUrlPrefixes(), StringComparer.OrdinalIgnoreCase))
if (!_urlPrefixes.SequenceEqual(GetUrlPrefixes(), StringComparer.OrdinalIgnoreCase))
{
requiresRestart = true;
}
@ -1393,6 +1392,20 @@ namespace Emby.Server.Implementations
_plugins = list.ToArray();
}
public IEnumerable<Assembly> GetApiPluginAssemblies()
{
var assemblies = _allConcreteTypes
.Where(i => typeof(ControllerBase).IsAssignableFrom(i))
.Select(i => i.Assembly)
.Distinct();
foreach (var assembly in assemblies)
{
Logger.LogDebug("Found API endpoints in plugin {name}", assembly.FullName);
yield return assembly;
}
}
public virtual void LaunchUrl(string url)
{
if (!CanLaunchWebBrowser)

View File

@ -15,7 +15,7 @@ namespace Emby.Server.Implementations
public static Dictionary<string, string> DefaultConfiguration => new Dictionary<string, string>
{
{ HostWebClientKey, bool.TrueString },
{ HttpListenerHost.DefaultRedirectKey, "web/index.html" },
{ DefaultRedirectKey, "web/index.html" },
{ FfmpegProbeSizeKey, "1G" },
{ FfmpegAnalyzeDurationKey, "200M" },
{ PlaylistsAllowDuplicatesKey, bool.TrueString },

View File

@ -1,250 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Emby.Server.Implementations.HttpServer
{
public class FileWriter : IHttpResult
{
private static readonly CultureInfo UsCulture = CultureInfo.ReadOnly(new CultureInfo("en-US"));
private static readonly string[] _skipLogExtensions = {
".js",
".html",
".css"
};
private readonly IStreamHelper _streamHelper;
private readonly ILogger _logger;
/// <summary>
/// The _options.
/// </summary>
private readonly IDictionary<string, string> _options = new Dictionary<string, string>();
/// <summary>
/// The _requested ranges.
/// </summary>
private List<KeyValuePair<long, long?>> _requestedRanges;
public FileWriter(string path, string contentType, string rangeHeader, ILogger logger, IFileSystem fileSystem, IStreamHelper streamHelper)
{
if (string.IsNullOrEmpty(contentType))
{
throw new ArgumentNullException(nameof(contentType));
}
_streamHelper = streamHelper;
Path = path;
_logger = logger;
RangeHeader = rangeHeader;
Headers[HeaderNames.ContentType] = contentType;
TotalContentLength = fileSystem.GetFileInfo(path).Length;
Headers[HeaderNames.AcceptRanges] = "bytes";
if (string.IsNullOrWhiteSpace(rangeHeader))
{
Headers[HeaderNames.ContentLength] = TotalContentLength.ToString(CultureInfo.InvariantCulture);
StatusCode = HttpStatusCode.OK;
}
else
{
StatusCode = HttpStatusCode.PartialContent;
SetRangeValues();
}
FileShare = FileShare.Read;
Cookies = new List<Cookie>();
}
private string RangeHeader { get; set; }
private bool IsHeadRequest { get; set; }
private long RangeStart { get; set; }
private long RangeEnd { get; set; }
private long RangeLength { get; set; }
public long TotalContentLength { get; set; }
public Action OnComplete { get; set; }
public Action OnError { get; set; }
public List<Cookie> Cookies { get; private set; }
public FileShare FileShare { get; set; }
/// <summary>
/// Gets the options.
/// </summary>
/// <value>The options.</value>
public IDictionary<string, string> Headers => _options;
public string Path { get; set; }
/// <summary>
/// Gets the requested ranges.
/// </summary>
/// <value>The requested ranges.</value>
protected List<KeyValuePair<long, long?>> RequestedRanges
{
get
{
if (_requestedRanges == null)
{
_requestedRanges = new List<KeyValuePair<long, long?>>();
// Example: bytes=0-,32-63
var ranges = RangeHeader.Split('=')[1].Split(',');
foreach (var range in ranges)
{
var vals = range.Split('-');
long start = 0;
long? end = null;
if (!string.IsNullOrEmpty(vals[0]))
{
start = long.Parse(vals[0], UsCulture);
}
if (!string.IsNullOrEmpty(vals[1]))
{
end = long.Parse(vals[1], UsCulture);
}
_requestedRanges.Add(new KeyValuePair<long, long?>(start, end));
}
}
return _requestedRanges;
}
}
public string ContentType { get; set; }
public IRequest RequestContext { get; set; }
public object Response { get; set; }
public int Status { get; set; }
public HttpStatusCode StatusCode
{
get => (HttpStatusCode)Status;
set => Status = (int)value;
}
/// <summary>
/// Sets the range values.
/// </summary>
private void SetRangeValues()
{
var requestedRange = RequestedRanges[0];
// If the requested range is "0-", we can optimize by just doing a stream copy
if (!requestedRange.Value.HasValue)
{
RangeEnd = TotalContentLength - 1;
}
else
{
RangeEnd = requestedRange.Value.Value;
}
RangeStart = requestedRange.Key;
RangeLength = 1 + RangeEnd - RangeStart;
// Content-Length is the length of what we're serving, not the original content
var lengthString = RangeLength.ToString(CultureInfo.InvariantCulture);
Headers[HeaderNames.ContentLength] = lengthString;
var rangeString = $"bytes {RangeStart}-{RangeEnd}/{TotalContentLength}";
Headers[HeaderNames.ContentRange] = rangeString;
_logger.LogDebug("Setting range response values for {0}. RangeRequest: {1} Content-Length: {2}, Content-Range: {3}", Path, RangeHeader, lengthString, rangeString);
}
public async Task WriteToAsync(HttpResponse response, CancellationToken cancellationToken)
{
try
{
// Headers only
if (IsHeadRequest)
{
return;
}
var path = Path;
var offset = RangeStart;
var count = RangeLength;
if (string.IsNullOrWhiteSpace(RangeHeader) || RangeStart <= 0 && RangeEnd >= TotalContentLength - 1)
{
var extension = System.IO.Path.GetExtension(path);
if (extension == null || !_skipLogExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("Transmit file {0}", path);
}
offset = 0;
count = 0;
}
await TransmitFile(response.Body, path, offset, count, FileShare, cancellationToken).ConfigureAwait(false);
}
finally
{
OnComplete?.Invoke();
}
}
public async Task TransmitFile(Stream stream, string path, long offset, long count, FileShare fileShare, CancellationToken cancellationToken)
{
var fileOptions = FileOptions.SequentialScan;
// use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
fileOptions |= FileOptions.Asynchronous;
}
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, fileOptions))
{
if (offset > 0)
{
fs.Position = offset;
}
if (count > 0)
{
await _streamHelper.CopyToAsync(fs, stream, count, cancellationToken).ConfigureAwait(false);
}
else
{
await fs.CopyToAsync(stream, IODefaults.CopyToBufferSize, cancellationToken).ConfigureAwait(false);
}
}
}
}
}

View File

@ -1,766 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Services;
using Emby.Server.Implementations.SocketSharp;
using Jellyfin.Data.Events;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using ServiceStack.Text.Jsv;
namespace Emby.Server.Implementations.HttpServer
{
public class HttpListenerHost : IHttpServer
{
/// <summary>
/// The key for a setting that specifies the default redirect path
/// to use for requests where the URL base prefix is invalid or missing.
/// </summary>
public const string DefaultRedirectKey = "HttpListenerHost:DefaultRedirectPath";
private readonly ILogger<HttpListenerHost> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IServerConfigurationManager _config;
private readonly INetworkManager _networkManager;
private readonly IServerApplicationHost _appHost;
private readonly IJsonSerializer _jsonSerializer;
private readonly IXmlSerializer _xmlSerializer;
private readonly Func<Type, Func<string, object>> _funcParseFn;
private readonly string _defaultRedirectPath;
private readonly string _baseUrlPrefix;
private readonly Dictionary<Type, Type> _serviceOperationsMap = new Dictionary<Type, Type>();
private readonly IHostEnvironment _hostEnvironment;
private IWebSocketListener[] _webSocketListeners = Array.Empty<IWebSocketListener>();
private bool _disposed = false;
public HttpListenerHost(
IServerApplicationHost applicationHost,
ILogger<HttpListenerHost> logger,
IServerConfigurationManager config,
IConfiguration configuration,
INetworkManager networkManager,
IJsonSerializer jsonSerializer,
IXmlSerializer xmlSerializer,
ILocalizationManager localizationManager,
ServiceController serviceController,
IHostEnvironment hostEnvironment,
ILoggerFactory loggerFactory)
{
_appHost = applicationHost;
_logger = logger;
_config = config;
_defaultRedirectPath = configuration[DefaultRedirectKey];
_baseUrlPrefix = _config.Configuration.BaseUrl;
_networkManager = networkManager;
_jsonSerializer = jsonSerializer;
_xmlSerializer = xmlSerializer;
ServiceController = serviceController;
_hostEnvironment = hostEnvironment;
_loggerFactory = loggerFactory;
_funcParseFn = t => s => JsvReader.GetParseFn(t)(s);
Instance = this;
ResponseFilters = Array.Empty<Action<IRequest, HttpResponse, object>>();
GlobalResponse = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
}
public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
public Action<IRequest, HttpResponse, object>[] ResponseFilters { get; set; }
public static HttpListenerHost Instance { get; protected set; }
public string[] UrlPrefixes { get; private set; }
public string GlobalResponse { get; set; }
public ServiceController ServiceController { get; }
public object CreateInstance(Type type)
{
return _appHost.CreateInstance(type);
}
private static string NormalizeUrlPath(string path)
{
if (path.Length > 0 && path[0] == '/')
{
// If the path begins with a leading slash, just return it as-is
return path;
}
else
{
// If the path does not begin with a leading slash, append one for consistency
return "/" + path;
}
}
/// <summary>
/// Applies the request filters. Returns whether or not the request has been handled
/// and no more processing should be done.
/// </summary>
/// <returns></returns>
public void ApplyRequestFilters(IRequest req, HttpResponse res, object requestDto)
{
// Exec all RequestFilter attributes with Priority < 0
var attributes = GetRequestFilterAttributes(requestDto.GetType());
int count = attributes.Count;
int i = 0;
for (; i < count && attributes[i].Priority < 0; i++)
{
var attribute = attributes[i];
attribute.RequestFilter(req, res, requestDto);
}
// Exec remaining RequestFilter attributes with Priority >= 0
for (; i < count && attributes[i].Priority >= 0; i++)
{
var attribute = attributes[i];
attribute.RequestFilter(req, res, requestDto);
}
}
public Type GetServiceTypeByRequest(Type requestType)
{
_serviceOperationsMap.TryGetValue(requestType, out var serviceType);
return serviceType;
}
public void AddServiceInfo(Type serviceType, Type requestType)
{
_serviceOperationsMap[requestType] = serviceType;
}
private List<IHasRequestFilter> GetRequestFilterAttributes(Type requestDtoType)
{
var attributes = requestDtoType.GetCustomAttributes(true).OfType<IHasRequestFilter>().ToList();
var serviceType = GetServiceTypeByRequest(requestDtoType);
if (serviceType != null)
{
attributes.AddRange(serviceType.GetCustomAttributes(true).OfType<IHasRequestFilter>());
}
attributes.Sort((x, y) => x.Priority - y.Priority);
return attributes;
}
private static Exception GetActualException(Exception ex)
{
if (ex is AggregateException agg)
{
var inner = agg.InnerException;
if (inner != null)
{
return GetActualException(inner);
}
else
{
var inners = agg.InnerExceptions;
if (inners.Count > 0)
{
return GetActualException(inners[0]);
}
}
}
return ex;
}
private int GetStatusCode(Exception ex)
{
switch (ex)
{
case ArgumentException _: return 400;
case AuthenticationException _: return 401;
case SecurityException _: return 403;
case DirectoryNotFoundException _:
case FileNotFoundException _:
case ResourceNotFoundException _: return 404;
case MethodNotAllowedException _: return 405;
default: return 500;
}
}
private async Task ErrorHandler(Exception ex, IRequest httpReq, int statusCode, string urlToLog, bool ignoreStackTrace)
{
if (ignoreStackTrace)
{
_logger.LogError("Error processing request: {Message}. URL: {Url}", ex.Message.TrimEnd('.'), urlToLog);
}
else
{
_logger.LogError(ex, "Error processing request. URL: {Url}", urlToLog);
}
var httpRes = httpReq.Response;
if (httpRes.HasStarted)
{
return;
}
httpRes.StatusCode = statusCode;
var errContent = _hostEnvironment.IsDevelopment()
? (NormalizeExceptionMessage(ex) ?? string.Empty)
: "Error processing request.";
httpRes.ContentType = "text/plain";
httpRes.ContentLength = errContent.Length;
await httpRes.WriteAsync(errContent).ConfigureAwait(false);
}
private string NormalizeExceptionMessage(Exception ex)
{
// Do not expose the exception message for AuthenticationException
if (ex is AuthenticationException)
{
return null;
}
// Strip any information we don't want to reveal
return ex.Message
?.Replace(_config.ApplicationPaths.ProgramSystemPath, string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace(_config.ApplicationPaths.ProgramDataPath, string.Empty, StringComparison.OrdinalIgnoreCase);
}
public static string RemoveQueryStringByKey(string url, string key)
{
var uri = new Uri(url);
// this gets all the query string key value pairs as a collection
var newQueryString = QueryHelpers.ParseQuery(uri.Query);
var originalCount = newQueryString.Count;
if (originalCount == 0)
{
return url;
}
// this removes the key if exists
newQueryString.Remove(key);
if (originalCount == newQueryString.Count)
{
return url;
}
// this gets the page path from root without QueryString
string pagePathWithoutQueryString = url.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries)[0];
return newQueryString.Count > 0
? QueryHelpers.AddQueryString(pagePathWithoutQueryString, newQueryString.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()))
: pagePathWithoutQueryString;
}
private static string GetUrlToLog(string url)
{
url = RemoveQueryStringByKey(url, "api_key");
return url;
}
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();
}
private bool ValidateHost(string host)
{
var hosts = _config
.Configuration
.LocalNetworkAddresses
.Select(NormalizeConfiguredLocalAddress)
.ToList();
if (hosts.Count == 0)
{
return true;
}
host ??= string.Empty;
if (_networkManager.IsInPrivateAddressSpace(host))
{
hosts.Add("localhost");
hosts.Add("127.0.0.1");
return hosts.Any(i => host.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1);
}
return true;
}
private bool ValidateRequest(string remoteIp, bool isLocal)
{
if (isLocal)
{
return true;
}
if (_config.Configuration.EnableRemoteAccess)
{
var addressFilter = _config.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
if (addressFilter.Length > 0 && !_networkManager.IsInLocalNetwork(remoteIp))
{
if (_config.Configuration.IsRemoteIPFilterBlacklist)
{
return !_networkManager.IsAddressInSubnets(remoteIp, addressFilter);
}
else
{
return _networkManager.IsAddressInSubnets(remoteIp, addressFilter);
}
}
}
else
{
if (!_networkManager.IsInLocalNetwork(remoteIp))
{
return false;
}
}
return true;
}
/// <summary>
/// Validate a connection from a remote IP address to a URL to see if a redirection to HTTPS is required.
/// </summary>
/// <returns>True if the request is valid, or false if the request is not valid and an HTTPS redirect is required.</returns>
private bool ValidateSsl(string remoteIp, string urlString)
{
if (_config.Configuration.RequireHttps
&& _appHost.ListenWithHttps
&& !urlString.Contains("https://", StringComparison.OrdinalIgnoreCase))
{
// These are hacks, but if these ever occur on ipv6 in the local network they could be incorrectly redirected
if (urlString.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) != -1
|| urlString.IndexOf("dlna/", StringComparison.OrdinalIgnoreCase) != -1)
{
return true;
}
if (!_networkManager.IsInLocalNetwork(remoteIp))
{
return false;
}
}
return true;
}
/// <inheritdoc />
public Task RequestHandler(HttpContext context)
{
if (context.WebSockets.IsWebSocketRequest)
{
return WebSocketRequestHandler(context);
}
var request = context.Request;
var response = context.Response;
var localPath = context.Request.Path.ToString();
var req = new WebSocketSharpRequest(request, response, request.Path);
return RequestHandler(req, request.GetDisplayUrl(), request.Host.ToString(), localPath, context.RequestAborted);
}
/// <summary>
/// Overridable method that can be used to implement a custom handler.
/// </summary>
private async Task RequestHandler(IHttpRequest httpReq, string urlString, string host, string localPath, CancellationToken cancellationToken)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var httpRes = httpReq.Response;
string urlToLog = GetUrlToLog(urlString);
string remoteIp = httpReq.RemoteIp;
try
{
if (_disposed)
{
httpRes.StatusCode = 503;
httpRes.ContentType = "text/plain";
await httpRes.WriteAsync("Server shutting down", cancellationToken).ConfigureAwait(false);
return;
}
if (!ValidateHost(host))
{
httpRes.StatusCode = 400;
httpRes.ContentType = "text/plain";
await httpRes.WriteAsync("Invalid host", cancellationToken).ConfigureAwait(false);
return;
}
if (!ValidateRequest(remoteIp, httpReq.IsLocal))
{
httpRes.StatusCode = 403;
httpRes.ContentType = "text/plain";
await httpRes.WriteAsync("Forbidden", cancellationToken).ConfigureAwait(false);
return;
}
if (!ValidateSsl(httpReq.RemoteIp, urlString))
{
RedirectToSecureUrl(httpReq, httpRes, urlString);
return;
}
if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
httpRes.StatusCode = 200;
foreach (var (key, value) in GetDefaultCorsHeaders(httpReq))
{
httpRes.Headers.Add(key, value);
}
httpRes.ContentType = "text/plain";
await httpRes.WriteAsync(string.Empty, cancellationToken).ConfigureAwait(false);
return;
}
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 a URL at {0}", localPath);
httpRes.Redirect(_baseUrlPrefix + "/" + _defaultRedirectPath);
return;
}
if (!string.IsNullOrEmpty(GlobalResponse))
{
// We don't want the address pings in ApplicationHost to fail
if (localPath.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) == -1)
{
httpRes.StatusCode = 503;
httpRes.ContentType = "text/html";
await httpRes.WriteAsync(GlobalResponse, cancellationToken).ConfigureAwait(false);
return;
}
}
var handler = GetServiceHandler(httpReq);
if (handler != null)
{
await handler.ProcessRequestAsync(this, httpReq, httpRes, cancellationToken).ConfigureAwait(false);
}
else
{
throw new FileNotFoundException();
}
}
catch (Exception requestEx)
{
try
{
var requestInnerEx = GetActualException(requestEx);
var statusCode = GetStatusCode(requestInnerEx);
foreach (var (key, value) in GetDefaultCorsHeaders(httpReq))
{
if (!httpRes.Headers.ContainsKey(key))
{
httpRes.Headers.Add(key, value);
}
}
bool ignoreStackTrace =
requestInnerEx is SocketException
|| requestInnerEx is IOException
|| requestInnerEx is OperationCanceledException
|| requestInnerEx is SecurityException
|| requestInnerEx is AuthenticationException
|| requestInnerEx is FileNotFoundException;
// Do not handle 500 server exceptions manually when in development mode.
// Instead, re-throw the exception so it can be handled by the DeveloperExceptionPageMiddleware.
// However, do not use the DeveloperExceptionPageMiddleware when the stack trace should be ignored,
// because it will log the stack trace when it handles the exception.
if (statusCode == 500 && !ignoreStackTrace && _hostEnvironment.IsDevelopment())
{
throw;
}
await ErrorHandler(requestInnerEx, httpReq, statusCode, urlToLog, ignoreStackTrace).ConfigureAwait(false);
}
catch (Exception handlerException)
{
var aggregateEx = new AggregateException("Error while handling request exception", requestEx, handlerException);
_logger.LogError(aggregateEx, "Error while handling exception in response to {Url}", urlToLog);
if (_hostEnvironment.IsDevelopment())
{
throw aggregateEx;
}
}
}
finally
{
if (httpRes.StatusCode >= 500)
{
_logger.LogDebug("Sending HTTP Response 500 in response to {Url}", urlToLog);
}
stopWatch.Stop();
var elapsed = stopWatch.Elapsed;
if (elapsed.TotalMilliseconds > 500)
{
_logger.LogWarning("HTTP Response {StatusCode} to {RemoteIp}. Time (slow): {Elapsed:g}. {Url}", httpRes.StatusCode, remoteIp, elapsed, urlToLog);
}
}
}
private async Task WebSocketRequestHandler(HttpContext context)
{
if (_disposed)
{
return;
}
try
{
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
using var connection = new WebSocketConnection(
_loggerFactory.CreateLogger<WebSocketConnection>(),
webSocket,
context.Connection.RemoteIpAddress,
context.Request.Query)
{
OnReceive = ProcessWebSocketMessageReceived
};
WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection));
await connection.ProcessAsync().ConfigureAwait(false);
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
}
catch (Exception ex) // Otherwise ASP.Net will ignore the exception
{
_logger.LogError(ex, "WS {IP} WebSocketRequestHandler error", context.Connection.RemoteIpAddress);
if (!context.Response.HasStarted)
{
context.Response.StatusCode = 500;
}
}
}
/// <summary>
/// Get the default CORS headers.
/// </summary>
/// <param name="req"></param>
/// <returns></returns>
public IDictionary<string, string> GetDefaultCorsHeaders(IRequest req)
{
var origin = req.Headers["Origin"];
if (origin == StringValues.Empty)
{
origin = req.Headers["Host"];
if (origin == StringValues.Empty)
{
origin = "*";
}
}
var headers = new Dictionary<string, string>();
headers.Add("Access-Control-Allow-Origin", origin);
headers.Add("Access-Control-Allow-Credentials", "true");
headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization, Range, X-MediaBrowser-Token, X-Emby-Authorization, Cookie");
return headers;
}
// Entry point for HttpListener
public ServiceHandler GetServiceHandler(IHttpRequest httpReq)
{
var pathInfo = httpReq.PathInfo;
pathInfo = ServiceHandler.GetSanitizedPathInfo(pathInfo, out string contentType);
var restPath = ServiceController.GetRestPathForRequest(httpReq.HttpMethod, pathInfo);
if (restPath != null)
{
return new ServiceHandler(restPath, contentType);
}
_logger.LogError("Could not find handler for {PathInfo}", pathInfo);
return null;
}
private void RedirectToSecureUrl(IHttpRequest httpReq, HttpResponse httpRes, string url)
{
if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri))
{
var builder = new UriBuilder(uri)
{
Port = _config.Configuration.PublicHttpsPort,
Scheme = "https"
};
url = builder.Uri.ToString();
}
httpRes.Redirect(url);
}
/// <summary>
/// Adds the rest handlers.
/// </summary>
/// <param name="serviceTypes">The service types to register with the <see cref="ServiceController"/>.</param>
/// <param name="listeners">The web socket listeners.</param>
/// <param name="urlPrefixes">The URL prefixes. See <see cref="UrlPrefixes"/>.</param>
public void Init(IEnumerable<Type> serviceTypes, IEnumerable<IWebSocketListener> listeners, IEnumerable<string> urlPrefixes)
{
_webSocketListeners = listeners.ToArray();
UrlPrefixes = urlPrefixes.ToArray();
ServiceController.Init(this, serviceTypes);
ResponseFilters = new Action<IRequest, HttpResponse, object>[]
{
new ResponseFilter(this, _logger).FilterResponse
};
}
public RouteAttribute[] GetRouteAttributes(Type requestType)
{
var routes = requestType.GetTypeInfo().GetCustomAttributes<RouteAttribute>(true).ToList();
var clone = routes.ToList();
foreach (var route in clone)
{
routes.Add(new RouteAttribute(NormalizeCustomRoutePath(route.Path), route.Verbs)
{
Notes = route.Notes,
Priority = route.Priority,
Summary = route.Summary
});
routes.Add(new RouteAttribute(NormalizeEmbyRoutePath(route.Path), route.Verbs)
{
Notes = route.Notes,
Priority = route.Priority,
Summary = route.Summary
});
routes.Add(new RouteAttribute(NormalizeMediaBrowserRoutePath(route.Path), route.Verbs)
{
Notes = route.Notes,
Priority = route.Priority,
Summary = route.Summary
});
}
return routes.ToArray();
}
public Func<string, object> GetParseFn(Type propertyType)
{
return _funcParseFn(propertyType);
}
public void SerializeToJson(object o, Stream stream)
{
_jsonSerializer.SerializeToStream(o, stream);
}
public void SerializeToXml(object o, Stream stream)
{
_xmlSerializer.SerializeToStream(o, stream);
}
public Task<object> DeserializeXml(Type type, Stream stream)
{
return Task.FromResult(_xmlSerializer.DeserializeFromStream(type, stream));
}
public Task<object> DeserializeJson(Type type, Stream stream)
{
return _jsonSerializer.DeserializeFromStreamAsync(stream, type);
}
private string NormalizeEmbyRoutePath(string path)
{
_logger.LogDebug("Normalizing /emby route");
return _baseUrlPrefix + "/emby" + NormalizeUrlPath(path);
}
private string NormalizeMediaBrowserRoutePath(string path)
{
_logger.LogDebug("Normalizing /mediabrowser route");
return _baseUrlPrefix + "/mediabrowser" + NormalizeUrlPath(path);
}
private string NormalizeCustomRoutePath(string path)
{
_logger.LogDebug("Normalizing custom route {0}", path);
return _baseUrlPrefix + NormalizeUrlPath(path);
}
/// <summary>
/// Processes the web socket message received.
/// </summary>
/// <param name="result">The result.</param>
private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
{
if (_disposed)
{
return Task.CompletedTask;
}
IEnumerable<Task> GetTasks()
{
foreach (var x in _webSocketListeners)
{
yield return x.ProcessMessageAsync(result);
}
}
return Task.WhenAll(GetTasks());
}
}
}

View File

@ -1,721 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Runtime.Serialization;
using System.Text;
using System.Threading.Tasks;
using System.Xml;
using Emby.Server.Implementations.Services;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using IRequest = MediaBrowser.Model.Services.IRequest;
using MimeTypes = MediaBrowser.Model.Net.MimeTypes;
namespace Emby.Server.Implementations.HttpServer
{
/// <summary>
/// Class HttpResultFactory.
/// </summary>
public class HttpResultFactory : IHttpResultFactory
{
// Last-Modified and If-Modified-Since must follow strict date format,
// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
private const string HttpDateFormat = "ddd, dd MMM yyyy HH:mm:ss \"GMT\"";
// We specifically use en-US culture because both day of week and month names require it
private static readonly CultureInfo _enUSculture = new CultureInfo("en-US", false);
/// <summary>
/// The logger.
/// </summary>
private readonly ILogger<HttpResultFactory> _logger;
private readonly IFileSystem _fileSystem;
private readonly IJsonSerializer _jsonSerializer;
private readonly IStreamHelper _streamHelper;
/// <summary>
/// Initializes a new instance of the <see cref="HttpResultFactory" /> class.
/// </summary>
public HttpResultFactory(ILoggerFactory loggerfactory, IFileSystem fileSystem, IJsonSerializer jsonSerializer, IStreamHelper streamHelper)
{
_fileSystem = fileSystem;
_jsonSerializer = jsonSerializer;
_streamHelper = streamHelper;
_logger = loggerfactory.CreateLogger<HttpResultFactory>();
}
/// <summary>
/// Gets the result.
/// </summary>
/// <param name="requestContext">The request context.</param>
/// <param name="content">The content.</param>
/// <param name="contentType">Type of the content.</param>
/// <param name="responseHeaders">The response headers.</param>
/// <returns>System.Object.</returns>
public object GetResult(IRequest requestContext, byte[] content, string contentType, IDictionary<string, string> responseHeaders = null)
{
return GetHttpResult(requestContext, content, contentType, true, responseHeaders);
}
public object GetResult(string content, string contentType, IDictionary<string, string> responseHeaders = null)
{
return GetHttpResult(null, content, contentType, true, responseHeaders);
}
public object GetResult(IRequest requestContext, Stream content, string contentType, IDictionary<string, string> responseHeaders = null)
{
return GetHttpResult(requestContext, content, contentType, true, responseHeaders);
}
public object GetResult(IRequest requestContext, string content, string contentType, IDictionary<string, string> responseHeaders = null)
{
return GetHttpResult(requestContext, content, contentType, true, responseHeaders);
}
public object GetRedirectResult(string url)
{
var responseHeaders = new Dictionary<string, string>();
responseHeaders[HeaderNames.Location] = url;
var result = new HttpResult(Array.Empty<byte>(), "text/plain", HttpStatusCode.Redirect);
AddResponseHeaders(result, responseHeaders);
return result;
}
/// <summary>
/// Gets the HTTP result.
/// </summary>
private IHasHeaders GetHttpResult(IRequest requestContext, Stream content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
{
var result = new StreamWriter(content, contentType);
if (responseHeaders == null)
{
responseHeaders = new Dictionary<string, string>();
}
if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out _))
{
responseHeaders[HeaderNames.Expires] = "0";
}
AddResponseHeaders(result, responseHeaders);
return result;
}
/// <summary>
/// Gets the HTTP result.
/// </summary>
private IHasHeaders GetHttpResult(IRequest requestContext, byte[] content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
{
string compressionType = null;
bool isHeadRequest = false;
if (requestContext != null)
{
compressionType = GetCompressionType(requestContext, content, contentType);
isHeadRequest = string.Equals(requestContext.Verb, "head", StringComparison.OrdinalIgnoreCase);
}
IHasHeaders result;
if (string.IsNullOrEmpty(compressionType))
{
var contentLength = content.Length;
if (isHeadRequest)
{
content = Array.Empty<byte>();
}
result = new StreamWriter(content, contentType, contentLength);
}
else
{
result = GetCompressedResult(content, compressionType, responseHeaders, isHeadRequest, contentType);
}
if (responseHeaders == null)
{
responseHeaders = new Dictionary<string, string>();
}
if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out string _))
{
responseHeaders[HeaderNames.Expires] = "0";
}
AddResponseHeaders(result, responseHeaders);
return result;
}
/// <summary>
/// Gets the HTTP result.
/// </summary>
private IHasHeaders GetHttpResult(IRequest requestContext, string content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
{
IHasHeaders result;
var bytes = Encoding.UTF8.GetBytes(content);
var compressionType = requestContext == null ? null : GetCompressionType(requestContext, bytes, contentType);
var isHeadRequest = requestContext == null ? false : string.Equals(requestContext.Verb, "head", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(compressionType))
{
var contentLength = bytes.Length;
if (isHeadRequest)
{
bytes = Array.Empty<byte>();
}
result = new StreamWriter(bytes, contentType, contentLength);
}
else
{
result = GetCompressedResult(bytes, compressionType, responseHeaders, isHeadRequest, contentType);
}
if (responseHeaders == null)
{
responseHeaders = new Dictionary<string, string>();
}
if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out string _))
{
responseHeaders[HeaderNames.Expires] = "0";
}
AddResponseHeaders(result, responseHeaders);
return result;
}
/// <summary>
/// Gets the optimized result.
/// </summary>
/// <typeparam name="T"></typeparam>
public object GetResult<T>(IRequest requestContext, T result, IDictionary<string, string> responseHeaders = null)
where T : class
{
if (result == null)
{
throw new ArgumentNullException(nameof(result));
}
if (responseHeaders == null)
{
responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
responseHeaders[HeaderNames.Expires] = "0";
return ToOptimizedResultInternal(requestContext, result, responseHeaders);
}
private string GetCompressionType(IRequest request, byte[] content, string responseContentType)
{
if (responseContentType == null)
{
return null;
}
// Per apple docs, hls manifests must be compressed
if (!responseContentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) &&
responseContentType.IndexOf("json", StringComparison.OrdinalIgnoreCase) == -1 &&
responseContentType.IndexOf("javascript", StringComparison.OrdinalIgnoreCase) == -1 &&
responseContentType.IndexOf("xml", StringComparison.OrdinalIgnoreCase) == -1 &&
responseContentType.IndexOf("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == -1)
{
return null;
}
if (content.Length < 1024)
{
return null;
}
return GetCompressionType(request);
}
private static string GetCompressionType(IRequest request)
{
var acceptEncoding = request.Headers[HeaderNames.AcceptEncoding].ToString();
if (!string.IsNullOrEmpty(acceptEncoding))
{
// if (_brotliCompressor != null && acceptEncoding.IndexOf("br", StringComparison.OrdinalIgnoreCase) != -1)
// return "br";
if (acceptEncoding.Contains("deflate", StringComparison.OrdinalIgnoreCase))
{
return "deflate";
}
if (acceptEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase))
{
return "gzip";
}
}
return null;
}
/// <summary>
/// Returns the optimized result for the IRequestContext.
/// Does not use or store results in any cache.
/// </summary>
/// <param name="request"></param>
/// <param name="dto"></param>
/// <returns></returns>
public object ToOptimizedResult<T>(IRequest request, T dto)
{
return ToOptimizedResultInternal(request, dto);
}
private object ToOptimizedResultInternal<T>(IRequest request, T dto, IDictionary<string, string> responseHeaders = null)
{
// TODO: @bond use Span and .Equals
var contentType = request.ResponseContentType?.Split(';')[0].Trim().ToLowerInvariant();
switch (contentType)
{
case "application/xml":
case "text/xml":
case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml
return GetHttpResult(request, SerializeToXmlString(dto), contentType, false, responseHeaders);
case "application/json":
case "text/json":
return GetHttpResult(request, _jsonSerializer.SerializeToString(dto), contentType, false, responseHeaders);
default:
break;
}
var isHeadRequest = string.Equals(request.Verb, "head", StringComparison.OrdinalIgnoreCase);
var ms = new MemoryStream();
var writerFn = RequestHelper.GetResponseWriter(HttpListenerHost.Instance, contentType);
writerFn(dto, ms);
ms.Position = 0;
if (isHeadRequest)
{
using (ms)
{
return GetHttpResult(request, Array.Empty<byte>(), contentType, true, responseHeaders);
}
}
return GetHttpResult(request, ms, contentType, true, responseHeaders);
}
private IHasHeaders GetCompressedResult(
byte[] content,
string requestedCompressionType,
IDictionary<string, string> responseHeaders,
bool isHeadRequest,
string contentType)
{
if (responseHeaders == null)
{
responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
content = Compress(content, requestedCompressionType);
responseHeaders[HeaderNames.ContentEncoding] = requestedCompressionType;
responseHeaders[HeaderNames.Vary] = HeaderNames.AcceptEncoding;
var contentLength = content.Length;
if (isHeadRequest)
{
var result = new StreamWriter(Array.Empty<byte>(), contentType, contentLength);
AddResponseHeaders(result, responseHeaders);
return result;
}
else
{
var result = new StreamWriter(content, contentType, contentLength);
AddResponseHeaders(result, responseHeaders);
return result;
}
}
private byte[] Compress(byte[] bytes, string compressionType)
{
if (string.Equals(compressionType, "deflate", StringComparison.OrdinalIgnoreCase))
{
return Deflate(bytes);
}
if (string.Equals(compressionType, "gzip", StringComparison.OrdinalIgnoreCase))
{
return GZip(bytes);
}
throw new NotSupportedException(compressionType);
}
private static byte[] Deflate(byte[] bytes)
{
// In .NET FX incompat-ville, you can't access compressed bytes without closing DeflateStream
// Which means we must use MemoryStream since you have to use ToArray() on a closed Stream
using (var ms = new MemoryStream())
using (var zipStream = new DeflateStream(ms, CompressionMode.Compress))
{
zipStream.Write(bytes, 0, bytes.Length);
zipStream.Dispose();
return ms.ToArray();
}
}
private static byte[] GZip(byte[] buffer)
{
using (var ms = new MemoryStream())
using (var zipStream = new GZipStream(ms, CompressionMode.Compress))
{
zipStream.Write(buffer, 0, buffer.Length);
zipStream.Dispose();
return ms.ToArray();
}
}
private static string SerializeToXmlString(object from)
{
using (var ms = new MemoryStream())
{
var xwSettings = new XmlWriterSettings();
xwSettings.Encoding = new UTF8Encoding(false);
xwSettings.OmitXmlDeclaration = false;
using (var xw = XmlWriter.Create(ms, xwSettings))
{
var serializer = new DataContractSerializer(from.GetType());
serializer.WriteObject(xw, from);
xw.Flush();
ms.Seek(0, SeekOrigin.Begin);
using (var reader = new StreamReader(ms))
{
return reader.ReadToEnd();
}
}
}
}
/// <summary>
/// Pres the process optimized result.
/// </summary>
private object GetCachedResult(IRequest requestContext, IDictionary<string, string> responseHeaders, StaticResultOptions options)
{
bool noCache = requestContext.Headers[HeaderNames.CacheControl].ToString().IndexOf("no-cache", StringComparison.OrdinalIgnoreCase) != -1;
AddCachingHeaders(responseHeaders, options.CacheDuration, noCache, options.DateLastModified);
if (!noCache)
{
if (!DateTime.TryParseExact(requestContext.Headers[HeaderNames.IfModifiedSince], HttpDateFormat, _enUSculture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ifModifiedSinceHeader))
{
_logger.LogDebug("Failed to parse If-Modified-Since header date: {0}", requestContext.Headers[HeaderNames.IfModifiedSince]);
return null;
}
if (IsNotModified(ifModifiedSinceHeader, options.CacheDuration, options.DateLastModified))
{
AddAgeHeader(responseHeaders, options.DateLastModified);
var result = new HttpResult(Array.Empty<byte>(), options.ContentType ?? "text/html", HttpStatusCode.NotModified);
AddResponseHeaders(result, responseHeaders);
return result;
}
}
return null;
}
public Task<object> GetStaticFileResult(IRequest requestContext,
string path,
FileShare fileShare = FileShare.Read)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentNullException(nameof(path));
}
return GetStaticFileResult(requestContext, new StaticFileResultOptions
{
Path = path,
FileShare = fileShare
});
}
public Task<object> GetStaticFileResult(IRequest requestContext, StaticFileResultOptions options)
{
var path = options.Path;
var fileShare = options.FileShare;
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Path can't be empty.", nameof(options));
}
if (fileShare != FileShare.Read && fileShare != FileShare.ReadWrite)
{
throw new ArgumentException("FileShare must be either Read or ReadWrite");
}
if (string.IsNullOrEmpty(options.ContentType))
{
options.ContentType = MimeTypes.GetMimeType(path);
}
if (!options.DateLastModified.HasValue)
{
options.DateLastModified = _fileSystem.GetLastWriteTimeUtc(path);
}
options.ContentFactory = () => Task.FromResult(GetFileStream(path, fileShare));
options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return GetStaticResult(requestContext, options);
}
/// <summary>
/// Gets the file stream.
/// </summary>
/// <param name="path">The path.</param>
/// <param name="fileShare">The file share.</param>
/// <returns>Stream.</returns>
private Stream GetFileStream(string path, FileShare fileShare)
{
return new FileStream(path, FileMode.Open, FileAccess.Read, fileShare);
}
public Task<object> GetStaticResult(IRequest requestContext,
Guid cacheKey,
DateTime? lastDateModified,
TimeSpan? cacheDuration,
string contentType,
Func<Task<Stream>> factoryFn,
IDictionary<string, string> responseHeaders = null,
bool isHeadRequest = false)
{
return GetStaticResult(requestContext, new StaticResultOptions
{
CacheDuration = cacheDuration,
ContentFactory = factoryFn,
ContentType = contentType,
DateLastModified = lastDateModified,
IsHeadRequest = isHeadRequest,
ResponseHeaders = responseHeaders
});
}
public async Task<object> GetStaticResult(IRequest requestContext, StaticResultOptions options)
{
options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var contentType = options.ContentType;
if (!StringValues.IsNullOrEmpty(requestContext.Headers[HeaderNames.IfModifiedSince]))
{
// See if the result is already cached in the browser
var result = GetCachedResult(requestContext, options.ResponseHeaders, options);
if (result != null)
{
return result;
}
}
// TODO: We don't really need the option value
var isHeadRequest = options.IsHeadRequest || string.Equals(requestContext.Verb, "HEAD", StringComparison.OrdinalIgnoreCase);
var factoryFn = options.ContentFactory;
var responseHeaders = options.ResponseHeaders;
AddCachingHeaders(responseHeaders, options.CacheDuration, false, options.DateLastModified);
AddAgeHeader(responseHeaders, options.DateLastModified);
var rangeHeader = requestContext.Headers[HeaderNames.Range];
if (!isHeadRequest && !string.IsNullOrEmpty(options.Path))
{
var hasHeaders = new FileWriter(options.Path, contentType, rangeHeader, _logger, _fileSystem, _streamHelper)
{
OnComplete = options.OnComplete,
OnError = options.OnError,
FileShare = options.FileShare
};
AddResponseHeaders(hasHeaders, options.ResponseHeaders);
return hasHeaders;
}
var stream = await factoryFn().ConfigureAwait(false);
var totalContentLength = options.ContentLength;
if (!totalContentLength.HasValue)
{
try
{
totalContentLength = stream.Length;
}
catch (NotSupportedException)
{
}
}
if (!string.IsNullOrWhiteSpace(rangeHeader) && totalContentLength.HasValue)
{
var hasHeaders = new RangeRequestWriter(rangeHeader, totalContentLength.Value, stream, contentType, isHeadRequest)
{
OnComplete = options.OnComplete
};
AddResponseHeaders(hasHeaders, options.ResponseHeaders);
return hasHeaders;
}
else
{
if (totalContentLength.HasValue)
{
responseHeaders["Content-Length"] = totalContentLength.Value.ToString(CultureInfo.InvariantCulture);
}
if (isHeadRequest)
{
using (stream)
{
return GetHttpResult(requestContext, Array.Empty<byte>(), contentType, true, responseHeaders);
}
}
var hasHeaders = new StreamWriter(stream, contentType)
{
OnComplete = options.OnComplete,
OnError = options.OnError
};
AddResponseHeaders(hasHeaders, options.ResponseHeaders);
return hasHeaders;
}
}
/// <summary>
/// Adds the caching responseHeaders.
/// </summary>
private void AddCachingHeaders(
IDictionary<string, string> responseHeaders,
TimeSpan? cacheDuration,
bool noCache,
DateTime? lastModifiedDate)
{
if (noCache)
{
responseHeaders[HeaderNames.CacheControl] = "no-cache, no-store, must-revalidate";
responseHeaders[HeaderNames.Pragma] = "no-cache, no-store, must-revalidate";
return;
}
if (cacheDuration.HasValue)
{
responseHeaders[HeaderNames.CacheControl] = "public, max-age=" + cacheDuration.Value.TotalSeconds;
}
else
{
responseHeaders[HeaderNames.CacheControl] = "public";
}
if (lastModifiedDate.HasValue)
{
responseHeaders[HeaderNames.LastModified] = lastModifiedDate.Value.ToUniversalTime().ToString(HttpDateFormat, _enUSculture);
}
}
/// <summary>
/// Adds the age header.
/// </summary>
/// <param name="responseHeaders">The responseHeaders.</param>
/// <param name="lastDateModified">The last date modified.</param>
private static void AddAgeHeader(IDictionary<string, string> responseHeaders, DateTime? lastDateModified)
{
if (lastDateModified.HasValue)
{
responseHeaders[HeaderNames.Age] = Convert.ToInt64((DateTime.UtcNow - lastDateModified.Value).TotalSeconds).ToString(CultureInfo.InvariantCulture);
}
}
/// <summary>
/// Determines whether [is not modified] [the specified if modified since].
/// </summary>
/// <param name="ifModifiedSince">If modified since.</param>
/// <param name="cacheDuration">Duration of the cache.</param>
/// <param name="dateModified">The date modified.</param>
/// <returns><c>true</c> if [is not modified] [the specified if modified since]; otherwise, <c>false</c>.</returns>
private bool IsNotModified(DateTime ifModifiedSince, TimeSpan? cacheDuration, DateTime? dateModified)
{
if (dateModified.HasValue)
{
var lastModified = NormalizeDateForComparison(dateModified.Value);
ifModifiedSince = NormalizeDateForComparison(ifModifiedSince);
return lastModified <= ifModifiedSince;
}
if (cacheDuration.HasValue)
{
var cacheExpirationDate = ifModifiedSince.Add(cacheDuration.Value);
if (DateTime.UtcNow < cacheExpirationDate)
{
return true;
}
}
return false;
}
/// <summary>
/// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that.
/// </summary>
/// <param name="date">The date.</param>
/// <returns>DateTime.</returns>
private static DateTime NormalizeDateForComparison(DateTime date)
{
return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind);
}
/// <summary>
/// Adds the response headers.
/// </summary>
/// <param name="hasHeaders">The has options.</param>
/// <param name="responseHeaders">The response headers.</param>
private static void AddResponseHeaders(IHasHeaders hasHeaders, IEnumerable<KeyValuePair<string, string>> responseHeaders)
{
foreach (var item in responseHeaders)
{
hasHeaders.Headers[item.Key] = item.Value;
}
}
}
}

View File

@ -1,212 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Services;
using Microsoft.Net.Http.Headers;
namespace Emby.Server.Implementations.HttpServer
{
public class RangeRequestWriter : IAsyncStreamWriter, IHttpResult
{
private const int BufferSize = 81920;
private readonly Dictionary<string, string> _options = new Dictionary<string, string>();
private List<KeyValuePair<long, long?>> _requestedRanges;
/// <summary>
/// Initializes a new instance of the <see cref="RangeRequestWriter" /> class.
/// </summary>
/// <param name="rangeHeader">The range header.</param>
/// <param name="contentLength">The content length.</param>
/// <param name="source">The source.</param>
/// <param name="contentType">Type of the content.</param>
/// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param>
public RangeRequestWriter(string rangeHeader, long contentLength, Stream source, string contentType, bool isHeadRequest)
{
if (string.IsNullOrEmpty(contentType))
{
throw new ArgumentNullException(nameof(contentType));
}
RangeHeader = rangeHeader;
SourceStream = source;
IsHeadRequest = isHeadRequest;
ContentType = contentType;
Headers[HeaderNames.ContentType] = contentType;
Headers[HeaderNames.AcceptRanges] = "bytes";
StatusCode = HttpStatusCode.PartialContent;
SetRangeValues(contentLength);
}
/// <summary>
/// Gets or sets the source stream.
/// </summary>
/// <value>The source stream.</value>
private Stream SourceStream { get; set; }
private string RangeHeader { get; set; }
private bool IsHeadRequest { get; set; }
private long RangeStart { get; set; }
private long RangeEnd { get; set; }
private long RangeLength { get; set; }
private long TotalContentLength { get; set; }
public Action OnComplete { get; set; }
/// <summary>
/// Additional HTTP Headers
/// </summary>
/// <value>The headers.</value>
public IDictionary<string, string> Headers => _options;
/// <summary>
/// Gets the requested ranges.
/// </summary>
/// <value>The requested ranges.</value>
protected List<KeyValuePair<long, long?>> RequestedRanges
{
get
{
if (_requestedRanges == null)
{
_requestedRanges = new List<KeyValuePair<long, long?>>();
// Example: bytes=0-,32-63
var ranges = RangeHeader.Split('=')[1].Split(',');
foreach (var range in ranges)
{
var vals = range.Split('-');
long start = 0;
long? end = null;
if (!string.IsNullOrEmpty(vals[0]))
{
start = long.Parse(vals[0], CultureInfo.InvariantCulture);
}
if (!string.IsNullOrEmpty(vals[1]))
{
end = long.Parse(vals[1], CultureInfo.InvariantCulture);
}
_requestedRanges.Add(new KeyValuePair<long, long?>(start, end));
}
}
return _requestedRanges;
}
}
public string ContentType { get; set; }
public IRequest RequestContext { get; set; }
public object Response { get; set; }
public int Status { get; set; }
public HttpStatusCode StatusCode
{
get => (HttpStatusCode)Status;
set => Status = (int)value;
}
/// <summary>
/// Sets the range values.
/// </summary>
private void SetRangeValues(long contentLength)
{
var requestedRange = RequestedRanges[0];
TotalContentLength = contentLength;
// If the requested range is "0-", we can optimize by just doing a stream copy
if (!requestedRange.Value.HasValue)
{
RangeEnd = TotalContentLength - 1;
}
else
{
RangeEnd = requestedRange.Value.Value;
}
RangeStart = requestedRange.Key;
RangeLength = 1 + RangeEnd - RangeStart;
Headers[HeaderNames.ContentLength] = RangeLength.ToString(CultureInfo.InvariantCulture);
Headers[HeaderNames.ContentRange] = $"bytes {RangeStart}-{RangeEnd}/{TotalContentLength}";
if (RangeStart > 0 && SourceStream.CanSeek)
{
SourceStream.Position = RangeStart;
}
}
public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
{
try
{
// Headers only
if (IsHeadRequest)
{
return;
}
using (var source = SourceStream)
{
// If the requested range is "0-", we can optimize by just doing a stream copy
if (RangeEnd >= TotalContentLength - 1)
{
await source.CopyToAsync(responseStream, BufferSize, cancellationToken).ConfigureAwait(false);
}
else
{
await CopyToInternalAsync(source, responseStream, RangeLength, cancellationToken).ConfigureAwait(false);
}
}
}
finally
{
OnComplete?.Invoke();
}
}
private static async Task CopyToInternalAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken)
{
var array = ArrayPool<byte>.Shared.Rent(BufferSize);
try
{
int bytesRead;
while ((bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false)) != 0)
{
var bytesToCopy = Math.Min(bytesRead, copyLength);
await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToCopy), cancellationToken).ConfigureAwait(false);
copyLength -= bytesToCopy;
if (copyLength <= 0)
{
break;
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(array);
}
}
}
}

View File

@ -1,113 +0,0 @@
using System;
using System.Globalization;
using System.Text;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Emby.Server.Implementations.HttpServer
{
/// <summary>
/// Class ResponseFilter.
/// </summary>
public class ResponseFilter
{
private readonly IHttpServer _server;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ResponseFilter"/> class.
/// </summary>
/// <param name="server">The HTTP server.</param>
/// <param name="logger">The logger.</param>
public ResponseFilter(IHttpServer server, ILogger logger)
{
_server = server;
_logger = logger;
}
/// <summary>
/// Filters the response.
/// </summary>
/// <param name="req">The req.</param>
/// <param name="res">The res.</param>
/// <param name="dto">The dto.</param>
public void FilterResponse(IRequest req, HttpResponse res, object dto)
{
foreach(var (key, value) in _server.GetDefaultCorsHeaders(req))
{
res.Headers.Add(key, value);
}
// Try to prevent compatibility view
res.Headers["Access-Control-Allow-Headers"] = "Accept, Accept-Language, Authorization, Cache-Control, " +
"Content-Disposition, Content-Encoding, Content-Language, Content-Length, Content-MD5, Content-Range, " +
"Content-Type, Cookie, Date, Host, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, " +
"Origin, OriginToken, Pragma, Range, Slug, Transfer-Encoding, Want-Digest, X-MediaBrowser-Token, " +
"X-Emby-Authorization";
if (dto is Exception exception)
{
_logger.LogError(exception, "Error processing request for {RawUrl}", req.RawUrl);
if (!string.IsNullOrEmpty(exception.Message))
{
var error = exception.Message.Replace(Environment.NewLine, " ", StringComparison.Ordinal);
error = RemoveControlCharacters(error);
res.Headers.Add("X-Application-Error-Code", error);
}
}
if (dto is IHasHeaders hasHeaders)
{
if (!hasHeaders.Headers.ContainsKey(HeaderNames.Server))
{
hasHeaders.Headers[HeaderNames.Server] = "Microsoft-NetCore/2.0, UPnP/1.0 DLNADOC/1.50";
}
// Content length has to be explicitly set on on HttpListenerResponse or it won't be happy
if (hasHeaders.Headers.TryGetValue(HeaderNames.ContentLength, out string contentLength)
&& !string.IsNullOrEmpty(contentLength))
{
var length = long.Parse(contentLength, CultureInfo.InvariantCulture);
if (length > 0)
{
res.ContentLength = length;
}
}
}
}
/// <summary>
/// Removes the control characters.
/// </summary>
/// <param name="inString">The in string.</param>
/// <returns>System.String.</returns>
public static string RemoveControlCharacters(string inString)
{
if (inString == null)
{
return null;
}
else if (inString.Length == 0)
{
return inString;
}
var newString = new StringBuilder(inString.Length);
foreach (var ch in inString)
{
if (!char.IsControl(ch))
{
newString.Append(ch);
}
}
return newString.ToString();
}
}
}

View File

@ -1,17 +1,7 @@
#pragma warning disable CS1591
using System;
using System.Linq;
using Emby.Server.Implementations.SocketSharp;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Security;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace Emby.Server.Implementations.HttpServer.Security
@ -19,32 +9,11 @@ namespace Emby.Server.Implementations.HttpServer.Security
public class AuthService : IAuthService
{
private readonly IAuthorizationContext _authorizationContext;
private readonly ISessionManager _sessionManager;
private readonly IServerConfigurationManager _config;
private readonly INetworkManager _networkManager;
public AuthService(
IAuthorizationContext authorizationContext,
IServerConfigurationManager config,
ISessionManager sessionManager,
INetworkManager networkManager)
IAuthorizationContext authorizationContext)
{
_authorizationContext = authorizationContext;
_config = config;
_sessionManager = sessionManager;
_networkManager = networkManager;
}
public void Authenticate(IRequest request, IAuthenticationAttributes authAttributes)
{
ValidateUser(request, authAttributes);
}
public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes)
{
var req = new WebSocketSharpRequest(request, null, request.Path);
var user = ValidateUser(req, authAttributes);
return user;
}
public AuthorizationInfo Authenticate(HttpRequest request)
@ -62,185 +31,5 @@ namespace Emby.Server.Implementations.HttpServer.Security
return auth;
}
private User ValidateUser(IRequest request, IAuthenticationAttributes authAttributes)
{
// This code is executed before the service
var auth = _authorizationContext.GetAuthorizationInfo(request);
if (!IsExemptFromAuthenticationToken(authAttributes, request))
{
ValidateSecurityToken(request, auth.Token);
}
if (authAttributes.AllowLocalOnly && !request.IsLocal)
{
throw new SecurityException("Operation not found.");
}
var user = auth.User;
if (user == null && auth.UserId != Guid.Empty)
{
throw new AuthenticationException("User with Id " + auth.UserId + " not found");
}
if (user != null)
{
ValidateUserAccess(user, request, authAttributes);
}
var info = GetTokenInfo(request);
if (!IsExemptFromRoles(auth, authAttributes, request, info))
{
var roles = authAttributes.GetRoles();
ValidateRoles(roles, user);
}
if (!string.IsNullOrEmpty(auth.DeviceId) &&
!string.IsNullOrEmpty(auth.Client) &&
!string.IsNullOrEmpty(auth.Device))
{
_sessionManager.LogSessionActivity(
auth.Client,
auth.Version,
auth.DeviceId,
auth.Device,
request.RemoteIp,
user);
}
return user;
}
private void ValidateUserAccess(
User user,
IRequest request,
IAuthenticationAttributes authAttributes)
{
if (user.HasPermission(PermissionKind.IsDisabled))
{
throw new SecurityException("User account has been disabled.");
}
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !_networkManager.IsInLocalNetwork(request.RemoteIp))
{
throw new SecurityException("User account has been disabled.");
}
if (!user.HasPermission(PermissionKind.IsAdministrator)
&& !authAttributes.EscapeParentalControl
&& !user.IsParentalScheduleAllowed())
{
request.Response.Headers.Add("X-Application-Error-Code", "ParentalControl");
throw new SecurityException("This user account is not allowed access at this time.");
}
}
private bool IsExemptFromAuthenticationToken(IAuthenticationAttributes authAttribtues, IRequest request)
{
if (!_config.Configuration.IsStartupWizardCompleted && authAttribtues.AllowBeforeStartupWizard)
{
return true;
}
if (authAttribtues.AllowLocal && request.IsLocal)
{
return true;
}
if (authAttribtues.AllowLocalOnly && request.IsLocal)
{
return true;
}
if (authAttribtues.IgnoreLegacyAuth)
{
return true;
}
return false;
}
private bool IsExemptFromRoles(AuthorizationInfo auth, IAuthenticationAttributes authAttribtues, IRequest request, AuthenticationInfo tokenInfo)
{
if (!_config.Configuration.IsStartupWizardCompleted && authAttribtues.AllowBeforeStartupWizard)
{
return true;
}
if (authAttribtues.AllowLocal && request.IsLocal)
{
return true;
}
if (authAttribtues.AllowLocalOnly && request.IsLocal)
{
return true;
}
if (string.IsNullOrEmpty(auth.Token))
{
return true;
}
if (tokenInfo != null && tokenInfo.UserId.Equals(Guid.Empty))
{
return true;
}
return false;
}
private static void ValidateRoles(string[] roles, User user)
{
if (roles.Contains("admin", StringComparer.OrdinalIgnoreCase))
{
if (user == null || !user.HasPermission(PermissionKind.IsAdministrator))
{
throw new SecurityException("User does not have admin access.");
}
}
if (roles.Contains("delete", StringComparer.OrdinalIgnoreCase))
{
if (user == null || !user.HasPermission(PermissionKind.EnableContentDeletion))
{
throw new SecurityException("User does not have delete access.");
}
}
if (roles.Contains("download", StringComparer.OrdinalIgnoreCase))
{
if (user == null || !user.HasPermission(PermissionKind.EnableContentDownloading))
{
throw new SecurityException("User does not have download access.");
}
}
}
private static AuthenticationInfo GetTokenInfo(IRequest request)
{
request.Items.TryGetValue("OriginalAuthenticationInfo", out var info);
return info as AuthenticationInfo;
}
private void ValidateSecurityToken(IRequest request, string token)
{
if (string.IsNullOrEmpty(token))
{
throw new AuthenticationException("Access token is required.");
}
var info = GetTokenInfo(request);
if (info == null)
{
throw new AuthenticationException("Access token is invalid or expired.");
}
}
}
}

View File

@ -7,7 +7,6 @@ using System.Net;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Security;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
@ -24,14 +23,9 @@ namespace Emby.Server.Implementations.HttpServer.Security
_userManager = userManager;
}
public AuthorizationInfo GetAuthorizationInfo(object requestContext)
public AuthorizationInfo GetAuthorizationInfo(HttpContext requestContext)
{
return GetAuthorizationInfo((IRequest)requestContext);
}
public AuthorizationInfo GetAuthorizationInfo(IRequest requestContext)
{
if (requestContext.Items.TryGetValue("AuthorizationInfo", out var cached))
if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached))
{
return (AuthorizationInfo)cached;
}
@ -52,18 +46,18 @@ namespace Emby.Server.Implementations.HttpServer.Security
/// </summary>
/// <param name="httpReq">The HTTP req.</param>
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private AuthorizationInfo GetAuthorization(IRequest httpReq)
private AuthorizationInfo GetAuthorization(HttpContext httpReq)
{
var auth = GetAuthorizationDictionary(httpReq);
var (authInfo, originalAuthInfo) =
GetAuthorizationInfoFromDictionary(auth, httpReq.Headers, httpReq.QueryString);
GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query);
if (originalAuthInfo != null)
{
httpReq.Items["OriginalAuthenticationInfo"] = originalAuthInfo;
httpReq.Request.HttpContext.Items["OriginalAuthenticationInfo"] = originalAuthInfo;
}
httpReq.Items["AuthorizationInfo"] = authInfo;
httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
return authInfo;
}
@ -203,13 +197,13 @@ namespace Emby.Server.Implementations.HttpServer.Security
/// </summary>
/// <param name="httpReq">The HTTP req.</param>
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private Dictionary<string, string> GetAuthorizationDictionary(IRequest httpReq)
private Dictionary<string, string> GetAuthorizationDictionary(HttpContext httpReq)
{
var auth = httpReq.Headers["X-Emby-Authorization"];
var auth = httpReq.Request.Headers["X-Emby-Authorization"];
if (string.IsNullOrEmpty(auth))
{
auth = httpReq.Headers[HeaderNames.Authorization];
auth = httpReq.Request.Headers[HeaderNames.Authorization];
}
return GetAuthorization(auth);

View File

@ -2,11 +2,11 @@
using System;
using Jellyfin.Data.Entities;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Security;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace Emby.Server.Implementations.HttpServer.Security
{
@ -23,26 +23,20 @@ namespace Emby.Server.Implementations.HttpServer.Security
_sessionManager = sessionManager;
}
public SessionInfo GetSession(IRequest requestContext)
public SessionInfo GetSession(HttpContext requestContext)
{
var authorization = _authContext.GetAuthorizationInfo(requestContext);
var user = authorization.User;
return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.RemoteIp, user);
}
private AuthenticationInfo GetTokenInfo(IRequest request)
{
request.Items.TryGetValue("OriginalAuthenticationInfo", out var info);
return info as AuthenticationInfo;
return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.Request.RemoteIp(), user);
}
public SessionInfo GetSession(object requestContext)
{
return GetSession((IRequest)requestContext);
return GetSession((HttpContext)requestContext);
}
public User GetUser(IRequest requestContext)
public User GetUser(HttpContext requestContext)
{
var session = GetSession(requestContext);
@ -51,7 +45,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
public User GetUser(object requestContext)
{
return GetUser((IRequest)requestContext);
return GetUser((HttpContext)requestContext);
}
}
}

View File

@ -1,120 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Services;
using Microsoft.Net.Http.Headers;
namespace Emby.Server.Implementations.HttpServer
{
/// <summary>
/// Class StreamWriter.
/// </summary>
public class StreamWriter : IAsyncStreamWriter, IHasHeaders
{
/// <summary>
/// The options.
/// </summary>
private readonly IDictionary<string, string> _options = new Dictionary<string, string>();
/// <summary>
/// Initializes a new instance of the <see cref="StreamWriter" /> class.
/// </summary>
/// <param name="source">The source.</param>
/// <param name="contentType">Type of the content.</param>
public StreamWriter(Stream source, string contentType)
{
if (string.IsNullOrEmpty(contentType))
{
throw new ArgumentNullException(nameof(contentType));
}
SourceStream = source;
Headers["Content-Type"] = contentType;
if (source.CanSeek)
{
Headers[HeaderNames.ContentLength] = source.Length.ToString(CultureInfo.InvariantCulture);
}
Headers[HeaderNames.ContentType] = contentType;
}
/// <summary>
/// Initializes a new instance of the <see cref="StreamWriter"/> class.
/// </summary>
/// <param name="source">The source.</param>
/// <param name="contentType">Type of the content.</param>
/// <param name="contentLength">The content length.</param>
public StreamWriter(byte[] source, string contentType, int contentLength)
{
if (string.IsNullOrEmpty(contentType))
{
throw new ArgumentNullException(nameof(contentType));
}
SourceBytes = source;
Headers[HeaderNames.ContentLength] = contentLength.ToString(CultureInfo.InvariantCulture);
Headers[HeaderNames.ContentType] = contentType;
}
/// <summary>
/// Gets or sets the source stream.
/// </summary>
/// <value>The source stream.</value>
private Stream SourceStream { get; set; }
private byte[] SourceBytes { get; set; }
/// <summary>
/// Gets the options.
/// </summary>
/// <value>The options.</value>
public IDictionary<string, string> Headers => _options;
/// <summary>
/// Fires when complete.
/// </summary>
public Action OnComplete { get; set; }
/// <summary>
/// Fires when an error occours.
/// </summary>
public Action OnError { get; set; }
/// <inheritdoc />
public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
{
try
{
var bytes = SourceBytes;
if (bytes != null)
{
await responseStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false);
}
else
{
using (var src = SourceStream)
{
await src.CopyToAsync(responseStream, cancellationToken).ConfigureAwait(false);
}
}
}
catch
{
OnError?.Invoke();
throw;
}
finally
{
OnComplete?.Invoke();
}
}
}
}

View File

@ -0,0 +1,102 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.HttpServer
{
public class WebSocketManager : IWebSocketManager
{
private readonly ILogger<WebSocketManager> _logger;
private readonly ILoggerFactory _loggerFactory;
private IWebSocketListener[] _webSocketListeners = Array.Empty<IWebSocketListener>();
private bool _disposed = false;
public WebSocketManager(
ILogger<WebSocketManager> logger,
ILoggerFactory loggerFactory)
{
_logger = logger;
_loggerFactory = loggerFactory;
}
public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
/// <inheritdoc />
public async Task WebSocketRequestHandler(HttpContext context)
{
if (_disposed)
{
return;
}
try
{
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
using var connection = new WebSocketConnection(
_loggerFactory.CreateLogger<WebSocketConnection>(),
webSocket,
context.Connection.RemoteIpAddress,
context.Request.Query)
{
OnReceive = ProcessWebSocketMessageReceived
};
WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection));
await connection.ProcessAsync().ConfigureAwait(false);
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
}
catch (Exception ex) // Otherwise ASP.Net will ignore the exception
{
_logger.LogError(ex, "WS {IP} WebSocketRequestHandler error", context.Connection.RemoteIpAddress);
if (!context.Response.HasStarted)
{
context.Response.StatusCode = 500;
}
}
}
/// <summary>
/// Adds the rest handlers.
/// </summary>
/// <param name="listeners">The web socket listeners.</param>
public void Init(IEnumerable<IWebSocketListener> listeners)
{
_webSocketListeners = listeners.ToArray();
}
/// <summary>
/// Processes the web socket message received.
/// </summary>
/// <param name="result">The result.</param>
private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
{
if (_disposed)
{
return Task.CompletedTask;
}
IEnumerable<Task> GetTasks()
{
foreach (var x in _webSocketListeners)
{
yield return x.ProcessMessageAsync(result);
}
}
return Task.WhenAll(GetTasks());
}
}
}

View File

@ -17,5 +17,8 @@
"Genres": "Géneros",
"Folders": "Carpetas",
"Favorites": "Favoritos",
"FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido de {0}"
"FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido de {0}",
"HeaderFavoriteSongs": "Canciones Favoritas",
"HeaderFavoriteEpisodes": "Episodios Favoritos",
"HeaderFavoriteArtists": "Artistas Favoritos"
}

View File

@ -45,7 +45,7 @@
"NameSeasonNumber": "Sesong {0}",
"NameSeasonUnknown": "Sesong ukjent",
"NewVersionIsAvailable": "En ny versjon av Jellyfin Server er tilgjengelig for nedlasting.",
"NotificationOptionApplicationUpdateAvailable": "Programvareoppdatering er tilgjengelig",
"NotificationOptionApplicationUpdateAvailable": "En programvareoppdatering er tilgjengelig",
"NotificationOptionApplicationUpdateInstalled": "Applikasjonsoppdatering installert",
"NotificationOptionAudioPlayback": "Lydavspilling startet",
"NotificationOptionAudioPlaybackStopped": "Lydavspilling stoppet",

View File

@ -1,76 +1,117 @@
{
"ProviderValue": "ผู้ให้บริการ: {0}",
"PluginUpdatedWithName": "{0} ได้รับการ update แล้ว",
"PluginUninstalledWithName": "ถอนการติดตั้ง {0}",
"PluginInstalledWithName": "{0} ได้รับการติดตั้ง",
"Plugin": "Plugin",
"Playlists": "รายการ",
"PluginUpdatedWithName": "อัปเดต {0} แล้ว",
"PluginUninstalledWithName": "ถอนการติดตั้ง {0} แล้ว",
"PluginInstalledWithName": "ติดตั้ง {0} แล้ว",
"Plugin": "ปลั๊กอิน",
"Playlists": "เพลย์ลิสต์",
"Photos": "รูปภาพ",
"NotificationOptionVideoPlaybackStopped": "หยุดการเล่น Video",
"NotificationOptionVideoPlayback": "เริ่มแสดง Video",
"NotificationOptionUserLockedOut": "ผู้ใช้ Locked Out",
"NotificationOptionTaskFailed": "ตารางการทำงานล้มเหลว",
"NotificationOptionServerRestartRequired": "ควร Restart Server",
"NotificationOptionPluginUpdateInstalled": "Update Plugin แล้ว",
"NotificationOptionPluginUninstalled": "ถอด Plugin",
"NotificationOptionPluginInstalled": "ติดตั้ง Plugin แล้ว",
"NotificationOptionPluginError": "Plugin ล้มเหลว",
"NotificationOptionNewLibraryContent": "เพิ่มข้อมูลใหม่แล้ว",
"NotificationOptionInstallationFailed": "ติดตั้งล้มเหลว",
"NotificationOptionCameraImageUploaded": "รูปภาพถูก upload",
"NotificationOptionAudioPlaybackStopped": "หยุดการเล่นเสียง",
"NotificationOptionVideoPlaybackStopped": "หยุดเล่นวิดีโอ",
"NotificationOptionVideoPlayback": "เริ่มเล่นวิดีโอ",
"NotificationOptionUserLockedOut": "ผู้ใช้ถูกล็อก",
"NotificationOptionTaskFailed": "งานตามกำหนดการล้มเหลว",
"NotificationOptionServerRestartRequired": "จำเป็นต้องรีสตาร์ทเซิร์ฟเวอร์",
"NotificationOptionPluginUpdateInstalled": "ติดตั้งการอัปเดตปลั๊กอินแล้ว",
"NotificationOptionPluginUninstalled": "ถอนการติดตั้งปลั๊กอินแล้ว",
"NotificationOptionPluginInstalled": "ติดตั้งปลั๊กอินแล้ว",
"NotificationOptionPluginError": "ปลั๊กอินล้มเหลว",
"NotificationOptionNewLibraryContent": "เพิ่มเนื้อหาใหม่แล้ว",
"NotificationOptionInstallationFailed": "การติดตั้งล้มเหลว",
"NotificationOptionCameraImageUploaded": "อัปโหลดภาพถ่ายแล้ว",
"NotificationOptionAudioPlaybackStopped": "หยุดเล่นเสียง",
"NotificationOptionAudioPlayback": "เริ่มเล่นเสียง",
"NotificationOptionApplicationUpdateInstalled": "Update ระบบแล้ว",
"NotificationOptionApplicationUpdateAvailable": "ระบบ update สามารถใช้ได้แล้ว",
"NewVersionIsAvailable": "ตรวจพบ Jellyfin เวอร์ชั่นใหม่",
"NameSeasonUnknown": "ไม่ทราบปี",
"NameSeasonNumber": "ปี {0}",
"NameInstallFailed": "{0} ติดตั้งไม่สำเร็จ",
"MusicVideos": "MV",
"Music": "เพลง",
"Movies": "ภาพยนต์",
"MixedContent": "รายการแบบผสม",
"MessageServerConfigurationUpdated": "การตั้งค่า update แล้ว",
"MessageNamedServerConfigurationUpdatedWithValue": "รายการตั้งค่า {0} ได้รับการ update แล้ว",
"MessageApplicationUpdatedTo": "Jellyfin Server จะ update ไปที่ {0}",
"MessageApplicationUpdated": "Jellyfin Server update แล้ว",
"NotificationOptionApplicationUpdateInstalled": "ติดตั้งการอัปเดตแอพพลิเคชันแล้ว",
"NotificationOptionApplicationUpdateAvailable": "มีการอัปเดตแอพพลิเคชัน",
"NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว",
"NameSeasonUnknown": "ไม่ทราบซีซัน",
"NameSeasonNumber": "ซีซัน {0}",
"NameInstallFailed": "การติดตั้ง {0} ล้มเหลว",
"MusicVideos": "มิวสิควิดีโอ",
"Music": "ดนตรี",
"Movies": "ภาพยนต์",
"MixedContent": "เนื้อหาผสม",
"MessageServerConfigurationUpdated": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์แล้ว",
"MessageNamedServerConfigurationUpdatedWithValue": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์ในส่วน {0} แล้ว",
"MessageApplicationUpdatedTo": "เซิร์ฟเวอร์ Jellyfin ได้รับการอัปเดตเป็น {0}",
"MessageApplicationUpdated": "อัพเดตเซิร์ฟเวอร์ Jellyfin แล้ว",
"Latest": "ล่าสุด",
"LabelRunningTimeValue": "เวลาที่เล่น : {0}",
"LabelIpAddressValue": "IP address: {0}",
"ItemRemovedWithName": "{0} ถูกลบจากรายการ",
"ItemAddedWithName": "{0} ถูกเพิ่มในรายการ",
"Inherit": "การสืบทอด",
"HomeVideos": "วีดีโอส่วนตัว",
"HeaderRecordingGroups": "ค่ายบันทึก",
"LabelRunningTimeValue": "ผ่านไปแล้ว: {0}",
"LabelIpAddressValue": "ที่อยู่ IP: {0}",
"ItemRemovedWithName": "{0} ถูกลบออกจากไลบราร",
"ItemAddedWithName": "{0} ถูกเพิ่มลงในไลบรารีแล้ว",
"Inherit": "สืบทอด",
"HomeVideos": "โฮมวิดีโอ",
"HeaderRecordingGroups": "กลุ่มการบันทึก",
"HeaderNextUp": "ถัดไป",
"HeaderLiveTV": "รายการสด",
"HeaderFavoriteSongs": "เพลงโปรด",
"HeaderFavoriteShows": "รายการโชว์โปรด",
"HeaderFavoriteEpisodes": "ฉากโปรด",
"HeaderFavoriteArtists": "นักแสดงโปรด",
"HeaderFavoriteAlbums": "อัมบั้มโปรด",
"HeaderContinueWatching": "ชมต่อจากเดิม",
"HeaderCameraUploads": "Upload รูปภาพ",
"HeaderAlbumArtists": "อัลบั้มักแสดง",
"HeaderLiveTV": "ทีวีสด",
"HeaderFavoriteSongs": "เพลงที่ชื่นชอบ",
"HeaderFavoriteShows": "รายการที่ชื่นชอบ",
"HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ",
"HeaderFavoriteArtists": "ศิลปินที่ชื่นชอบ",
"HeaderFavoriteAlbums": "อัมบั้มที่ชื่นชอบ",
"HeaderContinueWatching": "ดูต่อ",
"HeaderCameraUploads": "อัปโหลดรูปถ่าย",
"HeaderAlbumArtists": "อัลบั้มศิลปิน",
"Genres": "ประเภท",
"Folders": "โฟลเดอร์",
"Favorites": "รายการโปรด",
"FailedLoginAttemptWithUserName": "การเชื่อมต่อล้มเหลวจาก {0}",
"DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จ",
"DeviceOfflineWithName": "{0} ตัดการเชื่อมต่อ",
"Collections": "ชุด",
"ChapterNameValue": "บทที่ {0}",
"Channels": "ชาแนล",
"CameraImageUploadedFrom": "รูปภาพถูก upload จาก {0}",
"FailedLoginAttemptWithUserName": "ความพยายามในการเข้าสู่ระบบล้มเหลวจาก {0}",
"DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว",
"DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว",
"Collections": "คอลเลกชัน",
"ChapterNameValue": "บท {0}",
"Channels": "ช่อง",
"CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}",
"Books": "หนังสือ",
"AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จ",
"Artists": "ักแสดง",
"Application": "แอปพลิเคชั่น",
"AppDeviceValues": "App: {0}, อุปกรณ์: {1}",
"AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จแล้ว",
"Artists": "ศิลปิน",
"Application": "แอพพลิเคชัน",
"AppDeviceValues": "แอพ: {0}, อุปกรณ์: {1}",
"Albums": "อัลบั้ม",
"ScheduledTaskStartedWithName": "{0} เริ่มต้น",
"ScheduledTaskFailedWithName": "{0} ล้มเหลว",
"Songs": "เพลง",
"Shows": "แสดง",
"ServerNameNeedsToBeRestarted": "{0} ต้องการรีสตาร์ท"
"Shows": "รายการ",
"ServerNameNeedsToBeRestarted": "{0} ต้องการการรีสตาร์ท",
"TaskDownloadMissingSubtitlesDescription": "ค้นหาคำบรรยายที่หายไปในอินเทอร์เน็ตตามค่ากำหนดในข้อมูลเมตา",
"TaskDownloadMissingSubtitles": "ดาวน์โหลดคำบรรยายที่ขาดหายไป",
"TaskRefreshChannelsDescription": "รีเฟรชข้อมูลช่องอินเทอร์เน็ต",
"TaskRefreshChannels": "รีเฟรชช่อง",
"TaskCleanTranscodeDescription": "ลบไฟล์ทรานส์โค้ดที่มีอายุมากกว่าหนึ่งวัน",
"TaskCleanTranscode": "ล้างไดเรกทอรีทรานส์โค้ด",
"TaskUpdatePluginsDescription": "ดาวน์โหลดและติดตั้งโปรแกรมปรับปรุงให้กับปลั๊กอินที่กำหนดค่าให้อัปเดตโดยอัตโนมัติ",
"TaskUpdatePlugins": "อัปเดตปลั๊กอิน",
"TaskRefreshPeopleDescription": "อัปเดตข้อมูลเมตานักแสดงและผู้กำกับในไลบรารีสื่อ",
"TaskRefreshPeople": "รีเฟรชบุคคล",
"TaskCleanLogsDescription": "ลบไฟล์บันทึกที่เก่ากว่า {0} วัน",
"TaskCleanLogs": "ล้างไดเรกทอรีบันทึก",
"TaskRefreshLibraryDescription": "สแกนไลบรารีสื่อของคุณเพื่อหาไฟล์ใหม่และรีเฟรชข้อมูลเมตา",
"TaskRefreshLibrary": "สแกนไลบรารีสื่อ",
"TaskRefreshChapterImagesDescription": "สร้างภาพขนาดย่อสำหรับวิดีโอที่มีบท",
"TaskRefreshChapterImages": "แตกรูปภาพบท",
"TaskCleanCacheDescription": "ลบไฟล์แคชที่ระบบไม่ต้องการ",
"TaskCleanCache": "ล้างไดเรกทอรีแคช",
"TasksChannelsCategory": "ช่องอินเทอร์เน็ต",
"TasksApplicationCategory": "แอพพลิเคชัน",
"TasksLibraryCategory": "ไลบรารี",
"TasksMaintenanceCategory": "ปิดซ่อมบำรุง",
"VersionNumber": "เวอร์ชัน {0}",
"ValueSpecialEpisodeName": "พิเศษ - {0}",
"ValueHasBeenAddedToLibrary": "เพิ่ม {0} ลงในไลบรารีสื่อของคุณแล้ว",
"UserStoppedPlayingItemWithValues": "{0} เล่นเสร็จแล้ว {1} บน {2}",
"UserStartedPlayingItemWithValues": "{0} กำลังเล่น {1} บน {2}",
"UserPolicyUpdatedWithName": "มีการอัปเดตนโยบายผู้ใช้ของ {0}",
"UserPasswordChangedWithName": "มีการเปลี่ยนรหัสผ่านของผู้ใช้ {0}",
"UserOnlineFromDevice": "{0} ออนไลน์จาก {1}",
"UserOfflineFromDevice": "{0} ได้ยกเลิกการเชื่อมต่อจาก {1}",
"UserLockedOutWithName": "ผู้ใช้ {0} ถูกล็อก",
"UserDownloadingItemWithValues": "{0} กำลังดาวน์โหลด {1}",
"UserDeletedWithName": "ลบผู้ใช้ {0} แล้ว",
"UserCreatedWithName": "สร้างผู้ใช้ {0} แล้ว",
"User": "ผู้ใช้งาน",
"TvShows": "รายการทีวี",
"System": "ระบบ",
"Sync": "ซิงค์",
"SubtitleDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดคำบรรยายจาก {0} สำหรับ {1} ได้",
"StartupEmbyServerIsLoading": "กำลังโหลดเซิร์ฟเวอร์ Jellyfin โปรดลองอีกครั้งในอีกสักครู่"
}

View File

@ -0,0 +1,285 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using MediaBrowser.Common;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Security;
using MediaBrowser.Model.QuickConnect;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.QuickConnect
{
/// <summary>
/// Quick connect implementation.
/// </summary>
public class QuickConnectManager : IQuickConnect, IDisposable
{
private readonly RNGCryptoServiceProvider _rng = new RNGCryptoServiceProvider();
private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new ConcurrentDictionary<string, QuickConnectResult>();
private readonly IServerConfigurationManager _config;
private readonly ILogger<QuickConnectManager> _logger;
private readonly IAuthenticationRepository _authenticationRepository;
private readonly IAuthorizationContext _authContext;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="QuickConnectManager"/> class.
/// Should only be called at server startup when a singleton is created.
/// </summary>
/// <param name="config">Configuration.</param>
/// <param name="logger">Logger.</param>
/// <param name="appHost">Application host.</param>
/// <param name="authContext">Authentication context.</param>
/// <param name="authenticationRepository">Authentication repository.</param>
public QuickConnectManager(
IServerConfigurationManager config,
ILogger<QuickConnectManager> logger,
IServerApplicationHost appHost,
IAuthorizationContext authContext,
IAuthenticationRepository authenticationRepository)
{
_config = config;
_logger = logger;
_appHost = appHost;
_authContext = authContext;
_authenticationRepository = authenticationRepository;
ReloadConfiguration();
}
/// <inheritdoc/>
public int CodeLength { get; set; } = 6;
/// <inheritdoc/>
public string TokenName { get; set; } = "QuickConnect";
/// <inheritdoc/>
public QuickConnectState State { get; private set; } = QuickConnectState.Unavailable;
/// <inheritdoc/>
public int Timeout { get; set; } = 5;
private DateTime DateActivated { get; set; }
/// <inheritdoc/>
public void AssertActive()
{
if (State != QuickConnectState.Active)
{
throw new ArgumentException("Quick connect is not active on this server");
}
}
/// <inheritdoc/>
public void Activate()
{
DateActivated = DateTime.UtcNow;
SetState(QuickConnectState.Active);
}
/// <inheritdoc/>
public void SetState(QuickConnectState newState)
{
_logger.LogDebug("Changed quick connect state from {State} to {newState}", State, newState);
ExpireRequests(true);
State = newState;
_config.Configuration.QuickConnectAvailable = newState == QuickConnectState.Available || newState == QuickConnectState.Active;
_config.SaveConfiguration();
_logger.LogDebug("Configuration saved");
}
/// <inheritdoc/>
public QuickConnectResult TryConnect()
{
ExpireRequests();
if (State != QuickConnectState.Active)
{
_logger.LogDebug("Refusing quick connect initiation request, current state is {State}", State);
throw new AuthenticationException("Quick connect is not active on this server");
}
var code = GenerateCode();
var result = new QuickConnectResult()
{
Secret = GenerateSecureRandom(),
DateAdded = DateTime.UtcNow,
Code = code
};
_currentRequests[code] = result;
return result;
}
/// <inheritdoc/>
public QuickConnectResult CheckRequestStatus(string secret)
{
ExpireRequests();
AssertActive();
string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First();
if (!_currentRequests.TryGetValue(code, out QuickConnectResult result))
{
throw new ResourceNotFoundException("Unable to find request with provided secret");
}
return result;
}
/// <inheritdoc/>
public string GenerateCode()
{
Span<byte> raw = stackalloc byte[4];
int min = (int)Math.Pow(10, CodeLength - 1);
int max = (int)Math.Pow(10, CodeLength);
uint scale = uint.MaxValue;
while (scale == uint.MaxValue)
{
_rng.GetBytes(raw);
scale = BitConverter.ToUInt32(raw);
}
int code = (int)(min + ((max - min) * (scale / (double)uint.MaxValue)));
return code.ToString(CultureInfo.InvariantCulture);
}
/// <inheritdoc/>
public bool AuthorizeRequest(Guid userId, string code)
{
ExpireRequests();
AssertActive();
if (!_currentRequests.TryGetValue(code, out QuickConnectResult result))
{
throw new ResourceNotFoundException("Unable to find request");
}
if (result.Authenticated)
{
throw new InvalidOperationException("Request is already authorized");
}
result.Authentication = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
var added = result.DateAdded ?? DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(Timeout));
result.DateAdded = added.Subtract(TimeSpan.FromMinutes(Timeout - 1));
_authenticationRepository.Create(new AuthenticationInfo
{
AppName = TokenName,
AccessToken = result.Authentication,
DateCreated = DateTime.UtcNow,
DeviceId = _appHost.SystemId,
DeviceName = _appHost.FriendlyName,
AppVersion = _appHost.ApplicationVersionString,
UserId = userId
});
_logger.LogDebug("Authorizing device with code {Code} to login as user {userId}", code, userId);
return true;
}
/// <inheritdoc/>
public int DeleteAllDevices(Guid user)
{
var raw = _authenticationRepository.Get(new AuthenticationInfoQuery()
{
DeviceId = _appHost.SystemId,
UserId = user
});
var tokens = raw.Items.Where(x => x.AppName.StartsWith(TokenName, StringComparison.Ordinal));
var removed = 0;
foreach (var token in tokens)
{
_authenticationRepository.Delete(token);
_logger.LogDebug("Deleted token {AccessToken}", token.AccessToken);
removed++;
}
return removed;
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose.
/// </summary>
/// <param name="disposing">Dispose unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_rng?.Dispose();
}
}
private string GenerateSecureRandom(int length = 32)
{
Span<byte> bytes = stackalloc byte[length];
_rng.GetBytes(bytes);
return Hex.Encode(bytes);
}
/// <inheritdoc/>
public void ExpireRequests(bool expireAll = false)
{
// Check if quick connect should be deactivated
if (State == QuickConnectState.Active && DateTime.UtcNow > DateActivated.AddMinutes(Timeout) && !expireAll)
{
_logger.LogDebug("Quick connect time expired, deactivating");
SetState(QuickConnectState.Available);
expireAll = true;
}
// Expire stale connection requests
var code = string.Empty;
var values = _currentRequests.Values.ToList();
for (int i = 0; i < values.Count; i++)
{
var added = values[i].DateAdded ?? DateTime.UnixEpoch;
if (DateTime.UtcNow > added.AddMinutes(Timeout) || expireAll)
{
code = values[i].Code;
_logger.LogDebug("Removing expired request {code}", code);
if (!_currentRequests.TryRemove(code, out _))
{
_logger.LogWarning("Request {code} already expired", code);
}
}
}
}
private void ReloadConfiguration()
{
State = _config.Configuration.QuickConnectAvailable ? QuickConnectState.Available : QuickConnectState.Unavailable;
}
}
}

View File

@ -1,64 +0,0 @@
#pragma warning disable CS1591
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Services;
namespace Emby.Server.Implementations.Services
{
public class HttpResult
: IHttpResult, IAsyncStreamWriter
{
public HttpResult(object response, string contentType, HttpStatusCode statusCode)
{
this.Headers = new Dictionary<string, string>();
this.Response = response;
this.ContentType = contentType;
this.StatusCode = statusCode;
}
public object Response { get; set; }
public string ContentType { get; set; }
public IDictionary<string, string> Headers { get; private set; }
public int Status { get; set; }
public HttpStatusCode StatusCode
{
get => (HttpStatusCode)Status;
set => Status = (int)value;
}
public IRequest RequestContext { get; set; }
public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
{
var response = RequestContext?.Response;
if (this.Response is byte[] bytesResponse)
{
var contentLength = bytesResponse.Length;
if (response != null)
{
response.ContentLength = contentLength;
}
if (contentLength > 0)
{
await responseStream.WriteAsync(bytesResponse, 0, contentLength, cancellationToken).ConfigureAwait(false);
}
return;
}
await ResponseHelper.WriteObject(this.RequestContext, this.Response, response).ConfigureAwait(false);
}
}
}

View File

@ -1,51 +0,0 @@
#pragma warning disable CS1591
using System;
using System.IO;
using System.Threading.Tasks;
using Emby.Server.Implementations.HttpServer;
namespace Emby.Server.Implementations.Services
{
public class RequestHelper
{
public static Func<Type, Stream, Task<object>> GetRequestReader(HttpListenerHost host, string contentType)
{
switch (GetContentTypeWithoutEncoding(contentType))
{
case "application/xml":
case "text/xml":
case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml
return host.DeserializeXml;
case "application/json":
case "text/json":
return host.DeserializeJson;
}
return null;
}
public static Action<object, Stream> GetResponseWriter(HttpListenerHost host, string contentType)
{
switch (GetContentTypeWithoutEncoding(contentType))
{
case "application/xml":
case "text/xml":
case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml
return host.SerializeToXml;
case "application/json":
case "text/json":
return host.SerializeToJson;
}
return null;
}
private static string GetContentTypeWithoutEncoding(string contentType)
{
return contentType?.Split(';')[0].ToLowerInvariant().Trim();
}
}
}

View File

@ -1,141 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.HttpServer;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace Emby.Server.Implementations.Services
{
public static class ResponseHelper
{
public static Task WriteToResponse(HttpResponse response, IRequest request, object result, CancellationToken cancellationToken)
{
if (result == null)
{
if (response.StatusCode == (int)HttpStatusCode.OK)
{
response.StatusCode = (int)HttpStatusCode.NoContent;
}
response.ContentLength = 0;
return Task.CompletedTask;
}
var httpResult = result as IHttpResult;
if (httpResult != null)
{
httpResult.RequestContext = request;
request.ResponseContentType = httpResult.ContentType ?? request.ResponseContentType;
}
var defaultContentType = request.ResponseContentType;
if (httpResult != null)
{
if (httpResult.RequestContext == null)
{
httpResult.RequestContext = request;
}
response.StatusCode = httpResult.Status;
}
if (result is IHasHeaders responseOptions)
{
foreach (var responseHeaders in responseOptions.Headers)
{
if (string.Equals(responseHeaders.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
{
response.ContentLength = long.Parse(responseHeaders.Value, CultureInfo.InvariantCulture);
continue;
}
response.Headers.Add(responseHeaders.Key, responseHeaders.Value);
}
}
// ContentType='text/html' is the default for a HttpResponse
// Do not override if another has been set
if (response.ContentType == null || response.ContentType == "text/html")
{
response.ContentType = defaultContentType;
}
if (response.ContentType == "application/json")
{
response.ContentType += "; charset=utf-8";
}
switch (result)
{
case IAsyncStreamWriter asyncStreamWriter:
return asyncStreamWriter.WriteToAsync(response.Body, cancellationToken);
case IStreamWriter streamWriter:
streamWriter.WriteTo(response.Body);
return Task.CompletedTask;
case FileWriter fileWriter:
return fileWriter.WriteToAsync(response, cancellationToken);
case Stream stream:
return CopyStream(stream, response.Body);
case byte[] bytes:
response.ContentType = "application/octet-stream";
response.ContentLength = bytes.Length;
if (bytes.Length > 0)
{
return response.Body.WriteAsync(bytes, 0, bytes.Length, cancellationToken);
}
return Task.CompletedTask;
case string responseText:
var responseTextAsBytes = Encoding.UTF8.GetBytes(responseText);
response.ContentLength = responseTextAsBytes.Length;
if (responseTextAsBytes.Length > 0)
{
return response.Body.WriteAsync(responseTextAsBytes, 0, responseTextAsBytes.Length, cancellationToken);
}
return Task.CompletedTask;
}
return WriteObject(request, result, response);
}
private static async Task CopyStream(Stream src, Stream dest)
{
using (src)
{
await src.CopyToAsync(dest).ConfigureAwait(false);
}
}
public static async Task WriteObject(IRequest request, object result, HttpResponse response)
{
var contentType = request.ResponseContentType;
var serializer = RequestHelper.GetResponseWriter(HttpListenerHost.Instance, contentType);
using (var ms = new MemoryStream())
{
serializer(result, ms);
ms.Position = 0;
var contentLength = ms.Length;
response.ContentLength = contentLength;
if (contentLength > 0)
{
await ms.CopyToAsync(response.Body).ConfigureAwait(false);
}
}
}
}
}

View File

@ -1,202 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Emby.Server.Implementations.HttpServer;
using MediaBrowser.Model.Services;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Services
{
public delegate object ActionInvokerFn(object intance, object request);
public delegate void VoidActionInvokerFn(object intance, object request);
public class ServiceController
{
private readonly ILogger<ServiceController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ServiceController"/> class.
/// </summary>
/// <param name="logger">The <see cref="ServiceController"/> logger.</param>
public ServiceController(ILogger<ServiceController> logger)
{
_logger = logger;
}
public void Init(HttpListenerHost appHost, IEnumerable<Type> serviceTypes)
{
foreach (var serviceType in serviceTypes)
{
RegisterService(appHost, serviceType);
}
}
public void RegisterService(HttpListenerHost appHost, Type serviceType)
{
// Make sure the provided type implements IService
if (!typeof(IService).IsAssignableFrom(serviceType))
{
_logger.LogWarning("Tried to register a service that does not implement IService: {ServiceType}", serviceType);
return;
}
var processedReqs = new HashSet<Type>();
var actions = ServiceExecGeneral.Reset(serviceType);
foreach (var mi in serviceType.GetActions())
{
var requestType = mi.GetParameters()[0].ParameterType;
if (processedReqs.Contains(requestType))
{
continue;
}
processedReqs.Add(requestType);
ServiceExecGeneral.CreateServiceRunnersFor(requestType, actions);
// var returnMarker = GetTypeWithGenericTypeDefinitionOf(requestType, typeof(IReturn<>));
// var responseType = returnMarker != null ?
// GetGenericArguments(returnMarker)[0]
// : mi.ReturnType != typeof(object) && mi.ReturnType != typeof(void) ?
// mi.ReturnType
// : Type.GetType(requestType.FullName + "Response");
RegisterRestPaths(appHost, requestType, serviceType);
appHost.AddServiceInfo(serviceType, requestType);
}
}
public readonly RestPath.RestPathMap RestPathMap = new RestPath.RestPathMap();
public void RegisterRestPaths(HttpListenerHost appHost, Type requestType, Type serviceType)
{
var attrs = appHost.GetRouteAttributes(requestType);
foreach (var attr in attrs)
{
var restPath = new RestPath(appHost.CreateInstance, appHost.GetParseFn, requestType, serviceType, attr.Path, attr.Verbs, attr.IsHidden, attr.Summary, attr.Description);
RegisterRestPath(restPath);
}
}
private static readonly char[] InvalidRouteChars = new[] { '?', '&' };
public void RegisterRestPath(RestPath restPath)
{
if (restPath.Path[0] != '/')
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"Route '{0}' on '{1}' must start with a '/'",
restPath.Path,
restPath.RequestType.GetMethodName()));
}
if (restPath.Path.IndexOfAny(InvalidRouteChars) != -1)
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"Route '{0}' on '{1}' contains invalid chars. ",
restPath.Path,
restPath.RequestType.GetMethodName()));
}
if (RestPathMap.TryGetValue(restPath.FirstMatchHashKey, out List<RestPath> pathsAtFirstMatch))
{
pathsAtFirstMatch.Add(restPath);
}
else
{
RestPathMap[restPath.FirstMatchHashKey] = new List<RestPath>() { restPath };
}
}
public RestPath GetRestPathForRequest(string httpMethod, string pathInfo)
{
var matchUsingPathParts = RestPath.GetPathPartsForMatching(pathInfo);
List<RestPath> firstMatches;
var yieldedHashMatches = RestPath.GetFirstMatchHashKeys(matchUsingPathParts);
foreach (var potentialHashMatch in yieldedHashMatches)
{
if (!this.RestPathMap.TryGetValue(potentialHashMatch, out firstMatches))
{
continue;
}
var bestScore = -1;
RestPath bestMatch = null;
foreach (var restPath in firstMatches)
{
var score = restPath.MatchScore(httpMethod, matchUsingPathParts);
if (score > bestScore)
{
bestScore = score;
bestMatch = restPath;
}
}
if (bestScore > 0 && bestMatch != null)
{
return bestMatch;
}
}
var yieldedWildcardMatches = RestPath.GetFirstMatchWildCardHashKeys(matchUsingPathParts);
foreach (var potentialHashMatch in yieldedWildcardMatches)
{
if (!this.RestPathMap.TryGetValue(potentialHashMatch, out firstMatches))
{
continue;
}
var bestScore = -1;
RestPath bestMatch = null;
foreach (var restPath in firstMatches)
{
var score = restPath.MatchScore(httpMethod, matchUsingPathParts);
if (score > bestScore)
{
bestScore = score;
bestMatch = restPath;
}
}
if (bestScore > 0 && bestMatch != null)
{
return bestMatch;
}
}
return null;
}
public Task<object> Execute(HttpListenerHost httpHost, object requestDto, IRequest req)
{
var requestType = requestDto.GetType();
req.OperationName = requestType.Name;
var serviceType = httpHost.GetServiceTypeByRequest(requestType);
var service = httpHost.CreateInstance(serviceType);
if (service is IRequiresRequest serviceRequiresContext)
{
serviceRequiresContext.Request = req;
}
// Executes the service and returns the result
return ServiceExecGeneral.Execute(serviceType, req, service, requestDto, requestType.GetMethodName());
}
}
}

View File

@ -1,230 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading.Tasks;
using MediaBrowser.Model.Services;
namespace Emby.Server.Implementations.Services
{
public static class ServiceExecExtensions
{
public static string[] AllVerbs = new[] {
"OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", "CONNECT", // RFC 2616
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", // RFC 2518
"VERSION-CONTROL", "REPORT", "CHECKOUT", "CHECKIN", "UNCHECKOUT",
"MKWORKSPACE", "UPDATE", "LABEL", "MERGE", "BASELINE-CONTROL", "MKACTIVITY", // RFC 3253
"ORDERPATCH", // RFC 3648
"ACL", // RFC 3744
"PATCH", // https://datatracker.ietf.org/doc/draft-dusseault-http-patch/
"SEARCH", // https://datatracker.ietf.org/doc/draft-reschke-webdav-search/
"BCOPY", "BDELETE", "BMOVE", "BPROPFIND", "BPROPPATCH", "NOTIFY",
"POLL", "SUBSCRIBE", "UNSUBSCRIBE"
};
public static List<MethodInfo> GetActions(this Type serviceType)
{
var list = new List<MethodInfo>();
foreach (var mi in serviceType.GetRuntimeMethods())
{
if (!mi.IsPublic)
{
continue;
}
if (mi.IsStatic)
{
continue;
}
if (mi.GetParameters().Length != 1)
{
continue;
}
var actionName = mi.Name;
if (!AllVerbs.Contains(actionName, StringComparer.OrdinalIgnoreCase))
{
continue;
}
list.Add(mi);
}
return list;
}
}
internal static class ServiceExecGeneral
{
private static Dictionary<string, ServiceMethod> execMap = new Dictionary<string, ServiceMethod>();
public static void CreateServiceRunnersFor(Type requestType, List<ServiceMethod> actions)
{
foreach (var actionCtx in actions)
{
if (execMap.ContainsKey(actionCtx.Id))
{
continue;
}
execMap[actionCtx.Id] = actionCtx;
}
}
public static Task<object> Execute(Type serviceType, IRequest request, object instance, object requestDto, string requestName)
{
var actionName = request.Verb ?? "POST";
if (execMap.TryGetValue(ServiceMethod.Key(serviceType, actionName, requestName), out ServiceMethod actionContext))
{
if (actionContext.RequestFilters != null)
{
foreach (var requestFilter in actionContext.RequestFilters)
{
requestFilter.RequestFilter(request, request.Response, requestDto);
if (request.Response.HasStarted)
{
Task.FromResult<object>(null);
}
}
}
var response = actionContext.ServiceAction(instance, requestDto);
if (response is Task taskResponse)
{
return GetTaskResult(taskResponse);
}
return Task.FromResult(response);
}
var expectedMethodName = actionName.Substring(0, 1) + actionName.Substring(1).ToLowerInvariant();
throw new NotImplementedException(
string.Format(
CultureInfo.InvariantCulture,
"Could not find method named {1}({0}) or Any({0}) on Service {2}",
requestDto.GetType().GetMethodName(),
expectedMethodName,
serviceType.GetMethodName()));
}
private static async Task<object> GetTaskResult(Task task)
{
try
{
if (task is Task<object> taskObject)
{
return await taskObject.ConfigureAwait(false);
}
await task.ConfigureAwait(false);
var type = task.GetType().GetTypeInfo();
if (!type.IsGenericType)
{
return null;
}
var resultProperty = type.GetDeclaredProperty("Result");
if (resultProperty == null)
{
return null;
}
var result = resultProperty.GetValue(task);
// hack alert
if (result.GetType().Name.IndexOf("voidtaskresult", StringComparison.OrdinalIgnoreCase) != -1)
{
return null;
}
return result;
}
catch (TypeAccessException)
{
return null; // return null for void Task's
}
}
public static List<ServiceMethod> Reset(Type serviceType)
{
var actions = new List<ServiceMethod>();
foreach (var mi in serviceType.GetActions())
{
var actionName = mi.Name;
var args = mi.GetParameters();
var requestType = args[0].ParameterType;
var actionCtx = new ServiceMethod
{
Id = ServiceMethod.Key(serviceType, actionName, requestType.GetMethodName())
};
actionCtx.ServiceAction = CreateExecFn(serviceType, requestType, mi);
var reqFilters = new List<IHasRequestFilter>();
foreach (var attr in mi.GetCustomAttributes(true))
{
if (attr is IHasRequestFilter hasReqFilter)
{
reqFilters.Add(hasReqFilter);
}
}
if (reqFilters.Count > 0)
{
actionCtx.RequestFilters = reqFilters.OrderBy(i => i.Priority).ToArray();
}
actions.Add(actionCtx);
}
return actions;
}
private static ActionInvokerFn CreateExecFn(Type serviceType, Type requestType, MethodInfo mi)
{
var serviceParam = Expression.Parameter(typeof(object), "serviceObj");
var serviceStrong = Expression.Convert(serviceParam, serviceType);
var requestDtoParam = Expression.Parameter(typeof(object), "requestDto");
var requestDtoStrong = Expression.Convert(requestDtoParam, requestType);
Expression callExecute = Expression.Call(
serviceStrong, mi, requestDtoStrong);
if (mi.ReturnType != typeof(void))
{
var executeFunc = Expression.Lambda<ActionInvokerFn>(
callExecute,
serviceParam,
requestDtoParam).Compile();
return executeFunc;
}
else
{
var executeFunc = Expression.Lambda<VoidActionInvokerFn>(
callExecute,
serviceParam,
requestDtoParam).Compile();
return (service, request) =>
{
executeFunc(service, request);
return null;
};
}
}
}
}

View File

@ -1,212 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Net.Mime;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.HttpServer;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Services
{
public class ServiceHandler
{
private RestPath _restPath;
private string _responseContentType;
internal ServiceHandler(RestPath restPath, string responseContentType)
{
_restPath = restPath;
_responseContentType = responseContentType;
}
protected static Task<object> CreateContentTypeRequest(HttpListenerHost host, IRequest httpReq, Type requestType, string contentType)
{
if (!string.IsNullOrEmpty(contentType) && httpReq.ContentLength > 0)
{
var deserializer = RequestHelper.GetRequestReader(host, contentType);
if (deserializer != null)
{
return deserializer.Invoke(requestType, httpReq.InputStream);
}
}
return Task.FromResult(host.CreateInstance(requestType));
}
public static string GetSanitizedPathInfo(string pathInfo, out string contentType)
{
contentType = null;
var pos = pathInfo.LastIndexOf('.');
if (pos != -1)
{
var format = pathInfo.AsSpan().Slice(pos + 1);
contentType = GetFormatContentType(format);
if (contentType != null)
{
pathInfo = pathInfo.Substring(0, pos);
}
}
return pathInfo;
}
private static string GetFormatContentType(ReadOnlySpan<char> format)
{
if (format.Equals("json", StringComparison.Ordinal))
{
return MediaTypeNames.Application.Json;
}
else if (format.Equals("xml", StringComparison.Ordinal))
{
return MediaTypeNames.Application.Xml;
}
return null;
}
public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, CancellationToken cancellationToken)
{
httpReq.Items["__route"] = _restPath;
if (_responseContentType != null)
{
httpReq.ResponseContentType = _responseContentType;
}
var request = await CreateRequest(httpHost, httpReq, _restPath).ConfigureAwait(false);
httpHost.ApplyRequestFilters(httpReq, httpRes, request);
httpRes.HttpContext.SetServiceStackRequest(httpReq);
var response = await httpHost.ServiceController.Execute(httpHost, request, httpReq).ConfigureAwait(false);
// Apply response filters
foreach (var responseFilter in httpHost.ResponseFilters)
{
responseFilter(httpReq, httpRes, response);
}
await ResponseHelper.WriteToResponse(httpRes, httpReq, response, cancellationToken).ConfigureAwait(false);
}
public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath)
{
var requestType = restPath.RequestType;
if (RequireqRequestStream(requestType))
{
// Used by IRequiresRequestStream
var requestParams = GetRequestParams(httpReq.Response.HttpContext.Request);
var request = ServiceHandler.CreateRequest(httpReq, restPath, requestParams, host.CreateInstance(requestType));
var rawReq = (IRequiresRequestStream)request;
rawReq.RequestStream = httpReq.InputStream;
return rawReq;
}
else
{
var requestParams = GetFlattenedRequestParams(httpReq.Response.HttpContext.Request);
var requestDto = await CreateContentTypeRequest(host, httpReq, restPath.RequestType, httpReq.ContentType).ConfigureAwait(false);
return CreateRequest(httpReq, restPath, requestParams, requestDto);
}
}
public static bool RequireqRequestStream(Type requestType)
{
var requiresRequestStreamTypeInfo = typeof(IRequiresRequestStream).GetTypeInfo();
return requiresRequestStreamTypeInfo.IsAssignableFrom(requestType.GetTypeInfo());
}
public static object CreateRequest(IRequest httpReq, RestPath restPath, Dictionary<string, string> requestParams, object requestDto)
{
var pathInfo = !restPath.IsWildCardPath
? GetSanitizedPathInfo(httpReq.PathInfo, out _)
: httpReq.PathInfo;
return restPath.CreateRequest(pathInfo, requestParams, requestDto);
}
/// <summary>
/// Duplicate Params are given a unique key by appending a #1 suffix
/// </summary>
private static Dictionary<string, string> GetRequestParams(HttpRequest request)
{
var map = new Dictionary<string, string>();
foreach (var pair in request.Query)
{
var values = pair.Value;
if (values.Count == 1)
{
map[pair.Key] = values[0];
}
else
{
for (var i = 0; i < values.Count; i++)
{
map[pair.Key + (i == 0 ? string.Empty : "#" + i)] = values[i];
}
}
}
if ((IsMethod(request.Method, "POST") || IsMethod(request.Method, "PUT"))
&& request.HasFormContentType)
{
foreach (var pair in request.Form)
{
var values = pair.Value;
if (values.Count == 1)
{
map[pair.Key] = values[0];
}
else
{
for (var i = 0; i < values.Count; i++)
{
map[pair.Key + (i == 0 ? string.Empty : "#" + i)] = values[i];
}
}
}
}
return map;
}
private static bool IsMethod(string method, string expected)
=> string.Equals(method, expected, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Duplicate params have their values joined together in a comma-delimited string.
/// </summary>
private static Dictionary<string, string> GetFlattenedRequestParams(HttpRequest request)
{
var map = new Dictionary<string, string>();
foreach (var pair in request.Query)
{
map[pair.Key] = pair.Value;
}
if ((IsMethod(request.Method, "POST") || IsMethod(request.Method, "PUT"))
&& request.HasFormContentType)
{
foreach (var pair in request.Form)
{
map[pair.Key] = pair.Value;
}
}
return map;
}
}
}

View File

@ -1,20 +0,0 @@
#pragma warning disable CS1591
using System;
namespace Emby.Server.Implementations.Services
{
public class ServiceMethod
{
public string Id { get; set; }
public ActionInvokerFn ServiceAction { get; set; }
public MediaBrowser.Model.Services.IHasRequestFilter[] RequestFilters { get; set; }
public static string Key(Type serviceType, string method, string requestDtoName)
{
return serviceType.FullName + " " + method.ToUpperInvariant() + " " + requestDtoName;
}
}
}

View File

@ -1,550 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.Services
{
public class RestPath
{
private const string WildCard = "*";
private const char WildCardChar = '*';
private const string PathSeperator = "/";
private const char PathSeperatorChar = '/';
private const char ComponentSeperator = '.';
private const string VariablePrefix = "{";
private readonly bool[] componentsWithSeparators;
private readonly string restPath;
public bool IsWildCardPath { get; private set; }
private readonly string[] literalsToMatch;
private readonly string[] variablesNames;
private readonly bool[] isWildcard;
private readonly int wildcardCount = 0;
internal static string[] IgnoreAttributesNamed = new[]
{
nameof(JsonIgnoreAttribute)
};
private static Type _excludeType = typeof(Stream);
public int VariableArgsCount { get; set; }
/// <summary>
/// The number of segments separated by '/' determinable by path.Split('/').Length
/// e.g. /path/to/here.ext == 3
/// </summary>
public int PathComponentsCount { get; set; }
/// <summary>
/// Gets or sets the total number of segments after subparts have been exploded ('.')
/// e.g. /path/to/here.ext == 4.
/// </summary>
public int TotalComponentsCount { get; set; }
public string[] Verbs { get; private set; }
public Type RequestType { get; private set; }
public Type ServiceType { get; private set; }
public string Path => this.restPath;
public string Summary { get; private set; }
public string Description { get; private set; }
public bool IsHidden { get; private set; }
public static string[] GetPathPartsForMatching(string pathInfo)
{
return pathInfo.ToLowerInvariant().Split(new[] { PathSeperatorChar }, StringSplitOptions.RemoveEmptyEntries);
}
public static List<string> GetFirstMatchHashKeys(string[] pathPartsForMatching)
{
var hashPrefix = pathPartsForMatching.Length + PathSeperator;
return GetPotentialMatchesWithPrefix(hashPrefix, pathPartsForMatching);
}
public static List<string> GetFirstMatchWildCardHashKeys(string[] pathPartsForMatching)
{
const string HashPrefix = WildCard + PathSeperator;
return GetPotentialMatchesWithPrefix(HashPrefix, pathPartsForMatching);
}
private static List<string> GetPotentialMatchesWithPrefix(string hashPrefix, string[] pathPartsForMatching)
{
var list = new List<string>();
foreach (var part in pathPartsForMatching)
{
list.Add(hashPrefix + part);
if (part.IndexOf(ComponentSeperator, StringComparison.Ordinal) == -1)
{
continue;
}
var subParts = part.Split(ComponentSeperator);
foreach (var subPart in subParts)
{
list.Add(hashPrefix + subPart);
}
}
return list;
}
public RestPath(Func<Type, object> createInstanceFn, Func<Type, Func<string, object>> getParseFn, Type requestType, Type serviceType, string path, string verbs, bool isHidden = false, string summary = null, string description = null)
{
this.RequestType = requestType;
this.ServiceType = serviceType;
this.Summary = summary;
this.IsHidden = isHidden;
this.Description = description;
this.restPath = path;
this.Verbs = string.IsNullOrWhiteSpace(verbs) ? ServiceExecExtensions.AllVerbs : verbs.ToUpperInvariant().Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries);
var componentsList = new List<string>();
// We only split on '.' if the restPath has them. Allows for /{action}.{type}
var hasSeparators = new List<bool>();
foreach (var component in this.restPath.Split(PathSeperatorChar))
{
if (string.IsNullOrEmpty(component))
{
continue;
}
if (component.IndexOf(VariablePrefix, StringComparison.OrdinalIgnoreCase) != -1
&& component.IndexOf(ComponentSeperator, StringComparison.Ordinal) != -1)
{
hasSeparators.Add(true);
componentsList.AddRange(component.Split(ComponentSeperator));
}
else
{
hasSeparators.Add(false);
componentsList.Add(component);
}
}
var components = componentsList.ToArray();
this.TotalComponentsCount = components.Length;
this.literalsToMatch = new string[this.TotalComponentsCount];
this.variablesNames = new string[this.TotalComponentsCount];
this.isWildcard = new bool[this.TotalComponentsCount];
this.componentsWithSeparators = hasSeparators.ToArray();
this.PathComponentsCount = this.componentsWithSeparators.Length;
string firstLiteralMatch = null;
for (var i = 0; i < components.Length; i++)
{
var component = components[i];
if (component.StartsWith(VariablePrefix, StringComparison.Ordinal))
{
var variableName = component.Substring(1, component.Length - 2);
if (variableName[variableName.Length - 1] == WildCardChar)
{
this.isWildcard[i] = true;
variableName = variableName.Substring(0, variableName.Length - 1);
}
this.variablesNames[i] = variableName;
this.VariableArgsCount++;
}
else
{
this.literalsToMatch[i] = component.ToLowerInvariant();
if (firstLiteralMatch == null)
{
firstLiteralMatch = this.literalsToMatch[i];
}
}
}
for (var i = 0; i < components.Length - 1; i++)
{
if (!this.isWildcard[i])
{
continue;
}
if (this.literalsToMatch[i + 1] == null)
{
throw new ArgumentException(
"A wildcard path component must be at the end of the path or followed by a literal path component.");
}
}
this.wildcardCount = this.isWildcard.Length;
this.IsWildCardPath = this.wildcardCount > 0;
this.FirstMatchHashKey = !this.IsWildCardPath
? this.PathComponentsCount + PathSeperator + firstLiteralMatch
: WildCardChar + PathSeperator + firstLiteralMatch;
this.typeDeserializer = new StringMapTypeDeserializer(createInstanceFn, getParseFn, this.RequestType);
_propertyNamesMap = new HashSet<string>(
GetSerializableProperties(RequestType).Select(x => x.Name),
StringComparer.OrdinalIgnoreCase);
}
internal static IEnumerable<PropertyInfo> GetSerializableProperties(Type type)
{
foreach (var prop in GetPublicProperties(type))
{
if (prop.GetMethod == null
|| _excludeType == prop.PropertyType)
{
continue;
}
var ignored = false;
foreach (var attr in prop.GetCustomAttributes(true))
{
if (IgnoreAttributesNamed.Contains(attr.GetType().Name))
{
ignored = true;
break;
}
}
if (!ignored)
{
yield return prop;
}
}
}
private static IEnumerable<PropertyInfo> GetPublicProperties(Type type)
{
if (type.IsInterface)
{
var propertyInfos = new List<PropertyInfo>();
var considered = new List<Type>()
{
type
};
var queue = new Queue<Type>();
queue.Enqueue(type);
while (queue.Count > 0)
{
var subType = queue.Dequeue();
foreach (var subInterface in subType.GetTypeInfo().ImplementedInterfaces)
{
if (considered.Contains(subInterface))
{
continue;
}
considered.Add(subInterface);
queue.Enqueue(subInterface);
}
var newPropertyInfos = GetTypesPublicProperties(subType)
.Where(x => !propertyInfos.Contains(x));
propertyInfos.InsertRange(0, newPropertyInfos);
}
return propertyInfos;
}
return GetTypesPublicProperties(type)
.Where(x => x.GetIndexParameters().Length == 0);
}
private static IEnumerable<PropertyInfo> GetTypesPublicProperties(Type subType)
{
foreach (var pi in subType.GetRuntimeProperties())
{
var mi = pi.GetMethod ?? pi.SetMethod;
if (mi != null && mi.IsStatic)
{
continue;
}
yield return pi;
}
}
/// <summary>
/// Provide for quick lookups based on hashes that can be determined from a request url.
/// </summary>
public string FirstMatchHashKey { get; private set; }
private readonly StringMapTypeDeserializer typeDeserializer;
private readonly HashSet<string> _propertyNamesMap;
public int MatchScore(string httpMethod, string[] withPathInfoParts)
{
var isMatch = IsMatch(httpMethod, withPathInfoParts, out var wildcardMatchCount);
if (!isMatch)
{
return -1;
}
// Routes with least wildcard matches get the highest score
var score = Math.Max(100 - wildcardMatchCount, 1) * 1000
// Routes with less variable (and more literal) matches
+ Math.Max(10 - VariableArgsCount, 1) * 100;
// Exact verb match is better than ANY
if (Verbs.Length == 1 && string.Equals(httpMethod, Verbs[0], StringComparison.OrdinalIgnoreCase))
{
score += 10;
}
else
{
score += 1;
}
return score;
}
/// <summary>
/// For performance withPathInfoParts should already be a lower case string
/// to minimize redundant matching operations.
/// </summary>
public bool IsMatch(string httpMethod, string[] withPathInfoParts, out int wildcardMatchCount)
{
wildcardMatchCount = 0;
if (withPathInfoParts.Length != this.PathComponentsCount && !this.IsWildCardPath)
{
return false;
}
if (!Verbs.Contains(httpMethod, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!ExplodeComponents(ref withPathInfoParts))
{
return false;
}
if (this.TotalComponentsCount != withPathInfoParts.Length && !this.IsWildCardPath)
{
return false;
}
int pathIx = 0;
for (var i = 0; i < this.TotalComponentsCount; i++)
{
if (this.isWildcard[i])
{
if (i < this.TotalComponentsCount - 1)
{
// Continue to consume up until a match with the next literal
while (pathIx < withPathInfoParts.Length
&& !string.Equals(withPathInfoParts[pathIx], this.literalsToMatch[i + 1], StringComparison.InvariantCultureIgnoreCase))
{
pathIx++;
wildcardMatchCount++;
}
// Ensure there are still enough parts left to match the remainder
if ((withPathInfoParts.Length - pathIx) < (this.TotalComponentsCount - i - 1))
{
return false;
}
}
else
{
// A wildcard at the end matches the remainder of path
wildcardMatchCount += withPathInfoParts.Length - pathIx;
pathIx = withPathInfoParts.Length;
}
}
else
{
var literalToMatch = this.literalsToMatch[i];
if (literalToMatch == null)
{
// Matching an ordinary (non-wildcard) variable consumes a single part
pathIx++;
continue;
}
if (withPathInfoParts.Length <= pathIx
|| !string.Equals(withPathInfoParts[pathIx], literalToMatch, StringComparison.InvariantCultureIgnoreCase))
{
return false;
}
pathIx++;
}
}
return pathIx == withPathInfoParts.Length;
}
private bool ExplodeComponents(ref string[] withPathInfoParts)
{
var totalComponents = new List<string>();
for (var i = 0; i < withPathInfoParts.Length; i++)
{
var component = withPathInfoParts[i];
if (string.IsNullOrEmpty(component))
{
continue;
}
if (this.PathComponentsCount != this.TotalComponentsCount
&& this.componentsWithSeparators[i])
{
var subComponents = component.Split(ComponentSeperator);
if (subComponents.Length < 2)
{
return false;
}
totalComponents.AddRange(subComponents);
}
else
{
totalComponents.Add(component);
}
}
withPathInfoParts = totalComponents.ToArray();
return true;
}
public object CreateRequest(string pathInfo, Dictionary<string, string> queryStringAndFormData, object fromInstance)
{
var requestComponents = pathInfo.Split(new[] { PathSeperatorChar }, StringSplitOptions.RemoveEmptyEntries);
ExplodeComponents(ref requestComponents);
if (requestComponents.Length != this.TotalComponentsCount)
{
var isValidWildCardPath = this.IsWildCardPath
&& requestComponents.Length >= this.TotalComponentsCount - this.wildcardCount;
if (!isValidWildCardPath)
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"Path Mismatch: Request Path '{0}' has invalid number of components compared to: '{1}'",
pathInfo,
this.restPath));
}
}
var requestKeyValuesMap = new Dictionary<string, string>();
var pathIx = 0;
for (var i = 0; i < this.TotalComponentsCount; i++)
{
var variableName = this.variablesNames[i];
if (variableName == null)
{
pathIx++;
continue;
}
if (!this._propertyNamesMap.Contains(variableName))
{
if (string.Equals("ignore", variableName, StringComparison.OrdinalIgnoreCase))
{
pathIx++;
continue;
}
throw new ArgumentException("Could not find property "
+ variableName + " on " + RequestType.GetMethodName());
}
var value = requestComponents.Length > pathIx ? requestComponents[pathIx] : null; // wildcard has arg mismatch
if (value != null && this.isWildcard[i])
{
if (i == this.TotalComponentsCount - 1)
{
// Wildcard at end of path definition consumes all the rest
var sb = new StringBuilder();
sb.Append(value);
for (var j = pathIx + 1; j < requestComponents.Length; j++)
{
sb.Append(PathSeperatorChar)
.Append(requestComponents[j]);
}
value = sb.ToString();
}
else
{
// Wildcard in middle of path definition consumes up until it
// hits a match for the next element in the definition (which must be a literal)
// It may consume 0 or more path parts
var stopLiteral = i == this.TotalComponentsCount - 1 ? null : this.literalsToMatch[i + 1];
if (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase))
{
var sb = new StringBuilder(value);
pathIx++;
while (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase))
{
sb.Append(PathSeperatorChar)
.Append(requestComponents[pathIx++]);
}
value = sb.ToString();
}
else
{
value = null;
}
}
}
else
{
// Variable consumes single path item
pathIx++;
}
requestKeyValuesMap[variableName] = value;
}
if (queryStringAndFormData != null)
{
// Query String and form data can override variable path matches
// path variables < query string < form data
foreach (var name in queryStringAndFormData)
{
requestKeyValuesMap[name.Key] = name.Value;
}
}
return this.typeDeserializer.PopulateFromMap(fromInstance, requestKeyValuesMap);
}
public class RestPathMap : SortedDictionary<string, List<RestPath>>
{
public RestPathMap() : base(StringComparer.OrdinalIgnoreCase)
{
}
}
}
}

View File

@ -1,118 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Reflection;
using MediaBrowser.Common.Extensions;
namespace Emby.Server.Implementations.Services
{
/// <summary>
/// Serializer cache of delegates required to create a type from a string map (e.g. for REST urls)
/// </summary>
public class StringMapTypeDeserializer
{
internal class PropertySerializerEntry
{
public PropertySerializerEntry(Action<object, object> propertySetFn, Func<string, object> propertyParseStringFn, Type propertyType)
{
PropertySetFn = propertySetFn;
PropertyParseStringFn = propertyParseStringFn;
PropertyType = propertyType;
}
public Action<object, object> PropertySetFn { get; private set; }
public Func<string, object> PropertyParseStringFn { get; private set; }
public Type PropertyType { get; private set; }
}
private readonly Type type;
private readonly Dictionary<string, PropertySerializerEntry> propertySetterMap
= new Dictionary<string, PropertySerializerEntry>(StringComparer.OrdinalIgnoreCase);
public Func<string, object> GetParseFn(Type propertyType)
{
if (propertyType == typeof(string))
{
return s => s;
}
return _GetParseFn(propertyType);
}
private readonly Func<Type, object> _CreateInstanceFn;
private readonly Func<Type, Func<string, object>> _GetParseFn;
public StringMapTypeDeserializer(Func<Type, object> createInstanceFn, Func<Type, Func<string, object>> getParseFn, Type type)
{
_CreateInstanceFn = createInstanceFn;
_GetParseFn = getParseFn;
this.type = type;
foreach (var propertyInfo in RestPath.GetSerializableProperties(type))
{
var propertySetFn = TypeAccessor.GetSetPropertyMethod(propertyInfo);
var propertyType = propertyInfo.PropertyType;
var propertyParseStringFn = GetParseFn(propertyType);
var propertySerializer = new PropertySerializerEntry(propertySetFn, propertyParseStringFn, propertyType);
propertySetterMap[propertyInfo.Name] = propertySerializer;
}
}
public object PopulateFromMap(object instance, IDictionary<string, string> keyValuePairs)
{
PropertySerializerEntry propertySerializerEntry = null;
if (instance == null)
{
instance = _CreateInstanceFn(type);
}
foreach (var pair in keyValuePairs)
{
string propertyName = pair.Key;
string propertyTextValue = pair.Value;
if (propertyTextValue == null
|| !propertySetterMap.TryGetValue(propertyName, out propertySerializerEntry)
|| propertySerializerEntry.PropertySetFn == null)
{
continue;
}
if (propertySerializerEntry.PropertyType == typeof(bool))
{
// InputExtensions.cs#530 MVC Checkbox helper emits extra hidden input field, generating 2 values, first is the real value
propertyTextValue = StringExtensions.LeftPart(propertyTextValue, ',').ToString();
}
var value = propertySerializerEntry.PropertyParseStringFn(propertyTextValue);
if (value == null)
{
continue;
}
propertySerializerEntry.PropertySetFn(instance, value);
}
return instance;
}
}
internal static class TypeAccessor
{
public static Action<object, object> GetSetPropertyMethod(PropertyInfo propertyInfo)
{
if (!propertyInfo.CanWrite || propertyInfo.GetIndexParameters().Length > 0)
{
return null;
}
var setMethodInfo = propertyInfo.SetMethod;
return (instance, value) => setMethodInfo.Invoke(instance, new[] { value });
}
}
}

View File

@ -1,27 +0,0 @@
#pragma warning disable CS1591
using System;
using MediaBrowser.Common.Extensions;
namespace Emby.Server.Implementations.Services
{
/// <summary>
/// Donated by Ivan Korneliuk from his post:
/// http://korneliuk.blogspot.com/2012/08/servicestack-reusing-dtos.html
///
/// Modified to only allow using routes matching the supplied HTTP Verb.
/// </summary>
public static class UrlExtensions
{
public static string GetMethodName(this Type type)
{
var typeName = type.FullName != null // can be null, e.g. generic types
? StringExtensions.LeftPart(type.FullName, "[[", StringComparison.Ordinal).ToString() // Generic Fullname
.Replace(type.Namespace + ".", string.Empty, StringComparison.Ordinal) // Trim Namespaces
.Replace("+", ".", StringComparison.Ordinal) // Convert nested into normal type
: type.Name;
return type.IsGenericParameter ? "'" + typeName : typeName;
}
}
}

View File

@ -1429,6 +1429,24 @@ namespace Emby.Server.Implementations.Session
return AuthenticateNewSessionInternal(request, false);
}
public Task<AuthenticationResult> AuthenticateQuickConnect(AuthenticationRequest request, string token)
{
var result = _authRepo.Get(new AuthenticationInfoQuery()
{
AccessToken = token,
DeviceId = _appHost.SystemId,
Limit = 1
});
if (result.TotalRecordCount == 0)
{
throw new SecurityException("Unknown quick connect token");
}
request.UserId = result.Items[0].UserId;
return AuthenticateNewSessionInternal(request, false);
}
private async Task<AuthenticationResult> AuthenticateNewSessionInternal(AuthenticationRequest request, bool enforcePassword)
{
CheckDisposed();

View File

@ -44,7 +44,7 @@ namespace Emby.Server.Implementations.Session
private readonly ILogger<SessionWebSocketListener> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpServer _httpServer;
private readonly IWebSocketManager _webSocketManager;
/// <summary>
/// The KeepAlive cancellation token.
@ -72,19 +72,19 @@ namespace Emby.Server.Implementations.Session
/// <param name="logger">The logger.</param>
/// <param name="sessionManager">The session manager.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpServer">The HTTP server.</param>
/// <param name="webSocketManager">The HTTP server.</param>
public SessionWebSocketListener(
ILogger<SessionWebSocketListener> logger,
ISessionManager sessionManager,
ILoggerFactory loggerFactory,
IHttpServer httpServer)
IWebSocketManager webSocketManager)
{
_logger = logger;
_sessionManager = sessionManager;
_loggerFactory = loggerFactory;
_httpServer = httpServer;
_webSocketManager = webSocketManager;
httpServer.WebSocketConnected += OnServerManagerWebSocketConnected;
webSocketManager.WebSocketConnected += OnServerManagerWebSocketConnected;
}
private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
@ -121,7 +121,7 @@ namespace Emby.Server.Implementations.Session
/// <inheritdoc />
public void Dispose()
{
_httpServer.WebSocketConnected -= OnServerManagerWebSocketConnected;
_webSocketManager.WebSocketConnected -= OnServerManagerWebSocketConnected;
StopKeepAlive();
}

View File

@ -1,248 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Mime;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using IHttpRequest = MediaBrowser.Model.Services.IHttpRequest;
namespace Emby.Server.Implementations.SocketSharp
{
public class WebSocketSharpRequest : IHttpRequest
{
private const string FormUrlEncoded = "application/x-www-form-urlencoded";
private const string MultiPartFormData = "multipart/form-data";
private const string Soap11 = "text/xml; charset=utf-8";
private string _remoteIp;
private Dictionary<string, object> _items;
private string _responseContentType;
public WebSocketSharpRequest(HttpRequest httpRequest, HttpResponse httpResponse, string operationName)
{
this.OperationName = operationName;
this.Request = httpRequest;
this.Response = httpResponse;
}
public string Accept => StringValues.IsNullOrEmpty(Request.Headers[HeaderNames.Accept]) ? null : Request.Headers[HeaderNames.Accept].ToString();
public string Authorization => StringValues.IsNullOrEmpty(Request.Headers[HeaderNames.Authorization]) ? null : Request.Headers[HeaderNames.Authorization].ToString();
public HttpRequest Request { get; }
public HttpResponse Response { get; }
public string OperationName { get; set; }
public string RawUrl => Request.GetEncodedPathAndQuery();
public string AbsoluteUri => Request.GetDisplayUrl().TrimEnd('/');
public string RemoteIp
{
get
{
if (_remoteIp != null)
{
return _remoteIp;
}
IPAddress ip;
// "Real" remote ip might be in X-Forwarded-For of X-Real-Ip
// (if the server is behind a reverse proxy for example)
if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XForwardedFor), out ip))
{
if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XRealIP), out ip))
{
ip = Request.HttpContext.Connection.RemoteIpAddress;
// Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests)
ip ??= IPAddress.Loopback;
}
}
return _remoteIp = NormalizeIp(ip).ToString();
}
}
public string[] AcceptTypes => Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
public Dictionary<string, object> Items => _items ?? (_items = new Dictionary<string, object>());
public string ResponseContentType
{
get =>
_responseContentType
?? (_responseContentType = GetResponseContentType(Request));
set => _responseContentType = value;
}
public string PathInfo => Request.Path.Value;
public string UserAgent => Request.Headers[HeaderNames.UserAgent];
public IHeaderDictionary Headers => Request.Headers;
public IQueryCollection QueryString => Request.Query;
public bool IsLocal =>
(Request.HttpContext.Connection.LocalIpAddress == null
&& Request.HttpContext.Connection.RemoteIpAddress == null)
|| Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress);
public string HttpMethod => Request.Method;
public string Verb => HttpMethod;
public string ContentType => Request.ContentType;
public Uri UrlReferrer => Request.GetTypedHeaders().Referer;
public Stream InputStream => Request.Body;
public long ContentLength => Request.ContentLength ?? 0;
private string GetHeader(string name) => Request.Headers[name].ToString();
private static IPAddress NormalizeIp(IPAddress ip)
{
if (ip.IsIPv4MappedToIPv6)
{
return ip.MapToIPv4();
}
return ip;
}
public static string GetResponseContentType(HttpRequest httpReq)
{
var specifiedContentType = GetQueryStringContentType(httpReq);
if (!string.IsNullOrEmpty(specifiedContentType))
{
return specifiedContentType;
}
const string ServerDefaultContentType = MediaTypeNames.Application.Json;
var acceptContentTypes = httpReq.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
string defaultContentType = null;
if (HasAnyOfContentTypes(httpReq, FormUrlEncoded, MultiPartFormData))
{
defaultContentType = ServerDefaultContentType;
}
var acceptsAnything = false;
var hasDefaultContentType = defaultContentType != null;
if (acceptContentTypes != null)
{
foreach (ReadOnlySpan<char> acceptsType in acceptContentTypes)
{
ReadOnlySpan<char> contentType = acceptsType;
var index = contentType.IndexOf(';');
if (index != -1)
{
contentType = contentType.Slice(0, index);
}
contentType = contentType.Trim();
acceptsAnything = contentType.Equals("*/*", StringComparison.OrdinalIgnoreCase);
if (acceptsAnything)
{
break;
}
}
if (acceptsAnything)
{
if (hasDefaultContentType)
{
return defaultContentType;
}
else
{
return ServerDefaultContentType;
}
}
}
if (acceptContentTypes == null && httpReq.ContentType == Soap11)
{
return Soap11;
}
// We could also send a '406 Not Acceptable', but this is allowed also
return ServerDefaultContentType;
}
public static bool HasAnyOfContentTypes(HttpRequest request, params string[] contentTypes)
{
if (contentTypes == null || request.ContentType == null)
{
return false;
}
foreach (var contentType in contentTypes)
{
if (IsContentType(request, contentType))
{
return true;
}
}
return false;
}
public static bool IsContentType(HttpRequest request, string contentType)
{
return request.ContentType.StartsWith(contentType, StringComparison.OrdinalIgnoreCase);
}
private static string GetQueryStringContentType(HttpRequest httpReq)
{
ReadOnlySpan<char> format = httpReq.Query["format"].ToString();
if (format == ReadOnlySpan<char>.Empty)
{
const int FormatMaxLength = 4;
ReadOnlySpan<char> pi = httpReq.Path.ToString();
if (pi == null || pi.Length <= FormatMaxLength)
{
return null;
}
if (pi[0] == '/')
{
pi = pi.Slice(1);
}
format = pi.LeftPart('/');
if (format.Length > FormatMaxLength)
{
return null;
}
}
format = format.LeftPart('.');
if (format.Contains("json", StringComparison.OrdinalIgnoreCase))
{
return "application/json";
}
else if (format.Contains("xml", StringComparison.OrdinalIgnoreCase))
{
return "application/xml";
}
return null;
}
}
}

View File

@ -0,0 +1,154 @@
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.QuickConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Api.Controllers
{
/// <summary>
/// Quick connect controller.
/// </summary>
public class QuickConnectController : BaseJellyfinApiController
{
private readonly IQuickConnect _quickConnect;
/// <summary>
/// Initializes a new instance of the <see cref="QuickConnectController"/> class.
/// </summary>
/// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param>
public QuickConnectController(IQuickConnect quickConnect)
{
_quickConnect = quickConnect;
}
/// <summary>
/// Gets the current quick connect state.
/// </summary>
/// <response code="200">Quick connect state returned.</response>
/// <returns>The current <see cref="QuickConnectState"/>.</returns>
[HttpGet("Status")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QuickConnectState> GetStatus()
{
_quickConnect.ExpireRequests();
return _quickConnect.State;
}
/// <summary>
/// Initiate a new quick connect request.
/// </summary>
/// <response code="200">Quick connect request successfully created.</response>
/// <response code="401">Quick connect is not active on this server.</response>
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
[HttpGet("Initiate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QuickConnectResult> Initiate()
{
return _quickConnect.TryConnect();
}
/// <summary>
/// Attempts to retrieve authentication information.
/// </summary>
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
/// <response code="200">Quick connect result returned.</response>
/// <response code="404">Unknown quick connect secret.</response>
/// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
[HttpGet("Connect")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QuickConnectResult> Connect([FromQuery, Required] string secret)
{
try
{
return _quickConnect.CheckRequestStatus(secret);
}
catch (ResourceNotFoundException)
{
return NotFound("Unknown secret");
}
}
/// <summary>
/// Temporarily activates quick connect for five minutes.
/// </summary>
/// <response code="204">Quick connect has been temporarily activated.</response>
/// <response code="403">Quick connect is unavailable on this server.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("Activate")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult Activate()
{
if (_quickConnect.State == QuickConnectState.Unavailable)
{
return Forbid("Quick connect is unavailable");
}
_quickConnect.Activate();
return NoContent();
}
/// <summary>
/// Enables or disables quick connect.
/// </summary>
/// <param name="status">New <see cref="QuickConnectState"/>.</param>
/// <response code="204">Quick connect state set successfully.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("Available")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult Available([FromQuery] QuickConnectState status = QuickConnectState.Available)
{
_quickConnect.SetState(status);
return NoContent();
}
/// <summary>
/// Authorizes a pending quick connect request.
/// </summary>
/// <param name="code">Quick connect code to authorize.</param>
/// <response code="200">Quick connect result authorized successfully.</response>
/// <response code="403">Unknown user id.</response>
/// <returns>Boolean indicating if the authorization was successful.</returns>
[HttpPost("Authorize")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult<bool> Authorize([FromQuery, Required] string code)
{
var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
if (!userId.HasValue)
{
return Forbid("Unknown user id");
}
return _quickConnect.AuthorizeRequest(userId.Value, code);
}
/// <summary>
/// Deauthorize all quick connect devices for the current user.
/// </summary>
/// <response code="200">All quick connect devices were deleted.</response>
/// <returns>The number of devices that were deleted.</returns>
[HttpPost("Deauthorize")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<int> Deauthorize()
{
var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
if (!userId.HasValue)
{
return 0;
}
return _quickConnect.DeleteAllDevices(userId.Value);
}
}
}

View File

@ -216,6 +216,40 @@ namespace Jellyfin.Api.Controllers
}
}
/// <summary>
/// Authenticates a user with quick connect.
/// </summary>
/// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
/// <response code="200">User authenticated.</response>
/// <response code="400">Missing token.</response>
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
[HttpPost("AuthenticateWithQuickConnect")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<AuthenticationResult>> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
{
var auth = _authContext.GetAuthorizationInfo(Request);
try
{
var authRequest = new AuthenticationRequest
{
App = auth.Client,
AppVersion = auth.Version,
DeviceId = auth.DeviceId,
DeviceName = auth.Device,
};
return await _sessionManager.AuthenticateQuickConnect(
authRequest,
request.Token).ConfigureAwait(false);
}
catch (SecurityException e)
{
// rethrow adding IP address to message
throw new SecurityException($"[{HttpContext.Connection.RemoteIpAddress}] {e.Message}", e);
}
}
/// <summary>
/// Updates a user's password.
/// </summary>

View File

@ -233,7 +233,7 @@ namespace Jellyfin.Api.Controllers
.First();
}
var list = primaryVersion.LinkedAlternateVersions.ToList();
var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList();
foreach (var item in items.Where(i => i.Id != primaryVersion.Id))
{
@ -241,17 +241,20 @@ namespace Jellyfin.Api.Controllers
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
list.Add(new LinkedChild
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase)))
{
alternateVersionsOfPrimary.Add(new LinkedChild
{
Path = item.Path,
ItemId = item.Id
});
}
foreach (var linkedItem in item.LinkedAlternateVersions)
{
if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
{
list.Add(linkedItem);
alternateVersionsOfPrimary.Add(linkedItem);
}
}
@ -262,7 +265,7 @@ namespace Jellyfin.Api.Controllers
}
}
primaryVersion.LinkedAlternateVersions = list.ToArray();
primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray();
await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
return NoContent();
}

View File

@ -130,6 +130,8 @@ namespace Jellyfin.Api.Helpers
private async Task<int> CopyToInternalAsync(Stream source, Stream destination, bool readAsync, CancellationToken cancellationToken)
{
var array = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize);
try
{
int bytesRead;
int totalBytesRead = 0;
@ -171,5 +173,10 @@ namespace Jellyfin.Api.Helpers
return totalBytesRead;
}
finally
{
ArrayPool<byte>.Shared.Return(array);
}
}
}
}

View File

@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Jellyfin.Api.Models.UserDtos
{
/// <summary>
/// The quick connect request body.
/// </summary>
public class QuickConnectDto
{
/// <summary>
/// Gets or sets the quick connect token.
/// </summary>
[Required]
public string? Token { get; set; }
}
}

View File

@ -5,6 +5,15 @@
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
<PropertyGroup>
@ -19,6 +28,10 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
</ItemGroup>
<!-- Code analysers-->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />

View File

@ -1,3 +1,4 @@
using Jellyfin.Server.Middleware;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
@ -46,5 +47,55 @@ namespace Jellyfin.Server.Extensions
c.RoutePrefix = $"{baseUrl}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>();
}
}
}

View File

@ -18,6 +18,7 @@ using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
using Jellyfin.Server.Formatters;
using Jellyfin.Server.Models;
using MediaBrowser.Common;
using MediaBrowser.Common.Json;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authentication;
@ -135,10 +136,11 @@ namespace Jellyfin.Server.Extensions
/// </summary>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="baseUrl">The base url for the API.</param>
/// <param name="pluginAssemblies">An IEnumberable containing all plugin assemblies with API controllers.</param>
/// <returns>The MVC builder.</returns>
public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl)
public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl, IEnumerable<Assembly> pluginAssemblies)
{
return serviceCollection
IMvcBuilder mvcBuilder = serviceCollection
.AddCors(options =>
{
options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy);
@ -179,8 +181,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>

View File

@ -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);
}
}
}

View File

@ -0,0 +1,76 @@
using System.Linq;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Server.Middleware
{
/// <summary>
/// Validates the IP of requests coming from local networks wrt. remote access.
/// </summary>
public class IpBasedAccessValidationMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class.
/// </summary>
/// <param name="next">The next delegate in the pipeline.</param>
public IpBasedAccessValidationMiddleware(RequestDelegate next)
{
_next = next;
}
/// <summary>
/// Executes the middleware action.
/// </summary>
/// <param name="httpContext">The current HTTP context.</param>
/// <param name="networkManager">The network manager.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
/// <returns>The async task.</returns>
public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
{
if (httpContext.Request.IsLocal())
{
await _next(httpContext).ConfigureAwait(false);
return;
}
var remoteIp = httpContext.Request.RemoteIp();
if (serverConfigurationManager.Configuration.EnableRemoteAccess)
{
var addressFilter = serverConfigurationManager.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
if (addressFilter.Length > 0 && !networkManager.IsInLocalNetwork(remoteIp))
{
if (serverConfigurationManager.Configuration.IsRemoteIPFilterBlacklist)
{
if (networkManager.IsAddressInSubnets(remoteIp, addressFilter))
{
return;
}
}
else
{
if (!networkManager.IsAddressInSubnets(remoteIp, addressFilter))
{
return;
}
}
}
}
else
{
if (!networkManager.IsInLocalNetwork(remoteIp))
{
return;
}
}
await _next(httpContext).ConfigureAwait(false);
}
}
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,49 @@
using System.Net.Mime;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Model.Globalization;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Server.Middleware
{
/// <summary>
/// Shows a custom message during server startup.
/// </summary>
public class ServerStartupMessageMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class.
/// </summary>
/// <param name="next">The next delegate in the pipeline.</param>
public ServerStartupMessageMiddleware(RequestDelegate next)
{
_next = next;
}
/// <summary>
/// Executes the middleware action.
/// </summary>
/// <param name="httpContext">The current HTTP context.</param>
/// <param name="serverApplicationHost">The server application host.</param>
/// <param name="localizationManager">The localization manager.</param>
/// <returns>The async task.</returns>
public async Task Invoke(
HttpContext httpContext,
IServerApplicationHost serverApplicationHost,
ILocalizationManager localizationManager)
{
if (serverApplicationHost.CoreStartupHasCompleted)
{
await _next(httpContext).ConfigureAwait(false);
return;
}
var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
httpContext.Response.ContentType = MediaTypeNames.Text.Html;
await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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
@ -594,7 +594,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

View File

@ -1,9 +1,12 @@
using System;
using System.ComponentModel;
using System.Net.Http.Headers;
using Jellyfin.Api.TypeConverters;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Middleware;
using Jellyfin.Server.Models;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
@ -20,14 +23,19 @@ namespace Jellyfin.Server
public class Startup
{
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IServerApplicationHost _serverApplicationHost;
/// <summary>
/// Initializes a new instance of the <see cref="Startup" /> class.
/// </summary>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
public Startup(IServerConfigurationManager serverConfigurationManager)
/// <param name="serverApplicationHost">The server application host.</param>
public Startup(
IServerConfigurationManager serverConfigurationManager,
IServerApplicationHost serverApplicationHost)
{
_serverConfigurationManager = serverConfigurationManager;
_serverApplicationHost = serverApplicationHost;
}
/// <summary>
@ -38,7 +46,13 @@ namespace Jellyfin.Server
{
services.AddResponseCompression();
services.AddHttpContextAccessor();
services.AddJellyfinApi(_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/'));
services.AddHttpsRedirection(options =>
{
options.HttpsPort = _serverApplicationHost.HttpsPort;
});
services.AddJellyfinApi(
_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/'),
_serverApplicationHost.GetApiPluginAssemblies());
services.AddJellyfinApiSwagger();
@ -46,7 +60,23 @@ 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());
}
/// <summary>
@ -54,11 +84,9 @@ namespace Jellyfin.Server
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="env">The webhost environment.</param>
/// <param name="serverApplicationHost">The server application host.</param>
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IServerApplicationHost serverApplicationHost)
IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
@ -73,12 +101,17 @@ namespace Jellyfin.Server
app.UseResponseCompression();
// TODO app.UseMiddleware<WebSocketMiddleware>();
app.UseCors(ServerCorsPolicy.DefaultPolicyName);
if (_serverConfigurationManager.Configuration.RequireHttps
&& _serverApplicationHost.ListenWithHttps)
{
app.UseHttpsRedirection();
}
app.UseAuthentication();
app.UseJellyfinApiSwagger(_serverConfigurationManager);
app.UseRouting();
app.UseCors(ServerCorsPolicy.DefaultPolicyName);
app.UseAuthorization();
if (_serverConfigurationManager.Configuration.EnableMetrics)
{
@ -86,6 +119,12 @@ namespace Jellyfin.Server
app.UseHttpMetrics();
}
app.UseLanFiltering();
app.UseIpBasedAccessValidation();
app.UseBaseUrlRedirection();
app.UseWebSocketHandler();
app.UseServerStartupMessage();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
@ -95,8 +134,6 @@ namespace Jellyfin.Server
}
});
app.Use(serverApplicationHost.ExecuteHttpHandlerAsync);
// Add type descriptor for legacy datetime parsing.
TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter)));
}

View File

@ -1,4 +1,5 @@
using MediaBrowser.Model.Services;
using System.Net;
using MediaBrowser.Common.Net;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Common.Extensions
@ -8,26 +9,55 @@ namespace MediaBrowser.Common.Extensions
/// </summary>
public static class HttpContextExtensions
{
private const string ServiceStackRequest = "ServiceStackRequest";
/// <summary>
/// Set the ServiceStack request.
/// Checks the origin of the HTTP request.
/// </summary>
/// <param name="httpContext">The HttpContext instance.</param>
/// <param name="request">The service stack request instance.</param>
public static void SetServiceStackRequest(this HttpContext httpContext, IRequest request)
/// <param name="request">The incoming HTTP request.</param>
/// <returns><c>true</c> if the request is coming from LAN, <c>false</c> otherwise.</returns>
public static bool IsLocal(this HttpRequest request)
{
httpContext.Items[ServiceStackRequest] = request;
return (request.HttpContext.Connection.LocalIpAddress == null
&& request.HttpContext.Connection.RemoteIpAddress == null)
|| request.HttpContext.Connection.LocalIpAddress.Equals(request.HttpContext.Connection.RemoteIpAddress);
}
/// <summary>
/// Get the ServiceStack request.
/// Extracts the remote IP address of the caller of the HTTP request.
/// </summary>
/// <param name="httpContext">The HttpContext instance.</param>
/// <returns>The service stack request instance.</returns>
public static IRequest GetServiceStackRequest(this HttpContext httpContext)
/// <param name="request">The HTTP request.</param>
/// <returns>The remote caller IP address.</returns>
public static string RemoteIp(this HttpRequest request)
{
return (IRequest)httpContext.Items[ServiceStackRequest];
var cachedRemoteIp = request.HttpContext.Items["RemoteIp"]?.ToString();
if (!string.IsNullOrEmpty(cachedRemoteIp))
{
return cachedRemoteIp;
}
IPAddress ip;
// "Real" remote ip might be in X-Forwarded-For of X-Real-Ip
// (if the server is behind a reverse proxy for example)
if (!IPAddress.TryParse(request.Headers[CustomHeaderNames.XForwardedFor].ToString(), out ip))
{
if (!IPAddress.TryParse(request.Headers[CustomHeaderNames.XRealIP].ToString(), out ip))
{
ip = request.HttpContext.Connection.RemoteIpAddress;
// Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests)
ip ??= IPAddress.Loopback;
}
}
if (ip.IsIPv4MappedToIPv6)
{
ip = ip.MapToIPv4();
}
var normalizedIp = ip.ToString();
request.HttpContext.Items["RemoteIp"] = normalizedIp;
return normalizedIp;
}
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using MediaBrowser.Common.Plugins;
using Microsoft.Extensions.DependencyInjection;
@ -76,6 +77,12 @@ namespace MediaBrowser.Common
/// <value>The plugins.</value>
IReadOnlyList<IPlugin> Plugins { get; }
/// <summary>
/// Gets all plugin assemblies which implement a custom rest api.
/// </summary>
/// <returns>An <see cref="IEnumerable{Assembly}"/> containing the plugin assemblies.</returns>
IEnumerable<Assembly> GetApiPluginAssemblies();
/// <summary>
/// Notifies the pending restart.
/// </summary>

View File

@ -0,0 +1,44 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MediaBrowser.Common.Json.Converters
{
/// <summary>
/// Converts a nullable struct or value to/from JSON.
/// Required - some clients send an empty string.
/// </summary>
/// <typeparam name="T">The struct type.</typeparam>
public class JsonNullableStructConverter<T> : JsonConverter<T?>
where T : struct
{
private readonly JsonConverter<T?> _baseJsonConverter;
/// <summary>
/// Initializes a new instance of the <see cref="JsonNullableStructConverter{T}"/> class.
/// </summary>
/// <param name="baseJsonConverter">The base json converter.</param>
public JsonNullableStructConverter(JsonConverter<T?> baseJsonConverter)
{
_baseJsonConverter = baseJsonConverter;
}
/// <inheritdoc />
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Handle empty string.
if (reader.TokenType == JsonTokenType.String && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty) || reader.ValueSpan.IsEmpty))
{
return null;
}
return _baseJsonConverter.Read(ref reader, typeToConvert, options);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
_baseJsonConverter.Write(writer, value, options);
}
}
}

View File

@ -29,8 +29,14 @@ namespace MediaBrowser.Common.Json
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
// Get built-in converters for fallback converting.
var baseNullableInt32Converter = (JsonConverter<int?>)options.GetConverter(typeof(int?));
var baseNullableInt64Converter = (JsonConverter<long?>)options.GetConverter(typeof(long?));
options.Converters.Add(new JsonGuidConverter());
options.Converters.Add(new JsonStringEnumConverter());
options.Converters.Add(new JsonNullableStructConverter<int>(baseNullableInt32Converter));
options.Converters.Add(new JsonNullableStructConverter<long>(baseNullableInt64Converter));
return options;
}

View File

@ -20,6 +20,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.7" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="3.1.7" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
</ItemGroup>
@ -32,6 +33,15 @@
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
<!-- Code analyzers-->

View File

@ -0,0 +1,20 @@
using System.Net;
using System.Net.Http;
namespace MediaBrowser.Common.Net
{
/// <summary>
/// Default http client handler.
/// </summary>
public class DefaultHttpClientHandler : HttpClientHandler
{
/// <summary>
/// Initializes a new instance of the <see cref="DefaultHttpClientHandler"/> class.
/// </summary>
public DefaultHttpClientHandler()
{
// TODO change to DecompressionMethods.All with .NET5
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
}
}
}

View File

@ -0,0 +1,18 @@
namespace MediaBrowser.Common.Net
{
/// <summary>
/// Registered http client names.
/// </summary>
public static class NamedClient
{
/// <summary>
/// Gets the value for the default named http client.
/// </summary>
public const string Default = nameof(Default);
/// <summary>
/// Gets the value for the MusicBrainz named http client.
/// </summary>
public const string MusicBrainz = nameof(MusicBrainz);
}
}

View File

@ -2633,6 +2633,7 @@ namespace MediaBrowser.Controller.Entities
{
return new T
{
Path = Path,
MetadataCountryCode = GetPreferredMetadataCountryCode(),
MetadataLanguage = GetPreferredMetadataLanguage(),
Name = GetNameForMetadataLookup(),

View File

@ -8,6 +8,12 @@ namespace MediaBrowser.Controller.Extensions
/// </summary>
public static class ConfigurationExtensions
{
/// <summary>
/// The key for a setting that specifies the default redirect path
/// to use for requests where the URL base prefix is invalid or missing..
/// </summary>
public const string DefaultRedirectKey = "DefaultRedirectPath";
/// <summary>
/// The key for a setting that indicates whether the application should host web client content.
/// </summary>

View File

@ -20,6 +20,8 @@ namespace MediaBrowser.Controller
IServiceProvider ServiceProvider { get; }
bool CoreStartupHasCompleted { get; }
bool CanLaunchWebBrowser { get; }
/// <summary>
@ -117,8 +119,7 @@ namespace MediaBrowser.Controller
IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo();
string ExpandVirtualPath(string path);
string ReverseVirtualPath(string path);
Task ExecuteHttpHandlerAsync(HttpContext context, Func<Task> next);
string ReverseVirtualPath(string path);
}
}

View File

@ -16,6 +16,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.7" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
</ItemGroup>
<ItemGroup>
@ -32,6 +33,15 @@
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
<!-- Code Analyzers-->

View File

@ -4,7 +4,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Services;
namespace MediaBrowser.Controller.MediaEncoding
{
@ -63,26 +62,20 @@ namespace MediaBrowser.Controller.MediaEncoding
/// Gets or sets the id.
/// </summary>
/// <value>The id.</value>
[ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
public Guid Id { get; set; }
[ApiMember(Name = "MediaSourceId", Description = "The media version id, if playing an alternate version", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
public string MediaSourceId { get; set; }
[ApiMember(Name = "DeviceId", Description = "The device id of the client requesting. Used to stop encoding processes when needed.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
public string DeviceId { get; set; }
[ApiMember(Name = "Container", Description = "Container", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
public string Container { get; set; }
/// <summary>
/// Gets or sets the audio codec.
/// </summary>
/// <value>The audio codec.</value>
[ApiMember(Name = "AudioCodec", Description = "Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
public string AudioCodec { get; set; }
[ApiMember(Name = "EnableAutoStreamCopy", Description = "Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
public bool EnableAutoStreamCopy { get; set; }
public bool AllowVideoStreamCopy { get; set; }
@ -95,7 +88,6 @@ namespace MediaBrowser.Controller.MediaEncoding
/// Gets or sets the audio sample rate.
/// </summary>
/// <value>The audio sample rate.</value>
[ApiMember(Name = "AudioSampleRate", Description = "Optional. Specify a specific audio sample rate, e.g. 44100", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? AudioSampleRate { get; set; }
public int? MaxAudioBitDepth { get; set; }
@ -104,105 +96,86 @@ namespace MediaBrowser.Controller.MediaEncoding
/// Gets or sets the audio bit rate.
/// </summary>
/// <value>The audio bit rate.</value>
[ApiMember(Name = "AudioBitRate", Description = "Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? AudioBitRate { get; set; }
/// <summary>
/// Gets or sets the audio channels.
/// </summary>
/// <value>The audio channels.</value>
[ApiMember(Name = "AudioChannels", Description = "Optional. Specify a specific number of audio channels to encode to, e.g. 2", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? AudioChannels { get; set; }
[ApiMember(Name = "MaxAudioChannels", Description = "Optional. Specify a maximum number of audio channels to encode to, e.g. 2", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? MaxAudioChannels { get; set; }
[ApiMember(Name = "Static", Description = "Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
public bool Static { get; set; }
/// <summary>
/// Gets or sets the profile.
/// </summary>
/// <value>The profile.</value>
[ApiMember(Name = "Profile", Description = "Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
public string Profile { get; set; }
/// <summary>
/// Gets or sets the level.
/// </summary>
/// <value>The level.</value>
[ApiMember(Name = "Level", Description = "Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
public string Level { get; set; }
/// <summary>
/// Gets or sets the framerate.
/// </summary>
/// <value>The framerate.</value>
[ApiMember(Name = "Framerate", Description = "Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", IsRequired = false, DataType = "double", ParameterType = "query", Verb = "GET")]
public float? Framerate { get; set; }
[ApiMember(Name = "MaxFramerate", Description = "Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.", IsRequired = false, DataType = "double", ParameterType = "query", Verb = "GET")]
public float? MaxFramerate { get; set; }
[ApiMember(Name = "CopyTimestamps", Description = "Whether or not to copy timestamps when transcoding with an offset. Defaults to false.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
public bool CopyTimestamps { get; set; }
/// <summary>
/// Gets or sets the start time ticks.
/// </summary>
/// <value>The start time ticks.</value>
[ApiMember(Name = "StartTimeTicks", Description = "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public long? StartTimeTicks { get; set; }
/// <summary>
/// Gets or sets the width.
/// </summary>
/// <value>The width.</value>
[ApiMember(Name = "Width", Description = "Optional. The fixed horizontal resolution of the encoded video.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? Width { get; set; }
/// <summary>
/// Gets or sets the height.
/// </summary>
/// <value>The height.</value>
[ApiMember(Name = "Height", Description = "Optional. The fixed vertical resolution of the encoded video.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? Height { get; set; }
/// <summary>
/// Gets or sets the width of the max.
/// </summary>
/// <value>The width of the max.</value>
[ApiMember(Name = "MaxWidth", Description = "Optional. The maximum horizontal resolution of the encoded video.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? MaxWidth { get; set; }
/// <summary>
/// Gets or sets the height of the max.
/// </summary>
/// <value>The height of the max.</value>
[ApiMember(Name = "MaxHeight", Description = "Optional. The maximum vertical resolution of the encoded video.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? MaxHeight { get; set; }
/// <summary>
/// Gets or sets the video bit rate.
/// </summary>
/// <value>The video bit rate.</value>
[ApiMember(Name = "VideoBitRate", Description = "Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? VideoBitRate { get; set; }
/// <summary>
/// Gets or sets the index of the subtitle stream.
/// </summary>
/// <value>The index of the subtitle stream.</value>
[ApiMember(Name = "SubtitleStreamIndex", Description = "Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? SubtitleStreamIndex { get; set; }
[ApiMember(Name = "SubtitleMethod", Description = "Optional. Specify the subtitle delivery method.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
public SubtitleDeliveryMethod SubtitleMethod { get; set; }
[ApiMember(Name = "MaxRefFrames", Description = "Optional.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? MaxRefFrames { get; set; }
[ApiMember(Name = "MaxVideoBitDepth", Description = "Optional.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? MaxVideoBitDepth { get; set; }
public bool RequireAvc { get; set; }
@ -223,7 +196,6 @@ namespace MediaBrowser.Controller.MediaEncoding
/// Gets or sets the video codec.
/// </summary>
/// <value>The video codec.</value>
[ApiMember(Name = "VideoCodec", Description = "Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
public string VideoCodec { get; set; }
public string SubtitleCodec { get; set; }
@ -234,14 +206,12 @@ namespace MediaBrowser.Controller.MediaEncoding
/// Gets or sets the index of the audio stream.
/// </summary>
/// <value>The index of the audio stream.</value>
[ApiMember(Name = "AudioStreamIndex", Description = "Optional. The index of the audio stream to use. If omitted the first audio stream will be used.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? AudioStreamIndex { get; set; }
/// <summary>
/// Gets or sets the index of the video stream.
/// </summary>
/// <value>The index of the video stream.</value>
[ApiMember(Name = "VideoStreamIndex", Description = "Optional. The index of the video stream to use. If omitted the first video stream will be used.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
public int? VideoStreamIndex { get; set; }
public EncodingContext Context { get; set; }

View File

@ -1,76 +0,0 @@
#pragma warning disable CS1591
using System;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
{
public class AuthenticatedAttribute : Attribute, IHasRequestFilter, IAuthenticationAttributes
{
public static IAuthService AuthService { get; set; }
/// <summary>
/// Gets or sets the roles.
/// </summary>
/// <value>The roles.</value>
public string Roles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether [escape parental control].
/// </summary>
/// <value><c>true</c> if [escape parental control]; otherwise, <c>false</c>.</value>
public bool EscapeParentalControl { get; set; }
/// <summary>
/// Gets or sets a value indicating whether [allow before startup wizard].
/// </summary>
/// <value><c>true</c> if [allow before startup wizard]; otherwise, <c>false</c>.</value>
public bool AllowBeforeStartupWizard { get; set; }
public bool AllowLocal { get; set; }
/// <summary>
/// The request filter is executed before the service.
/// </summary>
/// <param name="request">The http request wrapper.</param>
/// <param name="response">The http response wrapper.</param>
/// <param name="requestDto">The request DTO.</param>
public void RequestFilter(IRequest request, HttpResponse response, object requestDto)
{
AuthService.Authenticate(request, this);
}
/// <summary>
/// Order in which Request Filters are executed.
/// &lt;0 Executed before global request filters
/// &gt;0 Executed after global request filters
/// </summary>
/// <value>The priority.</value>
public int Priority => 0;
public string[] GetRoles()
{
return (Roles ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
}
public bool IgnoreLegacyAuth { get; set; }
public bool AllowLocalOnly { get; set; }
}
public interface IAuthenticationAttributes
{
bool EscapeParentalControl { get; }
bool AllowBeforeStartupWizard { get; }
bool AllowLocal { get; }
bool AllowLocalOnly { get; }
string[] GetRoles();
bool IgnoreLegacyAuth { get; }
}
}

View File

@ -1,7 +1,5 @@
#nullable enable
using Jellyfin.Data.Entities;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
@ -11,21 +9,6 @@ namespace MediaBrowser.Controller.Net
/// </summary>
public interface IAuthService
{
/// <summary>
/// Authenticate and authorize request.
/// </summary>
/// <param name="request">Request.</param>
/// <param name="authAttribtutes">Authorization attributes.</param>
void Authenticate(IRequest request, IAuthenticationAttributes authAttribtutes);
/// <summary>
/// Authenticate and authorize request.
/// </summary>
/// <param name="request">Request.</param>
/// <param name="authAttribtutes">Authorization attributes.</param>
/// <returns>Authenticated user.</returns>
User? Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtutes);
/// <summary>
/// Authenticate request.
/// </summary>

View File

@ -1,4 +1,3 @@
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
@ -13,14 +12,7 @@ namespace MediaBrowser.Controller.Net
/// </summary>
/// <param name="requestContext">The request context.</param>
/// <returns>AuthorizationInfo.</returns>
AuthorizationInfo GetAuthorizationInfo(object requestContext);
/// <summary>
/// Gets the authorization information.
/// </summary>
/// <param name="requestContext">The request context.</param>
/// <returns>AuthorizationInfo.</returns>
AuthorizationInfo GetAuthorizationInfo(IRequest requestContext);
AuthorizationInfo GetAuthorizationInfo(HttpContext requestContext);
/// <summary>
/// Gets the authorization information.

View File

@ -1,17 +0,0 @@
using MediaBrowser.Model.Services;
namespace MediaBrowser.Controller.Net
{
/// <summary>
/// Interface IHasResultFactory
/// Services that require a ResultFactory should implement this
/// </summary>
public interface IHasResultFactory : IRequiresRequest
{
/// <summary>
/// Gets or sets the result factory.
/// </summary>
/// <value>The result factory.</value>
IHttpResultFactory ResultFactory { get; set; }
}
}

View File

@ -1,82 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using MediaBrowser.Model.Services;
namespace MediaBrowser.Controller.Net
{
/// <summary>
/// Interface IHttpResultFactory.
/// </summary>
public interface IHttpResultFactory
{
/// <summary>
/// Gets the result.
/// </summary>
/// <param name="content">The content.</param>
/// <param name="contentType">Type of the content.</param>
/// <param name="responseHeaders">The response headers.</param>
/// <returns>System.Object.</returns>
object GetResult(string content, string contentType, IDictionary<string, string> responseHeaders = null);
object GetResult(IRequest requestContext, byte[] content, string contentType, IDictionary<string, string> responseHeaders = null);
object GetResult(IRequest requestContext, Stream content, string contentType, IDictionary<string, string> responseHeaders = null);
object GetResult(IRequest requestContext, string content, string contentType, IDictionary<string, string> responseHeaders = null);
object GetRedirectResult(string url);
object GetResult<T>(IRequest requestContext, T result, IDictionary<string, string> responseHeaders = null)
where T : class;
/// <summary>
/// Gets the static result.
/// </summary>
/// <param name="requestContext">The request context.</param>
/// <param name="cacheKey">The cache key.</param>
/// <param name="lastDateModified">The last date modified.</param>
/// <param name="cacheDuration">Duration of the cache.</param>
/// <param name="contentType">Type of the content.</param>
/// <param name="factoryFn">The factory fn.</param>
/// <param name="responseHeaders">The response headers.</param>
/// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param>
/// <returns>System.Object.</returns>
Task<object> GetStaticResult(IRequest requestContext,
Guid cacheKey,
DateTime? lastDateModified,
TimeSpan? cacheDuration,
string contentType, Func<Task<Stream>> factoryFn,
IDictionary<string, string> responseHeaders = null,
bool isHeadRequest = false);
/// <summary>
/// Gets the static result.
/// </summary>
/// <param name="requestContext">The request context.</param>
/// <param name="options">The options.</param>
/// <returns>System.Object.</returns>
Task<object> GetStaticResult(IRequest requestContext, StaticResultOptions options);
/// <summary>
/// Gets the static file result.
/// </summary>
/// <param name="requestContext">The request context.</param>
/// <param name="path">The path.</param>
/// <param name="fileShare">The file share.</param>
/// <returns>System.Object.</returns>
Task<object> GetStaticFileResult(IRequest requestContext, string path, FileShare fileShare = FileShare.Read);
/// <summary>
/// Gets the static file result.
/// </summary>
/// <param name="requestContext">The request context.</param>
/// <param name="options">The options.</param>
/// <returns>System.Object.</returns>
Task<object> GetStaticFileResult(IRequest requestContext,
StaticFileResultOptions options);
}
}

View File

@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
{
/// <summary>
/// Interface IHttpServer.
/// </summary>
public interface IHttpServer
{
/// <summary>
/// Gets the URL prefix.
/// </summary>
/// <value>The URL prefix.</value>
string[] UrlPrefixes { get; }
/// <summary>
/// Occurs when [web socket connected].
/// </summary>
event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
/// <summary>
/// Inits this instance.
/// </summary>
void Init(IEnumerable<Type> serviceTypes, IEnumerable<IWebSocketListener> listener, IEnumerable<string> urlPrefixes);
/// <summary>
/// If set, all requests will respond with this message.
/// </summary>
string GlobalResponse { get; set; }
/// <summary>
/// The HTTP request handler.
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
Task RequestHandler(HttpContext context);
/// <summary>
/// Get the default CORS headers.
/// </summary>
/// <param name="req"></param>
/// <returns></returns>
IDictionary<string, string> GetDefaultCorsHeaders(IRequest req);
}
}

View File

@ -2,7 +2,7 @@
using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Services;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
{
@ -12,8 +12,8 @@ namespace MediaBrowser.Controller.Net
User GetUser(object requestContext);
SessionInfo GetSession(IRequest requestContext);
SessionInfo GetSession(HttpContext requestContext);
User GetUser(IRequest requestContext);
User GetUser(HttpContext requestContext);
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
{
/// <summary>
/// Interface IHttpServer.
/// </summary>
public interface IWebSocketManager
{
/// <summary>
/// Occurs when [web socket connected].
/// </summary>
event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
/// <summary>
/// Inits this instance.
/// </summary>
/// <param name="listeners">The websocket listeners.</param>
void Init(IEnumerable<IWebSocketListener> listeners);
/// <summary>
/// The HTTP request handler.
/// </summary>
/// <param name="context">The current HTTP context.</param>
/// <returns>The task.</returns>
Task WebSocketRequestHandler(HttpContext context);
}
}

View File

@ -1,44 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Net
{
public class StaticResultOptions
{
public string ContentType { get; set; }
public TimeSpan? CacheDuration { get; set; }
public DateTime? DateLastModified { get; set; }
public Func<Task<Stream>> ContentFactory { get; set; }
public bool IsHeadRequest { get; set; }
public IDictionary<string, string> ResponseHeaders { get; set; }
public Action OnComplete { get; set; }
public Action OnError { get; set; }
public string Path { get; set; }
public long? ContentLength { get; set; }
public FileShare FileShare { get; set; }
public StaticResultOptions()
{
ResponseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
FileShare = FileShare.Read;
}
}
public class StaticFileResultOptions : StaticResultOptions
{
}
}

View File

@ -14,6 +14,12 @@ namespace MediaBrowser.Controller.Providers
/// <value>The name.</value>
public string Name { get; set; }
/// <summary>
/// Gets or sets the path.
/// </summary>
/// <value>The path.</value>
public string Path { get; set; }
/// <summary>
/// Gets or sets the metadata language.
/// </summary>

View File

@ -0,0 +1,87 @@
using System;
using MediaBrowser.Model.QuickConnect;
namespace MediaBrowser.Controller.QuickConnect
{
/// <summary>
/// Quick connect standard interface.
/// </summary>
public interface IQuickConnect
{
/// <summary>
/// Gets or sets the length of user facing codes.
/// </summary>
int CodeLength { get; set; }
/// <summary>
/// Gets or sets the name of internal access tokens.
/// </summary>
string TokenName { get; set; }
/// <summary>
/// Gets the current state of quick connect.
/// </summary>
QuickConnectState State { get; }
/// <summary>
/// Gets or sets the time (in minutes) before quick connect will automatically deactivate.
/// </summary>
int Timeout { get; set; }
/// <summary>
/// Assert that quick connect is currently active and throws an exception if it is not.
/// </summary>
void AssertActive();
/// <summary>
/// Temporarily activates quick connect for a short amount of time.
/// </summary>
void Activate();
/// <summary>
/// Changes the state of quick connect.
/// </summary>
/// <param name="newState">New state to change to.</param>
void SetState(QuickConnectState newState);
/// <summary>
/// Initiates a new quick connect request.
/// </summary>
/// <returns>A quick connect result with tokens to proceed or throws an exception if not active.</returns>
QuickConnectResult TryConnect();
/// <summary>
/// Checks the status of an individual request.
/// </summary>
/// <param name="secret">Unique secret identifier of the request.</param>
/// <returns>Quick connect result.</returns>
QuickConnectResult CheckRequestStatus(string secret);
/// <summary>
/// Authorizes a quick connect request to connect as the calling user.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="code">Identifying code for the request.</param>
/// <returns>A boolean indicating if the authorization completed successfully.</returns>
bool AuthorizeRequest(Guid userId, string code);
/// <summary>
/// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired.
/// </summary>
/// <param name="expireAll">If true, all requests will be expired.</param>
void ExpireRequests(bool expireAll = false);
/// <summary>
/// Deletes all quick connect access tokens for the provided user.
/// </summary>
/// <param name="user">Guid of the user to delete tokens for.</param>
/// <returns>A count of the deleted tokens.</returns>
int DeleteAllDevices(Guid user);
/// <summary>
/// Generates a short code to display to the user to uniquely identify this request.
/// </summary>
/// <returns>A short, unique alphanumeric string.</returns>
string GenerateCode();
}
}

View File

@ -266,6 +266,14 @@ namespace MediaBrowser.Controller.Session
/// <returns>Task{SessionInfo}.</returns>
Task<AuthenticationResult> AuthenticateNewSession(AuthenticationRequest request);
/// <summary>
/// Authenticates a new session with quick connect.
/// </summary>
/// <param name="request">The request.</param>
/// <param name="token">Quick connect access token.</param>
/// <returns>Task{SessionInfo}.</returns>
Task<AuthenticationResult> AuthenticateQuickConnect(AuthenticationRequest request, string token);
/// <summary>
/// Creates the new session.
/// </summary>

View File

@ -78,6 +78,11 @@ namespace MediaBrowser.Model.Configuration
/// <value><c>true</c> if this instance is port authorized; otherwise, <c>false</c>.</value>
public bool IsPortAuthorized { get; set; }
/// <summary>
/// Gets or sets if quick connect is available for use on this server.
/// </summary>
public bool QuickConnectAvailable { get; set; }
public bool AutoRunWebApp { get; set; }
public bool EnableRemoteAccess { get; set; }
@ -299,6 +304,7 @@ namespace MediaBrowser.Model.Configuration
AutoRunWebApp = true;
EnableRemoteAccess = true;
QuickConnectAvailable = false;
EnableUPnP = false;
MinResumePct = 5;

View File

@ -20,9 +20,19 @@
<TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="3.1.7" />
<PackageReference Include="System.Globalization" Version="4.3.0" />

View File

@ -0,0 +1,40 @@
using System;
namespace MediaBrowser.Model.QuickConnect
{
/// <summary>
/// Stores the result of an incoming quick connect request.
/// </summary>
public class QuickConnectResult
{
/// <summary>
/// Gets a value indicating whether this request is authorized.
/// </summary>
public bool Authenticated => !string.IsNullOrEmpty(Authentication);
/// <summary>
/// Gets or sets the secret value used to uniquely identify this request. Can be used to retrieve authentication information.
/// </summary>
public string? Secret { get; set; }
/// <summary>
/// Gets or sets the user facing code used so the user can quickly differentiate this request from others.
/// </summary>
public string? Code { get; set; }
/// <summary>
/// Gets or sets the private access token.
/// </summary>
public string? Authentication { get; set; }
/// <summary>
/// Gets or sets an error message.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// Gets or sets the DateTime that this request was created.
/// </summary>
public DateTime? DateAdded { get; set; }
}
}

View File

@ -0,0 +1,23 @@
namespace MediaBrowser.Model.QuickConnect
{
/// <summary>
/// Quick connect state.
/// </summary>
public enum QuickConnectState
{
/// <summary>
/// This feature has not been opted into and is unavailable until the server administrator chooses to opt-in.
/// </summary>
Unavailable = 0,
/// <summary>
/// The feature is enabled for use on the server but is not currently accepting connection requests.
/// </summary>
Available = 1,
/// <summary>
/// The feature is actively accepting connection requests.
/// </summary>
Active = 2
}
}

View File

@ -1,65 +0,0 @@
#nullable disable
using System;
namespace MediaBrowser.Model.Services
{
/// <summary>
/// Identifies a single API endpoint.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
public class ApiMemberAttribute : Attribute
{
/// <summary>
/// Gets or sets verb to which applies attribute. By default applies to all verbs.
/// </summary>
public string Verb { get; set; }
/// <summary>
/// Gets or sets parameter type: It can be only one of the following: path, query, body, form, or header.
/// </summary>
public string ParameterType { get; set; }
/// <summary>
/// Gets or sets unique name for the parameter. Each name must be unique, even if they are associated with different paramType values.
/// </summary>
/// <remarks>
/// <para>
/// Other notes on the name field:
/// If paramType is body, the name is used only for UI and codegeneration.
/// If paramType is path, the name field must correspond to the associated path segment from the path field in the api object.
/// If paramType is query, the name field corresponds to the query param name.
/// </para>
/// </remarks>
public string Name { get; set; }
/// <summary>
/// Gets or sets the human-readable description for the parameter.
/// </summary>
public string Description { get; set; }
/// <summary>
/// For path, query, and header paramTypes, this field must be a primitive. For body, this can be a complex or container datatype.
/// </summary>
public string DataType { get; set; }
/// <summary>
/// For path, this is always true. Otherwise, this field tells the client whether or not the field must be supplied.
/// </summary>
public bool IsRequired { get; set; }
/// <summary>
/// For query params, this specifies that a comma-separated list of values can be passed to the API. For path and body types, this field cannot be true.
/// </summary>
public bool AllowMultiple { get; set; }
/// <summary>
/// Gets or sets route to which applies attribute, matches using StartsWith. By default applies to all routes.
/// </summary>
public string Route { get; set; }
/// <summary>
/// Whether to exclude this property from being included in the ModelSchema.
/// </summary>
public bool ExcludeInSchema { get; set; }
}
}

View File

@ -1,13 +0,0 @@
#pragma warning disable CS1591
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Model.Services
{
public interface IAsyncStreamWriter
{
Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken);
}
}

View File

@ -1,11 +0,0 @@
#pragma warning disable CS1591
using System.Collections.Generic;
namespace MediaBrowser.Model.Services
{
public interface IHasHeaders
{
IDictionary<string, string> Headers { get; }
}
}

View File

@ -1,24 +0,0 @@
#pragma warning disable CS1591
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Model.Services
{
public interface IHasRequestFilter
{
/// <summary>
/// Gets the order in which Request Filters are executed.
/// &lt;0 Executed before global request filters.
/// &gt;0 Executed after global request filters.
/// </summary>
int Priority { get; }
/// <summary>
/// The request filter is executed before the service.
/// </summary>
/// <param name="req">The http request wrapper.</param>
/// <param name="res">The http response wrapper.</param>
/// <param name="requestDto">The request DTO.</param>
void RequestFilter(IRequest req, HttpResponse res, object requestDto);
}
}

View File

@ -1,17 +0,0 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Services
{
public interface IHttpRequest : IRequest
{
/// <summary>
/// Gets the HTTP Verb.
/// </summary>
string HttpMethod { get; }
/// <summary>
/// Gets the value of the Accept HTTP Request Header.
/// </summary>
string Accept { get; }
}
}

View File

@ -1,35 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System.Net;
namespace MediaBrowser.Model.Services
{
public interface IHttpResult : IHasHeaders
{
/// <summary>
/// The HTTP Response Status.
/// </summary>
int Status { get; set; }
/// <summary>
/// The HTTP Response Status Code.
/// </summary>
HttpStatusCode StatusCode { get; set; }
/// <summary>
/// The HTTP Response ContentType.
/// </summary>
string ContentType { get; set; }
/// <summary>
/// Response DTO.
/// </summary>
object Response { get; set; }
/// <summary>
/// Holds the request call context.
/// </summary>
IRequest RequestContext { get; set; }
}
}

View File

@ -1,93 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Model.Services
{
public interface IRequest
{
HttpResponse Response { get; }
/// <summary>
/// The name of the service being called (e.g. Request DTO Name)
/// </summary>
string OperationName { get; set; }
/// <summary>
/// The Verb / HttpMethod or Action for this request
/// </summary>
string Verb { get; }
/// <summary>
/// The request ContentType.
/// </summary>
string ContentType { get; }
bool IsLocal { get; }
string UserAgent { get; }
/// <summary>
/// The expected Response ContentType for this request.
/// </summary>
string ResponseContentType { get; set; }
/// <summary>
/// Attach any data to this request that all filters and services can access.
/// </summary>
Dictionary<string, object> Items { get; }
IHeaderDictionary Headers { get; }
IQueryCollection QueryString { get; }
string RawUrl { get; }
string AbsoluteUri { get; }
/// <summary>
/// The Remote Ip as reported by X-Forwarded-For, X-Real-IP or Request.UserHostAddress
/// </summary>
string RemoteIp { get; }
/// <summary>
/// The value of the Authorization Header used to send the Api Key, null if not available.
/// </summary>
string Authorization { get; }
string[] AcceptTypes { get; }
string PathInfo { get; }
Stream InputStream { get; }
long ContentLength { get; }
/// <summary>
/// The value of the Referrer, null if not available.
/// </summary>
Uri UrlReferrer { get; }
}
public interface IHttpFile
{
string Name { get; }
string FileName { get; }
long ContentLength { get; }
string ContentType { get; }
Stream InputStream { get; }
}
public interface IRequiresRequest
{
IRequest Request { get; set; }
}
}

View File

@ -1,14 +0,0 @@
#pragma warning disable CS1591
using System.IO;
namespace MediaBrowser.Model.Services
{
public interface IRequiresRequestStream
{
/// <summary>
/// The raw Http Request Input Stream.
/// </summary>
Stream RequestStream { get; set; }
}
}

View File

@ -1,15 +0,0 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Services
{
// marker interface
public interface IService
{
}
public interface IReturn { }
public interface IReturn<T> : IReturn { }
public interface IReturnVoid : IReturn { }
}

View File

@ -1,11 +0,0 @@
#pragma warning disable CS1591
using System.IO;
namespace MediaBrowser.Model.Services
{
public interface IStreamWriter
{
void WriteTo(Stream responseStream);
}
}

View File

@ -1,147 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Model.Services
{
// Remove this garbage class, it's just a bastard copy of NameValueCollection
public class QueryParamCollection : List<NameValuePair>
{
public QueryParamCollection()
{
}
private static StringComparison GetStringComparison()
{
return StringComparison.OrdinalIgnoreCase;
}
/// <summary>
/// Adds a new query parameter.
/// </summary>
public void Add(string key, string value)
{
Add(new NameValuePair(key, value));
}
private void Set(string key, string value)
{
if (string.IsNullOrEmpty(value))
{
var parameters = GetItems(key);
foreach (var p in parameters)
{
Remove(p);
}
return;
}
foreach (var pair in this)
{
var stringComparison = GetStringComparison();
if (string.Equals(key, pair.Name, stringComparison))
{
pair.Value = value;
return;
}
}
Add(key, value);
}
private string Get(string name)
{
var stringComparison = GetStringComparison();
foreach (var pair in this)
{
if (string.Equals(pair.Name, name, stringComparison))
{
return pair.Value;
}
}
return null;
}
private List<NameValuePair> GetItems(string name)
{
var stringComparison = GetStringComparison();
var list = new List<NameValuePair>();
foreach (var pair in this)
{
if (string.Equals(pair.Name, name, stringComparison))
{
list.Add(pair);
}
}
return list;
}
public virtual List<string> GetValues(string name)
{
var stringComparison = GetStringComparison();
var list = new List<string>();
foreach (var pair in this)
{
if (string.Equals(pair.Name, name, stringComparison))
{
list.Add(pair.Value);
}
}
return list;
}
public IEnumerable<string> Keys
{
get
{
var keys = new string[this.Count];
for (var i = 0; i < keys.Length; i++)
{
keys[i] = this[i].Name;
}
return keys;
}
}
/// <summary>
/// Gets or sets a query parameter value by name. A query may contain multiple values of the same name
/// (i.e. "x=1&amp;x=2"), in which case the value is an array, which works for both getting and setting.
/// </summary>
/// <param name="name">The query parameter name.</param>
/// <returns>The query parameter value or array of values.</returns>
public string this[string name]
{
get => Get(name);
set => Set(name, value);
}
private string GetQueryStringValue(NameValuePair pair)
{
return pair.Name + "=" + pair.Value;
}
public override string ToString()
{
var vals = this.Select(GetQueryStringValue).ToArray();
return string.Join("&", vals);
}
}
}

View File

@ -1,163 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
namespace MediaBrowser.Model.Services
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class RouteAttribute : Attribute
{
/// <summary>
/// <para>Initializes an instance of the <see cref="RouteAttribute"/> class.</para>
/// </summary>
/// <param name="path">
/// <para>The path template to map to the request. See
/// <see cref="Path">RouteAttribute.Path</see>
/// for details on the correct format.</para>
/// </param>
public RouteAttribute(string path)
: this(path, null)
{
}
/// <summary>
/// <para>Initializes an instance of the <see cref="RouteAttribute"/> class.</para>
/// </summary>
/// <param name="path">
/// <para>The path template to map to the request. See
/// <see cref="Path">RouteAttribute.Path</see>
/// for details on the correct format.</para>
/// </param>
/// <param name="verbs">A comma-delimited list of HTTP verbs supported by the
/// service. If unspecified, all verbs are assumed to be supported.</param>
public RouteAttribute(string path, string verbs)
{
Path = path;
Verbs = verbs;
}
/// <summary>
/// Gets or sets the path template to be mapped to the request.
/// </summary>
/// <value>
/// A <see cref="String"/> value providing the path mapped to
/// the request. Never <see langword="null"/>.
/// </value>
/// <remarks>
/// <para>Some examples of valid paths are:</para>
///
/// <list>
/// <item>"/Inventory"</item>
/// <item>"/Inventory/{Category}/{ItemId}"</item>
/// <item>"/Inventory/{ItemPath*}"</item>
/// </list>
///
/// <para>Variables are specified within "{}"
/// brackets. Each variable in the path is mapped to the same-named property
/// on the request DTO. At runtime, ServiceStack will parse the
/// request URL, extract the variable values, instantiate the request DTO,
/// and assign the variable values into the corresponding request properties,
/// prior to passing the request DTO to the service object for processing.</para>
///
/// <para>It is not necessary to specify all request properties as
/// variables in the path. For unspecified properties, callers may provide
/// values in the query string. For example: the URL
/// "http://services/Inventory?Category=Books&amp;ItemId=12345" causes the same
/// request DTO to be processed as "http://services/Inventory/Books/12345",
/// provided that the paths "/Inventory" (which supports the first URL) and
/// "/Inventory/{Category}/{ItemId}" (which supports the second URL)
/// are both mapped to the request DTO.</para>
///
/// <para>Please note that while it is possible to specify property values
/// in the query string, it is generally considered to be less RESTful and
/// less desirable than to specify them as variables in the path. Using the
/// query string to specify property values may also interfere with HTTP
/// caching.</para>
///
/// <para>The final variable in the path may contain a "*" suffix
/// to grab all remaining segments in the path portion of the request URL and assign
/// them to a single property on the request DTO.
/// For example, if the path "/Inventory/{ItemPath*}" is mapped to the request DTO,
/// then the request URL "http://services/Inventory/Books/12345" will result
/// in a request DTO whose ItemPath property contains "Books/12345".
/// You may only specify one such variable in the path, and it must be positioned at
/// the end of the path.</para>
/// </remarks>
public string Path { get; set; }
/// <summary>
/// Gets or sets short summary of what the route does.
/// </summary>
public string Summary { get; set; }
public string Description { get; set; }
public bool IsHidden { get; set; }
/// <summary>
/// Gets or sets longer text to explain the behaviour of the route.
/// </summary>
public string Notes { get; set; }
/// <summary>
/// Gets or sets a comma-delimited list of HTTP verbs supported by the service, such as
/// "GET,PUT,POST,DELETE".
/// </summary>
/// <value>
/// A <see cref="String"/> providing a comma-delimited list of HTTP verbs supported
/// by the service, <see langword="null"/> or empty if all verbs are supported.
/// </value>
public string Verbs { get; set; }
/// <summary>
/// Used to rank the precedences of route definitions in reverse routing.
/// i.e. Priorities below 0 are auto-generated have less precedence.
/// </summary>
public int Priority { get; set; }
protected bool Equals(RouteAttribute other)
{
return base.Equals(other)
&& string.Equals(Path, other.Path)
&& string.Equals(Summary, other.Summary)
&& string.Equals(Notes, other.Notes)
&& string.Equals(Verbs, other.Verbs)
&& Priority == other.Priority;
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj))
{
return false;
}
if (ReferenceEquals(this, obj))
{
return true;
}
if (obj.GetType() != this.GetType())
{
return false;
}
return Equals((RouteAttribute)obj);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = base.GetHashCode();
hashCode = (hashCode * 397) ^ (Path != null ? Path.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (Summary != null ? Summary.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (Notes != null ? Notes.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (Verbs != null ? Verbs.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ Priority;
return hashCode;
}
}
}
}

View File

@ -2,7 +2,6 @@
#pragma warning disable CS1591
using System;
using MediaBrowser.Model.Services;
namespace MediaBrowser.Model.Session
{

View File

@ -1,18 +0,0 @@
using Emby.Server.Implementations.HttpServer;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.HttpServer
{
public class ResponseFilterTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", "")]
[InlineData("This is a clean string.", "This is a clean string.")]
[InlineData("This isn't \n\ra clean string.", "This isn't a clean string.")]
public void RemoveControlCharacters_ValidArgs_Correct(string? input, string? result)
{
Assert.Equal(result, ResponseFilter.RemoveControlCharacters(input));
}
}
}