using System; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Model.QuickConnect; using Microsoft.Extensions.Logging; using StackExchange.Redis; namespace Emby.Server.Implementations.QuickConnect; /// /// A Redis-backed that lets the initiate, authorize and exchange legs /// of a quick connect flow land on different instances. Expiry is the key TTL, and an authorization is /// consumed with GETDEL so only one instance can ever hand out a given secret's access token. /// public sealed class RedisQuickConnectStore : IQuickConnectStore { private const string KeyPrefix = "jellyfin:quickconnect:"; private readonly IDatabase _db; private readonly InMemoryQuickConnectStore _fallback; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The Redis connection multiplexer. /// The logger. public RedisQuickConnectStore(IConnectionMultiplexer redis, ILogger logger) { ArgumentNullException.ThrowIfNull(redis); _db = redis.GetDatabase(); _fallback = new InMemoryQuickConnectStore(); _logger = logger; } /// public async Task GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default) { try { var raw = await _db.StringGetAsync(RequestKey(secret)).ConfigureAwait(false); if (raw.HasValue) { return JsonSerializer.Deserialize(raw.ToString(), JsonDefaults.Options); } } catch (Exception ex) { LogDegraded(ex); } return await _fallback.GetRequestBySecretAsync(secret, cancellationToken).ConfigureAwait(false); } /// public async Task GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default) { try { var secret = await _db.StringGetAsync(CodeKey(code)).ConfigureAwait(false); if (secret.HasValue) { return await GetRequestBySecretAsync(secret.ToString(), cancellationToken).ConfigureAwait(false); } } catch (Exception ex) { LogDegraded(ex); } return await _fallback.GetRequestByCodeAsync(code, cancellationToken).ConfigureAwait(false); } /// public async Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var ttl = expiresUtc - DateTime.UtcNow; if (ttl <= TimeSpan.Zero) { return; } try { var json = JsonSerializer.Serialize(request, JsonDefaults.Options); await _db.StringSetAsync(RequestKey(request.Secret), json, ttl).ConfigureAwait(false); await _db.StringSetAsync(CodeKey(request.Code), request.Secret, ttl).ConfigureAwait(false); } catch (Exception ex) { LogDegraded(ex); await _fallback.SetRequestAsync(request, expiresUtc, cancellationToken).ConfigureAwait(false); } } /// public async Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default) { var ttl = expiresUtc - DateTime.UtcNow; if (ttl <= TimeSpan.Zero) { return; } try { var json = JsonSerializer.Serialize(authenticationResult, JsonDefaults.Options); await _db.StringSetAsync(AuthorizationKey(secret), json, ttl).ConfigureAwait(false); } catch (Exception ex) { LogDegraded(ex); await _fallback.SetAuthorizationAsync(secret, authenticationResult, expiresUtc, cancellationToken).ConfigureAwait(false); } } /// public async Task TryConsumeAuthorizationAsync(string secret, CancellationToken cancellationToken = default) { try { var raw = await _db.StringGetDeleteAsync(AuthorizationKey(secret)).ConfigureAwait(false); if (raw.HasValue) { return JsonSerializer.Deserialize(raw.ToString(), JsonDefaults.Options); } } catch (Exception ex) { LogDegraded(ex); } return await _fallback.TryConsumeAuthorizationAsync(secret, cancellationToken).ConfigureAwait(false); } private static string RequestKey(string secret) => KeyPrefix + "request:" + secret; private static string CodeKey(string code) => KeyPrefix + "code:" + code; private static string AuthorizationKey(string secret) => KeyPrefix + "auth:" + secret; private void LogDegraded(Exception exception) => _logger.LogWarning(exception, "Quick connect state could not be shared through Redis; falling back to this instance only."); }