diff options
Diffstat (limited to 'Emby.Server.Implementations/Connect')
| -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 |
5 files changed, 1538 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; + } + } +} |
