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.");
}