Add guards against exposing passwords to the UI (#477)

This commit is contained in:
Flaminel
2026-03-02 13:11:34 +02:00
committed by GitHub
parent 41b48d1104
commit bdb956ec84
42 changed files with 1441 additions and 74 deletions

View File

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

View File

@@ -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;
/// <summary>
/// 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
/// </summary>
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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

@@ -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;
/// <summary>
/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties
/// during JSON serialization — this is what controls the API response output.
/// </summary>
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
}

View File

@@ -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<IOptions<JsonOptions>>().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

View File

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

View File

@@ -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,
};
}
}

View File

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

View File

@@ -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()

View File

@@ -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,
};
}
}

View File

@@ -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,
};

View File

@@ -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();

View File

@@ -15,4 +15,6 @@ public record TestAppriseProviderRequest
// CLI mode fields
public string? ServiceUrls { get; init; }
public Guid? ProviderId { get; init; }
}

View File

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

View File

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

View File

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

View File

@@ -19,4 +19,6 @@ public record TestNtfyProviderRequest
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
public Guid? ProviderId { get; init; }
}

View File

@@ -19,4 +19,6 @@ public record TestPushoverProviderRequest
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
public Guid? ProviderId { get; init; }
}

View File

@@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
public Guid? ProviderId { get; init; }
}

View File

@@ -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<NotifiarrConfig>(
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<AppriseConfig>(
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<NtfyConfig>(
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<TelegramConfig>(
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<DiscordConfig>(
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<PushoverConfig>(
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<GotifyConfig>(
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<T?> GetExistingProviderConfig<T>(
Guid? providerId,
NotificationProviderType expectedType,
Func<NotificationConfig, T?> configSelector) where T : class
{
if (!providerId.HasValue)
{
return null;
}
IQueryable<NotificationConfig> 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);
}
}

View File

@@ -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;
/// <summary>
/// JSON type info resolver that masks properties decorated with <see cref="SensitiveDataAttribute"/>
/// by replacing their serialized values with the appropriate placeholder during serialization.
/// </summary>
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<SensitiveDataAttribute>();
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,
};
}
}

View File

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

View File

@@ -46,7 +46,6 @@ public sealed record DownloadClientConfig
/// <summary>
/// Username for authentication
/// </summary>
[SensitiveData]
public string? Username { get; init; }
/// <summary>

View File

@@ -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
/// </summary>
[MaxLength(4000)]
[SensitiveData(SensitiveDataType.AppriseUrl)]
public string? ServiceUrls { get; init; }
[NotMapped]

View File

@@ -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)]

View File

@@ -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;

View File

@@ -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]

View File

@@ -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]

View File

@@ -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
/// </summary>
[Required]
[MaxLength(50)]
[SensitiveData]
public string ApiToken { get; init; } = string.Empty;
/// <summary>
@@ -29,6 +31,7 @@ public sealed partial record PushoverConfig : IConfig
/// </summary>
[Required]
[MaxLength(50)]
[SensitiveData]
public string UserKey { get; init; } = string.Empty;
/// <summary>

View File

@@ -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]

View File

