Compare commits

...
9 Commits
44 changed files with 2626 additions and 178 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"
-8
View File
@@ -101,14 +101,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle">![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social)</span></span>
# Credits
Special thanks for inspiration go to:
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
@@ -178,6 +178,61 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(11)]
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(12)]
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(13)]
public async Task Setup_Complete_AfterCompletion_IsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
// Blocked by middleware (403) or controller defense-in-depth (409)
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(14)]
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
// Login endpoint must NOT be blocked by the middleware (403).
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(15)]
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
}
#region TOTP helpers
private static string _totpSecret = "";
@@ -0,0 +1,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);
}
}
@@ -29,7 +29,16 @@ public static class AuthDI
return ApiKeyAuthenticationDefaults.AuthenticationScheme;
}
return JwtBearerDefaults.AuthenticationScheme;
// Check for Bearer token or SignalR access_token
if (context.Request.Headers.ContainsKey("Authorization") ||
(context.Request.Path.StartsWithSegments("/api/hubs") &&
context.Request.Query.ContainsKey("access_token")))
{
return JwtBearerDefaults.AuthenticationScheme;
}
// Fall through to trusted network handler (returns NoResult if disabled)
return TrustedNetworkAuthenticationDefaults.AuthenticationScheme;
};
})
.AddJwtBearer(options =>
@@ -64,7 +73,9 @@ public static class AuthDI
};
})
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { });
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
.AddScheme<AuthenticationSchemeOptions, TrustedNetworkAuthenticationHandler>(
TrustedNetworkAuthenticationDefaults.AuthenticationScheme, _ => { });
services.AddAuthorization(options =>
{
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record Disable2faRequest
{
[Required]
public required string Password { get; init; }
[Required]
[StringLength(6, MinimumLength = 6)]
public required string TotpCode { get; init; }
}
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record Enable2faRequest
{
[Required]
public required string Password { get; init; }
}
@@ -4,4 +4,5 @@ public sealed record AuthStatusResponse
{
public required bool SetupCompleted { get; init; }
public bool PlexLinked { get; init; }
public bool AuthBypassActive { get; init; }
}
@@ -4,4 +4,5 @@ public sealed record LoginResponse
{
public required bool RequiresTwoFactor { get; init; }
public string? LoginToken { get; init; }
public TokenResponse? Tokens { get; init; }
}
@@ -139,6 +139,145 @@ public sealed class AccountController : ControllerBase
}
}
[HttpPost("2fa/enable")]
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null) return Unauthorized();
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
// Generate new TOTP
var secret = _totpService.GenerateSecret();
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
// Replace any existing recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
foreach (var code in recoveryCodes)
{
_usersContext.RecoveryCodes.Add(new RecoveryCode
{
Id = Guid.NewGuid(),
UserId = user.Id,
CodeHash = _totpService.HashRecoveryCode(code),
IsUsed = false
});
}
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
return Ok(new TotpSetupResponse
{
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
});
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("2fa/enable/verify")]
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return BadRequest(new { error = "Invalid verification code" });
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
return Ok(new { message = "2FA enabled" });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpPost("2fa/disable")]
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null) return Unauthorized();
if (!user.TotpEnabled)
{
return BadRequest(new { error = "2FA is not enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
}
user.TotpEnabled = false;
user.TotpSecret = string.Empty;
user.UpdatedAt = DateTime.UtcNow;
// Remove all recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
return Ok(new { message = "2FA disabled" });
}
finally
{
UsersContext.Lock.Release();
}
}
[HttpGet("api-key")]
public async Task<IActionResult> GetApiKey()
{
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using Cleanuparr.Api.Auth;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Infrastructure.Features.Auth;
@@ -43,10 +44,25 @@ public sealed class AuthController : ControllerBase
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
var authBypass = false;
await using var dataContext = DataContext.CreateStaticInstance();
var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
{
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
HttpContext, generalConfig.Auth.TrustForwardedHeaders);
if (clientIp is not null)
{
authBypass = TrustedNetworkAuthenticationHandler.IsTrustedAddress(
clientIp, generalConfig.Auth.TrustedNetworks);
}
}
return Ok(new AuthStatusResponse
{
SetupCompleted = user is { SetupCompleted: true },
PlexLinked = user?.PlexAccountId is not null
PlexLinked = user?.PlexAccountId is not null,
AuthBypassActive = authBypass
});
}
@@ -103,9 +119,9 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted && user.TotpEnabled)
if (user.SetupCompleted)
{
return Conflict(new { error = "2FA is already configured" });
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
}
// Generate new TOTP secret
@@ -160,6 +176,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
@@ -196,9 +217,9 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (!user.TotpEnabled)
if (user.SetupCompleted)
{
return BadRequest(new { error = "2FA must be configured before completing setup" });
return Conflict(new { error = "Setup already completed" });
}
user.SetupCompleted = true;
@@ -242,6 +263,22 @@ public sealed class AuthController : ControllerBase
// Reset failed attempts on successful password verification
await ResetFailedAttempts(user.Id);
// If 2FA is not enabled, issue tokens directly
if (!user.TotpEnabled)
{
// Re-fetch with tracking since the query above used AsNoTracking
var trackedUser = await _usersContext.Users.FirstAsync(u => u.Id == user.Id);
var tokenResponse = await GenerateTokenResponse(trackedUser);
_logger.LogInformation("User {Username} logged in (2FA disabled)", user.Username);
return Ok(new LoginResponse
{
RequiresTwoFactor = false,
Tokens = tokenResponse
});
}
// Password valid - require 2FA
var loginToken = _jwtService.GenerateLoginToken(user.Id);
@@ -355,6 +392,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
}
var pin = await _plexAuthService.RequestPin();
return Ok(new PlexPinStatusResponse
@@ -385,6 +427,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
}
user.PlexAccountId = plexAccount.AccountId;
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
@@ -445,7 +492,10 @@ public sealed class AuthController : ControllerBase
return Unauthorized(new { error = "Plex account does not match the linked account" });
}
// Plex login bypasses 2FA
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
// Plex account during setup or via account settings (both require authentication).
// Since Plex login verifies the exact same Plex account ID that was linked,
// 2FA is not required for Plex login.
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
var tokenResponse = await GenerateTokenResponse(user);
@@ -477,7 +527,7 @@ public sealed class AuthController : ControllerBase
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = 60 // seconds
ExpiresIn = 3600 // seconds
};
}
@@ -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;
}
}
@@ -15,52 +15,75 @@ public class SetupGuardMiddleware
public async Task InvokeAsync(HttpContext context)
{
// Fast path: setup already completed
string path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
// Always allow health checks and non-API paths (static files, SPA, etc.)
if (path.StartsWith("/health") || !path.StartsWith("/api/"))
{
await _next(context);
return;
}
// Setup-only paths (/api/auth/setup/*) require setup to NOT be complete
if (IsSetupOnlyPath(path))
{
if (await IsSetupCompleted())
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup already completed" });
return;
}
await _next(context);
return;
}
// Non-setup auth paths (login, refresh, logout, status) are always allowed
if (path.StartsWith("/api/auth/") || path == "/api/auth")
{
await _next(context);
return;
}
// All other API paths require setup to be complete
if (!await IsSetupCompleted())
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
return;
}
await _next(context);
}
public void ResetSetupState()
{
_setupCompleted = false;
}
private async Task<bool> IsSetupCompleted()
{
if (_setupCompleted)
{
await _next(context);
return;
return true;
}
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
// Always allow these paths regardless of setup state
if (IsAllowedPath(path))
{
await _next(context);
return;
}
// Check database for setup completion
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is { SetupCompleted: true })
{
_setupCompleted = true;
await _next(context);
return;
return true;
}
// Setup not complete - block non-auth requests
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
return false;
}
/// <summary>
/// Resets the cached setup state. Call this if the user database is reset.
/// </summary>
public void ResetSetupState()
private static bool IsSetupOnlyPath(string path)
{
_setupCompleted = false;
}
private static bool IsAllowedPath(string path)
{
return path.StartsWith("/api/auth/")
|| path == "/api/auth"
|| path.StartsWith("/health")
|| !path.StartsWith("/api/");
return path.StartsWith("/api/auth/setup/") || path == "/api/auth/setup";
}
}
+8 -1
View File
@@ -131,6 +131,13 @@ if (basePath is not null)
{
if (!string.IsNullOrEmpty(basePath) && !context.Request.Path.StartsWithSegments(basePath, StringComparison.OrdinalIgnoreCase))
{
// Redirect root to the base path for convenience
if (!context.Request.Path.HasValue || context.Request.Path.Value == "/")
{
context.Response.Redirect(basePath + "/");
return;
}
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
@@ -174,4 +181,4 @@ await app.RunAsync();
await Log.CloseAndFlushAsync();
// Make Program class accessible for testing
public partial class Program { }
public partial class Program { }
@@ -11,7 +11,7 @@ public sealed class JwtService : IJwtService
{
private const string Issuer = "Cleanuparr";
private const string Audience = "Cleanuparr";
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(1);
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromHours(1);
private static readonly TimeSpan LoginTokenLifetime = TimeSpan.FromMinutes(5);
private readonly byte[] _signingKey;
@@ -88,11 +88,20 @@ public class DataContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GeneralConfig>(entity =>
{
entity.ComplexProperty(e => e.Log, cp =>
{
cp.Property(l => l.Level).HasConversion<LowercaseEnumConverter<LogEventLevel>>();
})
);
});
entity.ComplexProperty(e => e.Auth, cp =>
{
cp.Property(a => a.TrustedNetworks)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
});
});
modelBuilder.Entity<QueueCleanerConfig>(entity =>
{
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAuthConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "auth_disable_auth_for_local_addresses",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "auth_trust_forwarded_headers",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "auth_trusted_networks",
table: "general_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "auth_disable_auth_for_local_addresses",
table: "general_configs");
migrationBuilder.DropColumn(
name: "auth_trust_forwarded_headers",
table: "general_configs");
migrationBuilder.DropColumn(
name: "auth_trusted_networks",
table: "general_configs");
}
}
}
@@ -319,6 +319,24 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("strike_inactivity_window_hours");
b.ComplexProperty(typeof(Dictionary<string, object>), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DisableAuthForLocalAddresses")
.HasColumnType("INTEGER")
.HasColumnName("auth_disable_auth_for_local_addresses");
b1.Property<bool>("TrustForwardedHeaders")
.HasColumnType("INTEGER")
.HasColumnName("auth_trust_forwarded_headers");
b1.Property<string>("TrustedNetworks")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("auth_trusted_networks");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Net;
using Cleanuparr.Domain.Exceptions;
namespace Cleanuparr.Persistence.Models.Configuration.General;
[ComplexType]
public sealed record AuthConfig : IConfig
{
public bool DisableAuthForLocalAddresses { get; set; }
public bool TrustForwardedHeaders { get; set; }
public List<string> TrustedNetworks { get; set; } = [];
public void Validate()
{
foreach (var entry in TrustedNetworks)
{
if (!IsValidIpOrCidr(entry))
{
throw new ValidationException($"Invalid IP address or CIDR range: {entry}");
}
}
}
private static bool IsValidIpOrCidr(string value)
{
// CIDR notation: 192.168.1.0/24
if (value.Contains('/'))
{
var parts = value.Split('/');
if (parts.Length != 2) return false;
if (!IPAddress.TryParse(parts[0], out _)) return false;
if (!int.TryParse(parts[1], out var prefix)) return false;
// IPv4: 0-32, IPv6: 0-128
var maxPrefix = parts[0].Contains(':') ? 128 : 32;
return prefix >= 0 && prefix <= maxPrefix;
}
// Plain IP address
return IPAddress.TryParse(value, out _);
}
}
@@ -36,6 +36,8 @@ public sealed record GeneralConfig : IConfig
public LoggingConfig Log { get; set; } = new();
public AuthConfig Auth { get; set; } = new();
public void Validate()
{
if (HttpTimeout is 0)
@@ -49,5 +51,6 @@ public sealed record GeneralConfig : IConfig
}
Log.Validate();
Auth.Validate();
}
}
+30 -4
View File
@@ -1,6 +1,34 @@
#!/bin/bash
set -e
# Default UMASK if unset to prevent errors with set -e
UMASK="${UMASK:-022}"
CURRENT_UID=$(id -u)
# If not running as root, skip all user/permission management.
# This supports docker-compose `user: PUID:PGID` (rootless mode).
if [ "$CURRENT_UID" != "0" ]; then
umask "$UMASK"
# In rootless mode, the app uses /config for all writable state.
# A non-root user cannot create directories under /, so validate early.
if [ ! -d /config ]; then
echo "ERROR: /config does not exist and the container is running as non-root (UID $CURRENT_UID)." >&2
echo "Please mount a writable volume at /config." >&2
exit 1
fi
if [ ! -w /config ]; then
echo "ERROR: /config is not writable by UID $CURRENT_UID." >&2
echo "Please adjust permissions or mount /config as a writable volume." >&2
exit 1
fi
exec "$@"
fi
# Running as root — use PUID/PGID to create user and drop privileges
# Create group if it doesn't exist
if ! getent group "$PGID" > /dev/null 2>&1; then
echo "Creating group with GID $PGID"
@@ -16,16 +44,14 @@ fi
# Set umask
umask "$UMASK"
# Change ownership of app directory if not running as root
# Ensure /config is writable by the target user
if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
mkdir -p /config
chown -R "$PUID:$PGID" /app
chown -R "$PUID:$PGID" /config
fi
# Execute the main command as the specified user
# Execute as the specified user (or root if PUID=0)
if [ "$PUID" = "0" ] && [ "$PGID" = "0" ]; then
# Running as root, no need for gosu
exec "$@"
else
# Use gosu to drop privileges
@@ -52,6 +52,18 @@ export class AccountApi {
return this.http.post<TotpSetupResponse>('/api/account/2fa/regenerate', request);
}
enable2fa(password: string): Observable<TotpSetupResponse> {
return this.http.post<TotpSetupResponse>('/api/account/2fa/enable', { password });
}
verifyEnable2fa(code: string): Observable<void> {
return this.http.post<void>('/api/account/2fa/enable/verify', { code });
}
disable2fa(password: string, totpCode: string): Observable<void> {
return this.http.post<void>('/api/account/2fa/disable', { password, totpCode });
}
getApiKey(): Observable<{ apiKey: string }> {
return this.http.get<{ apiKey: string }>('/api/account/api-key');
}
@@ -6,11 +6,13 @@ import { Router } from '@angular/router';
export interface AuthStatus {
setupCompleted: boolean;
plexLinked: boolean;
authBypassActive?: boolean;
}
export interface LoginResponse {
requiresTwoFactor: boolean;
loginToken?: string;
tokens?: TokenResponse;
}
export interface TokenResponse {
@@ -60,6 +62,13 @@ export class AuthService {
this._isSetupComplete.set(status.setupCompleted);
this._plexLinked.set(status.plexLinked);
// Trusted network bypass — no tokens needed
if (status.authBypassActive && status.setupCompleted) {
this._isAuthenticated.set(true);
this._isLoading.set(false);
return;
}
const token = localStorage.getItem('access_token');
if (token && status.setupCompleted) {
if (this.isTokenExpired(60)) {
@@ -112,7 +121,13 @@ export class AuthService {
// Login flow
login(username: string, password: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>('/api/auth/login', { username, password });
return this.http.post<LoginResponse>('/api/auth/login', { username, password }).pipe(
tap((response) => {
if (!response.requiresTwoFactor && response.tokens) {
this.handleTokens(response.tokens);
}
}),
);
}
verify2fa(loginToken: string, code: string, isRecoveryCode = false): Observable<TokenResponse> {
@@ -14,8 +14,11 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
if (error.error instanceof ErrorEvent) {
// Client-side error
message = error.error.message;
} else if (typeof error.error === 'string') {
// Server-side error with plain string body
message = error.error;
} else {
// Server-side error
// Server-side error with JSON body
message = error.error?.error
?? error.error?.message
?? error.message
@@ -59,6 +59,9 @@ export class DocumentationService {
'log.archiveTimeLimitHours': 'archive-time-limit',
'ignoredDownloads': 'ignored-downloads',
'strikeInactivityWindowHours': 'strike-inactivity-window',
'auth.disableLocalAuth': 'disable-auth-for-local-addresses',
'auth.trustForwardedHeaders': 'trust-forwarded-headers',
'auth.trustedNetworks': 'additional-trusted-networks',
},
'download-cleaner': {
'enabled': 'enable-download-cleaner',
@@ -83,6 +83,9 @@ export class LoginComponent implements OnInit, OnDestroy {
if (result.requiresTwoFactor && result.loginToken) {
this.loginToken.set(result.loginToken);
this.view.set('2fa');
} else if (!result.requiresTwoFactor) {
// 2FA not enabled — tokens already handled by AuthService
this.router.navigate(['/dashboard']);
}
this.loading.set(false);
},
@@ -144,6 +144,8 @@
Verify Code
</app-button>
</form>
<button class="skip-link" (click)="skip2fa()">Skip for now</button>
}
} @else {
<p class="step-subtitle success-text">
@@ -372,3 +372,20 @@
justify-content: flex-end;
margin-top: var(--space-4);
}
.skip-link {
display: block;
background: none;
border: none;
color: var(--text-tertiary);
font-family: var(--font-family);
font-size: var(--font-size-sm);
cursor: pointer;
text-align: center;
margin-top: var(--space-2);
padding: var(--space-1);
&:hover {
color: var(--text-secondary);
}
}
@@ -139,6 +139,11 @@ export class SetupComponent implements OnDestroy {
});
}
skip2fa(): void {
this.currentStep.set(3);
this.error.set('');
}
goToStep3(): void {
this.currentStep.set(3);
this.error.set('');
@@ -73,68 +73,167 @@
<div class="form-stack">
<div class="status-row">
<span class="status-label">Status</span>
<span class="status-value status-value--active">Active</span>
@if (account()!.twoFactorEnabled) {
<span class="status-value status-value--active">Active</span>
} @else {
<span class="status-value status-value--inactive">Disabled</span>
}
</div>
@if (newRecoveryCodes().length > 0) {
<div class="recovery-section">
<p class="recovery-title">New Authenticator Setup</p>
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ newTotpSecret() }}</code>
@if (account()!.twoFactorEnabled) {
<!-- 2FA Enabled: Regenerate or Disable -->
@if (newRecoveryCodes().length > 0) {
<div class="recovery-section">
<p class="recovery-title">New Authenticator Setup</p>
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
</details>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ newTotpSecret() }}</code>
</div>
</details>
</div>
<div class="form-divider"></div>
<p class="recovery-title">New Recovery Codes</p>
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of newRecoveryCodes(); track code) {
<div class="recovery-code">{{ code }}</div>
}
</div>
<div class="recovery-actions">
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
</div>
</div>
} @else {
<div class="form-divider"></div>
<p class="recovery-title">New Recovery Codes</p>
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of newRecoveryCodes(); track code) {
<div class="recovery-code">{{ code }}</div>
}
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
<app-input
label="Current Password"
type="password"
placeholder="Enter your password"
[value]="twoFaPassword()"
(valueChange)="twoFaPassword.set($event)"
/>
<app-input
label="Authenticator Code"
type="text"
placeholder="Enter 6-digit code"
[value]="twoFaCode()"
(valueChange)="twoFaCode.set($event)"
/>
<div class="form-actions">
<app-button
variant="destructive"
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
(clicked)="confirmRegenerate2fa()"
>
@if (regenerating2fa()) {
<app-spinner size="sm" /> Regenerating...
} @else {
Regenerate 2FA
}
</app-button>
<app-button
variant="destructive"
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || disabling2fa()"
(clicked)="confirmDisable2fa()"
>
@if (disabling2fa()) {
<app-spinner size="sm" /> Disabling...
} @else {
Disable 2FA
}
</app-button>
</div>
<div class="recovery-actions">
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
</div>
</div>
}
} @else {
<div class="form-divider"></div>
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
<app-input
label="Current Password"
type="password"
placeholder="Enter your password"
[value]="twoFaPassword()"
(valueChange)="twoFaPassword.set($event)"
/>
<app-input
label="Authenticator Code"
type="text"
placeholder="Enter 6-digit code"
[value]="twoFaCode()"
(valueChange)="twoFaCode.set($event)"
/>
<div class="form-actions">
<app-button
variant="destructive"
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
(clicked)="confirmRegenerate2fa()"
>
@if (regenerating2fa()) {
<app-spinner size="sm" /> Regenerating...
} @else {
Regenerate 2FA
<!-- 2FA Disabled: Enable flow -->
@if (enableSetup()) {
<!-- QR code + verify flow -->
<div class="recovery-section">
<p class="recovery-title">Set Up Authenticator</p>
<p class="recovery-desc">Scan this QR code with your authenticator app.</p>
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ newTotpSecret() }}</code>
</div>
</details>
</div>
@if (newRecoveryCodes().length > 0) {
<div class="form-divider"></div>
<p class="recovery-title">Recovery Codes</p>
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of newRecoveryCodes(); track code) {
<div class="recovery-code">{{ code }}</div>
}
</div>
<div class="recovery-actions">
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
</div>
}
</app-button>
</div>
<div class="form-divider"></div>
<app-input
label="Verification Code"
type="text"
placeholder="Enter 6-digit code from your app"
[value]="enableVerificationCode()"
(valueChange)="enableVerificationCode.set($event)"
/>
<div class="form-actions">
<app-button
variant="primary"
[disabled]="enableVerificationCode().length !== 6 || enabling2fa()"
(clicked)="verifyEnable2fa()"
>
@if (enabling2fa()) {
<app-spinner size="sm" /> Verifying...
} @else {
Verify & Enable 2FA
}
</app-button>
<app-button variant="ghost" (clicked)="cancelEnable2fa()">Cancel</app-button>
</div>
</div>
} @else {
<div class="form-divider"></div>
<p class="section-hint">Two-factor authentication adds an extra layer of security to your account.</p>
<app-input
label="Password"
type="password"
placeholder="Enter your password to enable 2FA"
[value]="enablePassword()"
(valueChange)="enablePassword.set($event)"
/>
<div class="form-actions">
<app-button
variant="primary"
[disabled]="!enablePassword() || enabling2fa()"
(clicked)="startEnable2fa()"
>
@if (enabling2fa()) {
<app-spinner size="sm" /> Setting up...
} @else {
Enable 2FA
}
</app-button>
</div>
}
}
</div>
</app-card>
@@ -59,6 +59,15 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
readonly newQrCodeUri = signal('');
readonly newTotpSecret = signal('');
// 2FA enable
readonly enablePassword = signal('');
readonly enableVerificationCode = signal('');
readonly enabling2fa = signal(false);
readonly enableSetup = signal(false);
// 2FA disable
readonly disabling2fa = signal(false);
// API key
readonly apiKey = signal('');
readonly apiKeyRevealed = signal(false);
@@ -172,6 +181,75 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
this.newTotpSecret.set('');
}
// 2FA enable flow
startEnable2fa(): void {
this.enabling2fa.set(true);
this.api.enable2fa(this.enablePassword()).subscribe({
next: (result) => {
this.newQrCodeUri.set(result.qrCodeUri);
this.newTotpSecret.set(result.secret);
this.newRecoveryCodes.set(result.recoveryCodes);
this.enableSetup.set(true);
this.enabling2fa.set(false);
},
error: () => {
this.toast.error('Failed to start 2FA setup. Check your password.');
this.enabling2fa.set(false);
},
});
}
verifyEnable2fa(): void {
this.enabling2fa.set(true);
this.api.verifyEnable2fa(this.enableVerificationCode()).subscribe({
next: () => {
this.toast.success('Two-factor authentication enabled');
this.cancelEnable2fa();
this.enabling2fa.set(false);
this.loadAccount();
},
error: () => {
this.toast.error('Invalid verification code');
this.enabling2fa.set(false);
},
});
}
cancelEnable2fa(): void {
this.enableSetup.set(false);
this.enablePassword.set('');
this.enableVerificationCode.set('');
this.newRecoveryCodes.set([]);
this.newQrCodeUri.set('');
this.newTotpSecret.set('');
}
// 2FA disable flow
async confirmDisable2fa(): Promise<void> {
const confirmed = await this.confirmService.confirm({
title: 'Disable 2FA',
message: 'This will remove two-factor authentication from your account. Your recovery codes will be deleted.',
confirmLabel: 'Disable',
destructive: true,
});
if (!confirmed) return;
this.disabling2fa.set(true);
this.api.disable2fa(this.twoFaPassword(), this.twoFaCode()).subscribe({
next: () => {
this.toast.success('Two-factor authentication disabled');
this.twoFaPassword.set('');
this.twoFaCode.set('');
this.disabling2fa.set(false);
this.loadAccount();
},
error: () => {
this.toast.error('Failed to disable 2FA. Check your password and code.');
this.disabling2fa.set(false);
},
});
}
// API key
revealApiKey(): void {
if (this.apiKeyRevealed()) {
@@ -57,7 +57,7 @@
</app-card>
@if (enabled()) {
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded">
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded" [error]="noFeaturesError()">
@for (cat of categories(); track $index; let i = $index) {
<div class="category-row">
<div class="category-row__header">
@@ -105,7 +105,7 @@
</app-button>
</app-accordion>
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded">
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded" [error]="noFeaturesError()">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="unlinkedEnabled"
hint="Enable management of downloads that have no hardlinks"
@@ -6,6 +6,7 @@ import {
EmptyStateComponent, LoadingStateComponent, type SelectOption,
} from '@ui';
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { DownloadCleanerConfig, CleanCategory, createDefaultCategory } from '@shared/models/download-cleaner-config.model';
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
@@ -123,7 +124,20 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return undefined;
}
readonly noFeaturesError = computed(() => {
if (!this.enabled()) return undefined;
const hasSeedingCategories = this.categories().length > 0;
const hasUnlinkedFeature = this.unlinkedEnabled()
&& !this.unlinkedTargetCategoryError()
&& !this.unlinkedCategoriesError();
if (!hasSeedingCategories && !hasUnlinkedFeature) {
return 'At least one feature must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noFeaturesError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.unlinkedTargetCategoryError()) return true;
@@ -224,8 +238,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to save download cleaner settings');
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400
? err.message
: 'Failed to save download cleaner settings');
this.saving.set(false);
},
});
@@ -41,6 +41,26 @@
</div>
</app-card>
<app-card header="Authentication">
<div class="form-stack">
<app-toggle label="Disable Authentication for Local Addresses" [(checked)]="authDisableLocalAuth"
hint="When enabled, requests from local network addresses (localhost, 192.168.x.x, 10.x.x.x, 172.16-31.x.x) will bypass authentication"
helpKey="general:auth.disableLocalAuth" />
@if (authDisableLocalAuth()) {
<app-toggle label="Trust Forwarded Headers" [(checked)]="authTrustForwardedHeaders"
hint="When behind a reverse proxy, trust X-Forwarded-For and X-Real-IP headers to determine the client's real IP address. Only enable this if you are using a reverse proxy."
helpKey="general:auth.trustForwardedHeaders" />
<app-chip-input
label="Additional Trusted Networks"
placeholder="e.g. 192.168.1.0/24"
hint="Add custom IP addresses or CIDR ranges that should bypass authentication"
[(items)]="authTrustedNetworks"
helpKey="general:auth.trustedNetworks"
/>
}
</div>
</app-card>
<app-card header="HTTP Settings">
<div class="form-stack">
<div class="form-row">
@@ -69,6 +69,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
readonly strikeInactivityWindowHours = signal<number | null>(24);
readonly purgingStrikes = signal(false);
// Auth
readonly authDisableLocalAuth = signal(false);
readonly authTrustForwardedHeaders = signal(false);
readonly authTrustedNetworks = signal<string[]>([]);
// Logging
readonly logLevel = signal<unknown>(LogEventLevel.Information);
readonly logRollingSizeMB = signal<number | null>(10);
@@ -182,6 +187,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
this.statusCheckEnabled.set(config.statusCheckEnabled);
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.strikeInactivityWindowHours.set(config.strikeInactivityWindowHours);
if (config.auth) {
this.authDisableLocalAuth.set(config.auth.disableAuthForLocalAddresses);
this.authTrustForwardedHeaders.set(config.auth.trustForwardedHeaders);
this.authTrustedNetworks.set(config.auth.trustedNetworks ?? []);
}
if (config.log) {
this.logLevel.set(config.log.level);
this.logRollingSizeMB.set(config.log.rollingSizeMB);
@@ -219,6 +229,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours() ?? 24,
ignoredDownloads: this.ignoredDownloads(),
auth: {
disableAuthForLocalAddresses: this.authDisableLocalAuth(),
trustForwardedHeaders: this.authTrustForwardedHeaders(),
trustedNetworks: this.authTrustedNetworks(),
},
log: {
level: this.logLevel() as LogEventLevel,
rollingSizeMB: this.logRollingSizeMB() ?? 10,
@@ -258,6 +273,9 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours(),
ignoredDownloads: this.ignoredDownloads(),
authDisableLocalAuth: this.authDisableLocalAuth(),
authTrustForwardedHeaders: this.authTrustForwardedHeaders(),
authTrustedNetworks: this.authTrustedNetworks(),
logLevel: this.logLevel(),
logRollingSizeMB: this.logRollingSizeMB(),
logRetainedFileCount: this.logRetainedFileCount(),
@@ -70,7 +70,7 @@
</app-card>
@if (enabled()) {
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded">
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded" [error]="noBlocklistError()">
@for (name of arrNames; track name) {
<div class="arr-blocklist">
<h4 class="arr-blocklist__title">{{ capitalize(name) }}</h4>
@@ -6,6 +6,7 @@ import {
type SelectOption,
} from '@ui';
import { MalwareBlockerApi } from '@core/api/malware-blocker.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { MalwareBlockerConfig, BlocklistSettings, MalwareScheduleOptions } from '@shared/models/malware-blocker-config.model';
import { BlocklistType, ScheduleUnit } from '@shared/models/enums';
@@ -120,7 +121,18 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
return undefined;
}
readonly noBlocklistError = computed(() => {
if (!this.enabled()) return undefined;
const blocklists = this.arrBlocklists();
const hasAnyEnabled = ARR_NAMES.some(name => blocklists[name]?.enabled);
if (!hasAnyEnabled) {
return 'At least one blocklist must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noBlocklistError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
@@ -225,8 +237,10 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to save malware blocker settings');
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400
? err.message
: 'Failed to save malware blocker settings');
this.saving.set(false);
},
});
@@ -75,7 +75,6 @@ export class NavSidebarComponent {
];
suggestedApps: ExternalLink[] = [
{ label: 'Huntarr', icon: 'tablerExternalLink', href: 'https://github.com/plexguide/Huntarr.io' },
];
onNavItemClick(): void {
@@ -10,6 +10,12 @@ export interface LoggingConfig {
archiveTimeLimitHours: number;
}
export interface AuthConfig {
disableAuthForLocalAddresses: boolean;
trustForwardedHeaders: boolean;
trustedNetworks: string[];
}
export interface GeneralConfig {
displaySupportBanner: boolean;
dryRun: boolean;
@@ -21,5 +27,6 @@ export interface GeneralConfig {
statusCheckEnabled: boolean;
strikeInactivityWindowHours: number;
log?: LoggingConfig;
auth?: AuthConfig;
ignoredDownloads: string[];
}
@@ -6,6 +6,9 @@
<span class="accordion__subtitle">{{ subtitle() }}</span>
}
</div>
@if (error()) {
<span class="accordion__error">{{ error() }}</span>
}
<ng-icon
[name]="expanded() ? 'tablerChevronDown' : 'tablerChevronRight'"
class="accordion__chevron"
@@ -49,6 +49,14 @@
color: var(--text-secondary);
}
&__error {
font-size: var(--font-size-xs);
color: var(--color-error);
margin-left: auto;
margin-right: var(--space-3);
flex-shrink: 0;
}
&__chevron {
font-size: 18px;
color: var(--text-tertiary);
@@ -12,6 +12,7 @@ import { NgIcon } from '@ng-icons/core';
export class AccordionComponent {
header = input.required<string>();
subtitle = input<string>();
error = input<string>();
expanded = model(false);
disabled = input(false);
+55 -4
View File
@@ -58,6 +58,61 @@ When disabled, the version check and "update available" notification in the side
<div className={styles.section}>
<SectionTitle icon="🔐">Authentication</SectionTitle>
<ConfigSection
title="Disable Auth for Local Addresses"
icon="🏠"
>
Bypass authentication for requests originating from local network addresses. When enabled, users on the local network can access Cleanuparr without logging in.
**Built-in trusted ranges:**
- `127.0.0.0/8` / `::1` (localhost)
- `10.0.0.0/8` (Class A private)
- `172.16.0.0/12` (Class B private)
- `192.168.0.0/16` (Class C private)
- IPv6 link-local (`fe80::`) and unique-local (`fd00::`) addresses
<Warning>
Only enable this if your Cleanuparr instance is not exposed to the internet. Anyone on your local network will have full access without authentication.
</Warning>
</ConfigSection>
<ConfigSection
title="Trust Forwarded Headers"
icon="🔀"
>
When running behind a reverse proxy (e.g. Nginx, Caddy, Traefik), enable this to use `X-Forwarded-For` and `X-Real-IP` headers to determine the client's real IP address instead of the proxy's address.
<Warning>
Only enable this if you are using a reverse proxy. Forwarded headers are only trusted when the direct connection comes from a local address, preventing spoofing from remote attackers.
</Warning>
</ConfigSection>
<ConfigSection
title="Additional Trusted Networks"
icon="🌐"
>
Add custom IP addresses or CIDR ranges that should bypass authentication, in addition to the built-in local address ranges.
**Examples:**
```
192.168.1.0/24
10.0.50.0/24
172.20.0.5
```
</ConfigSection>
</div>
<div className={styles.section}>
<SectionTitle icon="🌐">HTTP Configuration</SectionTitle>
<ConfigSection
@@ -105,10 +160,6 @@ SSL certificate validation for HTTPS connections.
Automatically search for replacements after removing downloads from *arr apps.
<Note>
If using [Huntarr](https://github.com/plexguide/Huntarr.io), disable this to let Huntarr handle searching.
</Note>
</ConfigSection>
<ConfigSection