diff options
Diffstat (limited to 'Emby.Server.Implementations')
| -rw-r--r-- | Emby.Server.Implementations/Connect/ConnectData.cs | 36 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Connect/ConnectEntryPoint.cs | 199 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Connect/ConnectManager.cs | 1189 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Connect/Responses.cs | 85 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Connect/Validator.cs | 29 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Emby.Server.Implementations.csproj | 9 | ||||
| -rw-r--r-- | Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs | 85 | ||||
| -rw-r--r-- | Emby.Server.Implementations/IO/FileRefresher.cs | 323 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Security/EncryptionManager.cs | 51 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Udp/UdpServer.cs | 247 |
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); + } + } + } + +} |
