aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/Connect/ConnectData.cs36
-rw-r--r--Emby.Server.Implementations/Connect/ConnectEntryPoint.cs199
-rw-r--r--Emby.Server.Implementations/Connect/ConnectManager.cs1189
-rw-r--r--Emby.Server.Implementations/Connect/Responses.cs85
-rw-r--r--Emby.Server.Implementations/Connect/Validator.cs29
-rw-r--r--Emby.Server.Implementations/Emby.Server.Implementations.csproj9
-rw-r--r--Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs85
-rw-r--r--Emby.Server.Implementations/IO/FileRefresher.cs323
-rw-r--r--Emby.Server.Implementations/Security/EncryptionManager.cs51
-rw-r--r--Emby.Server.Implementations/Udp/UdpServer.cs247
10 files changed, 2253 insertions, 0 deletions
diff --git a/Emby.Server.Implementations/Connect/ConnectData.cs b/Emby.Server.Implementations/Connect/ConnectData.cs
new file mode 100644
index 000000000..41b89ce52
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/ConnectData.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ConnectData
+ {
+ /// <summary>
+ /// Gets or sets the server identifier.
+ /// </summary>
+ /// <value>The server identifier.</value>
+ public string ServerId { get; set; }
+ /// <summary>
+ /// Gets or sets the access key.
+ /// </summary>
+ /// <value>The access key.</value>
+ public string AccessKey { get; set; }
+
+ /// <summary>
+ /// Gets or sets the authorizations.
+ /// </summary>
+ /// <value>The authorizations.</value>
+ public List<ConnectAuthorizationInternal> PendingAuthorizations { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last authorizations refresh.
+ /// </summary>
+ /// <value>The last authorizations refresh.</value>
+ public DateTime LastAuthorizationsRefresh { get; set; }
+
+ public ConnectData()
+ {
+ PendingAuthorizations = new List<ConnectAuthorizationInternal>();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/ConnectEntryPoint.cs b/Emby.Server.Implementations/Connect/ConnectEntryPoint.cs
new file mode 100644
index 000000000..d7574d466
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/ConnectEntryPoint.cs
@@ -0,0 +1,199 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Connect;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ConnectEntryPoint : IServerEntryPoint
+ {
+ private ITimer _timer;
+ private readonly IHttpClient _httpClient;
+ private readonly IApplicationPaths _appPaths;
+ private readonly ILogger _logger;
+ private readonly IConnectManager _connectManager;
+
+ private readonly INetworkManager _networkManager;
+ private readonly IApplicationHost _appHost;
+ private readonly IFileSystem _fileSystem;
+ private readonly ITimerFactory _timerFactory;
+
+ public ConnectEntryPoint(IHttpClient httpClient, IApplicationPaths appPaths, ILogger logger, INetworkManager networkManager, IConnectManager connectManager, IApplicationHost appHost, IFileSystem fileSystem, ITimerFactory timerFactory)
+ {
+ _httpClient = httpClient;
+ _appPaths = appPaths;
+ _logger = logger;
+ _networkManager = networkManager;
+ _connectManager = connectManager;
+ _appHost = appHost;
+ _fileSystem = fileSystem;
+ _timerFactory = timerFactory;
+ }
+
+ public void Run()
+ {
+ LoadCachedAddress();
+
+ _timer = _timerFactory.Create(TimerCallback, null, TimeSpan.FromSeconds(5), TimeSpan.FromHours(1));
+ ((ConnectManager)_connectManager).Start();
+ }
+
+ private readonly string[] _ipLookups =
+ {
+ "http://bot.whatismyipaddress.com",
+ "https://connect.emby.media/service/ip"
+ };
+
+ private async void TimerCallback(object state)
+ {
+ IpAddressInfo validIpAddress = null;
+
+ foreach (var ipLookupUrl in _ipLookups)
+ {
+ try
+ {
+ validIpAddress = await GetIpAddress(ipLookupUrl).ConfigureAwait(false);
+
+ // Try to find the ipv4 address, if present
+ if (!validIpAddress.IsIpv6)
+ {
+ break;
+ }
+ }
+ catch (HttpException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting connection info", ex);
+ }
+ }
+
+ // If this produced an ipv6 address, try again
+ if (validIpAddress != null && validIpAddress.IsIpv6)
+ {
+ foreach (var ipLookupUrl in _ipLookups)
+ {
+ try
+ {
+ var newAddress = await GetIpAddress(ipLookupUrl, true).ConfigureAwait(false);
+
+ // Try to find the ipv4 address, if present
+ if (!newAddress.IsIpv6)
+ {
+ validIpAddress = newAddress;
+ break;
+ }
+ }
+ catch (HttpException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting connection info", ex);
+ }
+ }
+ }
+
+ if (validIpAddress != null)
+ {
+ ((ConnectManager)_connectManager).OnWanAddressResolved(validIpAddress);
+ CacheAddress(validIpAddress);
+ }
+ }
+
+ private async Task<IpAddressInfo> GetIpAddress(string lookupUrl, bool preferIpv4 = false)
+ {
+ // Sometimes whatismyipaddress might fail, but it won't do us any good having users raise alarms over it.
+ var logErrors = false;
+
+#if DEBUG
+ logErrors = true;
+#endif
+ using (var stream = await _httpClient.Get(new HttpRequestOptions
+ {
+ Url = lookupUrl,
+ UserAgent = "Emby/" + _appHost.ApplicationVersion,
+ LogErrors = logErrors,
+
+ // Seeing block length errors with our server
+ EnableHttpCompression = false,
+ PreferIpv4 = preferIpv4,
+ BufferContent = false
+
+ }).ConfigureAwait(false))
+ {
+ using (var reader = new StreamReader(stream))
+ {
+ var addressString = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+ return _networkManager.ParseIpAddress(addressString);
+ }
+ }
+ }
+
+ private string CacheFilePath
+ {
+ get { return Path.Combine(_appPaths.DataPath, "wan.txt"); }
+ }
+
+ private void CacheAddress(IpAddressInfo address)
+ {
+ var path = CacheFilePath;
+
+ try
+ {
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+ _fileSystem.WriteAllText(path, address.ToString(), Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving data", ex);
+ }
+ }
+
+ private void LoadCachedAddress()
+ {
+ var path = CacheFilePath;
+
+ _logger.Info("Loading data from {0}", path);
+
+ try
+ {
+ var endpoint = _fileSystem.ReadAllText(path, Encoding.UTF8);
+ IpAddressInfo ipAddress;
+
+ if (_networkManager.TryParseIpAddress(endpoint, out ipAddress))
+ {
+ ((ConnectManager)_connectManager).OnWanAddressResolved(ipAddress);
+ }
+ }
+ catch (IOException)
+ {
+ // File isn't there. no biggie
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error loading data", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/ConnectManager.cs b/Emby.Server.Implementations/Connect/ConnectManager.cs
new file mode 100644
index 000000000..6c2ac40c3
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/ConnectManager.cs
@@ -0,0 +1,1189 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Connect;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Model.Connect;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Extensions;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ConnectManager : IConnectManager
+ {
+ private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1);
+
+ private readonly ILogger _logger;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IJsonSerializer _json;
+ private readonly IEncryptionManager _encryption;
+ private readonly IHttpClient _httpClient;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IServerConfigurationManager _config;
+ private readonly IUserManager _userManager;
+ private readonly IProviderManager _providerManager;
+ private readonly ISecurityManager _securityManager;
+ private readonly IFileSystem _fileSystem;
+
+ private ConnectData _data = new ConnectData();
+
+ public string ConnectServerId
+ {
+ get { return _data.ServerId; }
+ }
+ public string ConnectAccessKey
+ {
+ get { return _data.AccessKey; }
+ }
+
+ private IpAddressInfo DiscoveredWanIpAddress { get; set; }
+
+ public string WanIpAddress
+ {
+ get
+ {
+ var address = _config.Configuration.WanDdns;
+
+ if (!string.IsNullOrWhiteSpace(address))
+ {
+ Uri newUri;
+
+ if (Uri.TryCreate(address, UriKind.Absolute, out newUri))
+ {
+ address = newUri.Host;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(address) && DiscoveredWanIpAddress != null)
+ {
+ if (DiscoveredWanIpAddress.IsIpv6)
+ {
+ address = "[" + DiscoveredWanIpAddress + "]";
+ }
+ else
+ {
+ address = DiscoveredWanIpAddress.ToString();
+ }
+ }
+
+ return address;
+ }
+ }
+
+ public string WanApiAddress
+ {
+ get
+ {
+ var ip = WanIpAddress;
+
+ if (!string.IsNullOrEmpty(ip))
+ {
+ if (!ip.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
+ !ip.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ ip = (_appHost.EnableHttps ? "https://" : "http://") + ip;
+ }
+
+ ip += ":";
+ ip += _appHost.EnableHttps ? _config.Configuration.PublicHttpsPort.ToString(CultureInfo.InvariantCulture) : _config.Configuration.PublicPort.ToString(CultureInfo.InvariantCulture);
+
+ return ip;
+ }
+
+ return null;
+ }
+ }
+
+ private string XApplicationValue
+ {
+ get { return _appHost.Name + "/" + _appHost.ApplicationVersion; }
+ }
+
+ public ConnectManager(ILogger logger,
+ IApplicationPaths appPaths,
+ IJsonSerializer json,
+ IEncryptionManager encryption,
+ IHttpClient httpClient,
+ IServerApplicationHost appHost,
+ IServerConfigurationManager config, IUserManager userManager, IProviderManager providerManager, ISecurityManager securityManager, IFileSystem fileSystem)
+ {
+ _logger = logger;
+ _appPaths = appPaths;
+ _json = json;
+ _encryption = encryption;
+ _httpClient = httpClient;
+ _appHost = appHost;
+ _config = config;
+ _userManager = userManager;
+ _providerManager = providerManager;
+ _securityManager = securityManager;
+ _fileSystem = fileSystem;
+
+ LoadCachedData();
+ }
+
+ internal void Start()
+ {
+ _config.ConfigurationUpdated += _config_ConfigurationUpdated;
+ }
+
+ internal void OnWanAddressResolved(IpAddressInfo address)
+ {
+ DiscoveredWanIpAddress = address;
+
+ var task = UpdateConnectInfo();
+ }
+
+ private async Task UpdateConnectInfo()
+ {
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ await UpdateConnectInfoInternal().ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task UpdateConnectInfoInternal()
+ {
+ var wanApiAddress = WanApiAddress;
+
+ if (string.IsNullOrWhiteSpace(wanApiAddress))
+ {
+ _logger.Warn("Cannot update Emby Connect information without a WanApiAddress");
+ return;
+ }
+
+ try
+ {
+ var localAddress = await _appHost.GetLocalApiUrl().ConfigureAwait(false);
+
+ var hasExistingRecord = !string.IsNullOrWhiteSpace(ConnectServerId) &&
+ !string.IsNullOrWhiteSpace(ConnectAccessKey);
+
+ var createNewRegistration = !hasExistingRecord;
+
+ if (hasExistingRecord)
+ {
+ try
+ {
+ await UpdateServerRegistration(wanApiAddress, localAddress).ConfigureAwait(false);
+ }
+ catch (HttpException ex)
+ {
+ if (!ex.StatusCode.HasValue || !new[] { HttpStatusCode.NotFound, HttpStatusCode.Unauthorized }.Contains(ex.StatusCode.Value))
+ {
+ throw;
+ }
+
+ createNewRegistration = true;
+ }
+ }
+
+ if (createNewRegistration)
+ {
+ await CreateServerRegistration(wanApiAddress, localAddress).ConfigureAwait(false);
+ }
+
+ _lastReportedIdentifier = GetConnectReportingIdentifier(localAddress, wanApiAddress);
+
+ await RefreshAuthorizationsInternal(true, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error registering with Connect", ex);
+ }
+ }
+
+ private string _lastReportedIdentifier;
+ private async Task<string> GetConnectReportingIdentifier()
+ {
+ var url = await _appHost.GetLocalApiUrl().ConfigureAwait(false);
+ return GetConnectReportingIdentifier(url, WanApiAddress);
+ }
+ private string GetConnectReportingIdentifier(string localAddress, string remoteAddress)
+ {
+ return (remoteAddress ?? string.Empty) + (localAddress ?? string.Empty);
+ }
+
+ async void _config_ConfigurationUpdated(object sender, EventArgs e)
+ {
+ // If info hasn't changed, don't report anything
+ var connectIdentifier = await GetConnectReportingIdentifier().ConfigureAwait(false);
+ if (string.Equals(_lastReportedIdentifier, connectIdentifier, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ await UpdateConnectInfo().ConfigureAwait(false);
+ }
+
+ private async Task CreateServerRegistration(string wanApiAddress, string localAddress)
+ {
+ if (string.IsNullOrWhiteSpace(wanApiAddress))
+ {
+ throw new ArgumentNullException("wanApiAddress");
+ }
+
+ var url = "Servers";
+ url = GetConnectUrl(url);
+
+ var postData = new Dictionary<string, string>
+ {
+ {"name", _appHost.FriendlyName},
+ {"url", wanApiAddress},
+ {"systemId", _appHost.SystemId}
+ };
+
+ if (!string.IsNullOrWhiteSpace(localAddress))
+ {
+ postData["localAddress"] = localAddress;
+ }
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ options.SetPostData(postData);
+ SetApplicationHeader(options);
+
+ using (var response = await _httpClient.Post(options).ConfigureAwait(false))
+ {
+ var data = _json.DeserializeFromStream<ServerRegistrationResponse>(response.Content);
+
+ _data.ServerId = data.Id;
+ _data.AccessKey = data.AccessKey;
+
+ CacheData();
+ }
+ }
+
+ private async Task UpdateServerRegistration(string wanApiAddress, string localAddress)
+ {
+ if (string.IsNullOrWhiteSpace(wanApiAddress))
+ {
+ throw new ArgumentNullException("wanApiAddress");
+ }
+
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var url = "Servers";
+ url = GetConnectUrl(url);
+ url += "?id=" + ConnectServerId;
+
+ var postData = new Dictionary<string, string>
+ {
+ {"name", _appHost.FriendlyName},
+ {"url", wanApiAddress},
+ {"systemId", _appHost.SystemId}
+ };
+
+ if (!string.IsNullOrWhiteSpace(localAddress))
+ {
+ postData["localAddress"] = localAddress;
+ }
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ }
+ }
+
+ private readonly object _dataFileLock = new object();
+ private string CacheFilePath
+ {
+ get { return Path.Combine(_appPaths.DataPath, "connect.txt"); }
+ }
+
+ private void CacheData()
+ {
+ var path = CacheFilePath;
+
+ try
+ {
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ var json = _json.SerializeToString(_data);
+
+ var encrypted = _encryption.EncryptString(json);
+
+ lock (_dataFileLock)
+ {
+ _fileSystem.WriteAllText(path, encrypted, Encoding.UTF8);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving data", ex);
+ }
+ }
+
+ private void LoadCachedData()
+ {
+ var path = CacheFilePath;
+
+ _logger.Info("Loading data from {0}", path);
+
+ try
+ {
+ lock (_dataFileLock)
+ {
+ var encrypted = _fileSystem.ReadAllText(path, Encoding.UTF8);
+
+ var json = _encryption.DecryptString(encrypted);
+
+ _data = _json.DeserializeFromString<ConnectData>(json);
+ }
+ }
+ catch (IOException)
+ {
+ // File isn't there. no biggie
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error loading data", ex);
+ }
+ }
+
+ private User GetUser(string id)
+ {
+ var user = _userManager.GetUserById(id);
+
+ if (user == null)
+ {
+ throw new ArgumentException("User not found.");
+ }
+
+ return user;
+ }
+
+ private string GetConnectUrl(string handler)
+ {
+ return "https://connect.emby.media/service/" + handler;
+ }
+
+ public async Task<UserLinkResult> LinkUser(string userId, string connectUsername)
+ {
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ throw new ArgumentNullException("userId");
+ }
+ if (string.IsNullOrWhiteSpace(connectUsername))
+ {
+ throw new ArgumentNullException("connectUsername");
+ }
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ await UpdateConnectInfo().ConfigureAwait(false);
+ }
+
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ return await LinkUserInternal(userId, connectUsername).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task<UserLinkResult> LinkUserInternal(string userId, string connectUsername)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var connectUser = await GetConnectUser(new ConnectUserQuery
+ {
+ NameOrEmail = connectUsername
+
+ }, CancellationToken.None).ConfigureAwait(false);
+
+ if (!connectUser.IsActive)
+ {
+ throw new ArgumentException("The Emby account has been disabled.");
+ }
+
+ var existingUser = _userManager.Users.FirstOrDefault(i => string.Equals(i.ConnectUserId, connectUser.Id) && !string.IsNullOrWhiteSpace(i.ConnectAccessKey));
+ if (existingUser != null)
+ {
+ throw new InvalidOperationException("This connect user is already linked to local user " + existingUser.Name);
+ }
+
+ var user = GetUser(userId);
+
+ if (!string.IsNullOrWhiteSpace(user.ConnectUserId))
+ {
+ await RemoveConnect(user, user.ConnectUserId).ConfigureAwait(false);
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var accessToken = Guid.NewGuid().ToString("N");
+
+ var postData = new Dictionary<string, string>
+ {
+ {"serverId", ConnectServerId},
+ {"userId", connectUser.Id},
+ {"userType", "Linked"},
+ {"accessToken", accessToken}
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ var result = new UserLinkResult();
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ var response = _json.DeserializeFromStream<ServerUserAuthorizationResponse>(stream);
+
+ result.IsPending = string.Equals(response.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase);
+ }
+
+ user.ConnectAccessKey = accessToken;
+ user.ConnectUserName = connectUser.Name;
+ user.ConnectUserId = connectUser.Id;
+ user.ConnectLinkType = UserLinkType.LinkedUser;
+
+ await user.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+
+ await _userManager.UpdateConfiguration(user.Id.ToString("N"), user.Configuration);
+
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+
+ return result;
+ }
+
+ public async Task<UserLinkResult> InviteUser(ConnectAuthorizationRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ await UpdateConnectInfo().ConfigureAwait(false);
+ }
+
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ return await InviteUserInternal(request).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task<UserLinkResult> InviteUserInternal(ConnectAuthorizationRequest request)
+ {
+ var connectUsername = request.ConnectUserName;
+ var sendingUserId = request.SendingUserId;
+
+ if (string.IsNullOrWhiteSpace(connectUsername))
+ {
+ throw new ArgumentNullException("connectUsername");
+ }
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var sendingUser = GetUser(sendingUserId);
+ var requesterUserName = sendingUser.ConnectUserName;
+
+ if (string.IsNullOrWhiteSpace(requesterUserName))
+ {
+ throw new ArgumentException("A Connect account is required in order to send invitations.");
+ }
+
+ string connectUserId = null;
+ var result = new UserLinkResult();
+
+ try
+ {
+ var connectUser = await GetConnectUser(new ConnectUserQuery
+ {
+ NameOrEmail = connectUsername
+
+ }, CancellationToken.None).ConfigureAwait(false);
+
+ if (!connectUser.IsActive)
+ {
+ throw new ArgumentException("The Emby account is not active. Please ensure the account has been activated by following the instructions within the email confirmation.");
+ }
+
+ connectUserId = connectUser.Id;
+ result.GuestDisplayName = connectUser.Name;
+ }
+ catch (HttpException ex)
+ {
+ if (!ex.StatusCode.HasValue)
+ {
+ throw;
+ }
+
+ // If they entered a username, then whatever the error is just throw it, for example, user not found
+ if (!Validator.EmailIsValid(connectUsername))
+ {
+ if (ex.StatusCode.Value == HttpStatusCode.NotFound)
+ {
+ throw new ResourceNotFoundException();
+ }
+ throw;
+ }
+
+ if (ex.StatusCode.Value != HttpStatusCode.NotFound)
+ {
+ throw;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(connectUserId))
+ {
+ return await SendNewUserInvitation(requesterUserName, connectUsername).ConfigureAwait(false);
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var accessToken = Guid.NewGuid().ToString("N");
+
+ var postData = new Dictionary<string, string>
+ {
+ {"serverId", ConnectServerId},
+ {"userId", connectUserId},
+ {"userType", "Guest"},
+ {"accessToken", accessToken},
+ {"requesterUserName", requesterUserName}
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ var response = _json.DeserializeFromStream<ServerUserAuthorizationResponse>(stream);
+
+ result.IsPending = string.Equals(response.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase);
+
+ _data.PendingAuthorizations.Add(new ConnectAuthorizationInternal
+ {
+ ConnectUserId = response.UserId,
+ Id = response.Id,
+ ImageUrl = response.UserImageUrl,
+ UserName = response.UserName,
+ EnabledLibraries = request.EnabledLibraries,
+ EnabledChannels = request.EnabledChannels,
+ EnableLiveTv = request.EnableLiveTv,
+ AccessToken = accessToken
+ });
+
+ CacheData();
+ }
+
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+
+ return result;
+ }
+
+ private async Task<UserLinkResult> SendNewUserInvitation(string fromName, string email)
+ {
+ var url = GetConnectUrl("users/invite");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var postData = new Dictionary<string, string>
+ {
+ {"email", email},
+ {"requesterUserName", fromName}
+ };
+
+ options.SetPostData(postData);
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ }
+
+ return new UserLinkResult
+ {
+ IsNewUserInvitation = true,
+ GuestDisplayName = email
+ };
+ }
+
+ public Task RemoveConnect(string userId)
+ {
+ var user = GetUser(userId);
+
+ return RemoveConnect(user, user.ConnectUserId);
+ }
+
+ private async Task RemoveConnect(User user, string connectUserId)
+ {
+ if (!string.IsNullOrWhiteSpace(connectUserId))
+ {
+ await CancelAuthorizationByConnectUserId(connectUserId).ConfigureAwait(false);
+ }
+
+ user.ConnectAccessKey = null;
+ user.ConnectUserName = null;
+ user.ConnectUserId = null;
+ user.ConnectLinkType = null;
+
+ await user.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private async Task<ConnectUser> GetConnectUser(ConnectUserQuery query, CancellationToken cancellationToken)
+ {
+ var url = GetConnectUrl("user");
+
+ if (!string.IsNullOrWhiteSpace(query.Id))
+ {
+ url = url + "?id=" + WebUtility.UrlEncode(query.Id);
+ }
+ else if (!string.IsNullOrWhiteSpace(query.NameOrEmail))
+ {
+ url = url + "?nameOrEmail=" + WebUtility.UrlEncode(query.NameOrEmail);
+ }
+ else if (!string.IsNullOrWhiteSpace(query.Name))
+ {
+ url = url + "?name=" + WebUtility.UrlEncode(query.Name);
+ }
+ else if (!string.IsNullOrWhiteSpace(query.Email))
+ {
+ url = url + "?name=" + WebUtility.UrlEncode(query.Email);
+ }
+ else
+ {
+ throw new ArgumentException("Empty ConnectUserQuery supplied");
+ }
+
+ var options = new HttpRequestOptions
+ {
+ CancellationToken = cancellationToken,
+ Url = url,
+ BufferContent = false
+ };
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ using (var stream = await _httpClient.Get(options).ConfigureAwait(false))
+ {
+ var response = _json.DeserializeFromStream<GetConnectUserResponse>(stream);
+
+ return new ConnectUser
+ {
+ Email = response.Email,
+ Id = response.Id,
+ Name = response.Name,
+ IsActive = response.IsActive,
+ ImageUrl = response.ImageUrl
+ };
+ }
+ }
+
+ private void SetApplicationHeader(HttpRequestOptions options)
+ {
+ options.RequestHeaders.Add("X-Application", XApplicationValue);
+ }
+
+ private void SetServerAccessToken(HttpRequestOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectAccessKey))
+ {
+ throw new ArgumentNullException("ConnectAccessKey");
+ }
+
+ options.RequestHeaders.Add("X-Connect-Token", ConnectAccessKey);
+ }
+
+ public async Task RefreshAuthorizations(CancellationToken cancellationToken)
+ {
+ await _operationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ await RefreshAuthorizationsInternal(true, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task RefreshAuthorizationsInternal(bool refreshImages, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ url += "?serverId=" + ConnectServerId;
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = cancellationToken,
+ BufferContent = false
+ };
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ try
+ {
+ using (var stream = (await _httpClient.SendAsync(options, "GET").ConfigureAwait(false)).Content)
+ {
+ var list = _json.DeserializeFromStream<List<ServerUserAuthorizationResponse>>(stream);
+
+ await RefreshAuthorizations(list, refreshImages).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing server authorizations.", ex);
+ }
+ }
+
+ private readonly SemaphoreSlim _connectImageSemaphore = new SemaphoreSlim(5, 5);
+ private async Task RefreshAuthorizations(List<ServerUserAuthorizationResponse> list, bool refreshImages)
+ {
+ var users = _userManager.Users.ToList();
+
+ // Handle existing authorizations that were removed by the Connect server
+ // Handle existing authorizations whose status may have been updated
+ foreach (var user in users)
+ {
+ if (!string.IsNullOrWhiteSpace(user.ConnectUserId))
+ {
+ var connectEntry = list.FirstOrDefault(i => string.Equals(i.UserId, user.ConnectUserId, StringComparison.OrdinalIgnoreCase));
+
+ if (connectEntry == null)
+ {
+ var deleteUser = user.ConnectLinkType.HasValue &&
+ user.ConnectLinkType.Value == UserLinkType.Guest;
+
+ user.ConnectUserId = null;
+ user.ConnectAccessKey = null;
+ user.ConnectUserName = null;
+ user.ConnectLinkType = null;
+
+ await _userManager.UpdateUser(user).ConfigureAwait(false);
+
+ if (deleteUser)
+ {
+ _logger.Debug("Deleting guest user {0}", user.Name);
+ await _userManager.DeleteUser(user).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ var changed = !string.Equals(user.ConnectAccessKey, connectEntry.AccessToken, StringComparison.OrdinalIgnoreCase);
+
+ if (changed)
+ {
+ user.ConnectUserId = connectEntry.UserId;
+ user.ConnectAccessKey = connectEntry.AccessToken;
+
+ await _userManager.UpdateUser(user).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+
+ var currentPendingList = _data.PendingAuthorizations.ToList();
+ var newPendingList = new List<ConnectAuthorizationInternal>();
+
+ foreach (var connectEntry in list)
+ {
+ if (string.Equals(connectEntry.UserType, "guest", StringComparison.OrdinalIgnoreCase))
+ {
+ var currentPendingEntry = currentPendingList.FirstOrDefault(i => string.Equals(i.Id, connectEntry.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (string.Equals(connectEntry.AcceptStatus, "accepted", StringComparison.OrdinalIgnoreCase))
+ {
+ var user = _userManager.Users
+ .FirstOrDefault(i => string.Equals(i.ConnectUserId, connectEntry.UserId, StringComparison.OrdinalIgnoreCase));
+
+ if (user == null)
+ {
+ // Add user
+ user = await _userManager.CreateUser(_userManager.MakeValidUsername(connectEntry.UserName)).ConfigureAwait(false);
+
+ user.ConnectUserName = connectEntry.UserName;
+ user.ConnectUserId = connectEntry.UserId;
+ user.ConnectLinkType = UserLinkType.Guest;
+ user.ConnectAccessKey = connectEntry.AccessToken;
+
+ await _userManager.UpdateUser(user).ConfigureAwait(false);
+
+ user.Policy.IsHidden = true;
+ user.Policy.EnableLiveTvManagement = false;
+ user.Policy.EnableContentDeletion = false;
+ user.Policy.EnableRemoteControlOfOtherUsers = false;
+ user.Policy.EnableSharedDeviceControl = false;
+ user.Policy.IsAdministrator = false;
+
+ if (currentPendingEntry != null)
+ {
+ user.Policy.EnabledFolders = currentPendingEntry.EnabledLibraries;
+ user.Policy.EnableAllFolders = false;
+
+ user.Policy.EnabledChannels = currentPendingEntry.EnabledChannels;
+ user.Policy.EnableAllChannels = false;
+
+ user.Policy.EnableLiveTvAccess = currentPendingEntry.EnableLiveTv;
+ }
+
+ await _userManager.UpdateConfiguration(user.Id.ToString("N"), user.Configuration);
+ }
+ }
+ else if (string.Equals(connectEntry.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase))
+ {
+ currentPendingEntry = currentPendingEntry ?? new ConnectAuthorizationInternal();
+
+ currentPendingEntry.ConnectUserId = connectEntry.UserId;
+ currentPendingEntry.ImageUrl = connectEntry.UserImageUrl;
+ currentPendingEntry.UserName = connectEntry.UserName;
+ currentPendingEntry.Id = connectEntry.Id;
+ currentPendingEntry.AccessToken = connectEntry.AccessToken;
+
+ newPendingList.Add(currentPendingEntry);
+ }
+ }
+ }
+
+ _data.PendingAuthorizations = newPendingList;
+ CacheData();
+
+ await RefreshGuestNames(list, refreshImages).ConfigureAwait(false);
+ }
+
+ private async Task RefreshGuestNames(List<ServerUserAuthorizationResponse> list, bool refreshImages)
+ {
+ var users = _userManager.Users
+ .Where(i => !string.IsNullOrEmpty(i.ConnectUserId) && i.ConnectLinkType.HasValue && i.ConnectLinkType.Value == UserLinkType.Guest)
+ .ToList();
+
+ foreach (var user in users)
+ {
+ var authorization = list.FirstOrDefault(i => string.Equals(i.UserId, user.ConnectUserId, StringComparison.Ordinal));
+
+ if (authorization == null)
+ {
+ _logger.Warn("Unable to find connect authorization record for user {0}", user.Name);
+ continue;
+ }
+
+ var syncConnectName = true;
+ var syncConnectImage = true;
+
+ if (syncConnectName)
+ {
+ var changed = !string.Equals(authorization.UserName, user.Name, StringComparison.OrdinalIgnoreCase);
+
+ if (changed)
+ {
+ await user.Rename(authorization.UserName).ConfigureAwait(false);
+ }
+ }
+
+ if (syncConnectImage)
+ {
+ var imageUrl = authorization.UserImageUrl;
+
+ if (!string.IsNullOrWhiteSpace(imageUrl))
+ {
+ var changed = false;
+
+ if (!user.HasImage(ImageType.Primary))
+ {
+ changed = true;
+ }
+ else if (refreshImages)
+ {
+ using (var response = await _httpClient.SendAsync(new HttpRequestOptions
+ {
+ Url = imageUrl,
+ BufferContent = false
+
+ }, "HEAD").ConfigureAwait(false))
+ {
+ var length = response.ContentLength;
+
+ if (length != _fileSystem.GetFileInfo(user.GetImageInfo(ImageType.Primary, 0).Path).Length)
+ {
+ changed = true;
+ }
+ }
+ }
+
+ if (changed)
+ {
+ await _providerManager.SaveImage(user, imageUrl, _connectImageSemaphore, ImageType.Primary, null, CancellationToken.None).ConfigureAwait(false);
+
+ await user.RefreshMetadata(new MetadataRefreshOptions(_fileSystem)
+ {
+ ForceSave = true,
+
+ }, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+ }
+
+ public async Task<List<ConnectAuthorization>> GetPendingGuests()
+ {
+ var time = DateTime.UtcNow - _data.LastAuthorizationsRefresh;
+
+ if (time.TotalMinutes >= 5)
+ {
+ await _operationLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+
+ try
+ {
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+
+ _data.LastAuthorizationsRefresh = DateTime.UtcNow;
+ CacheData();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing authorization", ex);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ return _data.PendingAuthorizations.Select(i => new ConnectAuthorization
+ {
+ ConnectUserId = i.ConnectUserId,
+ EnableLiveTv = i.EnableLiveTv,
+ EnabledChannels = i.EnabledChannels,
+ EnabledLibraries = i.EnabledLibraries,
+ Id = i.Id,
+ ImageUrl = i.ImageUrl,
+ UserName = i.UserName
+
+ }).ToList();
+ }
+
+ public async Task CancelAuthorization(string id)
+ {
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ await CancelAuthorizationInternal(id).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task CancelAuthorizationInternal(string id)
+ {
+ var connectUserId = _data.PendingAuthorizations
+ .First(i => string.Equals(i.Id, id, StringComparison.Ordinal))
+ .ConnectUserId;
+
+ await CancelAuthorizationByConnectUserId(connectUserId).ConfigureAwait(false);
+
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private async Task CancelAuthorizationByConnectUserId(string connectUserId)
+ {
+ if (string.IsNullOrWhiteSpace(connectUserId))
+ {
+ throw new ArgumentNullException("connectUserId");
+ }
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var postData = new Dictionary<string, string>
+ {
+ {"serverId", ConnectServerId},
+ {"userId", connectUserId}
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ try
+ {
+ // No need to examine the response
+ using (var stream = (await _httpClient.SendAsync(options, "DELETE").ConfigureAwait(false)).Content)
+ {
+ }
+ }
+ catch (HttpException ex)
+ {
+ // If connect says the auth doesn't exist, we can handle that gracefully since this is a remove operation
+
+ if (!ex.StatusCode.HasValue || ex.StatusCode.Value != HttpStatusCode.NotFound)
+ {
+ throw;
+ }
+
+ _logger.Debug("Connect returned a 404 when removing a user auth link. Handling it.");
+ }
+ }
+
+ public async Task Authenticate(string username, string passwordMd5)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ throw new ArgumentNullException("username");
+ }
+
+ if (string.IsNullOrWhiteSpace(passwordMd5))
+ {
+ throw new ArgumentNullException("passwordMd5");
+ }
+
+ var options = new HttpRequestOptions
+ {
+ Url = GetConnectUrl("user/authenticate"),
+ BufferContent = false
+ };
+
+ options.SetPostData(new Dictionary<string, string>
+ {
+ {"userName",username},
+ {"password",passwordMd5}
+ });
+
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var response = (await _httpClient.SendAsync(options, "POST").ConfigureAwait(false)).Content)
+ {
+ }
+ }
+
+ public async Task<User> GetLocalUser(string connectUserId)
+ {
+ var user = _userManager.Users
+ .FirstOrDefault(i => string.Equals(i.ConnectUserId, connectUserId, StringComparison.OrdinalIgnoreCase));
+
+ if (user == null)
+ {
+ await RefreshAuthorizations(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ return _userManager.Users
+ .FirstOrDefault(i => string.Equals(i.ConnectUserId, connectUserId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public User GetUserFromExchangeToken(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentNullException("token");
+ }
+
+ return _userManager.Users.FirstOrDefault(u => string.Equals(token, u.ConnectAccessKey, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public bool IsAuthorizationTokenValid(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentNullException("token");
+ }
+
+ return _userManager.Users.Any(u => string.Equals(token, u.ConnectAccessKey, StringComparison.OrdinalIgnoreCase)) ||
+ _data.PendingAuthorizations.Select(i => i.AccessToken).Contains(token, StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/Responses.cs b/Emby.Server.Implementations/Connect/Responses.cs
new file mode 100644
index 000000000..87cb6cdf9
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/Responses.cs
@@ -0,0 +1,85 @@
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Connect;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ServerRegistrationResponse
+ {
+ public string Id { get; set; }
+ public string Url { get; set; }
+ public string Name { get; set; }
+ public string AccessKey { get; set; }
+ }
+
+ public class UpdateServerRegistrationResponse
+ {
+ public string Id { get; set; }
+ public string Url { get; set; }
+ public string Name { get; set; }
+ }
+
+ public class GetConnectUserResponse
+ {
+ public string Id { get; set; }
+ public string Name { get; set; }
+ public string DisplayName { get; set; }
+ public string Email { get; set; }
+ public bool IsActive { get; set; }
+ public string ImageUrl { get; set; }
+ }
+
+ public class ServerUserAuthorizationResponse
+ {
+ public string Id { get; set; }
+ public string ServerId { get; set; }
+ public string UserId { get; set; }
+ public string AccessToken { get; set; }
+ public string DateCreated { get; set; }
+ public bool IsActive { get; set; }
+ public string AcceptStatus { get; set; }
+ public string UserType { get; set; }
+ public string UserImageUrl { get; set; }
+ public string UserName { get; set; }
+ }
+
+ public class ConnectUserPreferences
+ {
+ public string[] PreferredAudioLanguages { get; set; }
+ public bool PlayDefaultAudioTrack { get; set; }
+ public string[] PreferredSubtitleLanguages { get; set; }
+ public SubtitlePlaybackMode SubtitleMode { get; set; }
+ public bool GroupMoviesIntoBoxSets { get; set; }
+
+ public ConnectUserPreferences()
+ {
+ PreferredAudioLanguages = new string[] { };
+ PreferredSubtitleLanguages = new string[] { };
+ }
+
+ public static ConnectUserPreferences FromUserConfiguration(UserConfiguration config)
+ {
+ return new ConnectUserPreferences
+ {
+ PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
+ SubtitleMode = config.SubtitleMode,
+ PreferredAudioLanguages = string.IsNullOrWhiteSpace(config.AudioLanguagePreference) ? new string[] { } : new[] { config.AudioLanguagePreference },
+ PreferredSubtitleLanguages = string.IsNullOrWhiteSpace(config.SubtitleLanguagePreference) ? new string[] { } : new[] { config.SubtitleLanguagePreference }
+ };
+ }
+
+ public void MergeInto(UserConfiguration config)
+ {
+
+ }
+ }
+
+ public class UserPreferencesDto<T>
+ {
+ public T data { get; set; }
+ }
+
+ public class ConnectAuthorizationInternal : ConnectAuthorization
+ {
+ public string AccessToken { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/Validator.cs b/Emby.Server.Implementations/Connect/Validator.cs
new file mode 100644
index 000000000..5c94fa71c
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/Validator.cs
@@ -0,0 +1,29 @@
+using System.Text.RegularExpressions;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public static class Validator
+ {
+ static readonly Regex ValidEmailRegex = CreateValidEmailRegex();
+
+ /// <summary>
+ /// Taken from http://haacked.com/archive/2007/08/21/i-knew-how-to-validate-an-email-address-until-i.aspx
+ /// </summary>
+ /// <returns></returns>
+ private static Regex CreateValidEmailRegex()
+ {
+ const string validEmailPattern = @"^(?!\.)(""([^""\r\\]|\\[""\r\\])*""|"
+ + @"([-a-z0-9!#$%&'*+/=?^_`{|}~]|(?<!\.)\.)*)(?<!\.)"
+ + @"@[a-z0-9][\w\.-]*[a-z0-9]\.[a-z][a-z\.]*[a-z]$";
+
+ return new Regex(validEmailPattern, RegexOptions.IgnoreCase);
+ }
+
+ internal static bool EmailIsValid(string emailAddress)
+ {
+ bool isValid = ValidEmailRegex.IsMatch(emailAddress);
+
+ return isValid;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
index 4a27ddb74..8d13d206a 100644
--- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj
+++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
@@ -43,6 +43,11 @@
<Compile Include="Channels\RefreshChannelsScheduledTask.cs" />
<Compile Include="Collections\CollectionImageProvider.cs" />
<Compile Include="Collections\CollectionManager.cs" />
+ <Compile Include="Connect\ConnectData.cs" />
+ <Compile Include="Connect\ConnectEntryPoint.cs" />
+ <Compile Include="Connect\ConnectManager.cs" />
+ <Compile Include="Connect\Responses.cs" />
+ <Compile Include="Connect\Validator.cs" />
<Compile Include="Devices\DeviceManager.cs" />
<Compile Include="Dto\DtoService.cs" />
<Compile Include="EntryPoints\AutomaticRestartEntryPoint.cs" />
@@ -51,6 +56,7 @@
<Compile Include="EntryPoints\RecordingNotifier.cs" />
<Compile Include="EntryPoints\RefreshUsersMetadata.cs" />
<Compile Include="EntryPoints\ServerEventNotifier.cs" />
+ <Compile Include="EntryPoints\UdpServerEntryPoint.cs" />
<Compile Include="EntryPoints\UsageEntryPoint.cs" />
<Compile Include="EntryPoints\UsageReporter.cs" />
<Compile Include="EntryPoints\UserDataChangeNotifier.cs" />
@@ -68,6 +74,7 @@
<Compile Include="HttpServer\StreamWriter.cs" />
<Compile Include="Images\BaseDynamicImageProvider.cs" />
<Compile Include="Intros\DefaultIntroProvider.cs" />
+ <Compile Include="IO\FileRefresher.cs" />
<Compile Include="IO\ThrottledStream.cs" />
<Compile Include="Library\CoreResolutionIgnoreRule.cs" />
<Compile Include="Library\LibraryManager.cs" />
@@ -159,6 +166,7 @@
<Compile Include="ScheduledTasks\RefreshIntrosTask.cs" />
<Compile Include="ScheduledTasks\RefreshMediaLibraryTask.cs" />
<Compile Include="ScheduledTasks\SystemUpdateTask.cs" />
+ <Compile Include="Security\EncryptionManager.cs" />
<Compile Include="Security\MBLicenseFile.cs" />
<Compile Include="Security\PluginSecurityManager.cs" />
<Compile Include="Security\RegRecord.cs" />
@@ -217,6 +225,7 @@
<Compile Include="Sync\TargetDataProvider.cs" />
<Compile Include="TV\SeriesPostScanTask.cs" />
<Compile Include="TV\TVSeriesManager.cs" />
+ <Compile Include="Udp\UdpServer.cs" />
<Compile Include="Updates\InstallationManager.cs" />
<Compile Include="UserViews\CollectionFolderImageProvider.cs" />
<Compile Include="UserViews\DynamicImageProvider.cs" />
diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
new file mode 100644
index 000000000..df5a7c985
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
@@ -0,0 +1,85 @@
+using System;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using Emby.Server.Implementations.Udp;
+using MediaBrowser.Model.Net;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ /// <summary>
+ /// Class UdpServerEntryPoint
+ /// </summary>
+ public class UdpServerEntryPoint : IServerEntryPoint
+ {
+ /// <summary>
+ /// Gets or sets the UDP server.
+ /// </summary>
+ /// <value>The UDP server.</value>
+ private UdpServer UdpServer { get; set; }
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+ private readonly ISocketFactory _socketFactory;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IJsonSerializer _json;
+
+ public const int PortNumber = 7359;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class.
+ /// </summary>
+ public UdpServerEntryPoint(ILogger logger, IServerApplicationHost appHost, IJsonSerializer json, ISocketFactory socketFactory)
+ {
+ _logger = logger;
+ _appHost = appHost;
+ _json = json;
+ _socketFactory = socketFactory;
+ }
+
+ /// <summary>
+ /// Runs this instance.
+ /// </summary>
+ public void Run()
+ {
+ var udpServer = new UdpServer(_logger, _appHost, _json, _socketFactory);
+
+ try
+ {
+ udpServer.Start(PortNumber);
+
+ UdpServer = udpServer;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Failed to start UDP Server", ex);
+ }
+ }
+
+ /// <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)
+ {
+ if (UdpServer != null)
+ {
+ UdpServer.Dispose();
+ }
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs
new file mode 100644
index 000000000..295ecc465
--- /dev/null
+++ b/Emby.Server.Implementations/IO/FileRefresher.cs
@@ -0,0 +1,323 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Tasks;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.IO
+{
+ public class FileRefresher : IDisposable
+ {
+ private ILogger Logger { get; set; }
+ private ITaskManager TaskManager { get; set; }
+ private ILibraryManager LibraryManager { get; set; }
+ private IServerConfigurationManager ConfigurationManager { get; set; }
+ private readonly IFileSystem _fileSystem;
+ private readonly List<string> _affectedPaths = new List<string>();
+ private ITimer _timer;
+ private readonly ITimerFactory _timerFactory;
+ private readonly object _timerLock = new object();
+ public string Path { get; private set; }
+
+ public event EventHandler<EventArgs> Completed;
+
+ public FileRefresher(string path, IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ITaskManager taskManager, ILogger logger, ITimerFactory timerFactory)
+ {
+ logger.Debug("New file refresher created for {0}", path);
+ Path = path;
+
+ _fileSystem = fileSystem;
+ ConfigurationManager = configurationManager;
+ LibraryManager = libraryManager;
+ TaskManager = taskManager;
+ Logger = logger;
+ _timerFactory = timerFactory;
+ AddPath(path);
+ }
+
+ private void AddAffectedPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ if (!_affectedPaths.Contains(path, StringComparer.Ordinal))
+ {
+ _affectedPaths.Add(path);
+ }
+ }
+
+ public void AddPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ lock (_timerLock)
+ {
+ AddAffectedPath(path);
+ }
+ RestartTimer();
+ }
+
+ public void RestartTimer()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ lock (_timerLock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_timer == null)
+ {
+ _timer = _timerFactory.Create(OnTimerCallback, null, TimeSpan.FromSeconds(ConfigurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1));
+ }
+ else
+ {
+ _timer.Change(TimeSpan.FromSeconds(ConfigurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1));
+ }
+ }
+ }
+
+ public void ResetPath(string path, string affectedFile)
+ {
+ lock (_timerLock)
+ {
+ Logger.Debug("Resetting file refresher from {0} to {1}", Path, path);
+
+ Path = path;
+ AddAffectedPath(path);
+
+ if (!string.IsNullOrWhiteSpace(affectedFile))
+ {
+ AddAffectedPath(affectedFile);
+ }
+ }
+ RestartTimer();
+ }
+
+ private async void OnTimerCallback(object state)
+ {
+ List<string> paths;
+
+ lock (_timerLock)
+ {
+ paths = _affectedPaths.ToList();
+ }
+
+ // Extend the timer as long as any of the paths are still being written to.
+ if (paths.Any(IsFileLocked))
+ {
+ Logger.Info("Timer extended.");
+ RestartTimer();
+ return;
+ }
+
+ Logger.Debug("Timer stopped.");
+
+ DisposeTimer();
+ EventHelper.FireEventIfNotNull(Completed, this, EventArgs.Empty, Logger);
+
+ try
+ {
+ await ProcessPathChanges(paths.ToList()).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error processing directory changes", ex);
+ }
+ }
+
+ private async Task ProcessPathChanges(List<string> paths)
+ {
+ var itemsToRefresh = paths
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Select(GetAffectedBaseItem)
+ .Where(item => item != null)
+ .DistinctBy(i => i.Id)
+ .ToList();
+
+ foreach (var p in paths)
+ {
+ Logger.Info(p + " reports change.");
+ }
+
+ // If the root folder changed, run the library task so the user can see it
+ if (itemsToRefresh.Any(i => i is AggregateFolder))
+ {
+ LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None);
+ return;
+ }
+
+ foreach (var item in itemsToRefresh)
+ {
+ Logger.Info(item.Name + " (" + item.Path + ") will be refreshed.");
+
+ try
+ {
+ await item.ChangedExternally().ConfigureAwait(false);
+ }
+ catch (IOException ex)
+ {
+ // For now swallow and log.
+ // Research item: If an IOException occurs, the item may be in a disconnected state (media unavailable)
+ // Should we remove it from it's parent?
+ Logger.ErrorException("Error refreshing {0}", ex, item.Name);
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error refreshing {0}", ex, item.Name);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the affected base item.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <returns>BaseItem.</returns>
+ private BaseItem GetAffectedBaseItem(string path)
+ {
+ BaseItem item = null;
+
+ while (item == null && !string.IsNullOrEmpty(path))
+ {
+ item = LibraryManager.FindByPath(path, null);
+
+ path = System.IO.Path.GetDirectoryName(path);
+ }
+
+ if (item != null)
+ {
+ // If the item has been deleted find the first valid parent that still exists
+ while (!_fileSystem.DirectoryExists(item.Path) && !_fileSystem.FileExists(item.Path))
+ {
+ item = item.GetParent();
+
+ if (item == null)
+ {
+ break;
+ }
+ }
+ }
+
+ return item;
+ }
+
+ private bool IsFileLocked(string path)
+ {
+ //if (Environment.OSVersion.Platform != PlatformID.Win32NT)
+ //{
+ // // Causing lockups on linux
+ // return false;
+ //}
+
+ try
+ {
+ var data = _fileSystem.GetFileSystemInfo(path);
+
+ if (!data.Exists
+ || data.IsDirectory
+
+ // Opening a writable stream will fail with readonly files
+ || data.IsReadOnly)
+ {
+ return false;
+ }
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error getting file system info for: {0}", ex, path);
+ return false;
+ }
+
+ // In order to determine if the file is being written to, we have to request write access
+ // But if the server only has readonly access, this is going to cause this entire algorithm to fail
+ // So we'll take a best guess about our access level
+ var requestedFileAccess = ConfigurationManager.Configuration.SaveLocalMeta
+ ? FileAccessMode.ReadWrite
+ : FileAccessMode.Read;
+
+ try
+ {
+ using (_fileSystem.GetFileStream(path, FileOpenMode.Open, requestedFileAccess, FileShareMode.ReadWrite))
+ {
+ //file is not locked
+ return false;
+ }
+ }
+ //catch (DirectoryNotFoundException)
+ //{
+ // // File may have been deleted
+ // return false;
+ //}
+ catch (FileNotFoundException)
+ {
+ // File may have been deleted
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ Logger.Debug("No write permission for: {0}.", path);
+ return false;
+ }
+ catch (IOException)
+ {
+ //the file is unavailable because it is:
+ //still being written to
+ //or being processed by another thread
+ //or does not exist (has already been processed)
+ Logger.Debug("{0} is locked.", path);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error determining if file is locked: {0}", ex, path);
+ return false;
+ }
+ }
+
+ private void DisposeTimer()
+ {
+ lock (_timerLock)
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+
+ private bool _disposed;
+ public void Dispose()
+ {
+ _disposed = true;
+ DisposeTimer();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Security/EncryptionManager.cs b/Emby.Server.Implementations/Security/EncryptionManager.cs
new file mode 100644
index 000000000..271b0bbdb
--- /dev/null
+++ b/Emby.Server.Implementations/Security/EncryptionManager.cs
@@ -0,0 +1,51 @@
+using MediaBrowser.Controller.Security;
+using System;
+using System.Text;
+
+namespace Emby.Server.Implementations.Security
+{
+ public class EncryptionManager : IEncryptionManager
+ {
+ /// <summary>
+ /// Encrypts the string.
+ /// </summary>
+ /// <param name="value">The value.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="System.ArgumentNullException">value</exception>
+ public string EncryptString(string value)
+ {
+ if (value == null) throw new ArgumentNullException("value");
+
+ return EncryptStringUniversal(value);
+ }
+
+ /// <summary>
+ /// Decrypts the string.
+ /// </summary>
+ /// <param name="value">The value.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="System.ArgumentNullException">value</exception>
+ public string DecryptString(string value)
+ {
+ if (value == null) throw new ArgumentNullException("value");
+
+ return DecryptStringUniversal(value);
+ }
+
+ private string EncryptStringUniversal(string value)
+ {
+ // Yes, this isn't good, but ProtectedData in mono is throwing exceptions, so use this for now
+
+ var bytes = Encoding.UTF8.GetBytes(value);
+ return Convert.ToBase64String(bytes);
+ }
+
+ private string DecryptStringUniversal(string value)
+ {
+ // Yes, this isn't good, but ProtectedData in mono is throwing exceptions, so use this for now
+
+ var bytes = Convert.FromBase64String(value);
+ return Encoding.UTF8.GetString(bytes, 0, bytes.Length);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs
new file mode 100644
index 000000000..c15e0ee41
--- /dev/null
+++ b/Emby.Server.Implementations/Udp/UdpServer.cs
@@ -0,0 +1,247 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Model.ApiClient;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Net;
+
+namespace Emby.Server.Implementations.Udp
+{
+ /// <summary>
+ /// Provides a Udp Server
+ /// </summary>
+ public class UdpServer : IDisposable
+ {
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private bool _isDisposed;
+
+ private readonly List<Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>> _responders = new List<Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>>();
+
+ private readonly IServerApplicationHost _appHost;
+ private readonly IJsonSerializer _json;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UdpServer" /> class.
+ /// </summary>
+ public UdpServer(ILogger logger, IServerApplicationHost appHost, IJsonSerializer json, ISocketFactory socketFactory)
+ {
+ _logger = logger;
+ _appHost = appHost;
+ _json = json;
+ _socketFactory = socketFactory;
+
+ AddMessageResponder("who is EmbyServer?", true, RespondToV2Message);
+ AddMessageResponder("who is MediaBrowserServer_v2?", false, RespondToV2Message);
+ }
+
+ private void AddMessageResponder(string message, bool isSubstring, Func<string, IpEndPointInfo, Encoding, Task> responder)
+ {
+ _responders.Add(new Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>(message, isSubstring, responder));
+ }
+
+ /// <summary>
+ /// Raises the <see cref="E:MessageReceived" /> event.
+ /// </summary>
+ private async void OnMessageReceived(GenericEventArgs<SocketReceiveResult> e)
+ {
+ var message = e.Argument;
+
+ var encoding = Encoding.UTF8;
+ var responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding);
+
+ if (responder == null)
+ {
+ encoding = Encoding.Unicode;
+ responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding);
+ }
+
+ if (responder != null)
+ {
+ try
+ {
+ await responder.Item2.Item3(responder.Item1, message.RemoteEndPoint, encoding).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in OnMessageReceived", ex);
+ }
+ }
+ }
+
+ private Tuple<string, Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>> GetResponder(byte[] buffer, int bytesReceived, Encoding encoding)
+ {
+ var text = encoding.GetString(buffer, 0, bytesReceived);
+ var responder = _responders.FirstOrDefault(i =>
+ {
+ if (i.Item2)
+ {
+ return text.IndexOf(i.Item1, StringComparison.OrdinalIgnoreCase) != -1;
+ }
+ return string.Equals(i.Item1, text, StringComparison.OrdinalIgnoreCase);
+ });
+
+ if (responder == null)
+ {
+ return null;
+ }
+ return new Tuple<string, Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>>(text, responder);
+ }
+
+ private async Task RespondToV2Message(string messageText, IpEndPointInfo endpoint, Encoding encoding)
+ {
+ var parts = messageText.Split('|');
+
+ var localUrl = await _appHost.GetLocalApiUrl().ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(localUrl))
+ {
+ var response = new ServerDiscoveryInfo
+ {
+ Address = localUrl,
+ Id = _appHost.SystemId,
+ Name = _appHost.FriendlyName
+ };
+
+ await SendAsync(encoding.GetBytes(_json.SerializeToString(response)), endpoint).ConfigureAwait(false);
+
+ if (parts.Length > 1)
+ {
+ _appHost.EnableLoopback(parts[1]);
+ }
+ }
+ else
+ {
+ _logger.Warn("Unable to respond to udp request because the local ip address could not be determined.");
+ }
+ }
+
+ /// <summary>
+ /// The _udp client
+ /// </summary>
+ private IUdpSocket _udpClient;
+ private readonly ISocketFactory _socketFactory;
+
+ /// <summary>
+ /// Starts the specified port.
+ /// </summary>
+ /// <param name="port">The port.</param>
+ public void Start(int port)
+ {
+ _udpClient = _socketFactory.CreateUdpSocket(port);
+
+ Task.Run(() => StartListening());
+ }
+
+ private async void StartListening()
+ {
+ while (!_isDisposed)
+ {
+ try
+ {
+ var result = await _udpClient.ReceiveAsync().ConfigureAwait(false);
+
+ OnMessageReceived(result);
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error receiving udp message", ex);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Called when [message received].
+ /// </summary>
+ /// <param name="message">The message.</param>
+ private void OnMessageReceived(SocketReceiveResult message)
+ {
+ if (message.RemoteEndPoint.Port == 0)
+ {
+ return;
+ }
+
+ try
+ {
+ OnMessageReceived(new GenericEventArgs<SocketReceiveResult>
+ {
+ Argument = message
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error handling UDP message", ex);
+ }
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Stops this instance.
+ /// </summary>
+ public void Stop()
+ {
+ _isDisposed = true;
+
+ if (_udpClient != null)
+ {
+ _udpClient.Dispose();
+ }
+ }
+
+ /// <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)
+ {
+ Stop();
+ }
+ }
+
+ public async Task SendAsync(byte[] bytes, IpEndPointInfo remoteEndPoint)
+ {
+ if (bytes == null)
+ {
+ throw new ArgumentNullException("bytes");
+ }
+
+ if (remoteEndPoint == null)
+ {
+ throw new ArgumentNullException("remoteEndPoint");
+ }
+
+ try
+ {
+ await _udpClient.SendAsync(bytes, bytes.Length, remoteEndPoint).ConfigureAwait(false);
+
+ _logger.Info("Udp message sent to {0}", remoteEndPoint);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending message to {0}", ex, remoteEndPoint);
+ }
+ }
+ }
+
+}