Compare commits

...
36 Commits
Author SHA1 Message Date
Flaminel 01dc90bfa7 Fix test workflows cancelling each other (#502) 2026-03-12 22:30:59 +02:00
Flaminel c37e6384a5 Add failed screen before login when the API can not be reached (#501) 2026-03-12 22:16:15 +02:00
Flaminel 70fc955d37 Add OIDC support (#500) 2026-03-12 22:12:20 +02:00
Flaminel a44f226e8a Pin dotnet build version (#499) 2026-03-10 20:42:58 +02:00
Flaminel edafde5810 Fix SQLite schema cause by process_no_content_id column (#498) 2026-03-10 20:28:23 +02:00
Flaminel 54cd037cd2 Update frontend packages (#496) 2026-03-10 17:54:02 +02:00
Flaminel 2ebf67d44d Add option to process items with no content id (#488) 2026-03-10 11:01:45 +02:00
Flaminel d2d294b93c Add explicit no-cache headers (#495) 2026-03-10 09:53:46 +02:00
Flaminel 39eb91ac48 Fix Cloudflare blocklist deployment (#491) 2026-03-06 18:33:32 +02:00
Flaminel 53376d94d9 Fix chip input not displaying the proper error message (#490) 2026-03-06 18:31:34 +02:00
Flaminel f51973bb7b Remove the known malware feature (#489) 2026-03-06 17:53:04 +02:00
Flaminel 33d1756fdd Revoke all refresh tokens on password change (#486) 2026-03-02 14:58:19 +02:00
Flaminel 63931763c4 Fix session invalidation for local auth bypass (#487) 2026-03-02 13:34:20 +02:00
Flaminel bdb956ec84 Add guards against exposing passwords to the UI (#477) 2026-03-02 13:11:34 +02:00
Flaminel 41b48d1104 Enforce authorize attribute explicitely (#485) 2026-03-02 13:00:23 +02:00
Flaminel 57fef26726 Add missing rTorrent and Whisparr v3 docs (#484) 2026-03-02 12:45:52 +02:00
Flaminel ea94dc4548 Fix reveal button being pressed on Enter instead of login (#483) 2026-03-02 12:41:35 +02:00
Flaminel 13a7232bc5 Update frontend packages (#478) 2026-02-28 04:16:11 +02:00
Flaminel 5fea8a0041 Add Docker multistep build (#475) 2026-02-27 20:42:27 +02:00
Flaminel 2333c86a08 Increase access token lifetime to 1 hour (#474) 2026-02-27 15:13:08 +02:00
Flaminel cbfc1b2875 Fix the Malware Blocker page not displaying validation errors (#473) 2026-02-25 22:28:49 +02:00
Flaminel 62e10afe7b Fix the Download Cleaner page not displaying validation errors (#472) 2026-02-25 22:20:49 +02:00
Flaminel d542c716f9 Add support for rootless Docker container (#471) 2026-02-24 19:03:01 +02:00
Flaminel 7eeaefaa65 Fix some auth endpoints not checking if user setup is complete (#468) 2026-02-23 20:33:22 +02:00
Flaminel bd55356881 Remove Huntarr because it's too hot right now (#467) 2026-02-23 19:11:08 +02:00
Flaminel df986a2e36 Redirect to base path from root path (#466) 2026-02-23 17:24:48 +02:00
Flaminel 8a2aca79f7 Add local auth bypass and make 2FA optional (#461) 2026-02-23 11:49:55 +02:00
Flaminel e1cc0dba28 Add rTorrent support (#446) 2026-02-22 19:49:14 +02:00
Flaminel 660dce0fa7 Fix strikes not being reset when disabling dry run (#456) 2026-02-22 19:37:03 +02:00
Flaminel ee343ba469 Fixed failed imports input being invalid when strikes are set to 0 (#454) 2026-02-22 19:00:52 +02:00
Flaminel d038738008 Add seeding rule privacy type (#449) 2026-02-22 00:55:22 +02:00
Flaminel 70f9995041 Add stats endpoint used for dashboards (#453) 2026-02-21 02:27:15 +02:00
Flaminel 6a1aaec7c2 Add seconds to the jobs next run in the dashboard (#451) 2026-02-20 19:18:22 +02:00
Flaminel 50e486bf5f Add authentication (#444) 2026-02-20 18:09:29 +02:00
Flaminel bc15a9a934 Fix PWA installation (#450) 2026-02-20 16:27:22 +02:00
Flaminel 573dbcf882 Fix modal transparency (#448) 2026-02-17 00:23:31 +02:00
326 changed files with 29581 additions and 2293 deletions

No files matched your search

+157 -53
View File
@@ -22,23 +22,32 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
jobs:
build_app:
# Compute tags, version, and push decision for downstream jobs
prepare:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
outputs:
tags: ${{ steps.build-info.outputs.tags }}
version: ${{ steps.build-info.outputs.version }}
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
branch: ${{ steps.build-info.outputs.branch }}
push: ${{ steps.build-info.outputs.push }}
github_sha: ${{ github.sha }}
steps:
- name: Set github context
timeout-minutes: 1
run: |
echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV
echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV
echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV
echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV
- name: Initialize build info
id: build-info
timeout-minutes: 1
run: |
githubHeadRef=${{ env.githubHeadRef }}
githubHeadRef="${{ github.head_ref }}"
githubRef="${{ github.ref }}"
inputVersion="${{ inputs.app_version }}"
latestDockerTag=""
versionDockerTag=""
@@ -71,10 +80,8 @@ jobs:
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
else
# Determine if this run is for the main branch or another branch
if [[ -z "$githubHeadRef" ]]; then
# Main branch
githubRef=${{ env.githubRef }}
branch=${githubRef##*/}
versionDockerTag="$branch"
else
@@ -85,25 +92,45 @@ jobs:
fi
githubTags=""
if [ -n "$latestDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
fi
if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
fi
if [ -n "$minorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
fi
if [ -n "$majorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
fi
# set env vars
echo "branch=$branch" >> $GITHUB_ENV
echo "githubTags=$githubTags" >> $GITHUB_ENV
echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV
echo "version=$version" >> $GITHUB_ENV
githubTags="${githubTags#,}"
# Determine push decision
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
echo "tags=$githubTags" >> $GITHUB_OUTPUT
echo "version=$version" >> $GITHUB_OUTPUT
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
echo "branch=$branch" >> $GITHUB_OUTPUT
echo "push=$push" >> $GITHUB_OUTPUT
# Build each platform in parallel
build:
runs-on: ubuntu-latest
needs: [prepare]
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare platform pair
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
@@ -113,8 +140,6 @@ jobs:
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/docker username | DOCKER_USERNAME;
secrets/data/docker password | DOCKER_PASSWORD;
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
@@ -122,16 +147,97 @@ jobs:
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ env.githubRepository }}
ref: ${{ env.branch }}
repository: ${{ github.repository }}
ref: ${{ needs.prepare.outputs.branch }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
timeout-minutes: 5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to GitHub Container Registry
if: needs.prepare.outputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
if: needs.prepare.outputs.push == 'true'
id: build-push
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Build (no push)
if: needs.prepare.outputs.push != 'true'
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
push: false
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
if: needs.prepare.outputs.push == 'true'
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build-push.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: needs.prepare.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Create multi-platform manifest and push with final tags
merge:
runs-on: ubuntu-latest
needs: [prepare, build]
if: needs.prepare.outputs.push == 'true'
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -140,27 +246,25 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push docker image
id: docker-build
timeout-minutes: 15
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ env.githubSha }}
version=${{ env.versionDockerTag }}
build-args: |
VERSION=${{ env.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: |
linux/amd64
linux/arm64
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
tags: |
${{ env.githubTags }}
# Enable BuildKit cache for faster builds
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create manifest list and push
timeout-minutes: 5
working-directory: ${{ runner.temp }}/digests
run: |
tags="${{ needs.prepare.outputs.tags }}"
tag_args=""
IFS=',' read -ra TAG_ARRAY <<< "$tags"
for tag in "${TAG_ARRAY[@]}"; do
tag=$(echo "$tag" | xargs)
if [ -n "$tag" ]; then
tag_args="$tag_args -t $tag"
fi
done
docker buildx imagetools create $tag_args \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
tags="${{ needs.prepare.outputs.tags }}"
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
docker buildx imagetools inspect "$first_tag"
+2 -2
View File
@@ -74,9 +74,9 @@ jobs:
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup dotnet
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
+2 -2
View File
@@ -84,9 +84,9 @@ jobs:
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
@@ -68,9 +68,9 @@ jobs:
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
@@ -20,6 +20,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create directory for static files
run: |
mkdir -p Cloudflare/static
- name: Copy root static files to Cloudflare static directory
run: |
+94
View File
@@ -0,0 +1,94 @@
name: E2E Tests
on:
push:
branches:
- main
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
pull_request:
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
workflow_call:
concurrency:
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Start services
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml up -d --build
env:
PACKAGES_USERNAME: ${{ github.repository_owner }}
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install E2E dependencies
working-directory: e2e
run: npm ci
- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium
- name: Wait for Keycloak
run: |
echo "Waiting for Keycloak realm to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
echo "Keycloak ready!"
- name: Wait for app
run: |
echo "Waiting for Cleanuparr to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
echo "App ready!"
- name: Run E2E tests
working-directory: e2e
run: npx playwright test
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7
- name: Stop services
if: always()
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml down
+19 -6
View File
@@ -96,24 +96,33 @@ jobs:
uses: ./.github/workflows/test.yml
secrets: inherit
# Run E2E tests
e2e:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
# Build frontend once for all build jobs and cache it
build-frontend:
needs: [validate, test]
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-frontend.yml
secrets: inherit
# Build portable executables
build-executables:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-executable.yml
@@ -123,11 +132,12 @@ jobs:
# Build Windows installer
build-windows-installer:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-windows-installer.yml
@@ -137,11 +147,12 @@ jobs:
# Build macOS installers (Intel and ARM)
build-macos:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-macos-installer.yml
@@ -151,11 +162,12 @@ jobs:
# Build and push Docker image(s)
build-docker:
needs: [validate, test]
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
uses: ./.github/workflows/build-docker.yml
with:
@@ -232,7 +244,7 @@ jobs:
# Summary job
summary:
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
runs-on: ubuntu-latest
if: always()
@@ -277,6 +289,7 @@ jobs:
}
print_result "Tests" "${{ needs.test.result }}"
print_result "E2E Tests" "${{ needs.e2e.result }}"
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
print_result "Portable Executables" "${{ needs.build-executables.result }}"
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
+3 -3
View File
@@ -15,7 +15,7 @@ on:
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -29,9 +29,9 @@ jobs:
timeout-minutes: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
+1 -1
View File
@@ -10,7 +10,7 @@
## Project Overview
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
**Key Features:**
- Strike system for bad downloads
@@ -1,2 +0,0 @@
thepirateheaven.org
RARBG.work
+1 -8
View File
@@ -49,6 +49,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
- **Transmission**
- **Deluge**
- **µTorrent**
- **rTorrent**
### Platforms
- **Docker**
@@ -101,14 +102,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle">![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social)</span></span>
# Credits
Special thanks for inspiration go to:
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
+6
View File
@@ -15,6 +15,12 @@ ifndef name
endif
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
migrate-users:
ifndef name
$(error name is required. Usage: make migrate-users name=YourMigrationName)
endif
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
docker-build:
ifndef tag
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
// Integration tests share file-system state (config-dir users.db used by SetupGuardMiddleware),
// so we must run them sequentially to avoid interference between factories.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace Cleanuparr.Api.Tests;
/// <summary>
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
/// The database file is created in a temp directory so both DI and static contexts share the same data.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _tempDir;
public CustomWebApplicationFactory()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Remove the existing UsersContext registration
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
if (descriptor != null) services.Remove(descriptor);
// Also remove the DbContext registration itself
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
if (contextDescriptor != null) services.Remove(contextDescriptor);
var dbPath = Path.Combine(_tempDir, "users.db");
services.AddDbContext<UsersContext>(options =>
{
options.UseSqlite($"Data Source={dbPath}");
});
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
// with a disposed ILoggerFactory from the previous factory lifecycle.
// Auth tests don't depend on background job scheduling, so this is safe.
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
services.Remove(hostedService);
// Ensure DB is created using a minimal isolated context (not the full app DI container)
// to avoid any residual static state contamination.
using var db = new UsersContext(
new DbContextOptionsBuilder<UsersContext>()
.UseSqlite($"Data Source={dbPath}")
.Options);
db.Database.EnsureCreated();
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(_tempDir))
{
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
}
}
}
@@ -0,0 +1,498 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly OidcLinkWebApplicationFactory _factory;
// Shared across ordered tests
private static string? _accessToken;
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
if (_accessToken is not null)
{
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
}
[Fact, TestPriority(0)]
public async Task Setup_CreateAccountAndComplete()
{
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "linkadmin",
password = "LinkPassword123!"
});
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(1)]
public async Task Login_StoreAccessToken()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "linkadmin",
password = "LinkPassword123!"
});
var bodyText = await response.Content.ReadAsStringAsync();
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
body.TryGetProperty("requiresTwoFactor", out var rtf)
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
rtf.GetBoolean().ShouldBeFalse();
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
_accessToken.ShouldNotBeNullOrEmpty();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
[Fact, TestPriority(2)]
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
{
var response = await _client.PostAsync("/api/account/oidc/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
}
[Fact, TestPriority(3)]
public async Task EnableOidcConfig_ViaDirectDbUpdate()
{
await _factory.EnableOidcAsync();
var statusResponse = await _client.GetAsync("/api/auth/status");
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
}
[Fact, TestPriority(4)]
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
{
var response = await _client.PostAsync("/api/account/oidc/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
var authUrl = body.GetProperty("authorizationUrl").GetString();
authUrl.ShouldNotBeNullOrEmpty();
authUrl.ShouldContain("authorize");
}
[Fact, TestPriority(5)]
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
{
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/settings/account");
location.ShouldContain("oidc_link_error=failed");
}
[Fact, TestPriority(6)]
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
{
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
}
[Fact, TestPriority(7)]
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
{
// First trigger StartOidcLink so the mock captures the initiatorUserId
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
// Now simulate the IdP callback with the mock's success state
var callbackResponse = await _client.GetAsync(
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = callbackResponse.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/settings/account");
location.ShouldContain("oidc_link=success");
location.ShouldNotContain("oidc_link_error");
// Verify the subject was saved to config
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
}
[Fact, TestPriority(8)]
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
{
var response = await _client.GetAsync(
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_link_error=failed");
}
[Fact, TestPriority(9)]
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
{
// Create a fresh unauthenticated client
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
#region Exclusive Mode
[Fact, TestPriority(10)]
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
{
await _factory.SetOidcExclusiveModeAsync(true);
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
}
[Fact, TestPriority(11)]
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PutAsJsonAsync("/api/account/password", new
{
currentPassword = "LinkPassword123!",
newPassword = "NewPassword456!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(12)]
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsync("/api/account/plex/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(13)]
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
{
var response = await _client.DeleteAsync("/api/account/plex/link");
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(14)]
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
{
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
{
enabled = true,
issuerUrl = "https://mock-oidc-provider.test",
clientId = "test-client",
clientSecret = "test-secret",
scopes = "openid profile email",
authorizedSubject = MockOidcAuthService.LinkedSubject,
providerName = "TestProvider",
redirectUrl = "",
exclusiveMode = true
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(15)]
public async Task OidcUnlink_ResetsExclusiveMode()
{
var response = await _client.DeleteAsync("/api/account/oidc/link");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
// Verify exclusive mode was reset
var exclusiveMode = await _factory.GetExclusiveModeAsync();
exclusiveMode.ShouldBeFalse();
}
[Fact, TestPriority(16)]
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
{
// Re-enable OIDC with a linked subject but without exclusive mode
await _factory.EnableOidcAsync();
await _factory.SetOidcExclusiveModeAsync(false);
var response = await _client.PutAsJsonAsync("/api/account/password", new
{
currentPassword = "LinkPassword123!",
newPassword = "NewPassword789!"
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
#endregion
#region Test Infrastructure
public class OidcLinkWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _configDir;
public OidcLinkWebApplicationFactory()
{
_configDir = Cleanuparr.Shared.Helpers.ConfigurationPathProvider.GetConfigPath();
// Delete both databases (and their WAL sidecar files) so each test run starts clean.
// users.db must be at the config path so CreateStaticInstance() (used by
// SetupGuardMiddleware) reads the same file as the DI-injected UsersContext.
// ClearAllPools() releases any SQLite connections held by the previous test factory's
// connection pool, which would otherwise prevent file deletion on Windows or cause
// SQLite to reconstruct a partial database from stale WAL files on other platforms.
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (var name in new[] {
"users.db", "users.db-shm", "users.db-wal",
"cleanuparr.db", "cleanuparr.db-shm", "cleanuparr.db-wal" })
{
var path = Path.Combine(_configDir, name);
if (File.Exists(path))
try { File.Delete(path); } catch { /* best effort */ }
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
if (descriptor != null) services.Remove(descriptor);
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
if (contextDescriptor != null) services.Remove(contextDescriptor);
// Use the config-path db so SetupGuardMiddleware (CreateStaticInstance) sees
// the same data as the DI-injected context. Apply the same naming conventions
// as CreateStaticInstance() so the schemas match.
var dbPath = Path.Combine(_configDir, "users.db");
services.AddDbContext<UsersContext>(options =>
{
options
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
});
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
// with a disposed ILoggerFactory from the previous factory lifecycle.
// Auth tests don't depend on background job scheduling, so this is safe.
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
services.Remove(hostedService);
// Ensure DB is created using a minimal isolated context (not the full app DI container)
// to avoid any residual static state contamination.
using var db = new UsersContext(
new DbContextOptionsBuilder<UsersContext>()
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention()
.Options);
db.Database.EnsureCreated();
});
}
public async Task EnableOidcAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return;
}
user.Oidc = new OidcConfig
{
Enabled = true,
IssuerUrl = "https://mock-oidc-provider.test",
ClientId = "test-client",
ClientSecret = "test-secret",
Scopes = "openid profile email",
AuthorizedSubject = "initial-subject",
ProviderName = "TestProvider"
};
await usersContext.SaveChangesAsync();
}
public async Task<string?> GetAuthorizedSubjectAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
return user?.Oidc.AuthorizedSubject;
}
public async Task SetOidcExclusiveModeAsync(bool enabled)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.ExclusiveMode = enabled;
await usersContext.SaveChangesAsync();
}
}
public async Task<bool> GetExclusiveModeAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
return user?.Oidc.ExclusiveMode ?? false;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (var name in new[] {
"users.db", "users.db-shm", "users.db-wal",
"cleanuparr.db", "cleanuparr.db-shm", "cleanuparr.db-wal" })
{
var path = Path.Combine(_configDir, name);
if (File.Exists(path))
try { File.Delete(path); } catch { /* best effort */ }
}
}
}
}
private sealed class MockOidcAuthService : IOidcAuthService
{
public const string LinkSuccessState = "mock-link-success-state";
public const string NoInitiatorState = "mock-no-initiator-state";
public const string LinkedSubject = "newly-linked-subject-123";
private string? _lastInitiatorUserId;
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
{
_lastInitiatorUserId = initiatorUserId;
return Task.FromResult(new OidcAuthorizationResult
{
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
State = LinkSuccessState
});
}
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
{
if (state == LinkSuccessState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = LinkedSubject,
PreferredUsername = "linkuser",
Email = "link@example.com",
InitiatorUserId = _lastInitiatorUserId
});
}
if (state == NoInitiatorState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = LinkedSubject,
InitiatorUserId = null // No initiator — controller should redirect with error
});
}
return Task.FromResult(new OidcCallbackResult
{
Success = false,
Error = "Invalid or expired OIDC state"
});
}
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
{
var code = Guid.NewGuid().ToString("N");
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = expiresIn
});
return code;
}
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
}
#endregion
}
@@ -0,0 +1,322 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for the authentication flow.
/// Uses a single shared factory to avoid static state conflicts.
/// Tests are ordered to build on each other: setup → login → protected endpoints.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public AuthControllerTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact, TestPriority(0)]
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
}
[Fact, TestPriority(0)]
public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders()
{
var response = await _client.GetAsync("/api/auth/status");
response.Headers.CacheControl.ShouldNotBeNull();
response.Headers.CacheControl!.NoCache.ShouldBeTrue();
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
}
[Fact, TestPriority(1)]
public async Task Setup_CreateAccount_ReturnsCreated()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(2)]
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "another",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
}
[Fact, TestPriority(3)]
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
// Store the secret for the next test
_totpSecret = body.GetProperty("secret").GetString()!;
}
[Fact, TestPriority(4)]
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
{
// If we don't have the secret from the previous test, generate it again
if (string.IsNullOrEmpty(_totpSecret))
{
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
_totpSecret = genBody.GetProperty("secret").GetString()!;
}
var code = GenerateTotpCode(_totpSecret);
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(5)]
public async Task Setup_Complete_Succeeds()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(6)]
public async Task Login_ValidCredentials_RequiresTwoFactor()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(7)]
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "WrongPassword!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact, TestPriority(8)]
public async Task Login_BruteForce_ReturnsRetryAfter()
{
// Make multiple failed attempts
for (int i = 0; i < 3; i++)
{
await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "WrongPassword!"
});
}
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "WrongPassword!"
});
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
}
else
{
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
retry.GetInt32().ShouldBeGreaterThan(0);
}
}
[Fact, TestPriority(9)]
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
{
var response = await _client.GetAsync("/api/account");
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(10)]
public async Task HealthEndpoint_WithoutAuth_Returns200()
{
var response = await _client.GetAsync("/health");
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();
}
[Fact, TestPriority(16)]
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "nonexistent-one-time-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(17)]
public async Task AuthStatus_IncludesOidcFields()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// Verify OIDC fields exist in the response (values depend on shared static DB state)
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
}
#region TOTP helpers
private static string _totpSecret = "";
private static string GenerateTotpCode(string base32Secret)
{
var key = Base32Decode(base32Secret);
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
var timestepBytes = BitConverter.GetBytes(timestep);
if (BitConverter.IsLittleEndian)
Array.Reverse(timestepBytes);
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
var hash = hmac.ComputeHash(timestepBytes);
var offset = hash[^1] & 0x0F;
var binaryCode =
((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
return (binaryCode % 1_000_000).ToString("D6");
}
private static byte[] Base32Decode(string base32)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
base32 = base32.ToUpperInvariant().TrimEnd('=');
var bits = new List<byte>();
foreach (var c in base32)
{
var val = alphabet.IndexOf(c);
if (val < 0) continue;
for (var i = 4; i >= 0; i--)
bits.Add((byte)((val >> i) & 1));
}
var bytes = new byte[bits.Count / 8];
for (var i = 0; i < bytes.Length; i++)
{
for (var j = 0; j < 8; j++)
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
}
return bytes;
}
#endregion
}
@@ -0,0 +1,656 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for the OIDC authentication flow.
/// Uses a mock IOidcAuthService to simulate IdP behavior.
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly OidcWebApplicationFactory _factory;
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // We want to inspect redirects
});
}
[Fact, TestPriority(0)]
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
// but the controller returns BadRequest because OIDC is not configured
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
[Fact, TestPriority(1)]
public async Task Setup_CreateAccountAndComplete()
{
// Create account
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "admin",
password = "TestPassword123!"
});
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
// Complete setup (skip 2FA for this test suite)
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(2)]
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
}
[Fact, TestPriority(3)]
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "some-random-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(4)]
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
{
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/auth/login");
location.ShouldContain("oidc_error=provider_error");
}
[Fact, TestPriority(5)]
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
{
var response = await _client.GetAsync("/api/auth/oidc/callback");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=invalid_request");
}
[Fact, TestPriority(6)]
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
{
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=invalid_request");
}
[Fact, TestPriority(7)]
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
{
// Even with code and state, if the state is invalid the mock will return failure
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=authentication_failed");
}
[Fact, TestPriority(8)]
public async Task EnableOidcConfig_ViaDirectDbUpdate()
{
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
await _factory.EnableOidcAsync();
// Verify auth status reflects OIDC enabled
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
}
[Fact, TestPriority(9)]
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
var authUrl = body.GetProperty("authorizationUrl").GetString();
authUrl.ShouldNotBeNullOrEmpty();
authUrl.ShouldContain("authorize");
}
[Fact, TestPriority(10)]
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
{
// Use the mock's valid state to simulate a successful callback
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/auth/oidc/callback");
location.ShouldContain("code=");
// Should NOT contain oidc_error
location.ShouldNotContain("oidc_error");
}
[Fact, TestPriority(11)]
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
{
// First, trigger a valid callback to get a one-time code
var callbackResponse = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = callbackResponse.Headers.Location?.ToString();
location.ShouldNotBeNull();
// Extract the one-time code from the redirect URL
var uri = new Uri("http://localhost" + location);
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
var oneTimeCode = queryParams["code"];
oneTimeCode.ShouldNotBeNullOrEmpty();
// Exchange the one-time code for tokens
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = oneTimeCode
});
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
}
[Fact, TestPriority(12)]
public async Task OidcExchange_SameCodeTwice_SecondFails()
{
// First, trigger a valid callback
var callbackResponse = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
var location = callbackResponse.Headers.Location?.ToString()!;
var uri = new Uri("http://localhost" + location);
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
var oneTimeCode = queryParams["code"]!;
// First exchange succeeds
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
// Second exchange with same code fails
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(13)]
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "completely-invalid-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(14)]
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
{
// Use the mock's state that returns a different subject
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=unauthorized");
}
[Fact, TestPriority(15)]
public async Task AuthStatus_IncludesOidcFields()
{
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();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
}
[Fact, TestPriority(16)]
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
// Should succeed (no 2FA since we skipped it in setup)
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// No 2FA, so should have tokens directly
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
}
[Fact, TestPriority(17)]
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
{
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
await _factory.SetOidcAuthorizedSubjectAsync("");
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
// Restore for subsequent tests
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
}
[Fact, TestPriority(17)]
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
{
// OIDC should be disabled when essential config (IssuerUrl) is missing
await _factory.SetOidcIssuerUrlAsync("");
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
// Restore for subsequent tests
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
}
[Fact, TestPriority(17)]
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
{
// Clear the authorized subject — any OIDC user should be allowed
await _factory.SetOidcAuthorizedSubjectAsync("");
// Use the "wrong subject" state — this returns a different subject than the authorized one
// With no linked subject, it should still succeed
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("code=");
location.ShouldNotContain("oidc_error");
// Restore for subsequent tests
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
}
[Fact, TestPriority(18)]
public async Task OidcExchange_RandomCode_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "completely-random-nonexistent-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
#region Exclusive Mode
[Fact, TestPriority(19)]
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
{
await _factory.SetOidcExclusiveModeAsync(true);
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
}
[Fact, TestPriority(20)]
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(21)]
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
{
loginToken = "some-token",
code = "123456"
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(22)]
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(23)]
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
{
pinId = 12345
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(24)]
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(25)]
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
{
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("code=");
location.ShouldNotContain("oidc_error");
}
[Fact, TestPriority(26)]
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
{
await _factory.SetOidcExclusiveModeAsync(false);
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
#endregion
#region Test Infrastructure
/// <summary>
/// Custom factory that replaces IOidcAuthService with a mock for testing.
/// </summary>
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _tempDir;
public OidcWebApplicationFactory()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
// Clean up any existing DataContext DB from previous test runs.
// DataContext.CreateStaticInstance() uses ConfigurationPathProvider which
// resolves to {AppContext.BaseDirectory}/config/cleanuparr.db.
// We need to ensure a clean state for our tests.
var configDir = Cleanuparr.Shared.Helpers.ConfigurationPathProvider.GetConfigPath();
var dataDbPath = Path.Combine(configDir, "cleanuparr.db");
if (File.Exists(dataDbPath))
{
try { File.Delete(dataDbPath); } catch { /* best effort */ }
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Remove existing UsersContext registration
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
if (descriptor != null) services.Remove(descriptor);
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
if (contextDescriptor != null) services.Remove(contextDescriptor);
var dbPath = Path.Combine(_tempDir, "users.db");
services.AddDbContext<UsersContext>(options =>
{
options.UseSqlite($"Data Source={dbPath}");
});
// Replace IOidcAuthService with mock
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
// with a disposed ILoggerFactory from the previous factory lifecycle.
// Auth tests don't depend on background job scheduling, so this is safe.
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
services.Remove(hostedService);
// Ensure DB is created using a minimal isolated context (not the full app DI container)
// to avoid any residual static state contamination.
using var db = new UsersContext(
new DbContextOptionsBuilder<UsersContext>()
.UseSqlite($"Data Source={dbPath}")
.Options);
db.Database.EnsureCreated();
});
}
/// <summary>
/// Enables OIDC on the user in the UsersContext database.
/// </summary>
public async Task EnableOidcAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return;
}
user.Oidc = new OidcConfig
{
Enabled = true,
IssuerUrl = "https://mock-oidc-provider.test",
ClientId = "test-client",
ClientSecret = "test-secret",
Scopes = "openid profile email",
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
ProviderName = "TestProvider"
};
await usersContext.SaveChangesAsync();
}
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.IssuerUrl = issuerUrl;
await usersContext.SaveChangesAsync();
}
}
public async Task SetOidcAuthorizedSubjectAsync(string subject)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.AuthorizedSubject = subject;
await usersContext.SaveChangesAsync();
}
}
public async Task SetOidcExclusiveModeAsync(bool enabled)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.ExclusiveMode = enabled;
await usersContext.SaveChangesAsync();
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(_tempDir))
{
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
}
}
}
/// <summary>
/// Mock OIDC auth service that simulates IdP behavior without network calls.
/// </summary>
private sealed class MockOidcAuthService : IOidcAuthService
{
public const string ValidState = "mock-valid-state";
public const string WrongSubjectState = "mock-wrong-subject-state";
public const string AuthorizedSubject = "mock-authorized-subject-123";
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
{
return Task.FromResult(new OidcAuthorizationResult
{
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
State = ValidState
});
}
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
{
if (state == ValidState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = AuthorizedSubject,
PreferredUsername = "testuser",
Email = "testuser@example.com"
});
}
if (state == WrongSubjectState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = "wrong-subject-that-doesnt-match",
PreferredUsername = "wronguser",
Email = "wrong@example.com"
});
}
return Task.FromResult(new OidcCallbackResult
{
Success = false,
Error = "Invalid or expired OIDC state"
});
}
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
{
var code = Guid.NewGuid().ToString("N");
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = expiresIn
});
return code;
}
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
{
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
}
}
#endregion
}
@@ -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,381 @@
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Auth;
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
#region UpdateOidcConfigRequest UPDATE
[Fact]
public void UpdateOidcConfigRequest_ApplyTo_WithPlaceholderClientSecret_PreservesExistingValue()
{
var request = new UpdateOidcConfigRequest
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = Placeholder,
Scopes = "openid profile email",
ProviderName = "Keycloak",
};
var existingConfig = new OidcConfig
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = "original-secret",
Scopes = "openid profile email",
ProviderName = "OIDC",
};
request.ApplyTo(existingConfig);
existingConfig.ClientSecret.ShouldBe("original-secret");
existingConfig.ProviderName.ShouldBe("Keycloak");
}
[Fact]
public void UpdateOidcConfigRequest_ApplyTo_WithRealClientSecret_UpdatesValue()
{
var request = new UpdateOidcConfigRequest
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = "brand-new-secret",
Scopes = "openid profile email",
ProviderName = "Keycloak",
};
var existingConfig = new OidcConfig
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = "original-secret",
Scopes = "openid profile email",
ProviderName = "OIDC",
};
request.ApplyTo(existingConfig);
existingConfig.ClientSecret.ShouldBe("brand-new-secret");
}
#endregion
}
@@ -0,0 +1,461 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Cleanuparr.Api.Json;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
/// <summary>
/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties
/// during JSON serialization — this is what controls the API response output.
/// </summary>
public class SensitiveDataResolverTests
{
private readonly JsonSerializerOptions _options;
private const string Placeholder = SensitiveDataHelper.Placeholder;
public SensitiveDataResolverTests()
{
_options = new JsonSerializerOptions
{
TypeInfoResolver = new SensitiveDataResolver(new DefaultJsonTypeInfoResolver()),
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
}
#region ArrInstance
[Fact]
public void ArrInstance_ApiKey_IsMasked()
{
var instance = new ArrInstance
{
Name = "Sonarr",
Url = new Uri("http://sonarr:8989"),
ApiKey = "super-secret-api-key-12345",
ArrConfigId = Guid.NewGuid(),
Version = 4
};
var json = JsonSerializer.Serialize(instance, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
}
[Fact]
public void ArrInstance_NonSensitiveFields_AreVisible()
{
var instance = new ArrInstance
{
Name = "Sonarr",
Url = new Uri("http://sonarr:8989"),
ExternalUrl = new Uri("https://sonarr.example.com"),
ApiKey = "super-secret-api-key-12345",
ArrConfigId = Guid.NewGuid(),
Version = 4
};
var json = JsonSerializer.Serialize(instance, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("name").GetString().ShouldBe("Sonarr");
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://sonarr:8989");
doc.RootElement.GetProperty("externalUrl").GetString().ShouldBe("https://sonarr.example.com");
}
[Fact]
public void ArrInstance_NullApiKey_RemainsNull()
{
// ApiKey is required, but let's test with the DTO which might handle null
var dto = new ArrInstanceDto
{
Name = "Sonarr",
Url = "http://sonarr:8989",
ApiKey = null!,
Version = 4
};
var json = JsonSerializer.Serialize(dto, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region ArrInstanceDto
[Fact]
public void ArrInstanceDto_ApiKey_IsMasked()
{
var dto = new ArrInstanceDto
{
Id = Guid.NewGuid(),
Name = "Radarr",
Url = "http://radarr:7878",
ApiKey = "dto-secret-api-key-67890",
Version = 5
};
var json = JsonSerializer.Serialize(dto, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("name").GetString().ShouldBe("Radarr");
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://radarr:7878");
}
#endregion
#region DownloadClientConfig
[Fact]
public void DownloadClientConfig_Password_IsMasked()
{
var config = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
Username = "admin",
Password = "my-secret-password",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
}
[Fact]
public void DownloadClientConfig_Username_IsVisible()
{
var config = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
Username = "admin",
Password = "my-secret-password",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("username").GetString().ShouldBe("admin");
doc.RootElement.GetProperty("name").GetString().ShouldBe("qBittorrent");
}
[Fact]
public void DownloadClientConfig_NullPassword_RemainsNull()
{
var config = new DownloadClientConfig
{
Name = "qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://qbit:8080"),
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region NotifiarrConfig
[Fact]
public void NotifiarrConfig_ApiKey_IsMasked()
{
var config = new NotifiarrConfig
{
ApiKey = "notifiarr-api-key-secret",
ChannelId = "123456789"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("123456789");
}
#endregion
#region DiscordConfig
[Fact]
public void DiscordConfig_WebhookUrl_IsMasked()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456/secret-token",
Username = "Cleanuparr Bot",
AvatarUrl = "https://example.com/avatar.png"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("username").GetString().ShouldBe("Cleanuparr Bot");
doc.RootElement.GetProperty("avatarUrl").GetString().ShouldBe("https://example.com/avatar.png");
}
#endregion
#region TelegramConfig
[Fact]
public void TelegramConfig_BotToken_IsMasked()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCdefGHIjklmnoPQRstuvWXyz",
ChatId = "-1001234567890",
TopicId = "42",
SendSilently = true
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("botToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("chatId").GetString().ShouldBe("-1001234567890");
doc.RootElement.GetProperty("topicId").GetString().ShouldBe("42");
}
#endregion
#region NtfyConfig
[Fact]
public void NtfyConfig_PasswordAndAccessToken_AreMasked()
{
var config = new NtfyConfig
{
ServerUrl = "https://ntfy.example.com",
Topics = ["test-topic"],
AuthenticationType = NtfyAuthenticationType.BasicAuth,
Username = "ntfy-user",
Password = "ntfy-secret-password",
AccessToken = "ntfy-access-token-secret",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("accessToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://ntfy.example.com");
doc.RootElement.GetProperty("username").GetString().ShouldBe("ntfy-user");
}
[Fact]
public void NtfyConfig_NullPasswordAndAccessToken_RemainNull()
{
var config = new NtfyConfig
{
ServerUrl = "https://ntfy.example.com",
Topics = ["test-topic"],
AuthenticationType = NtfyAuthenticationType.None,
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
doc.RootElement.GetProperty("accessToken").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region PushoverConfig
[Fact]
public void PushoverConfig_ApiTokenAndUserKey_AreMasked()
{
var config = new PushoverConfig
{
ApiToken = "pushover-api-token-secret",
UserKey = "pushover-user-key-secret",
Priority = PushoverPriority.Normal,
Devices = ["iphone", "desktop"]
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("devices").GetArrayLength().ShouldBe(2);
}
#endregion
#region GotifyConfig
[Fact]
public void GotifyConfig_ApplicationToken_IsMasked()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "gotify-app-token-secret",
Priority = 5
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("applicationToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://gotify.example.com");
}
#endregion
#region AppriseConfig
[Fact]
public void AppriseConfig_Key_IsMasked_WithFullMask()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Api,
Url = "https://apprise.example.com",
Key = "apprise-config-key-secret",
Tags = "urgent",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("key").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("url").GetString().ShouldBe("https://apprise.example.com");
}
[Fact]
public void AppriseConfig_ServiceUrls_IsMasked_WithAppriseUrlMask()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
ServiceUrls = "discord://webhook_id/webhook_token slack://tokenA/tokenB/tokenC"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
var maskedUrls = doc.RootElement.GetProperty("serviceUrls").GetString();
maskedUrls.ShouldContain("discord://••••••••");
maskedUrls.ShouldContain("slack://••••••••");
maskedUrls.ShouldNotContain("webhook_id");
maskedUrls.ShouldNotContain("webhook_token");
maskedUrls.ShouldNotContain("tokenA");
}
[Fact]
public void AppriseConfig_NullServiceUrls_RemainsNull()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Api,
Url = "https://apprise.example.com",
Key = "some-key",
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("serviceUrls").ValueKind.ShouldBe(JsonValueKind.Null);
}
#endregion
#region Polymorphic serialization (as used in NotificationProviderResponse)
[Fact]
public void PolymorphicSerialization_NotifiarrConfig_StillMasked()
{
// The notification providers endpoint casts configs to `object`.
// Verify that the resolver still masks when serializing as a concrete type at runtime.
object config = new NotifiarrConfig
{
ApiKey = "my-secret-notifiarr-key",
ChannelId = "987654321"
};
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("987654321");
}
[Fact]
public void PolymorphicSerialization_DiscordConfig_StillMasked()
{
object config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123/secret",
Username = "Bot"
};
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("username").GetString().ShouldBe("Bot");
}
#endregion
#region Edge cases
[Fact]
public void EmptySensitiveString_IsMasked_NotReturnedEmpty()
{
var config = new NotifiarrConfig
{
ApiKey = "",
ChannelId = "123"
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
// Even empty strings get masked to the placeholder
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
}
[Fact]
public void MultipleSensitiveFields_AllMasked()
{
var config = new PushoverConfig
{
ApiToken = "token-abc-123",
UserKey = "user-key-xyz-789",
Priority = PushoverPriority.High,
};
var json = JsonSerializer.Serialize(config, _options);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
}
#endregion
}
@@ -0,0 +1,37 @@
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Cleanuparr.Api.Tests;
public sealed class PriorityOrderer : ITestCaseOrderer
{
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
where TTestCase : ITestCase
{
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
foreach (var testCase in testCases)
{
var priority = testCase.TestMethod.Method
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
.FirstOrDefault()
?.GetNamedArgument<int>("Priority") ?? 0;
if (!sortedMethods.TryGetValue(priority, out var list))
{
list = [];
sortedMethods[priority] = list;
}
list.Add(testCase);
}
foreach (var list in sortedMethods.Values)
{
foreach (var testCase in list)
{
yield return testCase;
}
}
}
}
@@ -0,0 +1,9 @@
namespace Cleanuparr.Api.Tests;
/// <summary>
/// Auth integration tests share the file-system config directory (users.db via
/// SetupGuardMiddleware.CreateStaticInstance). Grouping them in one collection
/// forces sequential execution and prevents inter-factory interference.
/// </summary>
[CollectionDefinition("Auth Integration Tests")]
public class AuthIntegrationTestsCollection { }
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Tests;
[AttributeUsage(AttributeTargets.Method)]
public sealed class TestPriorityAttribute : Attribute
{
public int Priority { get; }
public TestPriorityAttribute(int priority)
{
Priority = priority;
}
}
@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}
@@ -0,0 +1,69 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Api.Auth;
public static class ApiKeyAuthenticationDefaults
{
public const string AuthenticationScheme = "ApiKey";
public const string HeaderName = "X-Api-Key";
public const string QueryParameterName = "apikey";
}
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Try header first, then query string
string? apiKey = null;
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue))
{
apiKey = headerValue.ToString();
}
else if (Request.Query.TryGetValue(ApiKeyAuthenticationDefaults.QueryParameterName, out var queryValue))
{
apiKey = queryValue.ToString();
}
if (string.IsNullOrWhiteSpace(apiKey))
{
return AuthenticateResult.NoResult();
}
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.ApiKey == apiKey && u.SetupCompleted);
if (user is null)
{
return AuthenticateResult.Fail("Invalid API key");
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("auth_method", "apikey")
};
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme);
return AuthenticateResult.Success(ticket);
}
}
@@ -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);
}
}
@@ -24,6 +24,7 @@
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -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;
@@ -149,33 +151,6 @@ public class EventsController : ControllerBase
return Ok(events);
}
/// <summary>
/// Gets event statistics
/// </summary>
[HttpGet("stats")]
public async Task<ActionResult<object>> GetEventStats()
{
var stats = new
{
TotalEvents = await _context.Events.CountAsync(),
EventsBySeverity = await _context.Events
.GroupBy(e => e.Severity)
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
.ToListAsync(),
EventsByType = await _context.Events
.GroupBy(e => e.EventType)
.Select(g => new { EventType = g.Key.ToString(), Count = g.Count() })
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync(),
RecentEventsCount = await _context.Events
.Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-24))
.CountAsync()
};
return Ok(stats);
}
/// <summary>
/// Manually triggers cleanup of old events
/// </summary>
@@ -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;
@@ -0,0 +1,53 @@
using Cleanuparr.Infrastructure.Stats;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Aggregated statistics endpoint for dashboard integrations
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class StatsController : ControllerBase
{
private readonly ILogger<StatsController> _logger;
private readonly IStatsService _statsService;
public StatsController(
ILogger<StatsController> logger,
IStatsService statsService)
{
_logger = logger;
_statsService = statsService;
}
/// <summary>
/// Gets aggregated application statistics for the specified timeframe
/// </summary>
/// <param name="hours">Timeframe in hours (default 24, range 1-720)</param>
/// <param name="includeEvents">Number of recent events to include (0 = none, max 100)</param>
/// <param name="includeStrikes">Number of recent strikes to include (0 = none, max 100)</param>
[HttpGet]
public async Task<IActionResult> GetStats(
[FromQuery] int hours = 24,
[FromQuery] int includeEvents = 0,
[FromQuery] int includeStrikes = 0)
{
try
{
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
return Ok(stats);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving stats");
return StatusCode(500, new { Error = "An error occurred while retrieving stats" });
}
}
}
@@ -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,7 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Cleanuparr.Api.Filters;
using Cleanuparr.Api.Json;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Hubs;
using Microsoft.AspNetCore.Http.Json;
@@ -17,12 +20,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 +36,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 +48,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
@@ -56,18 +65,22 @@ public static class ApiDI
// Enable compression
app.UseResponseCompression();
// Serve static files with caching
// Serve static files without caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = _ => {}
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
});
// Add the global exception handling middleware first
app.UseMiddleware<ExceptionMiddleware>();
// Block non-auth requests until setup is complete
app.UseMiddleware<SetupGuardMiddleware>();
app.UseCors("Any");
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
@@ -107,12 +120,13 @@ public static class ApiDI
);
context.Response.ContentType = "text/html";
NoCacheAttribute.Apply(context.Response.Headers);
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
});
}).AllowAnonymous();
// Map SignalR hubs
app.MapHub<HealthStatusHub>("/api/hubs/health");
app.MapHub<AppHub>("/api/hubs/app");
app.MapHub<HealthStatusHub>("/api/hubs/health").RequireAuthorization();
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
{
@@ -124,12 +138,18 @@ public static class ApiDI
{
name = "Cleanuparr",
short_name = "Cleanuparr",
description = "Automated cleanup for *arr applications and download clients",
start_url = basePath,
display = "standalone",
background_color = "#ffffff",
theme_color = "#ffffff",
background_color = "#0e0a1a",
theme_color = "#1a1135",
icons = new[]
{
new {
src = "icons/128.png",
sizes = "128x128",
type = "image/png"
},
new {
src = "icons/icon-192x192.png",
sizes = "192x192",
@@ -144,7 +164,7 @@ public static class ApiDI
};
return Results.Json(manifest, contentType: "application/manifest+json");
});
}).AllowAnonymous();
return app;
}
@@ -0,0 +1,92 @@
using Cleanuparr.Api.Auth;
using Cleanuparr.Infrastructure.Features.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace Cleanuparr.Api.DependencyInjection;
public static class AuthDI
{
private const string SmartScheme = "Smart";
public static IServiceCollection AddAuthServices(this IServiceCollection services)
{
// Get the signing key from the JwtService
var jwtService = new JwtService();
var signingKey = jwtService.GetOrCreateSigningKey();
services
.AddAuthentication(SmartScheme)
.AddPolicyScheme(SmartScheme, "JWT or API Key", options =>
{
// Route to the correct auth handler based on the request
options.ForwardDefaultSelector = context =>
{
if (context.Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.HeaderName) ||
context.Request.Query.ContainsKey(ApiKeyAuthenticationDefaults.QueryParameterName))
{
return ApiKeyAuthenticationDefaults.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 =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "Cleanuparr",
ValidateAudience = true,
ValidAudience = "Cleanuparr",
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(signingKey),
ClockSkew = TimeSpan.FromSeconds(30)
};
// Support SignalR token via query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/api/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
})
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
.AddScheme<AuthenticationSchemeOptions, TrustedNetworkAuthenticationHandler>(
TrustedNetworkAuthenticationDefaults.AuthenticationScheme, _ => { });
services.AddAuthorization(options =>
{
var defaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.DefaultPolicy = defaultPolicy;
options.FallbackPolicy = defaultPolicy;
});
return services;
}
}
@@ -56,8 +56,8 @@ public static class MainDI
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
@@ -87,10 +87,16 @@ public static class MainDI
{
// Add the dynamic HTTP client system - this replaces all the previous static configurations
services.AddDynamicHttpClients();
// Add the dynamic HTTP client provider that uses the new system
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
// Add HTTP client for Plex authentication
services.AddHttpClient("PlexAuth");
// Add HTTP client for OIDC authentication
services.AddHttpClient("OidcAuth");
return services;
}
@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
@@ -16,6 +17,7 @@ using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Stats;
using Cleanuparr.Persistence;
namespace Cleanuparr.Api.DependencyInjection;
@@ -26,6 +28,12 @@ public static class ServicesDI
services
.AddScoped<EventsContext>()
.AddScoped<DataContext>()
.AddScoped<UsersContext>()
.AddSingleton<IJwtService, JwtService>()
.AddSingleton<IPasswordService, PasswordService>()
.AddSingleton<ITotpService, TotpService>()
.AddScoped<IPlexAuthService, PlexAuthService>()
.AddScoped<IOidcAuthService, OidcAuthService>()
.AddScoped<IEventPublisher, EventPublisher>()
.AddHostedService<EventCleanupService>()
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
@@ -54,6 +62,7 @@ public static class ServicesDI
.AddScoped<IRuleManager, RuleManager>()
.AddScoped<IRuleEvaluator, RuleEvaluator>()
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
.AddScoped<IStatsService, StatsService>()
.AddSingleton<IJobManagementService, JobManagementService>()
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
.AddSingleton(TimeProvider.System)
@@ -0,0 +1,44 @@
using Cleanuparr.Infrastructure.Extensions;
namespace Cleanuparr.Api.Extensions;
public static class HttpRequestExtensions
{
/// <summary>
/// Returns the request PathBase as a safe relative path.
/// Rejects absolute URLs (e.g. "://" or "//") to prevent open redirect attacks.
/// </summary>
public static string GetSafeBasePath(this HttpRequest request)
{
var basePath = request.PathBase.Value?.TrimEnd('/') ?? "";
if (basePath.Contains("://") || basePath.StartsWith("//"))
{
return "";
}
return basePath;
}
/// <summary>
/// Returns the external base URL (scheme + host + basePath), respecting
/// X-Forwarded-Proto and X-Forwarded-Host headers when the connection
/// originates from a local address.
/// </summary>
public static string GetExternalBaseUrl(this HttpContext context)
{
var request = context.Request;
var scheme = request.Scheme;
var host = request.Host.ToString();
var remoteIp = context.Connection.RemoteIpAddress;
// Trust forwarded headers only from local connections
// (consistent with TrustedNetworkAuthenticationHandler)
if (remoteIp is not null && remoteIp.IsLocalAddress())
{
scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? scheme;
host = request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? host;
}
var basePath = request.GetSafeBasePath();
return $"{scheme}://{host}{basePath}";
}
}
@@ -1,7 +1,9 @@
using System;
using System.ComponentModel.DataAnnotations;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
@@ -23,16 +25,24 @@ public sealed record ArrInstanceRequest
public string? ExternalUrl { get; init; }
public ArrInstance ToEntity(Guid configId) => new()
public ArrInstance ToEntity(Guid configId)
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
};
if (ApiKey.IsPlaceholder())
{
throw new ValidationException("API key is required when creating a new instance");
}
return new()
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
};
}
public void ApplyTo(ArrInstance instance)
{
@@ -40,7 +50,7 @@ public sealed record ArrInstanceRequest
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
instance.ApiKey = ApiKey;
instance.ApiKey = ApiKey.IsPlaceholder() ? instance.ApiKey : ApiKey;
instance.Version = Version;
}
}
@@ -1,7 +1,9 @@
using System;
using System.ComponentModel.DataAnnotations;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
@@ -12,17 +14,29 @@ public sealed record TestArrInstanceRequest
[Required]
public required string ApiKey { get; init; }
[Required]
public required float Version { get; init; }
public ArrInstance ToTestInstance() => new()
public Guid? InstanceId { get; init; }
public ArrInstance ToTestInstance(string? resolvedApiKey = null)
{
Enabled = true,
Name = "Test Instance",
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = Guid.Empty,
Version = Version,
};
var apiKey = resolvedApiKey ?? ApiKey;
if (apiKey.IsPlaceholder())
{
throw new ValidationException("API key cannot be a placeholder value");
}
return new()
{
Enabled = true,
Name = "Test Instance",
Url = new Uri(Url),
ApiKey = apiKey,
ArrConfigId = Guid.Empty,
Version = Version,
};
}
}
@@ -3,7 +3,9 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Shared.Helpers;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -11,6 +13,7 @@ namespace Cleanuparr.Api.Features.Arr.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
@@ -282,7 +285,23 @@ public sealed class ArrConfigController : ControllerBase
{
try
{
var testInstance = request.ToTestInstance();
string? resolvedApiKey = null;
if (request.ApiKey.IsPlaceholder() && request.InstanceId.HasValue)
{
var existingInstance = await _dataContext.ArrInstances
.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == request.InstanceId.Value);
if (existingInstance is null)
{
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
}
resolvedApiKey = existingInstance.ApiKey;
}
var testInstance = request.ToTestInstance(resolvedApiKey);
var client = _arrClientFactory.GetClient(type, request.Version);
await client.HealthCheckAsync(testInstance);
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record ChangePasswordRequest
{
[Required]
public required string CurrentPassword { get; init; }
[Required]
[MinLength(8)]
[MaxLength(128)]
public required string NewPassword { get; init; }
}
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record CreateAccountRequest
{
[Required]
[MinLength(3)]
[MaxLength(50)]
public required string Username { get; init; }
[Required]
[MinLength(8)]
[MaxLength(128)]
public required string Password { get; init; }
}
@@ -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; }
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record LoginRequest
{
[Required]
public required string Username { get; init; }
[Required]
public required string Password { get; init; }
}
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record OidcExchangeRequest
{
[Required]
public required string Code { get; init; }
}
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record PlexPinRequest
{
[Required]
public required int PinId { get; init; }
}
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record RefreshTokenRequest
{
[Required]
public required string RefreshToken { get; init; }
}
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record Regenerate2faRequest
{
[Required]
public required string Password { get; init; }
[Required]
[StringLength(6, MinimumLength = 6)]
public required string TotpCode { get; init; }
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record TwoFactorRequest
{
[Required]
public required string LoginToken { get; init; }
[Required]
public required string Code { get; init; }
public bool IsRecoveryCode { get; init; }
}
@@ -0,0 +1,49 @@
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence.Models.Auth;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record UpdateOidcConfigRequest
{
public bool Enabled { get; init; }
public string IssuerUrl { get; init; } = string.Empty;
public string ClientId { get; init; } = string.Empty;
public string ClientSecret { get; init; } = string.Empty;
public string Scopes { get; init; } = "openid profile email";
public string ProviderName { get; init; } = "OIDC";
public string RedirectUrl { get; init; } = string.Empty;
public bool ExclusiveMode { get; init; }
public void ApplyTo(OidcConfig existingConfig)
{
var previousIssuerUrl = existingConfig.IssuerUrl;
existingConfig.Enabled = Enabled;
existingConfig.IssuerUrl = IssuerUrl;
existingConfig.ClientId = ClientId;
existingConfig.Scopes = Scopes;
existingConfig.ProviderName = ProviderName;
existingConfig.RedirectUrl = RedirectUrl;
existingConfig.ExclusiveMode = ExclusiveMode;
if (!ClientSecret.IsPlaceholder())
{
existingConfig.ClientSecret = ClientSecret;
}
// AuthorizedSubject is intentionally NOT mapped here — it is set only via the OIDC link callback
if (previousIssuerUrl != IssuerUrl)
{
OidcAuthService.ClearDiscoveryCache();
}
}
}
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record VerifyTotpRequest
{
[Required]
[StringLength(6, MinimumLength = 6)]
public required string Code { get; init; }
}
@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record AccountInfoResponse
{
public required string Username { get; init; }
public required bool PlexLinked { get; init; }
public string? PlexUsername { get; init; }
public required bool TwoFactorEnabled { get; init; }
public required string ApiKeyPreview { get; init; }
}
@@ -0,0 +1,11 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record AuthStatusResponse
{
public required bool SetupCompleted { get; init; }
public bool PlexLinked { get; init; }
public bool AuthBypassActive { get; init; }
public bool OidcEnabled { get; init; }
public string OidcProviderName { get; init; } = string.Empty;
public bool OidcExclusiveMode { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record LoginResponse
{
public required bool RequiresTwoFactor { get; init; }
public string? LoginToken { get; init; }
public TokenResponse? Tokens { get; init; }
}
@@ -0,0 +1,6 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record OidcStartResponse
{
public required string AuthorizationUrl { get; init; }
}
@@ -0,0 +1,13 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record PlexPinStatusResponse
{
public required int PinId { get; init; }
public required string AuthUrl { get; init; }
}
public sealed record PlexVerifyResponse
{
public required bool Completed { get; init; }
public TokenResponse? Tokens { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record TokenResponse
{
public required string AccessToken { get; init; }
public required string RefreshToken { get; init; }
public required int ExpiresIn { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record TotpSetupResponse
{
public required string Secret { get; init; }
public required string QrCodeUri { get; init; }
public required List<string> RecoveryCodes { get; init; }
}
@@ -0,0 +1,580 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.Auth.Controllers;
[ApiController]
[Route("api/account")]
[Authorize]
[NoCache]
public sealed class AccountController : ControllerBase
{
private readonly UsersContext _usersContext;
private readonly IPasswordService _passwordService;
private readonly ITotpService _totpService;
private readonly IPlexAuthService _plexAuthService;
private readonly IOidcAuthService _oidcAuthService;
private readonly ILogger<AccountController> _logger;
public AccountController(
UsersContext usersContext,
IPasswordService passwordService,
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AccountController> logger)
{
_usersContext = usersContext;
_passwordService = passwordService;
_totpService = totpService;
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAccountInfo()
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
return Ok(new AccountInfoResponse
{
Username = user.Username,
PlexLinked = user.PlexAccountId is not null,
PlexUsername = user.PlexUsername,
TwoFactorEnabled = user.TotpEnabled,
ApiKeyPreview = user.ApiKey[..8] + "..."
});
}
[HttpPut("password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
}
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
{
return BadRequest(new { error = "Current password is incorrect" });
}
DateTime now = DateTime.UtcNow;
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
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);
return Ok(new { message = "Password changed" });
}
[HttpPost("2fa/regenerate")]
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null)
{
return Unauthorized();
}
// Verify current credentials
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" });
}
// 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 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 regenerated for user {Username}", user.Username);
return Ok(new TotpSetupResponse
{
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
});
}
[HttpPost("2fa/enable")]
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
{
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
});
}
[HttpPost("2fa/enable/verify")]
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
{
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" });
}
[HttpPost("2fa/disable")]
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
{
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" });
}
[HttpGet("api-key")]
public async Task<IActionResult> GetApiKey()
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
return Ok(new { apiKey = user.ApiKey });
}
[HttpPost("api-key/regenerate")]
public async Task<IActionResult> RegenerateApiKey()
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
return Ok(new { apiKey = user.ApiKey });
}
[HttpPost("plex/link")]
public async Task<IActionResult> StartPlexLink()
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
}
var pin = await _plexAuthService.RequestPin();
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
}
[HttpPost("plex/link/verify")]
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
if (!pinResult.Completed || pinResult.AuthToken is null)
{
return Ok(new { completed = false });
}
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
user.PlexAccountId = plexAccount.AccountId;
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
user.PlexAuthToken = pinResult.AuthToken;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
user.Username, plexAccount.Username);
return Ok(new { completed = true, plexUsername = plexAccount.Username });
}
[HttpDelete("plex/link")]
public async Task<IActionResult> UnlinkPlex()
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
}
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
user.PlexAccountId = null;
user.PlexUsername = null;
user.PlexEmail = null;
user.PlexAuthToken = null;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
return Ok(new { message = "Plex account unlinked" });
}
[HttpGet("oidc")]
public async Task<IActionResult> GetOidcConfig()
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
return Ok(user.Oidc);
}
[HttpPut("oidc")]
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
{
try
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
request.ApplyTo(user.Oidc);
user.Oidc.Validate();
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
return Ok(new { message = "OIDC configuration updated" });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("oidc/link")]
public async Task<IActionResult> StartOidcLink()
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
if (user.Oidc is not { Enabled: true })
{
return BadRequest(new { error = "OIDC is not enabled" });
}
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
_logger.LogDebug("OIDC link start: using redirect URI {RedirectUri}", redirectUri);
try
{
var result = await _oidcAuthService.StartAuthorization(redirectUri, user.Id.ToString());
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
return StatusCode(429, new { error = ex.Message });
}
}
/// <remarks>
/// This endpoint must be [AllowAnonymous] because the IdP redirects the user's browser here
/// without a Bearer token. Security is ensured by validating that the OIDC flow was initiated
/// by an authenticated user (InitiatorUserId stored in the flow state during StartOidcLink).
/// </remarks>
[AllowAnonymous]
[HttpGet("oidc/link/callback")]
public async Task<IActionResult> OidcLinkCallback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error)
{
var basePath = HttpContext.Request.GetSafeBasePath();
if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
{
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
// Fetch any user to get the configured redirect URL for the OIDC callback
var oidcConfig = (await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync())?.Oidc;
var redirectUri = GetOidcLinkCallbackUrl(oidcConfig?.RedirectUrl);
_logger.LogDebug("OIDC link callback: using redirect URI {RedirectUri}", redirectUri);
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
if (!result.Success || string.IsNullOrEmpty(result.Subject))
{
_logger.LogWarning("OIDC link callback failed: {Error}", result.Error);
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
// Verify the flow was initiated by an authenticated user
if (string.IsNullOrEmpty(result.InitiatorUserId) ||
!Guid.TryParse(result.InitiatorUserId, out var initiatorId))
{
_logger.LogWarning("OIDC link callback missing initiator user ID");
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
// Save the authorized subject to the user's OIDC config
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.Id == initiatorId);
if (user is null)
{
_logger.LogWarning("OIDC link callback initiator user not found: {UserId}", result.InitiatorUserId);
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
user.Oidc.AuthorizedSubject = result.Subject;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
result.Subject, user.Username);
return Redirect($"{basePath}/settings/account?oidc_link=success");
}
[HttpDelete("oidc/link")]
public async Task<IActionResult> UnlinkOidc()
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
user.Oidc.AuthorizedSubject = string.Empty;
user.Oidc.ExclusiveMode = false;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
return Ok(new { message = "OIDC account unlinked" });
}
finally
{
UsersContext.Lock.Release();
}
}
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
{
var baseUrl = string.IsNullOrEmpty(redirectUrl)
? HttpContext.GetExternalBaseUrl()
: redirectUrl.TrimEnd('/');
return $"{baseUrl}/api/account/oidc/link/callback";
}
private async Task<bool> IsOidcExclusiveModeActive()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is not { SetupCompleted: true })
{
return false;
}
var oidc = user.Oidc;
return oidc is { Enabled: true, ExclusiveMode: true };
}
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userIdClaim is null || !Guid.TryParse(userIdClaim, out var userId))
{
return null;
}
var query = _usersContext.Users.AsQueryable();
if (includeRecoveryCodes)
{
query = query.Include(u => u.RecoveryCodes);
}
return await query.FirstOrDefaultAsync(u => u.Id == userId);
}
}
@@ -0,0 +1,774 @@
using System.Security.Cryptography;
using Cleanuparr.Api.Auth;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Auth.Controllers;
[ApiController]
[Route("api/auth")]
[AllowAnonymous]
[NoCache]
public sealed class AuthController : ControllerBase
{
private readonly UsersContext _usersContext;
private readonly DataContext _dataContext;
private readonly IJwtService _jwtService;
private readonly IPasswordService _passwordService;
private readonly ITotpService _totpService;
private readonly IPlexAuthService _plexAuthService;
private readonly IOidcAuthService _oidcAuthService;
private readonly ILogger<AuthController> _logger;
public AuthController(
UsersContext usersContext,
DataContext dataContext,
IJwtService jwtService,
IPasswordService passwordService,
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AuthController> logger)
{
_usersContext = usersContext;
_dataContext = dataContext;
_jwtService = jwtService;
_passwordService = passwordService;
_totpService = totpService;
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
}
[HttpGet("status")]
public async Task<IActionResult> GetStatus()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
var authBypass = false;
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);
}
}
var oidcConfig = user?.Oidc;
var oidcEnabled = oidcConfig is { Enabled: true } &&
!string.IsNullOrEmpty(oidcConfig.IssuerUrl) &&
!string.IsNullOrEmpty(oidcConfig.ClientId);
var oidcExclusiveMode = oidcEnabled && oidcConfig!.ExclusiveMode;
return Ok(new AuthStatusResponse
{
SetupCompleted = user is { SetupCompleted: true },
PlexLinked = user?.PlexAccountId is not null,
AuthBypassActive = authBypass,
OidcEnabled = oidcEnabled,
OidcProviderName = oidcEnabled ? oidcConfig!.ProviderName : string.Empty,
OidcExclusiveMode = oidcExclusiveMode
});
}
[HttpPost("setup/account")]
public async Task<IActionResult> CreateAccount([FromBody] CreateAccountRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
if (existingUser is not null)
{
return Conflict(new { error = "Account already exists" });
}
var user = new User
{
Id = Guid.NewGuid(),
Username = request.Username,
PasswordHash = _passwordService.HashPassword(request.Password),
TotpSecret = string.Empty,
TotpEnabled = false,
ApiKey = GenerateApiKey(),
SetupCompleted = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_usersContext.Users.Add(user);
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Admin account created for user {Username}", request.Username);
return Created("", new { userId = user.Id });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("setup/2fa/generate")]
public async Task<IActionResult> GenerateTotpSetup()
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await _usersContext.Users
.Include(u => u.RecoveryCodes)
.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
}
// Generate new TOTP secret
var secret = _totpService.GenerateSecret();
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
// Generate recovery codes
var recoveryCodes = _totpService.GenerateRecoveryCodes();
// Store secret (will be finalized on verify)
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
// Remove old recovery codes and add new ones
_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();
return Ok(new TotpSetupResponse
{
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
});
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("setup/2fa/verify")]
public async Task<IActionResult> VerifyTotpSetup([FromBody] VerifyTotpRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
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" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return Unauthorized(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 verified and enabled" });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("setup/complete")]
public async Task<IActionResult> CompleteSetup()
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed" });
}
user.SetupCompleted = true;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Setup completed for user {Username}", user.Username);
return Ok(new { message = "Setup complete" });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null || !user.SetupCompleted)
{
return Unauthorized(new { error = "Invalid credentials" });
}
// Check lockout
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
{
var remaining = (int)(user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds;
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash) ||
!string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
{
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
}
// 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);
return Ok(new LoginResponse
{
RequiresTwoFactor = true,
LoginToken = loginToken
});
}
[HttpPost("login/2fa")]
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
}
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
if (userId is null)
{
return Unauthorized(new { error = "Invalid or expired login token" });
}
var user = await _usersContext.Users
.Include(u => u.RecoveryCodes)
.FirstOrDefaultAsync(u => u.Id == userId.Value);
if (user is null)
{
return Unauthorized(new { error = "Invalid login token" });
}
bool codeValid;
if (request.IsRecoveryCode)
{
codeValid = await TryUseRecoveryCode(user, request.Code);
}
else
{
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
}
if (!codeValid)
{
return Unauthorized(new { error = "Invalid verification code" });
}
return Ok(await GenerateTokenResponse(user));
}
[HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var tokenHash = HashRefreshToken(request.RefreshToken);
var storedToken = await _usersContext.RefreshTokens
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
{
return Unauthorized(new { error = "Invalid or expired refresh token" });
}
// Revoke the old token (rotation)
storedToken.RevokedAt = DateTime.UtcNow;
// Generate new tokens
var response = await GenerateTokenResponse(storedToken.User);
await _usersContext.SaveChangesAsync();
return Ok(response);
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] RefreshTokenRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var tokenHash = HashRefreshToken(request.RefreshToken);
var storedToken = await _usersContext.RefreshTokens
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
if (storedToken is not null)
{
storedToken.RevokedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
}
return Ok(new { message = "Logged out" });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("setup/plex/pin")]
public async Task<IActionResult> RequestSetupPlexPin()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null)
{
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
{
PinId = pin.PinId,
AuthUrl = pin.AuthUrl
});
}
[HttpPost("setup/plex/verify")]
public async Task<IActionResult> VerifySetupPlexLink([FromBody] PlexPinRequest request)
{
var pinResult = await _plexAuthService.CheckPin(request.PinId);
if (!pinResult.Completed || pinResult.AuthToken is null)
{
return Ok(new PlexVerifyResponse { Completed = false });
}
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
await UsersContext.Lock.WaitAsync();
try
{
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
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;
user.PlexAuthToken = pinResult.AuthToken;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked during setup for user {Username}: {PlexUsername}",
user.Username, plexAccount.Username);
return Ok(new PlexVerifyResponse { Completed = true });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("login/plex/pin")]
public async Task<IActionResult> RequestPlexPin()
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
{
return BadRequest(new { error = "Plex login is not available" });
}
var pin = await _plexAuthService.RequestPin();
return Ok(new PlexPinStatusResponse
{
PinId = pin.PinId,
AuthUrl = pin.AuthUrl
});
}
[HttpPost("login/plex/verify")]
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
}
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
{
return BadRequest(new { error = "Plex login is not available" });
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
if (!pinResult.Completed || pinResult.AuthToken is null)
{
return Ok(new PlexVerifyResponse { Completed = false });
}
// Verify the Plex account matches the linked one
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
if (plexAccount.AccountId != user.PlexAccountId)
{
return Unauthorized(new { error = "Plex account does not match the linked account" });
}
// 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);
return Ok(new PlexVerifyResponse
{
Completed = true,
Tokens = tokenResponse
});
}
[HttpPost("oidc/start")]
public async Task<IActionResult> StartOidc()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
var oidcConfig = user?.Oidc;
if (oidcConfig is not { Enabled: true } ||
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
string.IsNullOrEmpty(oidcConfig.ClientId))
{
return BadRequest(new { error = "OIDC is not enabled or not configured" });
}
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
_logger.LogDebug("OIDC login start: using redirect URI {RedirectUri}", redirectUri);
try
{
var result = await _oidcAuthService.StartAuthorization(redirectUri);
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC authorization");
return StatusCode(429, new { error = ex.Message });
}
}
[HttpGet("oidc/callback")]
public async Task<IActionResult> OidcCallback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error)
{
var basePath = HttpContext.Request.GetSafeBasePath();
// Handle IdP error responses
if (!string.IsNullOrEmpty(error))
{
_logger.LogWarning("OIDC callback received error: {Error}", error);
return Redirect($"{basePath}/auth/login?oidc_error=provider_error");
}
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
{
return Redirect($"{basePath}/auth/login?oidc_error=invalid_request");
}
// Load the user early so we can use the configured redirect URL
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.SetupCompleted);
if (user is null)
{
return Redirect($"{basePath}/auth/login?oidc_error=no_account");
}
var redirectUri = GetOidcCallbackUrl(user.Oidc.RedirectUrl);
_logger.LogDebug("OIDC login callback: using redirect URI {RedirectUri}", redirectUri);
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
if (!result.Success)
{
_logger.LogWarning("OIDC callback failed: {Error}", result.Error);
return Redirect($"{basePath}/auth/login?oidc_error=authentication_failed");
}
if (!string.IsNullOrEmpty(user.Oidc.AuthorizedSubject) &&
result.Subject != user.Oidc.AuthorizedSubject)
{
_logger.LogWarning("OIDC subject mismatch. Expected: {Expected}, Got: {Got}",
user.Oidc.AuthorizedSubject, result.Subject);
return Redirect($"{basePath}/auth/login?oidc_error=unauthorized");
}
var tokenResponse = await GenerateTokenResponse(user);
// Store tokens with a one-time code (never put tokens in the URL)
var oneTimeCode = _oidcAuthService.StoreOneTimeCode(
tokenResponse.AccessToken,
tokenResponse.RefreshToken,
tokenResponse.ExpiresIn);
_logger.LogInformation("User {Username} authenticated via OIDC (subject: {Subject})",
user.Username, result.Subject);
return Redirect($"{basePath}/auth/oidc/callback?code={Uri.EscapeDataString(oneTimeCode)}");
}
[HttpPost("oidc/exchange")]
public IActionResult ExchangeOidcCode([FromBody] OidcExchangeRequest request)
{
var result = _oidcAuthService.ExchangeOneTimeCode(request.Code);
if (result is null)
{
return NotFound(new { error = "Invalid or expired code" });
}
return Ok(new TokenResponse
{
AccessToken = result.AccessToken,
RefreshToken = result.RefreshToken,
ExpiresIn = result.ExpiresIn
});
}
private string GetOidcCallbackUrl(string? redirectUrl = null)
{
var baseUrl = string.IsNullOrEmpty(redirectUrl)
? HttpContext.GetExternalBaseUrl()
: redirectUrl.TrimEnd('/');
return $"{baseUrl}/api/auth/oidc/callback";
}
private async Task<TokenResponse> GenerateTokenResponse(User user)
{
var accessToken = _jwtService.GenerateAccessToken(user);
var refreshToken = _jwtService.GenerateRefreshToken();
_usersContext.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
TokenHash = HashRefreshToken(refreshToken),
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow
});
await _usersContext.SaveChangesAsync();
return new TokenResponse
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = 3600 // seconds
};
}
private async Task<bool> TryUseRecoveryCode(User user, string code)
{
await UsersContext.Lock.WaitAsync();
try
{
foreach (var recoveryCode in user.RecoveryCodes.Where(r => !r.IsUsed))
{
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
{
recoveryCode.IsUsed = true;
recoveryCode.UsedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
return true;
}
}
return false;
}
finally
{
UsersContext.Lock.Release();
}
}
private async Task<int> IncrementFailedAttempts(Guid userId)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
user.FailedLoginAttempts++;
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
await _usersContext.SaveChangesAsync();
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
user.FailedLoginAttempts, user.Username, user.FailedLoginAttempts * 2);
return user.FailedLoginAttempts * 2;
}
finally
{
UsersContext.Lock.Release();
}
}
private async Task ResetFailedAttempts(Guid userId)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
user.FailedLoginAttempts = 0;
user.LockoutEnd = null;
await _usersContext.SaveChangesAsync();
}
finally
{
UsersContext.Lock.Release();
}
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string HashRefreshToken(string token)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(token);
var hash = SHA256.HashData(bytes);
return Convert.ToBase64String(hash);
}
private async Task<bool> IsOidcExclusiveModeActive()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is not { SetupCompleted: true })
{
return false;
}
var oidc = user.Oidc;
return oidc is { Enabled: true, ExclusiveMode: true };
}
}
@@ -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;
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
@@ -6,7 +7,12 @@ public record SeedingRuleRequest
{
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
public TorrentPrivacyType PrivacyType { get; init; } = TorrentPrivacyType.Public;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
@@ -13,8 +13,6 @@ public sealed record UpdateDownloadCleanerConfigRequest
public List<SeedingRuleRequest> Categories { get; init; } = [];
public bool DeletePrivate { get; init; }
/// <summary>
/// Indicates whether unlinked download handling is enabled.
/// </summary>
@@ -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;
@@ -76,7 +78,6 @@ public sealed class DownloadCleanerConfigController : ControllerBase
oldConfig.Enabled = newConfigDto.Enabled;
oldConfig.CronExpression = newConfigDto.CronExpression;
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.DeletePrivate = newConfigDto.DeletePrivate;
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
@@ -93,6 +94,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
_dataContext.SeedingRules.Add(new SeedingRule
{
Name = categoryDto.Name,
PrivacyType = categoryDto.PrivacyType,
MaxRatio = categoryDto.MaxRatio,
MinSeedTime = categoryDto.MinSeedTime,
MaxSeedTime = categoryDto.MaxSeedTime,
@@ -3,6 +3,7 @@ using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
@@ -47,6 +48,11 @@ public sealed record CreateDownloadClientRequest
{
throw new ValidationException("External URL is not a valid URL");
}
if (Password.IsPlaceholder())
{
throw new ValidationException("Password cannot be a placeholder value");
}
}
public DownloadClientConfig ToEntity() => new()
@@ -3,6 +3,7 @@ using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
@@ -20,6 +21,8 @@ public sealed record TestDownloadClientRequest
public string? UrlBase { get; init; }
public Guid? ClientId { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Host))
@@ -33,16 +36,26 @@ public sealed record TestDownloadClientRequest
}
}
public DownloadClientConfig ToTestConfig() => new()
public DownloadClientConfig ToTestConfig(string? resolvedPassword = null)
{
Id = Guid.NewGuid(),
Enabled = true,
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
var password = resolvedPassword ?? Password;
if (password.IsPlaceholder())
{
throw new ValidationException("Password cannot be a placeholder value");
}
return new()
{
Id = Guid.NewGuid(),
Enabled = true,
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = password,
UrlBase = UrlBase,
};
}
}
@@ -3,6 +3,7 @@ using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
@@ -57,7 +58,7 @@ public sealed record UpdateDownloadClientRequest
Type = Type,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
Password = Password.IsPlaceholder() ? existing.Password : Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
@@ -5,6 +5,8 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -12,6 +14,7 @@ namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class DownloadClientController : ControllerBase
{
private readonly ILogger<DownloadClientController> _logger;
@@ -156,7 +159,23 @@ public sealed class DownloadClientController : ControllerBase
{
request.Validate();
var testConfig = request.ToTestConfig();
string? resolvedPassword = null;
if (request.Password.IsPlaceholder() && request.ClientId.HasValue)
{
var existingClient = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == request.ClientId.Value);
if (existingClient is null)
{
return NotFound($"Download client with ID {request.ClientId.Value} not found");
}
resolvedPassword = existingClient.Password;
}
var testConfig = request.ToTestConfig(resolvedPassword);
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
var healthResult = await downloadService.HealthCheckAsync();
@@ -34,6 +34,8 @@ public sealed record UpdateGeneralConfigRequest
public UpdateLoggingConfigRequest Log { get; init; } = new();
public UpdateAuthConfigRequest Auth { get; init; } = new();
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
{
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
@@ -49,6 +51,7 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
Auth.ApplyTo(existingConfig.Auth);
Validate(existingConfig);
@@ -75,6 +78,7 @@ public sealed record UpdateGeneralConfigRequest
}
config.Log.Validate();
config.Auth.Validate();
}
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
@@ -145,3 +149,19 @@ public sealed record UpdateLoggingConfigRequest
public bool LevelOnlyChange { get; private set; }
}
public sealed record UpdateAuthConfigRequest
{
public bool DisableAuthForLocalAddresses { get; init; }
public bool TrustForwardedHeaders { get; init; }
public List<string> TrustedNetworks { get; init; } = [];
public void ApplyTo(AuthConfig existingConfig)
{
existingConfig.DisableAuthForLocalAddresses = DisableAuthForLocalAddresses;
existingConfig.TrustForwardedHeaders = TrustForwardedHeaders;
existingConfig.TrustedNetworks = TrustedNetworks;
}
}
@@ -3,32 +3,28 @@ using System.Linq;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.General.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class GeneralConfigController : ControllerBase
{
private readonly ILogger<GeneralConfigController> _logger;
private readonly DataContext _dataContext;
private readonly MemoryCache _cache;
public GeneralConfigController(
ILogger<GeneralConfigController> logger,
DataContext dataContext,
MemoryCache cache)
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
_cache = cache;
}
[HttpGet("general")]
@@ -49,7 +45,9 @@ public sealed class GeneralConfigController : ControllerBase
}
[HttpPut("general")]
public async Task<IActionResult> UpdateGeneralConfig([FromBody] UpdateGeneralConfigRequest request)
public async Task<IActionResult> UpdateGeneralConfig(
[FromBody] UpdateGeneralConfigRequest request,
[FromServices] EventsContext eventsContext)
{
await DataContext.Lock.WaitAsync();
try
@@ -63,7 +61,17 @@ public sealed class GeneralConfigController : ControllerBase
await _dataContext.SaveChangesAsync();
ClearStrikesCacheIfNeeded(wasDryRun, config.DryRun);
if (wasDryRun && !config.DryRun)
{
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
var deletedItems = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
_logger.LogWarning(
"Dry run disabled — purged all strikes: {Strikes} strikes, {Items} download items removed",
deletedStrikes, deletedItems);
}
return Ok(new { Message = "General configuration updated successfully" });
}
@@ -92,39 +100,4 @@ public sealed class GeneralConfigController : ControllerBase
return Ok(new { DeletedStrikes = deletedStrikes, DeletedItems = deletedItems });
}
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
{
if (!wasDryRun || isDryRun)
{
return;
}
List<object> keys;
// Remove strikes
foreach (string strikeType in Enum.GetNames(typeof(StrikeType)))
{
keys = _cache.Keys
.Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true)
.ToList();
foreach (object key in keys)
{
_cache.Remove(key);
}
_logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType);
}
// Remove strike cache items
keys = _cache.Keys
.Where(key => key.ToString()?.StartsWith("item_", StringComparison.InvariantCultureIgnoreCase) is true)
.ToList();
foreach (object key in keys)
{
_cache.Remove(key);
}
}
}
@@ -16,7 +16,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
public bool DeletePrivate { get; init; }
public bool DeleteKnownMalware { get; init; }
public bool ProcessNoContentId { get; init; }
public BlocklistSettings Sonarr { get; init; } = new();
@@ -37,7 +37,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
config.DeletePrivate = DeletePrivate;
config.DeleteKnownMalware = DeleteKnownMalware;
config.ProcessNoContentId = ProcessNoContentId;
config.Sonarr = Sonarr;
config.Radarr = Radarr;
config.Lidarr = Lidarr;
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -16,6 +17,7 @@ namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class MalwareBlockerConfigController : ControllerBase
{
private readonly ILogger<MalwareBlockerConfigController> _logger;
@@ -15,4 +15,6 @@ public record TestAppriseProviderRequest
// CLI mode fields
public string? ServiceUrls { get; init; }
public Guid? ProviderId { get; init; }
}
@@ -7,4 +7,6 @@ public record TestDiscordProviderRequest
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
public Guid? ProviderId { get; init; }
}
@@ -7,4 +7,6 @@ public record TestGotifyProviderRequest
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
public Guid? ProviderId { get; init; }
}
@@ -3,6 +3,8 @@ namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestNotifiarrProviderRequest
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
public Guid? ProviderId { get; init; }
}
@@ -19,4 +19,6 @@ public record TestNtfyProviderRequest
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
public Guid? ProviderId { get; init; }
}
@@ -19,4 +19,6 @@ public record TestPushoverProviderRequest
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
public Guid? ProviderId { get; init; }
}
@@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
public Guid? ProviderId { get; init; }
}
@@ -11,6 +11,8 @@ using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -18,6 +20,7 @@ namespace Cleanuparr.Api.Features.Notifications.Controllers;
[ApiController]
[Route("api/configuration/notification_providers")]
[Authorize]
public sealed class NotificationProvidersController : ControllerBase
{
private readonly ILogger<NotificationProvidersController> _logger;
@@ -127,6 +130,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.ApiKey.IsPlaceholder())
{
return BadRequest("API key cannot be a placeholder value");
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = newProvider.ApiKey,
@@ -184,6 +192,16 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.Key.IsPlaceholder())
{
return BadRequest("Key cannot be a placeholder value");
}
if (newProvider.ServiceUrls.IsPlaceholder())
{
return BadRequest("Service URLs cannot be a placeholder value");
}
var appriseConfig = new AppriseConfig
{
Mode = newProvider.Mode,
@@ -248,6 +266,16 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.Password.IsPlaceholder())
{
return BadRequest("Password cannot be a placeholder value");
}
if (newProvider.AccessToken.IsPlaceholder())
{
return BadRequest("Access token cannot be a placeholder value");
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = newProvider.ServerUrl,
@@ -315,6 +343,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.BotToken.IsPlaceholder())
{
return BadRequest("Bot token cannot be a placeholder value");
}
var telegramConfig = new TelegramConfig
{
BotToken = newProvider.BotToken,
@@ -392,7 +425,9 @@ public sealed class NotificationProvidersController : ControllerBase
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = updatedProvider.ApiKey,
ApiKey = updatedProvider.ApiKey.IsPlaceholder()
? existingProvider.NotifiarrConfiguration!.ApiKey
: updatedProvider.ApiKey,
ChannelId = updatedProvider.ChannelId
};
@@ -473,9 +508,13 @@ public sealed class NotificationProvidersController : ControllerBase
{
Mode = updatedProvider.Mode,
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Key = updatedProvider.Key.IsPlaceholder()
? existingProvider.AppriseConfiguration!.Key
: updatedProvider.Key,
Tags = updatedProvider.Tags,
ServiceUrls = updatedProvider.ServiceUrls
ServiceUrls = updatedProvider.ServiceUrls.IsPlaceholder()
? existingProvider.AppriseConfiguration!.ServiceUrls
: updatedProvider.ServiceUrls
};
if (existingProvider.AppriseConfiguration != null)
@@ -557,8 +596,12 @@ public sealed class NotificationProvidersController : ControllerBase
Topics = updatedProvider.Topics,
AuthenticationType = updatedProvider.AuthenticationType,
Username = updatedProvider.Username,
Password = updatedProvider.Password,
AccessToken = updatedProvider.AccessToken,
Password = updatedProvider.Password.IsPlaceholder()
? existingProvider.NtfyConfiguration!.Password
: updatedProvider.Password,
AccessToken = updatedProvider.AccessToken.IsPlaceholder()
? existingProvider.NtfyConfiguration!.AccessToken
: updatedProvider.AccessToken,
Priority = updatedProvider.Priority,
Tags = updatedProvider.Tags
};
@@ -638,7 +681,9 @@ public sealed class NotificationProvidersController : ControllerBase
var telegramConfig = new TelegramConfig
{
BotToken = updatedProvider.BotToken,
BotToken = updatedProvider.BotToken.IsPlaceholder()
? existingProvider.TelegramConfiguration!.BotToken
: updatedProvider.BotToken,
ChatId = updatedProvider.ChatId,
TopicId = updatedProvider.TopicId,
SendSilently = updatedProvider.SendSilently
@@ -735,9 +780,24 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var apiKey = testRequest.ApiKey;
if (apiKey.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<NotifiarrConfig>(
testRequest.ProviderId, NotificationProviderType.Notifiarr, p => p.NotifiarrConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "API key cannot be a placeholder value" });
}
apiKey = existing.ApiKey;
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = testRequest.ApiKey,
ApiKey = apiKey,
ChannelId = testRequest.ChannelId
};
notifiarrConfig.Validate();
@@ -775,13 +835,37 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var key = testRequest.Key;
var serviceUrls = testRequest.ServiceUrls;
if (key.IsPlaceholder() || serviceUrls.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<AppriseConfig>(
testRequest.ProviderId, NotificationProviderType.Apprise, p => p.AppriseConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
}
if (key.IsPlaceholder())
{
key = existing.Key;
}
if (serviceUrls.IsPlaceholder())
{
serviceUrls = existing.ServiceUrls;
}
}
var appriseConfig = new AppriseConfig
{
Mode = testRequest.Mode,
Url = testRequest.Url,
Key = testRequest.Key,
Key = key,
Tags = testRequest.Tags,
ServiceUrls = testRequest.ServiceUrls
ServiceUrls = serviceUrls
};
appriseConfig.Validate();
@@ -822,14 +906,38 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var password = testRequest.Password;
var accessToken = testRequest.AccessToken;
if (password.IsPlaceholder() || accessToken.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<NtfyConfig>(
testRequest.ProviderId, NotificationProviderType.Ntfy, p => p.NtfyConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
}
if (password.IsPlaceholder())
{
password = existing.Password;
}
if (accessToken.IsPlaceholder())
{
accessToken = existing.AccessToken;
}
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = testRequest.ServerUrl,
Topics = testRequest.Topics,
AuthenticationType = testRequest.AuthenticationType,
Username = testRequest.Username,
Password = testRequest.Password,
AccessToken = testRequest.AccessToken,
Password = password,
AccessToken = accessToken,
Priority = testRequest.Priority,
Tags = testRequest.Tags
};
@@ -868,9 +976,24 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var botToken = testRequest.BotToken;
if (botToken.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<TelegramConfig>(
testRequest.ProviderId, NotificationProviderType.Telegram, p => p.TelegramConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Bot token cannot be a placeholder value" });
}
botToken = existing.BotToken;
}
var telegramConfig = new TelegramConfig
{
BotToken = testRequest.BotToken,
BotToken = botToken,
ChatId = testRequest.ChatId,
TopicId = testRequest.TopicId,
SendSilently = testRequest.SendSilently
@@ -958,6 +1081,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.WebhookUrl.IsPlaceholder())
{
return BadRequest("Webhook URL cannot be a placeholder value");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = newProvider.WebhookUrl,
@@ -1034,7 +1162,9 @@ public sealed class NotificationProvidersController : ControllerBase
var discordConfig = new DiscordConfig
{
WebhookUrl = updatedProvider.WebhookUrl,
WebhookUrl = updatedProvider.WebhookUrl.IsPlaceholder()
? existingProvider.DiscordConfiguration!.WebhookUrl
: updatedProvider.WebhookUrl,
Username = updatedProvider.Username,
AvatarUrl = updatedProvider.AvatarUrl
};
@@ -1088,9 +1218,24 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var webhookUrl = testRequest.WebhookUrl;
if (webhookUrl.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<DiscordConfig>(
testRequest.ProviderId, NotificationProviderType.Discord, p => p.DiscordConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Webhook URL cannot be a placeholder value" });
}
webhookUrl = existing.WebhookUrl;
}
var discordConfig = new DiscordConfig
{
WebhookUrl = testRequest.WebhookUrl,
WebhookUrl = webhookUrl,
Username = testRequest.Username,
AvatarUrl = testRequest.AvatarUrl
};
@@ -1146,6 +1291,16 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.ApiToken.IsPlaceholder())
{
return BadRequest("API token cannot be a placeholder value");
}
if (newProvider.UserKey.IsPlaceholder())
{
return BadRequest("User key cannot be a placeholder value");
}
var pushoverConfig = new PushoverConfig
{
ApiToken = newProvider.ApiToken,
@@ -1227,8 +1382,12 @@ public sealed class NotificationProvidersController : ControllerBase
var pushoverConfig = new PushoverConfig
{
ApiToken = updatedProvider.ApiToken,
UserKey = updatedProvider.UserKey,
ApiToken = updatedProvider.ApiToken.IsPlaceholder()
? existingProvider.PushoverConfiguration!.ApiToken
: updatedProvider.ApiToken,
UserKey = updatedProvider.UserKey.IsPlaceholder()
? existingProvider.PushoverConfiguration!.UserKey
: updatedProvider.UserKey,
Devices = updatedProvider.Devices,
Priority = updatedProvider.Priority,
Sound = updatedProvider.Sound,
@@ -1286,10 +1445,34 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var apiToken = testRequest.ApiToken;
var userKey = testRequest.UserKey;
if (apiToken.IsPlaceholder() || userKey.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<PushoverConfig>(
testRequest.ProviderId, NotificationProviderType.Pushover, p => p.PushoverConfiguration);
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
}
if (apiToken.IsPlaceholder())
{
apiToken = existing.ApiToken;
}
if (userKey.IsPlaceholder())
{
userKey = existing.UserKey;
}
}
var pushoverConfig = new PushoverConfig
{
ApiToken = testRequest.ApiToken,
UserKey = testRequest.UserKey,
ApiToken = apiToken,
UserKey = userKey,
Devices = testRequest.Devices,
Priority = testRequest.Priority,
Sound = testRequest.Sound,
@@ -1344,6 +1527,11 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest("A provider with this name already exists");
}
if (newProvider.ApplicationToken.IsPlaceholder())
{
return BadRequest("Application token cannot be a placeholder value");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = newProvider.ServerUrl,
@@ -1421,7 +1609,9 @@ public sealed class NotificationProvidersController : ControllerBase
var gotifyConfig = new GotifyConfig
{
ServerUrl = updatedProvider.ServerUrl,
ApplicationToken = updatedProvider.ApplicationToken,
ApplicationToken = updatedProvider.ApplicationToken.IsPlaceholder()
? existingProvider.GotifyConfiguration!.ApplicationToken
: updatedProvider.ApplicationToken,
Priority = updatedProvider.Priority
};
@@ -1474,10 +1664,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
try
{
var applicationToken = testRequest.ApplicationToken;
if (applicationToken.IsPlaceholder())
{
var existing = await GetExistingProviderConfig<GotifyConfig>(
testRequest.ProviderId, NotificationProviderType.Gotify, p => p.GotifyConfiguration);
if (existing is null)
return BadRequest(new { Message = "Application token cannot be a placeholder value" });
applicationToken = existing.ApplicationToken;
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = testRequest.ServerUrl,
ApplicationToken = testRequest.ApplicationToken,
ApplicationToken = applicationToken,
Priority = testRequest.Priority
};
gotifyConfig.Validate();
@@ -1514,4 +1717,34 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
private async Task<T?> GetExistingProviderConfig<T>(
Guid? providerId,
NotificationProviderType expectedType,
Func<NotificationConfig, T?> configSelector) where T : class
{
if (!providerId.HasValue)
{
return null;
}
IQueryable<NotificationConfig> query = _dataContext.NotificationConfigs.AsNoTracking();
query = expectedType switch
{
NotificationProviderType.Notifiarr => query.Include(p => p.NotifiarrConfiguration),
NotificationProviderType.Apprise => query.Include(p => p.AppriseConfiguration),
NotificationProviderType.Ntfy => query.Include(p => p.NtfyConfiguration),
NotificationProviderType.Pushover => query.Include(p => p.PushoverConfiguration),
NotificationProviderType.Telegram => query.Include(p => p.TelegramConfiguration),
NotificationProviderType.Discord => query.Include(p => p.DiscordConfiguration),
NotificationProviderType.Gotify => query.Include(p => p.GotifyConfiguration),
_ => query
};
var provider = await query
.FirstOrDefaultAsync(p => p.Id == providerId.Value && p.Type == expectedType);
return provider is null ? null : configSelector(provider);
}
}
@@ -13,6 +13,8 @@ public sealed record UpdateQueueCleanerConfigRequest
public FailedImportConfig FailedImport { get; init; } = new();
public ushort DownloadingMetadataMaxStrikes { get; init; }
public bool ProcessNoContentId { get; init; }
public List<string> IgnoredDownloads { get; set; } = [];
}
@@ -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;
@@ -67,6 +69,7 @@ public sealed class QueueCleanerConfigController : ControllerBase
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.FailedImport = newConfigDto.FailedImport;
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
oldConfig.ProcessNoContentId = newConfigDto.ProcessNoContentId;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Validate();
@@ -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,25 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace Cleanuparr.Api.Filters;
/// <summary>
/// Prevents caching of sensitive responses by setting appropriate HTTP headers.
/// Applies Cache-Control: no-cache, no-store, Pragma: no-cache, and a past Expires date
/// for maximum compatibility with HTTP/1.0 and HTTP/1.1 clients and intermediaries.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class NoCacheAttribute : ActionFilterAttribute
{
public static void Apply(IHeaderDictionary headers)
{
headers.CacheControl = "no-cache, no-store";
headers.Pragma = "no-cache";
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
}
public override void OnResultExecuting(ResultExecutingContext context)
{
Apply(context.HttpContext.Response.Headers);
base.OnResultExecuting(context);
}
}
@@ -1,6 +1,4 @@
using System.Reflection;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -63,7 +61,14 @@ public static class HostExtensions
{
await configContext.Database.MigrateAsync();
}
// Apply users db migrations
await using var usersContext = UsersContext.CreateStaticInstance();
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
{
await usersContext.Database.MigrateAsync();
}
return builder;
}
}
@@ -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,
};
}
}
@@ -0,0 +1,89 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Middleware;
public class SetupGuardMiddleware
{
private readonly RequestDelegate _next;
private volatile bool _setupCompleted;
public SetupGuardMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
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)
{
return true;
}
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is { SetupCompleted: true })
{
_setupCompleted = true;
return true;
}
return false;
}
private static bool IsSetupOnlyPath(string path)
{
return path.StartsWith("/api/auth/setup/") || path == "/api/auth/setup";
}
}
+22 -7
View File
@@ -3,6 +3,7 @@ using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Microsoft.AspNetCore.DataProtection;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
@@ -70,12 +71,19 @@ builder.Services.ConfigureHttpJsonOptions(options =>
// Add services to the container
builder.Services
.AddInfrastructure(builder.Configuration)
.AddApiServices();
.AddApiServices()
.AddAuthServices();
// Persist Data Protection keys to the config directory
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "DataProtection-Keys")))
.SetApplicationName("Cleanuparr");
// Add CORS before SignalR
builder.Services.AddCors(options =>
builder.Services.AddCors(options =>
{
options.AddPolicy("Any", policy =>
options.AddPolicy("Any", policy =>
{
policy
// https://github.com/dotnet/aspnetcore/issues/4457#issuecomment-465669576
@@ -123,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;
}
@@ -146,14 +161,14 @@ app.Init();
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
SignalRLogSink.Instance.SetAppHubContext(appHub);
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
// Configure health check endpoints as middleware (before auth pipeline) so they don't require authentication
app.UseHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
app.UseHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
@@ -166,4 +181,4 @@ await app.RunAsync();
await Log.CloseAndFlushAsync();
// Make Program class accessible for testing
public partial class Program { }
public partial class Program { }
@@ -27,6 +27,8 @@ public interface ITorrentItemWrapper
long SeedingTimeSeconds { get; }
string? Category { get; set; }
string SavePath { get; }
bool IsDownloading();
@@ -0,0 +1,37 @@
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
/// <summary>
/// Represents a file within a torrent from rTorrent's XML-RPC f.multicall response
/// </summary>
public sealed record RTorrentFile
{
/// <summary>
/// File index within the torrent (0-based)
/// </summary>
public int Index { get; init; }
/// <summary>
/// File path relative to the torrent base directory
/// </summary>
public required string Path { get; init; }
/// <summary>
/// File size in bytes
/// </summary>
public long SizeBytes { get; init; }
/// <summary>
/// Download priority: 0 = skip/don't download, 1 = normal, 2 = high
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Number of completed chunks for this file
/// </summary>
public long CompletedChunks { get; init; }
/// <summary>
/// Total number of chunks for this file
/// </summary>
public long SizeChunks { get; init; }
}
@@ -0,0 +1,72 @@
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
/// <summary>
/// Represents a torrent from rTorrent's XML-RPC multicall response
/// </summary>
public sealed record RTorrentTorrent
{
/// <summary>
/// Torrent info hash (40-character hex string, uppercase)
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// Torrent name
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Whether the torrent is from a private tracker (0 or 1)
/// </summary>
public int IsPrivate { get; init; }
/// <summary>
/// Total size of the torrent in bytes
/// </summary>
public long SizeBytes { get; init; }
/// <summary>
/// Number of bytes completed/downloaded
/// </summary>
public long CompletedBytes { get; init; }
/// <summary>
/// Current download rate in bytes per second
/// </summary>
public long DownRate { get; init; }
/// <summary>
/// Upload/download ratio multiplied by 1000 (e.g., 1500 = 1.5 ratio)
/// </summary>
public long Ratio { get; init; }
/// <summary>
/// Torrent state: 0 = stopped, 1 = started
/// </summary>
public int State { get; init; }
/// <summary>
/// Completion status: 0 = incomplete, 1 = complete
/// </summary>
public int Complete { get; init; }
/// <summary>
/// Unix timestamp when the torrent finished downloading (0 if not finished)
/// </summary>
public long TimestampFinished { get; init; }
/// <summary>
/// Label/category from d.custom1 (commonly used by ruTorrent for labels)
/// </summary>
public string? Label { get; init; }
/// <summary>
/// Base path where the torrent data is stored
/// </summary>
public string? BasePath { get; init; }
/// <summary>
/// List of tracker URLs for this torrent
/// </summary>
public IReadOnlyList<string>? Trackers { get; init; }
}
@@ -11,5 +11,4 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
MalwareFileFound,
}
@@ -6,4 +6,5 @@ public enum DownloadClientTypeName
Deluge,
Transmission,
uTorrent,
rTorrent,
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Exceptions;
public class RTorrentClientException : Exception
{
public RTorrentClientException(string message) : base(message)
{
}
public RTorrentClientException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -1,10 +1,8 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
@@ -35,144 +33,4 @@ public class WhisparrV2ClientTests
_dryRunInterceptorMock.Object
);
}
#region IsRecordValid Tests
[Fact]
public void IsRecordValid_WhenEpisodeIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 1
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("episode id and/or series id missing")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public void IsRecordValid_WhenSeriesIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 1,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreSet_ReturnsTrue()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.True(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = null!,
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
#endregion
}
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more