ShareLinks: make guest redemption actually work end to end
The sign-in bootstrap sent the auth request body as a JS object, so it
was coerced to [object Object] and AuthenticateByName returned 400. Send
JSON.stringify(...) instead.
Guest credentials were written to localStorage as a flat object under
made-up keys. jellyfin-web reads jellyfin_credentials as
{Servers:[{Id,AccessToken,UserId,...}]}, so the guest was treated as
logged out. Write that shape, pulling server Id/name from
System/Info/Public, and redirect with the 10.11 hash route
(#/details?id=...&serverId=...) instead of the legacy #!/ form that
rendered a blank page.
Creating a link now rejects folders and libraries (only movies and
episodes are shareable) so a guest cannot land on an empty tag-filtered
library. Item ids from the menu action are validated as GUIDs client
side, rejected creates are logged server side, malformed redeem tokens
return 404 instead of 500, and the admin table shows the item name and a
copyable link instead of the raw item id.
Cette révision appartient à :
@@ -75,6 +75,8 @@ public sealed class ShareLinkAdminRecordDto
|
|||||||
public int CleanupAttempts { get; set; }
|
public int CleanupAttempts { get; set; }
|
||||||
|
|
||||||
public string? CleanupError { get; set; }
|
public string? CleanupError { get; set; }
|
||||||
|
|
||||||
|
public string? ShareUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Guest session state returned to the web client.</summary>
|
/// <summary>Guest session state returned to the web client.</summary>
|
||||||
@@ -166,11 +168,13 @@ public sealed class ShareLinksController : ControllerBase
|
|||||||
|
|
||||||
if (request is null || string.IsNullOrWhiteSpace(request.ItemId))
|
if (request is null || string.IsNullOrWhiteSpace(request.ItemId))
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning("ShareLinks: create rejected, missing itemId.");
|
||||||
return BadRequest(new { error = "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." });
|
return BadRequest(new { error = "Invalid itemId." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,15 +193,24 @@ public sealed class ShareLinksController : ControllerBase
|
|||||||
var item = _libraryManager.GetItemById(itemId);
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
if (item is null)
|
if (item is null)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning("ShareLinks: create rejected, item {ItemId} not found.", itemId);
|
||||||
return NotFound(new { error = "Item not found." });
|
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
|
try
|
||||||
{
|
{
|
||||||
var creatorUserId = GetCurrentUserId();
|
var creatorUserId = GetCurrentUserId();
|
||||||
var oneUse = request.OneUse ?? config.OneUseDefault;
|
var oneUse = request.OneUse ?? config.OneUseDefault;
|
||||||
var creation = await _creationService.CreateAsync(item, creatorUserId, expiryHours, oneUse, cancellationToken).ConfigureAwait(false);
|
var creation = await _creationService.CreateAsync(item, creatorUserId, expiryHours, oneUse, cancellationToken).ConfigureAwait(false);
|
||||||
var shareUrl = BuildShareUrl(Request, creation.RawToken);
|
var shareUrl = BuildShareUrl(Request, creation.RawToken);
|
||||||
|
creation.Record.ShareUrl = shareUrl;
|
||||||
|
await _store.UpdateAsync(creation.Record, cancellationToken).ConfigureAwait(false);
|
||||||
return Ok(new ShareLinkCreateResponse
|
return Ok(new ShareLinkCreateResponse
|
||||||
{
|
{
|
||||||
ShareUrl = shareUrl,
|
ShareUrl = shareUrl,
|
||||||
@@ -329,7 +342,8 @@ public sealed class ShareLinksController : ControllerBase
|
|||||||
OneUse = record.OneUse,
|
OneUse = record.OneUse,
|
||||||
MetadataTouched = record.MetadataTouched,
|
MetadataTouched = record.MetadataTouched,
|
||||||
CleanupAttempts = record.CleanupAttempts,
|
CleanupAttempts = record.CleanupAttempts,
|
||||||
CleanupError = record.CleanupError
|
CleanupError = record.CleanupError,
|
||||||
|
ShareUrl = record.ShareUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,4 +67,7 @@ public sealed class ShareLinkRecord
|
|||||||
|
|
||||||
/// <summary>Gets or sets the last cleanup error, if any.</summary>
|
/// <summary>Gets or sets the last cleanup error, if any.</summary>
|
||||||
public string? CleanupError { get; set; }
|
public string? CleanupError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the share URL issued at creation, for admin display.</summary>
|
||||||
|
public string? ShareUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ public sealed class ShareLinkRedemptionService
|
|||||||
public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
|
public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false);
|
var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (tokenHash is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
||||||
if (record is null)
|
if (record is null)
|
||||||
{
|
{
|
||||||
@@ -179,7 +184,7 @@ public sealed class ShareLinkRedemptionService
|
|||||||
{
|
{
|
||||||
var pathBase = request.PathBase.Value ?? string.Empty;
|
var pathBase = request.PathBase.Value ?? string.Empty;
|
||||||
var authUrl = $"{pathBase}/Users/AuthenticateByName";
|
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 username = record.GuestUserName ?? JellyfinGuestUserService.BuildGuestUsername(record);
|
||||||
var deviceId = record.DeviceId ?? string.Empty;
|
var deviceId = record.DeviceId ?? string.Empty;
|
||||||
|
|
||||||
@@ -193,6 +198,8 @@ public sealed class ShareLinkRedemptionService
|
|||||||
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
|
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
|
||||||
var usernameJson = JsonSerializer.Serialize(username);
|
var usernameJson = JsonSerializer.Serialize(username);
|
||||||
var deviceIdJson = JsonSerializer.Serialize(deviceId);
|
var deviceIdJson = JsonSerializer.Serialize(deviceId);
|
||||||
|
var infoUrlJson = JsonSerializer.Serialize($"{pathBase}/System/Info/Public");
|
||||||
|
var pathBaseJson = JsonSerializer.Serialize(pathBase);
|
||||||
|
|
||||||
return $$"""
|
return $$"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -229,7 +236,7 @@ public sealed class ShareLinkRedemptionService
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"X-Emby-Authorization": `MediaBrowser Client="ShareLinks", Device="ShareLinks", DeviceId="${deviceId}", Version="1.0.0"`
|
"X-Emby-Authorization": `MediaBrowser Client="ShareLinks", Device="ShareLinks", DeviceId="${deviceId}", Version="1.0.0"`
|
||||||
},
|
},
|
||||||
body: {{authJson}}
|
body: JSON.stringify({{authJson}})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -239,24 +246,35 @@ public sealed class ShareLinkRedemptionService
|
|||||||
const auth = await response.json();
|
const auth = await response.json();
|
||||||
const accessToken = auth.AccessToken ?? auth.accessToken ?? "";
|
const accessToken = auth.AccessToken ?? auth.accessToken ?? "";
|
||||||
const userId = auth.User?.Id ?? auth.user?.Id ?? auth.UserId ?? auth.userId ?? "";
|
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 = {
|
const info = await fetch({{infoUrlJson}}, {
|
||||||
AccessToken: accessToken,
|
credentials: "same-origin",
|
||||||
UserId: userId,
|
headers: { "Accept": "application/json" }
|
||||||
UserName: userName,
|
}).then((r) => r.json());
|
||||||
ServerUrl: window.location.origin
|
|
||||||
|
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 {
|
try {
|
||||||
for (const key of ["jellyfinCredentials", "jellyfin_credentials", "jellyfin-credentials"]) {
|
localStorage.setItem("jellyfin_credentials", JSON.stringify(credentials));
|
||||||
localStorage.setItem(key, JSON.stringify(snapshot));
|
|
||||||
}
|
|
||||||
localStorage.setItem("jellyfin.server", window.location.origin);
|
|
||||||
} catch (_) {
|
} 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) => {
|
})().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
document.getElementById("status").textContent = "Sign-in failed.";
|
document.getElementById("status").textContent = "Sign-in failed.";
|
||||||
|
|||||||
@@ -44,16 +44,28 @@ public sealed class ShareTokenService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Computes the stored hash for a presented token.</summary>
|
/// <summary>
|
||||||
public async Task<string> HashTokenAsync(string token, CancellationToken cancellationToken = default)
|
/// Computes the stored hash for a presented token, or <see langword="null"/> if the token
|
||||||
|
/// is missing or not well-formed base64url (treated as "no match" rather than an error).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> HashTokenAsync(string token, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
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 secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var tokenBytes = Base64UrlDecode(token);
|
|
||||||
return ComputeHash(secret, tokenBytes);
|
return ComputeHash(secret, tokenBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,21 +77,15 @@ public sealed class ShareTokenService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
var actualHash = await HashTokenAsync(token, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
if (actualHash is null)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
Encoding.UTF8.GetBytes(actualHash),
|
||||||
|
Encoding.UTF8.GetBytes(expectedHash));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Encrypts sensitive text using the shared plugin secret.</summary>
|
/// <summary>Encrypts sensitive text using the shared plugin secret.</summary>
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="width: 9rem;">Status</th>
|
<th style="width: 9rem;">Status</th>
|
||||||
<th>Item</th>
|
<th>Item</th>
|
||||||
|
<th style="width: 14rem;">Link</th>
|
||||||
<th style="width: 11rem;">Guest</th>
|
<th style="width: 11rem;">Guest</th>
|
||||||
<th style="width: 11rem;">Expires</th>
|
<th style="width: 11rem;">Expires</th>
|
||||||
<th style="width: 9rem;" class="sl-right">Actions</th>
|
<th style="width: 9rem;" class="sl-right">Actions</th>
|
||||||
@@ -177,7 +178,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="LinksBody">
|
<tbody id="LinksBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="sl-muted">No links loaded yet.</td>
|
<td colspan="6" class="sl-muted">No links loaded yet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -237,7 +238,7 @@
|
|||||||
}) : [];
|
}) : [];
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
body.innerHTML = '<tr><td colspan="5" class="sl-muted">No active links.</td></tr>';
|
body.innerHTML = '<tr><td colspan="6" class="sl-muted">No active links.</td></tr>';
|
||||||
page.querySelector('#LinksStatus').textContent = 'No share links.';
|
page.querySelector('#LinksStatus').textContent = 'No share links.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,13 +248,18 @@
|
|||||||
var itemName = escapeHtml(record.ItemNameSnapshot || record.ItemId || '');
|
var itemName = escapeHtml(record.ItemNameSnapshot || record.ItemId || '');
|
||||||
var guestName = escapeHtml(record.GuestUserName || 'n/a');
|
var guestName = escapeHtml(record.GuestUserName || 'n/a');
|
||||||
var expires = fmtDate(record.ExpiresAtUtc || record.ExpiresAt);
|
var expires = fmtDate(record.ExpiresAtUtc || record.ExpiresAt);
|
||||||
|
var shareUrl = record.ShareUrl ? escapeHtml(record.ShareUrl) : '';
|
||||||
|
var linkCell = shareUrl
|
||||||
|
? '<button is="emby-button" type="button" class="raised" data-copy="' + shareUrl + '"><span>Copy</span></button> ' +
|
||||||
|
'<a href="' + shareUrl + '" target="_blank" rel="noopener">Open</a>'
|
||||||
|
: '<span class="sl-muted">n/a</span>';
|
||||||
return [
|
return [
|
||||||
'<tr>',
|
'<tr>',
|
||||||
'<td>', escapeHtml(status), '</td>',
|
'<td>', escapeHtml(status), '</td>',
|
||||||
'<td>',
|
'<td>',
|
||||||
'<div><strong>', itemName, '</strong></div>',
|
'<div><strong>', itemName, '</strong></div>',
|
||||||
'<div class="sl-muted">', escapeHtml(record.ItemId || ''), '</div>',
|
|
||||||
'</td>',
|
'</td>',
|
||||||
|
'<td>', linkCell, '</td>',
|
||||||
'<td>', guestName, '</td>',
|
'<td>', guestName, '</td>',
|
||||||
'<td>', escapeHtml(expires), '</td>',
|
'<td>', escapeHtml(expires), '</td>',
|
||||||
'<td class="sl-right">',
|
'<td class="sl-right">',
|
||||||
@@ -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.';
|
page.querySelector('#LinksStatus').textContent = items.length + ' link' + (items.length === 1 ? '' : 's') + ' loaded.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,10 +297,57 @@
|
|||||||
renderLinks(list || []);
|
renderLinks(list || []);
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
status.textContent = 'Could not load share links.';
|
status.textContent = 'Could not load share links.';
|
||||||
page.querySelector('#LinksBody').innerHTML = '<tr><td colspan="5" class="sl-muted">' + escapeHtml(error && error.message ? error.message : 'Load failed.') + '</td></tr>';
|
page.querySelector('#LinksBody').innerHTML = '<tr><td colspan="6" class="sl-muted">' + escapeHtml(error && error.message ? error.message : 'Load failed.') + '</td></tr>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function revokeLink(id) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
|
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
|
||||||
var copyLabel = 'Copy Stream URL';
|
var copyLabel = 'Copy Stream URL';
|
||||||
var actionLabel = 'Create guest link';
|
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 allowedItemStorageKey = 'sharelinks.allowedItemId';
|
||||||
var guestClassName = 'sharelinks-guest';
|
var guestClassName = 'sharelinks-guest';
|
||||||
var hiddenAttr = 'data-sharelinks-hidden';
|
var hiddenAttr = 'data-sharelinks-hidden';
|
||||||
@@ -257,6 +257,10 @@
|
|||||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
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() {
|
async function scanForMoreMenuActions() {
|
||||||
var user = await getCurrentUser();
|
var user = await getCurrentUser();
|
||||||
if (!isAdministrator(user)) {
|
if (!isAdministrator(user)) {
|
||||||
@@ -430,7 +434,10 @@
|
|||||||
var text = sources[i];
|
var text = sources[i];
|
||||||
var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i);
|
var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i);
|
||||||
if (match && match[1]) {
|
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 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) {
|
function isAdministrator(user) {
|
||||||
@@ -483,7 +502,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isDetailsOrPlaybackRoute() {
|
function isDetailsOrPlaybackRoute() {
|
||||||
return /#!\/(?:details|playback|item)/i.test(location.hash || '');
|
return /#\/(?:details|video|playback|list|item)/i.test(location.hash || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedLocation() {
|
function isAllowedLocation() {
|
||||||
@@ -491,13 +510,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToItem(itemId) {
|
function navigateToItem(itemId) {
|
||||||
var target = '#!/details?id=' + encodeURIComponent(itemId);
|
var target = '#/details?id=' + encodeURIComponent(itemId);
|
||||||
if (location.hash !== target) {
|
if (location.hash !== target) {
|
||||||
location.hash = target;
|
location.hash = target;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGuestLink(itemId) {
|
async function createGuestLink(itemId) {
|
||||||
|
if (!isItemGuid(itemId)) {
|
||||||
|
notify('Could not determine which item to share. Open the item page and retry.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var config = await getConfig();
|
var config = await getConfig();
|
||||||
var user = await getCurrentUser();
|
var user = await getCurrentUser();
|
||||||
|
|||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur