mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-24 09:03:38 -04:00
Add guards against exposing passwords to the UI (#477)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -15,4 +15,6 @@ public record TestAppriseProviderRequest
|
||||
|
||||
// CLI mode fields
|
||||
public string? ServiceUrls { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -19,4 +19,6 @@ public record TestPushoverProviderRequest
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest
|
||||
public string? TopicId { get; init; }
|
||||
|
||||
public bool SendSilently { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
70
code/backend/Cleanuparr.Api/Json/SensitiveDataResolver.cs
Normal file
70
code/backend/Cleanuparr.Api/Json/SensitiveDataResolver.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -46,7 +46,6 @@ public sealed record DownloadClientConfig
|
||||
/// <summary>
|
||||
/// Username for authentication
|
||||
/// </summary>
|
||||
[SensitiveData]
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -26,4 +26,5 @@ export interface TestArrInstanceRequest {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
version: number;
|
||||
instanceId?: string;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface TestDownloadClientRequest {
|
||||
username?: string;
|
||||
password?: string;
|
||||
urlBase?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user