diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs index c37238a..096f24b 100644 --- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs +++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs @@ -326,13 +326,18 @@ public sealed class ShareLinksController : ControllerBase return LinkUnavailablePage(Request); } - var html = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false); - if (html is null) + var result = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false); + if (result.AtCapacity) + { + return LinkBusyPage(); + } + + if (result.Html is null) { return LinkUnavailablePage(Request); } - return Content(html, "text/html; charset=utf-8"); + return Content(result.Html, "text/html; charset=utf-8"); } private static ContentResult LinkUnavailablePage(HttpRequest request) @@ -380,6 +385,45 @@ setTimeout(function () { window.location.replace({{redirectUrlJson}}); }, 4000); }; } + /// + /// Served when a multi-use link has as many viewers as it is allowed. The link + /// itself is still good, so this deliberately invites a retry instead of + /// looking like a dead link. + /// + private static ContentResult LinkBusyPage() + { + var html = """ + + + + + + Too many viewers + + + +
+
This link is being watched by as many people as it allows right now.
+
Ce lien est deja utilise par autant de personnes qu'il l'autorise.
+

Try again

+
+ + +"""; + + return new ContentResult + { + StatusCode = StatusCodes.Status503ServiceUnavailable, + ContentType = "text/html; charset=utf-8", + Content = html + }; + } + private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record) { return new ShareLinkAdminRecordDto diff --git a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs index f4839ed..5a68fcd 100644 --- a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs @@ -39,6 +39,12 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets a value indicating whether links default to one use. public bool OneUseDefault { get; set; } = true; + /// + /// Gets or sets how many people may watch a multi-use link at the same time. + /// 0 means no limit. One-use links are always a single viewer regardless. + /// + public int MaxConcurrentViewers { get; set; } = 10; + /// Gets or sets a value indicating whether guest-mode lockdown is enabled. public bool GuestModeLockdownEnabled { get; set; } = true; diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj index a717594..672176a 100644 --- a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj +++ b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj @@ -6,9 +6,9 @@ latest Jellyfin.Plugin.ShareLinks Jellyfin.Plugin.ShareLinks - 1.0.2.0 - 1.0.2.0 - 1.0.2.0 + 1.0.3.0 + 1.0.3.0 + 1.0.3.0 true false disable diff --git a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs index 62a40d7..bf7b774 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs @@ -187,7 +187,9 @@ public sealed class JellyfinGuestUserService EnabledFolders = Array.Empty(), EnablePublicSharing = false, LoginAttemptsBeforeLockout = -1, - MaxActiveSessions = 1, + // One viewer for a one-use link. A multi-use link gets the configured + // ceiling, where 0 is how Jellyfin spells "no limit" in its session check. + MaxActiveSessions = record.OneUse ? 1 : Math.Max(config.MaxConcurrentViewers, 0), BlockUnratedItems = Array.Empty() }; diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs index 1f60e29..6ccf55d 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs @@ -1,4 +1,5 @@ using System; +using System.Security; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -13,6 +14,19 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.ShareLinks.Services; +/// Outcome of a redemption attempt. +public sealed class ShareLinkRedemptionResult +{ + /// Gets the bootstrap HTML when a session was minted, otherwise null. + public string? Html { get; init; } + + /// + /// Gets a value indicating whether the link is valid but already has as many + /// viewers as it is allowed to have. + /// + public bool AtCapacity { get; init; } +} + /// Handles public share-link redemption and the bootstrap HTML response. public sealed class ShareLinkRedemptionService { @@ -47,8 +61,8 @@ public sealed class ShareLinkRedemptionService _logger = logger; } - /// Redeems a token and returns the bootstrap HTML, or null if the token is unusable. - public async Task RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) + /// Redeems a token and returns the redemption result. + public async Task RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) { // One redemption at a time: the status checks below and the status write // that follows them are not atomic, so two requests arriving together with @@ -65,50 +79,50 @@ public sealed class ShareLinkRedemptionService } /// Runs a single redemption; callers must hold the redemption gate. - private async Task RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) + private async Task RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) { var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false); if (tokenHash is null) { - return null; + return new ShareLinkRedemptionResult(); } var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false); if (record is null) { - return null; + return new ShareLinkRedemptionResult(); } var now = DateTimeOffset.UtcNow; if (record.ExpiresAtUtc <= now) { await HandleTerminalRecordAsync(record, ShareLinkStatus.Expired, "Share link has expired.", cancellationToken).ConfigureAwait(false); - return null; + return new ShareLinkRedemptionResult(); } if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed) { - return null; + return new ShareLinkRedemptionResult(); } // Checked before any library write: re-tagging the whole tree on every hit // to an already-spent link would be a pointless metadata write storm. if (record.OneUse && record.Status == ShareLinkStatus.Redeemed) { - return null; + return new ShareLinkRedemptionResult(); } if (!Guid.TryParse(record.ItemId, out var itemId)) { await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false); - return null; + return new ShareLinkRedemptionResult(); } var item = _libraryManager.GetItemById(itemId); if (item is null) { await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false); - return null; + return new ShareLinkRedemptionResult(); } if (!string.IsNullOrWhiteSpace(record.AllowedTag)) @@ -117,7 +131,11 @@ public sealed class ShareLinkRedemptionService record.MetadataTouched = true; } - if (string.IsNullOrWhiteSpace(record.DeviceId)) + // Jellyfin logs out any existing session for the same user and device id, + // so every viewer of a multi-use link needs a device id of their own or + // each new arrival would kick the previous one off. A one-use link has a + // single viewer and keeps a stable id. + if (!record.OneUse || string.IsNullOrWhiteSpace(record.DeviceId)) { record.DeviceId = Guid.NewGuid().ToString("N"); } @@ -158,6 +176,17 @@ public sealed class ShareLinkRedemptionService record.CleanupError = null; await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); } + catch (SecurityException ex) + { + // The link is fine, the guest account has simply reached its viewer + // ceiling. Leave the record and the guest alone: marking this failed + // would tear down the account and throw out everyone already watching. + record.Status = record.RedeemedAtUtc.HasValue ? ShareLinkStatus.Redeemed : ShareLinkStatus.Active; + record.CleanupError = null; + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + _logger.LogInformation(ex, "ShareLinks: record {RecordId} is at its viewer ceiling; turning a viewer away.", record.Id); + return new ShareLinkRedemptionResult { AtCapacity = true }; + } catch (Exception ex) { record.Status = ShareLinkStatus.Failed; @@ -165,10 +194,10 @@ public sealed class ShareLinkRedemptionService 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 new ShareLinkRedemptionResult(); } - return BuildBootstrapHtml(request, authResult, itemId); + return new ShareLinkRedemptionResult { Html = BuildBootstrapHtml(request, authResult, itemId) }; } private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken) diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html index 2b5a11d..a69f826 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/configPage.html +++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html @@ -101,8 +101,14 @@
+
This only decides how the "Let several people use this link" box starts out in the create popup; you can change it for every link you make. A single-use link stops working the moment the first person opens it, and only that person keeps access until it expires. A multi-use link can be opened by everyone you send it to, for as long as it is valid.
+
+ +
+ +
How many people may watch a multi-use link at the same time. 0 means no limit. Someone arriving once the limit is reached is asked to try again later; nobody already watching is disturbed. Single-use links are always one viewer.
@@ -217,6 +223,7 @@ page.querySelector('#AllowRemuxing').checked = cfg.AllowRemuxing !== false; page.querySelector('#CleanupIntervalMinutes').value = cfg.CleanupIntervalMinutes || 60; page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false; + page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers; page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false; }).finally(function () { Dashboard.hideLoadingMsg(); @@ -398,6 +405,7 @@ cfg.AllowRemuxing = page.querySelector('#AllowRemuxing').checked; cfg.CleanupIntervalMinutes = parseInt(page.querySelector('#CleanupIntervalMinutes').value, 10) || 60; cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked; + cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0); cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked; ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js index c86b4e3..38fc769 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 = 'ShareLink'; - var clientVersion = '1.0.2-ui-3'; + var clientVersion = '1.0.3-ui-2'; var allowedItemStorageKey = 'sharelinks.allowedItemId'; var guestClassName = 'sharelinks-guest'; var hiddenAttr = 'data-sharelinks-hidden'; @@ -65,6 +65,10 @@ pickDateFirst: 'Pick a date and time first.', dateInvalid: 'That date is not valid.', pickFuture: 'Pick a time in the future.', + multiUseLabel: 'Let several people use this link', + multiUseHint: 'The link keeps working for anyone you send it to until it expires, instead of dying once the first person opens it.', + multiUseLimit: 'Up to {count} of them can watch at the same time.', + resultMultiUseNote: 'Anyone you send this link to can open it until it expires.', cannotDetermineItem: 'Could not determine which item to share. Open the item page and retry.', adminOnly: 'ShareLinks is available to administrators only.', disabled: 'ShareLinks is disabled.', @@ -89,6 +93,10 @@ pickDateFirst: 'Choisissez d\'abord une date et une heure.', dateInvalid: 'Cette date n\'est pas valide.', pickFuture: 'Choisissez une date dans le futur.', + multiUseLabel: 'Autoriser plusieurs personnes à utiliser ce lien', + multiUseHint: 'Le lien reste valable pour toutes les personnes à qui vous l\'envoyez jusqu\'à son expiration, au lieu de mourir dès la première ouverture.', + multiUseLimit: 'Jusqu\'a {count} d\'entre elles peuvent regarder en meme temps.', + resultMultiUseNote: 'Toutes les personnes à qui vous envoyez ce lien peuvent l\'ouvrir jusqu\'à son expiration.', cannotDetermineItem: 'Impossible de déterminer l\'élément à partager. Ouvrez la page du média et réessayez.', adminOnly: 'ShareLinks est réservé aux administrateurs.', disabled: 'ShareLinks est désactivé.', @@ -1063,11 +1071,11 @@ return; } - var result = await chooseExpiryHours(config, function (expiryHours) { + var result = await chooseExpiryHours(config, function (expiryHours, multiUse) { var payload = { itemId: itemId, expiryHours: expiryHours, - oneUse: config && config.OneUseDefault !== undefined ? !!config.OneUseDefault : true + oneUse: !multiUse }; var shareUrlPromise = apiPost('ShareLinks/Admin/Create', payload).then(function (response) { @@ -1086,7 +1094,8 @@ }).then(function (copied) { return { shareUrl: shareUrl, - copied: copied + copied: copied, + multiUse: !!multiUse }; }); }); @@ -1095,7 +1104,7 @@ return; } - showShareResult(result.shareUrl, result.copied); + showShareResult(result.shareUrl, result.copied, result.multiUse); } catch (error) { notify(extractErrorMessage(error, t('couldNotCreate'))); } @@ -1134,6 +1143,11 @@ options: options, onChoose: onChoose, cancelText: t('cancel'), + toggle: { + label: t('multiUseLabel'), + hint: buildMultiUseHint(config), + checked: !(config && config.OneUseDefault !== false) + }, datePicker: { min: toLocalDatetimeValue(minDate), max: toLocalDatetimeValue(maxDate), @@ -1143,6 +1157,16 @@ }); } + function buildMultiUseHint(config) { + var hint = t('multiUseHint'); + var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN; + if (Number.isFinite(limit) && limit > 0) { + hint += ' ' + t('multiUseLimit').replace('{count}', limit); + } + + return hint; + } + function copyTextWhenReady(textPromise) { if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) { try { @@ -1193,7 +1217,7 @@ }, 3600); } - function showShareResult(shareUrl, copied) { + function showShareResult(shareUrl, copied, multiUse) { ensureShareLinksUi(); var body = document.createElement('div'); var note = document.createElement('p'); @@ -1203,6 +1227,13 @@ : t('notCopiedNote'); body.appendChild(note); + if (multiUse) { + var multiUseNote = document.createElement('p'); + multiUseNote.className = 'sharelinks-note'; + multiUseNote.textContent = t('resultMultiUseNote'); + body.appendChild(multiUseNote); + } + var urlBox = document.createElement('textarea'); urlBox.className = 'sharelinks-url'; urlBox.readOnly = true; @@ -1276,6 +1307,40 @@ body.appendChild(dateRow); } + var toggleInput = null; + if (settings.toggle) { + var toggleRow = document.createElement('label'); + toggleRow.className = 'sharelinks-toggle-row'; + + toggleInput = document.createElement('input'); + toggleInput.type = 'checkbox'; + toggleInput.className = 'sharelinks-toggle-input'; + toggleInput.checked = !!settings.toggle.checked; + + var toggleText = document.createElement('span'); + toggleText.className = 'sharelinks-toggle-text'; + + var toggleLabel = document.createElement('span'); + toggleLabel.className = 'sharelinks-toggle-label'; + toggleLabel.textContent = settings.toggle.label; + toggleText.appendChild(toggleLabel); + + if (settings.toggle.hint) { + var toggleHint = document.createElement('span'); + toggleHint.className = 'sharelinks-toggle-hint'; + toggleHint.textContent = settings.toggle.hint; + toggleText.appendChild(toggleHint); + } + + toggleRow.appendChild(toggleInput); + toggleRow.appendChild(toggleText); + body.appendChild(toggleRow); + } + + function toggleChecked() { + return !!(toggleInput && toggleInput.checked); + } + return new Promise(function (resolve) { var modal; var actions = []; @@ -1310,7 +1375,7 @@ if (modal) { modal.close(); } - resolve(settings.onChoose ? settings.onChoose(hours) : hours); + resolve(settings.onChoose ? settings.onChoose(hours, toggleChecked()) : hours); } }); } @@ -1341,7 +1406,7 @@ button.addEventListener('click', function () { modal.close(); if (settings.onChoose) { - resolve(settings.onChoose(option.hours)); + resolve(settings.onChoose(option.hours, toggleChecked())); } else { resolve(option.hours); } @@ -1459,6 +1524,11 @@ '.sharelinks-date-row{margin-top:18px;display:flex;flex-direction:column;gap:8px;}', '.sharelinks-date-label{color:var(--text-secondary-color,#cfcfcf);font-size:.92rem;line-height:1.4;}', '.sharelinks-date-input{width:100%;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px 12px;min-height:42px;font:inherit;color-scheme:dark;}', + '.sharelinks-toggle-row{display:flex;align-items:flex-start;gap:10px;margin-top:18px;padding-top:16px;border-top:1px solid rgba(255,255,255,.12);cursor:pointer;}', + '.sharelinks-toggle-input{margin:2px 0 0;width:18px;height:18px;flex:0 0 auto;accent-color:var(--theme-primary-color,#00a4dc);cursor:pointer;}', + '.sharelinks-toggle-text{display:flex;flex-direction:column;gap:4px;}', + '.sharelinks-toggle-label{line-height:1.35;}', + '.sharelinks-toggle-hint{color:var(--text-secondary-color,#cfcfcf);font-size:.88rem;line-height:1.4;}', '.sharelinks-toast{position:fixed;left:24px;bottom:24px;z-index:1000000;max-width:min(460px,calc(100vw - 48px));background:rgba(24,24,24,.96);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:6px;padding:11px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;}', '.sharelinks-toast.is-visible{opacity:1;transform:translateY(0);}', '@media (max-width:520px){.sharelinks-duration-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sharelinks-dialog{padding:18px;}.sharelinks-actions{justify-content:stretch;}.sharelinks-action{flex:1;}}' diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json index c931d98..fa65dc0 100644 --- a/Jellyfin.Plugin.ShareLinks/meta.json +++ b/Jellyfin.Plugin.ShareLinks/meta.json @@ -1,12 +1,12 @@ { "guid": "68540b76-ee74-436d-85ff-2abc884bbea6", "name": "ShareLinks", - "version": "1.0.2.0", + "version": "1.0.3.0", "targetAbi": "10.11.0.0", "framework": "net9.0", "owner": "Franciskid", "overview": "Secure expiring guest-share links for Jellyfin items.", "description": "Adds secure, expiring share links for Jellyfin items with JSON-backed storage, token hashing, and cleanup scaffolding.", "category": "General", - "timestamp": "2026-07-26T00:00:00.0000000Z" + "timestamp": "2026-07-26T19:30:00.0000000Z" } diff --git a/README.md b/README.md index 8d70d41..1daacb6 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,12 @@ real user or handing over a login that sees everything. 1. As an admin you open the context menu on a movie, episode, series or season and hit **ShareLink**. You choose an expiry (1 hour up to 30 days) and the - plugin hands you a link, copied to your clipboard. + plugin hands you a link, copied to your clipboard. You also choose there + whether the link is single use, which is the default and stops working once + the first person opens it, or multi-use, which lets everyone you send it to + open it until it expires. A multi-use link has a ceiling on how many people can + watch at the same time, ten by default, and the eleventh is asked to try again + later rather than displacing anyone. 2. Behind the scenes the plugin tags the shared item with a unique, random tag and records the share. Share a series or a season and the tag is applied to the whole tree underneath it too - series, seasons and episodes - so the @@ -114,6 +119,35 @@ refuses every interactive sign-in, so the normal login page cannot be used to ge into a guest account at all, password or not. If the plugin is disabled Jellyfin falls back to its own invalid-provider handling, which refuses too. +### What a multi-use link does and does not protect + +A multi-use link is by design usable by anyone you send it to, so treat the URL +itself as the secret. Within that: + +- The tag policy is per account and the account is the same one, so every viewer + still sees exactly the shared title and nothing else. Letting more people in + does not widen what any of them can reach. +- The viewer ceiling caps how many people can *start* watching at once. It is not + a hard cap on how many people ever get in: sessions end, and each redemption + issues its own session token which keeps working until the link is revoked or + expires. If you need a hard stop, revoke the link. +- Everyone shares one temporary account, so they share playback position and + watched state on that title, and they can see each other's sessions in Jellyfin. + If that matters to you, use single-use links. +- Reaching the ceiling turns the new arrival away with a "try again" page. It does + not disturb anyone already watching, and it does not kill the link. + +### Known limits + +- The share token travels in the link's query string, so it will appear in your + reverse proxy's access log and in browser history. +- Redeeming is a public endpoint with no rate limit. Tokens are 256-bit random, so + guessing one is not realistic, but the endpoint is reachable by anyone. +- Records are kept after they expire, for audit, and are never pruned. +- The `sharelinks-` tag is hidden from non-admins in the web client only. It is + still present in the API response for anyone who looks, because that tag is what + confines the guest and it cannot be removed without removing the confinement. + ## Configuration All of these live on the plugin's dashboard page: @@ -125,7 +159,8 @@ All of these live on the plugin's dashboard page: | Guest username prefix | Prefix for the throwaway guest accounts (default `share-`) | | Allow transcoding / remuxing | Whether guest playback may transcode or remux | | Cleanup interval | How often the background cleanup runs | -| One-use default | Whether new links default to single redemption | +| Maximum viewers per multi-use link | How many people may watch one multi-use link at the same time (default 10, 0 means no limit) | +| Single use by default | How the single-use box starts out in the create popup; it is a per-link choice | | Guest lockdown | The web-client confinement described above (on by default) | | Guest hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI |