From 02a6ec254887c47fc1e224aeb3535438603265d5 Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 13 Jan 2025 21:14:31 +0100 Subject: [PATCH] Add API Key auth --- UmlautAdaptarr/Controllers/CapsController.cs | 15 +++- .../Controllers/SearchController.cs | 70 +++++++++++++++---- UmlautAdaptarr/Program.cs | 12 ++-- UmlautAdaptarr/Properties/launchSettings.json | 2 +- UmlautAdaptarr/Providers/SonarrClient.cs | 27 ++++++- UmlautAdaptarr/Services/HttpProxyService.cs | 29 +++++++- UmlautAdaptarr/Services/TitleApiService.cs | 65 ++++++++++++++++- UmlautAdaptarr/UmlautAdaptarr.csproj | 10 +-- 8 files changed, 198 insertions(+), 32 deletions(-) diff --git a/UmlautAdaptarr/Controllers/CapsController.cs b/UmlautAdaptarr/Controllers/CapsController.cs index e8df172..1980670 100644 --- a/UmlautAdaptarr/Controllers/CapsController.cs +++ b/UmlautAdaptarr/Controllers/CapsController.cs @@ -1,18 +1,29 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using System.Text; using System.Xml.Linq; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Controllers { - public class CapsController(ProxyRequestService proxyRequestService) : ControllerBase + public class CapsController(ProxyRequestService proxyRequestService, IOptions options, ILogger logger) : ControllerBase { private readonly ProxyRequestService _proxyRequestService = proxyRequestService; + private readonly GlobalOptions _options = options.Value; + private readonly ILogger _logger = logger; + [HttpGet] - public async Task Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey) + public async Task Caps([FromRoute] string apiKey, [FromRoute] string domain, [FromQuery] string? apikey) { + if (_options.ApiKey != null && !apiKey.Equals(apiKey)) + { + _logger.LogWarning("Invalid or missing API key for request."); + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + if (!domain.StartsWith("localhost") && !UrlUtilities.IsValidDomain(domain)) { return NotFound($"{domain} is not a valid URL."); diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 9961616..637a64c 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -1,24 +1,31 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using System.Text; using UmlautAdaptarr.Models; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Providers; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Controllers { - public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, ILogger logger) : ControllerBase + public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, IOptions options, ILogger logger) : ControllerBase { // TODO evaluate if this should be set to true by default private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true; private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false; - protected async Task BaseSearch(string options, + protected async Task BaseSearch(string apiKey, string domain, IDictionary queryParameters, SearchItem? searchItem = null) { try { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + if (!UrlUtilities.IsValidDomain(domain)) { return NotFound($"{domain} is not a valid URL."); @@ -159,30 +166,50 @@ namespace UmlautAdaptarr.Controllers return aggregatedResult; } + + internal bool AssureApiKey(string apiKey) + { + if (options.Value.ApiKey != null && !apiKey.Equals(options.Value.ApiKey)) + { + logger.LogWarning("Invalid or missing API key for request."); + return false; + } + return true; + } } public class SearchController(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, SearchItemLookupService searchItemLookupService, - ILogger logger) : SearchControllerBase(proxyRequestService, titleMatchingService, logger) + IOptions options, + ILogger logger) : SearchControllerBase(proxyRequestService, titleMatchingService, options, logger) { public readonly string[] LIDARR_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"]; public readonly string[] READARR_CATEGORY_IDS = ["3030", "3130", "7000", "7010", "7020", "7030", "7100", "7110", "7120", "7130"]; [HttpGet] - public async Task MovieSearch([FromRoute] string options, [FromRoute] string domain) + public async Task MovieSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } [HttpGet] - public async Task GenericSearch([FromRoute] string options, [FromRoute] string domain) + public async Task GenericSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } - var queryParameters = HttpContext.Request.Query.ToDictionary( + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); @@ -208,21 +235,31 @@ namespace UmlautAdaptarr.Controllers } } - return await BaseSearch(options, domain, queryParameters, searchItem); + return await BaseSearch(apiKey, domain, queryParameters, searchItem); } [HttpGet] - public async Task BookSearch([FromRoute] string options, [FromRoute] string domain) + public async Task BookSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } [HttpGet] - public async Task TVSearch([FromRoute] string options, [FromRoute] string domain) + public async Task TVSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); @@ -239,16 +276,21 @@ namespace UmlautAdaptarr.Controllers searchItem = await searchItemLookupService.GetOrFetchSearchItemByTitle(mediaType, title); } - return await BaseSearch(options, domain, queryParameters, searchItem); + return await BaseSearch(apiKey, domain, queryParameters, searchItem); } [HttpGet] - public async Task MusicSearch([FromRoute] string options, [FromRoute] string domain) + public async Task MusicSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } } } diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 1d2b3bc..dc871a0 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -69,32 +69,32 @@ internal class Program app.UseAuthorization(); app.MapControllerRoute("caps", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Caps", action = "Caps" }, new { t = new TRouteConstraint("caps") }); app.MapControllerRoute("movie-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "MovieSearch" }, new { t = new TRouteConstraint("movie") }); app.MapControllerRoute("tv-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "TVSearch" }, new { t = new TRouteConstraint("tvsearch") }); app.MapControllerRoute("music-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "MusicSearch" }, new { t = new TRouteConstraint("music") }); app.MapControllerRoute("book-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "BookSearch" }, new { t = new TRouteConstraint("book") }); app.MapControllerRoute("generic-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "GenericSearch" }, new { t = new TRouteConstraint("search") }); app.Run(); diff --git a/UmlautAdaptarr/Properties/launchSettings.json b/UmlautAdaptarr/Properties/launchSettings.json index 1b1f811..9677901 100644 --- a/UmlautAdaptarr/Properties/launchSettings.json +++ b/UmlautAdaptarr/Properties/launchSettings.json @@ -4,7 +4,7 @@ "commandName": "Project", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - "Kestrel__Endpoints__Http__Url": "http://[::]:8080" + "SETTINGS__ApiKey": "test123" }, "_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100", "dotnetRunMessages": true, diff --git a/UmlautAdaptarr/Providers/SonarrClient.cs b/UmlautAdaptarr/Providers/SonarrClient.cs index 9d9b0ad..af45b38 100644 --- a/UmlautAdaptarr/Providers/SonarrClient.cs +++ b/UmlautAdaptarr/Providers/SonarrClient.cs @@ -48,6 +48,21 @@ public class SonarrClient : ArrClientBase if (shows != null) { _logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr ({InstanceName})."); + // Bulk request (germanTitle, aliases) for all shows + var tvdbIds = new List(); + foreach (var show in shows) + { + if ((string)show.tvdbId is not string tvdbId) + { + continue; + } + tvdbIds.Add(tvdbId); + } + + var bulkTitleData = await _titleService.FetchGermanTitlesAndAliasesByExternalIdBulkAsync(tvdbIds); + string? germanTitle; + string[]? aliases; + foreach (var show in shows) { var tvdbId = (string)show.tvdbId; @@ -57,8 +72,16 @@ public class SonarrClient : ArrClientBase continue; } - var (germanTitle, aliases) = - await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); + if (bulkTitleData.TryGetValue(tvdbId, out var titleData)) + { + (germanTitle, aliases) = titleData; + } + else + { + (germanTitle, aliases) = + await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); + } + var searchItem = new SearchItem ( (int)show.id, diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 884d6a1..7253905 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -42,6 +42,19 @@ namespace UmlautAdaptarr.Services var bytesRead = await clientStream.ReadAsync(buffer); var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead); + if (_options.ApiKey != null) + { + var headers = ParseHeaders(buffer, bytesRead); + if (!headers.TryGetValue("Proxy-Authorization", out var proxyAuthorizationHeader) || + !ValidateApiKey(proxyAuthorizationHeader)) + { + _logger.LogWarning("Unauthorized access attempt."); + await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"Proxy\"\r\n\r\n")); + clientSocket.Close(); + return; + } + } + if (requestString.StartsWith("CONNECT")) { // Handle HTTPS CONNECT request @@ -53,6 +66,18 @@ namespace UmlautAdaptarr.Services await HandleHttp(requestString, clientStream, clientSocket, buffer, bytesRead); } } + private bool ValidateApiKey(string proxyAuthorizationHeader) + { + // Expect the header to be in the format: "Basic " + if (proxyAuthorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + { + var encodedKey = proxyAuthorizationHeader["Basic ".Length..].Trim(); + var decodedKey = Encoding.ASCII.GetString(Convert.FromBase64String(encodedKey)); + var password = decodedKey.Split(':')[^1]; + return password == _options.ApiKey; + } + return false; + } private async Task HandleHttpsConnect(string requestString, NetworkStream clientStream, Socket clientSocket) { @@ -99,7 +124,9 @@ namespace UmlautAdaptarr.Services var url = _configuration["Kestrel:Endpoints:Http:Url"]; var port = new Uri(url).Port; - var modifiedUri = $"http://localhost:{port}/_/{uri.Host}{uri.PathAndQuery}"; + var apiKey = _options.ApiKey == null ? "_" : _options.ApiKey; + + var modifiedUri = $"http://localhost:{port}/{apiKey}/{uri.Host}{uri.PathAndQuery}"; using var client = _clientFactory.CreateClient(); var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri); httpRequestMessage.Headers.Add("User-Agent", userAgent); diff --git a/UmlautAdaptarr/Services/TitleApiService.cs b/UmlautAdaptarr/Services/TitleApiService.cs index 26733c2..0083f7a 100644 --- a/UmlautAdaptarr/Services/TitleApiService.cs +++ b/UmlautAdaptarr/Services/TitleApiService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Text; using UmlautAdaptarr.Options; using UmlautAdaptarr.Utilities; @@ -22,7 +23,7 @@ namespace UmlautAdaptarr.Services lastRequestTime = DateTime.Now; } - // TODO add cache, TODO add bulk request + // TODO add caching public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId) { try @@ -68,6 +69,68 @@ namespace UmlautAdaptarr.Services return (null, null); } + public async Task> FetchGermanTitlesAndAliasesByExternalIdBulkAsync(IEnumerable tvdbIds) + { + try + { + await EnsureMinimumDelayAsync(); + + var httpClient = clientFactory.CreateClient(); + var bulkApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?bulk=true"; + logger.LogInformation($"TitleApiService POST {UrlUtilities.RedactApiKey(bulkApiUrl)}"); + + // Prepare POST request payload + var payload = new { tvdbIds = tvdbIds.ToArray() }; + var jsonPayload = JsonConvert.SerializeObject(payload); + var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + // Send POST request + var response = await httpClient.PostAsync(bulkApiUrl, content); + if (!response.IsSuccessStatusCode) + { + logger.LogError($"Failed to fetch German titles via bulk API. Status Code: {response.StatusCode}"); + return []; + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var bulkApiResponseData = JsonConvert.DeserializeObject(responseContent); + + if (bulkApiResponseData == null || bulkApiResponseData.status != "success") + { + logger.LogError($"Parsing UmlautAdaptarr Bulk API response resulted in null or an error status."); + return []; + } + + // Process response data + var results = new Dictionary(); + foreach (var entry in bulkApiResponseData.data) + { + string tvdbId = entry.tvdbId; + string? germanTitle = entry.germanTitle; + + string[]? aliases = null; + if (entry.aliases != null) + { + JArray aliasesArray = JArray.FromObject(entry.aliases); + aliases = aliasesArray.Children() + .Select(alias => alias["name"].ToString()) + .ToArray(); + } + + results[tvdbId] = (germanTitle, aliases); + } + + logger.LogInformation($"Successfully fetched German titles for {results.Count} TVDB IDs via bulk API."); + + return results; + } + catch (Exception ex) + { + logger.LogError($"Error fetching German titles in bulk: {ex.Message}"); + return new Dictionary(); + } + } + public async Task<(string? germanTitle, string? externalId, string[]? aliases)> FetchGermanTitleAndExternalIdAndAliasesByTitle(string mediaType, string title) { try diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index 1969466..a60f613 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -9,13 +9,13 @@ - + - - + + - - + +