Compare commits

...
14 Commits
86 changed files with 2861 additions and 1344 deletions

No files matched your search

+157 -53
View File
@@ -22,23 +22,32 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
jobs:
build_app:
# Compute tags, version, and push decision for downstream jobs
prepare:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
outputs:
tags: ${{ steps.build-info.outputs.tags }}
version: ${{ steps.build-info.outputs.version }}
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
branch: ${{ steps.build-info.outputs.branch }}
push: ${{ steps.build-info.outputs.push }}
github_sha: ${{ github.sha }}
steps:
- name: Set github context
timeout-minutes: 1
run: |
echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV
echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV
echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV
echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV
- name: Initialize build info
id: build-info
timeout-minutes: 1
run: |
githubHeadRef=${{ env.githubHeadRef }}
githubHeadRef="${{ github.head_ref }}"
githubRef="${{ github.ref }}"
inputVersion="${{ inputs.app_version }}"
latestDockerTag=""
versionDockerTag=""
@@ -71,10 +80,8 @@ jobs:
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
else
# Determine if this run is for the main branch or another branch
if [[ -z "$githubHeadRef" ]]; then
# Main branch
githubRef=${{ env.githubRef }}
branch=${githubRef##*/}
versionDockerTag="$branch"
else
@@ -85,25 +92,45 @@ jobs:
fi
githubTags=""
if [ -n "$latestDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
fi
if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
fi
if [ -n "$minorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
fi
if [ -n "$majorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
fi
# set env vars
echo "branch=$branch" >> $GITHUB_ENV
echo "githubTags=$githubTags" >> $GITHUB_ENV
echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV
echo "version=$version" >> $GITHUB_ENV
githubTags="${githubTags#,}"
# Determine push decision
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
echo "tags=$githubTags" >> $GITHUB_OUTPUT
echo "version=$version" >> $GITHUB_OUTPUT
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
echo "branch=$branch" >> $GITHUB_OUTPUT
echo "push=$push" >> $GITHUB_OUTPUT
# Build each platform in parallel
build:
runs-on: ubuntu-latest
needs: [prepare]
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare platform pair
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
@@ -113,8 +140,6 @@ jobs:
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/docker username | DOCKER_USERNAME;
secrets/data/docker password | DOCKER_PASSWORD;
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
@@ -122,16 +147,97 @@ jobs:
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ env.githubRepository }}
ref: ${{ env.branch }}
repository: ${{ github.repository }}
ref: ${{ needs.prepare.outputs.branch }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
timeout-minutes: 5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to GitHub Container Registry
if: needs.prepare.outputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
if: needs.prepare.outputs.push == 'true'
id: build-push
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Build (no push)
if: needs.prepare.outputs.push != 'true'
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
push: false
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
if: needs.prepare.outputs.push == 'true'
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build-push.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: needs.prepare.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Create multi-platform manifest and push with final tags
merge:
runs-on: ubuntu-latest
needs: [prepare, build]
if: needs.prepare.outputs.push == 'true'
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -140,27 +246,25 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push docker image
id: docker-build
timeout-minutes: 15
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ env.githubSha }}
version=${{ env.versionDockerTag }}
build-args: |
VERSION=${{ env.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: |
linux/amd64
linux/arm64
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
tags: |
${{ env.githubTags }}
# Enable BuildKit cache for faster builds
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create manifest list and push
timeout-minutes: 5
working-directory: ${{ runner.temp }}/digests
run: |
tags="${{ needs.prepare.outputs.tags }}"
tag_args=""
IFS=',' read -ra TAG_ARRAY <<< "$tags"
for tag in "${TAG_ARRAY[@]}"; do
tag=$(echo "$tag" | xargs)
if [ -n "$tag" ]; then
tag_args="$tag_args -t $tag"
fi
done
docker buildx imagetools create $tag_args \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
tags="${{ needs.prepare.outputs.tags }}"
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
docker buildx imagetools inspect "$first_tag"
+1 -1
View File
@@ -10,7 +10,7 @@
## Project Overview
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
**Key Features:**
- Strike system for bad downloads
+1 -8
View File
@@ -49,6 +49,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
- **Transmission**
- **Deluge**
- **µTorrent**
- **rTorrent**
### Platforms
- **Docker**
@@ -101,14 +102,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle">![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social)</span></span>
# Credits
Special thanks for inspiration go to:
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
@@ -178,6 +178,61 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(11)]
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(12)]
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(13)]
public async Task Setup_Complete_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(14)]
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
// Login endpoint must NOT be blocked by the middleware (403).
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(15)]
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
}
#region TOTP helpers
private static string _totpSecret = "";
@@ -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,9 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api")]
[Authorize]
public class ApiDocumentationController : ControllerBase
{
[HttpGet]
@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -9,6 +10,7 @@ namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class EventsController : ControllerBase
{
private readonly EventsContext _context;
@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Health;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
/// </summary>
[ApiController]
[Route("api/health")]
[Authorize]
public class HealthCheckController : ControllerBase
{
private readonly ILogger<HealthCheckController> _logger;
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
/// </summary>
[ApiController]
[Route("[controller]")]
[Authorize]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
@@ -23,6 +25,7 @@ public class HealthController : ControllerBase
/// Basic liveness probe - checks if the application is running
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
/// </summary>
[AllowAnonymous]
[HttpGet]
[Route("/health")]
public async Task<IActionResult> GetHealth()
@@ -47,6 +50,7 @@ public class HealthController : ControllerBase
/// Readiness probe - checks if the application is ready to serve traffic
/// Used by Kubernetes readiness probes
/// </summary>
[AllowAnonymous]
[HttpGet]
[Route("/health/ready")]
public async Task<IActionResult> GetReadiness()
@@ -2,12 +2,14 @@ using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class JobsController : ControllerBase
{
private readonly IJobManagementService _jobManagementService;
@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ManualEventsController : ControllerBase
{
private readonly EventsContext _context;
@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Stats;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class StatsController : ControllerBase
{
private readonly ILogger<StatsController> _logger;
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -9,6 +10,7 @@ namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class StatusController : ControllerBase
{
private readonly ILogger<StatusController> _logger;
@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class StrikesController : ControllerBase
{
private readonly EventsContext _context;
@@ -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,7 +3,9 @@ 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;
using Microsoft.EntityFrameworkCore;
@@ -11,6 +13,7 @@ namespace Cleanuparr.Api.Features.Arr.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
@@ -282,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);
@@ -66,8 +66,21 @@ public sealed class AccountController : ControllerBase
return BadRequest(new { error = "Current password is incorrect" });
}
DateTime now = DateTime.UtcNow;
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = now;
// Revoke all existing refresh tokens so old sessions can't be reused
var activeTokens = await _usersContext.RefreshTokens
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
.ToListAsync();
foreach (var token in activeTokens)
{
token.RevokedAt = now;
}
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Password changed for user {Username}", user.Username);
@@ -119,9 +119,9 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted && user.TotpEnabled)
if (user.SetupCompleted)
{
return Conflict(new { error = "2FA is already configured" });
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
}
// Generate new TOTP secret
@@ -176,6 +176,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
@@ -212,6 +217,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed" });
}
user.SetupCompleted = true;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
@@ -382,6 +392,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
}
var pin = await _plexAuthService.RequestPin();
return Ok(new PlexPinStatusResponse
@@ -412,6 +427,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
}
user.PlexAccountId = plexAccount.AccountId;
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
@@ -472,7 +492,10 @@ public sealed class AuthController : ControllerBase
return Unauthorized(new { error = "Plex account does not match the linked account" });
}
// Plex login bypasses 2FA
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
// Plex account during setup or via account settings (both require authentication).
// Since Plex login verifies the exact same Plex account ID that was linked,
// 2FA is not required for Plex login.
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
var tokenResponse = await GenerateTokenResponse(user);
@@ -504,7 +527,7 @@ public sealed class AuthController : ControllerBase
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = 60 // seconds
ExpiresIn = 3600 // seconds
};
}
@@ -6,6 +6,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -14,6 +15,7 @@ namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class BlacklistSyncConfigController : ControllerBase
{
private readonly ILogger<BlacklistSyncConfigController> _logger;
@@ -9,6 +9,7 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -17,6 +18,7 @@ namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class DownloadCleanerConfigController : ControllerBase
{
private readonly ILogger<DownloadCleanerConfigController> _logger;
@@ -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,8 @@ 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;
@@ -12,6 +14,7 @@ namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class DownloadClientController : ControllerBase
{
private readonly ILogger<DownloadClientController> _logger;
@@ -156,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();
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -12,6 +13,7 @@ namespace Cleanuparr.Api.Features.General.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class GeneralConfigController : ControllerBase
{
private readonly ILogger<GeneralConfigController> _logger;
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -16,6 +17,7 @@ namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class MalwareBlockerConfigController : ControllerBase
{
private readonly ILogger<MalwareBlockerConfigController> _logger;
@@ -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,8 @@ 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;
@@ -18,6 +20,7 @@ namespace Cleanuparr.Api.Features.Notifications.Controllers;
[ApiController]
[Route("api/configuration/notification_providers")]
[Authorize]
public sealed class NotificationProvidersController : ControllerBase
{
private readonly ILogger<NotificationProvidersController> _logger;
@@ -127,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,
@@ -184,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,
@@ -248,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,
@@ -315,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,
@@ -392,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
};
@@ -473,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)
@@ -557,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
};
@@ -638,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
@@ -735,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();
@@ -775,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();
@@ -822,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
};
@@ -868,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
@@ -958,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,
@@ -1034,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
};
@@ -1088,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
};
@@ -1146,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,
@@ -1227,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,
@@ -1286,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,
@@ -1344,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,
@@ -1421,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
};
@@ -1474,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();
@@ -1514,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);
}
}
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -15,6 +16,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class QueueCleanerConfigController : ControllerBase
{
private readonly ILogger<QueueCleanerConfigController> _logger;
@@ -3,6 +3,7 @@ using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -11,6 +12,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/queue-rules")]
[Authorize]
public class QueueRulesController : ControllerBase
{
private readonly ILogger<QueueRulesController> _logger;
@@ -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,
};
}
}
@@ -15,52 +15,75 @@ public class SetupGuardMiddleware
public async Task InvokeAsync(HttpContext context)
{
// Fast path: setup already completed
string path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
// Always allow health checks and non-API paths (static files, SPA, etc.)
if (path.StartsWith("/health") || !path.StartsWith("/api/"))
{
await _next(context);
return;
}
// Setup-only paths (/api/auth/setup/*) require setup to NOT be complete
if (IsSetupOnlyPath(path))
{
if (await IsSetupCompleted())
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup already completed" });
return;
}
await _next(context);
return;
}
// Non-setup auth paths (login, refresh, logout, status) are always allowed
if (path.StartsWith("/api/auth/") || path == "/api/auth")
{
await _next(context);
return;
}
// All other API paths require setup to be complete
if (!await IsSetupCompleted())
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
return;
}
await _next(context);
}
public void ResetSetupState()
{
_setupCompleted = false;
}
private async Task<bool> IsSetupCompleted()
{
if (_setupCompleted)
{
await _next(context);
return;
return true;
}
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
// Always allow these paths regardless of setup state
if (IsAllowedPath(path))
{
await _next(context);
return;
}
// Check database for setup completion
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is { SetupCompleted: true })
{
_setupCompleted = true;
await _next(context);
return;
return true;
}
// Setup not complete - block non-auth requests
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
return false;
}
/// <summary>
/// Resets the cached setup state. Call this if the user database is reset.
/// </summary>
public void ResetSetupState()
private static bool IsSetupOnlyPath(string path)
{
_setupCompleted = false;
}
private static bool IsAllowedPath(string path)
{
return path.StartsWith("/api/auth/")
|| path == "/api/auth"
|| path.StartsWith("/health")
|| !path.StartsWith("/api/");
return path.StartsWith("/api/auth/setup/") || path == "/api/auth/setup";
}
}
+1 -1
View File
@@ -181,4 +181,4 @@ await app.RunAsync();
await Log.CloseAndFlushAsync();
// Make Program class accessible for testing
public partial class Program { }
public partial class Program { }
@@ -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; }
@@ -11,7 +11,7 @@ public sealed class JwtService : IJwtService
{
private const string Issuer = "Cleanuparr";
private const string Audience = "Cleanuparr";
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(1);
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromHours(1);
private static readonly TimeSpan LoginTokenLifetime = TimeSpan.FromMinutes(5);
private readonly byte[] _signingKey;
@@ -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();
}
+30 -4
View File
@@ -1,6 +1,34 @@
#!/bin/bash
set -e
# Default UMASK if unset to prevent errors with set -e
UMASK="${UMASK:-022}"
CURRENT_UID=$(id -u)
# If not running as root, skip all user/permission management.
# This supports docker-compose `user: PUID:PGID` (rootless mode).
if [ "$CURRENT_UID" != "0" ]; then
umask "$UMASK"
# In rootless mode, the app uses /config for all writable state.
# A non-root user cannot create directories under /, so validate early.
if [ ! -d /config ]; then
echo "ERROR: /config does not exist and the container is running as non-root (UID $CURRENT_UID)." >&2
echo "Please mount a writable volume at /config." >&2
exit 1
fi
if [ ! -w /config ]; then
echo "ERROR: /config is not writable by UID $CURRENT_UID." >&2
echo "Please adjust permissions or mount /config as a writable volume." >&2
exit 1
fi
exec "$@"
fi
# Running as root — use PUID/PGID to create user and drop privileges
# Create group if it doesn't exist
if ! getent group "$PGID" > /dev/null 2>&1; then
echo "Creating group with GID $PGID"
@@ -16,16 +44,14 @@ fi
# Set umask
umask "$UMASK"
# Change ownership of app directory if not running as root
# Ensure /config is writable by the target user
if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
mkdir -p /config
chown -R "$PUID:$PGID" /app
chown -R "$PUID:$PGID" /config
fi
# Execute the main command as the specified user
# Execute as the specified user (or root if PUID=0)
if [ "$PUID" = "0" ] && [ "$PGID" = "0" ]; then
# Running as root, no need for gosu
exec "$@"
else
# Use gosu to drop privileges
+954 -1147
View File
File diff suppressed because it is too large. Load diff
@@ -169,7 +169,6 @@ export class AuthService {
const storedRefreshToken = localStorage.getItem('refresh_token');
if (!storedRefreshToken) {
this.clearAuth();
return of(null);
}
@@ -14,8 +14,11 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
if (error.error instanceof ErrorEvent) {
// Client-side error
message = error.error.message;
} else if (typeof error.error === 'string') {
// Server-side error with plain string body
message = error.error;
} else {
// Server-side error
// Server-side error with JSON body
message = error.error?.error
?? error.error?.message
?? error.message
@@ -29,6 +29,10 @@ export abstract class HubService implements OnDestroy {
this.connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: async () => {
// No tokens stored — trusted network bypass, no token needed
if (!this.authService.getAccessToken() && !localStorage.getItem('refresh_token')) {
return '';
}
if (this.authService.isTokenExpired(30)) {
const result = await firstValueFrom(this.authService.refreshToken());
if (result) {
@@ -72,7 +76,8 @@ export abstract class HubService implements OnDestroy {
this.connected.set(true);
this.reconnectAttempts = 0;
this.onConnected();
} catch {
} catch (err) {
console.warn('[SignalR] Connection failed:', err);
this.scheduleReconnect();
}
}
@@ -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({
@@ -57,7 +57,7 @@
</app-card>
@if (enabled()) {
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded">
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded" [error]="noFeaturesError()">
@for (cat of categories(); track $index; let i = $index) {
<div class="category-row">
<div class="category-row__header">
@@ -105,7 +105,7 @@
</app-button>
</app-accordion>
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded">
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded" [error]="noFeaturesError()">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="unlinkedEnabled"
hint="Enable management of downloads that have no hardlinks"
@@ -6,6 +6,7 @@ import {
EmptyStateComponent, LoadingStateComponent, type SelectOption,
} from '@ui';
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { DownloadCleanerConfig, CleanCategory, createDefaultCategory } from '@shared/models/download-cleaner-config.model';
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
@@ -123,7 +124,20 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return undefined;
}
readonly noFeaturesError = computed(() => {
if (!this.enabled()) return undefined;
const hasSeedingCategories = this.categories().length > 0;
const hasUnlinkedFeature = this.unlinkedEnabled()
&& !this.unlinkedTargetCategoryError()
&& !this.unlinkedCategoriesError();
if (!hasSeedingCategories && !hasUnlinkedFeature) {
return 'At least one feature must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noFeaturesError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.unlinkedTargetCategoryError()) return true;
@@ -224,8 +238,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to save download cleaner settings');
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400
? err.message
: 'Failed to save download cleaner settings');
this.saving.set(false);
},
});
@@ -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({
@@ -70,7 +70,7 @@
</app-card>
@if (enabled()) {
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded">
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded" [error]="noBlocklistError()">
@for (name of arrNames; track name) {
<div class="arr-blocklist">
<h4 class="arr-blocklist__title">{{ capitalize(name) }}</h4>
@@ -6,6 +6,7 @@ import {
type SelectOption,
} from '@ui';
import { MalwareBlockerApi } from '@core/api/malware-blocker.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { MalwareBlockerConfig, BlocklistSettings, MalwareScheduleOptions } from '@shared/models/malware-blocker-config.model';
import { BlocklistType, ScheduleUnit } from '@shared/models/enums';
@@ -120,7 +121,18 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
return undefined;
}
readonly noBlocklistError = computed(() => {
if (!this.enabled()) return undefined;
const blocklists = this.arrBlocklists();
const hasAnyEnabled = ARR_NAMES.some(name => blocklists[name]?.enabled);
if (!hasAnyEnabled) {
return 'At least one blocklist must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noBlocklistError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
@@ -225,8 +237,10 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to save malware blocker settings');
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400
? err.message
: 'Failed to save malware blocker settings');
this.saving.set(false);
},
});
@@ -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); },
@@ -75,7 +75,6 @@ export class NavSidebarComponent {
];
suggestedApps: ExternalLink[] = [
{ label: 'Huntarr', icon: 'tablerExternalLink', href: 'https://github.com/plexguide/Huntarr.io' },
];
onNavItemClick(): void {
@@ -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 {
@@ -6,6 +6,9 @@
<span class="accordion__subtitle">{{ subtitle() }}</span>
}
</div>
@if (error()) {
<span class="accordion__error">{{ error() }}</span>
}
<ng-icon
[name]="expanded() ? 'tablerChevronDown' : 'tablerChevronRight'"
class="accordion__chevron"
@@ -49,6 +49,14 @@
color: var(--text-secondary);
}
&__error {
font-size: var(--font-size-xs);
color: var(--color-error);
margin-left: auto;
margin-right: var(--space-3);
flex-shrink: 0;
}
&__chevron {
font-size: 18px;
color: var(--text-tertiary);
@@ -12,6 +12,7 @@ import { NgIcon } from '@ng-icons/core';
export class AccordionComponent {
header = input.required<string>();
subtitle = input<string>();
error = input<string>();
expanded = model(false);
disabled = input(false);
@@ -2,7 +2,7 @@
<label class="input-label">
{{ label() }}
@if (helpKey()) {
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
<button type="button" class="field-help-btn" (click)="onHelpClick($event)" [attr.aria-label]="'Open documentation for ' + label()">
<ng-icon name="tablerQuestionMark" size="14" />
</button>
}
@@ -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,8 +21,8 @@
[(ngModel)]="value"
(blur)="blurred.emit($event)"
/>
@if (type() === 'password') {
<button class="input-eye-btn" (click)="toggleSecret($event)" tabindex="-1" aria-label="Toggle visibility">
@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);
}
+14
View File
@@ -56,6 +56,13 @@ Only the latest versions of the following applications are supported, unless exp
iconDark: useBaseUrl('/img/icons/whisparr-dark.svg'),
url: 'https://whisparr.com',
color: '#e83e8c'
},
{
name: 'Whisparr v3',
iconLight: useBaseUrl('/img/icons/whisparr-light.svg'),
iconDark: useBaseUrl('/img/icons/whisparr-dark.svg'),
url: 'https://whisparr.com',
color: '#e83e8c'
}
]}
/>
@@ -91,6 +98,13 @@ Only the latest versions of the following applications are supported, unless exp
iconDark: useBaseUrl('/img/icons/utorrent-dark.svg'),
url: 'https://www.utorrent.com',
color: '#0ce216ff'
},
{
name: 'rTorrent',
iconLight: useBaseUrl('/img/icons/rtorrent-light.svg'),
iconDark: useBaseUrl('/img/icons/rtorrent-dark.svg'),
url: 'https://rakshasa.github.io/rtorrent/',
color: '#888888'
}
]}
/>
@@ -11,7 +11,12 @@ import {
# Download Client
Configure download client connections for torrents. Cleanuparr supports qBittorrent, Deluge, Transmission, µTorrent and rTorrent download clients.
Configure download client connections for torrents. Cleanuparr supports the latest versions of the following download clients:
- qBittorrent
- Deluge
- Transmission
- µTorrent
- rTorrent
<ElementNavigator />
+1 -4
View File
@@ -160,10 +160,6 @@ SSL certificate validation for HTTPS connections.
Automatically search for replacements after removing downloads from *arr apps.
<Note>
If using [Huntarr](https://github.com/plexguide/Huntarr.io), disable this to let Huntarr handle searching.
</Note>
</ConfigSection>
<ConfigSection
@@ -196,6 +192,7 @@ Downloads matching these patterns are skipped during all cleaning operations. Ma
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- rtorrent label
- Tracker domain
**Examples:**
@@ -70,6 +70,7 @@ Downloads matching these patterns will be ignored by Malware Blocker. Patterns c
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- rtorrent label
- torrent tracker domain
**Examples:**
@@ -69,6 +69,7 @@ Downloads matching these patterns will be ignored by Queue Cleaner. Patterns can
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- rtorrent label
- torrent tracker domain
**Examples:**
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32.77 -37.14 284.03 284.03"><g transform="translate(-18.408128,237.867827) scale(0.100000,-0.100000)" stroke="none"><path d="M991 2364 c-128 -20 -376 -84 -497 -129 -203 -76 -284 -146 -285 -248 -2 -80 30 -121 133 -172 78 -39 98 -66 107 -146 10 -82 -4 -609 -19 -713 -17 -120 -33 -149 -111 -212 -113 -90 -149 -158 -130 -245 26 -114 135 -172 385 -205 162 -21 701 -15 845 10 145 25 222 68 260 145 66 132 9 237 -159 295 -161 56 -165 70 -165 521 0 287 2 323 18 352 36 66 93 57 185 -30 126 -118 207 -157 323 -157 225 0 415 151 474 375 56 217 -61 471 -252 546 -83 33 -252 33 -335 0 -80 -31 -169 -99 -219 -168 -65 -88 -101 -123 -125 -123 -37 0 -60 41 -72 132 -19 134 -28 158 -67 174 -43 18 -169 17 -294 -2z"/></g></svg>

After

Width:  |  Height:  |  Size: 760 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32.77 -37.14 284.03 284.03"><g transform="translate(-18.408128,237.867827) scale(0.100000,-0.100000)" fill="#fff" stroke="none"><path d="M991 2364 c-128 -20 -376 -84 -497 -129 -203 -76 -284 -146 -285 -248 -2 -80 30 -121 133 -172 78 -39 98 -66 107 -146 10 -82 -4 -609 -19 -713 -17 -120 -33 -149 -111 -212 -113 -90 -149 -158 -130 -245 26 -114 135 -172 385 -205 162 -21 701 -15 845 10 145 25 222 68 260 145 66 132 9 237 -159 295 -161 56 -165 70 -165 521 0 287 2 323 18 352 36 66 93 57 185 -30 126 -118 207 -157 323 -157 225 0 415 151 474 375 56 217 -61 471 -252 546 -83 33 -252 33 -335 0 -80 -31 -169 -99 -219 -168 -65 -88 -101 -123 -125 -123 -37 0 -60 41 -72 132 -19 134 -28 158 -67 174 -43 18 -169 17 -294 -2z"/></g></svg>

After

Width:  |  Height:  |  Size: 772 B