@@ -1,9 +1,34 @@
namespace Cleanuparr.Shared.Attributes;
/// <summary>
/// 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.
/// </summary>
public enum SensitiveDataType
{
/// <summary>
/// Full mask: replaces the entire value with bullets (••••••••).
/// Use for passwords, API keys, tokens, webhook URLs.
/// </summary>
Full,
/// <summary>
/// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••).
/// Use for Apprise service URL strings that contain multiple notification service URLs.
/// </summary>
AppriseUrl,
}
/// <summary>
/// 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.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class SensitiveDataAttribute : Attribute
public class SensitiveDataAttribute : Attribute
{
public SensitiveDataType Type { get; }
public SensitiveDataAttribute(SensitiveDataType type = SensitiveDataType.Full)
{
Type = type;
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Text.RegularExpressions;
namespace Cleanuparr.Shared.Helpers;
/// <summary>
/// Helpers for sensitive data masking in API responses and request handling.
/// </summary>
public static partial class SensitiveDataHelper
{
/// <summary>
/// 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.
/// </summary>
public const string Placeholder = "••••••••";
/// <summary>
/// Returns true if the given value contains the sensitive data placeholder.
/// Uses Contains (not Equals) to handle Apprise URLs like "discord://••••••••".
/// </summary>
public static bool IsPlaceholder(this string? value)
=> value is not null && value.Contains(Placeholder, StringComparison.Ordinal);
/// <summary>
/// Masks Apprise service URLs by preserving only the scheme.
/// Input: "discord://token slack://tokenA/tokenB"
/// Output: "discord://•••••••• slack://••••••••"
/// </summary>
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();
}

View File

@@ -69,7 +69,7 @@
<app-input label="External URL" placeholder="https://sonarr.example.com" type="url" [(value)]="modalExternalUrl"
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"
helpKey="arr:externalUrl" />
<app-input label="API Key" placeholder="Enter API key" type="password" [(value)]="modalApiKey"
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [(value)]="modalApiKey"
hint="API key from your arr application's Settings > General"
[error]="modalApiKeyError()"
helpKey="arr:apiKey" />

View File

@@ -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({

View File

@@ -77,7 +77,7 @@
helpKey="download-client:username" />
}
@if (showPasswordField()) {
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalPassword"
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalPassword"
[hint]="passwordHint()"
helpKey="download-client:password" />
}

View File

@@ -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({

View File

@@ -103,7 +103,7 @@
<!-- Discord Fields -->
@if (modalType() === 'Discord') {
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [(value)]="modalWebhookUrl"
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [revealable]="false" [(value)]="modalWebhookUrl"
hint="Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations."
[error]="discordWebhookError()"
helpKey="notifications/discord:webhookUrl" />
@@ -117,7 +117,7 @@
<!-- Telegram Fields -->
@if (modalType() === 'Telegram') {
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [(value)]="modalBotToken"
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [revealable]="false" [(value)]="modalBotToken"
hint="Create a bot with BotFather and paste the API token"
[error]="telegramBotTokenError()"
helpKey="notifications/telegram:botToken" />
@@ -135,7 +135,7 @@
<!-- Notifiarr Fields -->
@if (modalType() === 'Notifiarr') {
<app-input label="API Key" placeholder="Enter API key" type="password" [(value)]="modalApiKey"
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [(value)]="modalApiKey"
hint="Your Notifiarr API key from your dashboard. Requires Passthrough integration."
[error]="notifiarrApiKeyError()"
helpKey="notifications/notifiarr:apiKey" />
@@ -187,12 +187,12 @@
<app-input label="Username" placeholder="Enter username" [(value)]="modalNtfyUsername"
hint="Your username for basic authentication."
helpKey="notifications/ntfy:username" />
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalNtfyPassword"
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalNtfyPassword"
hint="Your password for basic authentication."
helpKey="notifications/ntfy:password" />
}
@if (modalNtfyAuthType() === 'AccessToken') {
<app-input label="Access Token" placeholder="Enter access token" type="password" [(value)]="modalNtfyAccessToken"
<app-input label="Access Token" placeholder="Enter access token" type="password" [revealable]="false" [(value)]="modalNtfyAccessToken"
hint="Your access token for bearer token authentication."
helpKey="notifications/ntfy:accessToken" />
}
@@ -206,11 +206,11 @@
<!-- Pushover Fields -->
@if (modalType() === 'Pushover') {
<app-input label="API Token" placeholder="Enter API token" type="password" [(value)]="modalPushoverApiToken"
<app-input label="API Token" placeholder="Enter API token" type="password" [revealable]="false" [(value)]="modalPushoverApiToken"
hint="Your application API token from Pushover. Create one at pushover.net/apps/build."
[error]="pushoverApiTokenError()"
helpKey="notifications/pushover:apiToken" />
<app-input label="User Key" placeholder="Enter user key" type="password" [(value)]="modalPushoverUserKey"
<app-input label="User Key" placeholder="Enter user key" type="password" [revealable]="false" [(value)]="modalPushoverUserKey"
hint="Your user/group key from your Pushover dashboard."
[error]="pushoverUserKeyError()"
helpKey="notifications/pushover:userKey" />
@@ -247,7 +247,7 @@
hint="The base URL of your Gotify server instance."
[error]="gotifyServerUrlError()"
helpKey="notifications/gotify:serverUrl" />
<app-input label="Application Token" placeholder="Enter application token" type="password" [(value)]="modalGotifyApplicationToken"
<app-input label="Application Token" placeholder="Enter application token" type="password" [revealable]="false" [(value)]="modalGotifyApplicationToken"
hint="The application token from your Gotify server. Create one under Apps in the Gotify web UI."
[error]="gotifyApplicationTokenError()"
helpKey="notifications/gotify:applicationToken" />

View File

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

View File

@@ -26,4 +26,5 @@ export interface TestArrInstanceRequest {
url: string;
apiKey: string;
version: number;
instanceId?: string;
}

View File

@@ -36,6 +36,7 @@ export interface TestDownloadClientRequest {
username?: string;
password?: string;
urlBase?: string;
clientId?: string;
}
export interface TestConnectionResult {

View File

@@ -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 {

View File

@@ -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()) {
<button type="button" class="input-eye-btn" (click)="toggleSecret($event)" [attr.aria-label]="showSecret() ? 'Hide password' : 'Show password'">
<ng-icon [name]="showSecret() ? 'tablerEyeOff' : 'tablerEye'" size="16" />
</button>

View File

@@ -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<string>();
hint = input<string>();
helpKey = input<string>();
@@ -29,8 +30,9 @@ export class InputComponent {
blurred = output<FocusEvent>();
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);
}