From 1c7acfe185a9ddfa9f2ad9e38e63be83ec826913 Mon Sep 17 00:00:00 2001 From: Francois CB <74976008+Franciskid@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:31:45 +0200 Subject: [PATCH] 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. --- .../Jellyfin.Plugin.ShareLinks.csproj | 6 +- .../PluginServiceRegistrator.cs | 2 + .../Web/IndexHtmlScriptMiddleware.cs | 120 ++++++++++++++++++ .../Web/IndexHtmlScriptStartupFilter.cs | 21 +++ .../Web/WebInjectionHostedService.cs | 12 +- Jellyfin.Plugin.ShareLinks/meta.json | 4 +- 6 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptMiddleware.cs create mode 100644 Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptStartupFilter.cs diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj index 99a717c..a494d26 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.7.0 - 1.0.7.0 - 1.0.7.0 + 1.0.8.0 + 1.0.8.0 + 1.0.8.0 true false disable diff --git a/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs index 5f28bc8..ee06374 100644 --- a/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs +++ b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs @@ -6,6 +6,7 @@ using Jellyfin.Plugin.ShareLinks.Web; using MediaBrowser.Controller; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Plugins; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +30,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator serviceCollection.Configure(options => options.Filters.AddService()); serviceCollection.AddHostedService(); + serviceCollection.AddTransient(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptMiddleware.cs b/Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptMiddleware.cs new file mode 100644 index 0000000..c98eef0 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptMiddleware.cs @@ -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; + +/// +/// 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. +/// +public sealed class IndexHtmlScriptMiddleware +{ + private const string ScriptMarker = "ShareLinks/ClientScript"; + private const string Snippet = "\n\n\n\n"; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private int _logged; + + /// Initializes a new instance of the class. + public IndexHtmlScriptMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + /// Invokes the middleware for one request. + /// The current request's HTTP context. + 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("", 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; + } + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptStartupFilter.cs b/Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptStartupFilter.cs new file mode 100644 index 0000000..8bbd8b7 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Web/IndexHtmlScriptStartupFilter.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace Jellyfin.Plugin.ShareLinks.Web; + +/// +/// Puts in front of Jellyfin's own pipeline. +/// +public sealed class IndexHtmlScriptStartupFilter : IStartupFilter +{ + /// + public Action Configure(Action next) + { + return app => + { + app.UseMiddleware(); + next(app); + }; + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Web/WebInjectionHostedService.cs b/Jellyfin.Plugin.ShareLinks/Web/WebInjectionHostedService.cs index 5de63a1..d21a75d 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/WebInjectionHostedService.cs +++ b/Jellyfin.Plugin.ShareLinks/Web/WebInjectionHostedService.cs @@ -9,9 +9,11 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.ShareLinks.Web; /// -/// Injects the ShareLinks client script into Jellyfin Web's index.html using -/// explicit markers so the edit can be applied and removed repeatedly without -/// drift. +/// Best-effort extra: injects the ShareLinks client script into Jellyfin Web's +/// index.html on disk at startup, using explicit markers so the edit can be +/// applied and removed repeatedly without drift. +/// adds the same tag while Jellyfin serves the page, which is what makes the +/// guest flow work even when this cannot write to disk. /// public sealed class WebInjectionHostedService : IHostedService { @@ -39,7 +41,7 @@ public sealed class WebInjectionHostedService : IHostedService } 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; @@ -72,7 +74,7 @@ public sealed class WebInjectionHostedService : IHostedService TryBackup(path, path + ".sharelinks.bak"); - var snippet = "\n" + Begin + "\n\n" + End + "\n"; + var snippet = "\n" + Begin + "\n\n" + End + "\n"; var bodyIndex = html.LastIndexOf("", StringComparison.OrdinalIgnoreCase); html = bodyIndex >= 0 ? html.Insert(bodyIndex, snippet) : html + snippet; diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json index eedc354..73411e5 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.7.0", + "version": "1.0.8.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-09-18T14:00:00.0000000Z" + "timestamp": "2026-09-18T18:00:00.0000000Z" }