* block plugin routes for share guests Adds a global MVC filter that refuses share-guest accounts on any plugin controller. Jellyfin's own API stays open, the share tag policy already bounds it and playback needs it. Guests are identified by the auth provider marker on the account, so this covers a leaked token used from curl or a native client, not just the web client where the CSS lockdown runs. * add plugin exception list and honest wording Config page lists installed plugins with a checkbox each, for the ones that need to answer guests during playback. Default is unticked. Renames the hidden selectors setting to say it is cosmetic, and stops the readme implying the web client lockdown confines anything. * bump to 1.0.4.0
529 lignes
26 KiB
HTML
529 lignes
26 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>ShareLinks</title>
|
|
<style>
|
|
#ShareLinksConfigPage .sl-section {
|
|
margin-bottom: 1.25rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid rgba(127, 127, 127, 0.18);
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 0.75rem 1rem;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-field .fieldDescription {
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-muted {
|
|
color: var(--theme-secondary-color, #666);
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table th,
|
|
#ShareLinksConfigPage .sl-table td {
|
|
padding: 0.45rem 0.4rem;
|
|
border-bottom: 1px solid rgba(127, 127, 127, 0.14);
|
|
vertical-align: top;
|
|
text-align: left;
|
|
word-break: break-word;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table th {
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table .sl-right {
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-inline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-list-status {
|
|
min-height: 1.25rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
#ShareLinksConfigPage .sl-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="ShareLinksConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
|
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
|
<div data-role="content">
|
|
<div class="content-primary">
|
|
<form id="ShareLinksConfigForm">
|
|
<div class="sl-section">
|
|
<div class="sectionTitleContainer flex align-items-center">
|
|
<h2 class="sectionTitle">ShareLinks</h2>
|
|
</div>
|
|
<p class="fieldDescription">
|
|
Creates short-lived guest links for movies and episodes, then keeps the active records visible for revocation.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<h3 class="sectionTitle">General</h3>
|
|
<div class="sl-grid">
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="Enabled" />
|
|
<span>Enable ShareLinks</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="OneUseDefault" />
|
|
<span>New links are single use by default</span>
|
|
</label>
|
|
<div class="fieldDescription checkboxFieldDescription">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.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="MaxConcurrentViewers" label="Maximum viewers per multi-use link" min="0" max="500" />
|
|
<div class="fieldDescription">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.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="DefaultExpiryHours" label="Default expiry (hours)" min="1" max="8760" />
|
|
<div class="fieldDescription">Used by the menu action when the admin accepts the default.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="MaxExpiryHours" label="Maximum expiry (hours)" min="1" max="8760" />
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="text" id="PublicBaseUrlOverride" label="Public base URL override" />
|
|
<div class="fieldDescription">Leave empty to derive the public URL from the current request.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="text" id="GuestUsernamePrefix" label="Guest username prefix" />
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer" style="grid-column: 1 / -1;">
|
|
<input is="emby-input" type="text" id="GuestHiddenSelectors" label="Cosmetic: hide elements from guests (CSS, comma-separated)" />
|
|
<div class="fieldDescription">Tidies the guest's view only. This runs in the browser and blocks nothing, so do not rely on it to keep a guest out of anything. Use the plugin access section below for that.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="CleanupIntervalMinutes" label="Cleanup interval (minutes)" min="5" max="10080" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<h3 class="sectionTitle">Playback policy</h3>
|
|
<div class="sl-grid">
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="AllowTranscoding" />
|
|
<span>Allow transcoding</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="AllowRemuxing" />
|
|
<span>Allow remuxing</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="GuestModeLockdownEnabled" />
|
|
<span>Guest lockdown</span>
|
|
</label>
|
|
<div class="fieldDescription">Hides primary navigation for guest sessions in the web client. This is UX only, not the security boundary.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<h3 class="sectionTitle">Plugin access for guests</h3>
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="GuestPluginApiGuardEnabled" />
|
|
<span>Block other plugins for guests</span>
|
|
</label>
|
|
<div class="fieldDescription">Refuses guest accounts on other plugins' API endpoints, on the server. A guest holds a real Jellyfin token, so without this any installed plugin answers them directly, whatever the web client shows. Jellyfin's own API stays available: the share tag already limits it to the shared title, and playback needs it.</div>
|
|
</div>
|
|
<div class="sl-muted" style="margin-top:0.75rem;">Tick a plugin to let guests reach it anyway. Leave everything unticked unless a plugin needs to serve guests during playback, such as an intro skipper.</div>
|
|
<div id="GuestAllowedPlugins" style="margin-top:0.5rem;">
|
|
<div class="sl-muted">Loading plugins…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<div class="sl-inline">
|
|
<h3 class="sectionTitle">Share links</h3>
|
|
<button is="emby-button" type="button" id="RefreshLinks" class="raised">
|
|
<span>Refresh</span>
|
|
</button>
|
|
<button is="emby-button" type="button" id="CleanupLinks" class="raised">
|
|
<span>Clean up finished links</span>
|
|
</button>
|
|
<span id="LinksStatus" class="sl-muted sl-list-status">Loading…</span>
|
|
</div>
|
|
<div style="overflow-x:auto; margin-top:0.5rem;">
|
|
<table class="sl-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 9rem;">Status</th>
|
|
<th>Item</th>
|
|
<th style="width: 14rem;">Link</th>
|
|
<th style="width: 11rem;">Guest</th>
|
|
<th style="width: 11rem;">Expires</th>
|
|
<th style="width: 9rem;" class="sl-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="LinksBody">
|
|
<tr>
|
|
<td colspan="6" class="sl-muted">No links loaded yet.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<button is="emby-button" type="submit" class="raised button-submit block">
|
|
<span>Save</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/javascript">
|
|
(function () {
|
|
var ShareLinksPluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
|
|
var page;
|
|
|
|
function loadConfig() {
|
|
Dashboard.showLoadingMsg();
|
|
return ApiClient.getPluginConfiguration(ShareLinksPluginId).then(function (cfg) {
|
|
page.querySelector('#Enabled').checked = cfg.Enabled !== false;
|
|
page.querySelector('#DefaultExpiryHours').value = cfg.DefaultExpiryHours || 24;
|
|
page.querySelector('#MaxExpiryHours').value = cfg.MaxExpiryHours || 720;
|
|
page.querySelector('#PublicBaseUrlOverride').value = cfg.PublicBaseUrlOverride || '';
|
|
page.querySelector('#GuestUsernamePrefix').value = cfg.GuestUsernamePrefix || 'share-';
|
|
page.querySelector('#GuestHiddenSelectors').value = cfg.GuestHiddenSelectors != null ? cfg.GuestHiddenSelectors : '';
|
|
page.querySelector('#AllowTranscoding').checked = cfg.AllowTranscoding !== false;
|
|
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;
|
|
page.querySelector('#GuestPluginApiGuardEnabled').checked = cfg.GuestPluginApiGuardEnabled !== false;
|
|
}).finally(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
}
|
|
|
|
// The ticked state comes from the server rather than from the config we
|
|
// just loaded, so a plugin that has since been uninstalled drops out of
|
|
// the list instead of lingering as a stale checkbox.
|
|
function loadPlugins() {
|
|
var host = page.querySelector('#GuestAllowedPlugins');
|
|
return ApiClient.ajax({
|
|
type: 'GET',
|
|
url: ApiClient.getUrl('ShareLinks/Admin/Plugins'),
|
|
dataType: 'json'
|
|
}).then(function (list) {
|
|
var items = Array.isArray(list) ? list : [];
|
|
if (!items.length) {
|
|
host.innerHTML = '<div class="sl-muted">No other plugins installed.</div>';
|
|
return;
|
|
}
|
|
|
|
host.innerHTML = items.map(function (plugin) {
|
|
return '<div class="checkboxContainer">'
|
|
+ '<label>'
|
|
+ '<input is="emby-checkbox" type="checkbox" class="sl-plugin-allow" data-plugin-id="'
|
|
+ escapeHtml(plugin.Id) + '"' + (plugin.AllowedForGuests ? ' checked' : '') + ' />'
|
|
+ '<span>' + escapeHtml(plugin.Name) + '</span>'
|
|
+ '</label>'
|
|
+ '</div>';
|
|
}).join('');
|
|
}).catch(function () {
|
|
host.innerHTML = '<div class="sl-muted">Could not load the plugin list.</div>';
|
|
});
|
|
}
|
|
|
|
function collectAllowedPluginIds() {
|
|
// A failed plugin load leaves no checkboxes to read. Returning the saved
|
|
// config untouched in that case avoids silently clearing the exceptions.
|
|
var boxes = page.querySelectorAll('.sl-plugin-allow');
|
|
if (!boxes.length) {
|
|
return null;
|
|
}
|
|
|
|
return Array.prototype.filter.call(boxes, function (box) {
|
|
return box.checked;
|
|
}).map(function (box) {
|
|
return box.getAttribute('data-plugin-id');
|
|
});
|
|
}
|
|
|
|
function fmtDate(value) {
|
|
if (!value) { return 'n/a'; }
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderLinks(list) {
|
|
var body = page.querySelector('#LinksBody');
|
|
var items = Array.isArray(list) ? list.slice().sort(function (a, b) {
|
|
return new Date(b.CreatedAtUtc || b.CreatedAt || 0) - new Date(a.CreatedAtUtc || a.CreatedAt || 0);
|
|
}) : [];
|
|
|
|
if (!items.length) {
|
|
body.innerHTML = '<tr><td colspan="6" class="sl-muted">No active links.</td></tr>';
|
|
page.querySelector('#LinksStatus').textContent = 'No share links.';
|
|
return;
|
|
}
|
|
|
|
body.innerHTML = items.map(function (record) {
|
|
var status = record.Status || 'Unknown';
|
|
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
|
|
? '<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 [
|
|
'<tr>',
|
|
'<td>', escapeHtml(status), '</td>',
|
|
'<td>',
|
|
'<div><strong>', itemName, '</strong></div>',
|
|
'</td>',
|
|
'<td>', linkCell, '</td>',
|
|
'<td>', guestName, '</td>',
|
|
'<td>', escapeHtml(expires), '</td>',
|
|
'<td class="sl-right">',
|
|
'<button is="emby-button" type="button" class="raised" data-id="', escapeHtml(record.Id), '">',
|
|
'<span>Revoke</span>',
|
|
'</button>',
|
|
'</td>',
|
|
'</tr>'
|
|
].join('');
|
|
}).join('');
|
|
|
|
Array.from(body.querySelectorAll('button[data-id]')).forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
revokeLink(button.getAttribute('data-id'));
|
|
});
|
|
});
|
|
|
|
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.';
|
|
}
|
|
|
|
function loadLinks() {
|
|
var status = page.querySelector('#LinksStatus');
|
|
status.textContent = 'Loading share links…';
|
|
return ApiClient.ajax({
|
|
type: 'GET',
|
|
url: ApiClient.getUrl('ShareLinks/Admin/List'),
|
|
dataType: 'json'
|
|
}).then(function (list) {
|
|
renderLinks(list || []);
|
|
}).catch(function (error) {
|
|
status.textContent = 'Could not load share links.';
|
|
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) {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
var button = page.querySelector('button[data-id="' + id + '"]');
|
|
if (button && button.getAttribute('data-confirm') !== '1') {
|
|
button.setAttribute('data-confirm', '1');
|
|
button.querySelector('span').textContent = 'Confirm revoke';
|
|
window.setTimeout(function () {
|
|
if (button.isConnected && button.getAttribute('data-confirm') === '1') {
|
|
button.removeAttribute('data-confirm');
|
|
button.querySelector('span').textContent = 'Revoke';
|
|
}
|
|
}, 3500);
|
|
return;
|
|
}
|
|
|
|
Dashboard.showLoadingMsg();
|
|
ApiClient.ajax({
|
|
type: 'POST',
|
|
url: ApiClient.getUrl('ShareLinks/Admin/Revoke/' + id),
|
|
dataType: 'json'
|
|
}).then(function () {
|
|
return loadLinks();
|
|
}).finally(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
}
|
|
|
|
function save(e) {
|
|
e.preventDefault();
|
|
Dashboard.showLoadingMsg();
|
|
ApiClient.getPluginConfiguration(ShareLinksPluginId).then(function (cfg) {
|
|
cfg.Enabled = page.querySelector('#Enabled').checked;
|
|
cfg.DefaultExpiryHours = parseInt(page.querySelector('#DefaultExpiryHours').value, 10) || 24;
|
|
cfg.MaxExpiryHours = parseInt(page.querySelector('#MaxExpiryHours').value, 10) || 720;
|
|
cfg.PublicBaseUrlOverride = page.querySelector('#PublicBaseUrlOverride').value.trim();
|
|
cfg.GuestUsernamePrefix = page.querySelector('#GuestUsernamePrefix').value.trim() || 'share-';
|
|
cfg.GuestHiddenSelectors = page.querySelector('#GuestHiddenSelectors').value;
|
|
cfg.AllowTranscoding = page.querySelector('#AllowTranscoding').checked;
|
|
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;
|
|
cfg.GuestPluginApiGuardEnabled = page.querySelector('#GuestPluginApiGuardEnabled').checked;
|
|
|
|
var allowedPluginIds = collectAllowedPluginIds();
|
|
if (allowedPluginIds !== null) {
|
|
cfg.GuestAllowedPluginIds = allowedPluginIds;
|
|
}
|
|
|
|
ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) {
|
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
return loadLinks();
|
|
}).finally(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
});
|
|
return false;
|
|
}
|
|
|
|
document.querySelector('#ShareLinksConfigPage').addEventListener('pageshow', function () {
|
|
page = this;
|
|
loadConfig().then(loadPlugins).then(loadLinks);
|
|
});
|
|
|
|
document.querySelector('#ShareLinksConfigForm').addEventListener('submit', save);
|
|
document.querySelector('#RefreshLinks').addEventListener('click', function () {
|
|
loadLinks();
|
|
});
|
|
|
|
document.querySelector('#CleanupLinks').addEventListener('click', function () {
|
|
var button = page.querySelector('#CleanupLinks');
|
|
var status = page.querySelector('#LinksStatus');
|
|
button.disabled = true;
|
|
status.textContent = 'Cleaning up…';
|
|
ApiClient.ajax({
|
|
type: 'POST',
|
|
url: ApiClient.getUrl('ShareLinks/Admin/Cleanup'),
|
|
dataType: 'json'
|
|
}).then(function (result) {
|
|
var removed = result && (result.removed !== undefined ? result.removed : result.Removed);
|
|
removed = parseInt(removed, 10) || 0;
|
|
return loadLinks().then(function () {
|
|
page.querySelector('#LinksStatus').textContent = removed === 1
|
|
? '1 finished link removed.'
|
|
: removed + ' finished links removed.';
|
|
});
|
|
}).catch(function () {
|
|
status.textContent = 'Cleanup failed.';
|
|
}).finally(function () {
|
|
button.disabled = false;
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|