mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-11 21:10:38 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d1756fdd | ||
|
|
63931763c4 | ||
|
|
bdb956ec84 | ||
|
|
41b48d1104 | ||
|
|
57fef26726 | ||
|
|
ea94dc4548 | ||
|
|
13a7232bc5 | ||
|
|
5fea8a0041 | ||
|
|
2333c86a08 | ||
|
|
cbfc1b2875 | ||
|
|
62e10afe7b | ||
|
|
d542c716f9 | ||
|
|
7eeaefaa65 | ||
|
|
bd55356881 | ||
|
|
df986a2e36 | ||
|
|
8a2aca79f7 |
No files matched your search
@@ -22,23 +22,32 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
|
||||
|
||||
jobs:
|
||||
build_app:
|
||||
# Compute tags, version, and push decision for downstream jobs
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
outputs:
|
||||
tags: ${{ steps.build-info.outputs.tags }}
|
||||
version: ${{ steps.build-info.outputs.version }}
|
||||
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
|
||||
branch: ${{ steps.build-info.outputs.branch }}
|
||||
push: ${{ steps.build-info.outputs.push }}
|
||||
github_sha: ${{ github.sha }}
|
||||
steps:
|
||||
|
||||
- name: Set github context
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV
|
||||
echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV
|
||||
echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV
|
||||
echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV
|
||||
|
||||
- name: Initialize build info
|
||||
id: build-info
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
githubHeadRef="${{ github.head_ref }}"
|
||||
githubRef="${{ github.ref }}"
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
@@ -71,10 +80,8 @@ jobs:
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
# Main branch
|
||||
githubRef=${{ env.githubRef }}
|
||||
branch=${githubRef##*/}
|
||||
versionDockerTag="$branch"
|
||||
else
|
||||
@@ -85,25 +92,45 @@ jobs:
|
||||
fi
|
||||
|
||||
githubTags=""
|
||||
|
||||
if [ -n "$latestDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
|
||||
fi
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
echo "githubTags=$githubTags" >> $GITHUB_ENV
|
||||
echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV
|
||||
echo "version=$version" >> $GITHUB_ENV
|
||||
githubTags="${githubTags#,}"
|
||||
|
||||
# Determine push decision
|
||||
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
|
||||
|
||||
echo "tags=$githubTags" >> $GITHUB_OUTPUT
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
|
||||
echo "branch=$branch" >> $GITHUB_OUTPUT
|
||||
echo "push=$push" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build each platform in parallel
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
@@ -113,8 +140,6 @@ jobs:
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/docker username | DOCKER_USERNAME;
|
||||
secrets/data/docker password | DOCKER_PASSWORD;
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
@@ -122,16 +147,97 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ env.branch }}
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ needs.prepare.outputs.branch }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
id: build-push
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Build (no push)
|
||||
if: needs.prepare.outputs.push != 'true'
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-push.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Create multi-platform manifest and push with final tags
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -140,27 +246,25 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push docker image
|
||||
id: docker-build
|
||||
timeout-minutes: 15
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ env.githubSha }}
|
||||
version=${{ env.versionDockerTag }}
|
||||
build-args: |
|
||||
VERSION=${{ env.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
|
||||
tags: |
|
||||
${{ env.githubTags }}
|
||||
# Enable BuildKit cache for faster builds
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Create manifest list and push
|
||||
timeout-minutes: 5
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
tag_args=""
|
||||
IFS=',' read -ra TAG_ARRAY <<< "$tags"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
tag=$(echo "$tag" | xargs)
|
||||
if [ -n "$tag" ]; then
|
||||
tag_args="$tag_args -t $tag"
|
||||
fi
|
||||
done
|
||||
|
||||
docker buildx imagetools create $tag_args \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
|
||||
docker buildx imagetools inspect "$first_tag"
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
|
||||
**Key Features:**
|
||||
- Strike system for bad downloads
|
||||
|
||||
@@ -49,6 +49,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
- **rTorrent**
|
||||
|
||||
### Platforms
|
||||
- **Docker**
|
||||
@@ -101,14 +102,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
|
||||
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
|
||||
|
||||
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
|
||||
|
||||
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle"></span></span>
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
|
||||
@@ -178,6 +178,61 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task Setup_Complete_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Login endpoint must NOT be blocked by the middleware (403).
|
||||
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
public class SensitiveDataHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithPlaceholder_ReturnsTrue()
|
||||
{
|
||||
SensitiveDataHelper.Placeholder.IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithAppriseStyledPlaceholder_ReturnsTrue()
|
||||
{
|
||||
$"discord://{SensitiveDataHelper.Placeholder}".IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithNull_ReturnsFalse()
|
||||
{
|
||||
((string?)null).IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithEmptyString_ReturnsFalse()
|
||||
{
|
||||
"".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithRealValue_ReturnsFalse()
|
||||
{
|
||||
"my-secret-api-key-123".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("discord://webhook_id/webhook_token", "discord://••••••••")]
|
||||
[InlineData("slack://tokenA/tokenB/tokenC", "slack://••••••••")]
|
||||
[InlineData("mailto://user:pass@gmail.com", "mailto://••••••••")]
|
||||
[InlineData("json+http://user:pass@host/path", "json+http://••••••••")]
|
||||
public void MaskAppriseUrls_SingleUrl_MasksCorrectly(string input, string expected)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultipleUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1 slack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
result.ShouldNotContain("token1");
|
||||
result.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultilineUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1\nslack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void MaskAppriseUrls_EmptyOrNull_ReturnsAsIs(string? input)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that placeholder values are correctly handled on the input side:
|
||||
/// - UPDATE operations preserve the existing DB value when a placeholder is sent
|
||||
/// - CREATE operations reject placeholder values
|
||||
/// - TEST operations reject placeholder values
|
||||
/// </summary>
|
||||
public class SensitiveDataInputTests
|
||||
{
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
#region ArrInstanceRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithPlaceholderApiKey_PreservesExistingValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Updated Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("original-secret-key");
|
||||
existingInstance.Name.ShouldBe("Updated Sonarr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithRealApiKey_UpdatesValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "brand-new-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("brand-new-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithPlaceholderApiKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToEntity(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key-123",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var entity = request.ToEntity(Guid.NewGuid());
|
||||
entity.ApiKey.ShouldBe("real-api-key-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestArrInstanceRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndNoResolvedKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToTestInstance());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndResolvedKey_UsesResolvedKey()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
InstanceId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance("resolved-api-key-from-db");
|
||||
instance.ApiKey.ShouldBe("resolved-api-key-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance();
|
||||
instance.ApiKey.ShouldBe("real-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateDownloadClientRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithPlaceholderPassword_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "Updated qBit",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("original-secret-password");
|
||||
result.Name.ShouldBe("Updated qBit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithRealPassword_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = "new-password-123",
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("new-password-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateDownloadClientRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithPlaceholderPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithNullPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = null,
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestDownloadClientRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndNoResolvedPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
Should.Throw<ValidationException>(() => request.ToTestConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndResolvedPassword_UsesResolvedPassword()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
ClientId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig("resolved-password-from-db");
|
||||
config.Password.ShouldBe("resolved-password-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig();
|
||||
config.Password.ShouldBe("real-password");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties
|
||||
/// during JSON serialization — this is what controls the API response output.
|
||||
/// </summary>
|
||||
public class SensitiveDataResolverTests
|
||||
{
|
||||
private readonly JsonSerializerOptions _options;
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
public SensitiveDataResolverTests()
|
||||
{
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = new SensitiveDataResolver(new DefaultJsonTypeInfoResolver()),
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
}
|
||||
|
||||
#region ArrInstance
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_ApiKey_IsMasked()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NonSensitiveFields_AreVisible()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ExternalUrl = new Uri("https://sonarr.example.com"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Sonarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://sonarr:8989");
|
||||
doc.RootElement.GetProperty("externalUrl").GetString().ShouldBe("https://sonarr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NullApiKey_RemainsNull()
|
||||
{
|
||||
// ApiKey is required, but let's test with the DTO which might handle null
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = null!,
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceDto
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceDto_ApiKey_IsMasked()
|
||||
{
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Radarr",
|
||||
Url = "http://radarr:7878",
|
||||
ApiKey = "dto-secret-api-key-67890",
|
||||
Version = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Radarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://radarr:7878");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DownloadClientConfig
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Password_IsMasked()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Username_IsVisible()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("admin");
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("qBittorrent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_NullPassword_RemainsNull()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifiarrConfig
|
||||
|
||||
[Fact]
|
||||
public void NotifiarrConfig_ApiKey_IsMasked()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "notifiarr-api-key-secret",
|
||||
ChannelId = "123456789"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("123456789");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DiscordConfig
|
||||
|
||||
[Fact]
|
||||
public void DiscordConfig_WebhookUrl_IsMasked()
|
||||
{
|
||||
var config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123456/secret-token",
|
||||
Username = "Cleanuparr Bot",
|
||||
AvatarUrl = "https://example.com/avatar.png"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Cleanuparr Bot");
|
||||
doc.RootElement.GetProperty("avatarUrl").GetString().ShouldBe("https://example.com/avatar.png");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TelegramConfig
|
||||
|
||||
[Fact]
|
||||
public void TelegramConfig_BotToken_IsMasked()
|
||||
{
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
BotToken = "1234567890:ABCdefGHIjklmnoPQRstuvWXyz",
|
||||
ChatId = "-1001234567890",
|
||||
TopicId = "42",
|
||||
SendSilently = true
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("botToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("chatId").GetString().ShouldBe("-1001234567890");
|
||||
doc.RootElement.GetProperty("topicId").GetString().ShouldBe("42");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NtfyConfig
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_PasswordAndAccessToken_AreMasked()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.BasicAuth,
|
||||
Username = "ntfy-user",
|
||||
Password = "ntfy-secret-password",
|
||||
AccessToken = "ntfy-access-token-secret",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("accessToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://ntfy.example.com");
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("ntfy-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_NullPasswordAndAccessToken_RemainNull()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
doc.RootElement.GetProperty("accessToken").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PushoverConfig
|
||||
|
||||
[Fact]
|
||||
public void PushoverConfig_ApiTokenAndUserKey_AreMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "pushover-api-token-secret",
|
||||
UserKey = "pushover-user-key-secret",
|
||||
Priority = PushoverPriority.Normal,
|
||||
Devices = ["iphone", "desktop"]
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("devices").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GotifyConfig
|
||||
|
||||
[Fact]
|
||||
public void GotifyConfig_ApplicationToken_IsMasked()
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "gotify-app-token-secret",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("applicationToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://gotify.example.com");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AppriseConfig
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_Key_IsMasked_WithFullMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "apprise-config-key-secret",
|
||||
Tags = "urgent",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("key").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("https://apprise.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_ServiceUrls_IsMasked_WithAppriseUrlMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Cli,
|
||||
ServiceUrls = "discord://webhook_id/webhook_token slack://tokenA/tokenB/tokenC"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
var maskedUrls = doc.RootElement.GetProperty("serviceUrls").GetString();
|
||||
maskedUrls.ShouldContain("discord://••••••••");
|
||||
maskedUrls.ShouldContain("slack://••••••••");
|
||||
maskedUrls.ShouldNotContain("webhook_id");
|
||||
maskedUrls.ShouldNotContain("webhook_token");
|
||||
maskedUrls.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_NullServiceUrls_RemainsNull()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "some-key",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("serviceUrls").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Polymorphic serialization (as used in NotificationProviderResponse)
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_NotifiarrConfig_StillMasked()
|
||||
{
|
||||
// The notification providers endpoint casts configs to `object`.
|
||||
// Verify that the resolver still masks when serializing as a concrete type at runtime.
|
||||
object config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "my-secret-notifiarr-key",
|
||||
ChannelId = "987654321"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("987654321");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_DiscordConfig_StillMasked()
|
||||
{
|
||||
object config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123/secret",
|
||||
Username = "Bot"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Bot");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge cases
|
||||
|
||||
[Fact]
|
||||
public void EmptySensitiveString_IsMasked_NotReturnedEmpty()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "",
|
||||
ChannelId = "123"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Even empty strings get masked to the placeholder
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleSensitiveFields_AllMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "token-abc-123",
|
||||
UserKey = "user-key-xyz-789",
|
||||
Priority = PushoverPriority.High,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
-9
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
@@ -12,17 +14,29 @@ public sealed record TestArrInstanceRequest
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
|
||||
[Required]
|
||||
public required float Version { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance() => new()
|
||||
public Guid? InstanceId { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance(string? resolvedApiKey = null)
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
Version = Version,
|
||||
};
|
||||
var apiKey = resolvedApiKey ?? ApiKey;
|
||||
|
||||
if (apiKey.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("API key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = apiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
Version = Version,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -11,6 +13,7 @@ namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
@@ -282,7 +285,23 @@ public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var testInstance = request.ToTestInstance();
|
||||
string? resolvedApiKey = null;
|
||||
|
||||
if (request.ApiKey.IsPlaceholder() && request.InstanceId.HasValue)
|
||||
{
|
||||
var existingInstance = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == request.InstanceId.Value);
|
||||
|
||||
if (existingInstance is null)
|
||||
{
|
||||
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
|
||||
}
|
||||
|
||||
resolvedApiKey = existingInstance.ApiKey;
|
||||
}
|
||||
|
||||
var testInstance = request.ToTestInstance(resolvedApiKey);
|
||||
var client = _arrClientFactory.GetClient(type, request.Version);
|
||||
await client.HealthCheckAsync(testInstance);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,6 +15,7 @@ namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class BlacklistSyncConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<BlacklistSyncConfigController> _logger;
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,6 +18,7 @@ namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadCleanerConfigController> _logger;
|
||||
|
||||
+6
@@ -3,6 +3,7 @@ using System;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
@@ -47,6 +48,11 @@ public sealed record CreateDownloadClientRequest
|
||||
{
|
||||
throw new ValidationException("External URL is not a valid URL");
|
||||
}
|
||||
|
||||
if (Password.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("Password cannot be a placeholder value");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToEntity() => new()
|
||||
|
||||
+24
-11
@@ -3,6 +3,7 @@ using System;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
@@ -20,6 +21,8 @@ public sealed record TestDownloadClientRequest
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public Guid? ClientId { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Host))
|
||||
@@ -33,16 +36,26 @@ public sealed record TestDownloadClientRequest
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToTestConfig() => new()
|
||||
public DownloadClientConfig ToTestConfig(string? resolvedPassword = null)
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
var password = resolvedPassword ?? Password;
|
||||
|
||||
if (password.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("Password cannot be a placeholder value");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -3,6 +3,7 @@ using System;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
@@ -57,7 +58,7 @@ public sealed record UpdateDownloadClientRequest
|
||||
Type = Type,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
Password = Password.IsPlaceholder() ? existing.Password : Password,
|
||||
UrlBase = UrlBase,
|
||||
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
|
||||
};
|
||||
|
||||
+20
-1
@@ -5,6 +5,8 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -12,6 +14,7 @@ namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class DownloadClientController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadClientController> _logger;
|
||||
@@ -156,7 +159,23 @@ public sealed class DownloadClientController : ControllerBase
|
||||
{
|
||||
request.Validate();
|
||||
|
||||
var testConfig = request.ToTestConfig();
|
||||
string? resolvedPassword = null;
|
||||
|
||||
if (request.Password.IsPlaceholder() && request.ClientId.HasValue)
|
||||
{
|
||||
var existingClient = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == request.ClientId.Value);
|
||||
|
||||
if (existingClient is null)
|
||||
{
|
||||
return NotFound($"Download client with ID {request.ClientId.Value} not found");
|
||||
}
|
||||
|
||||
resolvedPassword = existingClient.Password;
|
||||
}
|
||||
|
||||
var testConfig = request.ToTestConfig(resolvedPassword);
|
||||
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
|
||||
var healthResult = await downloadService.HealthCheckAsync();
|
||||
|
||||
|
||||
+20
@@ -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;
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -16,6 +17,7 @@ namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class MalwareBlockerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<MalwareBlockerConfigController> _logger;
|
||||
|
||||
+2
@@ -15,4 +15,6 @@ public record TestAppriseProviderRequest
|
||||
|
||||
// CLI mode fields
|
||||
public string? ServiceUrls { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -7,4 +7,6 @@ public record TestDiscordProviderRequest
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string AvatarUrl { get; init; } = string.Empty;
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -7,4 +7,6 @@ public record TestGotifyProviderRequest
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+3
-1
@@ -3,6 +3,8 @@ namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
public record TestNotifiarrProviderRequest
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -19,4 +19,6 @@ public record TestNtfyProviderRequest
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -19,4 +19,6 @@ public record TestPushoverProviderRequest
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest
|
||||
public string? TopicId { get; init; }
|
||||
|
||||
public bool SendSilently { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+253
-20
@@ -11,6 +11,8 @@ using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -18,6 +20,7 @@ namespace Cleanuparr.Api.Features.Notifications.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration/notification_providers")]
|
||||
[Authorize]
|
||||
public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<NotificationProvidersController> _logger;
|
||||
@@ -127,6 +130,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.ApiKey.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("API key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = newProvider.ApiKey,
|
||||
@@ -184,6 +192,16 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.Key.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
if (newProvider.ServiceUrls.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Service URLs cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Mode = newProvider.Mode,
|
||||
@@ -248,6 +266,16 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.Password.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Password cannot be a placeholder value");
|
||||
}
|
||||
|
||||
if (newProvider.AccessToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Access token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
@@ -315,6 +343,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.BotToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Bot token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = newProvider.BotToken,
|
||||
@@ -392,7 +425,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = updatedProvider.ApiKey,
|
||||
ApiKey = updatedProvider.ApiKey.IsPlaceholder()
|
||||
? existingProvider.NotifiarrConfiguration!.ApiKey
|
||||
: updatedProvider.ApiKey,
|
||||
ChannelId = updatedProvider.ChannelId
|
||||
};
|
||||
|
||||
@@ -473,9 +508,13 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
Mode = updatedProvider.Mode,
|
||||
Url = updatedProvider.Url,
|
||||
Key = updatedProvider.Key,
|
||||
Key = updatedProvider.Key.IsPlaceholder()
|
||||
? existingProvider.AppriseConfiguration!.Key
|
||||
: updatedProvider.Key,
|
||||
Tags = updatedProvider.Tags,
|
||||
ServiceUrls = updatedProvider.ServiceUrls
|
||||
ServiceUrls = updatedProvider.ServiceUrls.IsPlaceholder()
|
||||
? existingProvider.AppriseConfiguration!.ServiceUrls
|
||||
: updatedProvider.ServiceUrls
|
||||
};
|
||||
|
||||
if (existingProvider.AppriseConfiguration != null)
|
||||
@@ -557,8 +596,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
Topics = updatedProvider.Topics,
|
||||
AuthenticationType = updatedProvider.AuthenticationType,
|
||||
Username = updatedProvider.Username,
|
||||
Password = updatedProvider.Password,
|
||||
AccessToken = updatedProvider.AccessToken,
|
||||
Password = updatedProvider.Password.IsPlaceholder()
|
||||
? existingProvider.NtfyConfiguration!.Password
|
||||
: updatedProvider.Password,
|
||||
AccessToken = updatedProvider.AccessToken.IsPlaceholder()
|
||||
? existingProvider.NtfyConfiguration!.AccessToken
|
||||
: updatedProvider.AccessToken,
|
||||
Priority = updatedProvider.Priority,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
@@ -638,7 +681,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = updatedProvider.BotToken,
|
||||
BotToken = updatedProvider.BotToken.IsPlaceholder()
|
||||
? existingProvider.TelegramConfiguration!.BotToken
|
||||
: updatedProvider.BotToken,
|
||||
ChatId = updatedProvider.ChatId,
|
||||
TopicId = updatedProvider.TopicId,
|
||||
SendSilently = updatedProvider.SendSilently
|
||||
@@ -735,9 +780,24 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiKey = testRequest.ApiKey;
|
||||
|
||||
if (apiKey.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<NotifiarrConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Notifiarr, p => p.NotifiarrConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "API key cannot be a placeholder value" });
|
||||
}
|
||||
|
||||
apiKey = existing.ApiKey;
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = testRequest.ApiKey,
|
||||
ApiKey = apiKey,
|
||||
ChannelId = testRequest.ChannelId
|
||||
};
|
||||
notifiarrConfig.Validate();
|
||||
@@ -775,13 +835,37 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = testRequest.Key;
|
||||
var serviceUrls = testRequest.ServiceUrls;
|
||||
|
||||
if (key.IsPlaceholder() || serviceUrls.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<AppriseConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Apprise, p => p.AppriseConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
|
||||
}
|
||||
|
||||
if (key.IsPlaceholder())
|
||||
{
|
||||
key = existing.Key;
|
||||
}
|
||||
|
||||
if (serviceUrls.IsPlaceholder())
|
||||
{
|
||||
serviceUrls = existing.ServiceUrls;
|
||||
}
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Mode = testRequest.Mode,
|
||||
Url = testRequest.Url,
|
||||
Key = testRequest.Key,
|
||||
Key = key,
|
||||
Tags = testRequest.Tags,
|
||||
ServiceUrls = testRequest.ServiceUrls
|
||||
ServiceUrls = serviceUrls
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
@@ -822,14 +906,38 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var password = testRequest.Password;
|
||||
var accessToken = testRequest.AccessToken;
|
||||
|
||||
if (password.IsPlaceholder() || accessToken.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<NtfyConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Ntfy, p => p.NtfyConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
|
||||
}
|
||||
|
||||
if (password.IsPlaceholder())
|
||||
{
|
||||
password = existing.Password;
|
||||
}
|
||||
|
||||
if (accessToken.IsPlaceholder())
|
||||
{
|
||||
accessToken = existing.AccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
Topics = testRequest.Topics,
|
||||
AuthenticationType = testRequest.AuthenticationType,
|
||||
Username = testRequest.Username,
|
||||
Password = testRequest.Password,
|
||||
AccessToken = testRequest.AccessToken,
|
||||
Password = password,
|
||||
AccessToken = accessToken,
|
||||
Priority = testRequest.Priority,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
@@ -868,9 +976,24 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var botToken = testRequest.BotToken;
|
||||
|
||||
if (botToken.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<TelegramConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Telegram, p => p.TelegramConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Bot token cannot be a placeholder value" });
|
||||
}
|
||||
|
||||
botToken = existing.BotToken;
|
||||
}
|
||||
|
||||
var telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = testRequest.BotToken,
|
||||
BotToken = botToken,
|
||||
ChatId = testRequest.ChatId,
|
||||
TopicId = testRequest.TopicId,
|
||||
SendSilently = testRequest.SendSilently
|
||||
@@ -958,6 +1081,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.WebhookUrl.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Webhook URL cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = newProvider.WebhookUrl,
|
||||
@@ -1034,7 +1162,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = updatedProvider.WebhookUrl,
|
||||
WebhookUrl = updatedProvider.WebhookUrl.IsPlaceholder()
|
||||
? existingProvider.DiscordConfiguration!.WebhookUrl
|
||||
: updatedProvider.WebhookUrl,
|
||||
Username = updatedProvider.Username,
|
||||
AvatarUrl = updatedProvider.AvatarUrl
|
||||
};
|
||||
@@ -1088,9 +1218,24 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var webhookUrl = testRequest.WebhookUrl;
|
||||
|
||||
if (webhookUrl.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<DiscordConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Discord, p => p.DiscordConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Webhook URL cannot be a placeholder value" });
|
||||
}
|
||||
|
||||
webhookUrl = existing.WebhookUrl;
|
||||
}
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = testRequest.WebhookUrl,
|
||||
WebhookUrl = webhookUrl,
|
||||
Username = testRequest.Username,
|
||||
AvatarUrl = testRequest.AvatarUrl
|
||||
};
|
||||
@@ -1146,6 +1291,16 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.ApiToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("API token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
if (newProvider.UserKey.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("User key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = newProvider.ApiToken,
|
||||
@@ -1227,8 +1382,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = updatedProvider.ApiToken,
|
||||
UserKey = updatedProvider.UserKey,
|
||||
ApiToken = updatedProvider.ApiToken.IsPlaceholder()
|
||||
? existingProvider.PushoverConfiguration!.ApiToken
|
||||
: updatedProvider.ApiToken,
|
||||
UserKey = updatedProvider.UserKey.IsPlaceholder()
|
||||
? existingProvider.PushoverConfiguration!.UserKey
|
||||
: updatedProvider.UserKey,
|
||||
Devices = updatedProvider.Devices,
|
||||
Priority = updatedProvider.Priority,
|
||||
Sound = updatedProvider.Sound,
|
||||
@@ -1286,10 +1445,34 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiToken = testRequest.ApiToken;
|
||||
var userKey = testRequest.UserKey;
|
||||
|
||||
if (apiToken.IsPlaceholder() || userKey.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<PushoverConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Pushover, p => p.PushoverConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
|
||||
}
|
||||
|
||||
if (apiToken.IsPlaceholder())
|
||||
{
|
||||
apiToken = existing.ApiToken;
|
||||
}
|
||||
|
||||
if (userKey.IsPlaceholder())
|
||||
{
|
||||
userKey = existing.UserKey;
|
||||
}
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = testRequest.ApiToken,
|
||||
UserKey = testRequest.UserKey,
|
||||
ApiToken = apiToken,
|
||||
UserKey = userKey,
|
||||
Devices = testRequest.Devices,
|
||||
Priority = testRequest.Priority,
|
||||
Sound = testRequest.Sound,
|
||||
@@ -1344,6 +1527,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.ApplicationToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Application token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
@@ -1421,7 +1609,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = updatedProvider.ServerUrl,
|
||||
ApplicationToken = updatedProvider.ApplicationToken,
|
||||
ApplicationToken = updatedProvider.ApplicationToken.IsPlaceholder()
|
||||
? existingProvider.GotifyConfiguration!.ApplicationToken
|
||||
: updatedProvider.ApplicationToken,
|
||||
Priority = updatedProvider.Priority
|
||||
};
|
||||
|
||||
@@ -1474,10 +1664,23 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var applicationToken = testRequest.ApplicationToken;
|
||||
|
||||
if (applicationToken.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<GotifyConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Gotify, p => p.GotifyConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
return BadRequest(new { Message = "Application token cannot be a placeholder value" });
|
||||
|
||||
applicationToken = existing.ApplicationToken;
|
||||
}
|
||||
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
ApplicationToken = testRequest.ApplicationToken,
|
||||
ApplicationToken = applicationToken,
|
||||
Priority = testRequest.Priority
|
||||
};
|
||||
gotifyConfig.Validate();
|
||||
@@ -1514,4 +1717,34 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> GetExistingProviderConfig<T>(
|
||||
Guid? providerId,
|
||||
NotificationProviderType expectedType,
|
||||
Func<NotificationConfig, T?> configSelector) where T : class
|
||||
{
|
||||
if (!providerId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IQueryable<NotificationConfig> query = _dataContext.NotificationConfigs.AsNoTracking();
|
||||
|
||||
query = expectedType switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => query.Include(p => p.NotifiarrConfiguration),
|
||||
NotificationProviderType.Apprise => query.Include(p => p.AppriseConfiguration),
|
||||
NotificationProviderType.Ntfy => query.Include(p => p.NtfyConfiguration),
|
||||
NotificationProviderType.Pushover => query.Include(p => p.PushoverConfiguration),
|
||||
NotificationProviderType.Telegram => query.Include(p => p.TelegramConfiguration),
|
||||
NotificationProviderType.Discord => query.Include(p => p.DiscordConfiguration),
|
||||
NotificationProviderType.Gotify => query.Include(p => p.GotifyConfiguration),
|
||||
_ => query
|
||||
};
|
||||
|
||||
var provider = await query
|
||||
.FirstOrDefaultAsync(p => p.Id == providerId.Value && p.Type == expectedType);
|
||||
|
||||
return provider is null ? null : configSelector(provider);
|
||||
}
|
||||
}
|
||||
+2
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class QueueCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueCleanerConfigController> _logger;
|
||||
|
||||
@@ -3,6 +3,7 @@ using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -11,6 +12,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/queue-rules")]
|
||||
[Authorize]
|
||||
public class QueueRulesController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueRulesController> _logger;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Json;
|
||||
|
||||
/// <summary>
|
||||
/// JSON type info resolver that masks properties decorated with <see cref="SensitiveDataAttribute"/>
|
||||
/// by replacing their serialized values with the appropriate placeholder during serialization.
|
||||
/// </summary>
|
||||
public sealed class SensitiveDataResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _innerResolver;
|
||||
|
||||
public SensitiveDataResolver(IJsonTypeInfoResolver innerResolver)
|
||||
{
|
||||
_innerResolver = innerResolver;
|
||||
}
|
||||
|
||||
public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var typeInfo = _innerResolver.GetTypeInfo(type, options);
|
||||
|
||||
if (typeInfo?.Kind != JsonTypeInfoKind.Object)
|
||||
return typeInfo;
|
||||
|
||||
foreach (var property in typeInfo.Properties)
|
||||
{
|
||||
if (property.AttributeProvider is not PropertyInfo propertyInfo)
|
||||
continue;
|
||||
|
||||
var sensitiveAttr = propertyInfo.GetCustomAttribute<SensitiveDataAttribute>();
|
||||
if (sensitiveAttr is null)
|
||||
continue;
|
||||
|
||||
ApplyMasking(property, sensitiveAttr.Type);
|
||||
}
|
||||
|
||||
return typeInfo;
|
||||
}
|
||||
|
||||
private static void ApplyMasking(JsonPropertyInfo property, SensitiveDataType maskType)
|
||||
{
|
||||
var originalGet = property.Get;
|
||||
if (originalGet is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
property.Get = maskType switch
|
||||
{
|
||||
SensitiveDataType.Full => obj =>
|
||||
{
|
||||
var value = originalGet(obj);
|
||||
return value is string ? SensitiveDataHelper.Placeholder : value;
|
||||
},
|
||||
|
||||
SensitiveDataType.AppriseUrl => obj =>
|
||||
{
|
||||
var value = originalGet(obj);
|
||||
return value is string s ? SensitiveDataHelper.MaskAppriseUrls(s) : value;
|
||||
},
|
||||
|
||||
_ => originalGet,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,52 +15,75 @@ public class SetupGuardMiddleware
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Fast path: setup already completed
|
||||
string path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow health checks and non-API paths (static files, SPA, etc.)
|
||||
if (path.StartsWith("/health") || !path.StartsWith("/api/"))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup-only paths (/api/auth/setup/*) require setup to NOT be complete
|
||||
if (IsSetupOnlyPath(path))
|
||||
{
|
||||
if (await IsSetupCompleted())
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup already completed" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-setup auth paths (login, refresh, logout, status) are always allowed
|
||||
if (path.StartsWith("/api/auth/") || path == "/api/auth")
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// All other API paths require setup to be complete
|
||||
if (!await IsSetupCompleted())
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
public void ResetSetupState()
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private async Task<bool> IsSetupCompleted()
|
||||
{
|
||||
if (_setupCompleted)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow these paths regardless of setup state
|
||||
if (IsAllowedPath(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check database for setup completion
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is { SetupCompleted: true })
|
||||
{
|
||||
_setupCompleted = true;
|
||||
await _next(context);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Setup not complete - block non-auth requests
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached setup state. Call this if the user database is reset.
|
||||
/// </summary>
|
||||
public void ResetSetupState()
|
||||
private static bool IsSetupOnlyPath(string path)
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private static bool IsAllowedPath(string path)
|
||||
{
|
||||
return path.StartsWith("/api/auth/")
|
||||
|| path == "/api/auth"
|
||||
|| path.StartsWith("/health")
|
||||
|| !path.StartsWith("/api/");
|
||||
return path.StartsWith("/api/auth/setup/") || path == "/api/auth/setup";
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
Generated
+1304
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;
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
@@ -19,6 +20,7 @@ public sealed record NotifiarrConfig : IConfig
|
||||
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
[SensitiveData]
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
@@ -31,9 +32,11 @@ public sealed record NtfyConfig : IConfig
|
||||
public string? Username { get; init; }
|
||||
|
||||
[MaxLength(255)]
|
||||
[SensitiveData]
|
||||
public string? Password { get; init; }
|
||||
|
||||
|
||||
[MaxLength(500)]
|
||||
[SensitiveData]
|
||||
public string? AccessToken { get; init; }
|
||||
|
||||
[Required]
|
||||
|
||||
+3
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
@@ -22,6 +23,7 @@ public sealed partial record PushoverConfig : IConfig
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
[SensitiveData]
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +31,7 @@ public sealed partial record PushoverConfig : IConfig
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
[SensitiveData]
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
|
||||
+2
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
@@ -20,6 +21,7 @@ public sealed record TelegramConfig : IConfig
|
||||
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
[SensitiveData]
|
||||
public string BotToken { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
namespace Cleanuparr.Shared.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a property as containing sensitive data that should be encrypted when stored in configuration files.
|
||||
/// Defines how sensitive data should be masked in API responses.
|
||||
/// </summary>
|
||||
public enum SensitiveDataType
|
||||
{
|
||||
/// <summary>
|
||||
/// Full mask: replaces the entire value with bullets (••••••••).
|
||||
/// Use for passwords, API keys, tokens, webhook URLs.
|
||||
/// </summary>
|
||||
Full,
|
||||
|
||||
/// <summary>
|
||||
/// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••).
|
||||
/// Use for Apprise service URL strings that contain multiple notification service URLs.
|
||||
/// </summary>
|
||||
AppriseUrl,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a property as containing sensitive data that should be masked in API responses
|
||||
/// and preserved when the placeholder value is sent back in updates.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class SensitiveDataAttribute : Attribute
|
||||
public class SensitiveDataAttribute : Attribute
|
||||
{
|
||||
public SensitiveDataType Type { get; }
|
||||
|
||||
public SensitiveDataAttribute(SensitiveDataType type = SensitiveDataType.Full)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Cleanuparr.Shared.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for sensitive data masking in API responses and request handling.
|
||||
/// </summary>
|
||||
public static partial class SensitiveDataHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// The placeholder string used to mask sensitive data in API responses.
|
||||
/// When this value is detected in an update request, the existing DB value is preserved.
|
||||
/// </summary>
|
||||
public const string Placeholder = "••••••••";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given value contains the sensitive data placeholder.
|
||||
/// Uses Contains (not Equals) to handle Apprise URLs like "discord://••••••••".
|
||||
/// </summary>
|
||||
public static bool IsPlaceholder(this string? value)
|
||||
=> value is not null && value.Contains(Placeholder, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Masks Apprise service URLs by preserving only the scheme.
|
||||
/// Input: "discord://token slack://tokenA/tokenB"
|
||||
/// Output: "discord://•••••••• slack://••••••••"
|
||||
/// </summary>
|
||||
public static string? MaskAppriseUrls(string? serviceUrls)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serviceUrls))
|
||||
{
|
||||
return serviceUrls;
|
||||
}
|
||||
|
||||
return AppriseUrlPattern().Replace(serviceUrls, match =>
|
||||
{
|
||||
var scheme = match.Groups[1].Value;
|
||||
return $"{scheme}://{Placeholder}";
|
||||
});
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"([a-zA-Z][a-zA-Z0-9+.\-]*)://\S+")]
|
||||
private static partial Regex AppriseUrlPattern();
|
||||
}
|
||||
+30
-4
@@ -1,6 +1,34 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Default UMASK if unset to prevent errors with set -e
|
||||
UMASK="${UMASK:-022}"
|
||||
CURRENT_UID=$(id -u)
|
||||
|
||||
# If not running as root, skip all user/permission management.
|
||||
# This supports docker-compose `user: PUID:PGID` (rootless mode).
|
||||
if [ "$CURRENT_UID" != "0" ]; then
|
||||
umask "$UMASK"
|
||||
|
||||
# In rootless mode, the app uses /config for all writable state.
|
||||
# A non-root user cannot create directories under /, so validate early.
|
||||
if [ ! -d /config ]; then
|
||||
echo "ERROR: /config does not exist and the container is running as non-root (UID $CURRENT_UID)." >&2
|
||||
echo "Please mount a writable volume at /config." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -w /config ]; then
|
||||
echo "ERROR: /config is not writable by UID $CURRENT_UID." >&2
|
||||
echo "Please adjust permissions or mount /config as a writable volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Running as root — use PUID/PGID to create user and drop privileges
|
||||
|
||||
# Create group if it doesn't exist
|
||||
if ! getent group "$PGID" > /dev/null 2>&1; then
|
||||
echo "Creating group with GID $PGID"
|
||||
@@ -16,16 +44,14 @@ fi
|
||||
# Set umask
|
||||
umask "$UMASK"
|
||||
|
||||
# Change ownership of app directory if not running as root
|
||||
# Ensure /config is writable by the target user
|
||||
if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
|
||||
mkdir -p /config
|
||||
chown -R "$PUID:$PGID" /app
|
||||
chown -R "$PUID:$PGID" /config
|
||||
fi
|
||||
|
||||
# Execute the main command as the specified user
|
||||
# Execute as the specified user (or root if PUID=0)
|
||||
if [ "$PUID" = "0" ] && [ "$PGID" = "0" ]; then
|
||||
# Running as root, no need for gosu
|
||||
exec "$@"
|
||||
else
|
||||
# Use gosu to drop privileges
|
||||
|
||||
Generated
+954
-1147
File diff suppressed because it is too large.
Load diff
@@ -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({
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@
|
||||
</app-card>
|
||||
|
||||
@if (enabled()) {
|
||||
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded">
|
||||
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded" [error]="noFeaturesError()">
|
||||
@for (cat of categories(); track $index; let i = $index) {
|
||||
<div class="category-row">
|
||||
<div class="category-row__header">
|
||||
@@ -105,7 +105,7 @@
|
||||
</app-button>
|
||||
</app-accordion>
|
||||
|
||||
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded">
|
||||
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded" [error]="noFeaturesError()">
|
||||
<div class="form-stack">
|
||||
<app-toggle label="Enabled" [(checked)]="unlinkedEnabled"
|
||||
hint="Enable management of downloads that have no hardlinks"
|
||||
|
||||
+18
-2
@@ -6,6 +6,7 @@ import {
|
||||
EmptyStateComponent, LoadingStateComponent, type SelectOption,
|
||||
} from '@ui';
|
||||
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { DownloadCleanerConfig, CleanCategory, createDefaultCategory } from '@shared/models/download-cleaner-config.model';
|
||||
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
|
||||
@@ -123,7 +124,20 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
readonly noFeaturesError = computed(() => {
|
||||
if (!this.enabled()) return undefined;
|
||||
const hasSeedingCategories = this.categories().length > 0;
|
||||
const hasUnlinkedFeature = this.unlinkedEnabled()
|
||||
&& !this.unlinkedTargetCategoryError()
|
||||
&& !this.unlinkedCategoriesError();
|
||||
if (!hasSeedingCategories && !hasUnlinkedFeature) {
|
||||
return 'At least one feature must be configured';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
readonly hasErrors = computed(() => {
|
||||
if (this.noFeaturesError()) return true;
|
||||
if (this.scheduleEveryError()) return true;
|
||||
if (this.cronError()) return true;
|
||||
if (this.unlinkedTargetCategoryError()) return true;
|
||||
@@ -224,8 +238,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
setTimeout(() => this.saved.set(false), 1500);
|
||||
this.savedSnapshot.set(this.buildSnapshot());
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to save download cleaner settings');
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.statusCode === 400
|
||||
? err.message
|
||||
: 'Failed to save download cleaner settings');
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@
|
||||
helpKey="download-client:username" />
|
||||
}
|
||||
@if (showPasswordField()) {
|
||||
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalPassword"
|
||||
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalPassword"
|
||||
[hint]="passwordHint()"
|
||||
helpKey="download-client:password" />
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
|
||||
username: this.modalUsername(),
|
||||
password: this.modalPassword(),
|
||||
urlBase: this.modalUrlBase(),
|
||||
clientId: this.editingClient()?.id,
|
||||
};
|
||||
this.testing.set(true);
|
||||
this.api.test(request).subscribe({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
</app-card>
|
||||
|
||||
@if (enabled()) {
|
||||
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded">
|
||||
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded" [error]="noBlocklistError()">
|
||||
@for (name of arrNames; track name) {
|
||||
<div class="arr-blocklist">
|
||||
<h4 class="arr-blocklist__title">{{ capitalize(name) }}</h4>
|
||||
|
||||
+16
-2
@@ -6,6 +6,7 @@ import {
|
||||
type SelectOption,
|
||||
} from '@ui';
|
||||
import { MalwareBlockerApi } from '@core/api/malware-blocker.api';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { MalwareBlockerConfig, BlocklistSettings, MalwareScheduleOptions } from '@shared/models/malware-blocker-config.model';
|
||||
import { BlocklistType, ScheduleUnit } from '@shared/models/enums';
|
||||
@@ -120,7 +121,18 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
readonly noBlocklistError = computed(() => {
|
||||
if (!this.enabled()) return undefined;
|
||||
const blocklists = this.arrBlocklists();
|
||||
const hasAnyEnabled = ARR_NAMES.some(name => blocklists[name]?.enabled);
|
||||
if (!hasAnyEnabled) {
|
||||
return 'At least one blocklist must be configured';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
readonly hasErrors = computed(() => {
|
||||
if (this.noBlocklistError()) return true;
|
||||
if (this.scheduleEveryError()) return true;
|
||||
if (this.cronError()) return true;
|
||||
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
|
||||
@@ -225,8 +237,10 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
|
||||
setTimeout(() => this.saved.set(false), 1500);
|
||||
this.savedSnapshot.set(this.buildSnapshot());
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to save malware blocker settings');
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.statusCode === 400
|
||||
? err.message
|
||||
: 'Failed to save malware blocker settings');
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
<!-- Discord Fields -->
|
||||
@if (modalType() === 'Discord') {
|
||||
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [(value)]="modalWebhookUrl"
|
||||
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [revealable]="false" [(value)]="modalWebhookUrl"
|
||||
hint="Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations."
|
||||
[error]="discordWebhookError()"
|
||||
helpKey="notifications/discord:webhookUrl" />
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
<!-- Telegram Fields -->
|
||||
@if (modalType() === 'Telegram') {
|
||||
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [(value)]="modalBotToken"
|
||||
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [revealable]="false" [(value)]="modalBotToken"
|
||||
hint="Create a bot with BotFather and paste the API token"
|
||||
[error]="telegramBotTokenError()"
|
||||
helpKey="notifications/telegram:botToken" />
|
||||
@@ -135,7 +135,7 @@
|
||||
|
||||
<!-- Notifiarr Fields -->
|
||||
@if (modalType() === 'Notifiarr') {
|
||||
<app-input label="API Key" placeholder="Enter API key" type="password" [(value)]="modalApiKey"
|
||||
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [(value)]="modalApiKey"
|
||||
hint="Your Notifiarr API key from your dashboard. Requires Passthrough integration."
|
||||
[error]="notifiarrApiKeyError()"
|
||||
helpKey="notifications/notifiarr:apiKey" />
|
||||
@@ -187,12 +187,12 @@
|
||||
<app-input label="Username" placeholder="Enter username" [(value)]="modalNtfyUsername"
|
||||
hint="Your username for basic authentication."
|
||||
helpKey="notifications/ntfy:username" />
|
||||
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalNtfyPassword"
|
||||
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalNtfyPassword"
|
||||
hint="Your password for basic authentication."
|
||||
helpKey="notifications/ntfy:password" />
|
||||
}
|
||||
@if (modalNtfyAuthType() === 'AccessToken') {
|
||||
<app-input label="Access Token" placeholder="Enter access token" type="password" [(value)]="modalNtfyAccessToken"
|
||||
<app-input label="Access Token" placeholder="Enter access token" type="password" [revealable]="false" [(value)]="modalNtfyAccessToken"
|
||||
hint="Your access token for bearer token authentication."
|
||||
helpKey="notifications/ntfy:accessToken" />
|
||||
}
|
||||
@@ -206,11 +206,11 @@
|
||||
|
||||
<!-- Pushover Fields -->
|
||||
@if (modalType() === 'Pushover') {
|
||||
<app-input label="API Token" placeholder="Enter API token" type="password" [(value)]="modalPushoverApiToken"
|
||||
<app-input label="API Token" placeholder="Enter API token" type="password" [revealable]="false" [(value)]="modalPushoverApiToken"
|
||||
hint="Your application API token from Pushover. Create one at pushover.net/apps/build."
|
||||
[error]="pushoverApiTokenError()"
|
||||
helpKey="notifications/pushover:apiToken" />
|
||||
<app-input label="User Key" placeholder="Enter user key" type="password" [(value)]="modalPushoverUserKey"
|
||||
<app-input label="User Key" placeholder="Enter user key" type="password" [revealable]="false" [(value)]="modalPushoverUserKey"
|
||||
hint="Your user/group key from your Pushover dashboard."
|
||||
[error]="pushoverUserKeyError()"
|
||||
helpKey="notifications/pushover:userKey" />
|
||||
@@ -247,7 +247,7 @@
|
||||
hint="The base URL of your Gotify server instance."
|
||||
[error]="gotifyServerUrlError()"
|
||||
helpKey="notifications/gotify:serverUrl" />
|
||||
<app-input label="Application Token" placeholder="Enter application token" type="password" [(value)]="modalGotifyApplicationToken"
|
||||
<app-input label="Application Token" placeholder="Enter application token" type="password" [revealable]="false" [(value)]="modalGotifyApplicationToken"
|
||||
hint="The application token from your Gotify server. Create one under Apps in the Gotify web UI."
|
||||
[error]="gotifyApplicationTokenError()"
|
||||
helpKey="notifications/gotify:applicationToken" />
|
||||
|
||||
@@ -478,6 +478,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
testNotification(): void {
|
||||
const type = this.modalType();
|
||||
this.testing.set(true);
|
||||
const providerId = this.editingProvider()?.id;
|
||||
|
||||
switch (type) {
|
||||
case NotificationProviderType.Discord:
|
||||
@@ -485,6 +486,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
webhookUrl: this.modalWebhookUrl(),
|
||||
username: this.modalUsername() || undefined,
|
||||
avatarUrl: this.modalAvatarUrl() || undefined,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
@@ -496,6 +498,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
chatId: this.modalChatId(),
|
||||
topicId: this.modalTopicId() || undefined,
|
||||
sendSilently: this.modalSendSilently(),
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
@@ -505,6 +508,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
this.api.testNotifiarr({
|
||||
apiKey: this.modalApiKey(),
|
||||
channelId: this.modalChannelId(),
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
@@ -517,6 +521,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
key: this.modalAppriseKey() || undefined,
|
||||
tags: this.modalAppriseTags() || undefined,
|
||||
serviceUrls: this.modalAppriseServiceUrls().join('\n') || undefined,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
@@ -532,6 +537,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
accessToken: this.modalNtfyAccessToken() || undefined,
|
||||
priority: this.modalNtfyPriority() as NtfyPriority,
|
||||
tags: this.modalNtfyTags().length > 0 ? this.modalNtfyTags() : undefined,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
@@ -548,6 +554,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
retry: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverRetry() ?? 30) : undefined,
|
||||
expire: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverExpire() ?? 3600) : undefined,
|
||||
tags: this.modalPushoverTags().length > 0 ? this.modalPushoverTags() : undefined,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
@@ -559,6 +566,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
serverUrl: this.modalGotifyServerUrl(),
|
||||
applicationToken: this.modalGotifyApplicationToken(),
|
||||
priority: parseInt(this.modalGotifyPriority() as string, 10) || 5,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
|
||||
@@ -75,7 +75,6 @@ export class NavSidebarComponent {
|
||||
];
|
||||
|
||||
suggestedApps: ExternalLink[] = [
|
||||
{ label: 'Huntarr', icon: 'tablerExternalLink', href: 'https://github.com/plexguide/Huntarr.io' },
|
||||
];
|
||||
|
||||
onNavItemClick(): void {
|
||||
|
||||
@@ -26,4 +26,5 @@ export interface TestArrInstanceRequest {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
version: number;
|
||||
instanceId?: string;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export interface TestDownloadClientRequest {
|
||||
username?: string;
|
||||
password?: string;
|
||||
urlBase?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user