Compare commits

...
16 Commits
Author SHA1 Message Date
Flaminel 33d1756fdd Revoke all refresh tokens on password change (#486) 2026-03-02 14:58:19 +02:00
Flaminel 63931763c4 Fix session invalidation for local auth bypass (#487) 2026-03-02 13:34:20 +02:00
Flaminel bdb956ec84 Add guards against exposing passwords to the UI (#477) 2026-03-02 13:11:34 +02:00
Flaminel 41b48d1104 Enforce authorize attribute explicitely (#485) 2026-03-02 13:00:23 +02:00
Flaminel 57fef26726 Add missing rTorrent and Whisparr v3 docs (#484) 2026-03-02 12:45:52 +02:00
Flaminel ea94dc4548 Fix reveal button being pressed on Enter instead of login (#483) 2026-03-02 12:41:35 +02:00
Flaminel 13a7232bc5 Update frontend packages (#478) 2026-02-28 04:16:11 +02:00
Flaminel 5fea8a0041 Add Docker multistep build (#475) 2026-02-27 20:42:27 +02:00
Flaminel 2333c86a08 Increase access token lifetime to 1 hour (#474) 2026-02-27 15:13:08 +02:00
Flaminel cbfc1b2875 Fix the Malware Blocker page not displaying validation errors (#473) 2026-02-25 22:28:49 +02:00
Flaminel 62e10afe7b Fix the Download Cleaner page not displaying validation errors (#472) 2026-02-25 22:20:49 +02:00
Flaminel d542c716f9 Add support for rootless Docker container (#471) 2026-02-24 19:03:01 +02:00
Flaminel 7eeaefaa65 Fix some auth endpoints not checking if user setup is complete (#468) 2026-02-23 20:33:22 +02:00
Flaminel bd55356881 Remove Huntarr because it's too hot right now (#467) 2026-02-23 19:11:08 +02:00
Flaminel df986a2e36 Redirect to base path from root path (#466) 2026-02-23 17:24:48 +02:00
Flaminel 8a2aca79f7 Add local auth bypass and make 2FA optional (#461) 2026-02-23 11:49:55 +02:00
110 changed files with 5108 additions and 1406 deletions

No files matched your search

+157 -53
View File
@@ -22,23 +22,32 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
jobs:
build_app:
# Compute tags, version, and push decision for downstream jobs
prepare:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
outputs:
tags: ${{ steps.build-info.outputs.tags }}
version: ${{ steps.build-info.outputs.version }}
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
branch: ${{ steps.build-info.outputs.branch }}
push: ${{ steps.build-info.outputs.push }}
github_sha: ${{ github.sha }}
steps:
- name: Set github context
timeout-minutes: 1
run: |
echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV
echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV
echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV
echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV
- name: Initialize build info
id: build-info
timeout-minutes: 1
run: |
githubHeadRef=${{ env.githubHeadRef }}
githubHeadRef="${{ github.head_ref }}"
githubRef="${{ github.ref }}"
inputVersion="${{ inputs.app_version }}"
latestDockerTag=""
versionDockerTag=""
@@ -71,10 +80,8 @@ jobs:
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
else
# Determine if this run is for the main branch or another branch
if [[ -z "$githubHeadRef" ]]; then
# Main branch
githubRef=${{ env.githubRef }}
branch=${githubRef##*/}
versionDockerTag="$branch"
else
@@ -85,25 +92,45 @@ jobs:
fi
githubTags=""
if [ -n "$latestDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
fi
if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
fi
if [ -n "$minorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
fi
if [ -n "$majorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
fi
# set env vars
echo "branch=$branch" >> $GITHUB_ENV
echo "githubTags=$githubTags" >> $GITHUB_ENV
echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV
echo "version=$version" >> $GITHUB_ENV
githubTags="${githubTags#,}"
# Determine push decision
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
echo "tags=$githubTags" >> $GITHUB_OUTPUT
echo "version=$version" >> $GITHUB_OUTPUT
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
echo "branch=$branch" >> $GITHUB_OUTPUT
echo "push=$push" >> $GITHUB_OUTPUT
# Build each platform in parallel
build:
runs-on: ubuntu-latest
needs: [prepare]
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare platform pair
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
@@ -113,8 +140,6 @@ jobs:
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/docker username | DOCKER_USERNAME;
secrets/data/docker password | DOCKER_PASSWORD;
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
@@ -122,16 +147,97 @@ jobs:
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ env.githubRepository }}
ref: ${{ env.branch }}
repository: ${{ github.repository }}
ref: ${{ needs.prepare.outputs.branch }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
timeout-minutes: 5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to GitHub Container Registry
if: needs.prepare.outputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
if: needs.prepare.outputs.push == 'true'
id: build-push
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Build (no push)
if: needs.prepare.outputs.push != 'true'
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
push: false
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
if: needs.prepare.outputs.push == 'true'
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build-push.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: needs.prepare.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Create multi-platform manifest and push with final tags
merge:
runs-on: ubuntu-latest
needs: [prepare, build]
if: needs.prepare.outputs.push == 'true'
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -140,27 +246,25 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push docker image
id: docker-build
timeout-minutes: 15
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ env.githubSha }}
version=${{ env.versionDockerTag }}
build-args: |
VERSION=${{ env.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: |
linux/amd64
linux/arm64
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
tags: |
${{ env.githubTags }}
# Enable BuildKit cache for faster builds
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create manifest list and push
timeout-minutes: 5
working-directory: ${{ runner.temp }}/digests
run: |
tags="${{ needs.prepare.outputs.tags }}"
tag_args=""
IFS=',' read -ra TAG_ARRAY <<< "$tags"
for tag in "${TAG_ARRAY[@]}"; do
tag=$(echo "$tag" | xargs)
if [ -n "$tag" ]; then
tag_args="$tag_args -t $tag"
fi
done
docker buildx imagetools create $tag_args \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
tags="${{ needs.prepare.outputs.tags }}"
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
docker buildx imagetools inspect "$first_tag"
+1 -1
View File
@@ -10,7 +10,7 @@
## Project Overview
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
**Key Features:**
- Strike system for bad downloads
+1 -8
View File
@@ -49,6 +49,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
- **Transmission**
- **Deluge**
- **µTorrent**
- **rTorrent**
### Platforms
- **Docker**
@@ -101,14 +102,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle">![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social)</span></span>
# Credits
Special thanks for inspiration go to:
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
@@ -178,6 +178,61 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(11)]
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(12)]
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(13)]
public async Task Setup_Complete_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(14)]
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
// Login endpoint must NOT be blocked by the middleware (403).
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(15)]
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
}
#region TOTP helpers
private static string _totpSecret = "";
@@ -0,0 +1,78 @@
using Cleanuparr.Shared.Helpers;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
public class SensitiveDataHelperTests
{
[Fact]
public void IsPlaceholder_WithPlaceholder_ReturnsTrue()
{
SensitiveDataHelper.Placeholder.IsPlaceholder().ShouldBeTrue();
}
[Fact]
public void IsPlaceholder_WithAppriseStyledPlaceholder_ReturnsTrue()
{
$"discord://{SensitiveDataHelper.Placeholder}".IsPlaceholder().ShouldBeTrue();
}
[Fact]
public void IsPlaceholder_WithNull_ReturnsFalse()
{
((string?)null).IsPlaceholder().ShouldBeFalse();
}
[Fact]
public void IsPlaceholder_WithEmptyString_ReturnsFalse()
{
"".IsPlaceholder().ShouldBeFalse();
}
[Fact]
public void IsPlaceholder_WithRealValue_ReturnsFalse()
{
"my-secret-api-key-123".IsPlaceholder().ShouldBeFalse();
}
[Theory]
[InlineData("discord://webhook_id/webhook_token", "discord://••••••••")]
[InlineData("slack://tokenA/tokenB/tokenC", "slack://••••••••")]
[InlineData("mailto://user:pass@gmail.com", "mailto://••••••••")]
[InlineData("json+http://user:pass@host/path", "json+http://••••••••")]
public void MaskAppriseUrls_SingleUrl_MasksCorrectly(string input, string expected)
{
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(expected);
}
[Fact]
public void MaskAppriseUrls_MultipleUrls_MasksAll()
{
var input = "discord://token1 slack://tokenA/tokenB";
var result = SensitiveDataHelper.MaskAppriseUrls(input);
result.ShouldContain("discord://••••••••");
result.ShouldContain("slack://••••••••");
result.ShouldNotContain("token1");
result.ShouldNotContain("tokenA");
}
[Fact]
public void MaskAppriseUrls_MultilineUrls_MasksAll()
{
var input = "discord://token1\nslack://tokenA/tokenB";
var result = SensitiveDataHelper.MaskAppriseUrls(input);
result.ShouldContain("discord://••••••••");
result.ShouldContain("slack://••••••••");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void MaskAppriseUrls_EmptyOrNull_ReturnsAsIs(string? input)
{
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(input);
}
}
@@ -0,0 +1,317 @@
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
using Shouldly;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
/// <summary>
/// Tests that placeholder values are correctly handled on the input side:
/// - UPDATE operations preserve the existing DB value when a placeholder is sent
/// - CREATE operations reject placeholder values
/// - TEST operations reject placeholder values
/// </summary>
public class SensitiveDataInputTests
{
private const string Placeholder = SensitiveDataHelper.Placeholder;
#region ArrInstanceRequest UPDATE
[Fact]
public void ArrInstanceRequest_ApplyTo_WithPlaceholderApiKey_PreservesExistingValue()
{
var request = new ArrInstanceRequest
{
Name = "Updated Sonarr",
Url = "http://sonarr:8989",
ApiKey = Placeholder,
Version = 4,
};
var existingInstance = new ArrInstance
{
Name = "Sonarr",
Url = new Uri("http://sonarr:8989"),
ApiKey = "original-secret-key",
ArrConfigId = Guid.NewGuid(),
Version = 4,
};
request.ApplyTo(existingInstance);
existingInstance.ApiKey.ShouldBe("original-secret-key");
existingInstance.Name.ShouldBe("Updated Sonarr");
}
[Fact]
public void ArrInstanceRequest_ApplyTo_WithRealApiKey_UpdatesValue()
{
var request = new ArrInstanceRequest
{
Name = "Sonarr",
Url = "http://sonarr:8989",
ApiKey = "brand-new-api-key",
Version = 4,
};
var existingInstance = new ArrInstance
{
Name = "Sonarr",
Url = new Uri("http://sonarr:8989"),
ApiKey = "original-secret-key",
ArrConfigId = Guid.NewGuid(),
Version = 4,
};
request.ApplyTo(existingInstance);
existingInstance.ApiKey.ShouldBe("brand-new-api-key");
}
#endregion
#region ArrInstanceRequest CREATE
[Fact]
public void ArrInstanceRequest_ToEntity_WithPlaceholderApiKey_ThrowsValidationException()
{
var request = new ArrInstanceRequest
{
Name = "Sonarr",
Url = "http://sonarr:8989",
ApiKey = Placeholder,
Version = 4,
};
Should.Throw<ValidationException>(() => request.ToEntity(Guid.NewGuid()));
}
[Fact]
public void ArrInstanceRequest_ToEntity_WithRealApiKey_Succeeds()
{
var request = new ArrInstanceRequest
{
Name = "Sonarr",
Url = "http://sonarr:8989",
ApiKey = "real-api-key-123",
Version = 4,
};
var entity = request.ToEntity(Guid.NewGuid());
entity.ApiKey.ShouldBe("real-api-key-123");
}
#endregion
#region TestArrInstanceRequest TEST
[Fact]
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndNoResolvedKey_ThrowsValidationException()
{
var request = new TestArrInstanceRequest
{
Url = "http://sonarr:8989",
ApiKey = Placeholder,
Version = 4,
};
Should.Throw<ValidationException>(() => request.ToTestInstance());
}
[Fact]
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndResolvedKey_UsesResolvedKey()
{
var request = new TestArrInstanceRequest
{
Url = "http://sonarr:8989",
ApiKey = Placeholder,
Version = 4,
InstanceId = Guid.NewGuid(),
};
var instance = request.ToTestInstance("resolved-api-key-from-db");
instance.ApiKey.ShouldBe("resolved-api-key-from-db");
}
[Fact]
public void TestArrInstanceRequest_ToTestInstance_WithRealApiKey_Succeeds()
{
var request = new TestArrInstanceRequest
{
Url = "http://sonarr:8989",
ApiKey = "real-api-key",
Version = 4,
};
var instance = request.ToTestInstance();
instance.ApiKey.ShouldBe("real-api-key");
}
#endregion
#region UpdateDownloadClientRequest UPDATE
[Fact]
public void UpdateDownloadClientRequest_ApplyTo_WithPlaceholderPassword_PreservesExistingValue()
{
var request = new UpdateDownloadClientRequest
{
Name = "Updated qBit",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Username = "admin",
Password = Placeholder,
};
var existing = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
Username = "admin",
Password = "original-secret-password",
};
var result = request.ApplyTo(existing);
result.Password.ShouldBe("original-secret-password");
result.Name.ShouldBe("Updated qBit");
}
[Fact]
public void UpdateDownloadClientRequest_ApplyTo_WithRealPassword_UpdatesValue()
{
var request = new UpdateDownloadClientRequest
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Username = "admin",
Password = "new-password-123",
};
var existing = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
Username = "admin",
Password = "original-secret-password",
};
var result = request.ApplyTo(existing);
result.Password.ShouldBe("new-password-123");
}
#endregion
#region CreateDownloadClientRequest CREATE
[Fact]
public void CreateDownloadClientRequest_Validate_WithPlaceholderPassword_ThrowsValidationException()
{
var request = new CreateDownloadClientRequest
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Password = Placeholder,
};
Should.Throw<ValidationException>(() => request.Validate());
}
[Fact]
public void CreateDownloadClientRequest_Validate_WithRealPassword_Succeeds()
{
var request = new CreateDownloadClientRequest
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Password = "real-password",
};
Should.NotThrow(() => request.Validate());
}
[Fact]
public void CreateDownloadClientRequest_Validate_WithNullPassword_Succeeds()
{
var request = new CreateDownloadClientRequest
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Password = null,
};
Should.NotThrow(() => request.Validate());
}
#endregion
#region TestDownloadClientRequest TEST
[Fact]
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndNoResolvedPassword_ThrowsValidationException()
{
var request = new TestDownloadClientRequest
{
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Password = Placeholder,
};
request.Validate();
Should.Throw<ValidationException>(() => request.ToTestConfig());
}
[Fact]
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndResolvedPassword_UsesResolvedPassword()
{
var request = new TestDownloadClientRequest
{
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Password = Placeholder,
ClientId = Guid.NewGuid(),
};
request.Validate();
var config = request.ToTestConfig("resolved-password-from-db");
config.Password.ShouldBe("resolved-password-from-db");
}
[Fact]
public void TestDownloadClientRequest_ToTestConfig_WithRealPassword_Succeeds()
{
var request = new TestDownloadClientRequest
{
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://qbit:8080",
Password = "real-password",
};
request.Validate();
var config = request.ToTestConfig();
config.Password.ShouldBe("real-password");
}
#endregion
}
@@ -0,0 +1,461 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Cleanuparr.Api.Json;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
/// <summary>
/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties
/// during JSON serialization — this is what controls the API response output.
/// </summary>
public class SensitiveDataResolverTests
{
private readonly JsonSerializerOptions _options;
private const string Placeholder = SensitiveDataHelper.Placeholder;
public SensitiveDataResolverTests()
{
_options = new JsonSerializerOptions
{
TypeInfoResolver = new SensitiveDataResolver(new DefaultJsonTypeInfoResolver()),
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
}
#region ArrInstance
[Fact]
public void ArrInstance_ApiKey_IsMasked()
{
var instance = new ArrInstance
{
Name = "Sonarr",
Url = new Uri("http://sonarr:8989"),
ApiKey = "super-secret-api-key-12345",
ArrConfigId = Guid.NewGuid(),
Version = 4
};
var json = JsonSerializer.Serialize(instance, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
}
[Fact]
public void ArrInstance_NonSensitiveFields_AreVisible()
{
var instance = new ArrInstance
{
Name = "Sonarr",
Url = new Uri("http://sonarr:8989"),
ExternalUrl = new Uri("https://sonarr.example.com"),
ApiKey = "super-secret-api-key-12345",
ArrConfigId = Guid.NewGuid(),
Version = 4
};
var json = JsonSerializer.Serialize(instance, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("name").GetString().ShouldBe("Sonarr");
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://sonarr:8989");
doc.RootElement.GetProperty("externalUrl").GetString().ShouldBe("https://sonarr.example.com");
}
[Fact]
public void ArrInstance_NullApiKey_RemainsNull()
{
// ApiKey is required, but let's test with the DTO which might handle null
var dto = new ArrInstanceDto
{
Name = "Sonarr",
Url = "http://sonarr:8989",
ApiKey = null!,
Version = 4
};
var json = JsonSerializer.Serialize(dto, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region ArrInstanceDto
[Fact]
public void ArrInstanceDto_ApiKey_IsMasked()
{
var dto = new ArrInstanceDto
{
Id = Guid.NewGuid(),
Name = "Radarr",
Url = "http://radarr:7878",
ApiKey = "dto-secret-api-key-67890",
Version = 5
};
var json = JsonSerializer.Serialize(dto, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("name").GetString().ShouldBe("Radarr");
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://radarr:7878");
}
#endregion
#region DownloadClientConfig
[Fact]
public void DownloadClientConfig_Password_IsMasked()
{
var config = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
Username = "admin",
Password = "my-secret-password",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
}
[Fact]
public void DownloadClientConfig_Username_IsVisible()
{
var config = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
Username = "admin",
Password = "my-secret-password",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("username").GetString().ShouldBe("admin");
doc.RootElement.GetProperty("name").GetString().ShouldBe("qBittorrent");
}
[Fact]
public void DownloadClientConfig_NullPassword_RemainsNull()
{
var config = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region NotifiarrConfig
[Fact]
public void NotifiarrConfig_ApiKey_IsMasked()
{
var config = new NotifiarrConfig
{
ApiKey = "notifiarr-api-key-secret",
ChannelId = "123456789"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("123456789");
}
#endregion
#region DiscordConfig
[Fact]
public void DiscordConfig_WebhookUrl_IsMasked()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456/secret-token",
Username = "Cleanuparr Bot",
AvatarUrl = "https://example.com/avatar.png"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("username").GetString().ShouldBe("Cleanuparr Bot");
doc.RootElement.GetProperty("avatarUrl").GetString().ShouldBe("https://example.com/avatar.png");
}
#endregion
#region TelegramConfig
[Fact]
public void TelegramConfig_BotToken_IsMasked()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCdefGHIjklmnoPQRstuvWXyz",
ChatId = "-1001234567890",
TopicId = "42",
SendSilently = true
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("botToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("chatId").GetString().ShouldBe("-1001234567890");
doc.RootElement.GetProperty("topicId").GetString().ShouldBe("42");
}
#endregion
#region NtfyConfig
[Fact]
public void NtfyConfig_PasswordAndAccessToken_AreMasked()
{
var config = new NtfyConfig
{
ServerUrl = "https://ntfy.example.com",
Topics = ["test-topic"],
AuthenticationType = NtfyAuthenticationType.BasicAuth,
Username = "ntfy-user",
Password = "ntfy-secret-password",
AccessToken = "ntfy-access-token-secret",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("accessToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://ntfy.example.com");
doc.RootElement.GetProperty("username").GetString().ShouldBe("ntfy-user");
}
[Fact]
public void NtfyConfig_NullPasswordAndAccessToken_RemainNull()
{
var config = new NtfyConfig
{
ServerUrl = "https://ntfy.example.com",
Topics = ["test-topic"],
AuthenticationType = NtfyAuthenticationType.None,
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
doc.RootElement.GetProperty("accessToken").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region PushoverConfig
[Fact]
public void PushoverConfig_ApiTokenAndUserKey_AreMasked()
{
var config = new PushoverConfig
{
ApiToken = "pushover-api-token-secret",
UserKey = "pushover-user-key-secret",
Priority = PushoverPriority.Normal,
Devices = ["iphone", "desktop"]
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("devices").GetArrayLength().ShouldBe(2);
}
#endregion
#region GotifyConfig
[Fact]
public void GotifyConfig_ApplicationToken_IsMasked()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "gotify-app-token-secret",
Priority = 5
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("applicationToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://gotify.example.com");
}
#endregion
#region AppriseConfig
[Fact]
public void AppriseConfig_Key_IsMasked_WithFullMask()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Api,
Url = "https://apprise.example.com",
Key = "apprise-config-key-secret",
Tags = "urgent",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("key").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("url").GetString().ShouldBe("https://apprise.example.com");
}
[Fact]
public void AppriseConfig_ServiceUrls_IsMasked_WithAppriseUrlMask()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
ServiceUrls = "discord://webhook_id/webhook_token slack://tokenA/tokenB/tokenC"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
var maskedUrls = doc.RootElement.GetProperty("serviceUrls").GetString();
maskedUrls.ShouldContain("discord://••••••••");
maskedUrls.ShouldContain("slack://••••••••");
maskedUrls.ShouldNotContain("webhook_id");
maskedUrls.ShouldNotContain("webhook_token");
maskedUrls.ShouldNotContain("tokenA");
}
[Fact]
public void AppriseConfig_NullServiceUrls_RemainsNull()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Api,
Url = "https://apprise.example.com",
Key = "some-key",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("serviceUrls").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region Polymorphic serialization (as used in NotificationProviderResponse)
[Fact]
public void PolymorphicSerialization_NotifiarrConfig_StillMasked()
{
// The notification providers endpoint casts configs to `object`.
// Verify that the resolver still masks when serializing as a concrete type at runtime.
object config = new NotifiarrConfig
{
ApiKey = "my-secret-notifiarr-key",
ChannelId = "987654321"
};
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("987654321");
}
[Fact]
public void PolymorphicSerialization_DiscordConfig_StillMasked()
{
object config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123/secret",
Username = "Bot"
};
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("username").GetString().ShouldBe("Bot");
}
#endregion
#region Edge cases
[Fact]
public void EmptySensitiveString_IsMasked_NotReturnedEmpty()
{
var config = new NotifiarrConfig
{
ApiKey = "",
ChannelId = "123"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
// Even empty strings get masked to the placeholder
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
}
[Fact]
public void MultipleSensitiveFields_AllMasked()
{
var config = new PushoverConfig
{
ApiToken = "token-abc-123",
UserKey = "user-key-xyz-789",
Priority = PushoverPriority.High,
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
}
#endregion
}
@@ -0,0 +1,192 @@
using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Api.Auth;
public static class TrustedNetworkAuthenticationDefaults
{
public const string AuthenticationScheme = "TrustedNetwork";
}
public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TrustedNetworkAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Load auth config from database
await using var dataContext = DataContext.CreateStaticInstance();
var config = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
if (config is null || !config.Auth.DisableAuthForLocalAddresses)
{
return AuthenticateResult.NoResult();
}
// Determine client IP
var clientIp = GetClientIp(config.Auth.TrustForwardedHeaders);
if (clientIp is null)
{
return AuthenticateResult.NoResult();
}
// Check if the client IP is trusted
if (!IsTrustedAddress(clientIp, config.Auth.TrustedNetworks))
{
return AuthenticateResult.NoResult();
}
// Load the admin user
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.SetupCompleted);
if (user is null)
{
return AuthenticateResult.NoResult();
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("auth_method", "trusted_network")
};
var identity = new ClaimsIdentity(claims, TrustedNetworkAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, TrustedNetworkAuthenticationDefaults.AuthenticationScheme);
return AuthenticateResult.Success(ticket);
}
private IPAddress? GetClientIp(bool trustForwardedHeaders) =>
ResolveClientIp(Context, trustForwardedHeaders);
public static IPAddress? ResolveClientIp(HttpContext httpContext, bool trustForwardedHeaders)
{
var remoteIp = httpContext.Connection.RemoteIpAddress;
if (remoteIp is null)
{
return null;
}
// Only trust forwarded headers if the direct connection is from a local address
if (trustForwardedHeaders && remoteIp.IsLocalAddress())
{
// Check X-Forwarded-For first, then X-Real-IP
var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(forwardedFor))
{
// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
// The first one is the original client
var firstIp = forwardedFor.Split(',')[0].Trim();
if (IPAddress.TryParse(firstIp, out var parsedIp))
{
return parsedIp;
}
}
var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault();
if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out var realParsedIp))
{
return realParsedIp;
}
}
return remoteIp;
}
public static bool IsTrustedAddress(IPAddress clientIp, List<string> trustedNetworks)
{
// Normalize IPv4-mapped IPv6 addresses
if (clientIp.IsIPv4MappedToIPv6)
{
clientIp = clientIp.MapToIPv4();
}
// Check if it's a local address (built-in ranges)
if (clientIp.IsLocalAddress())
{
return true;
}
// Check against custom trusted networks
foreach (var network in trustedNetworks)
{
if (MatchesCidr(clientIp, network))
{
return true;
}
}
return false;
}
public static bool MatchesCidr(IPAddress address, string cidr)
{
if (cidr.Contains('/'))
{
var parts = cidr.Split('/');
if (!IPAddress.TryParse(parts[0], out var networkAddress) ||
!int.TryParse(parts[1], out var prefixLength))
{
return false;
}
// Normalize both addresses
if (networkAddress.IsIPv4MappedToIPv6)
networkAddress = networkAddress.MapToIPv4();
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();
// Must be same address family
if (address.AddressFamily != networkAddress.AddressFamily)
return false;
var addressBytes = address.GetAddressBytes();
var networkBytes = networkAddress.GetAddressBytes();
// Compare bytes up to prefix length
var fullBytes = prefixLength / 8;
var remainingBits = prefixLength % 8;
for (var i = 0; i < fullBytes && i < addressBytes.Length; i++)
{
if (addressBytes[i] != networkBytes[i])
return false;
}
if (remainingBits > 0 && fullBytes < addressBytes.Length)
{
var mask = (byte)(0xFF << (8 - remainingBits));
if ((addressBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask))
return false;
}
return true;
}
// Plain IP match
if (!IPAddress.TryParse(cidr, out var singleIp))
return false;
if (singleIp.IsIPv4MappedToIPv6)
singleIp = singleIp.MapToIPv4();
return address.Equals(singleIp);
}
}
@@ -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
@@ -29,7 +29,16 @@ public static class AuthDI
return ApiKeyAuthenticationDefaults.AuthenticationScheme;
}
return JwtBearerDefaults.AuthenticationScheme;
// Check for Bearer token or SignalR access_token
if (context.Request.Headers.ContainsKey("Authorization") ||
(context.Request.Path.StartsWithSegments("/api/hubs") &&
context.Request.Query.ContainsKey("access_token")))
{
return JwtBearerDefaults.AuthenticationScheme;
}
// Fall through to trusted network handler (returns NoResult if disabled)
return TrustedNetworkAuthenticationDefaults.AuthenticationScheme;
};
})
.AddJwtBearer(options =>
@@ -64,7 +73,9 @@ public static class AuthDI
};
})
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { });
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
.AddScheme<AuthenticationSchemeOptions, TrustedNetworkAuthenticationHandler>(
TrustedNetworkAuthenticationDefaults.AuthenticationScheme, _ => { });
services.AddAuthorization(options =>
{
@@ -1,7 +1,9 @@
using System;
using System.ComponentModel.DataAnnotations;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
@@ -23,16 +25,24 @@ public sealed record ArrInstanceRequest
public string? ExternalUrl { get; init; }
public ArrInstance ToEntity(Guid configId) => new()
public ArrInstance ToEntity(Guid configId)
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
};
if (ApiKey.IsPlaceholder())
{
throw new ValidationException("API key is required when creating a new instance");
}
return new()
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
};
}
public void ApplyTo(ArrInstance instance)
{
@@ -40,7 +50,7 @@ public sealed record ArrInstanceRequest
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
instance.ApiKey = ApiKey;
instance.ApiKey = ApiKey.IsPlaceholder() ? instance.ApiKey : ApiKey;
instance.Version = Version;
}
}
@@ -1,7 +1,9 @@
using System;
using System.ComponentModel.DataAnnotations;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
@@ -12,17 +14,29 @@ public sealed record TestArrInstanceRequest
[Required]
public required string ApiKey { get; init; }
[Required]
public required float Version { get; init; }
public ArrInstance ToTestInstance() => new()
public Guid? InstanceId { get; init; }
public ArrInstance ToTestInstance(string? resolvedApiKey = null)
{
Enabled = true,
Name = "Test Instance",
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = Guid.Empty,
Version = Version,
};
var apiKey = resolvedApiKey ?? ApiKey;
if (apiKey.IsPlaceholder())
{
throw new ValidationException("API key cannot be a placeholder value");
}
return new()
{
Enabled = true,
Name = "Test Instance",
Url = new Uri(Url),
ApiKey = apiKey,
ArrConfigId = Guid.Empty,
Version = Version,
};
}
}
@@ -3,7 +3,9 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Shared.Helpers;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -11,6 +13,7 @@ namespace Cleanuparr.Api.Features.Arr.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
@@ -282,7 +285,23 @@ public sealed class ArrConfigController : ControllerBase
{
try
{
var testInstance = request.ToTestInstance();
string? resolvedApiKey = null;
if (request.ApiKey.IsPlaceholder() && request.InstanceId.HasValue)
{
var existingInstance = await _dataContext.ArrInstances
.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == request.InstanceId.Value);
if (existingInstance is null)
{
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
}
resolvedApiKey = existingInstance.ApiKey;
}
var testInstance = request.ToTestInstance(resolvedApiKey);
var client = _arrClientFactory.GetClient(type, request.Version);
await client.HealthCheckAsync(testInstance);
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record Disable2faRequest
{
[Required]
public required string Password { get; init; }
[Required]
[StringLength(6, MinimumLength = 6)]
public required string TotpCode { get; init; }
}
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record Enable2faRequest
{
[Required]
public required string Password { get; init; }
}
@@ -4,4 +4,5 @@ public sealed record AuthStatusResponse
{
public required bool SetupCompleted { get; init; }
public bool PlexLinked { get; init; }
public bool AuthBypassActive { get; init; }
}
@@ -4,4 +4,5 @@ public sealed record LoginResponse
{
public required bool RequiresTwoFactor { get; init; }
public string? LoginToken { get; init; }
public TokenResponse? Tokens { get; init; }
}
@@ -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);
@@ -139,6 +152,145 @@ public sealed class AccountController : ControllerBase
}
}
[HttpPost("2fa/enable")]
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null) return Unauthorized();
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
// Generate new TOTP
var secret = _totpService.GenerateSecret();
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
// Replace any existing recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
foreach (var code in recoveryCodes)
{
_usersContext.RecoveryCodes.Add(new RecoveryCode
{
Id = Guid.NewGuid(),
UserId = user.Id,
CodeHash = _totpService.HashRecoveryCode(code),
IsUsed = false
});
}
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
return Ok(new TotpSetupResponse
{
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
});
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("2fa/enable/verify")]
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return BadRequest(new { error = "Invalid verification code" });
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
return Ok(new { message = "2FA enabled" });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("2fa/disable")]
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null) return Unauthorized();
if (!user.TotpEnabled)
{
return BadRequest(new { error = "2FA is not enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
}
user.TotpEnabled = false;
user.TotpSecret = string.Empty;
user.UpdatedAt = DateTime.UtcNow;
// Remove all recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
return Ok(new { message = "2FA disabled" });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpGet("api-key")]
public async Task<IActionResult> GetApiKey()
{
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using Cleanuparr.Api.Auth;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Infrastructure.Features.Auth;
@@ -43,10 +44,25 @@ public sealed class AuthController : ControllerBase
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
var authBypass = false;
await using var dataContext = DataContext.CreateStaticInstance();
var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
{
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
HttpContext, generalConfig.Auth.TrustForwardedHeaders);
if (clientIp is not null)
{
authBypass = TrustedNetworkAuthenticationHandler.IsTrustedAddress(
clientIp, generalConfig.Auth.TrustedNetworks);
}
}
return Ok(new AuthStatusResponse
{
SetupCompleted = user is { SetupCompleted: true },
PlexLinked = user?.PlexAccountId is not null
PlexLinked = user?.PlexAccountId is not null,
AuthBypassActive = authBypass
});
}
@@ -103,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
@@ -160,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" });
@@ -196,9 +217,9 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (!user.TotpEnabled)
if (user.SetupCompleted)
{
return BadRequest(new { error = "2FA must be configured before completing setup" });
return Conflict(new { error = "Setup already completed" });
}
user.SetupCompleted = true;
@@ -242,6 +263,22 @@ public sealed class AuthController : ControllerBase
// Reset failed attempts on successful password verification
await ResetFailedAttempts(user.Id);
// If 2FA is not enabled, issue tokens directly
if (!user.TotpEnabled)
{
// Re-fetch with tracking since the query above used AsNoTracking
var trackedUser = await _usersContext.Users.FirstAsync(u => u.Id == user.Id);
var tokenResponse = await GenerateTokenResponse(trackedUser);
_logger.LogInformation("User {Username} logged in (2FA disabled)", user.Username);
return Ok(new LoginResponse
{
RequiresTwoFactor = false,
Tokens = tokenResponse
});
}
// Password valid - require 2FA
var loginToken = _jwtService.GenerateLoginToken(user.Id);
@@ -355,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
@@ -385,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;
@@ -445,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);
@@ -477,7 +527,7 @@ public sealed class AuthController : ControllerBase
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = 60 // seconds
ExpiresIn = 3600 // seconds
};
}
@@ -6,6 +6,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -14,6 +15,7 @@ namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class BlacklistSyncConfigController : ControllerBase
{
private readonly ILogger<BlacklistSyncConfigController> _logger;
@@ -9,6 +9,7 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -17,6 +18,7 @@ namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class DownloadCleanerConfigController : ControllerBase
{
private readonly ILogger<DownloadCleanerConfigController> _logger;
@@ -3,6 +3,7 @@ using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
@@ -47,6 +48,11 @@ public sealed record CreateDownloadClientRequest
{
throw new ValidationException("External URL is not a valid URL");
}
if (Password.IsPlaceholder())
{
throw new ValidationException("Password cannot be a placeholder value");
}
}
public DownloadClientConfig ToEntity() => new()
@@ -3,6 +3,7 @@ using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
@@ -20,6 +21,8 @@ public sealed record TestDownloadClientRequest
public string? UrlBase { get; init; }
public Guid? ClientId { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Host))
@@ -33,16 +36,26 @@ public sealed record TestDownloadClientRequest
}
}
public DownloadClientConfig ToTestConfig() => new()
public DownloadClientConfig ToTestConfig(string? resolvedPassword = null)
{
Id = Guid.NewGuid(),
Enabled = true,
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
var password = resolvedPassword ?? Password;
if (password.IsPlaceholder())
{
throw new ValidationException("Password cannot be a placeholder value");
}
return new()
{
Id = Guid.NewGuid(),
Enabled = true,
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = password,
UrlBase = UrlBase,
};
}
}
@@ -3,6 +3,7 @@ using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
@@ -57,7 +58,7 @@ public sealed record UpdateDownloadClientRequest
Type = Type,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
Password = Password.IsPlaceholder() ? existing.Password : Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
@@ -5,6 +5,8 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -12,6 +14,7 @@ namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class DownloadClientController : ControllerBase
{
private readonly ILogger<DownloadClientController> _logger;
@@ -156,7 +159,23 @@ public sealed class DownloadClientController : ControllerBase
{
request.Validate();
var testConfig = request.ToTestConfig();
string? resolvedPassword = null;
if (request.Password.IsPlaceholder() && request.ClientId.HasValue)
{
var existingClient = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == request.ClientId.Value);
if (existingClient is null)
{
return NotFound($"Download client with ID {request.ClientId.Value} not found");
}
resolvedPassword = existingClient.Password;
}
var testConfig = request.ToTestConfig(resolvedPassword);
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
var healthResult = await downloadService.HealthCheckAsync();
@@ -34,6 +34,8 @@ public sealed record UpdateGeneralConfigRequest
public UpdateLoggingConfigRequest Log { get; init; } = new();
public UpdateAuthConfigRequest Auth { get; init; } = new();
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
{
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
@@ -49,6 +51,7 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
Auth.ApplyTo(existingConfig.Auth);
Validate(existingConfig);
@@ -75,6 +78,7 @@ public sealed record UpdateGeneralConfigRequest
}
config.Log.Validate();
config.Auth.Validate();
}
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
@@ -145,3 +149,19 @@ public sealed record UpdateLoggingConfigRequest
public bool LevelOnlyChange { get; private set; }
}
public sealed record UpdateAuthConfigRequest
{
public bool DisableAuthForLocalAddresses { get; init; }
public bool TrustForwardedHeaders { get; init; }
public List<string> TrustedNetworks { get; init; } = [];
public void ApplyTo(AuthConfig existingConfig)
{
existingConfig.DisableAuthForLocalAddresses = DisableAuthForLocalAddresses;
existingConfig.TrustForwardedHeaders = TrustForwardedHeaders;
existingConfig.TrustedNetworks = TrustedNetworks;
}
}
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -12,6 +13,7 @@ namespace Cleanuparr.Api.Features.General.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class GeneralConfigController : ControllerBase
{
private readonly ILogger<GeneralConfigController> _logger;
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -16,6 +17,7 @@ namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class MalwareBlockerConfigController : ControllerBase
{
private readonly ILogger<MalwareBlockerConfigController> _logger;
@@ -15,4 +15,6 @@ public record TestAppriseProviderRequest
// CLI mode fields
public string? ServiceUrls { get; init; }
public Guid? ProviderId { get; init; }
}
@@ -7,4 +7,6 @@ public record TestDiscordProviderRequest
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
public Guid? ProviderId { get; init; }
}
@@ -7,4 +7,6 @@ public record TestGotifyProviderRequest
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
public Guid? ProviderId { get; init; }
}
@@ -3,6 +3,8 @@ namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestNotifiarrProviderRequest
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
public Guid? ProviderId { get; init; }
}
@@ -19,4 +19,6 @@ public record TestNtfyProviderRequest
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
public Guid? ProviderId { get; init; }
}
@@ -19,4 +19,6 @@ public record TestPushoverProviderRequest
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
public Guid? ProviderId { get; init; }
}
@@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
public Guid? ProviderId { get; init; }
}
@@ -11,6 +11,8 @@ using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -18,6 +20,7 @@ namespace Cleanuparr.Api.Features.Notifications.Controllers;
[ApiController]
[Route("api/configuration/notification_providers")]
[Authorize]
public sealed class NotificationProvidersController : ControllerBase
{
private readonly ILogger<NotificationProvidersController> _logger;
@@ -127,6 +130,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.ApiKey.IsPlaceholder())
{
return BadRequest("API key cannot be a placeholder value");
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = newProvider.ApiKey,
@@ -184,6 +192,16 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.Key.IsPlaceholder())
{
return BadRequest("Key cannot be a placeholder value");
}
if (newProvider.ServiceUrls.IsPlaceholder())
{
return BadRequest("Service URLs cannot be a placeholder value");
}
var appriseConfig = new AppriseConfig
{
Mode = newProvider.Mode,
@@ -248,6 +266,16 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.Password.IsPlaceholder())
{
return BadRequest("Password cannot be a placeholder value");
}
if (newProvider.AccessToken.IsPlaceholder())
{
return BadRequest("Access token cannot be a placeholder value");
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = newProvider.ServerUrl,
@@ -315,6 +343,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.BotToken.IsPlaceholder())
{
return BadRequest("Bot token cannot be a placeholder value");
}
var telegramConfig = new TelegramConfig
{
BotToken = newProvider.BotToken,
@@ -392,7 +425,9 @@ public sealed class NotificationProvidersController : ControllerBase
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = updatedProvider.ApiKey,
ApiKey = updatedProvider.ApiKey.IsPlaceholder()
? existingProvider.NotifiarrConfiguration!.ApiKey
: updatedProvider.ApiKey,
ChannelId = updatedProvider.ChannelId
};
@@ -473,9 +508,13 @@ public sealed class NotificationProvidersController : ControllerBase
{
Mode = updatedProvider.Mode,
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Key = updatedProvider.Key.IsPlaceholder()
? existingProvider.AppriseConfiguration!.Key
: updatedProvider.Key,
Tags = updatedProvider.Tags,
ServiceUrls = updatedProvider.ServiceUrls
ServiceUrls = updatedProvider.ServiceUrls.IsPlaceholder()
? existingProvider.AppriseConfiguration!.ServiceUrls
: updatedProvider.ServiceUrls
};
if (existingProvider.AppriseConfiguration != null)
@@ -557,8 +596,12 @@ public sealed class NotificationProvidersController : ControllerBase
Topics = updatedProvider.Topics,
AuthenticationType = updatedProvider.AuthenticationType,
Username = updatedProvider.Username,
Password = updatedProvider.Password,
AccessToken = updatedProvider.AccessToken,
Password = updatedProvider.Password.IsPlaceholder()
? existingProvider.NtfyConfiguration!.Password
: updatedProvider.Password,
AccessToken = updatedProvider.AccessToken.IsPlaceholder()
? existingProvider.NtfyConfiguration!.AccessToken
: updatedProvider.AccessToken,
Priority = updatedProvider.Priority,
Tags = updatedProvider.Tags
};
@@ -638,7 +681,9 @@ public sealed class NotificationProvidersController : ControllerBase
var telegramConfig = new TelegramConfig
{
BotToken = updatedProvider.BotToken,
BotToken = updatedProvider.BotToken.IsPlaceholder()
? existingProvider.TelegramConfiguration!.BotToken
: updatedProvider.BotToken,
ChatId = updatedProvider.ChatId,
TopicId = updatedProvider.TopicId,
SendSilently = updatedProvider.SendSilently
@@ -735,9 +780,24 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var apiKey = testRequest.ApiKey;
if (apiKey.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<NotifiarrConfig>(
testRequest.ProviderId, NotificationProviderType.Notifiarr, p => p.NotifiarrConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "API key cannot be a placeholder value" });
}
apiKey = existing.ApiKey;
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = testRequest.ApiKey,
ApiKey = apiKey,
ChannelId = testRequest.ChannelId
};
notifiarrConfig.Validate();
@@ -775,13 +835,37 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var key = testRequest.Key;
var serviceUrls = testRequest.ServiceUrls;
if (key.IsPlaceholder() || serviceUrls.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<AppriseConfig>(
testRequest.ProviderId, NotificationProviderType.Apprise, p => p.AppriseConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
}
if (key.IsPlaceholder())
{
key = existing.Key;
}
if (serviceUrls.IsPlaceholder())
{
serviceUrls = existing.ServiceUrls;
}
}
var appriseConfig = new AppriseConfig
{
Mode = testRequest.Mode,
Url = testRequest.Url,
Key = testRequest.Key,
Key = key,
Tags = testRequest.Tags,
ServiceUrls = testRequest.ServiceUrls
ServiceUrls = serviceUrls
};
appriseConfig.Validate();
@@ -822,14 +906,38 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var password = testRequest.Password;
var accessToken = testRequest.AccessToken;
if (password.IsPlaceholder() || accessToken.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<NtfyConfig>(
testRequest.ProviderId, NotificationProviderType.Ntfy, p => p.NtfyConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
}
if (password.IsPlaceholder())
{
password = existing.Password;
}
if (accessToken.IsPlaceholder())
{
accessToken = existing.AccessToken;
}
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = testRequest.ServerUrl,
Topics = testRequest.Topics,
AuthenticationType = testRequest.AuthenticationType,
Username = testRequest.Username,
Password = testRequest.Password,
AccessToken = testRequest.AccessToken,
Password = password,
AccessToken = accessToken,
Priority = testRequest.Priority,
Tags = testRequest.Tags
};
@@ -868,9 +976,24 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var botToken = testRequest.BotToken;
if (botToken.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<TelegramConfig>(
testRequest.ProviderId, NotificationProviderType.Telegram, p => p.TelegramConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Bot token cannot be a placeholder value" });
}
botToken = existing.BotToken;
}
var telegramConfig = new TelegramConfig
{
BotToken = testRequest.BotToken,
BotToken = botToken,
ChatId = testRequest.ChatId,
TopicId = testRequest.TopicId,
SendSilently = testRequest.SendSilently
@@ -958,6 +1081,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.WebhookUrl.IsPlaceholder())
{
return BadRequest("Webhook URL cannot be a placeholder value");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = newProvider.WebhookUrl,
@@ -1034,7 +1162,9 @@ public sealed class NotificationProvidersController : ControllerBase
var discordConfig = new DiscordConfig
{
WebhookUrl = updatedProvider.WebhookUrl,
WebhookUrl = updatedProvider.WebhookUrl.IsPlaceholder()
? existingProvider.DiscordConfiguration!.WebhookUrl
: updatedProvider.WebhookUrl,
Username = updatedProvider.Username,
AvatarUrl = updatedProvider.AvatarUrl
};
@@ -1088,9 +1218,24 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var webhookUrl = testRequest.WebhookUrl;
if (webhookUrl.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<DiscordConfig>(
testRequest.ProviderId, NotificationProviderType.Discord, p => p.DiscordConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Webhook URL cannot be a placeholder value" });
}
webhookUrl = existing.WebhookUrl;
}
var discordConfig = new DiscordConfig
{
WebhookUrl = testRequest.WebhookUrl,
WebhookUrl = webhookUrl,
Username = testRequest.Username,
AvatarUrl = testRequest.AvatarUrl
};
@@ -1146,6 +1291,16 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.ApiToken.IsPlaceholder())
{
return BadRequest("API token cannot be a placeholder value");
}
if (newProvider.UserKey.IsPlaceholder())
{
return BadRequest("User key cannot be a placeholder value");
}
var pushoverConfig = new PushoverConfig
{
ApiToken = newProvider.ApiToken,
@@ -1227,8 +1382,12 @@ public sealed class NotificationProvidersController : ControllerBase
var pushoverConfig = new PushoverConfig
{
ApiToken = updatedProvider.ApiToken,
UserKey = updatedProvider.UserKey,
ApiToken = updatedProvider.ApiToken.IsPlaceholder()
? existingProvider.PushoverConfiguration!.ApiToken
: updatedProvider.ApiToken,
UserKey = updatedProvider.UserKey.IsPlaceholder()
? existingProvider.PushoverConfiguration!.UserKey
: updatedProvider.UserKey,
Devices = updatedProvider.Devices,
Priority = updatedProvider.Priority,
Sound = updatedProvider.Sound,
@@ -1286,10 +1445,34 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var apiToken = testRequest.ApiToken;
var userKey = testRequest.UserKey;
if (apiToken.IsPlaceholder() || userKey.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<PushoverConfig>(
testRequest.ProviderId, NotificationProviderType.Pushover, p => p.PushoverConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
}
if (apiToken.IsPlaceholder())
{
apiToken = existing.ApiToken;
}
if (userKey.IsPlaceholder())
{
userKey = existing.UserKey;
}
}
var pushoverConfig = new PushoverConfig
{
ApiToken = testRequest.ApiToken,
UserKey = testRequest.UserKey,
ApiToken = apiToken,
UserKey = userKey,
Devices = testRequest.Devices,
Priority = testRequest.Priority,
Sound = testRequest.Sound,
@@ -1344,6 +1527,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.ApplicationToken.IsPlaceholder())
{
return BadRequest("Application token cannot be a placeholder value");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = newProvider.ServerUrl,
@@ -1421,7 +1609,9 @@ public sealed class NotificationProvidersController : ControllerBase
var gotifyConfig = new GotifyConfig
{
ServerUrl = updatedProvider.ServerUrl,
ApplicationToken = updatedProvider.ApplicationToken,
ApplicationToken = updatedProvider.ApplicationToken.IsPlaceholder()
? existingProvider.GotifyConfiguration!.ApplicationToken
: updatedProvider.ApplicationToken,
Priority = updatedProvider.Priority
};
@@ -1474,10 +1664,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var applicationToken = testRequest.ApplicationToken;
if (applicationToken.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<GotifyConfig>(
testRequest.ProviderId, NotificationProviderType.Gotify, p => p.GotifyConfiguration);
if (existing is null)
return BadRequest(new { Message = "Application token cannot be a placeholder value" });
applicationToken = existing.ApplicationToken;
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = testRequest.ServerUrl,
ApplicationToken = testRequest.ApplicationToken,
ApplicationToken = applicationToken,
Priority = testRequest.Priority
};
gotifyConfig.Validate();
@@ -1514,4 +1717,34 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
private async Task<T?> GetExistingProviderConfig<T>(
Guid? providerId,
NotificationProviderType expectedType,
Func<NotificationConfig, T?> configSelector) where T : class
{
if (!providerId.HasValue)
{
return null;
}
IQueryable<NotificationConfig> query = _dataContext.NotificationConfigs.AsNoTracking();
query = expectedType switch
{
NotificationProviderType.Notifiarr => query.Include(p => p.NotifiarrConfiguration),
NotificationProviderType.Apprise => query.Include(p => p.AppriseConfiguration),
NotificationProviderType.Ntfy => query.Include(p => p.NtfyConfiguration),
NotificationProviderType.Pushover => query.Include(p => p.PushoverConfiguration),
NotificationProviderType.Telegram => query.Include(p => p.TelegramConfiguration),
NotificationProviderType.Discord => query.Include(p => p.DiscordConfiguration),
NotificationProviderType.Gotify => query.Include(p => p.GotifyConfiguration),
_ => query
};
var provider = await query
.FirstOrDefaultAsync(p => p.Id == providerId.Value && p.Type == expectedType);
return provider is null ? null : configSelector(provider);
}
}
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -15,6 +16,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class QueueCleanerConfigController : ControllerBase
{
private readonly ILogger<QueueCleanerConfigController> _logger;
@@ -3,6 +3,7 @@ using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -11,6 +12,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/queue-rules")]
[Authorize]
public class QueueRulesController : ControllerBase
{
private readonly ILogger<QueueRulesController> _logger;
@@ -0,0 +1,70 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Cleanuparr.Shared.Attributes;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Json;
/// <summary>
/// JSON type info resolver that masks properties decorated with <see cref="SensitiveDataAttribute"/>
/// by replacing their serialized values with the appropriate placeholder during serialization.
/// </summary>
public sealed class SensitiveDataResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver _innerResolver;
public SensitiveDataResolver(IJsonTypeInfoResolver innerResolver)
{
_innerResolver = innerResolver;
}
public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
{
var typeInfo = _innerResolver.GetTypeInfo(type, options);
if (typeInfo?.Kind != JsonTypeInfoKind.Object)
return typeInfo;
foreach (var property in typeInfo.Properties)
{
if (property.AttributeProvider is not PropertyInfo propertyInfo)
continue;
var sensitiveAttr = propertyInfo.GetCustomAttribute<SensitiveDataAttribute>();
if (sensitiveAttr is null)
continue;
ApplyMasking(property, sensitiveAttr.Type);
}
return typeInfo;
}
private static void ApplyMasking(JsonPropertyInfo property, SensitiveDataType maskType)
{
var originalGet = property.Get;
if (originalGet is null)
{
return;
}
property.Get = maskType switch
{
SensitiveDataType.Full => obj =>
{
var value = originalGet(obj);
return value is string ? SensitiveDataHelper.Placeholder : value;
},
SensitiveDataType.AppriseUrl => obj =>
{
var value = originalGet(obj);
return value is string s ? SensitiveDataHelper.MaskAppriseUrls(s) : value;
},
_ => originalGet,
};
}
}
@@ -15,52 +15,75 @@ public class SetupGuardMiddleware
public async Task InvokeAsync(HttpContext context)
{
// Fast path: setup already completed
string path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
// Always allow health checks and non-API paths (static files, SPA, etc.)
if (path.StartsWith("/health") || !path.StartsWith("/api/"))
{
await _next(context);
return;
}
// Setup-only paths (/api/auth/setup/*) require setup to NOT be complete
if (IsSetupOnlyPath(path))
{
if (await IsSetupCompleted())
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup already completed" });
return;
}
await _next(context);
return;
}
// Non-setup auth paths (login, refresh, logout, status) are always allowed
if (path.StartsWith("/api/auth/") || path == "/api/auth")
{
await _next(context);
return;
}
// All other API paths require setup to be complete
if (!await IsSetupCompleted())
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
return;
}
await _next(context);
}
public void ResetSetupState()
{
_setupCompleted = false;
}
private async Task<bool> IsSetupCompleted()
{
if (_setupCompleted)
{
await _next(context);
return;
return true;
}
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
// Always allow these paths regardless of setup state
if (IsAllowedPath(path))
{
await _next(context);
return;
}
// Check database for setup completion
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is { SetupCompleted: true })
{
_setupCompleted = true;
await _next(context);
return;
return true;
}
// Setup not complete - block non-auth requests
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
return false;
}
/// <summary>
/// Resets the cached setup state. Call this if the user database is reset.
/// </summary>
public void ResetSetupState()
private static bool IsSetupOnlyPath(string path)
{
_setupCompleted = false;
}
private static bool IsAllowedPath(string path)
{
return path.StartsWith("/api/auth/")
|| path == "/api/auth"
|| path.StartsWith("/health")
|| !path.StartsWith("/api/");
return path.StartsWith("/api/auth/setup/") || path == "/api/auth/setup";
}
}
+8 -1
View File
@@ -131,6 +131,13 @@ if (basePath is not null)
{
if (!string.IsNullOrEmpty(basePath) && !context.Request.Path.StartsWithSegments(basePath, StringComparison.OrdinalIgnoreCase))
{
// Redirect root to the base path for convenience
if (!context.Request.Path.HasValue || context.Request.Path.Value == "/")
{
context.Response.Redirect(basePath + "/");
return;
}
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
@@ -174,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;
@@ -88,11 +88,20 @@ public class DataContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GeneralConfig>(entity =>
{
entity.ComplexProperty(e => e.Log, cp =>
{
cp.Property(l => l.Level).HasConversion<LowercaseEnumConverter<LogEventLevel>>();
})
);
});
entity.ComplexProperty(e => e.Auth, cp =>
{
cp.Property(a => a.TrustedNetworks)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
});
});
modelBuilder.Entity<QueueCleanerConfig>(entity =>
{
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAuthConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "auth_disable_auth_for_local_addresses",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "auth_trust_forwarded_headers",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "auth_trusted_networks",
table: "general_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "auth_disable_auth_for_local_addresses",
table: "general_configs");
migrationBuilder.DropColumn(
name: "auth_trust_forwarded_headers",
table: "general_configs");
migrationBuilder.DropColumn(
name: "auth_trusted_networks",
table: "general_configs");
}
}
}
@@ -319,6 +319,24 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("strike_inactivity_window_hours");
b.ComplexProperty(typeof(Dictionary<string, object>), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DisableAuthForLocalAddresses")
.HasColumnType("INTEGER")
.HasColumnName("auth_disable_auth_for_local_addresses");
b1.Property<bool>("TrustForwardedHeaders")
.HasColumnType("INTEGER")
.HasColumnName("auth_trust_forwarded_headers");
b1.Property<string>("TrustedNetworks")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("auth_trusted_networks");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
@@ -46,7 +46,6 @@ public sealed record DownloadClientConfig
/// <summary>
/// Username for authentication
/// </summary>
[SensitiveData]
public string? Username { get; init; }
/// <summary>
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Net;
using Cleanuparr.Domain.Exceptions;
namespace Cleanuparr.Persistence.Models.Configuration.General;
[ComplexType]
public sealed record AuthConfig : IConfig
{
public bool DisableAuthForLocalAddresses { get; set; }
public bool TrustForwardedHeaders { get; set; }
public List<string> TrustedNetworks { get; set; } = [];
public void Validate()
{
foreach (var entry in TrustedNetworks)
{
if (!IsValidIpOrCidr(entry))
{
throw new ValidationException($"Invalid IP address or CIDR range: {entry}");
}
}
}
private static bool IsValidIpOrCidr(string value)
{
// CIDR notation: 192.168.1.0/24
if (value.Contains('/'))
{
var parts = value.Split('/');
if (parts.Length != 2) return false;
if (!IPAddress.TryParse(parts[0], out _)) return false;
if (!int.TryParse(parts[1], out var prefix)) return false;
// IPv4: 0-32, IPv6: 0-128
var maxPrefix = parts[0].Contains(':') ? 128 : 32;
return prefix >= 0 && prefix <= maxPrefix;
}
// Plain IP address
return IPAddress.TryParse(value, out _);
}
}
@@ -36,6 +36,8 @@ public sealed record GeneralConfig : IConfig
public LoggingConfig Log { get; set; } = new();
public AuthConfig Auth { get; set; } = new();
public void Validate()
{
if (HttpTimeout is 0)
@@ -49,5 +51,6 @@ public sealed record GeneralConfig : IConfig
}
Log.Validate();
Auth.Validate();
}
}
@@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Attributes;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
@@ -29,6 +30,7 @@ public sealed record AppriseConfig : IConfig
public string Url { get; init; } = string.Empty;
[MaxLength(255)]
[SensitiveData]
public string Key { get; init; } = string.Empty;
[MaxLength(255)]
@@ -40,6 +42,7 @@ public sealed record AppriseConfig : IConfig
/// Example: discord://webhook_id/webhook_token
/// </summary>
[MaxLength(4000)]
[SensitiveData(SensitiveDataType.AppriseUrl)]
public string? ServiceUrls { get; init; }
[NotMapped]
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Attributes;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
@@ -19,6 +20,7 @@ public sealed record DiscordConfig : IConfig
[Required]
[MaxLength(500)]
[SensitiveData]
public string WebhookUrl { get; init; } = string.Empty;
[MaxLength(80)]
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Shared.Attributes;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
@@ -22,6 +23,7 @@ public sealed record GotifyConfig : IConfig
[Required]
[MaxLength(200)]
[SensitiveData]
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Attributes;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
@@ -19,6 +20,7 @@ public sealed record NotifiarrConfig : IConfig
[Required]
[MaxLength(255)]
[SensitiveData]
public string ApiKey { get; init; } = string.Empty;
[Required]
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Attributes;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
@@ -31,9 +32,11 @@ public sealed record NtfyConfig : IConfig
public string? Username { get; init; }
[MaxLength(255)]
[SensitiveData]
public string? Password { get; init; }
[MaxLength(500)]
[SensitiveData]
public string? AccessToken { get; init; }
[Required]
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Shared.Attributes;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
@@ -22,6 +23,7 @@ public sealed partial record PushoverConfig : IConfig
/// </summary>
[Required]
[MaxLength(50)]
[SensitiveData]
public string ApiToken { get; init; } = string.Empty;
/// <summary>
@@ -29,6 +31,7 @@ public sealed partial record PushoverConfig : IConfig
/// </summary>
[Required]
[MaxLength(50)]
[SensitiveData]
public string UserKey { get; init; } = string.Empty;
/// <summary>
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Attributes;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
@@ -20,6 +21,7 @@ public sealed record TelegramConfig : IConfig
[Required]
[MaxLength(255)]
[SensitiveData]
public string BotToken { get; init; } = string.Empty;
[Required]
@@ -1,9 +1,34 @@
namespace Cleanuparr.Shared.Attributes;
/// <summary>
/// Marks a property as containing sensitive data that should be encrypted when stored in configuration files.
/// Defines how sensitive data should be masked in API responses.
/// </summary>
public enum SensitiveDataType
{
/// <summary>
/// Full mask: replaces the entire value with bullets (••••••••).
/// Use for passwords, API keys, tokens, webhook URLs.
/// </summary>
Full,
/// <summary>
/// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••).
/// Use for Apprise service URL strings that contain multiple notification service URLs.
/// </summary>
AppriseUrl,
}
/// <summary>
/// Marks a property as containing sensitive data that should be masked in API responses
/// and preserved when the placeholder value is sent back in updates.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class SensitiveDataAttribute : Attribute
public class SensitiveDataAttribute : Attribute
{
public SensitiveDataType Type { get; }
public SensitiveDataAttribute(SensitiveDataType type = SensitiveDataType.Full)
{
Type = type;
}
}
@@ -0,0 +1,45 @@
using System;
using System.Text.RegularExpressions;
namespace Cleanuparr.Shared.Helpers;
/// <summary>
/// Helpers for sensitive data masking in API responses and request handling.
/// </summary>
public static partial class SensitiveDataHelper
{
/// <summary>
/// The placeholder string used to mask sensitive data in API responses.
/// When this value is detected in an update request, the existing DB value is preserved.
/// </summary>
public const string Placeholder = "••••••••";
/// <summary>
/// Returns true if the given value contains the sensitive data placeholder.
/// Uses Contains (not Equals) to handle Apprise URLs like "discord://••••••••".
/// </summary>
public static bool IsPlaceholder(this string? value)
=> value is not null && value.Contains(Placeholder, StringComparison.Ordinal);
/// <summary>
/// Masks Apprise service URLs by preserving only the scheme.
/// Input: "discord://token slack://tokenA/tokenB"
/// Output: "discord://•••••••• slack://••••••••"
/// </summary>
public static string? MaskAppriseUrls(string? serviceUrls)
{
if (string.IsNullOrWhiteSpace(serviceUrls))
{
return serviceUrls;
}
return AppriseUrlPattern().Replace(serviceUrls, match =>
{
var scheme = match.Groups[1].Value;
return $"{scheme}://{Placeholder}";
});
}
[GeneratedRegex(@"([a-zA-Z][a-zA-Z0-9+.\-]*)://\S+")]
private static partial Regex AppriseUrlPattern();
}
+30 -4
View File
@@ -1,6 +1,34 @@
#!/bin/bash
set -e
# Default UMASK if unset to prevent errors with set -e
UMASK="${UMASK:-022}"
CURRENT_UID=$(id -u)
# If not running as root, skip all user/permission management.
# This supports docker-compose `user: PUID:PGID` (rootless mode).
if [ "$CURRENT_UID" != "0" ]; then
umask "$UMASK"
# In rootless mode, the app uses /config for all writable state.
# A non-root user cannot create directories under /, so validate early.
if [ ! -d /config ]; then
echo "ERROR: /config does not exist and the container is running as non-root (UID $CURRENT_UID)." >&2
echo "Please mount a writable volume at /config." >&2
exit 1
fi
if [ ! -w /config ]; then
echo "ERROR: /config is not writable by UID $CURRENT_UID." >&2
echo "Please adjust permissions or mount /config as a writable volume." >&2
exit 1
fi
exec "$@"
fi
# Running as root — use PUID/PGID to create user and drop privileges
# Create group if it doesn't exist
if ! getent group "$PGID" > /dev/null 2>&1; then
echo "Creating group with GID $PGID"
@@ -16,16 +44,14 @@ fi
# Set umask
umask "$UMASK"
# Change ownership of app directory if not running as root
# Ensure /config is writable by the target user
if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
mkdir -p /config
chown -R "$PUID:$PGID" /app
chown -R "$PUID:$PGID" /config
fi
# Execute the main command as the specified user
# Execute as the specified user (or root if PUID=0)
if [ "$PUID" = "0" ] && [ "$PGID" = "0" ]; then
# Running as root, no need for gosu
exec "$@"
else
# Use gosu to drop privileges
+954 -1147
View File
File diff suppressed because it is too large. Load diff
@@ -52,6 +52,18 @@ export class AccountApi {
return this.http.post<TotpSetupResponse>('/api/account/2fa/regenerate', request);
}
enable2fa(password: string): Observable<TotpSetupResponse> {
return this.http.post<TotpSetupResponse>('/api/account/2fa/enable', { password });
}
verifyEnable2fa(code: string): Observable<void> {
return this.http.post<void>('/api/account/2fa/enable/verify', { code });
}
disable2fa(password: string, totpCode: string): Observable<void> {
return this.http.post<void>('/api/account/2fa/disable', { password, totpCode });
}
getApiKey(): Observable<{ apiKey: string }> {
return this.http.get<{ apiKey: string }>('/api/account/api-key');
}
@@ -6,11 +6,13 @@ import { Router } from '@angular/router';
export interface AuthStatus {
setupCompleted: boolean;
plexLinked: boolean;
authBypassActive?: boolean;
}
export interface LoginResponse {
requiresTwoFactor: boolean;
loginToken?: string;
tokens?: TokenResponse;
}
export interface TokenResponse {
@@ -60,6 +62,13 @@ export class AuthService {
this._isSetupComplete.set(status.setupCompleted);
this._plexLinked.set(status.plexLinked);
// Trusted network bypass — no tokens needed
if (status.authBypassActive && status.setupCompleted) {
this._isAuthenticated.set(true);
this._isLoading.set(false);
return;
}
const token = localStorage.getItem('access_token');
if (token && status.setupCompleted) {
if (this.isTokenExpired(60)) {
@@ -112,7 +121,13 @@ export class AuthService {
// Login flow
login(username: string, password: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>('/api/auth/login', { username, password });
return this.http.post<LoginResponse>('/api/auth/login', { username, password }).pipe(
tap((response) => {
if (!response.requiresTwoFactor && response.tokens) {
this.handleTokens(response.tokens);
}
}),
);
}
verify2fa(loginToken: string, code: string, isRecoveryCode = false): Observable<TokenResponse> {
@@ -154,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();
}
}
@@ -59,6 +59,9 @@ export class DocumentationService {
'log.archiveTimeLimitHours': 'archive-time-limit',
'ignoredDownloads': 'ignored-downloads',
'strikeInactivityWindowHours': 'strike-inactivity-window',
'auth.disableLocalAuth': 'disable-auth-for-local-addresses',
'auth.trustForwardedHeaders': 'trust-forwarded-headers',
'auth.trustedNetworks': 'additional-trusted-networks',
},
'download-cleaner': {
'enabled': 'enable-download-cleaner',
@@ -83,6 +83,9 @@ export class LoginComponent implements OnInit, OnDestroy {
if (result.requiresTwoFactor && result.loginToken) {
this.loginToken.set(result.loginToken);
this.view.set('2fa');
} else if (!result.requiresTwoFactor) {
// 2FA not enabled — tokens already handled by AuthService
this.router.navigate(['/dashboard']);
}
this.loading.set(false);
},
@@ -144,6 +144,8 @@
Verify Code
</app-button>
</form>
<button class="skip-link" (click)="skip2fa()">Skip for now</button>
}
} @else {
<p class="step-subtitle success-text">
@@ -372,3 +372,20 @@
justify-content: flex-end;
margin-top: var(--space-4);
}
.skip-link {
display: block;
background: none;
border: none;
color: var(--text-tertiary);
font-family: var(--font-family);
font-size: var(--font-size-sm);
cursor: pointer;
text-align: center;
margin-top: var(--space-2);
padding: var(--space-1);
&:hover {
color: var(--text-secondary);
}
}
@@ -139,6 +139,11 @@ export class SetupComponent implements OnDestroy {
});
}
skip2fa(): void {
this.currentStep.set(3);
this.error.set('');
}
goToStep3(): void {
this.currentStep.set(3);
this.error.set('');
@@ -73,68 +73,167 @@
<div class="form-stack">
<div class="status-row">
<span class="status-label">Status</span>
<span class="status-value status-value--active">Active</span>
@if (account()!.twoFactorEnabled) {
<span class="status-value status-value--active">Active</span>
} @else {
<span class="status-value status-value--inactive">Disabled</span>
}
</div>
@if (newRecoveryCodes().length > 0) {
<div class="recovery-section">
<p class="recovery-title">New Authenticator Setup</p>
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ newTotpSecret() }}</code>
@if (account()!.twoFactorEnabled) {
<!-- 2FA Enabled: Regenerate or Disable -->
@if (newRecoveryCodes().length > 0) {
<div class="recovery-section">
<p class="recovery-title">New Authenticator Setup</p>
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
</details>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ newTotpSecret() }}</code>
</div>
</details>
</div>
<div class="form-divider"></div>
<p class="recovery-title">New Recovery Codes</p>
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of newRecoveryCodes(); track code) {
<div class="recovery-code">{{ code }}</div>
}
</div>
<div class="recovery-actions">
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
</div>
</div>
} @else {
<div class="form-divider"></div>
<p class="recovery-title">New Recovery Codes</p>
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of newRecoveryCodes(); track code) {
<div class="recovery-code">{{ code }}</div>
}
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
<app-input
label="Current Password"
type="password"
placeholder="Enter your password"
[value]="twoFaPassword()"
(valueChange)="twoFaPassword.set($event)"
/>
<app-input
label="Authenticator Code"
type="text"
placeholder="Enter 6-digit code"
[value]="twoFaCode()"
(valueChange)="twoFaCode.set($event)"
/>
<div class="form-actions">
<app-button
variant="destructive"
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
(clicked)="confirmRegenerate2fa()"
>
@if (regenerating2fa()) {
<app-spinner size="sm" /> Regenerating...
} @else {
Regenerate 2FA
}
</app-button>
<app-button
variant="destructive"
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || disabling2fa()"
(clicked)="confirmDisable2fa()"
>
@if (disabling2fa()) {
<app-spinner size="sm" /> Disabling...
} @else {
Disable 2FA
}
</app-button>
</div>
<div class="recovery-actions">
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
</div>
</div>
}
} @else {
<div class="form-divider"></div>
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
<app-input
label="Current Password"
type="password"
placeholder="Enter your password"
[value]="twoFaPassword()"
(valueChange)="twoFaPassword.set($event)"
/>
<app-input
label="Authenticator Code"
type="text"
placeholder="Enter 6-digit code"
[value]="twoFaCode()"
(valueChange)="twoFaCode.set($event)"
/>
<div class="form-actions">
<app-button
variant="destructive"
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
(clicked)="confirmRegenerate2fa()"
>
@if (regenerating2fa()) {
<app-spinner size="sm" /> Regenerating...
} @else {
Regenerate 2FA
<!-- 2FA Disabled: Enable flow -->
@if (enableSetup()) {
<!-- QR code + verify flow -->
<div class="recovery-section">
<p class="recovery-title">Set Up Authenticator</p>
<p class="recovery-desc">Scan this QR code with your authenticator app.</p>
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ newTotpSecret() }}</code>
</div>
</details>
</div>
@if (newRecoveryCodes().length > 0) {
<div class="form-divider"></div>
<p class="recovery-title">Recovery Codes</p>
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of newRecoveryCodes(); track code) {
<div class="recovery-code">{{ code }}</div>
}
</div>
<div class="recovery-actions">
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
</div>
}
</app-button>
</div>
<div class="form-divider"></div>
<app-input
label="Verification Code"
type="text"
placeholder="Enter 6-digit code from your app"
[value]="enableVerificationCode()"
(valueChange)="enableVerificationCode.set($event)"
/>
<div class="form-actions">
<app-button
variant="primary"
[disabled]="enableVerificationCode().length !== 6 || enabling2fa()"
(clicked)="verifyEnable2fa()"
>
@if (enabling2fa()) {
<app-spinner size="sm" /> Verifying...
} @else {
Verify & Enable 2FA
}
</app-button>
<app-button variant="ghost" (clicked)="cancelEnable2fa()">Cancel</app-button>
</div>
</div>
} @else {
<div class="form-divider"></div>
<p class="section-hint">Two-factor authentication adds an extra layer of security to your account.</p>
<app-input
label="Password"
type="password"
placeholder="Enter your password to enable 2FA"
[value]="enablePassword()"
(valueChange)="enablePassword.set($event)"
/>
<div class="form-actions">
<app-button
variant="primary"
[disabled]="!enablePassword() || enabling2fa()"
(clicked)="startEnable2fa()"
>
@if (enabling2fa()) {
<app-spinner size="sm" /> Setting up...
} @else {
Enable 2FA
}
</app-button>
</div>
}
}
</div>
</app-card>
@@ -59,6 +59,15 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
readonly newQrCodeUri = signal('');
readonly newTotpSecret = signal('');
// 2FA enable
readonly enablePassword = signal('');
readonly enableVerificationCode = signal('');
readonly enabling2fa = signal(false);
readonly enableSetup = signal(false);
// 2FA disable
readonly disabling2fa = signal(false);
// API key
readonly apiKey = signal('');
readonly apiKeyRevealed = signal(false);
@@ -172,6 +181,75 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
this.newTotpSecret.set('');
}
// 2FA enable flow
startEnable2fa(): void {
this.enabling2fa.set(true);
this.api.enable2fa(this.enablePassword()).subscribe({
next: (result) => {
this.newQrCodeUri.set(result.qrCodeUri);
this.newTotpSecret.set(result.secret);
this.newRecoveryCodes.set(result.recoveryCodes);
this.enableSetup.set(true);
this.enabling2fa.set(false);
},
error: () => {
this.toast.error('Failed to start 2FA setup. Check your password.');
this.enabling2fa.set(false);
},
});
}
verifyEnable2fa(): void {
this.enabling2fa.set(true);
this.api.verifyEnable2fa(this.enableVerificationCode()).subscribe({
next: () => {
this.toast.success('Two-factor authentication enabled');
this.cancelEnable2fa();
this.enabling2fa.set(false);
this.loadAccount();
},
error: () => {
this.toast.error('Invalid verification code');
this.enabling2fa.set(false);
},
});
}
cancelEnable2fa(): void {
this.enableSetup.set(false);
this.enablePassword.set('');
this.enableVerificationCode.set('');
this.newRecoveryCodes.set([]);
this.newQrCodeUri.set('');
this.newTotpSecret.set('');
}
// 2FA disable flow
async confirmDisable2fa(): Promise<void> {
const confirmed = await this.confirmService.confirm({
title: 'Disable 2FA',
message: 'This will remove two-factor authentication from your account. Your recovery codes will be deleted.',
confirmLabel: 'Disable',
destructive: true,
});
if (!confirmed) return;
this.disabling2fa.set(true);
this.api.disable2fa(this.twoFaPassword(), this.twoFaCode()).subscribe({
next: () => {
this.toast.success('Two-factor authentication disabled');
this.twoFaPassword.set('');
this.twoFaCode.set('');
this.disabling2fa.set(false);
this.loadAccount();
},
error: () => {
this.toast.error('Failed to disable 2FA. Check your password and code.');
this.disabling2fa.set(false);
},
});
}
// API key
revealApiKey(): void {
if (this.apiKeyRevealed()) {
@@ -69,7 +69,7 @@
<app-input label="External URL" placeholder="https://sonarr.example.com" type="url" [(value)]="modalExternalUrl"
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"
helpKey="arr:externalUrl" />
<app-input label="API Key" placeholder="Enter API key" type="password" [(value)]="modalApiKey"
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [(value)]="modalApiKey"
hint="API key from your arr application's Settings > General"
[error]="modalApiKeyError()"
helpKey="arr:apiKey" />
@@ -146,6 +146,7 @@ export class ArrSettingsComponent implements HasPendingChanges {
url: this.modalUrl(),
apiKey: this.modalApiKey(),
version: (this.modalVersion() as number) ?? 3,
instanceId: this.editingInstance()?.id,
};
this.testing.set(true);
this.api.testInstance(this.arrType() as ArrType, request).subscribe({
@@ -57,7 +57,7 @@
</app-card>
@if (enabled()) {
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded">
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded" [error]="noFeaturesError()">
@for (cat of categories(); track $index; let i = $index) {
<div class="category-row">
<div class="category-row__header">
@@ -105,7 +105,7 @@
</app-button>
</app-accordion>
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded">
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded" [error]="noFeaturesError()">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="unlinkedEnabled"
hint="Enable management of downloads that have no hardlinks"
@@ -6,6 +6,7 @@ import {
EmptyStateComponent, LoadingStateComponent, type SelectOption,
} from '@ui';
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { DownloadCleanerConfig, CleanCategory, createDefaultCategory } from '@shared/models/download-cleaner-config.model';
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
@@ -123,7 +124,20 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return undefined;
}
readonly noFeaturesError = computed(() => {
if (!this.enabled()) return undefined;
const hasSeedingCategories = this.categories().length > 0;
const hasUnlinkedFeature = this.unlinkedEnabled()
&& !this.unlinkedTargetCategoryError()
&& !this.unlinkedCategoriesError();
if (!hasSeedingCategories && !hasUnlinkedFeature) {
return 'At least one feature must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noFeaturesError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.unlinkedTargetCategoryError()) return true;
@@ -224,8 +238,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to save download cleaner settings');
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400
? err.message
: 'Failed to save download cleaner settings');
this.saving.set(false);
},
});
@@ -77,7 +77,7 @@
helpKey="download-client:username" />
}
@if (showPasswordField()) {
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalPassword"
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalPassword"
[hint]="passwordHint()"
helpKey="download-client:password" />
}
@@ -170,6 +170,7 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
username: this.modalUsername(),
password: this.modalPassword(),
urlBase: this.modalUrlBase(),
clientId: this.editingClient()?.id,
};
this.testing.set(true);
this.api.test(request).subscribe({
@@ -41,6 +41,26 @@
</div>
</app-card>
<app-card header="Authentication">
<div class="form-stack">
<app-toggle label="Disable Authentication for Local Addresses" [(checked)]="authDisableLocalAuth"
hint="When enabled, requests from local network addresses (localhost, 192.168.x.x, 10.x.x.x, 172.16-31.x.x) will bypass authentication"
helpKey="general:auth.disableLocalAuth" />
@if (authDisableLocalAuth()) {
<app-toggle label="Trust Forwarded Headers" [(checked)]="authTrustForwardedHeaders"
hint="When behind a reverse proxy, trust X-Forwarded-For and X-Real-IP headers to determine the client's real IP address. Only enable this if you are using a reverse proxy."
helpKey="general:auth.trustForwardedHeaders" />
<app-chip-input
label="Additional Trusted Networks"
placeholder="e.g. 192.168.1.0/24"
hint="Add custom IP addresses or CIDR ranges that should bypass authentication"
[(items)]="authTrustedNetworks"
helpKey="general:auth.trustedNetworks"
/>
}
</div>
</app-card>
<app-card header="HTTP Settings">
<div class="form-stack">
<div class="form-row">
@@ -69,6 +69,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
readonly strikeInactivityWindowHours = signal<number | null>(24);
readonly purgingStrikes = signal(false);
// Auth
readonly authDisableLocalAuth = signal(false);
readonly authTrustForwardedHeaders = signal(false);
readonly authTrustedNetworks = signal<string[]>([]);
// Logging
readonly logLevel = signal<unknown>(LogEventLevel.Information);
readonly logRollingSizeMB = signal<number | null>(10);
@@ -182,6 +187,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
this.statusCheckEnabled.set(config.statusCheckEnabled);
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.strikeInactivityWindowHours.set(config.strikeInactivityWindowHours);
if (config.auth) {
this.authDisableLocalAuth.set(config.auth.disableAuthForLocalAddresses);
this.authTrustForwardedHeaders.set(config.auth.trustForwardedHeaders);
this.authTrustedNetworks.set(config.auth.trustedNetworks ?? []);
}
if (config.log) {
this.logLevel.set(config.log.level);
this.logRollingSizeMB.set(config.log.rollingSizeMB);
@@ -219,6 +229,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours() ?? 24,
ignoredDownloads: this.ignoredDownloads(),
auth: {
disableAuthForLocalAddresses: this.authDisableLocalAuth(),
trustForwardedHeaders: this.authTrustForwardedHeaders(),
trustedNetworks: this.authTrustedNetworks(),
},
log: {
level: this.logLevel() as LogEventLevel,
rollingSizeMB: this.logRollingSizeMB() ?? 10,
@@ -258,6 +273,9 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours(),
ignoredDownloads: this.ignoredDownloads(),
authDisableLocalAuth: this.authDisableLocalAuth(),
authTrustForwardedHeaders: this.authTrustForwardedHeaders(),
authTrustedNetworks: this.authTrustedNetworks(),
logLevel: this.logLevel(),
logRollingSizeMB: this.logRollingSizeMB(),
logRetainedFileCount: this.logRetainedFileCount(),
@@ -70,7 +70,7 @@
</app-card>
@if (enabled()) {
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded">
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded" [error]="noBlocklistError()">
@for (name of arrNames; track name) {
<div class="arr-blocklist">
<h4 class="arr-blocklist__title">{{ capitalize(name) }}</h4>
@@ -6,6 +6,7 @@ import {
type SelectOption,
} from '@ui';
import { MalwareBlockerApi } from '@core/api/malware-blocker.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { MalwareBlockerConfig, BlocklistSettings, MalwareScheduleOptions } from '@shared/models/malware-blocker-config.model';
import { BlocklistType, ScheduleUnit } from '@shared/models/enums';
@@ -120,7 +121,18 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
return undefined;
}
readonly noBlocklistError = computed(() => {
if (!this.enabled()) return undefined;
const blocklists = this.arrBlocklists();
const hasAnyEnabled = ARR_NAMES.some(name => blocklists[name]?.enabled);
if (!hasAnyEnabled) {
return 'At least one blocklist must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noBlocklistError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
@@ -225,8 +237,10 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to save malware blocker settings');
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400
? err.message
: 'Failed to save malware blocker settings');
this.saving.set(false);
},
});
@@ -103,7 +103,7 @@
<!-- Discord Fields -->
@if (modalType() === 'Discord') {
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [(value)]="modalWebhookUrl"
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [revealable]="false" [(value)]="modalWebhookUrl"
hint="Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations."
[error]="discordWebhookError()"
helpKey="notifications/discord:webhookUrl" />
@@ -117,7 +117,7 @@
<!-- Telegram Fields -->
@if (modalType() === 'Telegram') {
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [(value)]="modalBotToken"
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [revealable]="false" [(value)]="modalBotToken"
hint="Create a bot with BotFather and paste the API token"
[error]="telegramBotTokenError()"
helpKey="notifications/telegram:botToken" />
@@ -135,7 +135,7 @@
<!-- Notifiarr Fields -->
@if (modalType() === 'Notifiarr') {
<app-input label="API Key" placeholder="Enter API key" type="password" [(value)]="modalApiKey"
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [(value)]="modalApiKey"
hint="Your Notifiarr API key from your dashboard. Requires Passthrough integration."
[error]="notifiarrApiKeyError()"
helpKey="notifications/notifiarr:apiKey" />
@@ -187,12 +187,12 @@
<app-input label="Username" placeholder="Enter username" [(value)]="modalNtfyUsername"
hint="Your username for basic authentication."
helpKey="notifications/ntfy:username" />
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalNtfyPassword"
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalNtfyPassword"
hint="Your password for basic authentication."
helpKey="notifications/ntfy:password" />
}
@if (modalNtfyAuthType() === 'AccessToken') {
<app-input label="Access Token" placeholder="Enter access token" type="password" [(value)]="modalNtfyAccessToken"
<app-input label="Access Token" placeholder="Enter access token" type="password" [revealable]="false" [(value)]="modalNtfyAccessToken"
hint="Your access token for bearer token authentication."
helpKey="notifications/ntfy:accessToken" />
}
@@ -206,11 +206,11 @@
<!-- Pushover Fields -->
@if (modalType() === 'Pushover') {
<app-input label="API Token" placeholder="Enter API token" type="password" [(value)]="modalPushoverApiToken"
<app-input label="API Token" placeholder="Enter API token" type="password" [revealable]="false" [(value)]="modalPushoverApiToken"
hint="Your application API token from Pushover. Create one at pushover.net/apps/build."
[error]="pushoverApiTokenError()"
helpKey="notifications/pushover:apiToken" />
<app-input label="User Key" placeholder="Enter user key" type="password" [(value)]="modalPushoverUserKey"
<app-input label="User Key" placeholder="Enter user key" type="password" [revealable]="false" [(value)]="modalPushoverUserKey"
hint="Your user/group key from your Pushover dashboard."
[error]="pushoverUserKeyError()"
helpKey="notifications/pushover:userKey" />
@@ -247,7 +247,7 @@
hint="The base URL of your Gotify server instance."
[error]="gotifyServerUrlError()"
helpKey="notifications/gotify:serverUrl" />
<app-input label="Application Token" placeholder="Enter application token" type="password" [(value)]="modalGotifyApplicationToken"
<app-input label="Application Token" placeholder="Enter application token" type="password" [revealable]="false" [(value)]="modalGotifyApplicationToken"
hint="The application token from your Gotify server. Create one under Apps in the Gotify web UI."
[error]="gotifyApplicationTokenError()"
helpKey="notifications/gotify:applicationToken" />
@@ -478,6 +478,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
testNotification(): void {
const type = this.modalType();
this.testing.set(true);
const providerId = this.editingProvider()?.id;
switch (type) {
case NotificationProviderType.Discord:
@@ -485,6 +486,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
webhookUrl: this.modalWebhookUrl(),
username: this.modalUsername() || undefined,
avatarUrl: this.modalAvatarUrl() || undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
@@ -496,6 +498,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
chatId: this.modalChatId(),
topicId: this.modalTopicId() || undefined,
sendSilently: this.modalSendSilently(),
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
@@ -505,6 +508,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
this.api.testNotifiarr({
apiKey: this.modalApiKey(),
channelId: this.modalChannelId(),
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
@@ -517,6 +521,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
key: this.modalAppriseKey() || undefined,
tags: this.modalAppriseTags() || undefined,
serviceUrls: this.modalAppriseServiceUrls().join('\n') || undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
@@ -532,6 +537,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
accessToken: this.modalNtfyAccessToken() || undefined,
priority: this.modalNtfyPriority() as NtfyPriority,
tags: this.modalNtfyTags().length > 0 ? this.modalNtfyTags() : undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
@@ -548,6 +554,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
retry: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverRetry() ?? 30) : undefined,
expire: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverExpire() ?? 3600) : undefined,
tags: this.modalPushoverTags().length > 0 ? this.modalPushoverTags() : undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
@@ -559,6 +566,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
serverUrl: this.modalGotifyServerUrl(),
applicationToken: this.modalGotifyApplicationToken(),
priority: parseInt(this.modalGotifyPriority() as string, 10) || 5,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
@@ -75,7 +75,6 @@ export class NavSidebarComponent {
];
suggestedApps: ExternalLink[] = [
{ label: 'Huntarr', icon: 'tablerExternalLink', href: 'https://github.com/plexguide/Huntarr.io' },
];
onNavItemClick(): void {
@@ -26,4 +26,5 @@ export interface TestArrInstanceRequest {
url: string;
apiKey: string;
version: number;
instanceId?: string;
}
@@ -36,6 +36,7 @@ export interface TestDownloadClientRequest {
username?: string;
password?: string;
urlBase?: string;
clientId?: string;
}
export interface TestConnectionResult {
@@ -10,6 +10,12 @@ export interface LoggingConfig {
archiveTimeLimitHours: number;
}
export interface AuthConfig {
disableAuthForLocalAddresses: boolean;
trustForwardedHeaders: boolean;
trustedNetworks: string[];
}
export interface GeneralConfig {
displaySupportBanner: boolean;
dryRun: boolean;
@@ -21,5 +27,6 @@ export interface GeneralConfig {
statusCheckEnabled: boolean;
strikeInactivityWindowHours: number;
log?: LoggingConfig;
auth?: AuthConfig;
ignoredDownloads: string[];
}
@@ -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);
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more