diff options
Diffstat (limited to 'Jellyfin.Server')
| -rw-r--r-- | Jellyfin.Server/CoreAppHost.cs | 57 | ||||
| -rw-r--r-- | Jellyfin.Server/Jellyfin.Server.csproj | 44 | ||||
| -rw-r--r-- | Jellyfin.Server/PowerManagement.cs | 23 | ||||
| -rw-r--r-- | Jellyfin.Server/Program.cs | 304 | ||||
| -rw-r--r-- | Jellyfin.Server/Resources/Configuration/logging.json | 19 | ||||
| -rw-r--r-- | Jellyfin.Server/SocketSharp/HttpFile.cs | 14 | ||||
| -rw-r--r-- | Jellyfin.Server/SocketSharp/RequestMono.cs | 805 | ||||
| -rw-r--r-- | Jellyfin.Server/SocketSharp/SharpWebSocket.cs | 159 | ||||
| -rw-r--r-- | Jellyfin.Server/SocketSharp/WebSocketSharpListener.cs | 260 | ||||
| -rw-r--r-- | Jellyfin.Server/SocketSharp/WebSocketSharpRequest.cs | 554 | ||||
| -rw-r--r-- | Jellyfin.Server/SocketSharp/WebSocketSharpResponse.cs | 197 |
11 files changed, 2436 insertions, 0 deletions
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs new file mode 100644 index 000000000..b54634387 --- /dev/null +++ b/Jellyfin.Server/CoreAppHost.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Reflection; +using Emby.Server.Implementations; +using Emby.Server.Implementations.HttpServer; +using Jellyfin.SocketSharp; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.System; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server +{ + public class CoreAppHost : ApplicationHost + { + public CoreAppHost(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, StartupOptions options, IFileSystem fileSystem, IPowerManagement powerManagement, IEnvironmentInfo environmentInfo, MediaBrowser.Controller.Drawing.IImageEncoder imageEncoder, ISystemEvents systemEvents, MediaBrowser.Common.Net.INetworkManager networkManager) + : base(applicationPaths, loggerFactory, options, fileSystem, powerManagement, environmentInfo, imageEncoder, systemEvents, networkManager) + { + } + + public override bool CanSelfRestart + { + get + { + // A restart script must be provided + return StartupOptions.ContainsOption("-restartpath"); + } + } + + protected override void RestartInternal() => Program.Restart(); + + protected override IEnumerable<Assembly> GetAssembliesWithPartsInternal() + => new [] { typeof(CoreAppHost).Assembly }; + + protected override void ShutdownInternal() => Program.Shutdown(); + + protected override bool SupportsDualModeSockets + { + get + { + return true; + } + } + + protected override IHttpListener CreateHttpListener() + => new WebSocketSharpListener( + Logger, + Certificate, + StreamHelper, + TextEncoding, + NetworkManager, + SocketFactory, + CryptographyProvider, + SupportsDualModeSockets, + FileSystemManager, + EnvironmentInfo + ); + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj new file mode 100644 index 000000000..fa603a086 --- /dev/null +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -0,0 +1,44 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <AssemblyName>jellyfin</AssemblyName> + <OutputType>Exe</OutputType> + <TargetFramework>netcoreapp2.1</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + </PropertyGroup> + + <PropertyGroup> + <!-- We need C# 7.1 for async main--> + <LangVersion>latest</LangVersion> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\SharedVersion.cs" /> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Include="Resources/Configuration/*" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="2.2.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" /> + <PackageReference Include="Serilog.AspNetCore" Version="2.1.1" /> + <PackageReference Include="Serilog.Settings.Configuration" Version="3.0.1" /> + <PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" /> + <PackageReference Include="Serilog.Sinks.File" Version="4.0.0" /> + <PackageReference Include="SkiaSharp" Version="1.68.0" /> + <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="1.1.12" /> + <PackageReference Include="SQLitePCLRaw.core" Version="1.1.12" /> + <PackageReference Include="SQLitePCLRaw.provider.sqlite3.netstandard11" Version="1.1.12" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Emby.Drawing\Emby.Drawing.csproj" /> + <ProjectReference Include="..\Emby.Drawing.ImageMagick\Emby.Drawing.ImageMagick.csproj" /> + <ProjectReference Include="..\Emby.Drawing.Skia\Emby.Drawing.Skia.csproj" /> + <ProjectReference Include="..\Emby.IsoMounting\IsoMounter\IsoMounter.csproj" /> + <ProjectReference Include="..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" /> + </ItemGroup> + +</Project> diff --git a/Jellyfin.Server/PowerManagement.cs b/Jellyfin.Server/PowerManagement.cs new file mode 100644 index 000000000..c27c51893 --- /dev/null +++ b/Jellyfin.Server/PowerManagement.cs @@ -0,0 +1,23 @@ +using System; +using MediaBrowser.Model.System; + +namespace Jellyfin.Server.Native +{ + public class PowerManagement : IPowerManagement + { + public void PreventSystemStandby() + { + + } + + public void AllowSystemStandby() + { + + } + + public void ScheduleWake(DateTime wakeTimeUtc, string displayName) + { + + } + } +} diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs new file mode 100644 index 000000000..9cc2fe103 --- /dev/null +++ b/Jellyfin.Server/Program.cs @@ -0,0 +1,304 @@ +using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Security;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Emby.Drawing;
+using Emby.Drawing.ImageMagick;
+using Emby.Drawing.Skia;
+using Emby.Server.Implementations;
+using Emby.Server.Implementations.EnvironmentInfo;
+using Emby.Server.Implementations.IO;
+using Emby.Server.Implementations.Networking;
+using Jellyfin.Server.Native;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.System;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Serilog;
+using Serilog.AspNetCore;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
+
+namespace Jellyfin.Server
+{
+ public static class Program
+ {
+ private static readonly TaskCompletionSource<bool> ApplicationTaskCompletionSource = new TaskCompletionSource<bool>();
+ private static ILoggerFactory _loggerFactory;
+ private static ILogger _logger;
+ private static bool _restartOnShutdown;
+
+ public static async Task<int> Main(string[] args)
+ {
+ StartupOptions options = new StartupOptions(args);
+ Version version = Assembly.GetEntryAssembly().GetName().Version;
+
+ if (options.ContainsOption("-v") || options.ContainsOption("--version"))
+ {
+ Console.WriteLine(version.ToString());
+ return 0;
+ }
+
+ ServerApplicationPaths appPaths = createApplicationPaths(options);
+ await createLogger(appPaths);
+ _loggerFactory = new SerilogLoggerFactory();
+ _logger = _loggerFactory.CreateLogger("Main");
+
+ AppDomain.CurrentDomain.UnhandledException += (sender, e)
+ => _logger.LogCritical((Exception)e.ExceptionObject, "Unhandled Exception");
+
+ _logger.LogInformation("Jellyfin version: {Version}", version);
+
+ EnvironmentInfo environmentInfo = new EnvironmentInfo(getOperatingSystem());
+ ApplicationHost.LogEnvironmentInfo(_logger, appPaths, environmentInfo);
+
+ SQLitePCL.Batteries_V2.Init();
+
+ // Allow all https requests
+ ServicePointManager.ServerCertificateValidationCallback = new RemoteCertificateValidationCallback(delegate { return true; });
+
+ var fileSystem = new ManagedFileSystem(_loggerFactory.CreateLogger("FileSystem"), environmentInfo, null, appPaths.TempDirectory, true);
+
+ using (var appHost = new CoreAppHost(
+ appPaths,
+ _loggerFactory,
+ options,
+ fileSystem,
+ new PowerManagement(),
+ environmentInfo,
+ new NullImageEncoder(),
+ new SystemEvents(_loggerFactory.CreateLogger("SystemEvents")),
+ new NetworkManager(_loggerFactory.CreateLogger("NetworkManager"), environmentInfo)))
+ {
+ appHost.Init();
+
+ appHost.ImageProcessor.ImageEncoder = getImageEncoder(_logger, fileSystem, options, () => appHost.HttpClient, appPaths, environmentInfo, appHost.LocalizationManager);
+
+ _logger.LogInformation("Running startup tasks");
+
+ await appHost.RunStartupTasks();
+
+ // TODO: read input for a stop command
+ // Block main thread until shutdown
+ await ApplicationTaskCompletionSource.Task;
+
+ _logger.LogInformation("Disposing app host");
+ }
+
+ if (_restartOnShutdown)
+ {
+ StartNewInstance(options);
+ }
+
+ return 0;
+ }
+
+ private static ServerApplicationPaths createApplicationPaths(StartupOptions options)
+ {
+ string programDataPath;
+ if (options.ContainsOption("-programdata"))
+ {
+ programDataPath = options.GetOption("-programdata");
+ }
+ else
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ programDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ }
+ else
+ {
+ // $XDG_DATA_HOME defines the base directory relative to which user specific data files should be stored.
+ programDataPath = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
+ // If $XDG_DATA_HOME is either not set or empty, $HOME/.local/share should be used.
+ if (string.IsNullOrEmpty(programDataPath))
+ {
+ programDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share");
+ }
+ }
+ programDataPath = Path.Combine(programDataPath, "jellyfin");
+ }
+
+ string logDir = Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR");
+ if (string.IsNullOrEmpty(logDir))
+ {
+ logDir = Path.Combine(programDataPath, "logs");
+ // Ensure logDir exists
+ Directory.CreateDirectory(logDir);
+ // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager
+ Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", logDir);
+ }
+
+ string appPath = AppContext.BaseDirectory;
+
+ return new ServerApplicationPaths(programDataPath, appPath, appPath, logDir);
+ }
+
+ private static async Task createLogger(IApplicationPaths appPaths)
+ {
+ try
+ {
+ string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, "logging.json");
+
+ if (!File.Exists(configPath))
+ {
+ // For some reason the csproj name is used instead of the assembly name
+ using (Stream rscstr = typeof(Program).Assembly
+ .GetManifestResourceStream("Jellyfin.Server.Resources.Configuration.logging.json"))
+ using (Stream fstr = File.Open(configPath, FileMode.CreateNew))
+ {
+ await rscstr.CopyToAsync(fstr);
+ }
+ }
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(appPaths.ConfigurationDirectoryPath)
+ .AddJsonFile("logging.json")
+ .AddEnvironmentVariables("JELLYFIN_")
+ .Build();
+
+ // Serilog.Log is used by SerilogLoggerFactory when no logger is specified
+ Serilog.Log.Logger = new LoggerConfiguration()
+ .ReadFrom.Configuration(configuration)
+ .Enrich.FromLogContext()
+ .CreateLogger();
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger = new LoggerConfiguration()
+ .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .WriteTo.File(
+ Path.Combine(appPaths.LogDirectoryPath, "log_.log"),
+ rollingInterval: RollingInterval.Day,
+ outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}")
+ .Enrich.FromLogContext()
+ .CreateLogger();
+
+ Serilog.Log.Logger.Fatal(ex, "Failed to create/read logger configuration");
+ }
+ }
+
+ public static IImageEncoder getImageEncoder(
+ ILogger logger,
+ IFileSystem fileSystem,
+ StartupOptions startupOptions,
+ Func<IHttpClient> httpClient,
+ IApplicationPaths appPaths,
+ IEnvironmentInfo environment,
+ ILocalizationManager localizationManager)
+ {
+ if (!startupOptions.ContainsOption("-enablegdi"))
+ {
+ try
+ {
+ return new SkiaEncoder(logger, appPaths, httpClient, fileSystem, localizationManager);
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation(ex, "Skia not available. Will try next image processor. {0}");
+ }
+
+ try
+ {
+ return new ImageMagickEncoder(logger, appPaths, httpClient, fileSystem, environment);
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation(ex, "ImageMagick not available. Will try next image processor.");
+ }
+ _logger.LogInformation("Falling back on NullImageEncoder");
+ }
+
+ return new NullImageEncoder();
+ }
+
+ private static MediaBrowser.Model.System.OperatingSystem getOperatingSystem() {
+ switch (Environment.OSVersion.Platform)
+ {
+ case PlatformID.MacOSX:
+ return MediaBrowser.Model.System.OperatingSystem.OSX;
+ case PlatformID.Win32NT:
+ return MediaBrowser.Model.System.OperatingSystem.Windows;
+ case PlatformID.Unix:
+ default:
+ {
+ string osDescription = RuntimeInformation.OSDescription;
+ if (osDescription.Contains("linux", StringComparison.OrdinalIgnoreCase))
+ {
+ return MediaBrowser.Model.System.OperatingSystem.Linux;
+ }
+ else if (osDescription.Contains("darwin", StringComparison.OrdinalIgnoreCase))
+ {
+ return MediaBrowser.Model.System.OperatingSystem.OSX;
+ }
+ else if (osDescription.Contains("bsd", StringComparison.OrdinalIgnoreCase))
+ {
+ return MediaBrowser.Model.System.OperatingSystem.BSD;
+ }
+ throw new Exception($"Can't resolve OS with description: '{osDescription}'");
+ }
+ }
+ }
+
+ public static void Shutdown()
+ {
+ ApplicationTaskCompletionSource.SetResult(true);
+ }
+
+ public static void Restart()
+ {
+ _restartOnShutdown = true;
+
+ Shutdown();
+ }
+
+ private static void StartNewInstance(StartupOptions startupOptions)
+ {
+ _logger.LogInformation("Starting new instance");
+
+ string module = startupOptions.GetOption("-restartpath");
+
+ if (string.IsNullOrWhiteSpace(module))
+ {
+ module = Environment.GetCommandLineArgs().First();
+ }
+
+ string commandLineArgsString;
+
+ if (startupOptions.ContainsOption("-restartargs"))
+ {
+ commandLineArgsString = startupOptions.GetOption("-restartargs") ?? string.Empty;
+ }
+ else
+ {
+ commandLineArgsString = string .Join(" ",
+ Environment.GetCommandLineArgs()
+ .Skip(1)
+ .Select(NormalizeCommandLineArgument)
+ );
+ }
+
+ _logger.LogInformation("Executable: {0}", module);
+ _logger.LogInformation("Arguments: {0}", commandLineArgsString);
+
+ Process.Start(module, commandLineArgsString);
+ }
+
+ private static string NormalizeCommandLineArgument(string arg)
+ {
+ if (!arg.Contains(" ", StringComparison.OrdinalIgnoreCase))
+ {
+ return arg;
+ }
+
+ return "\"" + arg + "\"";
+ }
+ }
+}
diff --git a/Jellyfin.Server/Resources/Configuration/logging.json b/Jellyfin.Server/Resources/Configuration/logging.json new file mode 100644 index 000000000..78f99b2ad --- /dev/null +++ b/Jellyfin.Server/Resources/Configuration/logging.json @@ -0,0 +1,19 @@ +{ + "Serilog": { + "MinimumLevel": "Information", + "WriteTo": [ + { "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}" + } + }, + { "Name": "File", + "Args": { + "path": "%JELLYFIN_LOG_DIR%//log_.log", + "rollingInterval": "Day", + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}" + } + } + ] + } +} diff --git a/Jellyfin.Server/SocketSharp/HttpFile.cs b/Jellyfin.Server/SocketSharp/HttpFile.cs new file mode 100644 index 000000000..4a798062d --- /dev/null +++ b/Jellyfin.Server/SocketSharp/HttpFile.cs @@ -0,0 +1,14 @@ +using System.IO; +using MediaBrowser.Model.Services; + +namespace Jellyfin.SocketSharp +{ + public class HttpFile : IHttpFile + { + public string Name { get; set; } + public string FileName { get; set; } + public long ContentLength { get; set; } + public string ContentType { get; set; } + public Stream InputStream { get; set; } + } +} diff --git a/Jellyfin.Server/SocketSharp/RequestMono.cs b/Jellyfin.Server/SocketSharp/RequestMono.cs new file mode 100644 index 000000000..31f289497 --- /dev/null +++ b/Jellyfin.Server/SocketSharp/RequestMono.cs @@ -0,0 +1,805 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Model.Services; + +namespace Jellyfin.SocketSharp +{ + public partial class WebSocketSharpRequest : IHttpRequest + { + static internal string GetParameter(string header, string attr) + { + int ap = header.IndexOf(attr); + if (ap == -1) + return null; + + ap += attr.Length; + if (ap >= header.Length) + return null; + + char ending = header[ap]; + if (ending != '"') + ending = ' '; + + int end = header.IndexOf(ending, ap + 1); + if (end == -1) + return ending == '"' ? null : header.Substring(ap); + + return header.Substring(ap + 1, end - ap - 1); + } + + async Task LoadMultiPart(WebROCollection form) + { + string boundary = GetParameter(ContentType, "; boundary="); + if (boundary == null) + return; + + using (var requestStream = InputStream) + { + //DB: 30/01/11 - Hack to get around non-seekable stream and received HTTP request + //Not ending with \r\n? + var ms = new MemoryStream(32 * 1024); + await requestStream.CopyToAsync(ms).ConfigureAwait(false); + + var input = ms; + ms.WriteByte((byte)'\r'); + ms.WriteByte((byte)'\n'); + + input.Position = 0; + + // Uncomment to debug + //var content = new StreamReader(ms).ReadToEnd(); + //Console.WriteLine(boundary + "::" + content); + //input.Position = 0; + + var multi_part = new HttpMultipart(input, boundary, ContentEncoding); + + HttpMultipart.Element e; + while ((e = multi_part.ReadNextElement()) != null) + { + if (e.Filename == null) + { + byte[] copy = new byte[e.Length]; + + input.Position = e.Start; + input.Read(copy, 0, (int)e.Length); + + form.Add(e.Name, (e.Encoding ?? ContentEncoding).GetString(copy, 0, copy.Length)); + } + else + { + // + // We use a substream, as in 2.x we will support large uploads streamed to disk, + // + HttpPostedFile sub = new HttpPostedFile(e.Filename, e.ContentType, input, e.Start, e.Length); + files[e.Name] = sub; + } + } + } + } + + public async Task<QueryParamCollection> GetFormData() + { + var form = new WebROCollection(); + files = new Dictionary<string, HttpPostedFile>(); + + if (IsContentType("multipart/form-data", true)) + { + await LoadMultiPart(form).ConfigureAwait(false); + } + else if (IsContentType("application/x-www-form-urlencoded", true)) + { + await LoadWwwForm(form).ConfigureAwait(false); + } + +#if NET_4_0 + if (validateRequestNewMode && !checked_form) { + // Setting this before calling the validator prevents + // possible endless recursion + checked_form = true; + ValidateNameValueCollection ("Form", query_string_nvc, RequestValidationSource.Form); + } else +#endif + if (validate_form && !checked_form) + { + checked_form = true; + ValidateNameValueCollection("Form", form); + } + + return form; + } + + public string Accept + { + get + { + return string.IsNullOrEmpty(request.Headers["Accept"]) ? null : request.Headers["Accept"]; + } + } + + public string Authorization + { + get + { + return string.IsNullOrEmpty(request.Headers["Authorization"]) ? null : request.Headers["Authorization"]; + } + } + + protected bool validate_cookies, validate_query_string, validate_form; + protected bool checked_cookies, checked_query_string, checked_form; + + static void ThrowValidationException(string name, string key, string value) + { + string v = "\"" + value + "\""; + if (v.Length > 20) + v = v.Substring(0, 16) + "...\""; + + string msg = String.Format("A potentially dangerous Request.{0} value was " + + "detected from the client ({1}={2}).", name, key, v); + + throw new Exception(msg); + } + + static void ValidateNameValueCollection(string name, QueryParamCollection coll) + { + if (coll == null) + return; + + foreach (var pair in coll) + { + var key = pair.Name; + var val = pair.Value; + if (val != null && val.Length > 0 && IsInvalidString(val)) + ThrowValidationException(name, key, val); + } + } + + internal static bool IsInvalidString(string val) + { + int validationFailureIndex; + + return IsInvalidString(val, out validationFailureIndex); + } + + internal static bool IsInvalidString(string val, out int validationFailureIndex) + { + validationFailureIndex = 0; + + int len = val.Length; + if (len < 2) + return false; + + char current = val[0]; + for (int idx = 1; idx < len; idx++) + { + char next = val[idx]; + // See http://secunia.com/advisories/14325 + if (current == '<' || current == '\xff1c') + { + if (next == '!' || next < ' ' + || (next >= 'a' && next <= 'z') + || (next >= 'A' && next <= 'Z')) + { + validationFailureIndex = idx - 1; + return true; + } + } + else if (current == '&' && next == '#') + { + validationFailureIndex = idx - 1; + return true; + } + + current = next; + } + + return false; + } + + public void ValidateInput() + { + validate_cookies = true; + validate_query_string = true; + validate_form = true; + } + + bool IsContentType(string ct, bool starts_with) + { + if (ct == null || ContentType == null) return false; + + if (starts_with) + return StrUtils.StartsWith(ContentType, ct, true); + + return string.Equals(ContentType, ct, StringComparison.OrdinalIgnoreCase); + } + + async Task LoadWwwForm(WebROCollection form) + { + using (Stream input = InputStream) + { + using (var ms = new MemoryStream()) + { + await input.CopyToAsync(ms).ConfigureAwait(false); + ms.Position = 0; + + using (StreamReader s = new StreamReader(ms, ContentEncoding)) + { + StringBuilder key = new StringBuilder(); + StringBuilder value = new StringBuilder(); + int c; + + while ((c = s.Read()) != -1) + { + if (c == '=') + { + value.Length = 0; + while ((c = s.Read()) != -1) + { + if (c == '&') + { + AddRawKeyValue(form, key, value); + break; + } + else + value.Append((char)c); + } + if (c == -1) + { + AddRawKeyValue(form, key, value); + return; + } + } + else if (c == '&') + AddRawKeyValue(form, key, value); + else + key.Append((char)c); + } + if (c == -1) + AddRawKeyValue(form, key, value); + } + } + } + } + + void AddRawKeyValue(WebROCollection form, StringBuilder key, StringBuilder value) + { + string decodedKey = WebUtility.UrlDecode(key.ToString()); + form.Add(decodedKey, + WebUtility.UrlDecode(value.ToString())); + + key.Length = 0; + value.Length = 0; + } + + Dictionary<string, HttpPostedFile> files; + + class WebROCollection : QueryParamCollection + { + public override string ToString() + { + StringBuilder result = new StringBuilder(); + foreach (var pair in this) + { + if (result.Length > 0) + result.Append('&'); + + var key = pair.Name; + if (key != null && key.Length > 0) + { + result.Append(key); + result.Append('='); + } + result.Append(pair.Value); + } + + return result.ToString(); + } + } + + public sealed class HttpPostedFile + { + string name; + string content_type; + Stream stream; + + class ReadSubStream : Stream + { + Stream s; + long offset; + long end; + long position; + + public ReadSubStream(Stream s, long offset, long length) + { + this.s = s; + this.offset = offset; + this.end = offset + length; + position = offset; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int dest_offset, int count) + { + if (buffer == null) + throw new ArgumentNullException("buffer"); + + if (dest_offset < 0) + throw new ArgumentOutOfRangeException("dest_offset", "< 0"); + + if (count < 0) + throw new ArgumentOutOfRangeException("count", "< 0"); + + int len = buffer.Length; + if (dest_offset > len) + throw new ArgumentException("destination offset is beyond array size"); + // reordered to avoid possible integer overflow + if (dest_offset > len - count) + throw new ArgumentException("Reading would overrun buffer"); + + if (count > end - position) + count = (int)(end - position); + + if (count <= 0) + return 0; + + s.Position = position; + int result = s.Read(buffer, dest_offset, count); + if (result > 0) + position += result; + else + position = end; + + return result; + } + + public override int ReadByte() + { + if (position >= end) + return -1; + + s.Position = position; + int result = s.ReadByte(); + if (result < 0) + position = end; + else + position++; + + return result; + } + + public override long Seek(long d, SeekOrigin origin) + { + long real; + switch (origin) + { + case SeekOrigin.Begin: + real = offset + d; + break; + case SeekOrigin.End: + real = end + d; + break; + case SeekOrigin.Current: + real = position + d; + break; + default: + throw new ArgumentException(); + } + + long virt = real - offset; + if (virt < 0 || virt > Length) + throw new ArgumentException(); + + position = s.Seek(real, SeekOrigin.Begin); + return position; + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override bool CanRead + { + get { return true; } + } + public override bool CanSeek + { + get { return true; } + } + public override bool CanWrite + { + get { return false; } + } + + public override long Length + { + get { return end - offset; } + } + + public override long Position + { + get + { + return position - offset; + } + set + { + if (value > Length) + throw new ArgumentOutOfRangeException(); + + position = Seek(value, SeekOrigin.Begin); + } + } + } + + internal HttpPostedFile(string name, string content_type, Stream base_stream, long offset, long length) + { + this.name = name; + this.content_type = content_type; + this.stream = new ReadSubStream(base_stream, offset, length); + } + + public string ContentType + { + get + { + return content_type; + } + } + + public int ContentLength + { + get + { + return (int)stream.Length; + } + } + + public string FileName + { + get + { + return name; + } + } + + public Stream InputStream + { + get + { + return stream; + } + } + } + + class Helpers + { + public static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture; + } + + internal sealed class StrUtils + { + public static bool StartsWith(string str1, string str2, bool ignore_case) + { + if (string.IsNullOrEmpty(str1)) + { + return false; + } + + var comparison = ignore_case ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return str1.IndexOf(str2, comparison) == 0; + } + + public static bool EndsWith(string str1, string str2, bool ignore_case) + { + int l2 = str2.Length; + if (l2 == 0) + return true; + + int l1 = str1.Length; + if (l2 > l1) + return false; + + var comparison = ignore_case ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return str1.IndexOf(str2, comparison) == str1.Length - str2.Length - 1; + } + } + + class HttpMultipart + { + + public class Element + { + public string ContentType; + public string Name; + public string Filename; + public Encoding Encoding; + public long Start; + public long Length; + + public override string ToString() + { + return "ContentType " + ContentType + ", Name " + Name + ", Filename " + Filename + ", Start " + + Start.ToString() + ", Length " + Length.ToString(); + } + } + + Stream data; + string boundary; + byte[] boundary_bytes; + byte[] buffer; + bool at_eof; + Encoding encoding; + StringBuilder sb; + + const byte HYPHEN = (byte)'-', LF = (byte)'\n', CR = (byte)'\r'; + + // See RFC 2046 + // In the case of multipart entities, in which one or more different + // sets of data are combined in a single body, a "multipart" media type + // field must appear in the entity's header. The body must then contain + // one or more body parts, each preceded by a boundary delimiter line, + // and the last one followed by a closing boundary delimiter line. + // After its boundary delimiter line, each body part then consists of a + // header area, a blank line, and a body area. Thus a body part is + // similar to an RFC 822 message in syntax, but different in meaning. + + public HttpMultipart(Stream data, string b, Encoding encoding) + { + this.data = data; + //DB: 30/01/11: cannot set or read the Position in HttpListener in Win.NET + //var ms = new MemoryStream(32 * 1024); + //data.CopyTo(ms); + //this.data = ms; + + boundary = b; + boundary_bytes = encoding.GetBytes(b); + buffer = new byte[boundary_bytes.Length + 2]; // CRLF or '--' + this.encoding = encoding; + sb = new StringBuilder(); + } + + string ReadLine() + { + // CRLF or LF are ok as line endings. + bool got_cr = false; + int b = 0; + sb.Length = 0; + while (true) + { + b = data.ReadByte(); + if (b == -1) + { + return null; + } + + if (b == LF) + { + break; + } + got_cr = b == CR; + sb.Append((char)b); + } + + if (got_cr) + sb.Length--; + + return sb.ToString(); + + } + + static string GetContentDispositionAttribute(string l, string name) + { + int idx = l.IndexOf(name + "=\""); + if (idx < 0) + return null; + int begin = idx + name.Length + "=\"".Length; + int end = l.IndexOf('"', begin); + if (end < 0) + return null; + if (begin == end) + return ""; + return l.Substring(begin, end - begin); + } + + string GetContentDispositionAttributeWithEncoding(string l, string name) + { + int idx = l.IndexOf(name + "=\""); + if (idx < 0) + return null; + int begin = idx + name.Length + "=\"".Length; + int end = l.IndexOf('"', begin); + if (end < 0) + return null; + if (begin == end) + return ""; + + string temp = l.Substring(begin, end - begin); + byte[] source = new byte[temp.Length]; + for (int i = temp.Length - 1; i >= 0; i--) + source[i] = (byte)temp[i]; + + return encoding.GetString(source, 0, source.Length); + } + + bool ReadBoundary() + { + try + { + string line = ReadLine(); + while (line == "") + line = ReadLine(); + if (line[0] != '-' || line[1] != '-') + return false; + + if (!StrUtils.EndsWith(line, boundary, false)) + return true; + } + catch + { + } + + return false; + } + + string ReadHeaders() + { + string s = ReadLine(); + if (s == "") + return null; + + return s; + } + + bool CompareBytes(byte[] orig, byte[] other) + { + for (int i = orig.Length - 1; i >= 0; i--) + if (orig[i] != other[i]) + return false; + + return true; + } + + long MoveToNextBoundary() + { + long retval = 0; + bool got_cr = false; + + int state = 0; + int c = data.ReadByte(); + while (true) + { + if (c == -1) + return -1; + + if (state == 0 && c == LF) + { + retval = data.Position - 1; + if (got_cr) + retval--; + state = 1; + c = data.ReadByte(); + } + else if (state == 0) + { + got_cr = c == CR; + c = data.ReadByte(); + } + else if (state == 1 && c == '-') + { + c = data.ReadByte(); + if (c == -1) + return -1; + + if (c != '-') + { + state = 0; + got_cr = false; + continue; // no ReadByte() here + } + + int nread = data.Read(buffer, 0, buffer.Length); + int bl = buffer.Length; + if (nread != bl) + return -1; + + if (!CompareBytes(boundary_bytes, buffer)) + { + state = 0; + data.Position = retval + 2; + if (got_cr) + { + data.Position++; + got_cr = false; + } + c = data.ReadByte(); + continue; + } + + if (buffer[bl - 2] == '-' && buffer[bl - 1] == '-') + { + at_eof = true; + } + else if (buffer[bl - 2] != CR || buffer[bl - 1] != LF) + { + state = 0; + data.Position = retval + 2; + if (got_cr) + { + data.Position++; + got_cr = false; + } + c = data.ReadByte(); + continue; + } + data.Position = retval + 2; + if (got_cr) + data.Position++; + break; + } + else + { + // state == 1 + state = 0; // no ReadByte() here + } + } + + return retval; + } + + public Element ReadNextElement() + { + if (at_eof || ReadBoundary()) + return null; + + Element elem = new Element(); + string header; + while ((header = ReadHeaders()) != null) + { + if (StrUtils.StartsWith(header, "Content-Disposition:", true)) + { + elem.Name = GetContentDispositionAttribute(header, "name"); + elem.Filename = StripPath(GetContentDispositionAttributeWithEncoding(header, "filename")); + } + else if (StrUtils.StartsWith(header, "Content-Type:", true)) + { + elem.ContentType = header.Substring("Content-Type:".Length).Trim(); + elem.Encoding = GetEncoding(elem.ContentType); + } + } + + long start = 0; + start = data.Position; + elem.Start = start; + long pos = MoveToNextBoundary(); + if (pos == -1) + return null; + + elem.Length = pos - start; + return elem; + } + + static string StripPath(string path) + { + if (path == null || path.Length == 0) + return path; + + if (path.IndexOf(":\\") != 1 && !path.StartsWith("\\\\")) + return path; + return path.Substring(path.LastIndexOf('\\') + 1); + } + } + } +} diff --git a/Jellyfin.Server/SocketSharp/SharpWebSocket.cs b/Jellyfin.Server/SocketSharp/SharpWebSocket.cs new file mode 100644 index 000000000..1c72035a5 --- /dev/null +++ b/Jellyfin.Server/SocketSharp/SharpWebSocket.cs @@ -0,0 +1,159 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Net.WebSockets; +using Emby.Server.Implementations.Net; +using MediaBrowser.Common.Events; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.SocketSharp +{ + public class SharpWebSocket : IWebSocket + { + /// <summary> + /// The logger + /// </summary> + private readonly ILogger _logger; + + public event EventHandler<EventArgs> Closed; + + /// <summary> + /// Gets or sets the web socket. + /// </summary> + /// <value>The web socket.</value> + private SocketHttpListener.WebSocket WebSocket { get; set; } + + private TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + + public SharpWebSocket(SocketHttpListener.WebSocket socket, ILogger logger) + { + if (socket == null) + { + throw new ArgumentNullException("socket"); + } + + if (logger == null) + { + throw new ArgumentNullException("logger"); + } + + _logger = logger; + WebSocket = socket; + + socket.OnMessage += socket_OnMessage; + socket.OnClose += socket_OnClose; + socket.OnError += socket_OnError; + + WebSocket.ConnectAsServer(); + } + + public Task StartReceive() + { + return _taskCompletionSource.Task; + } + + void socket_OnError(object sender, SocketHttpListener.ErrorEventArgs e) + { + _logger.LogError("Error in SharpWebSocket: {0}", e.Message ?? string.Empty); + //EventHelper.FireEventIfNotNull(Closed, this, EventArgs.Empty, _logger); + } + + void socket_OnClose(object sender, SocketHttpListener.CloseEventArgs e) + { + _taskCompletionSource.TrySetResult(true); + + EventHelper.FireEventIfNotNull(Closed, this, EventArgs.Empty, _logger); + } + + void socket_OnMessage(object sender, SocketHttpListener.MessageEventArgs e) + { + //if (!string.IsNullOrEmpty(e.Data)) + //{ + // if (OnReceive != null) + // { + // OnReceive(e.Data); + // } + // return; + //} + if (OnReceiveBytes != null) + { + OnReceiveBytes(e.RawData); + } + } + + /// <summary> + /// Gets or sets the state. + /// </summary> + /// <value>The state.</value> + public WebSocketState State + { + get + { + return WebSocket.ReadyState; + } + } + + /// <summary> + /// Sends the async. + /// </summary> + /// <param name="bytes">The bytes.</param> + /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendAsync(byte[] bytes, bool endOfMessage, CancellationToken cancellationToken) + { + return WebSocket.SendAsync(bytes); + } + + /// <summary> + /// Sends the asynchronous. + /// </summary> + /// <param name="text">The text.</param> + /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendAsync(string text, bool endOfMessage, CancellationToken cancellationToken) + { + return WebSocket.SendAsync(text); + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + WebSocket.OnMessage -= socket_OnMessage; + WebSocket.OnClose -= socket_OnClose; + WebSocket.OnError -= socket_OnError; + + _cancellationTokenSource.Cancel(); + + WebSocket.Close(); + } + } + + /// <summary> + /// Gets or sets the receive action. + /// </summary> + /// <value>The receive action.</value> + public Action<byte[]> OnReceiveBytes { get; set; } + + /// <summary> + /// Gets or sets the on receive. + /// </summary> + /// <value>The on receive.</value> + public Action<string> OnReceive { get; set; } + } +} diff --git a/Jellyfin.Server/SocketSharp/WebSocketSharpListener.cs b/Jellyfin.Server/SocketSharp/WebSocketSharpListener.cs new file mode 100644 index 000000000..c360a8fce --- /dev/null +++ b/Jellyfin.Server/SocketSharp/WebSocketSharpListener.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Net; +using Emby.Server.Implementations.HttpServer; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Cryptography; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Services; +using MediaBrowser.Model.System; +using MediaBrowser.Model.Text; +using Microsoft.Extensions.Logging; +using SocketHttpListener.Net; + +namespace Jellyfin.SocketSharp +{ + public class WebSocketSharpListener : IHttpListener + { + private HttpListener _listener; + + private readonly ILogger _logger; + private readonly X509Certificate _certificate; + private readonly IStreamHelper _streamHelper; + private readonly ITextEncoding _textEncoding; + private readonly INetworkManager _networkManager; + private readonly ISocketFactory _socketFactory; + private readonly ICryptoProvider _cryptoProvider; + private readonly IFileSystem _fileSystem; + private readonly bool _enableDualMode; + private readonly IEnvironmentInfo _environment; + + private CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); + private CancellationToken _disposeCancellationToken; + + public WebSocketSharpListener(ILogger logger, X509Certificate certificate, IStreamHelper streamHelper, ITextEncoding textEncoding, INetworkManager networkManager, ISocketFactory socketFactory, ICryptoProvider cryptoProvider, bool enableDualMode, IFileSystem fileSystem, IEnvironmentInfo environment) + { + _logger = logger; + _certificate = certificate; + _streamHelper = streamHelper; + _textEncoding = textEncoding; + _networkManager = networkManager; + _socketFactory = socketFactory; + _cryptoProvider = cryptoProvider; + _enableDualMode = enableDualMode; + _fileSystem = fileSystem; + _environment = environment; + + _disposeCancellationToken = _disposeCancellationTokenSource.Token; + } + + public Func<Exception, IRequest, bool, bool, Task> ErrorHandler { get; set; } + public Func<IHttpRequest, string, string, string, CancellationToken, Task> RequestHandler { get; set; } + + public Action<WebSocketConnectingEventArgs> WebSocketConnecting { get; set; } + + public Action<WebSocketConnectEventArgs> WebSocketConnected { get; set; } + + public void Start(IEnumerable<string> urlPrefixes) + { + if (_listener == null) + _listener = new HttpListener(_logger, _cryptoProvider, _socketFactory, _networkManager, _textEncoding, _streamHelper, _fileSystem, _environment); + + _listener.EnableDualMode = _enableDualMode; + + if (_certificate != null) + { + _listener.LoadCert(_certificate); + } + + foreach (var prefix in urlPrefixes) + { + _logger.LogInformation("Adding HttpListener prefix " + prefix); + _listener.Prefixes.Add(prefix); + } + + _listener.OnContext = ProcessContext; + + _listener.Start(); + } + + private void ProcessContext(HttpListenerContext context) + { + //InitTask(context, _disposeCancellationToken); + Task.Run(() => InitTask(context, _disposeCancellationToken)); + } + + private void LogRequest(ILogger logger, HttpListenerRequest request) + { + var url = request.Url.ToString(); + + logger.LogInformation("{0} {1}. UserAgent: {2}", request.IsWebSocketRequest ? "WS" : "HTTP " + request.HttpMethod, url, request.UserAgent ?? string.Empty); + } + + private Task InitTask(HttpListenerContext context, CancellationToken cancellationToken) + { + IHttpRequest httpReq = null; + var request = context.Request; + + try + { + if (request.IsWebSocketRequest) + { + LogRequest(_logger, request); + + return ProcessWebSocketRequest(context); + } + + httpReq = GetRequest(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing request"); + + httpReq = httpReq ?? GetRequest(context); + return ErrorHandler(ex, httpReq, true, true); + } + + var uri = request.Url; + + return RequestHandler(httpReq, uri.OriginalString, uri.Host, uri.LocalPath, cancellationToken); + } + + private async Task ProcessWebSocketRequest(HttpListenerContext ctx) + { + try + { + var endpoint = ctx.Request.RemoteEndPoint.ToString(); + var url = ctx.Request.RawUrl; + + var queryString = ctx.Request.QueryString; + + var connectingArgs = new WebSocketConnectingEventArgs + { + Url = url, + QueryString = queryString, + Endpoint = endpoint + }; + + if (WebSocketConnecting != null) + { + WebSocketConnecting(connectingArgs); + } + + if (connectingArgs.AllowConnection) + { + _logger.LogDebug("Web socket connection allowed"); + + var webSocketContext = await ctx.AcceptWebSocketAsync(null).ConfigureAwait(false); + + if (WebSocketConnected != null) + { + var socket = new SharpWebSocket(webSocketContext.WebSocket, _logger); + + WebSocketConnected(new WebSocketConnectEventArgs + { + Url = url, + QueryString = queryString, + WebSocket = socket, + Endpoint = endpoint + }); + + await ReceiveWebSocket(ctx, socket).ConfigureAwait(false); + } + } + else + { + _logger.LogWarning("Web socket connection not allowed"); + ctx.Response.StatusCode = 401; + ctx.Response.Close(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "AcceptWebSocketAsync error"); + ctx.Response.StatusCode = 500; + ctx.Response.Close(); + } + } + + private async Task ReceiveWebSocket(HttpListenerContext ctx, SharpWebSocket socket) + { + try + { + await socket.StartReceive().ConfigureAwait(false); + } + finally + { + TryClose(ctx, 200); + } + } + + private void TryClose(HttpListenerContext ctx, int statusCode) + { + try + { + ctx.Response.StatusCode = 200; + ctx.Response.Close(); + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing web socket response"); + } + } + + private IHttpRequest GetRequest(HttpListenerContext httpContext) + { + var urlSegments = httpContext.Request.Url.Segments; + + var operationName = urlSegments[urlSegments.Length - 1]; + + var req = new WebSocketSharpRequest(httpContext, operationName, _logger); + + return req; + } + + public Task Stop() + { + _disposeCancellationTokenSource.Cancel(); + + if (_listener != null) + { + _listener.Close(); + } + + return Task.CompletedTask; + } + + public void Dispose() + { + Dispose(true); + } + + private bool _disposed; + private readonly object _disposeLock = new object(); + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + + lock (_disposeLock) + { + if (_disposed) return; + + if (disposing) + { + Stop(); + } + + //release unmanaged resources here... + _disposed = true; + } + } + } +} diff --git a/Jellyfin.Server/SocketSharp/WebSocketSharpRequest.cs b/Jellyfin.Server/SocketSharp/WebSocketSharpRequest.cs new file mode 100644 index 000000000..7c9dc8f88 --- /dev/null +++ b/Jellyfin.Server/SocketSharp/WebSocketSharpRequest.cs @@ -0,0 +1,554 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Emby.Server.Implementations.HttpServer; +using MediaBrowser.Model.Services; +using Microsoft.Extensions.Logging; +using SocketHttpListener.Net; +using IHttpFile = MediaBrowser.Model.Services.IHttpFile; +using IHttpRequest = MediaBrowser.Model.Services.IHttpRequest; +using IHttpResponse = MediaBrowser.Model.Services.IHttpResponse; +using IResponse = MediaBrowser.Model.Services.IResponse; + +namespace Jellyfin.SocketSharp +{ + public partial class WebSocketSharpRequest : IHttpRequest + { + private readonly HttpListenerRequest request; + private readonly IHttpResponse response; + + public WebSocketSharpRequest(HttpListenerContext httpContext, string operationName, ILogger logger) + { + this.OperationName = operationName; + this.request = httpContext.Request; + this.response = new WebSocketSharpResponse(logger, httpContext.Response, this); + + //HandlerFactoryPath = GetHandlerPathIfAny(UrlPrefixes[0]); + } + + private static string GetHandlerPathIfAny(string listenerUrl) + { + if (listenerUrl == null) return null; + var pos = listenerUrl.IndexOf("://", StringComparison.OrdinalIgnoreCase); + if (pos == -1) return null; + var startHostUrl = listenerUrl.Substring(pos + "://".Length); + var endPos = startHostUrl.IndexOf('/'); + if (endPos == -1) return null; + var endHostUrl = startHostUrl.Substring(endPos + 1); + return string.IsNullOrEmpty(endHostUrl) ? null : endHostUrl.TrimEnd('/'); + } + + public HttpListenerRequest HttpRequest + { + get { return request; } + } + + public object OriginalRequest + { + get { return request; } + } + + public IResponse Response + { + get { return response; } + } + + public IHttpResponse HttpResponse + { + get { return response; } + } + + public string OperationName { get; set; } + + public object Dto { get; set; } + + public string RawUrl + { + get { return request.RawUrl; } + } + + public string AbsoluteUri + { + get { return request.Url.AbsoluteUri.TrimEnd('/'); } + } + + public string UserHostAddress + { + get { return request.UserHostAddress; } + } + + public string XForwardedFor + { + get + { + return String.IsNullOrEmpty(request.Headers["X-Forwarded-For"]) ? null : request.Headers["X-Forwarded-For"]; + } + } + + public int? XForwardedPort + { + get + { + return string.IsNullOrEmpty(request.Headers["X-Forwarded-Port"]) ? (int?)null : int.Parse(request.Headers["X-Forwarded-Port"]); + } + } + + public string XForwardedProtocol + { + get + { + return string.IsNullOrEmpty(request.Headers["X-Forwarded-Proto"]) ? null : request.Headers["X-Forwarded-Proto"]; + } + } + + public string XRealIp + { + get + { + return String.IsNullOrEmpty(request.Headers["X-Real-IP"]) ? null : request.Headers["X-Real-IP"]; + } + } + + private string remoteIp; + public string RemoteIp + { + get + { + return remoteIp ?? + (remoteIp = (CheckBadChars(XForwardedFor)) ?? + (NormalizeIp(CheckBadChars(XRealIp)) ?? + (request.RemoteEndPoint != null ? NormalizeIp(request.RemoteEndPoint.Address.ToString()) : null))); + } + } + + private static readonly char[] HttpTrimCharacters = new char[] { (char)0x09, (char)0xA, (char)0xB, (char)0xC, (char)0xD, (char)0x20 }; + + // + // CheckBadChars - throws on invalid chars to be not found in header name/value + // + internal static string CheckBadChars(string name) + { + if (name == null || name.Length == 0) + { + return name; + } + + // VALUE check + //Trim spaces from both ends + name = name.Trim(HttpTrimCharacters); + + //First, check for correctly formed multi-line value + //Second, check for absenece of CTL characters + int crlf = 0; + for (int i = 0; i < name.Length; ++i) + { + char c = (char)(0x000000ff & (uint)name[i]); + switch (crlf) + { + case 0: + if (c == '\r') + { + crlf = 1; + } + else if (c == '\n') + { + // Technically this is bad HTTP. But it would be a breaking change to throw here. + // Is there an exploit? + crlf = 2; + } + else if (c == 127 || (c < ' ' && c != '\t')) + { + throw new ArgumentException("net_WebHeaderInvalidControlChars"); + } + break; + + case 1: + if (c == '\n') + { + crlf = 2; + break; + } + throw new ArgumentException("net_WebHeaderInvalidCRLFChars"); + + case 2: + if (c == ' ' || c == '\t') + { + crlf = 0; + break; + } + throw new ArgumentException("net_WebHeaderInvalidCRLFChars"); + } + } + if (crlf != 0) + { + throw new ArgumentException("net_WebHeaderInvalidCRLFChars"); + } + return name; + } + + internal static bool ContainsNonAsciiChars(string token) + { + for (int i = 0; i < token.Length; ++i) + { + if ((token[i] < 0x20) || (token[i] > 0x7e)) + { + return true; + } + } + return false; + } + + private string NormalizeIp(string ip) + { + if (!string.IsNullOrWhiteSpace(ip)) + { + // Handle ipv4 mapped to ipv6 + const string srch = "::ffff:"; + var index = ip.IndexOf(srch, StringComparison.OrdinalIgnoreCase); + if (index == 0) + { + ip = ip.Substring(srch.Length); + } + } + + return ip; + } + + public bool IsSecureConnection + { + get { return request.IsSecureConnection || XForwardedProtocol == "https"; } + } + + public string[] AcceptTypes + { + get { return request.AcceptTypes; } + } + + private Dictionary<string, object> items; + public Dictionary<string, object> Items + { + get { return items ?? (items = new Dictionary<string, object>()); } + } + + private string responseContentType; + public string ResponseContentType + { + get + { + return responseContentType + ?? (responseContentType = GetResponseContentType(this)); + } + set + { + this.responseContentType = value; + } + } + + public const string FormUrlEncoded = "application/x-www-form-urlencoded"; + public const string MultiPartFormData = "multipart/form-data"; + public static string GetResponseContentType(IRequest httpReq) + { + var specifiedContentType = GetQueryStringContentType(httpReq); + if (!string.IsNullOrEmpty(specifiedContentType)) return specifiedContentType; + + var serverDefaultContentType = "application/json"; + + var acceptContentTypes = httpReq.AcceptTypes; + string defaultContentType = null; + if (HasAnyOfContentTypes(httpReq, FormUrlEncoded, MultiPartFormData)) + { + defaultContentType = serverDefaultContentType; + } + + var acceptsAnything = false; + var hasDefaultContentType = !string.IsNullOrEmpty(defaultContentType); + if (acceptContentTypes != null) + { + foreach (var acceptsType in acceptContentTypes) + { + var contentType = HttpResultFactory.GetRealContentType(acceptsType); + acceptsAnything = acceptsAnything || contentType == "*/*"; + } + + if (acceptsAnything) + { + if (hasDefaultContentType) + return defaultContentType; + if (serverDefaultContentType != null) + 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 const string Soap11 = "text/xml; charset=utf-8"; + + public static bool HasAnyOfContentTypes(IRequest 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(IRequest request, string contentType) + { + return request.ContentType.StartsWith(contentType, StringComparison.OrdinalIgnoreCase); + } + + public const string Xml = "application/xml"; + private static string GetQueryStringContentType(IRequest httpReq) + { + var format = httpReq.QueryString["format"]; + if (format == null) + { + const int formatMaxLength = 4; + var pi = httpReq.PathInfo; + if (pi == null || pi.Length <= formatMaxLength) return null; + if (pi[0] == '/') pi = pi.Substring(1); + format = LeftPart(pi, '/'); + if (format.Length > formatMaxLength) return null; + } + + format = LeftPart(format, '.').ToLower(); + if (format.Contains("json")) return "application/json"; + if (format.Contains("xml")) return Xml; + + return null; + } + + public static string LeftPart(string strVal, char needle) + { + if (strVal == null) return null; + var pos = strVal.IndexOf(needle); + return pos == -1 + ? strVal + : strVal.Substring(0, pos); + } + + public static string HandlerFactoryPath; + + private string pathInfo; + public string PathInfo + { + get + { + if (this.pathInfo == null) + { + var mode = HandlerFactoryPath; + + var pos = request.RawUrl.IndexOf("?"); + if (pos != -1) + { + var path = request.RawUrl.Substring(0, pos); + this.pathInfo = GetPathInfo( + path, + mode, + mode ?? ""); + } + else + { + this.pathInfo = request.RawUrl; + } + + this.pathInfo = System.Net.WebUtility.UrlDecode(pathInfo); + this.pathInfo = NormalizePathInfo(pathInfo, mode); + } + return this.pathInfo; + } + } + + private static string GetPathInfo(string fullPath, string mode, string appPath) + { + var pathInfo = ResolvePathInfoFromMappedPath(fullPath, mode); + if (!string.IsNullOrEmpty(pathInfo)) return pathInfo; + + //Wildcard mode relies on this to work out the handlerPath + pathInfo = ResolvePathInfoFromMappedPath(fullPath, appPath); + if (!string.IsNullOrEmpty(pathInfo)) return pathInfo; + + return fullPath; + } + + private static string ResolvePathInfoFromMappedPath(string fullPath, string mappedPathRoot) + { + if (mappedPathRoot == null) return null; + + var sbPathInfo = new StringBuilder(); + var fullPathParts = fullPath.Split('/'); + var mappedPathRootParts = mappedPathRoot.Split('/'); + var fullPathIndexOffset = mappedPathRootParts.Length - 1; + var pathRootFound = false; + + for (var fullPathIndex = 0; fullPathIndex < fullPathParts.Length; fullPathIndex++) + { + if (pathRootFound) + { + sbPathInfo.Append("/" + fullPathParts[fullPathIndex]); + } + else if (fullPathIndex - fullPathIndexOffset >= 0) + { + pathRootFound = true; + for (var mappedPathRootIndex = 0; mappedPathRootIndex < mappedPathRootParts.Length; mappedPathRootIndex++) + { + if (!string.Equals(fullPathParts[fullPathIndex - fullPathIndexOffset + mappedPathRootIndex], mappedPathRootParts[mappedPathRootIndex], StringComparison.OrdinalIgnoreCase)) + { + pathRootFound = false; + break; + } + } + } + } + if (!pathRootFound) return null; + + var path = sbPathInfo.ToString(); + return path.Length > 1 ? path.TrimEnd('/') : "/"; + } + + private Dictionary<string, System.Net.Cookie> cookies; + public IDictionary<string, System.Net.Cookie> Cookies + { + get + { + if (cookies == null) + { + cookies = new Dictionary<string, System.Net.Cookie>(); + foreach (var cookie in this.request.Cookies) + { + var httpCookie = (System.Net.Cookie) cookie; + cookies[httpCookie.Name] = new System.Net.Cookie(httpCookie.Name, httpCookie.Value, httpCookie.Path, httpCookie.Domain); + } + } + + return cookies; + } + } + + public string UserAgent + { + get { return request.UserAgent; } + } + + public QueryParamCollection Headers + { + get { return request.Headers; } + } + + private QueryParamCollection queryString; + public QueryParamCollection QueryString + { + get { return queryString ?? (queryString = MyHttpUtility.ParseQueryString(request.Url.Query)); } + } + + public bool IsLocal + { + get { return request.IsLocal; } + } + + private string httpMethod; + public string HttpMethod + { + get + { + return httpMethod + ?? (httpMethod = request.HttpMethod); + } + } + + public string Verb + { + get { return HttpMethod; } + } + + public string ContentType + { + get { return request.ContentType; } + } + + public Encoding contentEncoding; + public Encoding ContentEncoding + { + get { return contentEncoding ?? request.ContentEncoding; } + set { contentEncoding = value; } + } + + public Uri UrlReferrer + { + get { return request.UrlReferrer; } + } + + public static Encoding GetEncoding(string contentTypeHeader) + { + var param = GetParameter(contentTypeHeader, "charset="); + if (param == null) return null; + try + { + return Encoding.GetEncoding(param); + } + catch (ArgumentException) + { + return null; + } + } + + public Stream InputStream + { + get { return request.InputStream; } + } + + public long ContentLength + { + get { return request.ContentLength64; } + } + + private IHttpFile[] httpFiles; + public IHttpFile[] Files + { + get + { + if (httpFiles == null) + { + if (files == null) + return httpFiles = new IHttpFile[0]; + + httpFiles = new IHttpFile[files.Count]; + var i = 0; + foreach (var pair in files) + { + var reqFile = pair.Value; + httpFiles[i] = new HttpFile + { + ContentType = reqFile.ContentType, + ContentLength = reqFile.ContentLength, + FileName = reqFile.FileName, + InputStream = reqFile.InputStream, + }; + i++; + } + } + return httpFiles; + } + } + + public static string NormalizePathInfo(string pathInfo, string handlerPath) + { + if (handlerPath != null && pathInfo.TrimStart('/').StartsWith( + handlerPath, StringComparison.OrdinalIgnoreCase)) + { + return pathInfo.TrimStart('/').Substring(handlerPath.Length); + } + + return pathInfo; + } + } +} diff --git a/Jellyfin.Server/SocketSharp/WebSocketSharpResponse.cs b/Jellyfin.Server/SocketSharp/WebSocketSharpResponse.cs new file mode 100644 index 000000000..c7437c825 --- /dev/null +++ b/Jellyfin.Server/SocketSharp/WebSocketSharpResponse.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Services; +using Microsoft.Extensions.Logging; +using HttpListenerResponse = SocketHttpListener.Net.HttpListenerResponse; +using IHttpResponse = MediaBrowser.Model.Services.IHttpResponse; +using IRequest = MediaBrowser.Model.Services.IRequest; + + +namespace Jellyfin.SocketSharp +{ + public class WebSocketSharpResponse : IHttpResponse + { + private readonly ILogger _logger; + private readonly HttpListenerResponse _response; + + public WebSocketSharpResponse(ILogger logger, HttpListenerResponse response, IRequest request) + { + _logger = logger; + this._response = response; + Items = new Dictionary<string, object>(); + Request = request; + } + + public IRequest Request { get; private set; } + public Dictionary<string, object> Items { get; private set; } + public object OriginalResponse + { + get { return _response; } + } + + public int StatusCode + { + get { return this._response.StatusCode; } + set { this._response.StatusCode = value; } + } + + public string StatusDescription + { + get { return this._response.StatusDescription; } + set { this._response.StatusDescription = value; } + } + + public string ContentType + { + get { return _response.ContentType; } + set { _response.ContentType = value; } + } + + //public ICookies Cookies { get; set; } + + public void AddHeader(string name, string value) + { + if (string.Equals(name, "Content-Type", StringComparison.OrdinalIgnoreCase)) + { + ContentType = value; + return; + } + + _response.AddHeader(name, value); + } + + public QueryParamCollection Headers + { + get + { + return _response.Headers; + } + } + + public string GetHeader(string name) + { + return _response.Headers[name]; + } + + public void Redirect(string url) + { + _response.Redirect(url); + } + + public Stream OutputStream + { + get { return _response.OutputStream; } + } + + public void Close() + { + if (!this.IsClosed) + { + this.IsClosed = true; + + try + { + var response = this._response; + + var outputStream = response.OutputStream; + + // This is needed with compression + outputStream.Flush(); + outputStream.Dispose(); + + response.Close(); + } + catch (SocketException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in HttpListenerResponseWrapper"); + } + } + } + + public bool IsClosed + { + get; + private set; + } + + public void SetContentLength(long contentLength) + { + //you can happily set the Content-Length header in Asp.Net + //but HttpListener will complain if you do - you have to set ContentLength64 on the response. + //workaround: HttpListener throws "The parameter is incorrect" exceptions when we try to set the Content-Length header + _response.ContentLength64 = contentLength; + } + + public void SetCookie(Cookie cookie) + { + var cookieStr = AsHeaderValue(cookie); + _response.Headers.Add("Set-Cookie", cookieStr); + } + + public static string AsHeaderValue(Cookie cookie) + { + var defaultExpires = DateTime.MinValue; + + var path = cookie.Expires == defaultExpires + ? "/" + : cookie.Path ?? "/"; + + var sb = new StringBuilder(); + + sb.Append($"{cookie.Name}={cookie.Value};path={path}"); + + if (cookie.Expires != defaultExpires) + { + sb.Append($";expires={cookie.Expires:R}"); + } + + if (!string.IsNullOrEmpty(cookie.Domain)) + { + sb.Append($";domain={cookie.Domain}"); + } + //else if (restrictAllCookiesToDomain != null) + //{ + // sb.Append($";domain={restrictAllCookiesToDomain}"); + //} + + if (cookie.Secure) + { + sb.Append(";Secure"); + } + if (cookie.HttpOnly) + { + sb.Append(";HttpOnly"); + } + + return sb.ToString(); + } + + + public bool SendChunked + { + get { return _response.SendChunked; } + set { _response.SendChunked = value; } + } + + public bool KeepAlive { get; set; } + + public void ClearCookies() + { + } + + public Task TransmitFile(string path, long offset, long count, FileShareMode fileShareMode, CancellationToken cancellationToken) + { + return _response.TransmitFile(path, offset, count, fileShareMode, cancellationToken); + } + } +} |
