Add the client script while index.html is served
Writing the tag into index.html on disk fails on most fresh installs (linuxserver image, distro packages, Docker as a normal user) because the web files belong to root. A middleware now adds the tag to the response instead, so the ShareLink action and the guest lockdown work right after install. The on-disk edit stays as a best-effort extra, and the tag uses a relative src so it also works under a base URL. Bump to 1.0.8.0.
Cette révision appartient à :
@@ -6,9 +6,9 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
|
||||||
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
|
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
|
||||||
<Version>1.0.7.0</Version>
|
<Version>1.0.8.0</Version>
|
||||||
<AssemblyVersion>1.0.7.0</AssemblyVersion>
|
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.7.0</FileVersion>
|
<FileVersion>1.0.8.0</FileVersion>
|
||||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Jellyfin.Plugin.ShareLinks.Web;
|
|||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Controller.Authentication;
|
using MediaBrowser.Controller.Authentication;
|
||||||
using MediaBrowser.Controller.Plugins;
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.Configure<MvcOptions>(options => options.Filters.AddService<GuestPluginApiGuard>());
|
serviceCollection.Configure<MvcOptions>(options => options.Filters.AddService<GuestPluginApiGuard>());
|
||||||
|
|
||||||
serviceCollection.AddHostedService<WebInjectionHostedService>();
|
serviceCollection.AddHostedService<WebInjectionHostedService>();
|
||||||
|
serviceCollection.AddTransient<IStartupFilter, IndexHtmlScriptStartupFilter>();
|
||||||
serviceCollection.AddSingleton<ShareLinkStore>();
|
serviceCollection.AddSingleton<ShareLinkStore>();
|
||||||
serviceCollection.AddSingleton<ShareTokenService>();
|
serviceCollection.AddSingleton<ShareTokenService>();
|
||||||
serviceCollection.AddSingleton<ItemTagService>();
|
serviceCollection.AddSingleton<ItemTagService>();
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.ShareLinks.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the plugin's client script tag to the web client's index.html while
|
||||||
|
/// Jellyfin serves it, so nothing on disk has to be writable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IndexHtmlScriptMiddleware
|
||||||
|
{
|
||||||
|
private const string ScriptMarker = "ShareLinks/ClientScript";
|
||||||
|
private const string Snippet = "\n<!-- ShareLinks:begin -->\n<script src=\"../ShareLinks/ClientScript\" defer></script>\n<!-- ShareLinks:end -->\n";
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<IndexHtmlScriptMiddleware> _logger;
|
||||||
|
private int _logged;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="IndexHtmlScriptMiddleware"/> class.</summary>
|
||||||
|
public IndexHtmlScriptMiddleware(RequestDelegate next, ILogger<IndexHtmlScriptMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invokes the middleware for one request.</summary>
|
||||||
|
/// <param name="context">The current request's HTTP context.</param>
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var request = context.Request;
|
||||||
|
if (!HttpMethods.IsGet(request.Method) || !IsIndexHtmlRequest(request.Path.Value))
|
||||||
|
{
|
||||||
|
await _next(context).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the inner pipeline for the full, uncompressed file: a cached 304
|
||||||
|
// or a compressed body would leave nothing usable to rewrite.
|
||||||
|
request.Headers.Remove(HeaderNames.AcceptEncoding);
|
||||||
|
request.Headers.Remove(HeaderNames.IfNoneMatch);
|
||||||
|
request.Headers.Remove(HeaderNames.IfModifiedSince);
|
||||||
|
request.Headers.Remove(HeaderNames.IfRange);
|
||||||
|
request.Headers.Remove(HeaderNames.Range);
|
||||||
|
|
||||||
|
var originalBody = context.Response.Body;
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
context.Response.Body = buffer;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
context.Response.Body = originalBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = buffer.ToArray();
|
||||||
|
if (context.Response.StatusCode == StatusCodes.Status200OK
|
||||||
|
&& context.Response.ContentType is not null
|
||||||
|
&& context.Response.ContentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
bytes = AddScriptTag(context, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await originalBody.WriteAsync(bytes, context.RequestAborted).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsIndexHtmlRequest(string? path)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(path)
|
||||||
|
&& (path.EndsWith("/web/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.EndsWith("/web/index.html", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] AddScriptTag(HttpContext context, byte[] original)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var html = Encoding.UTF8.GetString(original);
|
||||||
|
if (html.Contains(ScriptMarker, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Already tagged, whether by this middleware, an older on-disk
|
||||||
|
// injection, or by hand. Leave it alone.
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyIndex = html.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
|
||||||
|
html = bodyIndex >= 0 ? html.Insert(bodyIndex, Snippet) : html + Snippet;
|
||||||
|
var updated = Encoding.UTF8.GetBytes(html);
|
||||||
|
|
||||||
|
context.Response.Headers.Remove(HeaderNames.ETag);
|
||||||
|
context.Response.Headers.Remove(HeaderNames.LastModified);
|
||||||
|
context.Response.ContentLength = updated.Length;
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _logged, 1) == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("ShareLinks: adding the client script to index.html as it is served.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "ShareLinks: could not add the client script to index.html.");
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.ShareLinks.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts <see cref="IndexHtmlScriptMiddleware"/> in front of Jellyfin's own pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IndexHtmlScriptStartupFilter : IStartupFilter
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||||
|
{
|
||||||
|
return app =>
|
||||||
|
{
|
||||||
|
app.UseMiddleware<IndexHtmlScriptMiddleware>();
|
||||||
|
next(app);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Jellyfin.Plugin.ShareLinks.Web;
|
namespace Jellyfin.Plugin.ShareLinks.Web;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Injects the ShareLinks client script into Jellyfin Web's index.html using
|
/// Best-effort extra: injects the ShareLinks client script into Jellyfin Web's
|
||||||
/// explicit markers so the edit can be applied and removed repeatedly without
|
/// index.html on disk at startup, using explicit markers so the edit can be
|
||||||
/// drift.
|
/// applied and removed repeatedly without drift. <see cref="IndexHtmlScriptMiddleware"/>
|
||||||
|
/// adds the same tag while Jellyfin serves the page, which is what makes the
|
||||||
|
/// guest flow work even when this cannot write to disk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WebInjectionHostedService : IHostedService
|
public sealed class WebInjectionHostedService : IHostedService
|
||||||
{
|
{
|
||||||
@@ -39,7 +41,7 @@ public sealed class WebInjectionHostedService : IHostedService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "ShareLinks: could not inject client script into web index.html.");
|
_logger.LogDebug(ex, "ShareLinks: could not write the script tag into index.html, it is added when the page is served instead.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -72,7 +74,7 @@ public sealed class WebInjectionHostedService : IHostedService
|
|||||||
|
|
||||||
TryBackup(path, path + ".sharelinks.bak");
|
TryBackup(path, path + ".sharelinks.bak");
|
||||||
|
|
||||||
var snippet = "\n" + Begin + "\n<script src=\"/ShareLinks/ClientScript\" defer></script>\n" + End + "\n";
|
var snippet = "\n" + Begin + "\n<script src=\"../ShareLinks/ClientScript\" defer></script>\n" + End + "\n";
|
||||||
var bodyIndex = html.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
|
var bodyIndex = html.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
|
||||||
html = bodyIndex >= 0 ? html.Insert(bodyIndex, snippet) : html + snippet;
|
html = bodyIndex >= 0 ? html.Insert(bodyIndex, snippet) : html + snippet;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
|
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
|
||||||
"name": "ShareLinks",
|
"name": "ShareLinks",
|
||||||
"version": "1.0.7.0",
|
"version": "1.0.8.0",
|
||||||
"targetAbi": "10.11.0.0",
|
"targetAbi": "10.11.0.0",
|
||||||
"framework": "net9.0",
|
"framework": "net9.0",
|
||||||
"owner": "Franciskid",
|
"owner": "Franciskid",
|
||||||
"overview": "Secure expiring guest-share links for Jellyfin items.",
|
"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.",
|
"description": "Adds secure, expiring share links for Jellyfin items with JSON-backed storage, token hashing, and cleanup scaffolding.",
|
||||||
"category": "General",
|
"category": "General",
|
||||||
"timestamp": "2026-09-18T14:00:00.0000000Z"
|
"timestamp": "2026-09-18T18:00:00.0000000Z"
|
||||||
}
|
}
|
||||||
|
|||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur