mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-10 20:41:22 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d1756fdd | ||
|
|
63931763c4 | ||
|
|
bdb956ec84 | ||
|
|
41b48d1104 | ||
|
|
57fef26726 | ||
|
|
ea94dc4548 | ||
|
|
13a7232bc5 | ||
|
|
5fea8a0041 | ||
|
|
2333c86a08 | ||
|
|
cbfc1b2875 | ||
|
|
62e10afe7b | ||
|
|
d542c716f9 | ||
|
|
7eeaefaa65 | ||
|
|
bd55356881 |
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"></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
|
||||
}
|
||||
+461
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
-9
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+6
@@ -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()
|
||||
|
||||
+24
-11
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
};
|
||||
|
||||
+20
-1
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -15,4 +15,6 @@ public record TestAppriseProviderRequest
|
||||
|
||||
// CLI mode fields
|
||||
public string? ServiceUrls { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -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; }
|
||||
}
|
||||
+2
@@ -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
-1
@@ -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; }
|
||||
}
|
||||
+2
@@ -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; }
|
||||
}
|
||||
+2
@@ -19,4 +19,6 @@ public record TestPushoverProviderRequest
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest
|
||||
public string? TopicId { get; init; }
|
||||
|
||||
public bool SendSilently { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+253
-20
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+2
@@ -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]
|
||||
|
||||
+3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+954
-1147
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({
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+18
-2
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+16
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
Reference in new issue
Block a user