diff --git a/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataHelperTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataHelperTests.cs new file mode 100644 index 00000000..c394350f --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataHelperTests.cs @@ -0,0 +1,78 @@ +using Cleanuparr.Shared.Helpers; +using Shouldly; + +namespace Cleanuparr.Api.Tests.Features.SensitiveData; + +public class SensitiveDataHelperTests +{ + [Fact] + public void IsPlaceholder_WithPlaceholder_ReturnsTrue() + { + SensitiveDataHelper.Placeholder.IsPlaceholder().ShouldBeTrue(); + } + + [Fact] + public void IsPlaceholder_WithAppriseStyledPlaceholder_ReturnsTrue() + { + $"discord://{SensitiveDataHelper.Placeholder}".IsPlaceholder().ShouldBeTrue(); + } + + [Fact] + public void IsPlaceholder_WithNull_ReturnsFalse() + { + ((string?)null).IsPlaceholder().ShouldBeFalse(); + } + + [Fact] + public void IsPlaceholder_WithEmptyString_ReturnsFalse() + { + "".IsPlaceholder().ShouldBeFalse(); + } + + [Fact] + public void IsPlaceholder_WithRealValue_ReturnsFalse() + { + "my-secret-api-key-123".IsPlaceholder().ShouldBeFalse(); + } + + [Theory] + [InlineData("discord://webhook_id/webhook_token", "discord://••••••••")] + [InlineData("slack://tokenA/tokenB/tokenC", "slack://••••••••")] + [InlineData("mailto://user:pass@gmail.com", "mailto://••••••••")] + [InlineData("json+http://user:pass@host/path", "json+http://••••••••")] + public void MaskAppriseUrls_SingleUrl_MasksCorrectly(string input, string expected) + { + SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(expected); + } + + [Fact] + public void MaskAppriseUrls_MultipleUrls_MasksAll() + { + var input = "discord://token1 slack://tokenA/tokenB"; + var result = SensitiveDataHelper.MaskAppriseUrls(input); + + result.ShouldContain("discord://••••••••"); + result.ShouldContain("slack://••••••••"); + result.ShouldNotContain("token1"); + result.ShouldNotContain("tokenA"); + } + + [Fact] + public void MaskAppriseUrls_MultilineUrls_MasksAll() + { + var input = "discord://token1\nslack://tokenA/tokenB"; + var result = SensitiveDataHelper.MaskAppriseUrls(input); + + result.ShouldContain("discord://••••••••"); + result.ShouldContain("slack://••••••••"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MaskAppriseUrls_EmptyOrNull_ReturnsAsIs(string? input) + { + SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(input); + } +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs new file mode 100644 index 00000000..2f155d9a --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs @@ -0,0 +1,317 @@ +using Cleanuparr.Api.Features.Arr.Contracts.Requests; +using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Helpers; +using Shouldly; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Api.Tests.Features.SensitiveData; + +/// +/// Tests that placeholder values are correctly handled on the input side: +/// - UPDATE operations preserve the existing DB value when a placeholder is sent +/// - CREATE operations reject placeholder values +/// - TEST operations reject placeholder values +/// +public class SensitiveDataInputTests +{ + private const string Placeholder = SensitiveDataHelper.Placeholder; + + #region ArrInstanceRequest — UPDATE + + [Fact] + public void ArrInstanceRequest_ApplyTo_WithPlaceholderApiKey_PreservesExistingValue() + { + var request = new ArrInstanceRequest + { + Name = "Updated Sonarr", + Url = "http://sonarr:8989", + ApiKey = Placeholder, + Version = 4, + }; + + var existingInstance = new ArrInstance + { + Name = "Sonarr", + Url = new Uri("http://sonarr:8989"), + ApiKey = "original-secret-key", + ArrConfigId = Guid.NewGuid(), + Version = 4, + }; + + request.ApplyTo(existingInstance); + + existingInstance.ApiKey.ShouldBe("original-secret-key"); + existingInstance.Name.ShouldBe("Updated Sonarr"); + } + + [Fact] + public void ArrInstanceRequest_ApplyTo_WithRealApiKey_UpdatesValue() + { + var request = new ArrInstanceRequest + { + Name = "Sonarr", + Url = "http://sonarr:8989", + ApiKey = "brand-new-api-key", + Version = 4, + }; + + var existingInstance = new ArrInstance + { + Name = "Sonarr", + Url = new Uri("http://sonarr:8989"), + ApiKey = "original-secret-key", + ArrConfigId = Guid.NewGuid(), + Version = 4, + }; + + request.ApplyTo(existingInstance); + + existingInstance.ApiKey.ShouldBe("brand-new-api-key"); + } + + #endregion + + #region ArrInstanceRequest — CREATE + + [Fact] + public void ArrInstanceRequest_ToEntity_WithPlaceholderApiKey_ThrowsValidationException() + { + var request = new ArrInstanceRequest + { + Name = "Sonarr", + Url = "http://sonarr:8989", + ApiKey = Placeholder, + Version = 4, + }; + + Should.Throw(() => request.ToEntity(Guid.NewGuid())); + } + + [Fact] + public void ArrInstanceRequest_ToEntity_WithRealApiKey_Succeeds() + { + var request = new ArrInstanceRequest + { + Name = "Sonarr", + Url = "http://sonarr:8989", + ApiKey = "real-api-key-123", + Version = 4, + }; + + var entity = request.ToEntity(Guid.NewGuid()); + entity.ApiKey.ShouldBe("real-api-key-123"); + } + + #endregion + + #region TestArrInstanceRequest — TEST + + [Fact] + public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndNoResolvedKey_ThrowsValidationException() + { + var request = new TestArrInstanceRequest + { + Url = "http://sonarr:8989", + ApiKey = Placeholder, + Version = 4, + }; + + Should.Throw(() => request.ToTestInstance()); + } + + [Fact] + public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndResolvedKey_UsesResolvedKey() + { + var request = new TestArrInstanceRequest + { + Url = "http://sonarr:8989", + ApiKey = Placeholder, + Version = 4, + InstanceId = Guid.NewGuid(), + }; + + var instance = request.ToTestInstance("resolved-api-key-from-db"); + instance.ApiKey.ShouldBe("resolved-api-key-from-db"); + } + + [Fact] + public void TestArrInstanceRequest_ToTestInstance_WithRealApiKey_Succeeds() + { + var request = new TestArrInstanceRequest + { + Url = "http://sonarr:8989", + ApiKey = "real-api-key", + Version = 4, + }; + + var instance = request.ToTestInstance(); + instance.ApiKey.ShouldBe("real-api-key"); + } + + #endregion + + #region UpdateDownloadClientRequest — UPDATE + + [Fact] + public void UpdateDownloadClientRequest_ApplyTo_WithPlaceholderPassword_PreservesExistingValue() + { + var request = new UpdateDownloadClientRequest + { + Name = "Updated qBit", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Username = "admin", + Password = Placeholder, + }; + + var existing = new DownloadClientConfig + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://qbit:8080"), + Username = "admin", + Password = "original-secret-password", + }; + + var result = request.ApplyTo(existing); + + result.Password.ShouldBe("original-secret-password"); + result.Name.ShouldBe("Updated qBit"); + } + + [Fact] + public void UpdateDownloadClientRequest_ApplyTo_WithRealPassword_UpdatesValue() + { + var request = new UpdateDownloadClientRequest + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Username = "admin", + Password = "new-password-123", + }; + + var existing = new DownloadClientConfig + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://qbit:8080"), + Username = "admin", + Password = "original-secret-password", + }; + + var result = request.ApplyTo(existing); + + result.Password.ShouldBe("new-password-123"); + } + + #endregion + + #region CreateDownloadClientRequest — CREATE + + [Fact] + public void CreateDownloadClientRequest_Validate_WithPlaceholderPassword_ThrowsValidationException() + { + var request = new CreateDownloadClientRequest + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Password = Placeholder, + }; + + Should.Throw(() => request.Validate()); + } + + [Fact] + public void CreateDownloadClientRequest_Validate_WithRealPassword_Succeeds() + { + var request = new CreateDownloadClientRequest + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Password = "real-password", + }; + + Should.NotThrow(() => request.Validate()); + } + + [Fact] + public void CreateDownloadClientRequest_Validate_WithNullPassword_Succeeds() + { + var request = new CreateDownloadClientRequest + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Password = null, + }; + + Should.NotThrow(() => request.Validate()); + } + + #endregion + + #region TestDownloadClientRequest — TEST + + [Fact] + public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndNoResolvedPassword_ThrowsValidationException() + { + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Password = Placeholder, + }; + + request.Validate(); + Should.Throw(() => request.ToTestConfig()); + } + + [Fact] + public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndResolvedPassword_UsesResolvedPassword() + { + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Password = Placeholder, + ClientId = Guid.NewGuid(), + }; + + request.Validate(); + var config = request.ToTestConfig("resolved-password-from-db"); + config.Password.ShouldBe("resolved-password-from-db"); + } + + [Fact] + public void TestDownloadClientRequest_ToTestConfig_WithRealPassword_Succeeds() + { + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://qbit:8080", + Password = "real-password", + }; + + request.Validate(); + var config = request.ToTestConfig(); + config.Password.ShouldBe("real-password"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataResolverTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataResolverTests.cs new file mode 100644 index 00000000..d59a0813 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataResolverTests.cs @@ -0,0 +1,461 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Cleanuparr.Api.Json; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Dtos; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Cleanuparr.Shared.Helpers; +using Shouldly; + +namespace Cleanuparr.Api.Tests.Features.SensitiveData; + +/// +/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties +/// during JSON serialization — this is what controls the API response output. +/// +public class SensitiveDataResolverTests +{ + private readonly JsonSerializerOptions _options; + private const string Placeholder = SensitiveDataHelper.Placeholder; + + public SensitiveDataResolverTests() + { + _options = new JsonSerializerOptions + { + TypeInfoResolver = new SensitiveDataResolver(new DefaultJsonTypeInfoResolver()), + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + } + + #region ArrInstance + + [Fact] + public void ArrInstance_ApiKey_IsMasked() + { + var instance = new ArrInstance + { + Name = "Sonarr", + Url = new Uri("http://sonarr:8989"), + ApiKey = "super-secret-api-key-12345", + ArrConfigId = Guid.NewGuid(), + Version = 4 + }; + + var json = JsonSerializer.Serialize(instance, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder); + } + + [Fact] + public void ArrInstance_NonSensitiveFields_AreVisible() + { + var instance = new ArrInstance + { + Name = "Sonarr", + Url = new Uri("http://sonarr:8989"), + ExternalUrl = new Uri("https://sonarr.example.com"), + ApiKey = "super-secret-api-key-12345", + ArrConfigId = Guid.NewGuid(), + Version = 4 + }; + + var json = JsonSerializer.Serialize(instance, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("name").GetString().ShouldBe("Sonarr"); + doc.RootElement.GetProperty("url").GetString().ShouldBe("http://sonarr:8989"); + doc.RootElement.GetProperty("externalUrl").GetString().ShouldBe("https://sonarr.example.com"); + } + + [Fact] + public void ArrInstance_NullApiKey_RemainsNull() + { + // ApiKey is required, but let's test with the DTO which might handle null + var dto = new ArrInstanceDto + { + Name = "Sonarr", + Url = "http://sonarr:8989", + ApiKey = null!, + Version = 4 + }; + + var json = JsonSerializer.Serialize(dto, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("apiKey").ValueKind.ShouldBe(JsonValueKind.Null); + } + + #endregion + + #region ArrInstanceDto + + [Fact] + public void ArrInstanceDto_ApiKey_IsMasked() + { + var dto = new ArrInstanceDto + { + Id = Guid.NewGuid(), + Name = "Radarr", + Url = "http://radarr:7878", + ApiKey = "dto-secret-api-key-67890", + Version = 5 + }; + + var json = JsonSerializer.Serialize(dto, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("name").GetString().ShouldBe("Radarr"); + doc.RootElement.GetProperty("url").GetString().ShouldBe("http://radarr:7878"); + } + + #endregion + + #region DownloadClientConfig + + [Fact] + public void DownloadClientConfig_Password_IsMasked() + { + var config = new DownloadClientConfig + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://qbit:8080"), + Username = "admin", + Password = "my-secret-password", + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder); + } + + [Fact] + public void DownloadClientConfig_Username_IsVisible() + { + var config = new DownloadClientConfig + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://qbit:8080"), + Username = "admin", + Password = "my-secret-password", + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("username").GetString().ShouldBe("admin"); + doc.RootElement.GetProperty("name").GetString().ShouldBe("qBittorrent"); + } + + [Fact] + public void DownloadClientConfig_NullPassword_RemainsNull() + { + var config = new DownloadClientConfig + { + Name = "qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://qbit:8080"), + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null); + } + + #endregion + + #region NotifiarrConfig + + [Fact] + public void NotifiarrConfig_ApiKey_IsMasked() + { + var config = new NotifiarrConfig + { + ApiKey = "notifiarr-api-key-secret", + ChannelId = "123456789" + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("channelId").GetString().ShouldBe("123456789"); + } + + #endregion + + #region DiscordConfig + + [Fact] + public void DiscordConfig_WebhookUrl_IsMasked() + { + var config = new DiscordConfig + { + WebhookUrl = "https://discord.com/api/webhooks/123456/secret-token", + Username = "Cleanuparr Bot", + AvatarUrl = "https://example.com/avatar.png" + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("username").GetString().ShouldBe("Cleanuparr Bot"); + doc.RootElement.GetProperty("avatarUrl").GetString().ShouldBe("https://example.com/avatar.png"); + } + + #endregion + + #region TelegramConfig + + [Fact] + public void TelegramConfig_BotToken_IsMasked() + { + var config = new TelegramConfig + { + BotToken = "1234567890:ABCdefGHIjklmnoPQRstuvWXyz", + ChatId = "-1001234567890", + TopicId = "42", + SendSilently = true + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("botToken").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("chatId").GetString().ShouldBe("-1001234567890"); + doc.RootElement.GetProperty("topicId").GetString().ShouldBe("42"); + } + + #endregion + + #region NtfyConfig + + [Fact] + public void NtfyConfig_PasswordAndAccessToken_AreMasked() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.example.com", + Topics = ["test-topic"], + AuthenticationType = NtfyAuthenticationType.BasicAuth, + Username = "ntfy-user", + Password = "ntfy-secret-password", + AccessToken = "ntfy-access-token-secret", + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("accessToken").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://ntfy.example.com"); + doc.RootElement.GetProperty("username").GetString().ShouldBe("ntfy-user"); + } + + [Fact] + public void NtfyConfig_NullPasswordAndAccessToken_RemainNull() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.example.com", + Topics = ["test-topic"], + AuthenticationType = NtfyAuthenticationType.None, + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null); + doc.RootElement.GetProperty("accessToken").ValueKind.ShouldBe(JsonValueKind.Null); + } + + #endregion + + #region PushoverConfig + + [Fact] + public void PushoverConfig_ApiTokenAndUserKey_AreMasked() + { + var config = new PushoverConfig + { + ApiToken = "pushover-api-token-secret", + UserKey = "pushover-user-key-secret", + Priority = PushoverPriority.Normal, + Devices = ["iphone", "desktop"] + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("devices").GetArrayLength().ShouldBe(2); + } + + #endregion + + #region GotifyConfig + + [Fact] + public void GotifyConfig_ApplicationToken_IsMasked() + { + var config = new GotifyConfig + { + ServerUrl = "https://gotify.example.com", + ApplicationToken = "gotify-app-token-secret", + Priority = 5 + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("applicationToken").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://gotify.example.com"); + } + + #endregion + + #region AppriseConfig + + [Fact] + public void AppriseConfig_Key_IsMasked_WithFullMask() + { + var config = new AppriseConfig + { + Mode = AppriseMode.Api, + Url = "https://apprise.example.com", + Key = "apprise-config-key-secret", + Tags = "urgent", + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("key").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("url").GetString().ShouldBe("https://apprise.example.com"); + } + + [Fact] + public void AppriseConfig_ServiceUrls_IsMasked_WithAppriseUrlMask() + { + var config = new AppriseConfig + { + Mode = AppriseMode.Cli, + ServiceUrls = "discord://webhook_id/webhook_token slack://tokenA/tokenB/tokenC" + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + var maskedUrls = doc.RootElement.GetProperty("serviceUrls").GetString(); + maskedUrls.ShouldContain("discord://••••••••"); + maskedUrls.ShouldContain("slack://••••••••"); + maskedUrls.ShouldNotContain("webhook_id"); + maskedUrls.ShouldNotContain("webhook_token"); + maskedUrls.ShouldNotContain("tokenA"); + } + + [Fact] + public void AppriseConfig_NullServiceUrls_RemainsNull() + { + var config = new AppriseConfig + { + Mode = AppriseMode.Api, + Url = "https://apprise.example.com", + Key = "some-key", + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("serviceUrls").ValueKind.ShouldBe(JsonValueKind.Null); + } + + #endregion + + #region Polymorphic serialization (as used in NotificationProviderResponse) + + [Fact] + public void PolymorphicSerialization_NotifiarrConfig_StillMasked() + { + // The notification providers endpoint casts configs to `object`. + // Verify that the resolver still masks when serializing as a concrete type at runtime. + object config = new NotifiarrConfig + { + ApiKey = "my-secret-notifiarr-key", + ChannelId = "987654321" + }; + + var json = JsonSerializer.Serialize(config, config.GetType(), _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("channelId").GetString().ShouldBe("987654321"); + } + + [Fact] + public void PolymorphicSerialization_DiscordConfig_StillMasked() + { + object config = new DiscordConfig + { + WebhookUrl = "https://discord.com/api/webhooks/123/secret", + Username = "Bot" + }; + + var json = JsonSerializer.Serialize(config, config.GetType(), _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("username").GetString().ShouldBe("Bot"); + } + + #endregion + + #region Edge cases + + [Fact] + public void EmptySensitiveString_IsMasked_NotReturnedEmpty() + { + var config = new NotifiarrConfig + { + ApiKey = "", + ChannelId = "123" + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + // Even empty strings get masked to the placeholder + doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder); + } + + [Fact] + public void MultipleSensitiveFields_AllMasked() + { + var config = new PushoverConfig + { + ApiToken = "token-abc-123", + UserKey = "user-key-xyz-789", + Priority = PushoverPriority.High, + }; + + var json = JsonSerializer.Serialize(config, _options); + var doc = JsonDocument.Parse(json); + + doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder); + doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs index 1e254883..c582139a 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs @@ -1,4 +1,6 @@ using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Cleanuparr.Api.Json; using Cleanuparr.Infrastructure.Health; using Cleanuparr.Infrastructure.Hubs; using Microsoft.AspNetCore.Http.Json; @@ -17,12 +19,14 @@ public static class ApiDI options.SerializerOptions.PropertyNameCaseInsensitive = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.SerializerOptions.TypeInfoResolver = new SensitiveDataResolver( + options.SerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver()); }); - + // Make JsonSerializerOptions available for injection services.AddSingleton(sp => sp.GetRequiredService>().Value.SerializerOptions); - + // Add API-specific services services .AddControllers() @@ -31,9 +35,11 @@ public static class ApiDI options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.JsonSerializerOptions.TypeInfoResolver = new SensitiveDataResolver( + options.JsonSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver()); }); services.AddEndpointsApiExplorer(); - + // Add SignalR for real-time updates services .AddSignalR() @@ -41,6 +47,8 @@ public static class ApiDI { options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true; options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.PayloadSerializerOptions.TypeInfoResolver = new SensitiveDataResolver( + options.PayloadSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver()); }); // Add health status broadcaster diff --git a/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/ArrInstanceRequest.cs b/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/ArrInstanceRequest.cs index 6b5c898c..26415c98 100644 --- a/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/ArrInstanceRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/ArrInstanceRequest.cs @@ -1,7 +1,9 @@ using System; using System.ComponentModel.DataAnnotations; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Shared.Helpers; namespace Cleanuparr.Api.Features.Arr.Contracts.Requests; @@ -23,16 +25,24 @@ public sealed record ArrInstanceRequest public string? ExternalUrl { get; init; } - public ArrInstance ToEntity(Guid configId) => new() + public ArrInstance ToEntity(Guid configId) { - Enabled = Enabled, - Name = Name, - Url = new Uri(Url), - ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null, - ApiKey = ApiKey, - ArrConfigId = configId, - Version = Version, - }; + if (ApiKey.IsPlaceholder()) + { + throw new ValidationException("API key is required when creating a new instance"); + } + + return new() + { + Enabled = Enabled, + Name = Name, + Url = new Uri(Url), + ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null, + ApiKey = ApiKey, + ArrConfigId = configId, + Version = Version, + }; + } public void ApplyTo(ArrInstance instance) { @@ -40,7 +50,7 @@ public sealed record ArrInstanceRequest instance.Name = Name; instance.Url = new Uri(Url); instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null; - instance.ApiKey = ApiKey; + instance.ApiKey = ApiKey.IsPlaceholder() ? instance.ApiKey : ApiKey; instance.Version = Version; } } diff --git a/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/TestArrInstanceRequest.cs b/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/TestArrInstanceRequest.cs index 20b0fdb9..c762f479 100644 --- a/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/TestArrInstanceRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Arr/Contracts/Requests/TestArrInstanceRequest.cs @@ -1,7 +1,9 @@ using System; using System.ComponentModel.DataAnnotations; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Shared.Helpers; namespace Cleanuparr.Api.Features.Arr.Contracts.Requests; @@ -12,17 +14,29 @@ public sealed record TestArrInstanceRequest [Required] public required string ApiKey { get; init; } - + [Required] public required float Version { get; init; } - public ArrInstance ToTestInstance() => new() + public Guid? InstanceId { get; init; } + + public ArrInstance ToTestInstance(string? resolvedApiKey = null) { - Enabled = true, - Name = "Test Instance", - Url = new Uri(Url), - ApiKey = ApiKey, - ArrConfigId = Guid.Empty, - Version = Version, - }; + var apiKey = resolvedApiKey ?? ApiKey; + + if (apiKey.IsPlaceholder()) + { + throw new ValidationException("API key cannot be a placeholder value"); + } + + return new() + { + Enabled = true, + Name = "Test Instance", + Url = new Uri(Url), + ApiKey = apiKey, + ArrConfigId = Guid.Empty, + Version = Version, + }; + } } diff --git a/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs b/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs index 0fe6c00f..48bceb11 100644 --- a/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs @@ -3,6 +3,7 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Arr.Dtos; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Persistence; +using Cleanuparr.Shared.Helpers; using Mapster; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -284,7 +285,23 @@ public sealed class ArrConfigController : ControllerBase { try { - var testInstance = request.ToTestInstance(); + string? resolvedApiKey = null; + + if (request.ApiKey.IsPlaceholder() && request.InstanceId.HasValue) + { + var existingInstance = await _dataContext.ArrInstances + .AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == request.InstanceId.Value); + + if (existingInstance is null) + { + return NotFound($"Instance with ID {request.InstanceId.Value} not found"); + } + + resolvedApiKey = existingInstance.ApiKey; + } + + var testInstance = request.ToTestInstance(resolvedApiKey); var client = _arrClientFactory.GetClient(type, request.Version); await client.HealthCheckAsync(testInstance); diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs index 2e18bacf..ef871254 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs @@ -3,6 +3,7 @@ using System; using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Helpers; namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests; @@ -47,6 +48,11 @@ public sealed record CreateDownloadClientRequest { throw new ValidationException("External URL is not a valid URL"); } + + if (Password.IsPlaceholder()) + { + throw new ValidationException("Password cannot be a placeholder value"); + } } public DownloadClientConfig ToEntity() => new() diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs index 724be8e4..ae3b9e73 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs @@ -3,6 +3,7 @@ using System; using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Helpers; namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests; @@ -20,6 +21,8 @@ public sealed record TestDownloadClientRequest public string? UrlBase { get; init; } + public Guid? ClientId { get; init; } + public void Validate() { if (string.IsNullOrWhiteSpace(Host)) @@ -33,16 +36,26 @@ public sealed record TestDownloadClientRequest } } - public DownloadClientConfig ToTestConfig() => new() + public DownloadClientConfig ToTestConfig(string? resolvedPassword = null) { - Id = Guid.NewGuid(), - Enabled = true, - Name = "Test Client", - TypeName = TypeName, - Type = Type, - Host = new Uri(Host!, UriKind.RelativeOrAbsolute), - Username = Username, - Password = Password, - UrlBase = UrlBase, - }; + var password = resolvedPassword ?? Password; + + if (password.IsPlaceholder()) + { + throw new ValidationException("Password cannot be a placeholder value"); + } + + return new() + { + Id = Guid.NewGuid(), + Enabled = true, + Name = "Test Client", + TypeName = TypeName, + Type = Type, + Host = new Uri(Host!, UriKind.RelativeOrAbsolute), + Username = Username, + Password = password, + UrlBase = UrlBase, + }; + } } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs index a88b5310..3b07c4a4 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs @@ -3,6 +3,7 @@ using System; using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Helpers; namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests; @@ -57,7 +58,7 @@ public sealed record UpdateDownloadClientRequest Type = Type, Host = new Uri(Host!, UriKind.RelativeOrAbsolute), Username = Username, - Password = Password, + Password = Password.IsPlaceholder() ? existing.Password : Password, UrlBase = UrlBase, ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null, }; diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs index 50968213..8db84258 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs @@ -5,6 +5,7 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; using Cleanuparr.Persistence; +using Cleanuparr.Shared.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -158,7 +159,23 @@ public sealed class DownloadClientController : ControllerBase { request.Validate(); - var testConfig = request.ToTestConfig(); + string? resolvedPassword = null; + + if (request.Password.IsPlaceholder() && request.ClientId.HasValue) + { + var existingClient = await _dataContext.DownloadClients + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == request.ClientId.Value); + + if (existingClient is null) + { + return NotFound($"Download client with ID {request.ClientId.Value} not found"); + } + + resolvedPassword = existingClient.Password; + } + + var testConfig = request.ToTestConfig(resolvedPassword); using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig); var healthResult = await downloadService.HealthCheckAsync(); diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs index 5bd7b0dc..e6208939 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs @@ -15,4 +15,6 @@ public record TestAppriseProviderRequest // CLI mode fields public string? ServiceUrls { get; init; } + + public Guid? ProviderId { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestDiscordProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestDiscordProviderRequest.cs index 061ab31d..54a14e4f 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestDiscordProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestDiscordProviderRequest.cs @@ -7,4 +7,6 @@ public record TestDiscordProviderRequest public string Username { get; init; } = string.Empty; public string AvatarUrl { get; init; } = string.Empty; + + public Guid? ProviderId { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestGotifyProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestGotifyProviderRequest.cs index 421fc7f3..7f514f16 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestGotifyProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestGotifyProviderRequest.cs @@ -7,4 +7,6 @@ public record TestGotifyProviderRequest public string ApplicationToken { get; init; } = string.Empty; public int Priority { get; init; } = 5; + + public Guid? ProviderId { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNotifiarrProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNotifiarrProviderRequest.cs index dd12450e..3fba28af 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNotifiarrProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNotifiarrProviderRequest.cs @@ -3,6 +3,8 @@ namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests; public record TestNotifiarrProviderRequest { public string ApiKey { get; init; } = string.Empty; - + public string ChannelId { get; init; } = string.Empty; + + public Guid? ProviderId { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNtfyProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNtfyProviderRequest.cs index 3c68cb25..80bc7d7c 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNtfyProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestNtfyProviderRequest.cs @@ -19,4 +19,6 @@ public record TestNtfyProviderRequest public NtfyPriority Priority { get; init; } = NtfyPriority.Default; public List Tags { get; init; } = []; + + public Guid? ProviderId { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestPushoverProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestPushoverProviderRequest.cs index ea051abb..572e6ea5 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestPushoverProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestPushoverProviderRequest.cs @@ -19,4 +19,6 @@ public record TestPushoverProviderRequest public int? Expire { get; init; } public List Tags { get; init; } = []; + + public Guid? ProviderId { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestTelegramProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestTelegramProviderRequest.cs index 5c111afb..eeae9935 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestTelegramProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestTelegramProviderRequest.cs @@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest public string? TopicId { get; init; } public bool SendSilently { get; init; } + + public Guid? ProviderId { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs index 6af926e9..884f4f99 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs @@ -11,6 +11,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Telegram; using Cleanuparr.Infrastructure.Features.Notifications.Gotify; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Notification; +using Cleanuparr.Shared.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -129,6 +130,11 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest("A provider with this name already exists"); } + if (newProvider.ApiKey.IsPlaceholder()) + { + return BadRequest("API key cannot be a placeholder value"); + } + var notifiarrConfig = new NotifiarrConfig { ApiKey = newProvider.ApiKey, @@ -186,6 +192,16 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest("A provider with this name already exists"); } + if (newProvider.Key.IsPlaceholder()) + { + return BadRequest("Key cannot be a placeholder value"); + } + + if (newProvider.ServiceUrls.IsPlaceholder()) + { + return BadRequest("Service URLs cannot be a placeholder value"); + } + var appriseConfig = new AppriseConfig { Mode = newProvider.Mode, @@ -250,6 +266,16 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest("A provider with this name already exists"); } + if (newProvider.Password.IsPlaceholder()) + { + return BadRequest("Password cannot be a placeholder value"); + } + + if (newProvider.AccessToken.IsPlaceholder()) + { + return BadRequest("Access token cannot be a placeholder value"); + } + var ntfyConfig = new NtfyConfig { ServerUrl = newProvider.ServerUrl, @@ -317,6 +343,11 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest("A provider with this name already exists"); } + if (newProvider.BotToken.IsPlaceholder()) + { + return BadRequest("Bot token cannot be a placeholder value"); + } + var telegramConfig = new TelegramConfig { BotToken = newProvider.BotToken, @@ -394,7 +425,9 @@ public sealed class NotificationProvidersController : ControllerBase var notifiarrConfig = new NotifiarrConfig { - ApiKey = updatedProvider.ApiKey, + ApiKey = updatedProvider.ApiKey.IsPlaceholder() + ? existingProvider.NotifiarrConfiguration!.ApiKey + : updatedProvider.ApiKey, ChannelId = updatedProvider.ChannelId }; @@ -475,9 +508,13 @@ public sealed class NotificationProvidersController : ControllerBase { Mode = updatedProvider.Mode, Url = updatedProvider.Url, - Key = updatedProvider.Key, + Key = updatedProvider.Key.IsPlaceholder() + ? existingProvider.AppriseConfiguration!.Key + : updatedProvider.Key, Tags = updatedProvider.Tags, - ServiceUrls = updatedProvider.ServiceUrls + ServiceUrls = updatedProvider.ServiceUrls.IsPlaceholder() + ? existingProvider.AppriseConfiguration!.ServiceUrls + : updatedProvider.ServiceUrls }; if (existingProvider.AppriseConfiguration != null) @@ -559,8 +596,12 @@ public sealed class NotificationProvidersController : ControllerBase Topics = updatedProvider.Topics, AuthenticationType = updatedProvider.AuthenticationType, Username = updatedProvider.Username, - Password = updatedProvider.Password, - AccessToken = updatedProvider.AccessToken, + Password = updatedProvider.Password.IsPlaceholder() + ? existingProvider.NtfyConfiguration!.Password + : updatedProvider.Password, + AccessToken = updatedProvider.AccessToken.IsPlaceholder() + ? existingProvider.NtfyConfiguration!.AccessToken + : updatedProvider.AccessToken, Priority = updatedProvider.Priority, Tags = updatedProvider.Tags }; @@ -640,7 +681,9 @@ public sealed class NotificationProvidersController : ControllerBase var telegramConfig = new TelegramConfig { - BotToken = updatedProvider.BotToken, + BotToken = updatedProvider.BotToken.IsPlaceholder() + ? existingProvider.TelegramConfiguration!.BotToken + : updatedProvider.BotToken, ChatId = updatedProvider.ChatId, TopicId = updatedProvider.TopicId, SendSilently = updatedProvider.SendSilently @@ -737,9 +780,24 @@ public sealed class NotificationProvidersController : ControllerBase { try { + var apiKey = testRequest.ApiKey; + + if (apiKey.IsPlaceholder()) + { + var existing = await GetExistingProviderConfig( + testRequest.ProviderId, NotificationProviderType.Notifiarr, p => p.NotifiarrConfiguration); + + if (existing is null) + { + return BadRequest(new { Message = "API key cannot be a placeholder value" }); + } + + apiKey = existing.ApiKey; + } + var notifiarrConfig = new NotifiarrConfig { - ApiKey = testRequest.ApiKey, + ApiKey = apiKey, ChannelId = testRequest.ChannelId }; notifiarrConfig.Validate(); @@ -777,13 +835,37 @@ public sealed class NotificationProvidersController : ControllerBase { try { + var key = testRequest.Key; + var serviceUrls = testRequest.ServiceUrls; + + if (key.IsPlaceholder() || serviceUrls.IsPlaceholder()) + { + var existing = await GetExistingProviderConfig( + testRequest.ProviderId, NotificationProviderType.Apprise, p => p.AppriseConfiguration); + + if (existing is null) + { + return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" }); + } + + if (key.IsPlaceholder()) + { + key = existing.Key; + } + + if (serviceUrls.IsPlaceholder()) + { + serviceUrls = existing.ServiceUrls; + } + } + var appriseConfig = new AppriseConfig { Mode = testRequest.Mode, Url = testRequest.Url, - Key = testRequest.Key, + Key = key, Tags = testRequest.Tags, - ServiceUrls = testRequest.ServiceUrls + ServiceUrls = serviceUrls }; appriseConfig.Validate(); @@ -824,14 +906,38 @@ public sealed class NotificationProvidersController : ControllerBase { try { + var password = testRequest.Password; + var accessToken = testRequest.AccessToken; + + if (password.IsPlaceholder() || accessToken.IsPlaceholder()) + { + var existing = await GetExistingProviderConfig( + testRequest.ProviderId, NotificationProviderType.Ntfy, p => p.NtfyConfiguration); + + if (existing is null) + { + return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" }); + } + + if (password.IsPlaceholder()) + { + password = existing.Password; + } + + if (accessToken.IsPlaceholder()) + { + accessToken = existing.AccessToken; + } + } + var ntfyConfig = new NtfyConfig { ServerUrl = testRequest.ServerUrl, Topics = testRequest.Topics, AuthenticationType = testRequest.AuthenticationType, Username = testRequest.Username, - Password = testRequest.Password, - AccessToken = testRequest.AccessToken, + Password = password, + AccessToken = accessToken, Priority = testRequest.Priority, Tags = testRequest.Tags }; @@ -870,9 +976,24 @@ public sealed class NotificationProvidersController : ControllerBase { try { + var botToken = testRequest.BotToken; + + if (botToken.IsPlaceholder()) + { + var existing = await GetExistingProviderConfig( + testRequest.ProviderId, NotificationProviderType.Telegram, p => p.TelegramConfiguration); + + if (existing is null) + { + return BadRequest(new { Message = "Bot token cannot be a placeholder value" }); + } + + botToken = existing.BotToken; + } + var telegramConfig = new TelegramConfig { - BotToken = testRequest.BotToken, + BotToken = botToken, ChatId = testRequest.ChatId, TopicId = testRequest.TopicId, SendSilently = testRequest.SendSilently @@ -960,6 +1081,11 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest("A provider with this name already exists"); } + if (newProvider.WebhookUrl.IsPlaceholder()) + { + return BadRequest("Webhook URL cannot be a placeholder value"); + } + var discordConfig = new DiscordConfig { WebhookUrl = newProvider.WebhookUrl, @@ -1036,7 +1162,9 @@ public sealed class NotificationProvidersController : ControllerBase var discordConfig = new DiscordConfig { - WebhookUrl = updatedProvider.WebhookUrl, + WebhookUrl = updatedProvider.WebhookUrl.IsPlaceholder() + ? existingProvider.DiscordConfiguration!.WebhookUrl + : updatedProvider.WebhookUrl, Username = updatedProvider.Username, AvatarUrl = updatedProvider.AvatarUrl }; @@ -1090,9 +1218,24 @@ public sealed class NotificationProvidersController : ControllerBase { try { + var webhookUrl = testRequest.WebhookUrl; + + if (webhookUrl.IsPlaceholder()) + { + var existing = await GetExistingProviderConfig( + testRequest.ProviderId, NotificationProviderType.Discord, p => p.DiscordConfiguration); + + if (existing is null) + { + return BadRequest(new { Message = "Webhook URL cannot be a placeholder value" }); + } + + webhookUrl = existing.WebhookUrl; + } + var discordConfig = new DiscordConfig { - WebhookUrl = testRequest.WebhookUrl, + WebhookUrl = webhookUrl, Username = testRequest.Username, AvatarUrl = testRequest.AvatarUrl }; @@ -1148,6 +1291,16 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest("A provider with this name already exists"); } + if (newProvider.ApiToken.IsPlaceholder()) + { + return BadRequest("API token cannot be a placeholder value"); + } + + if (newProvider.UserKey.IsPlaceholder()) + { + return BadRequest("User key cannot be a placeholder value"); + } + var pushoverConfig = new PushoverConfig { ApiToken = newProvider.ApiToken, @@ -1229,8 +1382,12 @@ public sealed class NotificationProvidersController : ControllerBase var pushoverConfig = new PushoverConfig { - ApiToken = updatedProvider.ApiToken, - UserKey = updatedProvider.UserKey, + ApiToken = updatedProvider.ApiToken.IsPlaceholder() + ? existingProvider.PushoverConfiguration!.ApiToken + : updatedProvider.ApiToken, + UserKey = updatedProvider.UserKey.IsPlaceholder() + ? existingProvider.PushoverConfiguration!.UserKey + : updatedProvider.UserKey, Devices = updatedProvider.Devices, Priority = updatedProvider.Priority, Sound = updatedProvider.Sound, @@ -1288,10 +1445,34 @@ public sealed class NotificationProvidersController : ControllerBase { try { + var apiToken = testRequest.ApiToken; + var userKey = testRequest.UserKey; + + if (apiToken.IsPlaceholder() || userKey.IsPlaceholder()) + { + var existing = await GetExistingProviderConfig( + testRequest.ProviderId, NotificationProviderType.Pushover, p => p.PushoverConfiguration); + + if (existing is null) + { + return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" }); + } + + if (apiToken.IsPlaceholder()) + { + apiToken = existing.ApiToken; + } + + if (userKey.IsPlaceholder()) + { + userKey = existing.UserKey; + } + } + var pushoverConfig = new PushoverConfig { - ApiToken = testRequest.ApiToken, - UserKey = testRequest.UserKey, + ApiToken = apiToken, + UserKey = userKey, Devices = testRequest.Devices, Priority = testRequest.Priority, Sound = testRequest.Sound, @@ -1346,6 +1527,11 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest("A provider with this name already exists"); } + if (newProvider.ApplicationToken.IsPlaceholder()) + { + return BadRequest("Application token cannot be a placeholder value"); + } + var gotifyConfig = new GotifyConfig { ServerUrl = newProvider.ServerUrl, @@ -1423,7 +1609,9 @@ public sealed class NotificationProvidersController : ControllerBase var gotifyConfig = new GotifyConfig { ServerUrl = updatedProvider.ServerUrl, - ApplicationToken = updatedProvider.ApplicationToken, + ApplicationToken = updatedProvider.ApplicationToken.IsPlaceholder() + ? existingProvider.GotifyConfiguration!.ApplicationToken + : updatedProvider.ApplicationToken, Priority = updatedProvider.Priority }; @@ -1476,10 +1664,23 @@ public sealed class NotificationProvidersController : ControllerBase { try { + var applicationToken = testRequest.ApplicationToken; + + if (applicationToken.IsPlaceholder()) + { + var existing = await GetExistingProviderConfig( + testRequest.ProviderId, NotificationProviderType.Gotify, p => p.GotifyConfiguration); + + if (existing is null) + return BadRequest(new { Message = "Application token cannot be a placeholder value" }); + + applicationToken = existing.ApplicationToken; + } + var gotifyConfig = new GotifyConfig { ServerUrl = testRequest.ServerUrl, - ApplicationToken = testRequest.ApplicationToken, + ApplicationToken = applicationToken, Priority = testRequest.Priority }; gotifyConfig.Validate(); @@ -1516,4 +1717,34 @@ public sealed class NotificationProvidersController : ControllerBase return BadRequest(new { Message = $"Test failed: {ex.Message}" }); } } + + private async Task GetExistingProviderConfig( + Guid? providerId, + NotificationProviderType expectedType, + Func configSelector) where T : class + { + if (!providerId.HasValue) + { + return null; + } + + IQueryable query = _dataContext.NotificationConfigs.AsNoTracking(); + + query = expectedType switch + { + NotificationProviderType.Notifiarr => query.Include(p => p.NotifiarrConfiguration), + NotificationProviderType.Apprise => query.Include(p => p.AppriseConfiguration), + NotificationProviderType.Ntfy => query.Include(p => p.NtfyConfiguration), + NotificationProviderType.Pushover => query.Include(p => p.PushoverConfiguration), + NotificationProviderType.Telegram => query.Include(p => p.TelegramConfiguration), + NotificationProviderType.Discord => query.Include(p => p.DiscordConfiguration), + NotificationProviderType.Gotify => query.Include(p => p.GotifyConfiguration), + _ => query + }; + + var provider = await query + .FirstOrDefaultAsync(p => p.Id == providerId.Value && p.Type == expectedType); + + return provider is null ? null : configSelector(provider); + } } diff --git a/code/backend/Cleanuparr.Api/Json/SensitiveDataResolver.cs b/code/backend/Cleanuparr.Api/Json/SensitiveDataResolver.cs new file mode 100644 index 00000000..88d2bcc7 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Json/SensitiveDataResolver.cs @@ -0,0 +1,70 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +using Cleanuparr.Shared.Attributes; +using Cleanuparr.Shared.Helpers; + +namespace Cleanuparr.Api.Json; + +/// +/// JSON type info resolver that masks properties decorated with +/// by replacing their serialized values with the appropriate placeholder during serialization. +/// +public sealed class SensitiveDataResolver : IJsonTypeInfoResolver +{ + private readonly IJsonTypeInfoResolver _innerResolver; + + public SensitiveDataResolver(IJsonTypeInfoResolver innerResolver) + { + _innerResolver = innerResolver; + } + + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) + { + var typeInfo = _innerResolver.GetTypeInfo(type, options); + + if (typeInfo?.Kind != JsonTypeInfoKind.Object) + return typeInfo; + + foreach (var property in typeInfo.Properties) + { + if (property.AttributeProvider is not PropertyInfo propertyInfo) + continue; + + var sensitiveAttr = propertyInfo.GetCustomAttribute(); + if (sensitiveAttr is null) + continue; + + ApplyMasking(property, sensitiveAttr.Type); + } + + return typeInfo; + } + + private static void ApplyMasking(JsonPropertyInfo property, SensitiveDataType maskType) + { + var originalGet = property.Get; + if (originalGet is null) + { + return; + } + + property.Get = maskType switch + { + SensitiveDataType.Full => obj => + { + var value = originalGet(obj); + return value is string ? SensitiveDataHelper.Placeholder : value; + }, + + SensitiveDataType.AppriseUrl => obj => + { + var value = originalGet(obj); + return value is string s ? SensitiveDataHelper.MaskAppriseUrls(s) : value; + }, + + _ => originalGet, + }; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Dtos/ArrInstanceDto.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Dtos/ArrInstanceDto.cs index a50fc790..f5e03beb 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Dtos/ArrInstanceDto.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Dtos/ArrInstanceDto.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Cleanuparr.Shared.Attributes; namespace Cleanuparr.Infrastructure.Features.Arr.Dtos; @@ -23,6 +24,7 @@ public record ArrInstanceDto public required string Url { get; init; } [Required] + [SensitiveData] public required string ApiKey { get; init; } public string? ExternalUrl { get; init; } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs index 4dcaa2c6..0ae7eede 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs @@ -46,7 +46,6 @@ public sealed record DownloadClientConfig /// /// Username for authentication /// - [SensitiveData] public string? Username { get; init; } /// diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs index 85fef08e..504f33c0 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Attributes; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.Notification; @@ -29,6 +30,7 @@ public sealed record AppriseConfig : IConfig public string Url { get; init; } = string.Empty; [MaxLength(255)] + [SensitiveData] public string Key { get; init; } = string.Empty; [MaxLength(255)] @@ -40,6 +42,7 @@ public sealed record AppriseConfig : IConfig /// Example: discord://webhook_id/webhook_token /// [MaxLength(4000)] + [SensitiveData(SensitiveDataType.AppriseUrl)] public string? ServiceUrls { get; init; } [NotMapped] diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/DiscordConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/DiscordConfig.cs index db2fbc4d..93cab6f1 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/DiscordConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/DiscordConfig.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Attributes; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.Notification; @@ -19,6 +20,7 @@ public sealed record DiscordConfig : IConfig [Required] [MaxLength(500)] + [SensitiveData] public string WebhookUrl { get; init; } = string.Empty; [MaxLength(80)] diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/GotifyConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/GotifyConfig.cs index 8740211f..6af0d405 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/GotifyConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/GotifyConfig.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Shared.Attributes; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.Notification; @@ -22,6 +23,7 @@ public sealed record GotifyConfig : IConfig [Required] [MaxLength(200)] + [SensitiveData] public string ApplicationToken { get; init; } = string.Empty; public int Priority { get; init; } = 5; diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotifiarrConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotifiarrConfig.cs index 892d223f..178f41cc 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotifiarrConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotifiarrConfig.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Attributes; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.Notification; @@ -19,6 +20,7 @@ public sealed record NotifiarrConfig : IConfig [Required] [MaxLength(255)] + [SensitiveData] public string ApiKey { get; init; } = string.Empty; [Required] diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs index 62857128..feed29b7 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Attributes; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.Notification; @@ -31,9 +32,11 @@ public sealed record NtfyConfig : IConfig public string? Username { get; init; } [MaxLength(255)] + [SensitiveData] public string? Password { get; init; } - + [MaxLength(500)] + [SensitiveData] public string? AccessToken { get; init; } [Required] diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/PushoverConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/PushoverConfig.cs index 48464f0d..2309dd5b 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/PushoverConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/PushoverConfig.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.RegularExpressions; using Cleanuparr.Domain.Enums; +using Cleanuparr.Shared.Attributes; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.Notification; @@ -22,6 +23,7 @@ public sealed partial record PushoverConfig : IConfig /// [Required] [MaxLength(50)] + [SensitiveData] public string ApiToken { get; init; } = string.Empty; /// @@ -29,6 +31,7 @@ public sealed partial record PushoverConfig : IConfig /// [Required] [MaxLength(50)] + [SensitiveData] public string UserKey { get; init; } = string.Empty; /// diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/TelegramConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/TelegramConfig.cs index b94dfd27..64d957a2 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/TelegramConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/TelegramConfig.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Attributes; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.Notification; @@ -20,6 +21,7 @@ public sealed record TelegramConfig : IConfig [Required] [MaxLength(255)] + [SensitiveData] public string BotToken { get; init; } = string.Empty; [Required] diff --git a/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs b/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs index b3de69dd..7c3fe6e9 100644 --- a/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs +++ b/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs @@ -1,9 +1,34 @@ namespace Cleanuparr.Shared.Attributes; /// -/// Marks a property as containing sensitive data that should be encrypted when stored in configuration files. +/// Defines how sensitive data should be masked in API responses. +/// +public enum SensitiveDataType +{ + /// + /// Full mask: replaces the entire value with bullets (••••••••). + /// Use for passwords, API keys, tokens, webhook URLs. + /// + Full, + + /// + /// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••). + /// Use for Apprise service URL strings that contain multiple notification service URLs. + /// + AppriseUrl, +} + +/// +/// Marks a property as containing sensitive data that should be masked in API responses +/// and preserved when the placeholder value is sent back in updates. /// [AttributeUsage(AttributeTargets.Property)] -public class SensitiveDataAttribute : Attribute +public class SensitiveDataAttribute : Attribute { + public SensitiveDataType Type { get; } + + public SensitiveDataAttribute(SensitiveDataType type = SensitiveDataType.Full) + { + Type = type; + } } diff --git a/code/backend/Cleanuparr.Shared/Helpers/SensitiveDataHelper.cs b/code/backend/Cleanuparr.Shared/Helpers/SensitiveDataHelper.cs new file mode 100644 index 00000000..2331bf1b --- /dev/null +++ b/code/backend/Cleanuparr.Shared/Helpers/SensitiveDataHelper.cs @@ -0,0 +1,45 @@ +using System; +using System.Text.RegularExpressions; + +namespace Cleanuparr.Shared.Helpers; + +/// +/// Helpers for sensitive data masking in API responses and request handling. +/// +public static partial class SensitiveDataHelper +{ + /// + /// The placeholder string used to mask sensitive data in API responses. + /// When this value is detected in an update request, the existing DB value is preserved. + /// + public const string Placeholder = "••••••••"; + + /// + /// Returns true if the given value contains the sensitive data placeholder. + /// Uses Contains (not Equals) to handle Apprise URLs like "discord://••••••••". + /// + public static bool IsPlaceholder(this string? value) + => value is not null && value.Contains(Placeholder, StringComparison.Ordinal); + + /// + /// Masks Apprise service URLs by preserving only the scheme. + /// Input: "discord://token slack://tokenA/tokenB" + /// Output: "discord://•••••••• slack://••••••••" + /// + public static string? MaskAppriseUrls(string? serviceUrls) + { + if (string.IsNullOrWhiteSpace(serviceUrls)) + { + return serviceUrls; + } + + return AppriseUrlPattern().Replace(serviceUrls, match => + { + var scheme = match.Groups[1].Value; + return $"{scheme}://{Placeholder}"; + }); + } + + [GeneratedRegex(@"([a-zA-Z][a-zA-Z0-9+.\-]*)://\S+")] + private static partial Regex AppriseUrlPattern(); +} diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.html b/code/frontend/src/app/features/settings/arr/arr-settings.component.html index 1c8ff52b..9ff66d11 100644 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.html +++ b/code/frontend/src/app/features/settings/arr/arr-settings.component.html @@ -69,7 +69,7 @@ - diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.ts b/code/frontend/src/app/features/settings/arr/arr-settings.component.ts index 3405eba8..0694d4ac 100644 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.ts +++ b/code/frontend/src/app/features/settings/arr/arr-settings.component.ts @@ -146,6 +146,7 @@ export class ArrSettingsComponent implements HasPendingChanges { url: this.modalUrl(), apiKey: this.modalApiKey(), version: (this.modalVersion() as number) ?? 3, + instanceId: this.editingInstance()?.id, }; this.testing.set(true); this.api.testInstance(this.arrType() as ArrType, request).subscribe({ diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html index d9fbc73d..f381e898 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html @@ -77,7 +77,7 @@ helpKey="download-client:username" /> } @if (showPasswordField()) { - } diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts index 332dfda0..af59d29e 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts @@ -170,6 +170,7 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges { username: this.modalUsername(), password: this.modalPassword(), urlBase: this.modalUrlBase(), + clientId: this.editingClient()?.id, }; this.testing.set(true); this.api.test(request).subscribe({ diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.html b/code/frontend/src/app/features/settings/notifications/notifications.component.html index b0bc1589..17350657 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.html +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.html @@ -103,7 +103,7 @@ @if (modalType() === 'Discord') { - @@ -117,7 +117,7 @@ @if (modalType() === 'Telegram') { - @@ -135,7 +135,7 @@ @if (modalType() === 'Notifiarr') { - @@ -187,12 +187,12 @@ - } @if (modalNtfyAuthType() === 'AccessToken') { - } @@ -206,11 +206,11 @@ @if (modalType() === 'Pushover') { - - @@ -247,7 +247,7 @@ hint="The base URL of your Gotify server instance." [error]="gotifyServerUrlError()" helpKey="notifications/gotify:serverUrl" /> - diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.ts b/code/frontend/src/app/features/settings/notifications/notifications.component.ts index 5725958c..d63bb5d9 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.ts +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.ts @@ -478,6 +478,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { testNotification(): void { const type = this.modalType(); this.testing.set(true); + const providerId = this.editingProvider()?.id; switch (type) { case NotificationProviderType.Discord: @@ -485,6 +486,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { webhookUrl: this.modalWebhookUrl(), username: this.modalUsername() || undefined, avatarUrl: this.modalAvatarUrl() || undefined, + providerId, }).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, error: () => { this.toast.error('Test failed'); this.testing.set(false); }, @@ -496,6 +498,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { chatId: this.modalChatId(), topicId: this.modalTopicId() || undefined, sendSilently: this.modalSendSilently(), + providerId, }).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, error: () => { this.toast.error('Test failed'); this.testing.set(false); }, @@ -505,6 +508,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { this.api.testNotifiarr({ apiKey: this.modalApiKey(), channelId: this.modalChannelId(), + providerId, }).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, error: () => { this.toast.error('Test failed'); this.testing.set(false); }, @@ -517,6 +521,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { key: this.modalAppriseKey() || undefined, tags: this.modalAppriseTags() || undefined, serviceUrls: this.modalAppriseServiceUrls().join('\n') || undefined, + providerId, }).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, error: () => { this.toast.error('Test failed'); this.testing.set(false); }, @@ -532,6 +537,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { accessToken: this.modalNtfyAccessToken() || undefined, priority: this.modalNtfyPriority() as NtfyPriority, tags: this.modalNtfyTags().length > 0 ? this.modalNtfyTags() : undefined, + providerId, }).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, error: () => { this.toast.error('Test failed'); this.testing.set(false); }, @@ -548,6 +554,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { retry: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverRetry() ?? 30) : undefined, expire: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverExpire() ?? 3600) : undefined, tags: this.modalPushoverTags().length > 0 ? this.modalPushoverTags() : undefined, + providerId, }).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, error: () => { this.toast.error('Test failed'); this.testing.set(false); }, @@ -559,6 +566,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { serverUrl: this.modalGotifyServerUrl(), applicationToken: this.modalGotifyApplicationToken(), priority: parseInt(this.modalGotifyPriority() as string, 10) || 5, + providerId, }).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, error: () => { this.toast.error('Test failed'); this.testing.set(false); }, diff --git a/code/frontend/src/app/shared/models/arr-config.model.ts b/code/frontend/src/app/shared/models/arr-config.model.ts index 0eab9d20..a9c4cf22 100644 --- a/code/frontend/src/app/shared/models/arr-config.model.ts +++ b/code/frontend/src/app/shared/models/arr-config.model.ts @@ -26,4 +26,5 @@ export interface TestArrInstanceRequest { url: string; apiKey: string; version: number; + instanceId?: string; } diff --git a/code/frontend/src/app/shared/models/download-client-config.model.ts b/code/frontend/src/app/shared/models/download-client-config.model.ts index fa14425c..ed71c9e2 100644 --- a/code/frontend/src/app/shared/models/download-client-config.model.ts +++ b/code/frontend/src/app/shared/models/download-client-config.model.ts @@ -36,6 +36,7 @@ export interface TestDownloadClientRequest { username?: string; password?: string; urlBase?: string; + clientId?: string; } export interface TestConnectionResult { diff --git a/code/frontend/src/app/shared/models/notification-provider.model.ts b/code/frontend/src/app/shared/models/notification-provider.model.ts index 4bde3629..fd04616e 100644 --- a/code/frontend/src/app/shared/models/notification-provider.model.ts +++ b/code/frontend/src/app/shared/models/notification-provider.model.ts @@ -150,6 +150,7 @@ export interface CreateGotifyProviderRequest { export interface TestNotifiarrRequest { apiKey: string; channelId: string; + providerId?: string; } export interface TestAppriseRequest { @@ -158,6 +159,7 @@ export interface TestAppriseRequest { key?: string; tags?: string; serviceUrls?: string; + providerId?: string; } export interface TestNtfyRequest { @@ -169,6 +171,7 @@ export interface TestNtfyRequest { accessToken?: string; priority: NtfyPriority; tags?: string[]; + providerId?: string; } export interface TestTelegramRequest { @@ -176,12 +179,14 @@ export interface TestTelegramRequest { chatId: string; topicId?: string; sendSilently: boolean; + providerId?: string; } export interface TestDiscordRequest { webhookUrl: string; username?: string; avatarUrl?: string; + providerId?: string; } export interface TestPushoverRequest { @@ -193,12 +198,14 @@ export interface TestPushoverRequest { retry?: number; expire?: number; tags?: string[]; + providerId?: string; } export interface TestGotifyRequest { serverUrl: string; applicationToken: string; priority: number; + providerId?: string; } export interface TestNotificationResult { diff --git a/code/frontend/src/app/ui/input/input.component.html b/code/frontend/src/app/ui/input/input.component.html index 35880d89..7d9e0b2b 100644 --- a/code/frontend/src/app/ui/input/input.component.html +++ b/code/frontend/src/app/ui/input/input.component.html @@ -13,7 +13,7 @@ #inputEl class="input-field" [class.input-field--error]="error()" - [class.input-field--has-eye]="type() === 'password'" + [class.input-field--has-eye]="hasEye()" [type]="effectiveType()" [placeholder]="placeholder()" [disabled]="disabled()" @@ -21,7 +21,7 @@ [(ngModel)]="value" (blur)="blurred.emit($event)" /> - @if (type() === 'password') { + @if (hasEye()) { diff --git a/code/frontend/src/app/ui/input/input.component.ts b/code/frontend/src/app/ui/input/input.component.ts index eadd5ed4..c853b9dc 100644 --- a/code/frontend/src/app/ui/input/input.component.ts +++ b/code/frontend/src/app/ui/input/input.component.ts @@ -19,6 +19,7 @@ export class InputComponent { type = input<'text' | 'password' | 'email' | 'url' | 'search' | 'datetime-local' | 'date' | 'number'>('text'); disabled = input(false); readonly = input(false); + revealable = input(true); error = input(); hint = input(); helpKey = input(); @@ -29,8 +30,9 @@ export class InputComponent { blurred = output(); readonly showSecret = signal(false); + readonly hasEye = computed(() => this.type() === 'password' && this.revealable()); readonly effectiveType = computed(() => { - if (this.type() === 'password' && this.showSecret()) return 'text'; + if (this.hasEye() && this.showSecret()) return 'text'; return this.type(); }); @@ -40,6 +42,7 @@ export class InputComponent { toggleSecret(event: Event): void { event.preventDefault(); + if (!this.revealable()) return; this.showSecret.update(v => !v); }