From 1de627e90153e3ad3508bfdb7dc7ca88b7ed4721 Mon Sep 17 00:00:00 2001 From: mattsigal Date: Mon, 20 Jul 2026 15:35:54 -0700 Subject: [PATCH 1/2] Bypass Radarr and Sonarr settings gating for non-admin accounts Modify SeerrProxyController to check if a non-admin user requests settings/radarr or settings/sonarr. If so, intercept the request and execute it using a stored admin session so they can fetch host and API key details required for upcoming calendar rows. --- Jellyfin/backend/Api/SeerrProxyController.cs | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Jellyfin/backend/Api/SeerrProxyController.cs b/Jellyfin/backend/Api/SeerrProxyController.cs index dfd3979..0ae349e 100644 --- a/Jellyfin/backend/Api/SeerrProxyController.cs +++ b/Jellyfin/backend/Api/SeerrProxyController.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Net.Mime; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -252,6 +253,24 @@ private async Task ProxyApiRequest(HttpMethod method, string path return Unauthorized(new { error = "User not authenticated" }); } + var targetUserId = userId.Value; + var requestPath = path?.Trim().ToLowerInvariant() ?? string.Empty; + if (method == HttpMethod.Get && (requestPath == "settings/radarr" || requestPath == "settings/sonarr")) + { + var currentSession = await _sessionService.GetSessionAsync(userId.Value); + bool isCurrentAdmin = currentSession != null && (currentSession.SeerrUserId == OwnerSeerrUserId || (currentSession.Permissions & AdminBit) != 0); + + if (!isCurrentAdmin) + { + var adminSession = _sessionService.EnumerateSessions() + .FirstOrDefault(s => s.SeerrUserId == OwnerSeerrUserId || (s.Permissions & AdminBit) != 0); + if (adminSession != null) + { + targetUserId = adminSession.JellyfinUserId; + } + } + } + byte[]? body = null; string? contentType = null; @@ -264,9 +283,9 @@ private async Task ProxyApiRequest(HttpMethod method, string path } var result = await _sessionService.ProxyRequestAsync( - userId.Value, + targetUserId, method, - path, + path ?? "", Request.QueryString.Value, body, contentType); From 98ed2afeb48f02211ccbb12af46c483626373149 Mon Sep 17 00:00:00 2001 From: RadicalMuffinMan <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:44:40 -0400 Subject: [PATCH 2/2] Added a server-side Radarr and Sonarr calendar proxy --- .../Emby.Plugins.Moonfin/Api/SeerrRequests.cs | 18 ++++ Emby/Emby.Plugins.Moonfin/Api/SeerrService.cs | 21 +++++ .../Services/SeerrSessionService.cs | 94 +++++++++++++++++++ Jellyfin/backend/Api/SeerrProxyController.cs | 67 +++++++++---- .../backend/Services/SeerrSessionService.cs | 94 +++++++++++++++++++ 5 files changed, 276 insertions(+), 18 deletions(-) diff --git a/Emby/Emby.Plugins.Moonfin/Api/SeerrRequests.cs b/Emby/Emby.Plugins.Moonfin/Api/SeerrRequests.cs index a63a4ec..7ab54f2 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/SeerrRequests.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/SeerrRequests.cs @@ -28,6 +28,24 @@ public class ValidateSeerrSessionRequest : IReturn { } [Authenticated] public class SeerrLogoutRequest : IReturn { } + [Route("/Moonfin/Seerr/Radarr/Calendar", "GET")] +[Route("/Moonfin/Jellyseerr/Radarr/Calendar", "GET")] + [Authenticated] + public class GetRadarrCalendarRequest : IReturn + { + public string? Start { get; set; } + public string? End { get; set; } + } + + [Route("/Moonfin/Seerr/Sonarr/Calendar", "GET")] +[Route("/Moonfin/Jellyseerr/Sonarr/Calendar", "GET")] + [Authenticated] + public class GetSonarrCalendarRequest : IReturn + { + public string? Start { get; set; } + public string? End { get; set; } + } + [Route("/Moonfin/Seerr/Api/{Path*}", "GET")] [Route("/Moonfin/Jellyseerr/Api/{Path*}", "GET")] [Authenticated] diff --git a/Emby/Emby.Plugins.Moonfin/Api/SeerrService.cs b/Emby/Emby.Plugins.Moonfin/Api/SeerrService.cs index 4b76250..66ebf07 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/SeerrService.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/SeerrService.cs @@ -109,6 +109,27 @@ public SeerrService(IApplicationHost appHost) public async Task Put(SeerrProxyPutRequest request) => await ProxyApiRequest(HttpMethod.Put, request.Path, request.RequestStream).ConfigureAwait(false); public async Task Delete(SeerrProxyDeleteRequest request) => await ProxyApiRequest(HttpMethod.Delete, request.Path, null).ConfigureAwait(false); + public Task Get(GetRadarrCalendarRequest request) => GetArrCalendar("radarr", request.Start, request.End); + public Task Get(GetSonarrCalendarRequest request) => GetArrCalendar("sonarr", request.Start, request.End); + + private async Task GetArrCalendar(string service, string? start, string? end) + { + var config = Plugin.Instance?.Configuration; + if (config?.SeerrEnabled != true || string.IsNullOrEmpty(config.GetEffectiveSeerrUrl())) + { Request.Response.StatusCode = 503; return new { error = "Seerr integration is not enabled" }; } + + if (AuthHelpers.GetCurrentUserId(Request, _authContext) == null) + { Request.Response.StatusCode = 401; return new { error = "User not authenticated" }; } + + var result = await Session.GetArrCalendarAsync(service, start, end).ConfigureAwait(false); + + Request.Response.StatusCode = result.StatusCode; + if (result.Body == null) return null; + + var responseContentType = string.IsNullOrWhiteSpace(result.ContentType) ? "application/octet-stream" : result.ContentType; + return ResultFactory.GetResult(Request, new MemoryStream(result.Body), responseContentType, null); + } + private async Task ProxyApiRequest(HttpMethod method, string? path, System.IO.Stream? bodyStream) { var config = Plugin.Instance?.Configuration; diff --git a/Emby/Emby.Plugins.Moonfin/Services/SeerrSessionService.cs b/Emby/Emby.Plugins.Moonfin/Services/SeerrSessionService.cs index e620dbc..2d5560c 100644 --- a/Emby/Emby.Plugins.Moonfin/Services/SeerrSessionService.cs +++ b/Emby/Emby.Plugins.Moonfin/Services/SeerrSessionService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -520,6 +521,99 @@ private static SeerrProxyResponse ErrorResponse(int status, string error, string } /// Enumerates all stored Seerr sessions. + // Seerr owner is user 1, and permissions bit 2 is the admin flag. + private const int OwnerSeerrUserId = 1; + private const int AdminBit = 2; + + /// + /// Fetches the Radarr or Sonarr upcoming calendar server-side so the API key never reaches the + /// client. The arr connection is read from an admin Seerr session and the arr is called directly, + /// which also lets remote clients see the calendar when the arr host is only on the local network. + /// + public async Task GetArrCalendarAsync(string service, string? start, string? end) + { + var isSonarr = string.Equals(service, "sonarr", StringComparison.OrdinalIgnoreCase); + var settingsPath = isSonarr ? "settings/sonarr" : "settings/radarr"; + + var adminSession = EnumerateSessions().FirstOrDefault( + s => s.SeerrUserId == OwnerSeerrUserId || (s.Permissions & AdminBit) != 0); + if (adminSession == null) + { + return CalendarError(503, "No admin Seerr session is available to read the arr settings"); + } + + var settings = await ProxyRequestAsync(adminSession.JellyfinUserId, HttpMethod.Get, settingsPath).ConfigureAwait(false); + if (settings.Body == null || settings.StatusCode != 200) + { + return settings; + } + + JsonElement server; + try + { + using var doc = JsonDocument.Parse(settings.Body); + if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) + { + return CalendarError(404, "No " + service + " server is configured in Seerr"); + } + server = doc.RootElement[0].Clone(); + } + catch (JsonException) + { + return CalendarError(502, "Could not read the arr settings from Seerr"); + } + + var hostname = server.TryGetProperty("hostname", out var h) ? h.GetString() ?? string.Empty : string.Empty; + if (hostname.Length == 0) + { + return CalendarError(404, "No " + service + " host is configured in Seerr"); + } + + var port = server.TryGetProperty("port", out var p) && p.TryGetInt32(out var portValue) + ? portValue + : (isSonarr ? 8989 : 7878); + var useSsl = server.TryGetProperty("useSsl", out var ssl) && ssl.ValueKind == JsonValueKind.True; + var baseUrl = (server.TryGetProperty("baseUrl", out var b) ? b.GetString() : null)?.TrimEnd('/') ?? string.Empty; + var apiKey = server.TryGetProperty("apiKey", out var k) ? k.GetString() ?? string.Empty : string.Empty; + + var protocol = useSsl ? "https" : "http"; + var startDate = string.IsNullOrWhiteSpace(start) ? DateTime.UtcNow.ToString("yyyy-MM-dd") : start; + var endDate = string.IsNullOrWhiteSpace(end) ? DateTime.UtcNow.AddDays(90).ToString("yyyy-MM-dd") : end; + var query = "start=" + Uri.EscapeDataString(startDate) + "&end=" + Uri.EscapeDataString(endDate); + if (isSonarr) + { + query += "&includeSeries=true"; + } + var url = protocol + "://" + hostname + ":" + port + baseUrl + "/api/v3/calendar?" + query; + + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Api-Key", apiKey); + using var arrResponse = await client.SendAsync(request).ConfigureAwait(false); + var arrBody = await arrResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + return new SeerrProxyResponse + { + StatusCode = (int)arrResponse.StatusCode, + Body = arrBody, + ContentType = arrResponse.Content.Headers.ContentType?.ToString() ?? "application/json" + }; + } + catch (Exception ex) + { + _logger.Error("Failed to fetch " + service + " calendar: " + ex.Message); + return CalendarError(502, "Could not reach the " + service + " server"); + } + } + + private static SeerrProxyResponse CalendarError(int statusCode, string message) => new SeerrProxyResponse + { + StatusCode = statusCode, + Body = JsonSerializer.SerializeToUtf8Bytes(new { error = message }), + ContentType = "application/json" + }; + public IEnumerable EnumerateSessions() { if (!Directory.Exists(_sessionsPath)) yield break; diff --git a/Jellyfin/backend/Api/SeerrProxyController.cs b/Jellyfin/backend/Api/SeerrProxyController.cs index 0ae349e..43b0d29 100644 --- a/Jellyfin/backend/Api/SeerrProxyController.cs +++ b/Jellyfin/backend/Api/SeerrProxyController.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Net.Mime; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -237,7 +236,25 @@ public async Task ProxyDelete(string path) return await ProxyApiRequest(HttpMethod.Delete, path); } - private async Task ProxyApiRequest(HttpMethod method, string path) + /// + /// The upcoming Radarr calendar, fetched server-side so the API key stays on the server and a + /// remote client can still see the calendar when Radarr is only reachable on the local network. + /// + [HttpGet("Radarr/Calendar")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public Task GetRadarrCalendar() => GetArrCalendar("radarr"); + + /// + /// The upcoming Sonarr calendar, fetched server-side so the API key stays on the server and a + /// remote client can still see the calendar when Sonarr is only reachable on the local network. + /// + [HttpGet("Sonarr/Calendar")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public Task GetSonarrCalendar() => GetArrCalendar("sonarr"); + + private async Task GetArrCalendar(string service) { var config = MoonfinPlugin.Instance?.Configuration; var seerrUrl = config?.GetEffectiveSeerrUrl(); @@ -253,22 +270,36 @@ private async Task ProxyApiRequest(HttpMethod method, string path return Unauthorized(new { error = "User not authenticated" }); } - var targetUserId = userId.Value; - var requestPath = path?.Trim().ToLowerInvariant() ?? string.Empty; - if (method == HttpMethod.Get && (requestPath == "settings/radarr" || requestPath == "settings/sonarr")) + var start = Request.Query["start"].ToString(); + var end = Request.Query["end"].ToString(); + var result = await _sessionService.GetArrCalendarAsync(service, start, end); + if (result.Body == null) { - var currentSession = await _sessionService.GetSessionAsync(userId.Value); - bool isCurrentAdmin = currentSession != null && (currentSession.SeerrUserId == OwnerSeerrUserId || (currentSession.Permissions & AdminBit) != 0); + return StatusCode(result.StatusCode); + } - if (!isCurrentAdmin) - { - var adminSession = _sessionService.EnumerateSessions() - .FirstOrDefault(s => s.SeerrUserId == OwnerSeerrUserId || (s.Permissions & AdminBit) != 0); - if (adminSession != null) - { - targetUserId = adminSession.JellyfinUserId; - } - } + var responseContentType = string.IsNullOrWhiteSpace(result.ContentType) + ? MediaTypeNames.Application.Octet + : result.ContentType; + + Response.StatusCode = result.StatusCode; + return File(result.Body, responseContentType); + } + + private async Task ProxyApiRequest(HttpMethod method, string path) + { + var config = MoonfinPlugin.Instance?.Configuration; + var seerrUrl = config?.GetEffectiveSeerrUrl(); + if (config?.SeerrEnabled != true || string.IsNullOrEmpty(seerrUrl)) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, + new { error = "Seerr integration is not enabled" }); + } + + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(new { error = "User not authenticated" }); } byte[]? body = null; @@ -283,9 +314,9 @@ private async Task ProxyApiRequest(HttpMethod method, string path } var result = await _sessionService.ProxyRequestAsync( - targetUserId, + userId.Value, method, - path ?? "", + path, Request.QueryString.Value, body, contentType); diff --git a/Jellyfin/backend/Services/SeerrSessionService.cs b/Jellyfin/backend/Services/SeerrSessionService.cs index a9cbba4..38d2da3 100644 --- a/Jellyfin/backend/Services/SeerrSessionService.cs +++ b/Jellyfin/backend/Services/SeerrSessionService.cs @@ -671,6 +671,100 @@ public async Task ProxyRequestAsync( } } + // Seerr owner is user 1, and permissions bit 2 is the admin flag. + private const int OwnerSeerrUserId = 1; + private const int AdminBit = 2; + + /// + /// Fetches the Radarr or Sonarr upcoming calendar server-side so the API key never reaches the + /// client. The arr connection is read from an admin Seerr session and the arr is called directly, + /// which also lets remote clients see the calendar when the arr host is only on the local network. + /// + public async Task GetArrCalendarAsync(string service, string? start, string? end) + { + var isSonarr = string.Equals(service, "sonarr", StringComparison.OrdinalIgnoreCase); + var settingsPath = isSonarr ? "settings/sonarr" : "settings/radarr"; + + var adminSession = EnumerateSessions().FirstOrDefault( + s => s.SeerrUserId == OwnerSeerrUserId || (s.Permissions & AdminBit) != 0); + if (adminSession == null) + { + return CalendarError(503, "No admin Seerr session is available to read the arr settings"); + } + + var settings = await ProxyRequestAsync(adminSession.JellyfinUserId, HttpMethod.Get, settingsPath); + if (settings.Body == null || settings.StatusCode != 200) + { + return settings; + } + + JsonElement server; + try + { + using var doc = JsonDocument.Parse(settings.Body); + if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) + { + return CalendarError(404, $"No {service} server is configured in Seerr"); + } + server = doc.RootElement[0].Clone(); + } + catch (JsonException) + { + return CalendarError(502, "Could not read the arr settings from Seerr"); + } + + var hostname = server.TryGetProperty("hostname", out var h) ? h.GetString() ?? string.Empty : string.Empty; + if (hostname.Length == 0) + { + return CalendarError(404, $"No {service} host is configured in Seerr"); + } + + var port = server.TryGetProperty("port", out var p) && p.TryGetInt32(out var portValue) + ? portValue + : (isSonarr ? 8989 : 7878); + var useSsl = server.TryGetProperty("useSsl", out var ssl) && ssl.ValueKind == JsonValueKind.True; + var baseUrl = (server.TryGetProperty("baseUrl", out var b) ? b.GetString() : null)?.TrimEnd('/') ?? string.Empty; + var apiKey = server.TryGetProperty("apiKey", out var k) ? k.GetString() ?? string.Empty : string.Empty; + + var protocol = useSsl ? "https" : "http"; + var startDate = string.IsNullOrWhiteSpace(start) ? DateTime.UtcNow.ToString("yyyy-MM-dd") : start; + var endDate = string.IsNullOrWhiteSpace(end) ? DateTime.UtcNow.AddDays(90).ToString("yyyy-MM-dd") : end; + var query = $"start={Uri.EscapeDataString(startDate)}&end={Uri.EscapeDataString(endDate)}"; + if (isSonarr) + { + query += "&includeSeries=true"; + } + var url = $"{protocol}://{hostname}:{port}{baseUrl}/api/v3/calendar?{query}"; + + try + { + using var client = _httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(15); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Api-Key", apiKey); + using var arrResponse = await client.SendAsync(request); + var arrBody = await arrResponse.Content.ReadAsByteArrayAsync(); + return new SeerrProxyResponse + { + StatusCode = (int)arrResponse.StatusCode, + Body = arrBody, + ContentType = arrResponse.Content.Headers.ContentType?.ToString() ?? "application/json" + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch {Service} calendar", service); + return CalendarError(502, $"Could not reach the {service} server"); + } + } + + private static SeerrProxyResponse CalendarError(int statusCode, string message) => new SeerrProxyResponse + { + StatusCode = statusCode, + Body = JsonSerializer.SerializeToUtf8Bytes(new { error = message }), + ContentType = "application/json" + }; + /// /// Enumerates all stored Seerr sessions. ///