diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs index cb782c6..17b5aeb 100644 --- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs +++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs @@ -75,6 +75,8 @@ public sealed class ShareLinkAdminRecordDto public int CleanupAttempts { get; set; } public string? CleanupError { get; set; } + + public string? ShareUrl { get; set; } } /// Guest session state returned to the web client. @@ -166,11 +168,13 @@ public sealed class ShareLinksController : ControllerBase if (request is null || string.IsNullOrWhiteSpace(request.ItemId)) { + _logger.LogWarning("ShareLinks: create rejected, missing itemId."); return BadRequest(new { error = "Missing itemId." }); } - if (!Guid.TryParse(request.ItemId, out var itemId)) + if (!Guid.TryParse(request.ItemId!.Trim(), out var itemId)) { + _logger.LogWarning("ShareLinks: create rejected, itemId {ItemId} is not a GUID.", request.ItemId); return BadRequest(new { error = "Invalid itemId." }); } @@ -189,15 +193,24 @@ public sealed class ShareLinksController : ControllerBase var item = _libraryManager.GetItemById(itemId); if (item is null) { + _logger.LogWarning("ShareLinks: create rejected, item {ItemId} not found.", itemId); return NotFound(new { error = "Item not found." }); } + if (item.IsFolder) + { + _logger.LogWarning("ShareLinks: create rejected, item {ItemId} \"{ItemName}\" is a folder or library, not shareable media.", itemId, item.Name); + return BadRequest(new { error = "Only a movie or episode can be shared, not a folder or library. Open the title's page and try again." }); + } + try { var creatorUserId = GetCurrentUserId(); var oneUse = request.OneUse ?? config.OneUseDefault; var creation = await _creationService.CreateAsync(item, creatorUserId, expiryHours, oneUse, cancellationToken).ConfigureAwait(false); var shareUrl = BuildShareUrl(Request, creation.RawToken); + creation.Record.ShareUrl = shareUrl; + await _store.UpdateAsync(creation.Record, cancellationToken).ConfigureAwait(false); return Ok(new ShareLinkCreateResponse { ShareUrl = shareUrl, @@ -329,7 +342,8 @@ public sealed class ShareLinksController : ControllerBase OneUse = record.OneUse, MetadataTouched = record.MetadataTouched, CleanupAttempts = record.CleanupAttempts, - CleanupError = record.CleanupError + CleanupError = record.CleanupError, + ShareUrl = record.ShareUrl }; } diff --git a/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs index d02cd84..f41e6f9 100644 --- a/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs +++ b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs @@ -67,4 +67,7 @@ public sealed class ShareLinkRecord /// Gets or sets the last cleanup error, if any. public string? CleanupError { get; set; } + + /// Gets or sets the share URL issued at creation, for admin display. + public string? ShareUrl { get; set; } } diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs index 408aece..20be892 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs @@ -45,6 +45,11 @@ public sealed class ShareLinkRedemptionService public async Task RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) { var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false); + if (tokenHash is null) + { + return null; + } + var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false); if (record is null) { @@ -179,7 +184,7 @@ public sealed class ShareLinkRedemptionService { 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 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; @@ -193,6 +198,8 @@ public sealed class ShareLinkRedemptionService var redirectUrlJson = JsonSerializer.Serialize(redirectUrl); var usernameJson = JsonSerializer.Serialize(username); var deviceIdJson = JsonSerializer.Serialize(deviceId); + var infoUrlJson = JsonSerializer.Serialize($"{pathBase}/System/Info/Public"); + var pathBaseJson = JsonSerializer.Serialize(pathBase); return $$""" @@ -229,7 +236,7 @@ public sealed class ShareLinkRedemptionService "Accept": "application/json", "X-Emby-Authorization": `MediaBrowser Client="ShareLinks", Device="ShareLinks", DeviceId="${deviceId}", Version="1.0.0"` }, - body: {{authJson}} + body: JSON.stringify({{authJson}}) }); if (!response.ok) { @@ -239,24 +246,35 @@ public sealed class ShareLinkRedemptionService 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 + + const info = await fetch({{infoUrlJson}}, { + credentials: "same-origin", + headers: { "Accept": "application/json" } + }).then((r) => r.json()); + + const serverAddress = window.location.origin + {{pathBaseJson}}; + const credentials = { + Servers: [ + { + ManualAddress: serverAddress, + manualAddressOnly: true, + Name: info.ServerName || "Jellyfin", + Id: info.Id, + LastConnectionMode: 1, + AccessToken: accessToken, + UserId: userId, + DateLastAccessed: Date.now() + } + ] }; try { - for (const key of ["jellyfinCredentials", "jellyfin_credentials", "jellyfin-credentials"]) { - localStorage.setItem(key, JSON.stringify(snapshot)); - } - localStorage.setItem("jellyfin.server", window.location.origin); + localStorage.setItem("jellyfin_credentials", JSON.stringify(credentials)); } catch (_) { - // Best effort only. Jellyfin Web storage format should be verified live. + // If storage is blocked the redirect lands on the login screen. } - window.location.replace(redirectUrl); + window.location.replace(redirectUrl + "&serverId=" + encodeURIComponent(info.Id)); })().catch((error) => { console.error(error); document.getElementById("status").textContent = "Sign-in failed."; diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs index c87084d..02c25df 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs @@ -44,16 +44,28 @@ public sealed class ShareTokenService }; } - /// Computes the stored hash for a presented token. - public async Task HashTokenAsync(string token, CancellationToken cancellationToken = default) + /// + /// Computes the stored hash for a presented token, or if the token + /// is missing or not well-formed base64url (treated as "no match" rather than an error). + /// + public async Task HashTokenAsync(string token, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(token)) { - throw new ArgumentException("Token cannot be empty.", nameof(token)); + return null; + } + + byte[] tokenBytes; + try + { + tokenBytes = Base64UrlDecode(token); + } + catch (FormatException) + { + return null; } var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false); - var tokenBytes = Base64UrlDecode(token); return ComputeHash(secret, tokenBytes); } @@ -65,21 +77,15 @@ public sealed class ShareTokenService return false; } - try - { - var actualHash = await HashTokenAsync(token, cancellationToken).ConfigureAwait(false); - return CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(actualHash), - Encoding.UTF8.GetBytes(expectedHash)); - } - catch (ArgumentException) - { - return false; - } - catch (FormatException) + var actualHash = await HashTokenAsync(token, cancellationToken).ConfigureAwait(false); + if (actualHash is null) { return false; } + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(actualHash), + Encoding.UTF8.GetBytes(expectedHash)); } /// Encrypts sensitive text using the shared plugin secret. diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html index f59307e..af35a35 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/configPage.html +++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html @@ -170,6 +170,7 @@ Status Item + Link Guest Expires Actions @@ -177,7 +178,7 @@ - No links loaded yet. + No links loaded yet. @@ -237,7 +238,7 @@ }) : []; if (!items.length) { - body.innerHTML = 'No active links.'; + body.innerHTML = 'No active links.'; page.querySelector('#LinksStatus').textContent = 'No share links.'; return; } @@ -247,13 +248,18 @@ var itemName = escapeHtml(record.ItemNameSnapshot || record.ItemId || ''); var guestName = escapeHtml(record.GuestUserName || 'n/a'); var expires = fmtDate(record.ExpiresAtUtc || record.ExpiresAt); + var shareUrl = record.ShareUrl ? escapeHtml(record.ShareUrl) : ''; + var linkCell = shareUrl + ? ' ' + + 'Open' + : 'n/a'; return [ '', '', escapeHtml(status), '', '', '
', itemName, '
', - '
', escapeHtml(record.ItemId || ''), '
', '', + '', linkCell, '', '', guestName, '', '', escapeHtml(expires), '', '', @@ -271,6 +277,12 @@ }); }); + Array.from(body.querySelectorAll('button[data-copy]')).forEach(function (button) { + button.addEventListener('click', function () { + copyShareUrl(button); + }); + }); + page.querySelector('#LinksStatus').textContent = items.length + ' link' + (items.length === 1 ? '' : 's') + ' loaded.'; } @@ -285,10 +297,57 @@ renderLinks(list || []); }).catch(function (error) { status.textContent = 'Could not load share links.'; - page.querySelector('#LinksBody').innerHTML = '' + escapeHtml(error && error.message ? error.message : 'Load failed.') + ''; + page.querySelector('#LinksBody').innerHTML = '' + escapeHtml(error && error.message ? error.message : 'Load failed.') + ''; }); } + function copyShareUrl(button) { + var url = button.getAttribute('data-copy'); + if (!url) { + return; + } + + copyText(url).then(function () { + var span = button.querySelector('span'); + if (!span) { + return; + } + span.textContent = 'Copied'; + window.setTimeout(function () { + span.textContent = 'Copy'; + }, 2000); + }); + } + + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).catch(function () { + return fallbackCopyText(text); + }); + } + + return fallbackCopyText(text); + } + + function fallbackCopyText(text) { + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.top = '-1000px'; + textarea.style.left = '-1000px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand('copy'); + } catch (error) { + // Best effort only. + } + textarea.remove(); + return Promise.resolve(); + } + function revokeLink(id) { if (!id) { return; diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js index 1620ce2..ef47a27 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js +++ b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js @@ -2,7 +2,7 @@ var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6'; var copyLabel = 'Copy Stream URL'; var actionLabel = 'Create guest link'; - var clientVersion = '1.0.0-ui-modal-3'; + var clientVersion = '1.0.0-ui-modal-6'; var allowedItemStorageKey = 'sharelinks.allowedItemId'; var guestClassName = 'sharelinks-guest'; var hiddenAttr = 'data-sharelinks-hidden'; @@ -257,6 +257,10 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } + function isItemGuid(value) { + return /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(String(value || '').trim()); + } + async function scanForMoreMenuActions() { var user = await getCurrentUser(); if (!isAdministrator(user)) { @@ -430,7 +434,10 @@ var text = sources[i]; var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i); if (match && match[1]) { - return decodeURIComponent(match[1]); + var decoded = decodeURIComponent(match[1]); + if (isItemGuid(decoded)) { + return decoded; + } } } @@ -464,7 +471,19 @@ return null; } - return node.getAttribute('data-itemid') || node.getAttribute('data-id') || node.getAttribute('data-item-id') || null; + var candidates = [ + node.getAttribute('data-itemid'), + node.getAttribute('data-id'), + node.getAttribute('data-item-id') + ]; + + for (var i = 0; i < candidates.length; i += 1) { + if (isItemGuid(candidates[i])) { + return candidates[i]; + } + } + + return null; } function isAdministrator(user) { @@ -483,7 +502,7 @@ } function isDetailsOrPlaybackRoute() { - return /#!\/(?:details|playback|item)/i.test(location.hash || ''); + return /#\/(?:details|video|playback|list|item)/i.test(location.hash || ''); } function isAllowedLocation() { @@ -491,13 +510,18 @@ } function navigateToItem(itemId) { - var target = '#!/details?id=' + encodeURIComponent(itemId); + var target = '#/details?id=' + encodeURIComponent(itemId); if (location.hash !== target) { location.hash = target; } } async function createGuestLink(itemId) { + if (!isItemGuid(itemId)) { + notify('Could not determine which item to share. Open the item page and retry.'); + return; + } + try { var config = await getConfig(); var user = await getCurrentUser();