Fichiers
jellyfin-plugin-sharelinks/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs
T
Franciskid c29ea7f20c ShareLinks plugin: guest share links for Jellyfin
Includes fix for redemption failing with DbUpdateConcurrencyException:
change the guest password before applying the user policy, since
UpdatePolicyAsync bumps the user's EF concurrency token and a stale
instance then breaks ChangePassword.
2026-07-06 18:21:22 +02:00

270 lignes
10 KiB
C#

using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Handles public share-link redemption and the bootstrap HTML response.</summary>
public sealed class ShareLinkRedemptionService
{
private readonly ILibraryManager _libraryManager;
private readonly ShareLinkStore _store;
private readonly ShareTokenService _tokenService;
private readonly ItemTagService _itemTagService;
private readonly JellyfinGuestUserService _guestUserService;
private readonly ShareLinkCleanupService _cleanupService;
private readonly ILogger<ShareLinkRedemptionService> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
public ShareLinkRedemptionService(
ILibraryManager libraryManager,
ShareLinkStore store,
ShareTokenService tokenService,
ItemTagService itemTagService,
JellyfinGuestUserService guestUserService,
ShareLinkCleanupService cleanupService,
ILogger<ShareLinkRedemptionService> logger)
{
_libraryManager = libraryManager;
_store = store;
_tokenService = tokenService;
_itemTagService = itemTagService;
_guestUserService = guestUserService;
_cleanupService = cleanupService;
_logger = logger;
}
/// <summary>Redeems a token and returns the bootstrap HTML, or null if the token is unusable.</summary>
public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
{
var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false);
var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
var now = DateTimeOffset.UtcNow;
if (record.ExpiresAtUtc <= now)
{
await HandleTerminalRecordAsync(record, ShareLinkStatus.Expired, "Share link has expired.", cancellationToken).ConfigureAwait(false);
return null;
}
if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed)
{
return null;
}
if (!Guid.TryParse(record.ItemId, out var itemId))
{
await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false);
return null;
}
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false);
return null;
}
if (!string.IsNullOrWhiteSpace(record.AllowedTag))
{
await _itemTagService.EnsureTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false);
record.MetadataTouched = true;
}
if (record.OneUse && record.Status == ShareLinkStatus.Redeemed)
{
return null;
}
if (string.IsNullOrWhiteSpace(record.DeviceId))
{
record.DeviceId = Guid.NewGuid().ToString("N");
}
record.Status = ShareLinkStatus.Redeeming;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
var password = await GetOrCreatePasswordAsync(record, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(record.GuestUserName))
{
record.GuestUserName = JellyfinGuestUserService.BuildGuestUsername(record);
}
try
{
var user = await _guestUserService.EnsureGuestUserAsync(record, password, cancellationToken).ConfigureAwait(false);
record.GuestUserId = user.Id;
record.GuestUserName = user.Username;
record.RedeemedAtUtc ??= now;
record.Status = ShareLinkStatus.Redeemed;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
record.Status = ShareLinkStatus.Failed;
record.CleanupError = ex.Message;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
_logger.LogWarning(ex, "ShareLinks: failed to prepare guest session for record {RecordId}.", record.Id);
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
return null;
}
return BuildBootstrapHtml(request, record, password, itemId);
}
private async Task<string> GetOrCreatePasswordAsync(ShareLinkRecord record, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(record.GuestPasswordEncrypted))
{
try
{
return await _tokenService.UnprotectStringAsync(record.GuestPasswordEncrypted, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: stored guest password could not be decrypted for record {RecordId}; generating a replacement.", record.Id);
}
}
var password = JellyfinGuestUserService.GeneratePassword();
record.GuestPasswordEncrypted = await _tokenService.ProtectStringAsync(password, cancellationToken).ConfigureAwait(false);
record.Status = ShareLinkStatus.Redeeming;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
return password;
}
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
{
record.Status = terminalStatus;
record.CleanupError = reason;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
}
private async Task HandleFailureAsync(ShareLinkRecord record, string reason, CancellationToken cancellationToken)
{
record.Status = ShareLinkStatus.Failed;
record.CleanupError = reason;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
}
private async Task TryCleanupAsync(ShareLinkRecord record, CancellationToken cancellationToken)
{
try
{
await _cleanupService.CleanupRecordAsync(record.Id, true, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ShareLinks: cleanup after failed redemption did not complete for record {RecordId}.", record.Id);
}
}
private static string BuildBootstrapHtml(HttpRequest request, ShareLinkRecord record, string password, Guid itemId)
{
var pathBase = request.PathBase.Value ?? string.Empty;
var authUrl = $"{pathBase}/Users/AuthenticateByName";
var redirectUrl = $"{pathBase}/web/index.html#!/details?id={Uri.EscapeDataString(itemId.ToString("D"))}";
var username = record.GuestUserName ?? JellyfinGuestUserService.BuildGuestUsername(record);
var deviceId = record.DeviceId ?? string.Empty;
var authJson = JsonSerializer.Serialize(new
{
Username = username,
Pw = password
});
var authUrlJson = JsonSerializer.Serialize(authUrl);
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
var usernameJson = JsonSerializer.Serialize(username);
var deviceIdJson = JsonSerializer.Serialize(deviceId);
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signing in...</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; }
main { max-width: 36rem; padding: 2rem; }
.muted { color: #9ca3af; }
</style>
</head>
<body>
<main>
<div>Signing you in...</div>
<div class="muted" id="status">Preparing temporary access.</div>
</main>
<script>
(async () => {
const authUrl = {{authUrlJson}};
const redirectUrl = {{redirectUrlJson}};
const username = {{usernameJson}};
const deviceId = {{deviceIdJson}} || crypto.randomUUID().replace(/-/g, "");
document.getElementById("status").textContent = "Authenticating " + username + ".";
const response = await fetch(authUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Emby-Authorization": `MediaBrowser Client="ShareLinks", Device="ShareLinks", DeviceId="${deviceId}", Version="1.0.0"`
},
body: {{authJson}}
});
if (!response.ok) {
throw new Error(`Authentication failed (${response.status})`);
}
const auth = await response.json();
const accessToken = auth.AccessToken ?? auth.accessToken ?? "";
const userId = auth.User?.Id ?? auth.user?.Id ?? auth.UserId ?? auth.userId ?? "";
const userName = auth.User?.Name ?? auth.user?.Name ?? auth.UserName ?? auth.userName ?? username;
const snapshot = {
AccessToken: accessToken,
UserId: userId,
UserName: userName,
ServerUrl: window.location.origin
};
try {
for (const key of ["jellyfinCredentials", "jellyfin_credentials", "jellyfin-credentials"]) {
localStorage.setItem(key, JSON.stringify(snapshot));
}
localStorage.setItem("jellyfin.server", window.location.origin);
} catch (_) {
// Best effort only. Jellyfin Web storage format should be verified live.
}
window.location.replace(redirectUrl);
})().catch((error) => {
console.error(error);
document.getElementById("status").textContent = "Sign-in failed.";
});
</script>
</body>
</html>
""";
}
}