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);
}