diff --git a/.gitattributes b/.gitattributes index bd209d92..d4beb249 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ *.* linguist-language=C# -*.jar filter=lfs diff=lfs merge=lfs -text -*.dll filter=lfs diff=lfs merge=lfs -text + +# Force LF line endings for scripts and CI files so they run on Linux runners. +.githooks/** text eol=lf +*.sh text eol=lf +.github/workflows/*.yml text eol=lf +.github/workflows/*.yaml text eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 00000000..02b7a3da --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Local guard mirroring .github/workflows/guard-binaries.yml. +# Blocks commits that introduce build artifacts or oversized binaries. +# +# To enable for this clone: +# git config core.hooksPath .githooks +# +# Bypass (NOT recommended; CI will still reject): +# git commit --no-verify + +set -euo pipefail + +MAX_SIZE=$((50 * 1024 * 1024)) # 50 MB +MAX_JAR=$((10 * 1024 * 1024)) # 10 MB for .jar + +FORBIDDEN_PREFIXES=( + "Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + "Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher/libs/" +) + +STAGED=$(git diff --cached --name-only --diff-filter=AM || true) + +if [ -z "$STAGED" ]; then + exit 0 +fi + +FAIL=0 + +while IFS= read -r FILE; do + [ -n "$FILE" ] || continue + [ -f "$FILE" ] || continue + + for PREFIX in "${FORBIDDEN_PREFIXES[@]}"; do + case "$FILE" in + "$PREFIX"*) + echo "ERROR: $FILE is a forbidden build-artifact path ($PREFIX)." + echo " Build outputs belong in GitHub Releases, not git." + FAIL=1 + ;; + esac + done + + case "$FILE" in + */build/libs/*.jar) + echo "ERROR: $FILE is a Gradle build output. Add to .gitignore and publish via Releases." + FAIL=1 + ;; + */bin/Release/*.dll|*/bin/Release/*/*.dll|*/bin/Debug/*.dll|*/bin/Debug/*/*.dll) + echo "ERROR: $FILE is a .NET build output. Add to .gitignore and publish via Releases." + FAIL=1 + ;; + esac + + SIZE=$(wc -c < "$FILE") + + if [ "$SIZE" -gt "$MAX_SIZE" ]; then + SIZE_MB=$((SIZE / 1024 / 1024)) + echo "ERROR: $FILE is ${SIZE_MB} MB (limit 50 MB). Use Releases for large binaries." + FAIL=1 + fi + + case "$FILE" in + *.jar) + if [ "$SIZE" -gt "$MAX_JAR" ]; then + SIZE_MB=$((SIZE / 1024 / 1024)) + echo "ERROR: $FILE is a ${SIZE_MB} MB .jar (limit 10 MB). Use Releases." + FAIL=1 + fi + ;; + esac +done <<< "$STAGED" + +if [ "$FAIL" -eq 1 ]; then + echo "" + echo "Pre-commit guard rejected this commit." + echo "Bypass with --no-verify (CI will still enforce)." + exit 1 +fi + +exit 0 diff --git a/.github/workflows/build-desktop-tray.yml b/.github/workflows/build-desktop-tray.yml new file mode 100644 index 00000000..627542b9 --- /dev/null +++ b/.github/workflows/build-desktop-tray.yml @@ -0,0 +1,270 @@ +name: Build Desktop Tray + +# Builds the KaizokuTray Avalonia desktop application as self-contained +# single-file binaries for Windows and Linux (x64 + arm64), then publishes +# them to a GitHub Release. +# +# Triggers: +# - Push of a tag matching v* -> auto-build + publish release named after the tag +# - Manual dispatch -> requires 'version' input + +on: + workflow_dispatch: + inputs: + version: + description: "Version tag for the release (e.g. v1.0.0). Required." + required: true + type: string + draft: + description: "Publish as draft release" + required: false + type: boolean + default: false + prerelease: + description: "Mark release as prerelease" + required: false + type: boolean + default: false + push: + tags: + - "v*" + +jobs: + prepare: + name: Prepare shared build inputs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download Android.Compat.dll from release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + DLL_PATH="Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + mkdir -p "$(dirname "$DLL_PATH")" + gh release download android-compat-v1 \ + --repo "${{ github.repository }}" \ + --pattern "Android.Compat.dll" \ + --dir "$(dirname "$DLL_PATH")" \ + --clobber + echo "Downloaded: $(du -h "$DLL_PATH" | cut -f1)" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Build Frontend + working-directory: KaizokuFrontend + run: | + pnpm install --frozen-lockfile + pnpm run build + + - name: Package Frontend for Backend + run: | + set -euo pipefail + cd KaizokuFrontend/out + zip -r ../../KaizokuBackend/wwwroot.zip . + cd ../.. + sha256sum KaizokuBackend/wwwroot.zip | awk '{print $1}' > KaizokuBackend/wwwroot.sha256 + echo "wwwroot.zip: $(du -h KaizokuBackend/wwwroot.zip | cut -f1)" + echo "wwwroot.sha256: $(cat KaizokuBackend/wwwroot.sha256)" + + - name: Upload prep artifacts + uses: actions/upload-artifact@v4 + with: + name: build-prep + path: | + Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll + KaizokuBackend/wwwroot.zip + KaizokuBackend/wwwroot.sha256 + retention-days: 1 + if-no-files-found: error + + build: + name: Build (${{ matrix.rid }}) + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + archive_ext: zip + - rid: win-arm64 + archive_ext: zip + - rid: linux-x64 + archive_ext: tar.gz + - rid: linux-arm64 + archive_ext: tar.gz + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Download prep artifacts + uses: actions/download-artifact@v4 + with: + name: build-prep + path: . + + - name: Verify prep artifacts + run: | + set -euo pipefail + test -f Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll + test -f KaizokuBackend/wwwroot.zip + test -f KaizokuBackend/wwwroot.sha256 + echo "All prep artifacts present." + + - name: Determine version + id: version + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + VERSION="${GITHUB_REF_NAME}" + else + VERSION="${{ inputs.version }}" + fi + if [ -z "$VERSION" ]; then + echo "::error::No version available. Push a v* tag or provide 'version' input." + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building version: $VERSION" + + - name: Publish single-file binary + run: | + set -euo pipefail + dotnet publish KaizokuTray/KaizokuTray.csproj \ + -c Release \ + -r ${{ matrix.rid }} \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:EnableCompressionInSingleFile=true \ + -p:DebugType=embedded \ + -o publish/${{ matrix.rid }} + + - name: List publish output + run: ls -la publish/${{ matrix.rid }}/ + + - name: Package + id: package + run: | + set -euo pipefail + NAME="KaizokuTray-${{ steps.version.outputs.version }}-${{ matrix.rid }}" + mkdir -p dist + + if [ "${{ matrix.archive_ext }}" = "zip" ]; then + (cd publish/${{ matrix.rid }} && zip -9 -r "../../dist/${NAME}.zip" .) + ASSET="dist/${NAME}.zip" + else + tar -czf "dist/${NAME}.tar.gz" -C publish/${{ matrix.rid }} . + ASSET="dist/${NAME}.tar.gz" + fi + + echo "asset=$ASSET" >> "$GITHUB_OUTPUT" + echo "Packaged: $ASSET ($(du -h "$ASSET" | cut -f1))" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: kaizoku-tray-${{ matrix.rid }} + path: ${{ steps.package.outputs.asset }} + retention-days: 30 + if-no-files-found: error + + release: + name: Publish Release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Determine version + id: version + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + VERSION="${GITHUB_REF_NAME}" + else + VERSION="${{ inputs.version }}" + fi + if [ -z "$VERSION" ]; then + echo "::error::No version available." + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + pattern: kaizoku-tray-* + merge-multiple: true + + - name: List release assets + run: ls -la dist/ + + - name: Publish or update release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + DRAFT: ${{ inputs.draft }} + PRERELEASE: ${{ inputs.prerelease }} + run: | + set -euo pipefail + + NOTES=$(cat </dev/null 2>&1; then + gh release upload "$VERSION" dist/* \ + --clobber \ + --repo "${{ github.repository }}" + gh release edit "$VERSION" \ + --notes "$NOTES" \ + --repo "${{ github.repository }}" + echo "Updated existing release $VERSION with $(ls dist/ | wc -l) assets." + else + gh release create "$VERSION" dist/* \ + --repo "${{ github.repository }}" \ + --title "Kaizoku.NET $VERSION" \ + --notes "$NOTES" \ + "${FLAGS[@]}" + echo "Created release $VERSION with $(ls dist/ | wc -l) assets." + fi diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 9592b254..dc6b432c 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -38,7 +38,17 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ inputs.branch }} - lfs: true + + - name: Download Android.Compat.dll from release + run: | + DLL_PATH="Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + gh release download android-compat-v1 \ + --pattern "Android.Compat.dll" \ + --dir "$(dirname "$DLL_PATH")" \ + --clobber + echo "Downloaded: $(du -h "$DLL_PATH" | cut -f1)" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -58,6 +68,15 @@ jobs: with: dotnet-version: '8.0.x' + - name: Verify Android.Compat.dll + run: | + DLL="Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + if [ ! -f "$DLL" ]; then + echo "::error::Android.Compat.dll not found. Run the 'Rebuild Android.Compat.dll' workflow first." + exit 1 + fi + echo "Android.Compat.dll: $(du -h "$DLL" | cut -f1)" + - name: Setup Node.js and pnpm uses: actions/setup-node@v4 with: diff --git a/.github/workflows/guard-binaries.yml b/.github/workflows/guard-binaries.yml new file mode 100644 index 00000000..f9d97350 --- /dev/null +++ b/.github/workflows/guard-binaries.yml @@ -0,0 +1,127 @@ +name: Guard Binaries + +# Blocks commits that introduce build artifacts or oversized binaries. +# Build outputs (jars, compiled DLLs) belong in GitHub Releases, not git history. +# See: .githooks/pre-commit for the local equivalent of these checks. + +on: + pull_request: + push: + # Run on any branch push. Tag pushes don't match branch filters, so tags + # (which can reference historical commits with legacy oversized files) are + # naturally excluded. + branches: + - '**' + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine diff range + id: range + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + fi + # New branch / initial push: github.event.before is all zeros. + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + BASE="" + fi + echo "base=$BASE" >> "$GITHUB_OUTPUT" + echo "head=$HEAD" >> "$GITHUB_OUTPUT" + + - name: Check for forbidden paths and oversized files + env: + BASE: ${{ steps.range.outputs.base }} + HEAD: ${{ steps.range.outputs.head }} + run: | + set -euo pipefail + + MAX_SIZE=$((50 * 1024 * 1024)) # 50 MB hard cap for any committed file + MAX_JAR=$((10 * 1024 * 1024)) # 10 MB cap for .jar specifically + + # Forbidden path prefixes (build outputs that belong in Releases). + FORBIDDEN_PREFIXES=( + "Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + "Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher/libs/" + ) + + # Build list of files added/modified in the diff range. + if [ -z "$BASE" ]; then + # No base (new branch): inspect files in the head commit only. + CHANGED=$(git show --name-only --diff-filter=AM --pretty=format: "$HEAD" | grep -v '^$' || true) + else + CHANGED=$(git diff --name-only --diff-filter=AM "$BASE...$HEAD" || true) + fi + + if [ -z "$CHANGED" ]; then + echo "No added/modified files in range. Guard passes." + exit 0 + fi + + FAIL=0 + + while IFS= read -r FILE; do + [ -n "$FILE" ] || continue + [ -f "$FILE" ] || continue + + # Forbidden prefix check + for PREFIX in "${FORBIDDEN_PREFIXES[@]}"; do + case "$FILE" in + "$PREFIX"*) + echo "::error file=$FILE::Build artifact path is forbidden ($PREFIX). Use GitHub Releases." + FAIL=1 + ;; + esac + done + + # Glob patterns for known build-output shapes + case "$FILE" in + */build/libs/*.jar) + echo "::error file=$FILE::Gradle build output detected. Add to .gitignore and publish via Releases." + FAIL=1 + ;; + */bin/Release/*.dll|*/bin/Release/**/*.dll|*/bin/Debug/*.dll|*/bin/Debug/**/*.dll) + echo "::error file=$FILE::.NET build output detected. Add to .gitignore and publish via Releases." + FAIL=1 + ;; + esac + + # Size checks + SIZE=$(wc -c < "$FILE") + + if [ "$SIZE" -gt "$MAX_SIZE" ]; then + SIZE_MB=$((SIZE / 1024 / 1024)) + echo "::error file=$FILE::File is ${SIZE_MB} MB (limit 50 MB). Use Releases for large binaries." + FAIL=1 + fi + + case "$FILE" in + *.jar) + if [ "$SIZE" -gt "$MAX_JAR" ]; then + SIZE_MB=$((SIZE / 1024 / 1024)) + echo "::error file=$FILE::JAR is ${SIZE_MB} MB (limit 10 MB for .jar). Use Releases." + FAIL=1 + fi + ;; + esac + done <<< "$CHANGED" + + if [ "$FAIL" -eq 1 ]; then + echo "" + echo "::error::Guard failed: forbidden paths or oversized files detected." + echo "::error::Build artifacts should be published via the 'Rebuild Android.Compat.dll' workflow to GitHub Releases." + exit 1 + fi + + echo "Guard passed: no forbidden binaries or oversized files in diff." diff --git a/.github/workflows/rebuild-androidcompat.yml b/.github/workflows/rebuild-androidcompat.yml new file mode 100644 index 00000000..4b94c2bb --- /dev/null +++ b/.github/workflows/rebuild-androidcompat.yml @@ -0,0 +1,128 @@ +name: Rebuild Android.Compat.dll + +on: + workflow_dispatch: + push: + paths: + - 'Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/gradle/libs.versions.toml' + - 'Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/**/build.gradle.kts' + - 'Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/settings.gradle.kts' + - 'Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.Builder/**' + - 'Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher/**' + - 'Mihon.ExtensionsBridge.Net/IKVM.AndroidCompact.Build.ps1' + +jobs: + rebuild: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '9.2.1' + + - name: Build AndroidCompat fat JAR + working-directory: Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer + run: gradle fatJar :AndroidCompat:jar --no-daemon --stacktrace + + - name: Verify fat JAR exists + run: | + JAR="Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/AndroidCompat/build/libs/AndroidCompat-1.0-all.jar" + if [ ! -f "$JAR" ]; then + echo "::error::Fat JAR not found at $JAR" + exit 1 + fi + echo "Fat JAR size: $(du -h "$JAR" | cut -f1)" + + - name: Build IKVM Android.Compat.dll + working-directory: Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.Builder + run: dotnet build -c Release + + - name: Verify Builder output + run: | + DLL="Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.Builder/bin/Release/net8.0/Android.Compat.dll" + if [ ! -f "$DLL" ]; then + echo "::error::Builder output not found at $DLL" + exit 1 + fi + echo "Builder DLL size: $(du -h "$DLL" | cut -f1)" + + - name: Build CIL Patcher + working-directory: Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher + run: dotnet build -c Release + + - name: Run CIL Patcher + run: | + INPUT="Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.Builder/bin/Release/net8.0/Android.Compat.dll" + OUTPUT="Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + mkdir -p "$(dirname "$OUTPUT")" + + PATCHER="Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher/bin/Release/net8.0/IKVM.Android.Compatibility.Layer.CILPatcher" + if [ -f "${PATCHER}.dll" ]; then + dotnet "${PATCHER}.dll" "$INPUT" "$OUTPUT" + elif [ -f "$PATCHER" ]; then + "$PATCHER" "$INPUT" "$OUTPUT" + else + echo "::error::CIL Patcher executable not found" + exit 1 + fi + + - name: Verify final Android.Compat.dll + run: | + DLL="Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + if [ ! -f "$DLL" ]; then + echo "::error::Final Android.Compat.dll not found" + exit 1 + fi + SIZE=$(du -h "$DLL" | cut -f1) + SHA=$(sha256sum "$DLL" | cut -d' ' -f1) + echo "## Android.Compat.dll Rebuilt" >> $GITHUB_STEP_SUMMARY + echo "- **Size:** $SIZE" >> $GITHUB_STEP_SUMMARY + echo "- **SHA256:** \`$SHA\`" >> $GITHUB_STEP_SUMMARY + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: Android.Compat.dll + path: Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll + retention-days: 30 + + - name: Publish release asset + run: | + set -euo pipefail + DLL="Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + SHA=$(sha256sum "$DLL" | cut -d' ' -f1) + NOTES="Pre-built Android.Compat.dll for CI builds. SHA256: $SHA" + + # Create the release if it doesn't exist (idempotent — handles fresh forks). + if gh release view android-compat-v1 >/dev/null 2>&1; then + gh release upload android-compat-v1 "$DLL" --clobber + gh release edit android-compat-v1 --notes "$NOTES" + echo "Updated existing release android-compat-v1 with SHA256: $SHA" + else + gh release create android-compat-v1 "$DLL" \ + --title "Android.Compat.dll (prebuilt)" \ + --notes "$NOTES" + echo "Created release android-compat-v1 with SHA256: $SHA" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.gitignore b/.gitignore index 9e4b3593..b180953a 100644 --- a/.gitignore +++ b/.gitignore @@ -314,4 +314,15 @@ KaizokuBackend/runtime/kaizoku.schedule.db-wal /Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/.gradle /Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/build/reports/problems /uploads -/.claude/ \ No newline at end of file +/.claude/ +Claude Code.bat + +# Binary dependencies (downloaded by CI, not stored in repo) +Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll +Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher/libs/ +Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/gradle/wrapper/gradle-wrapper.jar + +# AI generated artifacts +generated-images/ +modal-redesign-mockup.html +/mockups diff --git a/KaizokuBackend/Authorization/BootstrapModeMiddleware.cs b/KaizokuBackend/Authorization/BootstrapModeMiddleware.cs new file mode 100644 index 00000000..2046426e --- /dev/null +++ b/KaizokuBackend/Authorization/BootstrapModeMiddleware.cs @@ -0,0 +1,50 @@ +using KaizokuBackend.Data; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Authorization +{ + /// + /// Maintains an in-process cache of whether any users exist in the database. + /// This cache is used by GET /api/auth/status and first-user detection + /// logic without hitting the database on every request. + /// + /// The previous "block all /api/* until setup" behaviour has been removed. + /// Enforcement of auth requirements is now handled by . + /// + public class BootstrapModeMiddleware + { + private readonly RequestDelegate _next; + private static bool? _hasUsers; + + public BootstrapModeMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, AppDbContext db) + { + // Refresh the cache when it has been invalidated or is indeterminate. + if (_hasUsers == null || _hasUsers == false) + { + _hasUsers = await db.Users.AnyAsync().ConfigureAwait(false); + } + + await _next(context).ConfigureAwait(false); + } + + /// + /// Returns the current cached value. May be null if the cache has never been + /// populated (i.e. no request has been handled yet). + /// + public static bool? HasUsers => _hasUsers; + + /// + /// Invalidates the cached user-existence check so it re-queries on the next request. + /// Call this after the first user is created or after any user is deleted. + /// + public static void InvalidateCache() + { + _hasUsers = null; + } + } +} diff --git a/KaizokuBackend/Authorization/JwtKeyResolver.cs b/KaizokuBackend/Authorization/JwtKeyResolver.cs new file mode 100644 index 00000000..5a85ed17 --- /dev/null +++ b/KaizokuBackend/Authorization/JwtKeyResolver.cs @@ -0,0 +1,49 @@ +using KaizokuBackend.Data; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace KaizokuBackend.Authorization +{ + /// + /// Resolves the JWT signing key from the database at runtime. + /// Implements IPostConfigureOptions to set the IssuerSigningKeyResolver + /// after the service provider is built. + /// + public class JwtKeyResolver : IPostConfigureOptions + { + private readonly IServiceScopeFactory _scopeFactory; + private SymmetricSecurityKey? _cachedKey; + + public JwtKeyResolver(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + public void PostConfigure(string? name, JwtBearerOptions options) + { + options.TokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => + { + if (_cachedKey != null) + return new[] { _cachedKey }; + + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var setting = db.Settings.FirstOrDefault(s => s.Name == "JwtSecret"); + if (setting == null) + return Array.Empty(); + + _cachedKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(setting.Value)); + return new[] { _cachedKey }; + } + catch + { + return Array.Empty(); + } + }; + } + } +} diff --git a/KaizokuBackend/Authorization/PermissionAuthorizationHandler.cs b/KaizokuBackend/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 00000000..f858c3e7 --- /dev/null +++ b/KaizokuBackend/Authorization/PermissionAuthorizationHandler.cs @@ -0,0 +1,58 @@ +using KaizokuBackend.Models.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace KaizokuBackend.Authorization +{ + public class PermissionAuthorizationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement) + { + if (!context.User.Identity?.IsAuthenticated ?? true) + { + return Task.CompletedTask; + } + + // Admins always have all permissions + var roleClaim = context.User.FindFirst("Role")?.Value; + if (roleClaim != null && Enum.TryParse(roleClaim, out var role) && role == UserRole.Admin) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + // Check permission claims — user needs ANY ONE of the required permissions + foreach (var permission in requirement.Permissions) + { + var permissionClaim = context.User.FindFirst(permission)?.Value; + if (permissionClaim != null && bool.TryParse(permissionClaim, out var hasPermission) && hasPermission) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + } + + return Task.CompletedTask; + } + } + + public class AdminRequirement : IAuthorizationRequirement { } + + public class AdminAuthorizationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AdminRequirement requirement) + { + if (!context.User.Identity?.IsAuthenticated ?? true) + { + return Task.CompletedTask; + } + + var roleClaim = context.User.FindFirst("Role")?.Value; + if (roleClaim != null && Enum.TryParse(roleClaim, out var role) && role == UserRole.Admin) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/KaizokuBackend/Authorization/PermissionPolicyProvider.cs b/KaizokuBackend/Authorization/PermissionPolicyProvider.cs new file mode 100644 index 00000000..e2857acd --- /dev/null +++ b/KaizokuBackend/Authorization/PermissionPolicyProvider.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace KaizokuBackend.Authorization +{ + public class PermissionPolicyProvider : IAuthorizationPolicyProvider + { + private const string RequirePermissionPrefix = "RequirePermission:"; + private const string RequireAdminPolicy = "RequireAdmin"; + private readonly DefaultAuthorizationPolicyProvider _fallbackPolicyProvider; + + public PermissionPolicyProvider(IOptions options) + { + _fallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options); + } + + public Task GetDefaultPolicyAsync() + { + return _fallbackPolicyProvider.GetDefaultPolicyAsync(); + } + + public Task GetFallbackPolicyAsync() + { + return _fallbackPolicyProvider.GetFallbackPolicyAsync(); + } + + public Task GetPolicyAsync(string policyName) + { + if (policyName == RequireAdminPolicy) + { + var policy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddRequirements(new AdminRequirement()) + .Build(); + return Task.FromResult(policy); + } + + if (policyName.StartsWith(RequirePermissionPrefix, StringComparison.OrdinalIgnoreCase)) + { + var permission = policyName.Substring(RequirePermissionPrefix.Length); + var policy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(permission)) + .Build(); + return Task.FromResult(policy); + } + + return _fallbackPolicyProvider.GetPolicyAsync(policyName); + } + } +} diff --git a/KaizokuBackend/Authorization/PermissionRequirement.cs b/KaizokuBackend/Authorization/PermissionRequirement.cs new file mode 100644 index 00000000..23189fae --- /dev/null +++ b/KaizokuBackend/Authorization/PermissionRequirement.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; + +namespace KaizokuBackend.Authorization +{ + public class PermissionRequirement : IAuthorizationRequirement + { + public string Permission { get; } + + /// + /// All permissions that satisfy this requirement (OR logic). + /// For single-permission policies this contains just one entry. + /// For comma-separated policies (e.g. "CanEditSeries,CanDeleteSeries") + /// the user needs ANY ONE of these permissions. + /// + public string[] Permissions { get; } + + public PermissionRequirement(string permission) + { + Permission = permission; + Permissions = permission.Contains(',') + ? permission.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : new[] { permission }; + } + } +} diff --git a/KaizokuBackend/Authorization/RequireUserLevelAttribute.cs b/KaizokuBackend/Authorization/RequireUserLevelAttribute.cs new file mode 100644 index 00000000..2f85a88f --- /dev/null +++ b/KaizokuBackend/Authorization/RequireUserLevelAttribute.cs @@ -0,0 +1,58 @@ +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Auth; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace KaizokuBackend.Authorization +{ + /// + /// Enforces a minimum on a controller or action. + /// + /// When authentication is disabled (via ), the check + /// is a no-op and the request is allowed through. + /// + /// When authentication is enabled: + /// + /// No user in HttpContext.Items["User"] → 401 Unauthorized. + /// User level below → 403 Forbidden. + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class RequireUserLevelAttribute : Attribute, IAuthorizationFilter + { + private readonly UserLevel _minimumLevel; + + public RequireUserLevelAttribute(UserLevel minimumLevel) + { + _minimumLevel = minimumLevel; + } + + public void OnAuthorization(AuthorizationFilterContext context) + { + // Resolve IAuthSettingsCache via DI — attributes are not DI-constructed. + var authSettingsCache = context.HttpContext.RequestServices.GetRequiredService(); + + // Auth disabled → allow everything. + if (!authSettingsCache.AuthenticationEnabled) + return; + + var user = context.HttpContext.Items["User"] as UserEntity; + + if (user == null) + { + context.Result = new UnauthorizedObjectResult(new { error = "Authentication required." }); + return; + } + + var effectiveLevel = UserService.ResolveLevel(user); + if (effectiveLevel < _minimumLevel) + { + context.Result = new ObjectResult(new { error = "Insufficient privileges." }) + { + StatusCode = StatusCodes.Status403Forbidden + }; + } + } + } +} diff --git a/KaizokuBackend/Controllers/AuthController.cs b/KaizokuBackend/Controllers/AuthController.cs new file mode 100644 index 00000000..f9a44911 --- /dev/null +++ b/KaizokuBackend/Controllers/AuthController.cs @@ -0,0 +1,345 @@ +using KaizokuBackend.Authorization; +using KaizokuBackend.Data; +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Services.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Controllers +{ + [ApiController] + [Route("api/auth")] + [Produces("application/json")] + public class AuthController : ControllerBase + { + private readonly AuthService _authService; + private readonly UserService _userService; + private readonly IAuthSettingsCache _authSettingsCache; + private readonly AppDbContext _db; + private readonly ILogger _logger; + public AuthController( + AuthService authService, + UserService userService, + IAuthSettingsCache authSettingsCache, + AppDbContext db, + ILogger logger) + { + _authService = authService; + _userService = userService; + _authSettingsCache = authSettingsCache; + _db = db; + _logger = logger; + } + + [HttpPost("login")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> LoginAsync([FromBody] LoginDto dto, CancellationToken token = default) + { + try + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = Request.Headers.UserAgent.ToString(); + var result = await _authService.LoginAsync(dto, ipAddress, userAgent, token).ConfigureAwait(false); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during login"); + return StatusCode(500, new { error = "An error occurred during login" }); + } + } + + [HttpPost("register")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> RegisterAsync([FromBody] RegisterDto dto, CancellationToken token = default) + { + try + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = Request.Headers.UserAgent.ToString(); + var result = await _authService.RegisterAsync(dto, ipAddress, userAgent, token).ConfigureAwait(false); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during registration"); + return StatusCode(500, new { error = "An error occurred during registration" }); + } + } + + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> RefreshTokenAsync([FromBody] RefreshTokenDto dto, CancellationToken token = default) + { + try + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = Request.Headers.UserAgent.ToString(); + var result = await _authService.RefreshTokenAsync(dto.RefreshToken, ipAddress, userAgent, token).ConfigureAwait(false); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during token refresh"); + return StatusCode(500, new { error = "An error occurred during token refresh" }); + } + } + + [HttpPost("logout")] + [Authorize] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task LogoutAsync([FromBody] RefreshTokenDto dto, CancellationToken token = default) + { + try + { + var userIdClaim = User.FindFirst("UserId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + return BadRequest(new { error = "Invalid user context" }); + + await _authService.LogoutAsync(userId, dto.RefreshToken, token).ConfigureAwait(false); + return Ok(new { message = "Logged out successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during logout"); + return StatusCode(500, new { error = "An error occurred during logout" }); + } + } + + /// + /// Returns the current auth configuration and, when auth is disabled, the list of + /// active users so the frontend can render a profile selector. + /// + [HttpGet("status")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthStatusDto), StatusCodes.Status200OK)] + public async Task> GetStatusAsync(CancellationToken token = default) + { + try + { + var hasUsers = await _userService.AnyUsersExistAsync(token).ConfigureAwait(false); + var authEnabled = _authSettingsCache.AuthenticationEnabled; + + var response = new AuthStatusDto + { + AuthenticationEnabled = authEnabled, + HasUsers = hasUsers, + NeedsAdminPassword = !authEnabled && hasUsers + && !(await _userService.AnyAdminHasPasswordAsync(token).ConfigureAwait(false)) + }; + + if (!authEnabled) + { + // Provide the user list for the profile selector in disabled mode. + var users = await _db.Users + .AsNoTracking() + .Where(u => u.IsActive) + .OrderBy(u => u.Username) + .Select(u => new StatusUserEntryDto + { + Id = u.Id, + Username = u.Username, + DisplayName = u.DisplayName, + AvatarBase64 = u.AvatarBlob != null && u.AvatarBlob.Length > 0 + ? Convert.ToBase64String(u.AvatarBlob) + : null, + AvatarContentType = u.AvatarContentType, + HasPassword = u.PasswordHash != null + }) + .ToListAsync(token) + .ConfigureAwait(false); + + response.Users = users; + } + + return Ok(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking auth status"); + return StatusCode(500, new { error = "An error occurred checking auth status" }); + } + } + + /// + /// Selects a user by username in auth-disabled (profile-picker) mode. + /// Returns the user DTO; no JWT is issued. + /// + [HttpPost("select-user")] + [AllowAnonymous] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)] + public async Task> SelectUserAsync([FromBody] SelectUserDto dto, CancellationToken token = default) + { + try + { + if (_authSettingsCache.AuthenticationEnabled) + return BadRequest(new { error = "select-user is only available when authentication is disabled." }); + + if (string.IsNullOrWhiteSpace(dto.Username)) + return BadRequest(new { error = "Username is required." }); + + // Single tracked query so the updated LastLoginAt is reflected in the DTO. + var user = await _db.Users + .FirstOrDefaultAsync(u => u.Username == dto.Username && u.IsActive, token) + .ConfigureAwait(false); + + if (user == null) + return NotFound(new { error = "User not found." }); + + // Claimed profiles require their password even in profile-picker mode; + // otherwise claiming would be purely cosmetic. + if (user.PasswordHash != null) + { + if (string.IsNullOrEmpty(dto.Password)) + return Unauthorized(new { error = "This profile is protected by a password.", passwordRequired = true }); + + if (!UserService.VerifyUserPassword(user, dto.Password)) + return Unauthorized(new { error = "Invalid password.", passwordRequired = true }); + } + + user.LastLoginAt = DateTime.UtcNow; + try + { + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to persist LastLoginAt for user {UserId} during select-user", user.Id); + // Non-fatal: return the user even if the timestamp write failed. + } + + return Ok(AuthService.MapUserDto(user)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during select-user"); + return StatusCode(500, new { error = "An error occurred during user selection" }); + } + } + + [HttpPost("set-password")] + [AllowAnonymous] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> SetPasswordAsync([FromBody] SetPasswordDto dto, CancellationToken token = default) + { + try + { + var result = await _userService.SetPasswordWithTokenAsync(dto.Token, dto.NewPassword, token).ConfigureAwait(false); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting password via invite token"); + return StatusCode(500, new { error = "An error occurred while setting the password" }); + } + } + + [HttpPost("set-admin-password")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task SetAdminPasswordAsync([FromBody] SetAdminPasswordDto dto, CancellationToken token = default) + { + try + { + var userIdClaim = User.FindFirst("UserId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + return BadRequest(new { error = "Invalid user context" }); + + await _userService.ResetPasswordAsync(userId, dto.NewPassword, token).ConfigureAwait(false); + return Ok(new { message = "Password set successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting admin password"); + return StatusCode(500, new { error = "An error occurred while setting the admin password" }); + } + } + + [HttpPost("setup")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> SetupAdminAsync([FromBody] CreateUserDto dto, CancellationToken token = default) + { + // Prevent TOCTOU race: two concurrent requests could both pass AnyUsersExistAsync + // and create duplicate admin accounts. Serialize setup with a lock. + await SetupGate.Lock.WaitAsync(token).ConfigureAwait(false); + try + { + var hasUsers = await _userService.AnyUsersExistAsync(token).ConfigureAwait(false); + if (hasUsers) + return BadRequest(new { error = "Setup has already been completed. An admin user already exists." }); + + if (string.IsNullOrWhiteSpace(dto.Password)) + return BadRequest(new { error = "A password is required to create the first admin via setup." }); + + dto.Role = Models.Enums.UserRole.Admin; + var user = await _userService.CreateAsync(dto, token).ConfigureAwait(false); + + // Login the newly created admin + var loginDto = new LoginDto + { + UsernameOrEmail = dto.Username, + Password = dto.Password, + RememberMe = true + }; + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = Request.Headers.UserAgent.ToString(); + var result = await _authService.LoginAsync(loginDto, ipAddress, userAgent, token).ConfigureAwait(false); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during admin setup"); + return StatusCode(500, new { error = "An error occurred during admin setup" }); + } + finally + { + SetupGate.Lock.Release(); + } + } + } +} diff --git a/KaizokuBackend/Controllers/DownloadsController.cs b/KaizokuBackend/Controllers/DownloadsController.cs index dbba7673..9ccb7a73 100644 --- a/KaizokuBackend/Controllers/DownloadsController.cs +++ b/KaizokuBackend/Controllers/DownloadsController.cs @@ -2,6 +2,7 @@ using KaizokuBackend.Models.Enums; using KaizokuBackend.Services.Downloads; using KaizokuBackend.Services.Images; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace KaizokuBackend.Controllers @@ -9,6 +10,7 @@ namespace KaizokuBackend.Controllers [ApiController] [Route("api/downloads")] [Produces("application/json")] + [Authorize] public class DownloadsController : ControllerBase { private readonly DownloadQueryService _downloadQuery; @@ -28,6 +30,7 @@ public DownloadsController(ILogger logger, } [HttpGet("series")] + [Authorize(Policy = "RequirePermission:CanViewQueue")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task>> GetDownloadsForSeriesAsync([FromQuery] Guid seriesId, CancellationToken token = default) @@ -46,6 +49,7 @@ public async Task>> GetDownloadsForSeriesAsyn } [HttpGet] + [Authorize(Policy = "RequirePermission:CanViewQueue")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> GetDownloadsAsync([FromQuery] QueueStatus status, int limit = 100, string? keyword = null, CancellationToken token = default) @@ -64,6 +68,7 @@ public async Task> GetDownloadsAsync([FromQuer } [HttpGet("metrics")] + [Authorize(Policy = "RequirePermission:CanViewQueue")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> GetDownloadsMetricsAsync(CancellationToken token = default) @@ -81,6 +86,7 @@ public async Task> GetDownloadsMetricsAsync(Ca } [HttpPatch] + [Authorize(Policy = "RequirePermission:CanManageDownloads")] public async Task ManageErrorDownloadAsync([FromQuery]Guid id, [FromQuery]ErrorDownloadAction action, CancellationToken token = default) { try @@ -94,5 +100,73 @@ public async Task ManageErrorDownloadAsync([FromQuery]Guid id, [Fr return StatusCode(500, new { error = "An error occurred while managing the download." }); } } + + /// + /// Remove a single download from the queue (waiting, completed, or failed) + /// + [HttpDelete("{id:guid}")] + [Authorize(Policy = "RequirePermission:CanManageDownloads")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task RemoveDownloadAsync(Guid id, CancellationToken token = default) + { + try + { + bool removed = await _downloadCommand.RemoveDownloadAsync(id, token).ConfigureAwait(false); + return removed ? Ok() : NotFound(new { error = "Download not found or is currently running." }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing download {Id}: {Message}", id, ex.Message); + return StatusCode(500, new { error = "An error occurred while removing the download." }); + } + } + + /// + /// Clear all downloads with a given status + /// + [HttpDelete("clear")] + [Authorize(Policy = "RequirePermission:CanManageDownloads")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> ClearDownloadsByStatusAsync([FromQuery] QueueStatus status, CancellationToken token = default) + { + try + { + if (status == QueueStatus.Running) + return BadRequest(new { error = "Cannot clear running downloads." }); + + int count = await _downloadCommand.ClearDownloadsByStatusAsync(status, token).ConfigureAwait(false); + return Ok(new { cleared = count }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing downloads with status {Status}: {Message}", status, ex.Message); + return StatusCode(500, new { error = "An error occurred while clearing downloads." }); + } + } + + /// + /// Retry all failed downloads + /// + [HttpPost("retry-all")] + [Authorize(Policy = "RequirePermission:CanManageDownloads")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> RetryAllFailedDownloadsAsync(CancellationToken token = default) + { + try + { + int count = await _downloadCommand.RetryAllFailedDownloadsAsync(token).ConfigureAwait(false); + return Ok(new { retried = count }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrying failed downloads: {Message}", ex.Message); + return StatusCode(500, new { error = "An error occurred while retrying failed downloads." }); + } + } } } diff --git a/KaizokuBackend/Controllers/ImagesController.cs b/KaizokuBackend/Controllers/ImagesController.cs index 145c3fa9..5f1874ad 100644 --- a/KaizokuBackend/Controllers/ImagesController.cs +++ b/KaizokuBackend/Controllers/ImagesController.cs @@ -1,28 +1,54 @@ using com.sun.xml.@internal.bind.v2.model.core; using KaizokuBackend.Extensions; +using KaizokuBackend.Models; using KaizokuBackend.Services.Downloads; using KaizokuBackend.Services.Images; using KaizokuBackend.Services.Images.Providers; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.Extensions.Options; namespace KaizokuBackend.Controllers { [ApiController] [Route("api/image")] + [Authorize] [Produces("image/png","image/jpeg","image/gif","image/bmp","image/tiff","image/webp","image/jxl","image/jp2","image/avif","image/heic")] public class ImagesController : ControllerBase { private readonly ThumbCacheService _thumbs; private readonly ILogger _logger; private static string naetag=null; + // volatile ensures the double-checked lock is safe on ARM/aarch64 where the + // memory model does not guarantee that a non-null reference implies a + // fully-constructed object visible to other threads. + private static volatile SemaphoreSlim? _gate; + private static readonly object _gateLock = new object(); - - public ImagesController(ILogger logger, ThumbCacheService thumbs) + public ImagesController(ILogger logger, ThumbCacheService thumbs, IOptions cacheOptions) { _thumbs = thumbs; _logger = logger; + if (_gate == null) + { + lock (_gateLock) + { + if (_gate == null) + { + // Guard against zero/negative config values — SemaphoreSlim(0,0) + // would deadlock every caller permanently. Default to 12. + int concurrency = cacheOptions.Value.MaxImageConcurrency > 0 + ? cacheOptions.Value.MaxImageConcurrency + : 12; + _gate = new SemaphoreSlim(concurrency, concurrency); + } + } + } } [HttpGet("{key?}")] + [AllowAnonymous] + [OutputCache(PolicyName = "images")] [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status304NotModified)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -48,18 +74,28 @@ public async Task GetImageAsync([FromRoute] string key, Cancellat Response.AddETag(_thumbs.GetCacheDuration(), naetag); return File(fs, "image/jpeg"); } - var result = await _thumbs.ProcessKeyAsync(key, etag ?? string.Empty, token).ConfigureAwait(false); - if (result.StatusCode == System.Net.HttpStatusCode.OK) + await _gate.WaitAsync(token).ConfigureAwait(false); + try + { + var result = await _thumbs.ProcessKeyAsync(key, etag ?? string.Empty, token).ConfigureAwait(false); + if (result.StatusCode == System.Net.HttpStatusCode.OK) + { + Response.AddETag(_thumbs.GetCacheDuration(), result.etag!); + return File(result.stream ?? new MemoryStream(), result.mimetype ?? "application/octet-stream"); + } + return StatusCode((int)result.StatusCode); + } + finally { - Response.AddETag(_thumbs.GetCacheDuration(), result.etag!); - return File(result.stream ?? new MemoryStream(), result.mimetype ?? "application/octet-stream"); + _gate.Release(); } - return StatusCode((int)result.StatusCode); } catch (OperationCanceledException) { - _logger.LogWarning("GetImageAsync operation was cancelled for key: {Key}", key); - return StatusCode(StatusCodes.Status500InternalServerError, "Operation was cancelled."); + // 499 Client Closed Request — the client disconnected; this is not a + // server error and must not be logged at Error level. + _logger.LogDebug("GetImageAsync cancelled (client closed connection) for key: {Key}", key); + return StatusCode(499); } catch (Exception ex) { diff --git a/KaizokuBackend/Controllers/InviteController.cs b/KaizokuBackend/Controllers/InviteController.cs new file mode 100644 index 00000000..a23ac85c --- /dev/null +++ b/KaizokuBackend/Controllers/InviteController.cs @@ -0,0 +1,103 @@ +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Services.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KaizokuBackend.Controllers +{ + [ApiController] + [Route("api/invites")] + [Produces("application/json")] + [Authorize(Policy = "RequireAdmin")] + public class InviteController : ControllerBase + { + private readonly InviteLinkService _inviteService; + private readonly ILogger _logger; + + public InviteController(InviteLinkService inviteService, ILogger logger) + { + _inviteService = inviteService; + _logger = logger; + } + + private Guid GetCurrentUserId() + { + var claim = User.FindFirst("UserId")?.Value; + if (string.IsNullOrEmpty(claim) || !Guid.TryParse(claim, out var id)) + throw new InvalidOperationException("Missing or invalid UserId claim in JWT token."); + return id; + } + + [HttpPost] + [ProducesResponseType(typeof(InviteLinkDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> CreateInviteAsync([FromBody] CreateInviteDto dto, CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var invite = await _inviteService.CreateAsync(dto, userId, token).ConfigureAwait(false); + return Ok(invite); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating invite"); + return StatusCode(500, new { error = "An error occurred while creating invite" }); + } + } + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetInvitesAsync(CancellationToken token = default) + { + try + { + var invites = await _inviteService.ListActiveAsync(token).ConfigureAwait(false); + return Ok(invites); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting invites"); + return StatusCode(500, new { error = "An error occurred while retrieving invites" }); + } + } + + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task RevokeInviteAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + await _inviteService.RevokeAsync(id, token).ConfigureAwait(false); + return Ok(new { message = "Invite revoked successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error revoking invite"); + return StatusCode(500, new { error = "An error occurred while revoking invite" }); + } + } + + [HttpGet("validate/{code}")] + [AllowAnonymous] + [ProducesResponseType(typeof(InviteValidationDto), StatusCodes.Status200OK)] + public async Task> ValidateInviteAsync([FromRoute] string code, CancellationToken token = default) + { + try + { + var result = await _inviteService.ValidateCodePublicAsync(code, token).ConfigureAwait(false); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating invite"); + return StatusCode(500, new { error = "An error occurred while validating invite" }); + } + } + } +} diff --git a/KaizokuBackend/Controllers/PermissionPresetController.cs b/KaizokuBackend/Controllers/PermissionPresetController.cs new file mode 100644 index 00000000..cb1e0e57 --- /dev/null +++ b/KaizokuBackend/Controllers/PermissionPresetController.cs @@ -0,0 +1,144 @@ +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Services.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KaizokuBackend.Controllers +{ + [ApiController] + [Route("api/permission-presets")] + [Produces("application/json")] + [Authorize(Policy = "RequireAdmin")] + public class PermissionPresetController : ControllerBase + { + private readonly PermissionPresetService _presetService; + private readonly ILogger _logger; + + public PermissionPresetController(PermissionPresetService presetService, ILogger logger) + { + _presetService = presetService; + _logger = logger; + } + + private Guid GetCurrentUserId() + { + var claim = User.FindFirst("UserId")?.Value; + if (string.IsNullOrEmpty(claim) || !Guid.TryParse(claim, out var id)) + throw new InvalidOperationException("Missing or invalid UserId claim in JWT token."); + return id; + } + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetPresetsAsync(CancellationToken token = default) + { + try + { + var presets = await _presetService.ListAsync(token).ConfigureAwait(false); + return Ok(presets); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting presets"); + return StatusCode(500, new { error = "An error occurred while retrieving presets" }); + } + } + + [HttpPost] + [ProducesResponseType(typeof(PermissionPresetDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> CreatePresetAsync([FromBody] CreatePresetDto dto, CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var preset = await _presetService.CreateAsync(dto, userId, token).ConfigureAwait(false); + return Ok(preset); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating preset"); + return StatusCode(500, new { error = "An error occurred while creating preset" }); + } + } + + [HttpPatch("{id:guid}")] + [ProducesResponseType(typeof(PermissionPresetDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> UpdatePresetAsync([FromRoute] Guid id, [FromBody] UpdatePresetDto dto, CancellationToken token = default) + { + try + { + var preset = await _presetService.UpdateAsync(id, dto, token).ConfigureAwait(false); + return Ok(preset); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating preset"); + return StatusCode(500, new { error = "An error occurred while updating preset" }); + } + } + + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task DeletePresetAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + await _presetService.DeleteAsync(id, token).ConfigureAwait(false); + return Ok(new { message = "Preset deleted successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting preset"); + return StatusCode(500, new { error = "An error occurred while deleting preset" }); + } + } + + [HttpPost("{id:guid}/set-default")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task SetDefaultAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + await _presetService.SetDefaultAsync(id, token).ConfigureAwait(false); + return Ok(new { message = "Default preset updated successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting default preset"); + return StatusCode(500, new { error = "An error occurred while setting default preset" }); + } + } + + [HttpPost("clear-default")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public async Task ClearDefaultAsync(CancellationToken token = default) + { + try + { + await _presetService.ClearDefaultAsync(token).ConfigureAwait(false); + return Ok(new { message = "Default preset cleared successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing default preset"); + return StatusCode(500, new { error = "An error occurred while clearing default preset" }); + } + } + } +} diff --git a/KaizokuBackend/Controllers/ProviderController.cs b/KaizokuBackend/Controllers/ProviderController.cs index f0e68c69..607e9581 100644 --- a/KaizokuBackend/Controllers/ProviderController.cs +++ b/KaizokuBackend/Controllers/ProviderController.cs @@ -1,6 +1,7 @@ using KaizokuBackend.Models.Dto; using KaizokuBackend.Services.Images; using KaizokuBackend.Services.Providers; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace KaizokuBackend.Controllers @@ -8,10 +9,12 @@ namespace KaizokuBackend.Controllers [ApiController] [Route("api/provider")] [Produces("application/json")] + [Authorize(Policy = "RequireAdmin")] public class ProviderController : ControllerBase { private readonly ProviderManagerService _managerService; private readonly ProviderPreferencesService _preferencesService; + private readonly ProviderHealthCheckService _healthCheckService; private readonly ThumbCacheService _thumbs; private readonly ILogger _logger; @@ -19,12 +22,14 @@ public ProviderController( ILogger logger, ThumbCacheService thumbs, ProviderManagerService installationService, - ProviderPreferencesService preferencesService) + ProviderPreferencesService preferencesService, + ProviderHealthCheckService healthCheckService) { _logger = logger; _thumbs = thumbs; _managerService = installationService; _preferencesService = preferencesService; + _healthCheckService = healthCheckService; } /// @@ -208,5 +213,70 @@ public async Task> InstallProviderFromFileAsync([FromForm] return StatusCode(500, new { error =$"Error installing extension from file {file?.FileName ?? ""}."}); } } + + /// + /// Checks the health of a single provider by performing a test search + /// + [HttpPost("health-check/{mihonProviderId}")] + [ProducesResponseType(typeof(ProviderHealthResultDto), 200)] + [ProducesResponseType(typeof(object), 500)] + public async Task> CheckProviderHealthAsync( + [FromRoute] string mihonProviderId, CancellationToken token = default) + { + try + { + var result = await _healthCheckService.CheckProviderAsync( + Uri.UnescapeDataString(mihonProviderId), token).ConfigureAwait(false); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health of provider {ProviderId}", mihonProviderId); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Checks the health of all sources in a specific extension package + /// + [HttpPost("health-check/package/{pkgName}")] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(object), 500)] + public async Task>> CheckPackageHealthAsync( + [FromRoute] string pkgName, CancellationToken token = default) + { + try + { + var results = await _healthCheckService.CheckByPackageAsync( + Uri.UnescapeDataString(pkgName), token).ConfigureAwait(false); + return Ok(results); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health of package {PkgName}", pkgName); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Checks the health of all installed/enabled providers + /// + [HttpPost("health-check")] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(object), 500)] + public async Task>> CheckAllProvidersHealthAsync( + CancellationToken token = default) + { + try + { + var results = await _healthCheckService.CheckAllProvidersAsync(token).ConfigureAwait(false); + return Ok(results); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health of all providers"); + return StatusCode(500, new { error = ex.Message }); + } + } } } diff --git a/KaizokuBackend/Controllers/RequestController.cs b/KaizokuBackend/Controllers/RequestController.cs new file mode 100644 index 00000000..a0c2f4cf --- /dev/null +++ b/KaizokuBackend/Controllers/RequestController.cs @@ -0,0 +1,173 @@ +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Requests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KaizokuBackend.Controllers +{ + [ApiController] + [Route("api/requests")] + [Produces("application/json")] + [Authorize] + public class RequestController : ControllerBase + { + private readonly MangaRequestService _requestService; + private readonly ILogger _logger; + + public RequestController(MangaRequestService requestService, ILogger logger) + { + _requestService = requestService; + _logger = logger; + } + + private Guid GetCurrentUserId() + { + var claim = User.FindFirst("UserId")?.Value; + if (string.IsNullOrEmpty(claim) || !Guid.TryParse(claim, out var id)) + throw new InvalidOperationException("Missing or invalid UserId claim in JWT token."); + return id; + } + + private bool IsAdmin() + { + var roleClaim = User.FindFirst("Role")?.Value; + return roleClaim != null && Enum.TryParse(roleClaim, out var role) && role == UserRole.Admin; + } + + [HttpPost] + [Authorize(Policy = "RequirePermission:CanRequestSeries")] + [ProducesResponseType(typeof(MangaRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> CreateRequestAsync([FromBody] CreateRequestDto dto, CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var request = await _requestService.CreateAsync(dto, userId, token).ConfigureAwait(false); + return Ok(request); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating request"); + return StatusCode(500, new { error = "An error occurred while creating request" }); + } + } + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetRequestsAsync(CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + List requests; + + if (IsAdmin()) + { + requests = await _requestService.GetAllAsync(token).ConfigureAwait(false); + } + else + { + requests = await _requestService.GetByUserAsync(userId, token).ConfigureAwait(false); + } + + return Ok(requests); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting requests"); + return StatusCode(500, new { error = "An error occurred while retrieving requests" }); + } + } + + [HttpGet("pending-count")] + [Authorize(Policy = "RequirePermission:CanManageRequests")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public async Task GetPendingCountAsync(CancellationToken token = default) + { + try + { + var count = await _requestService.GetPendingCountAsync(token).ConfigureAwait(false); + return Ok(new { count }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting pending count"); + return StatusCode(500, new { error = "An error occurred while getting pending count" }); + } + } + + [HttpPatch("{id:guid}/approve")] + [Authorize(Policy = "RequirePermission:CanManageRequests")] + [ProducesResponseType(typeof(MangaRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> ApproveRequestAsync([FromRoute] Guid id, [FromBody] ApproveRequestDto? dto, CancellationToken token = default) + { + try + { + var adminUserId = GetCurrentUserId(); + var request = await _requestService.ApproveAsync(id, adminUserId, dto ?? new ApproveRequestDto(), token).ConfigureAwait(false); + return Ok(request); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error approving request"); + return StatusCode(500, new { error = "An error occurred while approving request" }); + } + } + + [HttpPatch("{id:guid}/deny")] + [Authorize(Policy = "RequirePermission:CanManageRequests")] + [ProducesResponseType(typeof(MangaRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> DenyRequestAsync([FromRoute] Guid id, [FromBody] DenyRequestDto? dto, CancellationToken token = default) + { + try + { + var adminUserId = GetCurrentUserId(); + var request = await _requestService.DenyAsync(id, adminUserId, dto ?? new DenyRequestDto(), token).ConfigureAwait(false); + return Ok(request); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error denying request"); + return StatusCode(500, new { error = "An error occurred while denying request" }); + } + } + + [HttpPatch("{id:guid}/cancel")] + [ProducesResponseType(typeof(MangaRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> CancelRequestAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var request = await _requestService.CancelAsync(id, userId, token).ConfigureAwait(false); + return Ok(request); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cancelling request"); + return StatusCode(500, new { error = "An error occurred while cancelling request" }); + } + } + } +} diff --git a/KaizokuBackend/Controllers/SearchController.cs b/KaizokuBackend/Controllers/SearchController.cs index ce7140e6..0ecabf8a 100644 --- a/KaizokuBackend/Controllers/SearchController.cs +++ b/KaizokuBackend/Controllers/SearchController.cs @@ -2,6 +2,7 @@ using KaizokuBackend.Services.Images; using KaizokuBackend.Services.Search; using KaizokuBackend.Services.Settings; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace KaizokuBackend.Controllers @@ -11,6 +12,7 @@ namespace KaizokuBackend.Controllers /// [ApiController] [Route("api/search")] + [Authorize] public class SearchController : ControllerBase { private readonly ILogger _logger; @@ -70,6 +72,7 @@ public async Task> AugmentSeriesAsync([FromBo /// /// List of available search sources [HttpGet("sources")] + [Authorize(Policy = "RequirePermission:CanBrowseSources")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task>> GetAvailableSearchSourcesAsync(CancellationToken token = default) @@ -119,9 +122,20 @@ public async Task>> SearchSeriesAsync( try { var results = await _searchQueryService.SearchSeriesAsync(keyword, languageList, searchSources, 0.1f, token).ConfigureAwait(false); + + // Guard against populating thumbs with a token that's already been cancelled + // (e.g. browser disconnected while search was running) + token.ThrowIfCancellationRequested(); + await _thumbs.PopulateThumbsAsync(results, "/api/image/", token).ConfigureAwait(false); return Ok(results); } + catch (OperationCanceledException) + { + // Client disconnected or request was cancelled — return partial results gracefully + _logger.LogWarning("Search for '{keyword}' was cancelled by the client.", keyword); + return Ok(new List()); + } catch (Exception ex) { _logger.LogError(ex, "Error searching series: {Message}", ex.Message); diff --git a/KaizokuBackend/Controllers/SeriesController.cs b/KaizokuBackend/Controllers/SeriesController.cs index dc0c6dd7..73a099f3 100644 --- a/KaizokuBackend/Controllers/SeriesController.cs +++ b/KaizokuBackend/Controllers/SeriesController.cs @@ -4,12 +4,14 @@ using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Providers; using KaizokuBackend.Services.Series; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace KaizokuBackend.Controllers { [ApiController] - [Route("api/serie")] + [Route("api/series")] + [Authorize] public class SeriesController : ControllerBase { private readonly ILogger _logger; @@ -44,6 +46,7 @@ public SeriesController(ILogger logger, /// Cancellation token. /// Extended information about the series. [HttpGet] + [Authorize(Policy = "RequirePermission:CanViewLibrary")] [ProducesResponseType(typeof(SeriesExtendedDto), 200)] [ProducesResponseType(400)] [ProducesResponseType(500)] @@ -64,6 +67,7 @@ public async Task> GetSeriesAsync([FromQuery] Gu } [HttpGet("verify")] + [Authorize(Policy = "RequirePermission:CanEditSeries")] [ProducesResponseType(typeof(SeriesIntegrityResultDto), 200)] [ProducesResponseType(500)] public async Task> VerifyIntegrityAsync([FromQuery] Guid g, CancellationToken token = default) @@ -81,6 +85,7 @@ public async Task> VerifyIntegrityAsync([ } [HttpGet("cleanup")] + [Authorize(Policy = "RequirePermission:CanEditSeries")] [ProducesResponseType(500)] public async Task CleanupSeriesAsync([FromQuery] Guid g, CancellationToken token = default) { @@ -96,7 +101,26 @@ public async Task CleanupSeriesAsync([FromQuery] Guid g, Cancellat } } + [HttpPost("rename")] + [Authorize(Policy = "RequirePermission:CanEditSeries")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task RenameSeriesFilesAsync([FromQuery] Guid g, CancellationToken token = default) + { + try + { + await _archiveService.RenameSeriesFilesAsync(g, token).ConfigureAwait(false); + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error renaming series files: {Message}", ex.Message); + return StatusCode(500, "Error renaming series files."); + } + } + [HttpPost("update-all")] + [Authorize(Policy = "RequirePermission:CanEditSeries")] [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] @@ -120,6 +144,7 @@ public async Task UpdateAllSeriesAsync(CancellationToken token = d /// Cancellation token. /// List of series in the library. [HttpGet("library")] + [Authorize(Policy = "RequirePermission:CanViewLibrary")] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(500)] public async Task>> GetLibraryAsync(CancellationToken token = default) @@ -140,13 +165,20 @@ public async Task>> GetLibraryAsync(Cancellatio } [HttpGet("latest")] + [Authorize(Policy = "RequirePermission:CanViewLibrary")] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(500)] - public async Task>> GetLatestAsync([FromQuery] int start, [FromQuery] int count, [FromQuery] string? sourceId = null, [FromQuery] string? keyword = null, CancellationToken token = default) + public async Task>> GetLatestAsync( + [FromQuery] int start, + [FromQuery] int count, + [FromQuery] string? sourceId = null, + [FromQuery] string? keyword = null, + [FromQuery(Name = "genre")] string[]? genre = null, + CancellationToken token = default) { try { - var result = await _queryService.GetLatestAsync(start, count, sourceId, keyword, token).ConfigureAwait(false); + var result = await _queryService.GetLatestAsync(start, count, sourceId, keyword, genre, token).ConfigureAwait(false); await _thumb.PopulateThumbsAsync(result, "/api/image/", token).ConfigureAwait(false); return Ok(result); } @@ -157,6 +189,29 @@ public async Task>> GetLatestAsync([FromQuery } } + /// + /// Returns the distinct tags/genres present in the cached "Latest" cloud + /// catalogue along with their occurrence counts. Used to populate the + /// tag filter on the browse screen. + /// + [HttpGet("latest/genres")] + [Authorize(Policy = "RequirePermission:CanViewLibrary")] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(500)] + public async Task>> GetLatestGenresAsync(CancellationToken token = default) + { + try + { + var result = await _queryService.GetLatestGenresAsync(token).ConfigureAwait(false); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting latest genres: {Message}", ex.Message); + return StatusCode(500, "Error getting latest genres."); + } + } + /// /// Gets a provider match by provider ID. /// @@ -164,6 +219,7 @@ public async Task>> GetLatestAsync([FromQuery /// Cancellation token. /// The provider match if found. [HttpGet("match/{providerId}")] + [Authorize(Policy = "RequirePermission:CanViewLibrary")] [ProducesResponseType(typeof(ProviderMatchDto), 200)] [ProducesResponseType(500)] public async Task> GetMatchAsync([FromRoute] Guid providerId, CancellationToken token = default) @@ -187,6 +243,7 @@ public async Task>> GetLatestAsync([FromQuery /// Cancellation token. /// True if the match was set successfully. [HttpPost("match")] + [Authorize(Policy = "RequirePermission:CanEditSeries")] [ProducesResponseType(typeof(bool), 200)] [ProducesResponseType(400)] [ProducesResponseType(500)] @@ -213,6 +270,7 @@ public async Task> SetMatchAsync([FromBody] ProviderMatchDto /// Cancellation token. /// The ID of the newly created series. [HttpPost] + [Authorize(Policy = "RequirePermission:CanAddSeries")] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(400)] [ProducesResponseType(500)] @@ -242,6 +300,7 @@ public async Task AddSeriesAsync([FromBody] AugmentedResponseDto /// Cancellation token. /// The updated series information. [HttpPatch] + [Authorize(Policy = "RequirePermission:CanEditSeries")] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(400)] [ProducesResponseType(500)] @@ -266,6 +325,7 @@ public async Task> UpdateSeriesAsync([FromBody] } [HttpDelete] + [Authorize(Policy = "RequirePermission:CanDeleteSeries")] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(400)] [ProducesResponseType(500)] @@ -282,5 +342,83 @@ public async Task DeleteSeriesAsync([FromQuery] Guid id, [FromQuer return StatusCode(500, $"Error updating series with id {id}"); } } + + /// + /// Returns the aggregated chapter list for a series, grouped across all providers. + /// + [HttpGet("{id:guid}/chapters")] + [Authorize(Policy = "RequirePermission:CanViewLibrary")] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task>> GetChaptersForSeries([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var chapters = await _queryService.GetChaptersForSeriesAsync(id, token).ConfigureAwait(false); + if (chapters == null) + return NotFound(); + return Ok(chapters); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting chapters for series {Id}: {Message}", id, ex.Message); + return StatusCode(500, $"Error getting chapters for series."); + } + } + + /// + /// Queues downloads for missing chapters. Supply specific chapter numbers in the + /// request body, or omit / send null to queue all missing chapters. + /// + [HttpPost("{id:guid}/chapters/download")] + [Authorize(Policy = "RequirePermission:CanEditSeries")] + [ProducesResponseType(typeof(DownloadMissingResultDto), 200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task> DownloadMissingChapters([FromRoute] Guid id, [FromBody] DownloadChaptersRequestDto? body, CancellationToken token = default) + { + try + { + var enqueued = await _commandService.QueueMissingChaptersAsync(id, body?.ChapterNumbers, token).ConfigureAwait(false); + return Ok(new DownloadMissingResultDto { EnqueuedCount = enqueued }); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error queuing missing chapters for series {Id}: {Message}", id, ex.Message); + return StatusCode(500, $"Error queuing missing chapters."); + } + } + + /// + /// Enqueues an immediate high-priority GetChapters job for each active provider + /// of the series, forcing a chapter list refresh. + /// + [HttpPost("{id:guid}/chapters/refresh")] + [Authorize(Policy = "RequirePermission:CanEditSeries")] + [ProducesResponseType(typeof(RefreshChaptersResultDto), 200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task> RefreshChapters([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var jobsEnqueued = await _commandService.ForceRefreshChaptersAsync(id, token).ConfigureAwait(false); + return Ok(new RefreshChaptersResultDto { JobsEnqueued = jobsEnqueued }); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing chapters for series {Id}: {Message}", id, ex.Message); + return StatusCode(500, $"Error refreshing chapters."); + } + } } } diff --git a/KaizokuBackend/Controllers/SettingsController.cs b/KaizokuBackend/Controllers/SettingsController.cs index 1f3ce41f..027a08a8 100644 --- a/KaizokuBackend/Controllers/SettingsController.cs +++ b/KaizokuBackend/Controllers/SettingsController.cs @@ -1,4 +1,5 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.ComponentModel.DataAnnotations; using KaizokuBackend.Services.Settings; @@ -12,6 +13,7 @@ namespace KaizokuBackend.Controllers [ApiController] [Route("api/settings")] [Produces("application/json")] + [Authorize(Policy = "RequireAdmin")] public class SettingsController : ControllerBase { private readonly SettingsService _settingsService; @@ -96,6 +98,10 @@ public async Task UpdateAsync([FromBody][Required] SettingsDto se await _settingsService.SaveSettingsAsync(settings, false, token).ConfigureAwait(false); return Ok(new { message = "Settings updated successfully" }); } + catch (KaizokuBackend.Services.Auth.AuthLockoutException ex) + { + return Conflict(new { error = ex.Message, needsPassword = true }); + } catch (Exception ex) { _logger.LogError(ex, "Error updating settings"); diff --git a/KaizokuBackend/Controllers/SetupWizardController.cs b/KaizokuBackend/Controllers/SetupWizardController.cs index 2d108908..51091433 100644 --- a/KaizokuBackend/Controllers/SetupWizardController.cs +++ b/KaizokuBackend/Controllers/SetupWizardController.cs @@ -7,6 +7,7 @@ using KaizokuBackend.Services.Import; using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Settings; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.IO; @@ -15,11 +16,13 @@ namespace KaizokuBackend.Controllers { /// - /// Controller for handling setup wizard operations + /// Controller for handling setup wizard operations. + /// Accessible without auth when no users exist (bootstrap mode), otherwise requires admin. /// [ApiController] [Route("api/setup")] [Produces("application/json")] + [Authorize(Policy = "RequireAdmin")] public class SetupWizardController : ControllerBase { private readonly ILogger _logger; diff --git a/KaizokuBackend/Controllers/UserController.cs b/KaizokuBackend/Controllers/UserController.cs new file mode 100644 index 00000000..c6a7bc72 --- /dev/null +++ b/KaizokuBackend/Controllers/UserController.cs @@ -0,0 +1,480 @@ +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KaizokuBackend.Controllers +{ + [ApiController] + [Route("api/users")] + [Produces("application/json")] + [Authorize] + public class UserController : ControllerBase + { + private readonly UserService _userService; + private readonly UserInviteService _userInviteService; + private readonly PermissionService _permissionService; + private readonly UserPreferencesService _preferencesService; + private readonly ILogger _logger; + private readonly IAuthSettingsCache _authSettingsCache; + + public UserController(UserService userService, UserInviteService userInviteService, + PermissionService permissionService, + UserPreferencesService preferencesService, ILogger logger, + IAuthSettingsCache authSettingsCache) + { + _userService = userService; + _userInviteService = userInviteService; + _permissionService = permissionService; + _preferencesService = preferencesService; + _logger = logger; + _authSettingsCache = authSettingsCache; + } + + private Guid GetCurrentUserId() + { + var claim = User.FindFirst("UserId")?.Value; + if (string.IsNullOrEmpty(claim) || !Guid.TryParse(claim, out var id)) + throw new InvalidOperationException("Missing or invalid UserId claim in JWT token."); + return id; + } + + // ─── Admin endpoints ─────────────────────────────────────────── + + [HttpGet] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetAllUsersAsync(CancellationToken token = default) + { + try + { + var users = await _userService.GetAllAsync(token).ConfigureAwait(false); + return Ok(users); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting users"); + return StatusCode(500, new { error = "An error occurred while retrieving users" }); + } + } + + [HttpPost] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(UserDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> CreateUserAsync([FromBody] CreateUserDto dto, CancellationToken token = default) + { + try + { + var user = await _userService.CreateAsync(dto, token).ConfigureAwait(false); + return Ok(user); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating user"); + return StatusCode(500, new { error = "An error occurred while creating user" }); + } + } + + [HttpGet("{id:guid}")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(UserDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetUserByIdAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var user = await _userService.GetByIdAsync(id, token).ConfigureAwait(false); + if (user == null) return NotFound(new { error = "User not found" }); + return Ok(user); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting user"); + return StatusCode(500, new { error = "An error occurred while retrieving user" }); + } + } + + [HttpPatch("{id:guid}")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(UserDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> UpdateUserAsync([FromRoute] Guid id, [FromBody] UpdateUserDto dto, CancellationToken token = default) + { + try + { + var user = await _userService.UpdateAsync(id, dto, token).ConfigureAwait(false); + return Ok(user); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating user"); + return StatusCode(500, new { error = "An error occurred while updating user" }); + } + } + + [HttpDelete("{id:guid}")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task DeleteUserAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var currentUserId = GetCurrentUserId(); + if (id == currentUserId) + return BadRequest(new { error = "You cannot delete your own account" }); + + await _userService.DeleteAsync(id, token).ConfigureAwait(false); + return Ok(new { message = "User deleted successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting user"); + return StatusCode(500, new { error = "An error occurred while deleting user" }); + } + } + + [HttpPatch("{id:guid}/permissions")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task UpdatePermissionsAsync([FromRoute] Guid id, [FromBody] UpdatePermissionDto dto, CancellationToken token = default) + { + try + { + await _permissionService.UpdatePermissionsAsync(id, dto, token).ConfigureAwait(false); + return Ok(new { message = "Permissions updated successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating permissions"); + return StatusCode(500, new { error = "An error occurred while updating permissions" }); + } + } + + [HttpPost("{id:guid}/reset-password")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task ResetPasswordAsync([FromRoute] Guid id, [FromBody] ResetPasswordDto dto, CancellationToken token = default) + { + try + { + await _userService.ResetPasswordAsync(id, dto.NewPassword, token).ConfigureAwait(false); + return Ok(new { message = "Password reset successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resetting password"); + return StatusCode(500, new { error = "An error occurred while resetting password" }); + } + } + + [HttpPost("{id:guid}/disable")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task DisableUserAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var currentUserId = GetCurrentUserId(); + if (id == currentUserId) + return BadRequest(new { error = "You cannot disable your own account" }); + + await _userService.DisableAsync(id, token).ConfigureAwait(false); + return Ok(new { message = "User disabled successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disabling user"); + return StatusCode(500, new { error = "An error occurred while disabling user" }); + } + } + + [HttpPost("{id:guid}/enable")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task EnableUserAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + await _userService.EnableAsync(id, token).ConfigureAwait(false); + return Ok(new { message = "User enabled successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error enabling user"); + return StatusCode(500, new { error = "An error occurred while enabling user" }); + } + } + + // ─── Self endpoints ──────────────────────────────────────────── + + [HttpGet("me")] + [ProducesResponseType(typeof(UserDetailDto), StatusCodes.Status200OK)] + public async Task> GetCurrentUserAsync(CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var user = await _userService.GetByIdAsync(userId, token).ConfigureAwait(false); + if (user == null) return NotFound(new { error = "User not found" }); + return Ok(user); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting current user"); + return StatusCode(500, new { error = "An error occurred while retrieving user profile" }); + } + } + + [HttpPatch("me")] + [ProducesResponseType(typeof(UserDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task> UpdateProfileAsync([FromBody] UpdateUserDto dto, CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var user = await _userService.UpdateProfileAsync(userId, dto, token).ConfigureAwait(false); + return Ok(user); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating profile"); + return StatusCode(500, new { error = "An error occurred while updating profile" }); + } + } + + [HttpPatch("me/password")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task ChangePasswordAsync([FromBody] ChangePasswordDto dto, CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + await _userService.ChangePasswordAsync(userId, dto, token).ConfigureAwait(false); + return Ok(new { message = "Password changed successfully" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error changing password"); + return StatusCode(500, new { error = "An error occurred while changing password" }); + } + } + + [HttpGet("me/preferences")] + [ProducesResponseType(typeof(UserPreferencesDto), StatusCodes.Status200OK)] + public async Task> GetPreferencesAsync(CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var prefs = await _preferencesService.GetAsync(userId, token).ConfigureAwait(false); + return Ok(prefs); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting preferences"); + return StatusCode(500, new { error = "An error occurred while retrieving preferences" }); + } + } + + [HttpPatch("me/preferences")] + [ProducesResponseType(typeof(UserPreferencesDto), StatusCodes.Status200OK)] + public async Task> UpdatePreferencesAsync([FromBody] UpdatePreferencesDto dto, CancellationToken token = default) + { + try + { + var userId = GetCurrentUserId(); + var prefs = await _preferencesService.UpdateAsync(userId, dto, token).ConfigureAwait(false); + return Ok(prefs); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating preferences"); + return StatusCode(500, new { error = "An error occurred while updating preferences" }); + } + } + + [HttpPost("me/avatar")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + public async Task UploadAvatarAsync([FromForm] IFormFile file, CancellationToken token = default) + { + try + { + if (file == null || file.Length == 0) + return BadRequest(new { error = "No file uploaded" }); + + if (file.Length > 5 * 1024 * 1024) + return BadRequest(new { error = "File size must be less than 5MB" }); + + var userId = GetCurrentUserId(); + var avatarUrl = await _userService.UploadAvatarAsync(userId, file, token).ConfigureAwait(false); + return Ok(new { avatarUrl = avatarUrl }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading avatar"); + return StatusCode(500, new { error = "An error occurred while uploading avatar" }); + } + } + + [HttpGet("avatar/{id:guid}")] + [AllowAnonymous] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAvatarAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var result = await _userService.GetAvatarBlobAsync(id, token).ConfigureAwait(false); + if (result == null) + return NotFound(); + + return File(result.Value.Bytes, result.Value.ContentType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving avatar"); + return StatusCode(500, "An error occurred while retrieving the avatar."); + } + } + + // ─── Setup / invite endpoints ────────────────────────────────── + + [HttpPost("first")] + [AllowAnonymous] + [ProducesResponseType(typeof(UserDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> CreateFirstUserAsync([FromBody] FirstUserDto dto, CancellationToken token = default) + { + await SetupGate.Lock.WaitAsync(token).ConfigureAwait(false); + try + { + if (await _userService.AnyUsersExistAsync(token).ConfigureAwait(false)) + return BadRequest(new { error = "Setup has already been completed." }); + + var createDto = new CreateUserDto + { + Username = dto.Username, + DisplayName = dto.DisplayName, + Password = dto.Password ?? string.Empty, + Role = UserRole.Admin + }; + + var user = await _userService.CreateAsync(createDto, token).ConfigureAwait(false); + return Ok(user); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating first user"); + return StatusCode(500, new { error = "An error occurred while creating the first user" }); + } + finally + { + SetupGate.Lock.Release(); + } + } + + [HttpPut("{id:guid}/claim")] + [AllowAnonymous] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> ClaimUserAsync([FromRoute] Guid id, [FromBody] ClaimUserDto dto, CancellationToken token = default) + { + try + { + if (_authSettingsCache.AuthenticationEnabled) + return BadRequest(new { error = "Profile claiming is only available when authentication is disabled." }); + + var result = await _userService.ClaimUserAsync(id, dto.Password, token).ConfigureAwait(false); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error claiming user {UserId}", id); + return StatusCode(500, new { error = "An error occurred while claiming the user" }); + } + } + + [HttpPost("{id:guid}/generate-invite")] + [Authorize(Policy = "RequireAdmin")] + [ProducesResponseType(typeof(GenerateInviteResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task> GenerateInviteAsync([FromRoute] Guid id, CancellationToken token = default) + { + try + { + var result = await _userInviteService.CreateInviteAsync(id, token).ConfigureAwait(false); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating invite for user {UserId}", id); + return StatusCode(500, new { error = "An error occurred while generating the invite" }); + } + } + } +} diff --git a/KaizokuBackend/Data/AppDbContext.cs b/KaizokuBackend/Data/AppDbContext.cs index 9da45099..973200a7 100644 --- a/KaizokuBackend/Data/AppDbContext.cs +++ b/KaizokuBackend/Data/AppDbContext.cs @@ -1,12 +1,13 @@ using KaizokuBackend.Models; using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto; +using KaizokuBackend.Models.Enums; using Mihon.ExtensionsBridge.Models.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using System.Text.Json; using System.Text.Json.Serialization; using Chapter = KaizokuBackend.Models.Chapter; -using KaizokuBackend.Models.Enums; namespace KaizokuBackend.Data { @@ -67,7 +68,6 @@ public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { - int a = 1; } public DbSet Series { get; set; } @@ -80,6 +80,15 @@ public AppDbContext(DbContextOptions options) : base(options) public DbSet Queues { get; set; } public DbSet LatestSeries { get; set; } + // Auth & Multi-user entities + public DbSet Users { get; set; } + public DbSet UserPermissions { get; set; } + public DbSet UserSessions { get; set; } + public DbSet UserPreferences { get; set; } + public DbSet InviteLinks { get; set; } + public DbSet PermissionPresets { get; set; } + public DbSet MangaRequests { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -308,6 +317,115 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.GroupKey).HasDatabaseName("IX_Enqueue_GroupKey"); entity.HasIndex(e => e.FinishedDate).HasDatabaseName("IX_Enqueue_FinishedDate"); }); + + // ─── Auth & Multi-user entities ──────────────────────────────── + + modelBuilder.Entity(entity => + { + entity.HasKey(u => u.Id); + entity.Property(u => u.Username).UseCollation("BINARY").IsRequired(); + // Email is retained for backfill/transition; no longer required or unique on new installs. + entity.Property(u => u.Email).UseCollation("BINARY").IsRequired(false); + entity.Property(u => u.DisplayName).UseCollation("BINARY").IsRequired(); + entity.Property(u => u.PasswordHash).UseCollation("BINARY").IsRequired(false); + entity.Property(u => u.Salt).UseCollation("BINARY").IsRequired(false); + entity.Property(u => u.Role).IsRequired().HasConversion(); + entity.Property(u => u.Level).IsRequired().HasConversion(); + entity.Property(u => u.OpdsPath).UseCollation("BINARY").IsRequired(); + // AvatarPath is retained for backfill/transition. + entity.Property(u => u.AvatarPath).UseCollation("BINARY").IsRequired(false); + entity.Property(u => u.AvatarBlob).IsRequired(false); + entity.Property(u => u.AvatarContentType).UseCollation("BINARY").IsRequired(false); + entity.Property(u => u.PasswordSetToken).UseCollation("BINARY").IsRequired(false); + entity.Property(u => u.PasswordSetTokenExpiresAt).IsRequired(false); + entity.Property(u => u.CreatedAt).IsRequired(); + entity.Property(u => u.UpdatedAt).IsRequired(); + entity.Property(u => u.LastLoginAt).IsRequired(false); + entity.Property(u => u.IsActive).IsRequired(); + entity.HasIndex(u => u.Username).IsUnique().HasDatabaseName("IX_User_Username"); + entity.HasIndex(u => u.OpdsPath).IsUnique().HasDatabaseName("IX_User_OpdsPath"); + entity.HasOne(u => u.Permissions) + .WithOne(p => p.User) + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(u => u.Preferences) + .WithOne(p => p.User) + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasMany(u => u.Sessions) + .WithOne(s => s.User) + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(p => p.UserId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(s => s.Id); + entity.Property(s => s.RefreshToken).UseCollation("BINARY").IsRequired(); + entity.Property(s => s.IpAddress).UseCollation("BINARY").IsRequired(false); + entity.Property(s => s.UserAgent).UseCollation("BINARY").IsRequired(false); + entity.Property(s => s.ExpiresAt).IsRequired(); + entity.Property(s => s.CreatedAt).IsRequired(); + entity.Property(s => s.IsRevoked).IsRequired(); + entity.HasIndex(s => s.RefreshToken).HasDatabaseName("IX_UserSession_RefreshToken"); + entity.HasIndex(s => s.UserId).HasDatabaseName("IX_UserSession_UserId"); + entity.HasIndex(s => new { s.UserId, s.IsRevoked }).HasDatabaseName("IX_UserSession_UserId_IsRevoked"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(p => p.UserId); + entity.Property(p => p.Theme).UseCollation("BINARY").IsRequired(); + entity.Property(p => p.DefaultLanguage).UseCollation("BINARY").IsRequired(); + entity.Property(p => p.CardSize).UseCollation("BINARY").IsRequired(); + entity.Property(p => p.NsfwVisibility).IsRequired().HasConversion(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(i => i.Id); + entity.Property(i => i.Code).UseCollation("BINARY").IsRequired(); + entity.Property(i => i.ExpiresAt).IsRequired(); + entity.Property(i => i.MaxUses).IsRequired(); + entity.Property(i => i.UsedCount).IsRequired(); + entity.Property(i => i.IsActive).IsRequired(); + entity.HasIndex(i => i.Code).IsUnique().HasDatabaseName("IX_InviteLink_Code"); + entity.HasIndex(i => i.IsActive).HasDatabaseName("IX_InviteLink_IsActive"); + entity.HasOne(i => i.CreatedByUser).WithMany().HasForeignKey(i => i.CreatedByUserId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(i => i.PermissionPreset).WithMany().HasForeignKey(i => i.PermissionPresetId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(p => p.Id); + entity.Property(p => p.Name).UseCollation("BINARY").IsRequired(); + entity.Property(p => p.IsDefault).IsRequired(); + entity.HasIndex(p => p.Name).HasDatabaseName("IX_PermissionPreset_Name"); + entity.HasIndex(p => p.IsDefault).HasDatabaseName("IX_PermissionPreset_IsDefault"); + entity.HasOne(p => p.CreatedByUser).WithMany().HasForeignKey(p => p.CreatedByUserId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(r => r.Id); + entity.Property(r => r.Title).UseCollation("BINARY").IsRequired(); + entity.Property(r => r.Description).UseCollation("BINARY").IsRequired(false); + entity.Property(r => r.ThumbnailUrl).UseCollation("BINARY").IsRequired(false); + entity.Property(r => r.ProviderData).UseCollation("BINARY").IsRequired(false); + entity.Property(r => r.Status).IsRequired().HasConversion(); + entity.Property(r => r.ReviewNote).UseCollation("BINARY").IsRequired(false); + entity.Property(r => r.ReviewedAt).IsRequired(false); + entity.Property(r => r.CreatedAt).IsRequired(); + entity.HasIndex(r => r.Status).HasDatabaseName("IX_MangaRequest_Status"); + entity.HasIndex(r => r.RequestedByUserId).HasDatabaseName("IX_MangaRequest_RequestedByUserId"); + entity.HasOne(r => r.RequestedByUser).WithMany().HasForeignKey(r => r.RequestedByUserId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(r => r.ReviewedByUser).WithMany().HasForeignKey(r => r.ReviewedByUserId).OnDelete(DeleteBehavior.SetNull); + }); } } } diff --git a/KaizokuBackend/EFMigrations/AddProviderHealthCheckColumns.cs b/KaizokuBackend/EFMigrations/AddProviderHealthCheckColumns.cs new file mode 100644 index 00000000..055a3917 --- /dev/null +++ b/KaizokuBackend/EFMigrations/AddProviderHealthCheckColumns.cs @@ -0,0 +1,46 @@ +using KaizokuBackend.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace KaizokuBackend.Migrations; + +[DbContext(typeof(AppDbContext))] +[Migration("20260321120000_AddProviderHealthCheckColumns")] +public partial class AddProviderHealthCheckColumns : Microsoft.EntityFrameworkCore.Migrations.Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastHealthCheckUtc", + table: "Providers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastHealthCheckPassed", + table: "Providers", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastHealthCheckError", + table: "Providers", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastHealthCheckUtc", + table: "Providers"); + + migrationBuilder.DropColumn( + name: "LastHealthCheckPassed", + table: "Providers"); + + migrationBuilder.DropColumn( + name: "LastHealthCheckError", + table: "Providers"); + } +} diff --git a/KaizokuBackend/EFMigrations/AddSeriesNeedsRename.cs b/KaizokuBackend/EFMigrations/AddSeriesNeedsRename.cs new file mode 100644 index 00000000..a6dcb1d8 --- /dev/null +++ b/KaizokuBackend/EFMigrations/AddSeriesNeedsRename.cs @@ -0,0 +1,27 @@ +using KaizokuBackend.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace KaizokuBackend.Migrations; + +[DbContext(typeof(AppDbContext))] +[Migration("20260325120000_AddSeriesNeedsRename")] +public partial class AddSeriesNeedsRename : Microsoft.EntityFrameworkCore.Migrations.Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NeedsRename", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NeedsRename", + table: "Series"); + } +} diff --git a/KaizokuBackend/EFMigrations/ReconcileUserSchema.cs b/KaizokuBackend/EFMigrations/ReconcileUserSchema.cs new file mode 100644 index 00000000..3085580a --- /dev/null +++ b/KaizokuBackend/EFMigrations/ReconcileUserSchema.cs @@ -0,0 +1,33 @@ +using KaizokuBackend.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace KaizokuBackend.Migrations; + +/// +/// Intentionally a no-op. The Users-table reconciliation (Level, OpdsPath, AvatarBlob, +/// AvatarContentType, PasswordSetToken, PasswordSetTokenExpiresAt columns, Level backfill, +/// and the legacy NOT NULL rebuild) is performed exclusively by +/// StartupHostedService.EnsureAuthTablesAsync, which runs immediately after MigrateAsync +/// on every startup and guards every step with PRAGMA table_info probes / IF NOT EXISTS. +/// +/// It cannot be done here: a plain AddColumn crashes on databases that predate the Users +/// table (the table was only ever created by raw SQL at startup, never by an EF migration) +/// and equally crashes with "duplicate column" on databases EnsureAuthTablesAsync already +/// upgraded. SQLite offers no conditional DDL, so the guarded C# path is authoritative and +/// this migration exists only to keep the model snapshot and __EFMigrationsHistory aligned. +/// +[DbContext(typeof(AppDbContext))] +[Migration("20260528120000_ReconcileUserSchema")] +public partial class ReconcileUserSchema : Microsoft.EntityFrameworkCore.Migrations.Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // No-op — see class summary. + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op — see class summary. + } +} diff --git a/KaizokuBackend/Extensions/FileSystemExtensions.cs b/KaizokuBackend/Extensions/FileSystemExtensions.cs index 2533d9c5..9b87e980 100644 --- a/KaizokuBackend/Extensions/FileSystemExtensions.cs +++ b/KaizokuBackend/Extensions/FileSystemExtensions.cs @@ -205,7 +205,7 @@ public static string RestoreOriginalPathCharacters(this string path) var ret = path; foreach (var kvp in ReversePathCharacterMap) ret = ret.Replace(kvp.Key, kvp.Value); - ret = ret.Replace("\u2026", "..."); // ? ... + ret = ret.Replace("\u2026", "..."); // � ? ... return ret.Trim(); } diff --git a/KaizokuBackend/Extensions/ModelExtensions.cs b/KaizokuBackend/Extensions/ModelExtensions.cs index 88526522..5c07b014 100644 --- a/KaizokuBackend/Extensions/ModelExtensions.cs +++ b/KaizokuBackend/Extensions/ModelExtensions.cs @@ -263,9 +263,10 @@ public static SeriesExtendedDto ToSeriesExtendedInfo(this SeriesEntity s, Settin Status = s.Status, Type = s.Type, StartFromChapter = s.StartFromChapter, - Path = EnvironmentSetup.IsDocker ? s.StoragePath : Path.Combine(settings.StorageFolder, s.StoragePath), + Path = Path.Combine(settings.StorageFolder, s.StoragePath), IsActive = s.Sources.Any(a => !a.IsDisabled && !a.IsUninstalled), HasUnknown = s.Sources.Any(a=>!a.IsUnknown), + NeedsRename = s.NeedsRename, StoragePath = s.StoragePath, ChapterCount = s.ChapterCount, ChapterList = s.Sources diff --git a/KaizokuBackend/Hubs/ProgressHub.cs b/KaizokuBackend/Hubs/ProgressHub.cs index 11b1631d..7d4da057 100644 --- a/KaizokuBackend/Hubs/ProgressHub.cs +++ b/KaizokuBackend/Hubs/ProgressHub.cs @@ -1,9 +1,11 @@ -using KaizokuBackend.Models; +using KaizokuBackend.Models; using KaizokuBackend.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; namespace KaizokuBackend.Hubs { + [Authorize] public class ProgressHub : Hub { private readonly ILogger _logger; @@ -13,7 +15,8 @@ public ProgressHub(ILogger logger) } public override Task OnConnectedAsync() { - _logger.LogInformation($"SignalR Client connected: {Context.ConnectionId}"); + var userId = Context.User?.FindFirst("UserId")?.Value ?? "unknown"; + _logger.LogInformation($"SignalR Client connected: {Context.ConnectionId} (User: {userId})"); return base.OnConnectedAsync(); } @@ -22,8 +25,5 @@ public override Task OnDisconnectedAsync(Exception? exception) _logger.LogInformation($"SignalR Client disconnected: {Context.ConnectionId}"); return base.OnDisconnectedAsync(exception); } - - - } } diff --git a/KaizokuBackend/KaizokuBackend.csproj b/KaizokuBackend/KaizokuBackend.csproj index 3b4fdec5..c2937661 100644 --- a/KaizokuBackend/KaizokuBackend.csproj +++ b/KaizokuBackend/KaizokuBackend.csproj @@ -56,8 +56,11 @@ + + + diff --git a/KaizokuBackend/Models/CacheOptions.cs b/KaizokuBackend/Models/CacheOptions.cs index 6c72865e..cd38b373 100644 --- a/KaizokuBackend/Models/CacheOptions.cs +++ b/KaizokuBackend/Models/CacheOptions.cs @@ -4,6 +4,7 @@ public class CacheOptions { public string CachePath { get; set; } public int AgeInDays { get; set; } + public int MaxImageConcurrency { get; set; } } } diff --git a/KaizokuBackend/Models/Database/InviteLinkEntity.cs b/KaizokuBackend/Models/Database/InviteLinkEntity.cs new file mode 100644 index 00000000..5dc1eb2b --- /dev/null +++ b/KaizokuBackend/Models/Database/InviteLinkEntity.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace KaizokuBackend.Models.Database +{ + public class InviteLinkEntity + { + [Key] + public Guid Id { get; set; } + public string Code { get; set; } = string.Empty; + public Guid CreatedByUserId { get; set; } + public DateTime ExpiresAt { get; set; } + public int MaxUses { get; set; } = 1; + public int UsedCount { get; set; } = 0; + public Guid? PermissionPresetId { get; set; } + public bool IsActive { get; set; } = true; + + [ForeignKey(nameof(CreatedByUserId))] + public virtual UserEntity? CreatedByUser { get; set; } + + [ForeignKey(nameof(PermissionPresetId))] + public virtual PermissionPresetEntity? PermissionPreset { get; set; } + } +} diff --git a/KaizokuBackend/Models/Database/MangaRequestEntity.cs b/KaizokuBackend/Models/Database/MangaRequestEntity.cs new file mode 100644 index 00000000..12f4e431 --- /dev/null +++ b/KaizokuBackend/Models/Database/MangaRequestEntity.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using KaizokuBackend.Models.Enums; + +namespace KaizokuBackend.Models.Database +{ + public class MangaRequestEntity + { + [Key] + public Guid Id { get; set; } + public Guid RequestedByUserId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public string? ThumbnailUrl { get; set; } + public string? ProviderData { get; set; } + public RequestStatus Status { get; set; } = RequestStatus.Pending; + public Guid? ReviewedByUserId { get; set; } + public DateTime? ReviewedAt { get; set; } + public string? ReviewNote { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(RequestedByUserId))] + public virtual UserEntity? RequestedByUser { get; set; } + + [ForeignKey(nameof(ReviewedByUserId))] + public virtual UserEntity? ReviewedByUser { get; set; } + } +} diff --git a/KaizokuBackend/Models/Database/PermissionPresetEntity.cs b/KaizokuBackend/Models/Database/PermissionPresetEntity.cs new file mode 100644 index 00000000..3f3238a4 --- /dev/null +++ b/KaizokuBackend/Models/Database/PermissionPresetEntity.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace KaizokuBackend.Models.Database +{ + public class PermissionPresetEntity + { + [Key] + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public Guid CreatedByUserId { get; set; } + public bool IsDefault { get; set; } = false; + + public bool CanViewLibrary { get; set; } = true; + public bool CanRequestSeries { get; set; } = true; + public bool CanAddSeries { get; set; } = false; + public bool CanEditSeries { get; set; } = false; + public bool CanDeleteSeries { get; set; } = false; + public bool CanManageDownloads { get; set; } = false; + public bool CanViewQueue { get; set; } = true; + public bool CanBrowseSources { get; set; } = true; + public bool CanViewNSFW { get; set; } = false; + public bool CanManageRequests { get; set; } = false; + public bool CanManageJobs { get; set; } = false; + public bool CanViewStatistics { get; set; } = true; + + [ForeignKey(nameof(CreatedByUserId))] + public virtual UserEntity? CreatedByUser { get; set; } + } +} diff --git a/KaizokuBackend/Models/Database/ProviderStorageEntity.cs b/KaizokuBackend/Models/Database/ProviderStorageEntity.cs index eb3ee1e6..b57d3f5e 100644 --- a/KaizokuBackend/Models/Database/ProviderStorageEntity.cs +++ b/KaizokuBackend/Models/Database/ProviderStorageEntity.cs @@ -38,6 +38,9 @@ public override string Provider public bool IsEnabled { get; set; } = true; public bool IsBroken { get; set; } = false; public bool IsDead { get; set; } = false; + public DateTime? LastHealthCheckUtc { get; set; } + public bool? LastHealthCheckPassed { get; set; } + public string? LastHealthCheckError { get; set; } /// /// Provider is truly usable: enabled by user, not broken, and not dead. diff --git a/KaizokuBackend/Models/Database/SeriesEntity.cs b/KaizokuBackend/Models/Database/SeriesEntity.cs index 12c4fdf0..ca96b7c0 100644 --- a/KaizokuBackend/Models/Database/SeriesEntity.cs +++ b/KaizokuBackend/Models/Database/SeriesEntity.cs @@ -48,6 +48,14 @@ public int ChapterCount [JsonPropertyName("startFromChapter")] public decimal? StartFromChapter { get; set; } + /// + /// Set when the series title was recovered from a truncated source title, + /// indicating that the storage folder and chapter filenames still use the old truncated name + /// and should be renamed by the user. + /// + [JsonPropertyName("needsRename")] + public bool NeedsRename { get; set; } = false; + public virtual ICollection Sources { get; set; } = []; } } diff --git a/KaizokuBackend/Models/Database/UserEntity.cs b/KaizokuBackend/Models/Database/UserEntity.cs new file mode 100644 index 00000000..7da8551d --- /dev/null +++ b/KaizokuBackend/Models/Database/UserEntity.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using KaizokuBackend.Models.Enums; + +namespace KaizokuBackend.Models.Database +{ + public class UserEntity + { + [Key] + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + + // Obsolete: retained for backfill/transition — no longer required or unique on new installs. + public string? Email { get; set; } + + public string DisplayName { get; set; } = string.Empty; + public string? PasswordHash { get; set; } + public string? Salt { get; set; } + public UserRole Role { get; set; } = UserRole.User; + public UserLevel Level { get; set; } = UserLevel.User; + public string OpdsPath { get; set; } = string.Empty; + + // Obsolete: retained for backfill/transition — blob storage replaces file-system avatar. + public string? AvatarPath { get; set; } + + public byte[]? AvatarBlob { get; set; } + public string? AvatarContentType { get; set; } + public string? PasswordSetToken { get; set; } + public DateTime? PasswordSetTokenExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastLoginAt { get; set; } + public bool IsActive { get; set; } = true; + + public virtual UserPermissionEntity? Permissions { get; set; } + public virtual UserPreferencesEntity? Preferences { get; set; } + public virtual ICollection Sessions { get; set; } = []; + } +} diff --git a/KaizokuBackend/Models/Database/UserPermissionEntity.cs b/KaizokuBackend/Models/Database/UserPermissionEntity.cs new file mode 100644 index 00000000..1063be1a --- /dev/null +++ b/KaizokuBackend/Models/Database/UserPermissionEntity.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace KaizokuBackend.Models.Database +{ + public class UserPermissionEntity + { + [Key] + [ForeignKey(nameof(User))] + public Guid UserId { get; set; } + + public bool CanViewLibrary { get; set; } = true; + public bool CanRequestSeries { get; set; } = true; + public bool CanAddSeries { get; set; } = false; + public bool CanEditSeries { get; set; } = false; + public bool CanDeleteSeries { get; set; } = false; + public bool CanManageDownloads { get; set; } = false; + public bool CanViewQueue { get; set; } = false; + public bool CanBrowseSources { get; set; } = false; + public bool CanViewNSFW { get; set; } = false; + public bool CanManageRequests { get; set; } = false; + public bool CanManageJobs { get; set; } = false; + public bool CanViewStatistics { get; set; } = false; + + public virtual UserEntity? User { get; set; } + } +} diff --git a/KaizokuBackend/Models/Database/UserPreferencesEntity.cs b/KaizokuBackend/Models/Database/UserPreferencesEntity.cs new file mode 100644 index 00000000..55125477 --- /dev/null +++ b/KaizokuBackend/Models/Database/UserPreferencesEntity.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using KaizokuBackend.Models.Dto; + +namespace KaizokuBackend.Models.Database +{ + public class UserPreferencesEntity + { + [Key] + [ForeignKey(nameof(User))] + public Guid UserId { get; set; } + + public string Theme { get; set; } = "dark"; + public string DefaultLanguage { get; set; } = "en"; + public string CardSize { get; set; } = "medium"; + public NsfwVisibility NsfwVisibility { get; set; } = NsfwVisibility.HideByDefault; + + public virtual UserEntity? User { get; set; } + } +} diff --git a/KaizokuBackend/Models/Database/UserSessionEntity.cs b/KaizokuBackend/Models/Database/UserSessionEntity.cs new file mode 100644 index 00000000..1d0bb39c --- /dev/null +++ b/KaizokuBackend/Models/Database/UserSessionEntity.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace KaizokuBackend.Models.Database +{ + public class UserSessionEntity + { + [Key] + public Guid Id { get; set; } + + public Guid UserId { get; set; } + public string RefreshToken { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public bool IsRevoked { get; set; } = false; + + [ForeignKey(nameof(UserId))] + public virtual UserEntity? User { get; set; } + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/AuthResponseDto.cs b/KaizokuBackend/Models/Dto/Auth/AuthResponseDto.cs new file mode 100644 index 00000000..a3072aaf --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/AuthResponseDto.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class AuthResponseDto + { + [JsonPropertyName("accessToken")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("refreshToken")] + public string RefreshToken { get; set; } = string.Empty; + + [JsonPropertyName("expiresAt")] + public DateTime ExpiresAt { get; set; } + + [JsonPropertyName("user")] + public UserDetailDto User { get; set; } = new(); + + /// + /// When true, the user's current password does not meet the updated password policy + /// and they should be prompted to change it before proceeding. + /// + [JsonPropertyName("requiresPasswordChange")] + public bool RequiresPasswordChange { get; set; } + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/InviteFlowDto.cs b/KaizokuBackend/Models/Dto/Auth/InviteFlowDto.cs new file mode 100644 index 00000000..04b09c45 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/InviteFlowDto.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class FirstUserDto + { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("password")] + public string? Password { get; set; } + } + + public class SetPasswordDto + { + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; + + [JsonPropertyName("newPassword")] + public string NewPassword { get; set; } = string.Empty; + } + + public class SetAdminPasswordDto + { + [JsonPropertyName("newPassword")] + public string NewPassword { get; set; } = string.Empty; + } + + public class ClaimUserDto + { + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + } + + public class GenerateInviteResponseDto + { + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("expiresAt")] + public DateTime ExpiresAt { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/InviteLinkDto.cs b/KaizokuBackend/Models/Dto/Auth/InviteLinkDto.cs new file mode 100644 index 00000000..a6b00673 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/InviteLinkDto.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class InviteLinkDto + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("createdByUserId")] + public Guid CreatedByUserId { get; set; } + + [JsonPropertyName("createdByUsername")] + public string CreatedByUsername { get; set; } = string.Empty; + + [JsonPropertyName("expiresAt")] + public DateTime ExpiresAt { get; set; } + + [JsonPropertyName("maxUses")] + public int MaxUses { get; set; } + + [JsonPropertyName("usedCount")] + public int UsedCount { get; set; } + + [JsonPropertyName("permissionPresetId")] + public Guid? PermissionPresetId { get; set; } + + [JsonPropertyName("permissionPresetName")] + public string? PermissionPresetName { get; set; } + + [JsonPropertyName("isActive")] + public bool IsActive { get; set; } + } + + public class InviteValidationDto + { + [JsonPropertyName("isValid")] + public bool IsValid { get; set; } + + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + [JsonPropertyName("permissionPresetName")] + public string? PermissionPresetName { get; set; } + } + + public class CreateInviteDto + { + [JsonPropertyName("expiresInDays")] + public int ExpiresInDays { get; set; } = 7; + + [JsonPropertyName("maxUses")] + public int MaxUses { get; set; } = 1; + + [JsonPropertyName("permissionPresetId")] + public Guid? PermissionPresetId { get; set; } + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/LoginDto.cs b/KaizokuBackend/Models/Dto/Auth/LoginDto.cs new file mode 100644 index 00000000..8651ea33 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/LoginDto.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class LoginDto + { + /// + /// Username for login. The "OrEmail" suffix is retained for client backward-compatibility + /// but email-based lookup is no longer performed. + /// + [JsonPropertyName("usernameOrEmail")] + public string UsernameOrEmail { get; set; } = string.Empty; + + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + + [JsonPropertyName("rememberMe")] + public bool RememberMe { get; set; } = false; + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/MangaRequestDto.cs b/KaizokuBackend/Models/Dto/Auth/MangaRequestDto.cs new file mode 100644 index 00000000..019f4390 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/MangaRequestDto.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using KaizokuBackend.Models.Enums; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class MangaRequestDto + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("requestedByUserId")] + public Guid RequestedByUserId { get; set; } + + [JsonPropertyName("requestedByUsername")] + public string RequestedByUsername { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("thumbnailUrl")] + public string? ThumbnailUrl { get; set; } + + [JsonPropertyName("providerData")] + public string? ProviderData { get; set; } + + [JsonPropertyName("status")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public RequestStatus Status { get; set; } + + [JsonPropertyName("reviewedByUserId")] + public Guid? ReviewedByUserId { get; set; } + + [JsonPropertyName("reviewedByUsername")] + public string? ReviewedByUsername { get; set; } + + [JsonPropertyName("reviewedAt")] + public DateTime? ReviewedAt { get; set; } + + [JsonPropertyName("reviewNote")] + public string? ReviewNote { get; set; } + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + } + + public class CreateRequestDto + { + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("thumbnailUrl")] + public string? ThumbnailUrl { get; set; } + + [JsonPropertyName("providerData")] + public string? ProviderData { get; set; } + } + + public class ApproveRequestDto + { + [JsonPropertyName("seriesData")] + public AugmentedResponseDto? SeriesData { get; set; } + + [JsonPropertyName("reviewNote")] + public string? ReviewNote { get; set; } + } + + public class DenyRequestDto + { + [JsonPropertyName("reviewNote")] + public string? ReviewNote { get; set; } + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/PermissionDto.cs b/KaizokuBackend/Models/Dto/Auth/PermissionDto.cs new file mode 100644 index 00000000..f8e18700 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/PermissionDto.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class PermissionDto + { + [JsonPropertyName("canViewLibrary")] + public bool CanViewLibrary { get; set; } = true; + + [JsonPropertyName("canRequestSeries")] + public bool CanRequestSeries { get; set; } = true; + + [JsonPropertyName("canAddSeries")] + public bool CanAddSeries { get; set; } = false; + + [JsonPropertyName("canEditSeries")] + public bool CanEditSeries { get; set; } = false; + + [JsonPropertyName("canDeleteSeries")] + public bool CanDeleteSeries { get; set; } = false; + + [JsonPropertyName("canManageDownloads")] + public bool CanManageDownloads { get; set; } = false; + + // Defaults must match UserPermissionEntity defaults (false) to prevent + // silent permission escalation when a DTO is deserialized with missing fields. + [JsonPropertyName("canViewQueue")] + public bool CanViewQueue { get; set; } = false; + + [JsonPropertyName("canBrowseSources")] + public bool CanBrowseSources { get; set; } = false; + + [JsonPropertyName("canViewNSFW")] + public bool CanViewNSFW { get; set; } = false; + + [JsonPropertyName("canManageRequests")] + public bool CanManageRequests { get; set; } = false; + + [JsonPropertyName("canManageJobs")] + public bool CanManageJobs { get; set; } = false; + + [JsonPropertyName("canViewStatistics")] + public bool CanViewStatistics { get; set; } = false; + } + + public class UpdatePermissionDto : PermissionDto + { + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/PermissionPresetDto.cs b/KaizokuBackend/Models/Dto/Auth/PermissionPresetDto.cs new file mode 100644 index 00000000..b3876c1c --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/PermissionPresetDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class PermissionPresetDto + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + [JsonPropertyName("createdByUserId")] + public Guid CreatedByUserId { get; set; } + + [JsonPropertyName("permissions")] + public PermissionDto Permissions { get; set; } = new(); + } + + public class CreatePresetDto + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("permissions")] + public PermissionDto Permissions { get; set; } = new(); + } + + public class UpdatePresetDto + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("permissions")] + public PermissionDto? Permissions { get; set; } + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/RefreshTokenDto.cs b/KaizokuBackend/Models/Dto/Auth/RefreshTokenDto.cs new file mode 100644 index 00000000..08e02a5e --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/RefreshTokenDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class RefreshTokenDto + { + [JsonPropertyName("refreshToken")] + public string RefreshToken { get; set; } = string.Empty; + } + + public class CreateUserDto + { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("role")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public KaizokuBackend.Models.Enums.UserRole Role { get; set; } = KaizokuBackend.Models.Enums.UserRole.User; + + [JsonPropertyName("permissions")] + public PermissionDto? Permissions { get; set; } + + /// + /// Optional permission preset applied as the permission base. An explicit + /// object overrides individual preset values. + /// + [JsonPropertyName("permissionPresetId")] + public Guid? PermissionPresetId { get; set; } + } + + public class ResetPasswordDto + { + [JsonPropertyName("newPassword")] + public string NewPassword { get; set; } = string.Empty; + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/RegisterDto.cs b/KaizokuBackend/Models/Dto/Auth/RegisterDto.cs new file mode 100644 index 00000000..86a22815 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/RegisterDto.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class RegisterDto + { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("inviteCode")] + public string InviteCode { get; set; } = string.Empty; + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/SelectUserDto.cs b/KaizokuBackend/Models/Dto/Auth/SelectUserDto.cs new file mode 100644 index 00000000..68c48bb9 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/SelectUserDto.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + /// Request body for POST /api/auth/select-user (auth-disabled mode). + public class SelectUserDto + { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + /// Required when the selected profile has been claimed (password-protected). + [JsonPropertyName("password")] + public string? Password { get; set; } + } + + /// + /// Slim user entry included in the GET /api/auth/status response when + /// authentication is disabled, so the frontend can render a profile selector. + /// + public class StatusUserEntryDto + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("avatarBase64")] + public string? AvatarBase64 { get; set; } + + [JsonPropertyName("avatarContentType")] + public string? AvatarContentType { get; set; } + + /// True when this profile is claimed: select-user requires its password. + [JsonPropertyName("hasPassword")] + public bool HasPassword { get; set; } + } + + /// Response shape for GET /api/auth/status. + public class AuthStatusDto + { + /// True when password-based JWT authentication is required. + [JsonPropertyName("authenticationEnabled")] + public bool AuthenticationEnabled { get; set; } + + /// True when at least one user exists (setup has been completed). + [JsonPropertyName("hasUsers")] + public bool HasUsers { get; set; } + + /// + /// Back-compat alias: equals !HasUsers. + /// Kept so existing frontend setup-detection logic does not break. + /// + [JsonPropertyName("requiresSetup")] + public bool RequiresSetup => !HasUsers; + + /// + /// True when auth is disabled and no admin account has a password set. + /// Signals the frontend that an admin must set a password before authentication can be enabled. + /// + [JsonPropertyName("needsAdminPassword")] + public bool NeedsAdminPassword { get; set; } + + /// + /// List of active users for the profile selector. + /// Populated only when is false; omitted from + /// the JSON response entirely when null (auth-enabled mode). + /// + [JsonPropertyName("users")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Users { get; set; } + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/UserDto.cs b/KaizokuBackend/Models/Dto/Auth/UserDto.cs new file mode 100644 index 00000000..44e63ea1 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/UserDto.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; +using KaizokuBackend.Models.Enums; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class UserDto + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("role")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public UserRole Role { get; set; } + + [JsonPropertyName("level")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public UserLevel Level { get; set; } + + [JsonPropertyName("opdsPath")] + public string OpdsPath { get; set; } = string.Empty; + + /// + /// Base64-encoded avatar image, or null when no avatar is set. + /// + [JsonPropertyName("avatarBase64")] + public string? AvatarBase64 { get; set; } + + [JsonPropertyName("avatarContentType")] + public string? AvatarContentType { get; set; } + + /// + /// True when the user has a password hash set (i.e. password-based login is possible). + /// + [JsonPropertyName("hasPassword")] + public bool HasPassword { get; set; } + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("lastLoginAt")] + public DateTime? LastLoginAt { get; set; } + + [JsonPropertyName("isActive")] + public bool IsActive { get; set; } + } + + public class UserDetailDto : UserDto + { + [JsonPropertyName("updatedAt")] + public DateTime UpdatedAt { get; set; } + + [JsonPropertyName("permissions")] + public PermissionDto Permissions { get; set; } = new(); + + [JsonPropertyName("preferences")] + public UserPreferencesDto Preferences { get; set; } = new(); + } + + public class UpdateUserDto + { + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("role")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public UserRole? Role { get; set; } + + [JsonPropertyName("isActive")] + public bool? IsActive { get; set; } + } + + public class ChangePasswordDto + { + [JsonPropertyName("currentPassword")] + public string CurrentPassword { get; set; } = string.Empty; + + [JsonPropertyName("newPassword")] + public string NewPassword { get; set; } = string.Empty; + } +} diff --git a/KaizokuBackend/Models/Dto/Auth/UserPreferencesDto.cs b/KaizokuBackend/Models/Dto/Auth/UserPreferencesDto.cs new file mode 100644 index 00000000..14a2aa1c --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/UserPreferencesDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class UserPreferencesDto + { + [JsonPropertyName("theme")] + public string Theme { get; set; } = "dark"; + + [JsonPropertyName("defaultLanguage")] + public string DefaultLanguage { get; set; } = "en"; + + [JsonPropertyName("cardSize")] + public string CardSize { get; set; } = "medium"; + + [JsonPropertyName("nsfwVisibility")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public NsfwVisibility NsfwVisibility { get; set; } = NsfwVisibility.HideByDefault; + } + + /// + /// Partial update: only the properties present in the request body are applied; + /// omitted properties keep their stored values. + /// + public class UpdatePreferencesDto + { + [JsonPropertyName("theme")] + public string? Theme { get; set; } + + [JsonPropertyName("defaultLanguage")] + public string? DefaultLanguage { get; set; } + + [JsonPropertyName("cardSize")] + public string? CardSize { get; set; } + + [JsonPropertyName("nsfwVisibility")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public NsfwVisibility? NsfwVisibility { get; set; } + } +} diff --git a/KaizokuBackend/Models/Dto/BaseSeriesDto.cs b/KaizokuBackend/Models/Dto/BaseSeriesDto.cs index f325b5a5..9780d44f 100644 --- a/KaizokuBackend/Models/Dto/BaseSeriesDto.cs +++ b/KaizokuBackend/Models/Dto/BaseSeriesDto.cs @@ -57,4 +57,7 @@ public int ChapterCount [JsonPropertyName("startFromChapter")] public decimal? StartFromChapter { get; set; } + + [JsonPropertyName("needsRename")] + public bool NeedsRename { get; set; } = false; } \ No newline at end of file diff --git a/KaizokuBackend/Models/Dto/ChapterDto.cs b/KaizokuBackend/Models/Dto/ChapterDto.cs new file mode 100644 index 00000000..404e048f --- /dev/null +++ b/KaizokuBackend/Models/Dto/ChapterDto.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto; + +public class ChapterDto +{ + [JsonPropertyName("number")] + public decimal? Number { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("pageCount")] + public int? PageCount { get; set; } + + [JsonPropertyName("providerUploadDate")] + public DateTime? ProviderUploadDate { get; set; } + + [JsonPropertyName("downloadDate")] + public DateTime? DownloadDate { get; set; } + + [JsonPropertyName("filename")] + public string? Filename { get; set; } + + [JsonPropertyName("status")] + public ChapterDownloadStatus Status { get; set; } + + [JsonPropertyName("providers")] + public List Providers { get; set; } = new(); +} + +public class ChapterProviderDto +{ + [JsonPropertyName("providerId")] + public Guid ProviderId { get; set; } + + [JsonPropertyName("provider")] + public string Provider { get; set; } = string.Empty; + + [JsonPropertyName("scanlator")] + public string? Scanlator { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("providerIndex")] + public int ProviderIndex { get; set; } + + [JsonPropertyName("isDownloaded")] + public bool IsDownloaded { get; set; } +} + +public enum ChapterDownloadStatus +{ + Missing = 0, + Queued = 1, + Downloaded = 2, + Failed = 3 +} + +public class DownloadChaptersRequestDto +{ + [JsonPropertyName("chapterNumbers")] + public decimal[]? ChapterNumbers { get; set; } +} + +public class DownloadMissingResultDto +{ + [JsonPropertyName("enqueuedCount")] + public int EnqueuedCount { get; set; } +} + +public class RefreshChaptersResultDto +{ + [JsonPropertyName("jobsEnqueued")] + public int JobsEnqueued { get; set; } +} diff --git a/KaizokuBackend/Models/Dto/EditableSettingsDto.cs b/KaizokuBackend/Models/Dto/EditableSettingsDto.cs index 66715cea..550548d5 100644 --- a/KaizokuBackend/Models/Dto/EditableSettingsDto.cs +++ b/KaizokuBackend/Models/Dto/EditableSettingsDto.cs @@ -65,6 +65,21 @@ public class EditableSettingsDto [JsonConverter(typeof(JsonStringEnumConverter))] public NsfwVisibility NsfwVisibility { get; set; } = NsfwVisibility.HideByDefault; + [JsonPropertyName("maxPendingRequestsPerUser")] + public int MaxPendingRequestsPerUser { get; set; } = 10; + + [JsonPropertyName("defaultPermissionPresetId")] + public string DefaultPermissionPresetId { get; set; } = string.Empty; + + [JsonPropertyName("registrationEnabled")] + public bool RegistrationEnabled { get; set; } = true; + + [JsonPropertyName("authenticationEnabled")] + public bool AuthenticationEnabled { get; set; } = false; + + [JsonPropertyName("externalDomain")] + public string ExternalDomain { get; set; } = ""; + } public enum NsfwVisibility { diff --git a/KaizokuBackend/Models/Dto/ExtensionDto.cs b/KaizokuBackend/Models/Dto/ExtensionDto.cs index 5df217d1..04dd1141 100644 --- a/KaizokuBackend/Models/Dto/ExtensionDto.cs +++ b/KaizokuBackend/Models/Dto/ExtensionDto.cs @@ -30,4 +30,10 @@ public class ExtensionDto : IThumb public bool AutoUpdate { get; set; } = true; [JsonPropertyName("onlineRepositories")] public List Repositories { get; set; } = []; + [JsonPropertyName("lastHealthCheckUtc")] + public DateTime? LastHealthCheckUtc { get; set; } + [JsonPropertyName("lastHealthCheckPassed")] + public bool? LastHealthCheckPassed { get; set; } + [JsonPropertyName("lastHealthCheckError")] + public string? LastHealthCheckError { get; set; } } diff --git a/KaizokuBackend/Models/Dto/LatestGenreDto.cs b/KaizokuBackend/Models/Dto/LatestGenreDto.cs new file mode 100644 index 00000000..21cf03d5 --- /dev/null +++ b/KaizokuBackend/Models/Dto/LatestGenreDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto; + +/// +/// A genre/tag available from the cached "Latest" cloud catalogue, along with +/// how many recently fetched titles carry it. Used to populate the tag filter +/// chips on the browse screen. +/// +// [Schema] // Controller I/O Model +public class LatestGenreDto +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("count")] + public int Count { get; set; } +} diff --git a/KaizokuBackend/Models/Dto/LinkedSeriesDto.cs b/KaizokuBackend/Models/Dto/LinkedSeriesDto.cs index 59fc730d..cf9b5a43 100644 --- a/KaizokuBackend/Models/Dto/LinkedSeriesDto.cs +++ b/KaizokuBackend/Models/Dto/LinkedSeriesDto.cs @@ -6,6 +6,7 @@ namespace KaizokuBackend.Models.Dto; // [Schema] // Controller I/O Model public class LinkedSeriesDto : SeriesSummaryBase { + [JsonPropertyName("providerId")] public string ProviderId { get; set; } = ""; [JsonPropertyName("linkedIds")] public List LinkedIds { get; set; } = new List(); diff --git a/KaizokuBackend/Models/Dto/ProviderHealthResultDto.cs b/KaizokuBackend/Models/Dto/ProviderHealthResultDto.cs new file mode 100644 index 00000000..169697b9 --- /dev/null +++ b/KaizokuBackend/Models/Dto/ProviderHealthResultDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto; + +/// +/// Result of a provider health check +/// +public class ProviderHealthResultDto +{ + [JsonPropertyName("mihonProviderId")] + public string MihonProviderId { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("passed")] + public bool Passed { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("checkedAtUtc")] + public DateTime CheckedAtUtc { get; set; } +} diff --git a/KaizokuBackend/Models/Enums/ArchiveResult.cs b/KaizokuBackend/Models/Enums/ArchiveResult.cs index e59e7985..f944d3f6 100644 --- a/KaizokuBackend/Models/Enums/ArchiveResult.cs +++ b/KaizokuBackend/Models/Enums/ArchiveResult.cs @@ -6,4 +6,11 @@ public enum ArchiveResult NotAnArchive, NoImages, NotFound, + /// + /// File exists on disk but the archive could not be read (locked file, I/O error, + /// transient failure, etc). Distinct from , which means + /// the file was read successfully but contains no entries. Callers should treat + /// this as transient and avoid destructive actions. + /// + Unreadable, } \ No newline at end of file diff --git a/KaizokuBackend/Models/Enums/DownloadManageAction.cs b/KaizokuBackend/Models/Enums/DownloadManageAction.cs new file mode 100644 index 00000000..82fabb4e --- /dev/null +++ b/KaizokuBackend/Models/Enums/DownloadManageAction.cs @@ -0,0 +1,16 @@ +namespace KaizokuBackend.Models.Enums; + +/// +/// Actions available for managing downloads in the queue +/// +public enum DownloadManageAction +{ + /// Remove a single download from the queue (waiting/failed/completed) + Remove, + /// Retry a single failed download + Retry, + /// Clear all downloads with a given status + ClearAll, + /// Retry all failed downloads + RetryAll, +} diff --git a/KaizokuBackend/Models/Enums/JobType.cs b/KaizokuBackend/Models/Enums/JobType.cs index 385a9a48..fe3bb422 100644 --- a/KaizokuBackend/Models/Enums/JobType.cs +++ b/KaizokuBackend/Models/Enums/JobType.cs @@ -11,5 +11,6 @@ public enum JobType Download, UpdateExtensions, UpdateAllSeries, - DailyUpdate + DailyUpdate, + HealthCheckSources } \ No newline at end of file diff --git a/KaizokuBackend/Models/Enums/RequestStatus.cs b/KaizokuBackend/Models/Enums/RequestStatus.cs new file mode 100644 index 00000000..0e1a978b --- /dev/null +++ b/KaizokuBackend/Models/Enums/RequestStatus.cs @@ -0,0 +1,10 @@ +namespace KaizokuBackend.Models.Enums +{ + public enum RequestStatus + { + Pending = 0, + Approved = 1, + Denied = 2, + Cancelled = 3 + } +} diff --git a/KaizokuBackend/Models/Enums/UserLevel.cs b/KaizokuBackend/Models/Enums/UserLevel.cs new file mode 100644 index 00000000..ffd7af0d --- /dev/null +++ b/KaizokuBackend/Models/Enums/UserLevel.cs @@ -0,0 +1,9 @@ +namespace KaizokuBackend.Models.Enums +{ + public enum UserLevel + { + User = 0, + Manager = 1, + Admin = 2 + } +} diff --git a/KaizokuBackend/Models/Enums/UserRole.cs b/KaizokuBackend/Models/Enums/UserRole.cs new file mode 100644 index 00000000..889317d3 --- /dev/null +++ b/KaizokuBackend/Models/Enums/UserRole.cs @@ -0,0 +1,8 @@ +namespace KaizokuBackend.Models.Enums +{ + public enum UserRole + { + Admin = 0, + User = 1 + } +} diff --git a/KaizokuBackend/Program.cs b/KaizokuBackend/Program.cs index f3427a98..97aa0525 100644 --- a/KaizokuBackend/Program.cs +++ b/KaizokuBackend/Program.cs @@ -1,3 +1,5 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Services.Auth; using KaizokuBackend.Utils; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; @@ -16,6 +18,62 @@ public static async Task Main(string[] args) var host = CreateHostBuilder(args).Build(); + // Eagerly warm the auth-settings cache BEFORE the HTTP pipeline starts serving + // requests. This eliminates the window between Kestrel accepting its first + // connection and StartupHostedService completing its DB migration + settings load. + // + // Strategy: perform a direct, lightweight read of the AuthenticationEnabled row + // from the Settings table. If the table does not yet exist (brand-new install + // whose migration has not run) the query throws and we tolerate the failure — + // the fail-closed default in AuthSettingsCache already returns true (auth required) + // until Update() is called, so the instance remains protected. + // + // Guard: skip the warm-up entirely when the database file does not yet exist. + // Opening an AppDbContext against a missing SQLite file causes EF/SQLite to + // create an empty kaizoku.db in ReadWriteCreate mode. That premature file then + // fools MigrationService.RunAsync (which keys off File.Exists) into treating a + // fresh install as a legacy v1.0 database, breaking the migration sequence. + // On a brand-new install there are no settings to warm; the fail-closed default + // in AuthSettingsCache keeps the instance protected until StartupHostedService + // completes migration and calls cache.Update(). + var warmUpConnectionString = EnvironmentSetup.Configuration!.GetConnectionString("DefaultConnection"); + var shouldWarm = false; + if (!string.IsNullOrEmpty(warmUpConnectionString) && + warmUpConnectionString.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase)) + { + var warmUpDbPath = warmUpConnectionString.Substring("Data Source=".Length).Trim(); + warmUpDbPath = Path.GetFullPath(warmUpDbPath); + shouldWarm = File.Exists(warmUpDbPath); + } + + if (shouldWarm) + { + try + { + using var warmScope = host.Services.CreateScope(); + var warmDb = warmScope.ServiceProvider.GetRequiredService(); + var settingRow = await warmDb.Settings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Name == "AuthenticationEnabled") + .ConfigureAwait(false); + + if (settingRow != null && + bool.TryParse(settingRow.Value, out var authEnabled)) + { + var cache = warmScope.ServiceProvider.GetRequiredService(); + cache.Update(authEnabled); + } + // If the row is missing or the table does not exist, leave the cache + // unloaded: the fail-closed default (return true) keeps the instance safe. + } + catch + { + // DB not yet migrated (brand-new install) — intentionally swallowed. + // Fail-closed default in AuthSettingsCache ensures auth is required until + // StartupHostedService completes its migration and warms the cache properly. + } + } + await host.RunAsync(); } diff --git a/KaizokuBackend/Services/Auth/AuthLockoutException.cs b/KaizokuBackend/Services/Auth/AuthLockoutException.cs new file mode 100644 index 00000000..93cfb47a --- /dev/null +++ b/KaizokuBackend/Services/Auth/AuthLockoutException.cs @@ -0,0 +1,9 @@ +namespace KaizokuBackend.Services.Auth +{ + public class AuthLockoutException : InvalidOperationException + { + public AuthLockoutException(string message) : base(message) + { + } + } +} diff --git a/KaizokuBackend/Services/Auth/AuthMiddleware.cs b/KaizokuBackend/Services/Auth/AuthMiddleware.cs new file mode 100644 index 00000000..56937fc7 --- /dev/null +++ b/KaizokuBackend/Services/Auth/AuthMiddleware.cs @@ -0,0 +1,183 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace KaizokuBackend.Services.Auth +{ + /// + /// Resolves the current and builds the request principal. + /// + /// When authentication is disabled (default): reads the X-Kaizoku-User + /// header and attempts to load that user (active, with permissions) from the database. + /// If the header is absent or the username is not found, falls back to the highest-level + /// active user (primary admin). When a user is resolved, an authenticated + /// is constructed via + /// and assigned to context.User; the entity is also stored in + /// context.Items["User"]. A fresh install with zero users leaves + /// context.User untouched so setup/anonymous endpoints continue to work normally. + /// No JWT validation, no 401 issued by this middleware in disabled mode. + /// + /// When authentication is enabled: allow-listed paths pass through without auth. + /// For all other paths the middleware expects UseAuthentication to have already run + /// and populated context.User. It maps the UserId claim to the entity and + /// stores it in Items. If the principal is not authenticated it short-circuits with 401. + /// + public class AuthMiddleware + { + private readonly RequestDelegate _next; + private readonly IAuthSettingsCache _authSettingsCache; + + // Exact paths (lowercase, no trailing slash) that are always public in enabled mode. + // A request whose normalised path equals one of these strings passes without auth. + private static readonly HashSet _allowListExact = new(StringComparer.Ordinal) + { + "/api/auth/login", + "/api/auth/select-user", + "/api/auth/status", + "/api/auth/refresh", + "/api/auth/register", + "/api/auth/setup", + "/api/auth/set-password", + "/api/users/first", + }; + + public AuthMiddleware(RequestDelegate next, IAuthSettingsCache authSettingsCache) + { + _next = next; + _authSettingsCache = authSettingsCache; + } + + public async Task InvokeAsync(HttpContext context, AppDbContext db) + { + var path = context.Request.Path.Value?.ToLowerInvariant() ?? string.Empty; + + // Only API routes and the SignalR hub need a resolved user / enforcement. + // Static assets and SPA pages pass straight through in both modes — in + // enabled mode the login page itself could never load otherwise, and in + // disabled mode this skips 1-2 user queries per asset request. + var isProtectedSurface = path.StartsWith("/api/", StringComparison.Ordinal) || + path == "/api" || + path.StartsWith("/progress", StringComparison.Ordinal); + if (!isProtectedSurface) + { + await _next(context).ConfigureAwait(false); + return; + } + + if (_authSettingsCache.AuthenticationEnabled) + { + await HandleEnabledModeAsync(context, db, path).ConfigureAwait(false); + } + else + { + await HandleDisabledModeAsync(context, db).ConfigureAwait(false); + } + } + + // -------------------------------------------------------------------- + // Disabled mode: header-based user selection, no enforcement. + // -------------------------------------------------------------------- + + private async Task HandleDisabledModeAsync(HttpContext context, AppDbContext db) + { + UserEntity? resolvedUser = null; + + var headerUsername = context.Request.Headers["X-Kaizoku-User"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(headerUsername)) + { + resolvedUser = await db.Users + .Include(u => u.Permissions) + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Username == headerUsername && u.IsActive) + .ConfigureAwait(false); + } + + if (resolvedUser == null) + { + resolvedUser = await db.Users + .Include(u => u.Permissions) + .AsNoTracking() + .Where(u => u.IsActive) + .OrderByDescending(u => u.Level) + .ThenBy(u => u.CreatedAt) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + + if (resolvedUser != null) + { + var permissions = resolvedUser.Permissions ?? new UserPermissionEntity { UserId = resolvedUser.Id }; + var claims = AuthService.BuildUserClaims(resolvedUser, permissions); + var identity = new ClaimsIdentity(claims, "KaizokuDisabledMode"); + context.User = new ClaimsPrincipal(identity); + context.Items["User"] = resolvedUser; + } + + await _next(context).ConfigureAwait(false); + } + + // -------------------------------------------------------------------- + // Enabled mode: JWT must be valid for non-allow-listed routes. + // -------------------------------------------------------------------- + + private async Task HandleEnabledModeAsync(HttpContext context, AppDbContext db, string path) + { + // Honour [AllowAnonymous] endpoint metadata (this middleware runs after + // UseRouting, so the endpoint is resolved). Covers login/register/status, + // image thumbnails, avatars, invite validation, first-user and claim — + // each of which carries its own internal guard where needed. The static + // allow-list below remains as a fallback. + var endpoint = context.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata() != null || + IsAllowListed(path, context.Request.Method)) + { + await _next(context).ConfigureAwait(false); + return; + } + + // UseAuthentication runs before this middleware and sets context.User when a + // valid Bearer token is present. We rely on that — no manual token parsing. + if (context.User?.Identity?.IsAuthenticated != true) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"error\":\"Authentication required.\"}").ConfigureAwait(false); + return; + } + + var userIdClaim = context.User.FindFirst("UserId")?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId)) + { + var user = await db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == userId && u.IsActive) + .ConfigureAwait(false); + + if (user != null) + context.Items["User"] = user; + } + + await _next(context).ConfigureAwait(false); + } + + // -------------------------------------------------------------------- + // Allow-list check + // -------------------------------------------------------------------- + + private static bool IsAllowListed(string path, string method) + { + // Normalise: strip a single trailing slash so /api/auth/login/ matches too. + var normPath = path.Length > 1 && path[path.Length - 1] == '/' + ? path.Substring(0, path.Length - 1) + : path; + + // Exact-path check (HashSet lookup is O(1) and the set is already lowercase). + if (_allowListExact.Contains(normPath)) + return true; + + return false; + } + } +} diff --git a/KaizokuBackend/Services/Auth/AuthService.cs b/KaizokuBackend/Services/Auth/AuthService.cs new file mode 100644 index 00000000..9a96dfcd --- /dev/null +++ b/KaizokuBackend/Services/Auth/AuthService.cs @@ -0,0 +1,407 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Models.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace KaizokuBackend.Services.Auth +{ + public class AuthService + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + private readonly InviteLinkService _inviteLinkService; + private readonly PermissionService _permissionService; + private readonly OpdsPathGenerator _opdsPathGenerator; + + public AuthService(AppDbContext db, ILogger logger, + InviteLinkService inviteLinkService, PermissionService permissionService, + OpdsPathGenerator opdsPathGenerator) + { + _db = db; + _logger = logger; + _inviteLinkService = inviteLinkService; + _permissionService = permissionService; + _opdsPathGenerator = opdsPathGenerator; + } + + public async Task RegisterAsync(RegisterDto dto, string? ipAddress, string? userAgent, CancellationToken token = default) + { + // Validate invite code + var invite = await _inviteLinkService.ValidateAsync(dto.InviteCode, token).ConfigureAwait(false); + if (invite == null) + throw new InvalidOperationException("Invalid or expired invite code."); + + // Check for duplicate username + var existingUser = await _db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Username == dto.Username, token) + .ConfigureAwait(false); + + if (existingUser != null) + throw new InvalidOperationException("Username is already taken."); + + // Validate password against policy + var passwordError = PasswordPolicy.Validate(dto.Password); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + // Generate OPDS path + var opdsPath = await _opdsPathGenerator.GenerateUniqueAsync(token).ConfigureAwait(false); + + // Create user + var salt = GenerateSalt(); + var user = new UserEntity + { + Id = Guid.NewGuid(), + Username = dto.Username.Trim(), + DisplayName = string.IsNullOrWhiteSpace(dto.DisplayName) ? dto.Username : dto.DisplayName.Trim(), + PasswordHash = HashPassword(dto.Password, salt), + Salt = salt, + Role = UserRole.User, + Level = UserLevel.User, + OpdsPath = opdsPath, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsActive = true + }; + + _db.Users.Add(user); + + // Create permissions from preset or defaults + var permissions = new UserPermissionEntity { UserId = user.Id }; + + if (invite.PermissionPresetId.HasValue) + { + var preset = await _db.PermissionPresets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == invite.PermissionPresetId.Value, token) + .ConfigureAwait(false); + + if (preset != null) + { + permissions.CanViewLibrary = preset.CanViewLibrary; + permissions.CanRequestSeries = preset.CanRequestSeries; + permissions.CanAddSeries = preset.CanAddSeries; + permissions.CanEditSeries = preset.CanEditSeries; + permissions.CanDeleteSeries = preset.CanDeleteSeries; + permissions.CanManageDownloads = preset.CanManageDownloads; + permissions.CanViewQueue = preset.CanViewQueue; + permissions.CanBrowseSources = preset.CanBrowseSources; + permissions.CanViewNSFW = preset.CanViewNSFW; + permissions.CanManageRequests = preset.CanManageRequests; + permissions.CanManageJobs = preset.CanManageJobs; + permissions.CanViewStatistics = preset.CanViewStatistics; + } + } + else + { + // Check for default preset + var defaultPreset = await _db.PermissionPresets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.IsDefault, token) + .ConfigureAwait(false); + + if (defaultPreset != null) + { + permissions.CanViewLibrary = defaultPreset.CanViewLibrary; + permissions.CanRequestSeries = defaultPreset.CanRequestSeries; + permissions.CanAddSeries = defaultPreset.CanAddSeries; + permissions.CanEditSeries = defaultPreset.CanEditSeries; + permissions.CanDeleteSeries = defaultPreset.CanDeleteSeries; + permissions.CanManageDownloads = defaultPreset.CanManageDownloads; + permissions.CanViewQueue = defaultPreset.CanViewQueue; + permissions.CanBrowseSources = defaultPreset.CanBrowseSources; + permissions.CanViewNSFW = defaultPreset.CanViewNSFW; + permissions.CanManageRequests = defaultPreset.CanManageRequests; + permissions.CanManageJobs = defaultPreset.CanManageJobs; + permissions.CanViewStatistics = defaultPreset.CanViewStatistics; + } + } + + _db.UserPermissions.Add(permissions); + + // Create default preferences + var preferences = new UserPreferencesEntity { UserId = user.Id }; + _db.UserPreferences.Add(preferences); + + // Save user first, then mark invite as used. This prevents the invite + // from being consumed if user creation fails (e.g., unique constraint). + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + await _inviteLinkService.UseAsync(dto.InviteCode, token).ConfigureAwait(false); + + // Generate tokens + return await CreateAuthResponseAsync(user, permissions, false, ipAddress, userAgent, token).ConfigureAwait(false); + } + + public async Task LoginAsync(LoginDto dto, string? ipAddress, string? userAgent, CancellationToken token = default) + { + var username = dto.UsernameOrEmail.Trim(); + var user = await _db.Users + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Username == username, token) + .ConfigureAwait(false); + + if (user == null) + throw new InvalidOperationException("Invalid username or password."); + + if (!user.IsActive) + throw new InvalidOperationException("Account is disabled."); + + // Same message as the unknown-user case: a distinct "no password set" error + // would let an attacker enumerate which usernames exist as passwordless profiles. + if (user.PasswordHash == null || user.Salt == null) + throw new InvalidOperationException("Invalid username or password."); + + if (!VerifyPassword(dto.Password, user.PasswordHash!, user.Salt!)) + throw new InvalidOperationException("Invalid username or password."); + + // Check if the user's password meets the current policy (detects legacy weak passwords) + bool requiresPasswordChange = !PasswordPolicy.MeetsPolicy(dto.Password); + + user.LastLoginAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + var permissions = user.Permissions ?? new UserPermissionEntity { UserId = user.Id }; + var response = await CreateAuthResponseAsync(user, permissions, dto.RememberMe, ipAddress, userAgent, token).ConfigureAwait(false); + response.RequiresPasswordChange = requiresPasswordChange; + return response; + } + + public async Task LogoutAsync(Guid userId, string refreshToken, CancellationToken token = default) + { + var session = await _db.UserSessions + .FirstOrDefaultAsync(s => s.UserId == userId && s.RefreshToken == refreshToken && !s.IsRevoked, token) + .ConfigureAwait(false); + + if (session != null) + { + session.IsRevoked = true; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + } + + public async Task RefreshTokenAsync(string refreshToken, string? ipAddress, string? userAgent, CancellationToken token = default) + { + var session = await _db.UserSessions + .Include(s => s.User) + .ThenInclude(u => u!.Permissions) + .FirstOrDefaultAsync(s => s.RefreshToken == refreshToken && !s.IsRevoked, token) + .ConfigureAwait(false); + + if (session == null) + throw new InvalidOperationException("Invalid refresh token."); + + if (session.ExpiresAt < DateTime.UtcNow) + { + session.IsRevoked = true; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + throw new InvalidOperationException("Refresh token has expired."); + } + + if (session.User == null || !session.User.IsActive) + throw new InvalidOperationException("Account is disabled."); + + // Revoke old session + session.IsRevoked = true; + + var user = session.User; + var permissions = user.Permissions ?? new UserPermissionEntity { UserId = user.Id }; + + // Determine if this was a remember-me session (> 2 days means it was remember-me) + bool rememberMe = (session.ExpiresAt - session.CreatedAt).TotalDays > 2; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return await CreateAuthResponseAsync(user, permissions, rememberMe, ipAddress, userAgent, token).ConfigureAwait(false); + } + + public static List BuildUserClaims(UserEntity user, UserPermissionEntity permissions) + { + return new List + { + new Claim("UserId", user.Id.ToString()), + new Claim("Username", user.Username), + new Claim("Role", user.Role.ToString()), + new Claim("Level", user.Level.ToString()), + new Claim("CanViewLibrary", permissions.CanViewLibrary.ToString()), + new Claim("CanRequestSeries", permissions.CanRequestSeries.ToString()), + new Claim("CanAddSeries", permissions.CanAddSeries.ToString()), + new Claim("CanEditSeries", permissions.CanEditSeries.ToString()), + new Claim("CanDeleteSeries", permissions.CanDeleteSeries.ToString()), + new Claim("CanManageDownloads", permissions.CanManageDownloads.ToString()), + new Claim("CanViewQueue", permissions.CanViewQueue.ToString()), + new Claim("CanBrowseSources", permissions.CanBrowseSources.ToString()), + new Claim("CanViewNSFW", permissions.CanViewNSFW.ToString()), + new Claim("CanManageRequests", permissions.CanManageRequests.ToString()), + new Claim("CanManageJobs", permissions.CanManageJobs.ToString()), + new Claim("CanViewStatistics", permissions.CanViewStatistics.ToString()), + }; + } + + private async Task CreateAuthResponseAsync(UserEntity user, UserPermissionEntity permissions, + bool rememberMe, string? ipAddress, string? userAgent, CancellationToken token) + { + var jwtSecret = await GetOrCreateJwtSecretAsync(token).ConfigureAwait(false); + var accessTokenExpiry = DateTime.UtcNow.AddMinutes(15); + + var claims = BuildUserClaims(user, permissions); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var jwtToken = new JwtSecurityToken( + issuer: "KaizokuNET", + audience: "KaizokuNET", + claims: claims, + expires: accessTokenExpiry, + signingCredentials: creds + ); + + var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtToken); + + // Create refresh token + var refreshTokenValue = GenerateRefreshToken(); + var refreshExpiry = rememberMe ? DateTime.UtcNow.AddDays(30) : DateTime.UtcNow.AddDays(1); + + var session = new UserSessionEntity + { + Id = Guid.NewGuid(), + UserId = user.Id, + RefreshToken = refreshTokenValue, + ExpiresAt = refreshExpiry, + CreatedAt = DateTime.UtcNow, + IpAddress = ipAddress, + UserAgent = userAgent, + IsRevoked = false + }; + + _db.UserSessions.Add(session); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + // Load preferences for the response + var preferences = await _db.UserPreferences + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == user.Id, token) + .ConfigureAwait(false); + + return new AuthResponseDto + { + AccessToken = accessToken, + RefreshToken = refreshTokenValue, + ExpiresAt = accessTokenExpiry, + User = MapUserDetailDto(user, permissions, preferences) + }; + } + + public async Task GetOrCreateJwtSecretAsync(CancellationToken token = default) + { + var setting = await _db.Settings + .FirstOrDefaultAsync(s => s.Name == "JwtSecret", token) + .ConfigureAwait(false); + + if (setting != null) + return setting.Value; + + // Generate a new 256-bit key + var keyBytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(keyBytes); + } + var secret = Convert.ToBase64String(keyBytes); + + _db.Settings.Add(new SettingEntity { Name = "JwtSecret", Value = secret }); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return secret; + } + + private static string GenerateSalt() + { + var saltBytes = new byte[16]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(saltBytes); + } + return Convert.ToBase64String(saltBytes); + } + + private static string HashPassword(string password, string salt) + { + return BCrypt.Net.BCrypt.HashPassword(password + salt, workFactor: 12); + } + + private static bool VerifyPassword(string password, string hash, string salt) + { + return BCrypt.Net.BCrypt.Verify(password + salt, hash); + } + + private static string GenerateRefreshToken() + { + var tokenBytes = new byte[64]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(tokenBytes); + } + return Convert.ToBase64String(tokenBytes); + } + + public static UserDto MapUserDto(UserEntity user) + { + return new UserDto + { + Id = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Role = user.Role, + Level = UserService.ResolveLevel(user), + OpdsPath = user.OpdsPath, + AvatarBase64 = user.AvatarBlob != null && user.AvatarBlob.Length > 0 + ? Convert.ToBase64String(user.AvatarBlob) + : null, + AvatarContentType = user.AvatarContentType, + HasPassword = user.PasswordHash != null, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + IsActive = user.IsActive + }; + } + + public static UserDetailDto MapUserDetailDto(UserEntity user, UserPermissionEntity permissions, UserPreferencesEntity? preferences) + { + return new UserDetailDto + { + Id = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Role = user.Role, + Level = UserService.ResolveLevel(user), + OpdsPath = user.OpdsPath, + AvatarBase64 = user.AvatarBlob != null && user.AvatarBlob.Length > 0 + ? Convert.ToBase64String(user.AvatarBlob) + : null, + AvatarContentType = user.AvatarContentType, + HasPassword = user.PasswordHash != null, + CreatedAt = user.CreatedAt, + UpdatedAt = user.UpdatedAt, + LastLoginAt = user.LastLoginAt, + IsActive = user.IsActive, + Permissions = PermissionService.MapToDto(permissions), + Preferences = preferences != null ? new UserPreferencesDto + { + Theme = preferences.Theme, + DefaultLanguage = preferences.DefaultLanguage, + CardSize = preferences.CardSize, + NsfwVisibility = preferences.NsfwVisibility + } : new UserPreferencesDto() + }; + } + } +} diff --git a/KaizokuBackend/Services/Auth/AuthSettingsCache.cs b/KaizokuBackend/Services/Auth/AuthSettingsCache.cs new file mode 100644 index 00000000..baa14507 --- /dev/null +++ b/KaizokuBackend/Services/Auth/AuthSettingsCache.cs @@ -0,0 +1,71 @@ +namespace KaizokuBackend.Services.Auth +{ + /// + /// Provides a cheap in-process cache for the AuthenticationEnabled setting so that + /// middleware does not hit the database on every request. + /// + public interface IAuthSettingsCache + { + /// + /// Returns the currently cached value of AuthenticationEnabled. + /// + /// Fail-closed: returns true (auth required) until the cache has been + /// explicitly populated via . This ensures no auth-bypass window + /// exists between process start and the first successful DB read. + /// + /// + bool AuthenticationEnabled { get; } + + /// + /// Updates the cached value. Called by SettingsService after each successful load or save. + /// + void Update(bool authenticationEnabled); + } + + /// + /// Thread-safe singleton implementation. + /// + /// Uses two separate volatile fields written in a deliberate order so that the + /// getter never observes _loaded = true with a stale _value: + /// + /// writes _value first (with ), + /// then sets _loaded = true. + /// The getter reads _loaded first (with ); + /// only when true does it read _value. + /// + /// This store-release / load-acquire pair is correct on x86/x64 (TSO) and ARM. + /// + /// + public sealed class AuthSettingsCache : IAuthSettingsCache + { + // Written by Update() before _loaded is set to true. + private volatile bool _value; + + // Written AFTER _value is stable. Never reset to false once set. + private volatile bool _loaded; + + /// + /// Returns the persisted value when the cache has been loaded, or true + /// (fail-closed — require authentication) until the first call. + /// + public bool AuthenticationEnabled + { + get + { + // Read _loaded with acquire semantics; if true, _value is already visible. + if (Volatile.Read(ref _loaded)) + return Volatile.Read(ref _value); + + // Cache not yet populated — fail closed (require auth). + return true; + } + } + + public void Update(bool authenticationEnabled) + { + // Write the concrete value first so it is visible before _loaded is set. + Volatile.Write(ref _value, authenticationEnabled); + Volatile.Write(ref _loaded, true); + } + } +} diff --git a/KaizokuBackend/Services/Auth/InviteLinkService.cs b/KaizokuBackend/Services/Auth/InviteLinkService.cs new file mode 100644 index 00000000..9dcdef6f --- /dev/null +++ b/KaizokuBackend/Services/Auth/InviteLinkService.cs @@ -0,0 +1,198 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto.Auth; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; + +namespace KaizokuBackend.Services.Auth +{ + public class InviteLinkService + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + private static readonly char[] AlphanumericChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789".ToCharArray(); + + public InviteLinkService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task CreateAsync(CreateInviteDto dto, Guid createdByUserId, CancellationToken token = default) + { + var code = GenerateCode(8); + + var entity = new InviteLinkEntity + { + Id = Guid.NewGuid(), + Code = code, + CreatedByUserId = createdByUserId, + ExpiresAt = DateTime.UtcNow.AddDays(dto.ExpiresInDays > 0 ? dto.ExpiresInDays : 7), + MaxUses = dto.MaxUses > 0 ? dto.MaxUses : 0, + UsedCount = 0, + PermissionPresetId = dto.PermissionPresetId, + IsActive = true + }; + + _db.InviteLinks.Add(entity); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return await MapToDtoAsync(entity, token).ConfigureAwait(false); + } + + public async Task ValidateAsync(string code, CancellationToken token = default) + { + var invite = await _db.InviteLinks + .FirstOrDefaultAsync(i => i.Code == code && i.IsActive, token) + .ConfigureAwait(false); + + if (invite == null) + return null; + + if (invite.ExpiresAt < DateTime.UtcNow) + return null; + + // MaxUses == 0 means unlimited + if (invite.MaxUses > 0 && invite.UsedCount >= invite.MaxUses) + return null; + + return invite; + } + + public async Task UseAsync(string code, CancellationToken token = default) + { + var invite = await _db.InviteLinks + .FirstOrDefaultAsync(i => i.Code == code && i.IsActive, token) + .ConfigureAwait(false); + + if (invite == null) + return; + + invite.UsedCount++; + + // MaxUses == 0 means unlimited; only deactivate when a finite limit is reached + if (invite.MaxUses > 0 && invite.UsedCount >= invite.MaxUses) + { + invite.IsActive = false; + } + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task RevokeAsync(Guid id, CancellationToken token = default) + { + var invite = await _db.InviteLinks + .FirstOrDefaultAsync(i => i.Id == id, token) + .ConfigureAwait(false); + + if (invite == null) + throw new InvalidOperationException("Invite link not found."); + + invite.IsActive = false; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task> ListActiveAsync(CancellationToken token = default) + { + var invites = await _db.InviteLinks + .Include(i => i.CreatedByUser) + .Include(i => i.PermissionPreset) + .Where(i => i.IsActive) + .OrderByDescending(i => i.ExpiresAt) + .ToListAsync(token) + .ConfigureAwait(false); + + return invites.Select(i => MapToDtoInternal(i)).ToList(); + } + + public async Task ValidateCodePublicAsync(string code, CancellationToken token = default) + { + var invite = await _db.InviteLinks + .Include(i => i.PermissionPreset) + .FirstOrDefaultAsync(i => i.Code == code, token) + .ConfigureAwait(false); + + if (invite == null) + return new InviteValidationDto { IsValid = false, Reason = "Invite code not found." }; + + if (!invite.IsActive) + return new InviteValidationDto { IsValid = false, Reason = "This invite has been revoked." }; + + if (invite.ExpiresAt < DateTime.UtcNow) + return new InviteValidationDto { IsValid = false, Reason = "This invite has expired." }; + + if (invite.MaxUses > 0 && invite.UsedCount >= invite.MaxUses) + return new InviteValidationDto { IsValid = false, Reason = "This invite has reached its maximum uses." }; + + return new InviteValidationDto + { + IsValid = true, + PermissionPresetName = invite.PermissionPreset?.Name + }; + } + + private static string GenerateCode(int length) + { + var bytes = new byte[length]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + + var chars = new char[length]; + for (int i = 0; i < length; i++) + { + chars[i] = AlphanumericChars[bytes[i] % AlphanumericChars.Length]; + } + + return new string(chars); + } + + private async Task MapToDtoAsync(InviteLinkEntity entity, CancellationToken token) + { + var user = await _db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == entity.CreatedByUserId, token) + .ConfigureAwait(false); + + string? presetName = null; + if (entity.PermissionPresetId.HasValue) + { + var preset = await _db.PermissionPresets.AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == entity.PermissionPresetId.Value, token) + .ConfigureAwait(false); + presetName = preset?.Name; + } + + return new InviteLinkDto + { + Id = entity.Id, + Code = entity.Code, + CreatedByUserId = entity.CreatedByUserId, + CreatedByUsername = user?.Username ?? "Unknown", + ExpiresAt = entity.ExpiresAt, + MaxUses = entity.MaxUses, + UsedCount = entity.UsedCount, + PermissionPresetId = entity.PermissionPresetId, + PermissionPresetName = presetName, + IsActive = entity.IsActive + }; + } + + private static InviteLinkDto MapToDtoInternal(InviteLinkEntity entity) + { + return new InviteLinkDto + { + Id = entity.Id, + Code = entity.Code, + CreatedByUserId = entity.CreatedByUserId, + CreatedByUsername = entity.CreatedByUser?.Username ?? "Unknown", + ExpiresAt = entity.ExpiresAt, + MaxUses = entity.MaxUses, + UsedCount = entity.UsedCount, + PermissionPresetId = entity.PermissionPresetId, + PermissionPresetName = entity.PermissionPreset?.Name, + IsActive = entity.IsActive + }; + } + } +} diff --git a/KaizokuBackend/Services/Auth/OpdsPathGenerator.cs b/KaizokuBackend/Services/Auth/OpdsPathGenerator.cs new file mode 100644 index 00000000..4d4ebd3c --- /dev/null +++ b/KaizokuBackend/Services/Auth/OpdsPathGenerator.cs @@ -0,0 +1,90 @@ +using KaizokuBackend.Data; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Services.Auth +{ + /// + /// Generates a unique two-word hyphenated OPDS path (e.g. "feather-flood") for a user. + /// Retries on collision against existing OpdsPath values in the database. + /// + public class OpdsPathGenerator + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + + private static readonly string[] Words = + [ + "amber", "anchor", "angle", "apple", "arrow", "aspen", "atlas", "azure", + "badge", "baker", "basin", "bench", "birch", "blade", "blaze", "bloom", + "board", "boreal", "boxer", "brace", "braid", "brake", "brand", "brave", + "briar", "brick", "bridge", "brine", "brook", "brush", "bugle", "cairn", + "canoe", "cape", "cargo", "cedar", "chase", "chief", "chord", "cider", + "cliff", "cloak", "cloud", "clover", "comet", "coral", "crest", "croft", + "crown", "crush", "curve", "dagger", "delta", "depot", "drift", "dune", + "eagle", "echo", "ember", "envoy", "equinox", "fable", "falcon", "fawn", + "feather", "fern", "ferry", "field", "finch", "fjord", "flame", "flare", + "flint", "flood", "flora", "flume", "foam", "forge", "forte", "forum", + "frost", "gale", "garnet", "gavel", "glade", "glare", "glen", "glint", + "globe", "gloom", "glow", "gorge", "grain", "grant", "gravel", "grove", + "guard", "guild", "gust", "haven", "hawk", "hazel", "heath", "hedge", + "helm", "herald", "heron", "hinge", "hollow", "holly", "honor", "horizon", + "hound", "hull", "inlet", "isle", "ivory", "jade", "jasper", "jetty", + "kayak", "kelp", "knoll", "larch", "latch", "laurel", "ledge", "level", + "light", "lime", "linden", "locket", "lodge", "loft", "lore", "lotus", + "lunar", "lynx", "maple", "marble", "marsh", "mast", "meadow", "mesa", + "mist", "morel", "moss", "motif", "mound", "mount", "mulch", "nave", + "nebula", "nettle", "niche", "noble", "north", "notch", "nova", "oak", + "oaken", "opal", "orbit", "otter", "outpost", "oxbow", "paddle", "parch", + "patrol", "peak", "pebble", "pelican", "pine", "pivot", "plank", "plume", + "plunge", "ponder", "portal", "prism", "probe", "pulsar", "quartz", "quill", + "radiant", "rapid", "raven", "reach", "reef", "relay", "ridge", "rift", + "rivet", "robin", "rock", "rogue", "rosewood", "rowan", "rudder", "rush", + "sable", "saddle", "sage", "sail", "sand", "sapling", "scout", "seam", + "sedge", "shade", "shaft", "shale", "shore", "signal", "silver", "slate", + "sleet", "slope", "snare", "solar", "spar", "spark", "spire", "spoke", + "spray", "spruce", "spur", "squad", "squall", "stag", "stake", "steed", + "stern", "stone", "storm", "strand", "stream", "surge", "swift", "tallow", + "talon", "tangle", "tarn", "terrace", "thorn", "tidal", "tide", "timber", + "token", "torch", "totem", "tower", "trace", "track", "trail", "traverse", + "trek", "trident", "tundra", "turret", "vale", "valor", "vault", "vector", + "veil", "velvet", "venture", "vernal", "vessel", "vigil", "viper", "vista", + "vortex", "warden", "wave", "wedge", "willow", "wind", "wing", "wolf", + "yew", "zenith" + ]; + + public OpdsPathGenerator(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + /// + /// Generates a unique two-word hyphenated OPDS path. + /// Retries up to 50 times before throwing if every attempt collides. + /// + public async Task GenerateUniqueAsync(CancellationToken cancellationToken = default) + { + const int maxAttempts = 50; + var rng = Random.Shared; + + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + var word1 = Words[rng.Next(Words.Length)]; + var word2 = Words[rng.Next(Words.Length)]; + var candidate = $"{word1}-{word2}"; + + bool exists = await _db.Users + .AsNoTracking() + .AnyAsync(u => u.OpdsPath == candidate, cancellationToken) + .ConfigureAwait(false); + + if (!exists) + return candidate; + + _logger.LogDebug("OPDS path collision on attempt {Attempt}: {Candidate}", attempt + 1, candidate); + } + + throw new InvalidOperationException($"Failed to generate a unique OPDS path after {maxAttempts} attempts."); + } + } +} diff --git a/KaizokuBackend/Services/Auth/PasswordPolicy.cs b/KaizokuBackend/Services/Auth/PasswordPolicy.cs new file mode 100644 index 00000000..47c4c438 --- /dev/null +++ b/KaizokuBackend/Services/Auth/PasswordPolicy.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; + +namespace KaizokuBackend.Services.Auth +{ + /// + /// Centralized password policy enforcement. + /// Rules: min 8 characters, at least one letter, at least one number. + /// + public static partial class PasswordPolicy + { + public const int MinLength = 8; + + /// + /// Validates a password against the policy. Returns null if valid, or an error message if not. + /// + public static string? Validate(string? password) + { + if (string.IsNullOrWhiteSpace(password)) + return "Password is required."; + + if (password.Length < MinLength) + return $"Password must be at least {MinLength} characters."; + + if (!HasLetterRegex().IsMatch(password)) + return "Password must contain at least one letter."; + + if (!HasDigitRegex().IsMatch(password)) + return "Password must contain at least one number."; + + return null; + } + + /// + /// Returns true if the password meets the current policy requirements. + /// Used at login time to detect legacy weak passwords that need updating. + /// + public static bool MeetsPolicy(string? password) + { + return Validate(password) == null; + } + + [GeneratedRegex(@"[a-zA-Z]")] + private static partial Regex HasLetterRegex(); + + [GeneratedRegex(@"\d")] + private static partial Regex HasDigitRegex(); + } +} diff --git a/KaizokuBackend/Services/Auth/PermissionPresetService.cs b/KaizokuBackend/Services/Auth/PermissionPresetService.cs new file mode 100644 index 00000000..956870d9 --- /dev/null +++ b/KaizokuBackend/Services/Auth/PermissionPresetService.cs @@ -0,0 +1,178 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto.Auth; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Services.Auth +{ + public class PermissionPresetService + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public PermissionPresetService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task CreateAsync(CreatePresetDto dto, Guid createdByUserId, CancellationToken token = default) + { + var entity = new PermissionPresetEntity + { + Id = Guid.NewGuid(), + Name = dto.Name.Trim(), + CreatedByUserId = createdByUserId, + IsDefault = false, + CanViewLibrary = dto.Permissions.CanViewLibrary, + CanRequestSeries = dto.Permissions.CanRequestSeries, + CanAddSeries = dto.Permissions.CanAddSeries, + CanEditSeries = dto.Permissions.CanEditSeries, + CanDeleteSeries = dto.Permissions.CanDeleteSeries, + CanManageDownloads = dto.Permissions.CanManageDownloads, + CanViewQueue = dto.Permissions.CanViewQueue, + CanBrowseSources = dto.Permissions.CanBrowseSources, + CanViewNSFW = dto.Permissions.CanViewNSFW, + CanManageRequests = dto.Permissions.CanManageRequests, + CanManageJobs = dto.Permissions.CanManageJobs, + CanViewStatistics = dto.Permissions.CanViewStatistics + }; + + _db.PermissionPresets.Add(entity); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return MapToDto(entity); + } + + public async Task UpdateAsync(Guid id, UpdatePresetDto dto, CancellationToken token = default) + { + var entity = await _db.PermissionPresets + .FirstOrDefaultAsync(p => p.Id == id, token) + .ConfigureAwait(false); + + if (entity == null) + throw new InvalidOperationException("Permission preset not found."); + + if (!string.IsNullOrWhiteSpace(dto.Name)) + entity.Name = dto.Name.Trim(); + + if (dto.Permissions != null) + { + entity.CanViewLibrary = dto.Permissions.CanViewLibrary; + entity.CanRequestSeries = dto.Permissions.CanRequestSeries; + entity.CanAddSeries = dto.Permissions.CanAddSeries; + entity.CanEditSeries = dto.Permissions.CanEditSeries; + entity.CanDeleteSeries = dto.Permissions.CanDeleteSeries; + entity.CanManageDownloads = dto.Permissions.CanManageDownloads; + entity.CanViewQueue = dto.Permissions.CanViewQueue; + entity.CanBrowseSources = dto.Permissions.CanBrowseSources; + entity.CanViewNSFW = dto.Permissions.CanViewNSFW; + entity.CanManageRequests = dto.Permissions.CanManageRequests; + entity.CanManageJobs = dto.Permissions.CanManageJobs; + entity.CanViewStatistics = dto.Permissions.CanViewStatistics; + } + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + return MapToDto(entity); + } + + public async Task DeleteAsync(Guid id, CancellationToken token = default) + { + var entity = await _db.PermissionPresets + .FirstOrDefaultAsync(p => p.Id == id, token) + .ConfigureAwait(false); + + if (entity == null) + throw new InvalidOperationException("Permission preset not found."); + + _db.PermissionPresets.Remove(entity); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task> ListAsync(CancellationToken token = default) + { + var presets = await _db.PermissionPresets + .AsNoTracking() + .OrderBy(p => p.Name) + .ToListAsync(token) + .ConfigureAwait(false); + + return presets.Select(MapToDto).ToList(); + } + + public async Task GetByIdAsync(Guid id, CancellationToken token = default) + { + var entity = await _db.PermissionPresets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == id, token) + .ConfigureAwait(false); + + return entity != null ? MapToDto(entity) : null; + } + + public async Task SetDefaultAsync(Guid id, CancellationToken token = default) + { + // Remove current default + var currentDefaults = await _db.PermissionPresets + .Where(p => p.IsDefault) + .ToListAsync(token) + .ConfigureAwait(false); + + foreach (var current in currentDefaults) + { + current.IsDefault = false; + } + + var entity = await _db.PermissionPresets + .FirstOrDefaultAsync(p => p.Id == id, token) + .ConfigureAwait(false); + + if (entity == null) + throw new InvalidOperationException("Permission preset not found."); + + entity.IsDefault = true; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task ClearDefaultAsync(CancellationToken token = default) + { + var currentDefaults = await _db.PermissionPresets + .Where(p => p.IsDefault) + .ToListAsync(token) + .ConfigureAwait(false); + + foreach (var current in currentDefaults) + { + current.IsDefault = false; + } + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public static PermissionPresetDto MapToDto(PermissionPresetEntity entity) + { + return new PermissionPresetDto + { + Id = entity.Id, + Name = entity.Name, + IsDefault = entity.IsDefault, + CreatedByUserId = entity.CreatedByUserId, + Permissions = new PermissionDto + { + CanViewLibrary = entity.CanViewLibrary, + CanRequestSeries = entity.CanRequestSeries, + CanAddSeries = entity.CanAddSeries, + CanEditSeries = entity.CanEditSeries, + CanDeleteSeries = entity.CanDeleteSeries, + CanManageDownloads = entity.CanManageDownloads, + CanViewQueue = entity.CanViewQueue, + CanBrowseSources = entity.CanBrowseSources, + CanViewNSFW = entity.CanViewNSFW, + CanManageRequests = entity.CanManageRequests, + CanManageJobs = entity.CanManageJobs, + CanViewStatistics = entity.CanViewStatistics + } + }; + } + } +} diff --git a/KaizokuBackend/Services/Auth/PermissionService.cs b/KaizokuBackend/Services/Auth/PermissionService.cs new file mode 100644 index 00000000..5c34cf94 --- /dev/null +++ b/KaizokuBackend/Services/Auth/PermissionService.cs @@ -0,0 +1,149 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto.Auth; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Services.Auth +{ + public class PermissionService + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public PermissionService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task GetPermissionsAsync(Guid userId, CancellationToken token = default) + { + var permissions = await _db.UserPermissions + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == userId, token) + .ConfigureAwait(false); + + if (permissions == null) + throw new InvalidOperationException("User permissions not found."); + + return MapToDto(permissions); + } + + public async Task UpdatePermissionsAsync(Guid userId, UpdatePermissionDto dto, CancellationToken token = default) + { + var permissions = await _db.UserPermissions + .FirstOrDefaultAsync(p => p.UserId == userId, token) + .ConfigureAwait(false); + + if (permissions == null) + throw new InvalidOperationException("User permissions not found."); + + permissions.CanViewLibrary = dto.CanViewLibrary; + permissions.CanRequestSeries = dto.CanRequestSeries; + permissions.CanAddSeries = dto.CanAddSeries; + permissions.CanEditSeries = dto.CanEditSeries; + permissions.CanDeleteSeries = dto.CanDeleteSeries; + permissions.CanManageDownloads = dto.CanManageDownloads; + permissions.CanViewQueue = dto.CanViewQueue; + permissions.CanBrowseSources = dto.CanBrowseSources; + permissions.CanViewNSFW = dto.CanViewNSFW; + permissions.CanManageRequests = dto.CanManageRequests; + permissions.CanManageJobs = dto.CanManageJobs; + permissions.CanViewStatistics = dto.CanViewStatistics; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task ApplyPresetAsync(Guid userId, Guid presetId, CancellationToken token = default) + { + var preset = await _db.PermissionPresets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == presetId, token) + .ConfigureAwait(false); + + if (preset == null) + throw new InvalidOperationException("Permission preset not found."); + + var permissions = await _db.UserPermissions + .FirstOrDefaultAsync(p => p.UserId == userId, token) + .ConfigureAwait(false); + + if (permissions == null) + throw new InvalidOperationException("User permissions not found."); + + ApplyPresetToPermission(preset, permissions); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task BulkApplyPresetAsync(Guid[] userIds, Guid presetId, CancellationToken token = default) + { + var preset = await _db.PermissionPresets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == presetId, token) + .ConfigureAwait(false); + + if (preset == null) + throw new InvalidOperationException("Permission preset not found."); + + var permissions = await _db.UserPermissions + .Where(p => userIds.Contains(p.UserId)) + .ToListAsync(token) + .ConfigureAwait(false); + + foreach (var perm in permissions) + { + ApplyPresetToPermission(preset, perm); + } + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task GetDefaultPresetAsync(CancellationToken token = default) + { + var preset = await _db.PermissionPresets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.IsDefault, token) + .ConfigureAwait(false); + + if (preset == null) + return null; + + return PermissionPresetService.MapToDto(preset); + } + + private static void ApplyPresetToPermission(PermissionPresetEntity preset, UserPermissionEntity permissions) + { + permissions.CanViewLibrary = preset.CanViewLibrary; + permissions.CanRequestSeries = preset.CanRequestSeries; + permissions.CanAddSeries = preset.CanAddSeries; + permissions.CanEditSeries = preset.CanEditSeries; + permissions.CanDeleteSeries = preset.CanDeleteSeries; + permissions.CanManageDownloads = preset.CanManageDownloads; + permissions.CanViewQueue = preset.CanViewQueue; + permissions.CanBrowseSources = preset.CanBrowseSources; + permissions.CanViewNSFW = preset.CanViewNSFW; + permissions.CanManageRequests = preset.CanManageRequests; + permissions.CanManageJobs = preset.CanManageJobs; + permissions.CanViewStatistics = preset.CanViewStatistics; + } + + public static PermissionDto MapToDto(UserPermissionEntity entity) + { + return new PermissionDto + { + CanViewLibrary = entity.CanViewLibrary, + CanRequestSeries = entity.CanRequestSeries, + CanAddSeries = entity.CanAddSeries, + CanEditSeries = entity.CanEditSeries, + CanDeleteSeries = entity.CanDeleteSeries, + CanManageDownloads = entity.CanManageDownloads, + CanViewQueue = entity.CanViewQueue, + CanBrowseSources = entity.CanBrowseSources, + CanViewNSFW = entity.CanViewNSFW, + CanManageRequests = entity.CanManageRequests, + CanManageJobs = entity.CanManageJobs, + CanViewStatistics = entity.CanViewStatistics + }; + } + } +} diff --git a/KaizokuBackend/Services/Auth/SetupGate.cs b/KaizokuBackend/Services/Auth/SetupGate.cs new file mode 100644 index 00000000..25fd4516 --- /dev/null +++ b/KaizokuBackend/Services/Auth/SetupGate.cs @@ -0,0 +1,12 @@ +namespace KaizokuBackend.Services.Auth +{ + /// + /// Process-wide lock serializing first-user creation across all entry points + /// (POST /api/auth/setup and POST /api/users/first) so the "no users exist" + /// check-then-create cannot race between the two endpoints. + /// + internal static class SetupGate + { + internal static readonly System.Threading.SemaphoreSlim Lock = new(1, 1); + } +} diff --git a/KaizokuBackend/Services/Auth/UserInviteService.cs b/KaizokuBackend/Services/Auth/UserInviteService.cs new file mode 100644 index 00000000..57fd7ad8 --- /dev/null +++ b/KaizokuBackend/Services/Auth/UserInviteService.cs @@ -0,0 +1,47 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Dto.Auth; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Services.Auth +{ + public class UserInviteService + { + private readonly UserService _userService; + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public UserInviteService(UserService userService, AppDbContext db, ILogger logger) + { + _userService = userService; + _db = db; + _logger = logger; + } + + public async Task CreateInviteAsync(Guid userId, CancellationToken token = default) + { + var (rawToken, expiresAt) = await _userService.GenerateInviteTokenAsync(userId, token).ConfigureAwait(false); + + var domainSetting = await _db.Settings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Name == "ExternalDomain", token) + .ConfigureAwait(false); + + var domain = (domainSetting?.Value ?? string.Empty).Trim(); + string url; + if (domain.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || domain.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + url = $"{domain.TrimEnd('/')}/set-password?token={rawToken}"; + else + url = $"/set-password?token={rawToken}"; + + var message = $"You've been invited to Kaizoku. Set your password here: {url} (expires {expiresAt:u})."; + + return new GenerateInviteResponseDto + { + Token = rawToken, + Url = url, + ExpiresAt = expiresAt, + Message = message + }; + } + } +} diff --git a/KaizokuBackend/Services/Auth/UserPreferencesService.cs b/KaizokuBackend/Services/Auth/UserPreferencesService.cs new file mode 100644 index 00000000..078b5e4d --- /dev/null +++ b/KaizokuBackend/Services/Auth/UserPreferencesService.cs @@ -0,0 +1,70 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto.Auth; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Services.Auth +{ + public class UserPreferencesService + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public UserPreferencesService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task GetAsync(Guid userId, CancellationToken token = default) + { + var prefs = await _db.UserPreferences + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == userId, token) + .ConfigureAwait(false); + + if (prefs == null) + { + // Create default preferences + prefs = new UserPreferencesEntity { UserId = userId }; + _db.UserPreferences.Add(prefs); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + return MapToDto(prefs); + } + + public async Task UpdateAsync(Guid userId, UpdatePreferencesDto dto, CancellationToken token = default) + { + var prefs = await _db.UserPreferences + .FirstOrDefaultAsync(p => p.UserId == userId, token) + .ConfigureAwait(false); + + if (prefs == null) + { + prefs = new UserPreferencesEntity { UserId = userId }; + _db.UserPreferences.Add(prefs); + } + + // Partial update: omitted properties keep their stored (or default) values. + if (dto.Theme != null) prefs.Theme = dto.Theme; + if (dto.DefaultLanguage != null) prefs.DefaultLanguage = dto.DefaultLanguage; + if (dto.CardSize != null) prefs.CardSize = dto.CardSize; + if (dto.NsfwVisibility != null) prefs.NsfwVisibility = dto.NsfwVisibility.Value; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + return MapToDto(prefs); + } + + private static UserPreferencesDto MapToDto(UserPreferencesEntity entity) + { + return new UserPreferencesDto + { + Theme = entity.Theme, + DefaultLanguage = entity.DefaultLanguage, + CardSize = entity.CardSize, + NsfwVisibility = entity.NsfwVisibility + }; + } + } +} diff --git a/KaizokuBackend/Services/Auth/UserService.cs b/KaizokuBackend/Services/Auth/UserService.cs new file mode 100644 index 00000000..741105c4 --- /dev/null +++ b/KaizokuBackend/Services/Auth/UserService.cs @@ -0,0 +1,600 @@ +using KaizokuBackend.Authorization; +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Models.Enums; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; + +namespace KaizokuBackend.Services.Auth +{ + public class UserService + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + private readonly OpdsPathGenerator _opdsPathGenerator; + + public UserService(AppDbContext db, ILogger logger, OpdsPathGenerator opdsPathGenerator) + { + _db = db; + _logger = logger; + _opdsPathGenerator = opdsPathGenerator; + } + + public async Task> GetAllAsync(CancellationToken token = default) + { + var users = await _db.Users + .AsNoTracking() + .Include(u => u.Permissions) + .OrderBy(u => u.Username) + .ToListAsync(token) + .ConfigureAwait(false); + + return users.Select(MapToDetailDto).ToList(); + } + + public async Task GetByIdAsync(Guid id, CancellationToken token = default) + { + var user = await _db.Users + .AsNoTracking() + .Include(u => u.Permissions) + .Include(u => u.Preferences) + .FirstOrDefaultAsync(u => u.Id == id, token) + .ConfigureAwait(false); + + return user != null ? MapToDetailDto(user) : null; + } + + public async Task GetByUsernameAsync(string username, CancellationToken token = default) + { + var user = await _db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Username == username, token) + .ConfigureAwait(false); + + return user != null ? AuthService.MapUserDto(user) : null; + } + + /// + /// Looks up a user by email address. Retained for backfill/transition only. + /// + [Obsolete("Email lookup is retained for backfill/transition. Use GetByUsernameAsync instead.")] + public async Task GetByEmailAsync(string email, CancellationToken token = default) + { + var user = await _db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), token) + .ConfigureAwait(false); + + return user != null ? AuthService.MapUserDto(user) : null; + } + + public async Task CreateAsync(CreateUserDto dto, CancellationToken token = default) + { + var existing = await _db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Username == dto.Username, token) + .ConfigureAwait(false); + + if (existing != null) + throw new InvalidOperationException("Username is already taken."); + + string? passwordHash = null; + string? salt = null; + + if (!string.IsNullOrWhiteSpace(dto.Password)) + { + var passwordError = PasswordPolicy.Validate(dto.Password); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + salt = GenerateSalt(); + passwordHash = HashPassword(dto.Password, salt); + } + + var opdsPath = await _opdsPathGenerator.GenerateUniqueAsync(token).ConfigureAwait(false); + var user = new UserEntity + { + Id = Guid.NewGuid(), + Username = dto.Username.Trim(), + DisplayName = string.IsNullOrWhiteSpace(dto.DisplayName) ? dto.Username : dto.DisplayName.Trim(), + PasswordHash = passwordHash, + Salt = salt, + Role = dto.Role, + Level = dto.Role == UserRole.Admin ? UserLevel.Admin : UserLevel.User, + OpdsPath = opdsPath, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsActive = true + }; + + _db.Users.Add(user); + + var permissions = new UserPermissionEntity { UserId = user.Id }; + + // Preset (when chosen) forms the permission base; an explicit Permissions + // object below overrides it, and the Admin role overrides everything. + if (dto.PermissionPresetId != null) + { + var preset = await _db.PermissionPresets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == dto.PermissionPresetId.Value, token) + .ConfigureAwait(false); + + if (preset == null) + throw new InvalidOperationException("Permission preset not found."); + + permissions.CanViewLibrary = preset.CanViewLibrary; + permissions.CanRequestSeries = preset.CanRequestSeries; + permissions.CanAddSeries = preset.CanAddSeries; + permissions.CanEditSeries = preset.CanEditSeries; + permissions.CanDeleteSeries = preset.CanDeleteSeries; + permissions.CanManageDownloads = preset.CanManageDownloads; + permissions.CanViewQueue = preset.CanViewQueue; + permissions.CanBrowseSources = preset.CanBrowseSources; + permissions.CanViewNSFW = preset.CanViewNSFW; + permissions.CanManageRequests = preset.CanManageRequests; + permissions.CanManageJobs = preset.CanManageJobs; + permissions.CanViewStatistics = preset.CanViewStatistics; + } + + if (dto.Permissions != null) + { + permissions.CanViewLibrary = dto.Permissions.CanViewLibrary; + permissions.CanRequestSeries = dto.Permissions.CanRequestSeries; + permissions.CanAddSeries = dto.Permissions.CanAddSeries; + permissions.CanEditSeries = dto.Permissions.CanEditSeries; + permissions.CanDeleteSeries = dto.Permissions.CanDeleteSeries; + permissions.CanManageDownloads = dto.Permissions.CanManageDownloads; + permissions.CanViewQueue = dto.Permissions.CanViewQueue; + permissions.CanBrowseSources = dto.Permissions.CanBrowseSources; + permissions.CanViewNSFW = dto.Permissions.CanViewNSFW; + permissions.CanManageRequests = dto.Permissions.CanManageRequests; + permissions.CanManageJobs = dto.Permissions.CanManageJobs; + permissions.CanViewStatistics = dto.Permissions.CanViewStatistics; + } + + // Admin users get all permissions + if (dto.Role == UserRole.Admin) + { + permissions.CanViewLibrary = true; + permissions.CanRequestSeries = true; + permissions.CanAddSeries = true; + permissions.CanEditSeries = true; + permissions.CanDeleteSeries = true; + permissions.CanManageDownloads = true; + permissions.CanViewQueue = true; + permissions.CanBrowseSources = true; + permissions.CanViewNSFW = true; + permissions.CanManageRequests = true; + permissions.CanManageJobs = true; + permissions.CanViewStatistics = true; + } + + _db.UserPermissions.Add(permissions); + + var preferences = new UserPreferencesEntity { UserId = user.Id }; + _db.UserPreferences.Add(preferences); + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + // Invalidate bootstrap mode cache since a user now exists + BootstrapModeMiddleware.InvalidateCache(); + + user.Permissions = permissions; + return MapToDetailDto(user); + } + + public async Task UpdateAsync(Guid id, UpdateUserDto dto, CancellationToken token = default) + { + var user = await _db.Users + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Id == id, token) + .ConfigureAwait(false); + + if (user == null) + throw new InvalidOperationException("User not found."); + + if (dto.DisplayName != null) + user.DisplayName = dto.DisplayName.Trim(); + + if (dto.Role.HasValue) + { + user.Role = dto.Role.Value; + // Keep Level in sync with Role for backward-compatibility. + user.Level = dto.Role.Value == UserRole.Admin ? UserLevel.Admin : UserLevel.User; + } + + if (dto.IsActive.HasValue) + user.IsActive = dto.IsActive.Value; + + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return MapToDetailDto(user); + } + + public async Task DeleteAsync(Guid id, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id, token).ConfigureAwait(false); + if (user == null) + throw new InvalidOperationException("User not found."); + + // Delete all related data + var sessions = await _db.UserSessions.Where(s => s.UserId == id).ToListAsync(token).ConfigureAwait(false); + _db.UserSessions.RemoveRange(sessions); + + var permissions = await _db.UserPermissions.FirstOrDefaultAsync(p => p.UserId == id, token).ConfigureAwait(false); + if (permissions != null) _db.UserPermissions.Remove(permissions); + + var preferences = await _db.UserPreferences.FirstOrDefaultAsync(p => p.UserId == id, token).ConfigureAwait(false); + if (preferences != null) _db.UserPreferences.Remove(preferences); + + var requests = await _db.MangaRequests.Where(r => r.RequestedByUserId == id).ToListAsync(token).ConfigureAwait(false); + _db.MangaRequests.RemoveRange(requests); + + _db.Users.Remove(user); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + // Invalidate bootstrap cache so the system can re-enter setup mode if all users are deleted + BootstrapModeMiddleware.InvalidateCache(); + } + + public async Task DisableAsync(Guid id, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id, token).ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + user.IsActive = false; + user.UpdatedAt = DateTime.UtcNow; + + // Revoke all sessions + var sessions = await _db.UserSessions.Where(s => s.UserId == id && !s.IsRevoked).ToListAsync(token).ConfigureAwait(false); + foreach (var session in sessions) + session.IsRevoked = true; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task EnableAsync(Guid id, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id, token).ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + user.IsActive = true; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task ResetPasswordAsync(Guid id, string newPassword, CancellationToken token = default) + { + var passwordError = PasswordPolicy.Validate(newPassword); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id, token).ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + var salt = GenerateSalt(); + user.PasswordHash = HashPassword(newPassword, salt); + user.Salt = salt; + user.UpdatedAt = DateTime.UtcNow; + + // Revoke all sessions + var sessions = await _db.UserSessions.Where(s => s.UserId == id && !s.IsRevoked).ToListAsync(token).ConfigureAwait(false); + foreach (var session in sessions) + session.IsRevoked = true; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task UpdateProfileAsync(Guid userId, UpdateUserDto dto, CancellationToken token = default) + { + // Returns the full detail DTO (incl. permissions/preferences): the frontend + // replaces its auth-context user with this response, and a permissions-less + // payload would blank out every permission-gated UI element. + var user = await _db.Users + .Include(u => u.Permissions) + .Include(u => u.Preferences) + .FirstOrDefaultAsync(u => u.Id == userId, token) + .ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + if (dto.DisplayName != null) + user.DisplayName = dto.DisplayName.Trim(); + + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + return MapToDetailDto(user); + } + + public async Task ChangePasswordAsync(Guid userId, ChangePasswordDto dto, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, token).ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + if (user.PasswordHash == null || user.Salt == null) + throw new InvalidOperationException("This account does not use password-based authentication."); + + if (!VerifyPassword(dto.CurrentPassword, user.PasswordHash!, user.Salt!)) + throw new InvalidOperationException("Current password is incorrect."); + + var passwordError = PasswordPolicy.Validate(dto.NewPassword); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + var salt = GenerateSalt(); + user.PasswordHash = HashPassword(dto.NewPassword, salt); + user.Salt = salt; + user.UpdatedAt = DateTime.UtcNow; + + // Revoke all existing sessions so stolen tokens can't be reused + var sessions = await _db.UserSessions.Where(s => s.UserId == userId && !s.IsRevoked).ToListAsync(token).ConfigureAwait(false); + foreach (var session in sessions) + session.IsRevoked = true; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + private static readonly HashSet AllowedAvatarContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/png", "image/jpeg", "image/gif", "image/webp" + }; + + /// + /// Stores an avatar as a blob from a base64-encoded string. + /// + public async Task UploadAvatarAsync(Guid userId, string base64Data, string contentType, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, token).ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + if (string.IsNullOrWhiteSpace(contentType) || !AllowedAvatarContentTypes.Contains(contentType)) + throw new InvalidOperationException("Invalid content type. Only image/png, image/jpeg, image/gif, image/webp are allowed."); + + byte[] bytes; + try + { + bytes = Convert.FromBase64String(base64Data); + } + catch (FormatException) + { + throw new InvalidOperationException("Avatar data is not valid base64."); + } + + user.AvatarBlob = bytes; + user.AvatarContentType = contentType; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return $"/api/users/avatar/{userId}"; + } + + /// + /// Stores an avatar as a blob from an upload. + /// + public async Task UploadAvatarAsync(Guid userId, IFormFile file, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, token).ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + var contentType = file.ContentType?.ToLowerInvariant() ?? string.Empty; + if (string.IsNullOrEmpty(contentType) || !AllowedAvatarContentTypes.Contains(contentType)) + throw new InvalidOperationException("Invalid content type. Only image/png, image/jpeg, image/gif, image/webp are allowed."); + + using var ms = new MemoryStream(); + await file.CopyToAsync(ms, token).ConfigureAwait(false); + + user.AvatarBlob = ms.ToArray(); + user.AvatarContentType = contentType; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return $"/api/users/avatar/{userId}"; + } + + public async Task DeleteAvatarAsync(Guid userId, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, token).ConfigureAwait(false); + if (user == null) throw new InvalidOperationException("User not found."); + + user.AvatarBlob = null; + user.AvatarContentType = null; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + /// + /// Returns the avatar image bytes and content type for a user, or null if none is set. + /// + public async Task<(byte[] Bytes, string ContentType)?> GetAvatarBlobAsync(Guid userId, CancellationToken token = default) + { + var user = await _db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == userId, token) + .ConfigureAwait(false); + + if (user?.AvatarBlob == null || user.AvatarBlob.Length == 0) + return null; + + return (user.AvatarBlob, user.AvatarContentType ?? "application/octet-stream"); + } + + public async Task AnyUsersExistAsync(CancellationToken token = default) + { + return await _db.Users.AnyAsync(token).ConfigureAwait(false); + } + + public async Task<(string token, DateTime expiresAt)> GenerateInviteTokenAsync(Guid userId, CancellationToken token = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, token).ConfigureAwait(false); + if (user == null) + throw new InvalidOperationException("User not found."); + + var randomBytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randomBytes); + } + + var rawToken = Convert.ToBase64String(randomBytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + + var expiresAt = DateTime.UtcNow.AddDays(7); + // Store only a SHA-256 digest: a leaked database copy must not be enough to + // take over an account via /api/auth/set-password during the 7-day window. + user.PasswordSetToken = HashInviteToken(rawToken); + user.PasswordSetTokenExpiresAt = expiresAt; + user.UpdatedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return (rawToken, expiresAt); + } + + public async Task SetPasswordWithTokenAsync(string token, string newPassword, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Invalid or expired token."); + + var tokenHash = HashInviteToken(token); + var user = await _db.Users.FirstOrDefaultAsync(u => u.PasswordSetToken == tokenHash && u.IsActive, ct).ConfigureAwait(false); + + if (user == null || user.PasswordSetTokenExpiresAt == null || user.PasswordSetTokenExpiresAt < DateTime.UtcNow) + throw new InvalidOperationException("Invalid or expired token."); + + var passwordError = PasswordPolicy.Validate(newPassword); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + var salt = GenerateSalt(); + user.PasswordHash = HashPassword(newPassword, salt); + user.Salt = salt; + user.PasswordSetToken = null; + user.PasswordSetTokenExpiresAt = null; + user.UpdatedAt = DateTime.UtcNow; + + var sessions = await _db.UserSessions.Where(s => s.UserId == user.Id && !s.IsRevoked).ToListAsync(ct).ConfigureAwait(false); + foreach (var session in sessions) + session.IsRevoked = true; + + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + return AuthService.MapUserDto(user); + } + + public async Task ClaimUserAsync(Guid userId, string password, CancellationToken ct = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct).ConfigureAwait(false); + if (user == null) + throw new InvalidOperationException("User not found."); + + if (user.PasswordHash != null) + throw new InvalidOperationException("This profile is already protected by a password."); + + var passwordError = PasswordPolicy.Validate(password); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + var salt = GenerateSalt(); + user.PasswordHash = HashPassword(password, salt); + user.Salt = salt; + user.UpdatedAt = DateTime.UtcNow; + + var sessions = await _db.UserSessions.Where(s => s.UserId == userId && !s.IsRevoked).ToListAsync(ct).ConfigureAwait(false); + foreach (var session in sessions) session.IsRevoked = true; + + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + return AuthService.MapUserDto(user); + } + + public async Task AnyAdminHasPasswordAsync(CancellationToken token = default) + { + return await _db.Users + .AsNoTracking() + .AnyAsync(u => u.IsActive && u.Role == UserRole.Admin && u.PasswordHash != null, token) + .ConfigureAwait(false); + } + + private static string GenerateSalt() + { + var saltBytes = new byte[16]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(saltBytes); + } + return Convert.ToBase64String(saltBytes); + } + + private static string HashPassword(string password, string salt) + { + return BCrypt.Net.BCrypt.HashPassword(password + salt, workFactor: 12); + } + + private static bool VerifyPassword(string password, string hash, string salt) + { + return BCrypt.Net.BCrypt.Verify(password + salt, hash); + } + + /// Verifies a plain-text password against a user's stored hash. False for passwordless users. + internal static bool VerifyUserPassword(UserEntity user, string password) + { + return user.PasswordHash != null && user.Salt != null && + VerifyPassword(password, user.PasswordHash, user.Salt); + } + + private static string HashInviteToken(string rawToken) + { + return Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rawToken))); + } + + private static UserDetailDto MapToDetailDto(UserEntity user) + { + return new UserDetailDto + { + Id = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Role = user.Role, + Level = ResolveLevel(user), + OpdsPath = user.OpdsPath, + AvatarBase64 = user.AvatarBlob != null && user.AvatarBlob.Length > 0 + ? Convert.ToBase64String(user.AvatarBlob) + : null, + AvatarContentType = user.AvatarContentType, + HasPassword = user.PasswordHash != null, + CreatedAt = user.CreatedAt, + UpdatedAt = user.UpdatedAt, + LastLoginAt = user.LastLoginAt, + IsActive = user.IsActive, + Permissions = user.Permissions != null ? PermissionService.MapToDto(user.Permissions) : new PermissionDto(), + Preferences = user.Preferences != null ? new UserPreferencesDto + { + Theme = user.Preferences.Theme, + DefaultLanguage = user.Preferences.DefaultLanguage, + CardSize = user.Preferences.CardSize, + NsfwVisibility = user.Preferences.NsfwVisibility + } : new UserPreferencesDto() + }; + } + + /// + /// Returns the effective Level for a user row. Shared by and + /// so the Role→Level fallback lives in exactly one place. + /// + /// For legacy rows where Level was not yet backfilled, derives from Role: + /// Role.Admin (0) → Level.Admin (2) + /// Role.User (1) → Level.User (0) — same as the column default, no change + /// + public static UserLevel ResolveLevel(UserEntity user) + { + if (user.Level != UserLevel.User) + return user.Level; + + // Legacy row: Level=0 (User) may be correct, but for admins it should be 2. + return user.Role == UserRole.Admin ? UserLevel.Admin : UserLevel.User; + } + } +} diff --git a/KaizokuBackend/Services/Background/StartupHostedService.cs b/KaizokuBackend/Services/Background/StartupHostedService.cs index 5df9baf3..72a24965 100644 --- a/KaizokuBackend/Services/Background/StartupHostedService.cs +++ b/KaizokuBackend/Services/Background/StartupHostedService.cs @@ -3,35 +3,39 @@ using KaizokuBackend.Migration; using KaizokuBackend.Models.Dto; using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Auth; using KaizokuBackend.Services.Bridge; using KaizokuBackend.Services.Helpers; using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Providers; using KaizokuBackend.Services.Settings; +using KaizokuBackend.Utils; using Microsoft.EntityFrameworkCore; using Mihon.ExtensionsBridge.Core.Utilities; using Mihon.ExtensionsBridge.Models.Abstractions; using System.ComponentModel; +using System.Data; +using System.Text.Json; +using System.Text.Json.Nodes; namespace KaizokuBackend.Services.Background { public class StartupHostedService : IHostedService, IDisposable { - private readonly NouisanceFixer20ExtraLarge _fixes; private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; + private readonly IConfiguration _config; private readonly List _workerTasks = new(); private CancellationTokenSource? _workerCts; private bool _disposed = false; - public StartupHostedService(ILogger logger, + public StartupHostedService(ILogger logger, IServiceScopeFactory scopeFactory, - NouisanceFixer20ExtraLarge fixes, IConfiguration config) { _logger = logger; _scopeFactory = scopeFactory; - _fixes = fixes; + _config = config; } public void Dispose() @@ -109,10 +113,27 @@ public async Task StartAsync(CancellationToken cancellationToken) settingsService.SetThreadSettings(settings); await settingsService.SetTimesSettingsAsync(settings, cancellationToken).ConfigureAwait(false); AppDbContext db = scope.ServiceProvider.GetRequiredService(); + var fixes = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + await EnsureAuthTablesAsync(db, cancellationToken).ConfigureAwait(false); + await ResetFirstAdminPasswordIfRequestedAsync(db, scope, settings, cancellationToken).ConfigureAwait(false); await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", cancellationToken).ConfigureAwait(false); //await db.Database.ExecuteSqlRawAsync("PRAGMA busy_timeout=5000;", cancellationToken).ConfigureAwait(false); - await _fixes.FixThumbnailsOfSeriesWithMissingThumbnailsAsync(cancellationToken).ConfigureAwait(false); + await fixes.FixThumbnailsOfSeriesWithMissingThumbnailsAsync(cancellationToken).ConfigureAwait(false); + + // Retag historical Browse-tab rows that were written before per-title + // language detection. This lets the user's PreferredLanguages filter + // immediately hide unwanted scripts from multi-language sources. + try + { + int retagged = await fixes.BackfillLatestSeriesLanguagesAsync(cancellationToken).ConfigureAwait(false); + if (retagged > 0) + _logger.LogInformation("Backfilled language tags on {Count} cloud-latest rows.", retagged); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Latest-series language backfill failed; Browse filter may be incomplete until next source refresh."); + } IHostApplicationLifetime lifetime = scope.ServiceProvider.GetRequiredService(); JobManagementService jobManagement = scope.ServiceProvider.GetRequiredService(); @@ -120,9 +141,17 @@ public async Task StartAsync(CancellationToken cancellationToken) bool save = await CheckStorageStatusAsync(db, settings, lifetime, cancellationToken).ConfigureAwait(false); if (save) await settingsService.SaveSettingsAsync(settings, true, cancellationToken).ConfigureAwait(false); - // Cache providers + // Cache providers — wrapped in try-catch so broken extensions don't prevent startup _logger.LogInformation("Syncing Mihon Extensions Preferences."); - await providerCacheService.RefreshCacheAsync(false, cancellationToken).ConfigureAwait(false); + try + { + await providerCacheService.RefreshCacheAsync(false, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Provider cache refresh failed during startup. " + + "Some extensions may be unavailable. The app will continue with a degraded provider set."); + } var jobs = await jobManagement.GetRecurringJobsByTypeAsync(JobType.DailyUpdate, cancellationToken).ConfigureAwait(false); if (jobs.Count == 0) { @@ -140,6 +169,524 @@ public async Task StartAsync(CancellationToken cancellationToken) } } + /// + /// Ensures auth/multi-user tables exist and are up to date for both new and existing databases. + /// + /// New installs: tables are created via EnsureCreatedAsync earlier in the pipeline; this + /// method is a no-op for them (the table SELECT probe succeeds immediately). + /// + /// Brand-new tables (first ever start): created with the current/correct schema here. + /// + /// Existing installs (table already present but possibly old shape): each new column is + /// probed via PRAGMA table_info and added with ALTER TABLE only when absent, so this + /// method is idempotent and safe to run even after ReconcileUserSchema migration has + /// already applied the same columns. + /// + private async Task EnsureAuthTablesAsync(AppDbContext db, CancellationToken cancellationToken) + { + try + { + // ── Probe whether the Users table exists ────────────────────────────────── + var tableExists = false; + try + { + await db.Database.ExecuteSqlRawAsync( + "SELECT 1 FROM \"Users\" LIMIT 1;", cancellationToken).ConfigureAwait(false); + tableExists = true; + } + catch + { + tableExists = false; + } + + if (!tableExists) + { + _logger.LogInformation("Creating auth tables for multi-user support..."); + + // Create Users with the current/correct schema: + // - PasswordHash / Salt are nullable (no NOT NULL) + // - Email is nullable (no NOT NULL, no unique constraint) + // - Level, OpdsPath, AvatarBlob, AvatarContentType, PasswordSetToken included + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""Users"" ( + ""Id"" TEXT NOT NULL PRIMARY KEY, + ""Username"" TEXT NOT NULL COLLATE BINARY, + ""Email"" TEXT COLLATE BINARY, + ""DisplayName"" TEXT NOT NULL COLLATE BINARY, + ""PasswordHash"" TEXT COLLATE BINARY, + ""Salt"" TEXT COLLATE BINARY, + ""Role"" INTEGER NOT NULL, + ""Level"" INTEGER NOT NULL DEFAULT 0, + ""OpdsPath"" TEXT NOT NULL DEFAULT '' COLLATE BINARY, + ""AvatarPath"" TEXT COLLATE BINARY, + ""AvatarBlob"" BLOB, + ""AvatarContentType"" TEXT COLLATE BINARY, + ""PasswordSetToken"" TEXT COLLATE BINARY, + ""PasswordSetTokenExpiresAt"" TEXT, + ""CreatedAt"" TEXT NOT NULL, + ""UpdatedAt"" TEXT NOT NULL, + ""LastLoginAt"" TEXT, + ""IsActive"" INTEGER NOT NULL + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_User_Username"" ON ""Users"" (""Username"");", cancellationToken).ConfigureAwait(false); + // IX_User_OpdsPath is created unconditionally after BackfillOpdsPathsAsync below. + + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""UserPermissions"" ( + ""UserId"" TEXT NOT NULL PRIMARY KEY, + ""CanViewLibrary"" INTEGER NOT NULL, + ""CanRequestSeries"" INTEGER NOT NULL, + ""CanAddSeries"" INTEGER NOT NULL, + ""CanEditSeries"" INTEGER NOT NULL, + ""CanDeleteSeries"" INTEGER NOT NULL, + ""CanManageDownloads"" INTEGER NOT NULL, + ""CanViewQueue"" INTEGER NOT NULL, + ""CanBrowseSources"" INTEGER NOT NULL, + ""CanViewNSFW"" INTEGER NOT NULL, + ""CanManageRequests"" INTEGER NOT NULL, + ""CanManageJobs"" INTEGER NOT NULL, + ""CanViewStatistics"" INTEGER NOT NULL, + FOREIGN KEY (""UserId"") REFERENCES ""Users"" (""Id"") ON DELETE CASCADE + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""UserSessions"" ( + ""Id"" TEXT NOT NULL PRIMARY KEY, + ""UserId"" TEXT NOT NULL, + ""RefreshToken"" TEXT NOT NULL COLLATE BINARY, + ""ExpiresAt"" TEXT NOT NULL, + ""CreatedAt"" TEXT NOT NULL, + ""IpAddress"" TEXT COLLATE BINARY, + ""UserAgent"" TEXT COLLATE BINARY, + ""IsRevoked"" INTEGER NOT NULL, + FOREIGN KEY (""UserId"") REFERENCES ""Users"" (""Id"") ON DELETE CASCADE + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_UserSession_RefreshToken"" ON ""UserSessions"" (""RefreshToken"");", cancellationToken).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_UserSession_UserId"" ON ""UserSessions"" (""UserId"");", cancellationToken).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_UserSession_UserId_IsRevoked"" ON ""UserSessions"" (""UserId"", ""IsRevoked"");", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""UserPreferences"" ( + ""UserId"" TEXT NOT NULL PRIMARY KEY, + ""Theme"" TEXT NOT NULL COLLATE BINARY, + ""DefaultLanguage"" TEXT NOT NULL COLLATE BINARY, + ""CardSize"" TEXT NOT NULL COLLATE BINARY, + ""NsfwVisibility"" INTEGER NOT NULL, + FOREIGN KEY (""UserId"") REFERENCES ""Users"" (""Id"") ON DELETE CASCADE + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""PermissionPresets"" ( + ""Id"" TEXT NOT NULL PRIMARY KEY, + ""Name"" TEXT NOT NULL COLLATE BINARY, + ""CreatedByUserId"" TEXT NOT NULL, + ""IsDefault"" INTEGER NOT NULL, + ""CanViewLibrary"" INTEGER NOT NULL, + ""CanRequestSeries"" INTEGER NOT NULL, + ""CanAddSeries"" INTEGER NOT NULL, + ""CanEditSeries"" INTEGER NOT NULL, + ""CanDeleteSeries"" INTEGER NOT NULL, + ""CanManageDownloads"" INTEGER NOT NULL, + ""CanViewQueue"" INTEGER NOT NULL, + ""CanBrowseSources"" INTEGER NOT NULL, + ""CanViewNSFW"" INTEGER NOT NULL, + ""CanManageRequests"" INTEGER NOT NULL, + ""CanManageJobs"" INTEGER NOT NULL, + ""CanViewStatistics"" INTEGER NOT NULL, + FOREIGN KEY (""CreatedByUserId"") REFERENCES ""Users"" (""Id"") ON DELETE CASCADE + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_PermissionPreset_Name"" ON ""PermissionPresets"" (""Name"");", cancellationToken).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_PermissionPreset_IsDefault"" ON ""PermissionPresets"" (""IsDefault"");", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""InviteLinks"" ( + ""Id"" TEXT NOT NULL PRIMARY KEY, + ""Code"" TEXT NOT NULL COLLATE BINARY, + ""CreatedByUserId"" TEXT NOT NULL, + ""ExpiresAt"" TEXT NOT NULL, + ""MaxUses"" INTEGER NOT NULL, + ""UsedCount"" INTEGER NOT NULL, + ""PermissionPresetId"" TEXT, + ""IsActive"" INTEGER NOT NULL, + FOREIGN KEY (""CreatedByUserId"") REFERENCES ""Users"" (""Id"") ON DELETE CASCADE, + FOREIGN KEY (""PermissionPresetId"") REFERENCES ""PermissionPresets"" (""Id"") ON DELETE SET NULL + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_InviteLink_Code"" ON ""InviteLinks"" (""Code"");", cancellationToken).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_InviteLink_IsActive"" ON ""InviteLinks"" (""IsActive"");", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""MangaRequests"" ( + ""Id"" TEXT NOT NULL PRIMARY KEY, + ""RequestedByUserId"" TEXT NOT NULL, + ""Title"" TEXT NOT NULL COLLATE BINARY, + ""Description"" TEXT COLLATE BINARY, + ""ThumbnailUrl"" TEXT COLLATE BINARY, + ""ProviderData"" TEXT COLLATE BINARY, + ""Status"" INTEGER NOT NULL, + ""ReviewedByUserId"" TEXT, + ""ReviewedAt"" TEXT, + ""ReviewNote"" TEXT COLLATE BINARY, + ""CreatedAt"" TEXT NOT NULL, + FOREIGN KEY (""RequestedByUserId"") REFERENCES ""Users"" (""Id"") ON DELETE CASCADE, + FOREIGN KEY (""ReviewedByUserId"") REFERENCES ""Users"" (""Id"") ON DELETE SET NULL + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_MangaRequest_Status"" ON ""MangaRequests"" (""Status"");", cancellationToken).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync(@"CREATE INDEX IF NOT EXISTS ""IX_MangaRequest_RequestedByUserId"" ON ""MangaRequests"" (""RequestedByUserId"");", cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Auth tables created successfully."); + } + else + { + // ── Existing install: upgrade Users table shape if needed ───────────── + // Guard every column with a PRAGMA table_info probe so this block is + // idempotent whether or not ReconcileUserSchema migration already ran. + _logger.LogInformation("Checking Users table for required columns..."); + + await AddColumnIfMissingAsync(db, "Users", "Level", + @"ALTER TABLE ""Users"" ADD COLUMN ""Level"" INTEGER NOT NULL DEFAULT 0;", + cancellationToken).ConfigureAwait(false); + + await AddColumnIfMissingAsync(db, "Users", "OpdsPath", + @"ALTER TABLE ""Users"" ADD COLUMN ""OpdsPath"" TEXT NOT NULL DEFAULT '';", + cancellationToken).ConfigureAwait(false); + + await AddColumnIfMissingAsync(db, "Users", "AvatarBlob", + @"ALTER TABLE ""Users"" ADD COLUMN ""AvatarBlob"" BLOB NULL;", + cancellationToken).ConfigureAwait(false); + + await AddColumnIfMissingAsync(db, "Users", "AvatarContentType", + @"ALTER TABLE ""Users"" ADD COLUMN ""AvatarContentType"" TEXT NULL;", + cancellationToken).ConfigureAwait(false); + + await AddColumnIfMissingAsync(db, "Users", "PasswordSetToken", + @"ALTER TABLE ""Users"" ADD COLUMN ""PasswordSetToken"" TEXT NULL;", + cancellationToken).ConfigureAwait(false); + + await AddColumnIfMissingAsync(db, "Users", "PasswordSetTokenExpiresAt", + @"ALTER TABLE ""Users"" ADD COLUMN ""PasswordSetTokenExpiresAt"" TEXT NULL;", + cancellationToken).ConfigureAwait(false); + + // Backfill Level from Role for rows that still carry the default 0 + // (this is safe to re-run: rows that were already backfilled will not change + // because Admin rows get Level=2 and User rows get Level=0 — same as default). + await db.Database.ExecuteSqlRawAsync(@" + UPDATE ""Users"" + SET ""Level"" = CASE ""Role"" + WHEN 0 THEN 2 + WHEN 1 THEN 0 + ELSE 0 + END + WHERE ""Level"" = 0 AND ""Role"" = 0;", + cancellationToken).ConfigureAwait(false); + + // Tables created by earlier releases declared Email/PasswordHash/Salt as + // NOT NULL (plus a unique IX_User_Email). The current model no longer sets + // Email and allows passwordless users, so every INSERT/UPDATE would hit a + // NOT NULL constraint. SQLite cannot drop NOT NULL via ALTER — rebuild the + // table once when the legacy shape is detected. Runs after the column adds + // above so the copy column list is always the full current set. + await RebuildUsersTableIfLegacyShapeAsync(db, cancellationToken).ConfigureAwait(false); + } + + // ── OpdsPath per-row backfill (C# required — SQL cannot generate word-pairs) ── + await BackfillOpdsPathsAsync(cancellationToken).ConfigureAwait(false); + + // ── OpdsPath unique index — created AFTER backfill so every row already carries + // a distinct slug. IF NOT EXISTS makes this idempotent across new + existing installs + // and re-runs. New installs also get this index from EF's EnsureCreatedAsync via the + // model definition, so the IF NOT EXISTS guard is essential. + await db.Database.ExecuteSqlRawAsync( + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_User_OpdsPath"" ON ""Users"" (""OpdsPath"");", + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating/upgrading auth tables"); + throw; + } + } + + /// + /// Lockout-recovery flag (upstream plan D.8): when Authentication:ResetFirstAdminPassword + /// is true, clears the first admin's password (the profile becomes passwordless), revokes + /// that admin's refresh-token sessions, and — if password authentication is currently + /// enforced — disables it, because a passwordless admin cannot log in through the JWT flow. + /// The instance falls back to profile-picker mode where the admin can sign in and set a + /// new password from Settings. The flag is rewritten to false in appsettings.json so the + /// reset does not repeat on the next start. + /// + private async Task ResetFirstAdminPasswordIfRequestedAsync(AppDbContext db, IServiceScope scope, SettingsDto settings, CancellationToken cancellationToken) + { + if (!_config.GetValue("Authentication:ResetFirstAdminPassword")) + return; + + var admin = await db.Users + .Where(u => u.IsActive && (u.Level == UserLevel.Admin || u.Role == UserRole.Admin)) + .OrderBy(u => u.CreatedAt) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + if (admin == null) + { + _logger.LogWarning("Authentication:ResetFirstAdminPassword is set but no active admin user exists; nothing to reset."); + } + else + { + admin.PasswordHash = null; + admin.Salt = null; + admin.PasswordSetToken = null; + admin.PasswordSetTokenExpiresAt = null; + admin.UpdatedAt = DateTime.UtcNow; + + var sessions = await db.UserSessions + .Where(s => s.UserId == admin.Id) + .ToListAsync(cancellationToken).ConfigureAwait(false); + db.UserSessions.RemoveRange(sessions); + + var authRow = await db.Settings + .FirstOrDefaultAsync(s => s.Name == "AuthenticationEnabled", cancellationToken) + .ConfigureAwait(false); + if (authRow != null && bool.TryParse(authRow.Value, out var authEnabled) && authEnabled) + { + authRow.Value = "false"; + scope.ServiceProvider.GetRequiredService().Update(false); + _logger.LogWarning("Password authentication has been disabled as part of the admin password reset; the instance is back in profile-picker mode."); + } + // The settings DTO loaded earlier in StartAsync is saved again later in the + // startup sequence (SaveSettingsAsync after CheckStorageStatusAsync). Without + // this in-memory update that save would write the stale 'true' back to the DB + // and re-enable auth with a passwordless admin — the exact lockout this flag + // exists to recover from. + settings.AuthenticationEnabled = false; + + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogWarning("Admin password for '{Username}' has been reset via Authentication:ResetFirstAdminPassword. " + + "Select the profile and set a new password from Settings.", admin.Username); + } + + await ClearResetFlagInAppSettingsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Rewrites Authentication:ResetFirstAdminPassword to false in the data-folder + /// appsettings.json so the one-shot reset does not run again. If the flag was supplied + /// via an environment variable instead, it cannot be cleared from here — the warning + /// repeats on every start until the variable is removed, which is intentional. + /// + private async Task ClearResetFlagInAppSettingsAsync(CancellationToken cancellationToken) + { + try + { + var appSettingsPath = Path.Combine(EnvironmentSetup.Path, "appsettings.json"); + if (!File.Exists(appSettingsPath)) + return; + + var content = await File.ReadAllTextAsync(appSettingsPath, cancellationToken).ConfigureAwait(false); + if (JsonNode.Parse(content) is not JsonObject json) + return; + + // When the section or flag is absent the value came from an env var or another + // config source; nothing to rewrite here (see remark above). + if (json["Authentication"] is not JsonObject authSection) + return; + + if (authSection["ResetFirstAdminPassword"]?.GetValue() != true) + return; + + authSection["ResetFirstAdminPassword"] = false; + await File.WriteAllTextAsync(appSettingsPath, + json.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), + cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Authentication:ResetFirstAdminPassword rewritten to false in appsettings.json."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not rewrite Authentication:ResetFirstAdminPassword in appsettings.json; the reset will run again on next start."); + } + } + + /// + /// Detects the legacy Users table shape (NOT NULL on Email/PasswordHash/Salt, written by + /// earlier releases) and rebuilds the table with the current relaxed schema using the + /// standard SQLite copy-and-rename procedure. Foreign keys are disabled for the duration: + /// the child tables (UserPermissions, UserSessions, ...) declare ON DELETE CASCADE, so + /// dropping the old parent with FKs on would wipe their rows. The legacy unique + /// IX_User_Email disappears with the old table by design (Email is no longer used). + /// + private async Task RebuildUsersTableIfLegacyShapeAsync(AppDbContext db, CancellationToken cancellationToken) + { + // Probe NOT NULL flags via PRAGMA table_info. + var notNullColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + var conn = db.Database.GetDbConnection(); + bool wasOpen = conn.State == System.Data.ConnectionState.Open; + if (!wasOpen) + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "PRAGMA table_info(\"Users\");"; + using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (reader.GetInt32(reader.GetOrdinal("notnull")) == 1) + notNullColumns.Add(reader.GetString(reader.GetOrdinal("name"))); + } + } + finally + { + if (!wasOpen) + await conn.CloseAsync().ConfigureAwait(false); + } + + if (!notNullColumns.Contains("Email") && + !notNullColumns.Contains("PasswordHash") && + !notNullColumns.Contains("Salt")) + return; + + _logger.LogInformation("Rebuilding Users table to drop legacy NOT NULL constraints (Email/PasswordHash/Salt)..."); + + // Pin a single connection so the foreign_keys PRAGMA applies to every statement. + await db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + try + { + await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys=OFF;", cancellationToken).ConfigureAwait(false); + using (var tx = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false)) + { + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE ""Users_rebuild"" ( + ""Id"" TEXT NOT NULL PRIMARY KEY, + ""Username"" TEXT NOT NULL COLLATE BINARY, + ""Email"" TEXT COLLATE BINARY, + ""DisplayName"" TEXT NOT NULL COLLATE BINARY, + ""PasswordHash"" TEXT COLLATE BINARY, + ""Salt"" TEXT COLLATE BINARY, + ""Role"" INTEGER NOT NULL, + ""Level"" INTEGER NOT NULL DEFAULT 0, + ""OpdsPath"" TEXT NOT NULL DEFAULT '' COLLATE BINARY, + ""AvatarPath"" TEXT COLLATE BINARY, + ""AvatarBlob"" BLOB, + ""AvatarContentType"" TEXT COLLATE BINARY, + ""PasswordSetToken"" TEXT COLLATE BINARY, + ""PasswordSetTokenExpiresAt"" TEXT, + ""CreatedAt"" TEXT NOT NULL, + ""UpdatedAt"" TEXT NOT NULL, + ""LastLoginAt"" TEXT, + ""IsActive"" INTEGER NOT NULL + );", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@" + INSERT INTO ""Users_rebuild"" + (""Id"", ""Username"", ""Email"", ""DisplayName"", ""PasswordHash"", ""Salt"", + ""Role"", ""Level"", ""OpdsPath"", ""AvatarPath"", ""AvatarBlob"", ""AvatarContentType"", + ""PasswordSetToken"", ""PasswordSetTokenExpiresAt"", ""CreatedAt"", ""UpdatedAt"", + ""LastLoginAt"", ""IsActive"") + SELECT ""Id"", ""Username"", ""Email"", ""DisplayName"", ""PasswordHash"", ""Salt"", + ""Role"", ""Level"", ""OpdsPath"", ""AvatarPath"", ""AvatarBlob"", ""AvatarContentType"", + ""PasswordSetToken"", ""PasswordSetTokenExpiresAt"", ""CreatedAt"", ""UpdatedAt"", + ""LastLoginAt"", ""IsActive"" + FROM ""Users"";", cancellationToken).ConfigureAwait(false); + + await db.Database.ExecuteSqlRawAsync(@"DROP TABLE ""Users"";", cancellationToken).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync(@"ALTER TABLE ""Users_rebuild"" RENAME TO ""Users"";", cancellationToken).ConfigureAwait(false); + await db.Database.ExecuteSqlRawAsync(@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_User_Username"" ON ""Users"" (""Username"");", cancellationToken).ConfigureAwait(false); + // IX_User_OpdsPath is recreated unconditionally after BackfillOpdsPathsAsync. + + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + } + await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys=ON;", cancellationToken).ConfigureAwait(false); + } + finally + { + await db.Database.CloseConnectionAsync().ConfigureAwait(false); + } + + _logger.LogInformation("Users table rebuild complete."); + } + + /// + /// Checks PRAGMA table_info for the given column; executes + /// only when the column is absent. This guard ensures AddColumn is never run twice, + /// whether by EnsureAuthTablesAsync or by the ReconcileUserSchema EF migration. + /// + private async Task AddColumnIfMissingAsync(AppDbContext db, string tableName, string columnName, + string alterSql, CancellationToken cancellationToken) + { + var columns = new List(); + var conn = db.Database.GetDbConnection(); + bool wasOpen = conn.State == System.Data.ConnectionState.Open; + if (!wasOpen) + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"PRAGMA table_info(\"{tableName.Replace("\"", "\"\"")}\");"; + using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var name = reader.GetString(reader.GetOrdinal("name")); + columns.Add(name); + } + } + finally + { + if (!wasOpen) + await conn.CloseAsync().ConfigureAwait(false); + } + + if (!columns.Any(c => string.Equals(c, columnName, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogInformation("Adding column {Column} to {Table}.", columnName, tableName); + await db.Database.ExecuteSqlRawAsync(alterSql, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// For each user row whose OpdsPath is null or empty, generates a unique word-pair slug + /// via and persists it. Uses a fresh DI scope so the + /// generator gets its own short-lived AppDbContext that does not conflict with the + /// caller's context. + /// + private async Task BackfillOpdsPathsAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var innerDb = scope.ServiceProvider.GetRequiredService(); + var generator = scope.ServiceProvider.GetRequiredService(); + + var usersNeedingOpdsPath = await innerDb.Users + .Where(u => u.OpdsPath == null || u.OpdsPath == string.Empty) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (usersNeedingOpdsPath.Count == 0) + return; + + _logger.LogInformation("Backfilling OpdsPath for {Count} user(s).", usersNeedingOpdsPath.Count); + + // Track paths assigned within this batch so two users in the same SaveChangesAsync + // call cannot receive the same slug. GenerateUniqueAsync only probes committed DB rows, + // so without this set a second user in the batch could receive a path already given + // to the first user whose write has not been committed yet. + var assignedThisBatch = new HashSet(StringComparer.Ordinal); + + foreach (var user in usersNeedingOpdsPath) + { + string candidate; + do + { + candidate = await generator.GenerateUniqueAsync(cancellationToken).ConfigureAwait(false); + } while (!assignedThisBatch.Add(candidate)); + + user.OpdsPath = candidate; + } + + await innerDb.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("OpdsPath backfill complete."); + } + private Task StartWorker(CancellationToken workerToken) where TWorker : IWorkerService { var task = Task.Run(async () => diff --git a/KaizokuBackend/Services/Downloads/DownloadCommandService.cs b/KaizokuBackend/Services/Downloads/DownloadCommandService.cs index 1fff0ce3..c0e41d4f 100644 --- a/KaizokuBackend/Services/Downloads/DownloadCommandService.cs +++ b/KaizokuBackend/Services/Downloads/DownloadCommandService.cs @@ -142,11 +142,8 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo try { - lock (_lock) - { - if (!Directory.Exists(_tempFolder)) - Directory.CreateDirectory(_tempFolder); - } + // Directory.CreateDirectory is already thread-safe (no-ops if exists) + Directory.CreateDirectory(_tempFolder); if (File.Exists(tempZipPath)) File.Delete(tempZipPath); @@ -356,6 +353,71 @@ public async Task ManageErrorDownloadAsync(Guid id, ErrorDownloadAction action, } } + /// + /// Removes a single download from the queue (non-running only) + /// + public async Task RemoveDownloadAsync(Guid id, CancellationToken token = default) + { + var entity = await _db.Queues.FirstOrDefaultAsync(a => a.Id == id && a.JobType == JobType.Download && a.Status != QueueStatus.Running, token).ConfigureAwait(false); + if (entity == null) + return false; + + _db.Queues.Remove(entity); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + _logger.LogInformation("Removed download {Id} with status {Status}", id, entity.Status); + return true; + } + + /// + /// Clears all downloads with a given status (non-running only) + /// + public async Task ClearDownloadsByStatusAsync(QueueStatus status, CancellationToken token = default) + { + if (status == QueueStatus.Running) + return 0; + + var downloads = await _db.Queues + .Where(a => a.JobType == JobType.Download && a.Status == status) + .ToListAsync(token).ConfigureAwait(false); + + int count = downloads.Count; + if (count > 0) + { + _db.Queues.RemoveRange(downloads); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + _logger.LogInformation("Cleared {Count} downloads with status {Status}", count, status); + } + return count; + } + + /// + /// Retries all failed downloads by resetting their retry count and rescheduling + /// + public async Task RetryAllFailedDownloadsAsync(CancellationToken token = default) + { + var failedDownloads = await _db.Queues + .Where(a => a.JobType == JobType.Download && a.Status == QueueStatus.Failed) + .ToListAsync(token).ConfigureAwait(false); + + int count = 0; + foreach (var d in failedDownloads) + { + if (string.IsNullOrEmpty(d.JobParameters)) + continue; + + ChapterDownload? ch = JsonSerializer.Deserialize(d.JobParameters); + if (ch == null) + continue; + + ch.Retries = 0; + await RescheduleDownloadAsync(ch, token).ConfigureAwait(false); + count++; + } + + _logger.LogInformation("Retried {Count} failed downloads", count); + return count; + } + /// /// Queues chapter downloads for a series provider /// diff --git a/KaizokuBackend/Services/Downloads/DownloadsExtensions.cs b/KaizokuBackend/Services/Downloads/DownloadsExtensions.cs index 351508b7..0b17fff0 100644 --- a/KaizokuBackend/Services/Downloads/DownloadsExtensions.cs +++ b/KaizokuBackend/Services/Downloads/DownloadsExtensions.cs @@ -28,7 +28,11 @@ public static List ToDownloads(this KaizokuBackend.Models.Datab ProviderName = sp.Provider, ComicUploadDateUTC = chapter.DateUpload.DateTime, Title = s.Title, - SeriesTitle = sp.Title, + // Prefer the series-level title for filenames since provider titles may be + // truncated by the source (e.g. "Long Title..."). The series title is the + // consolidated/user-chosen title which should always be the full version. + // Fall back to provider title only if series title is empty. + SeriesTitle = !string.IsNullOrEmpty(s.Title) ? s.Title : sp.Title, Url = chapter.RealUrl, Language = sp.Language, ThumbnailUrl = string.IsNullOrEmpty(sp.ThumbnailUrl) ? s.ThumbnailUrl : sp.ThumbnailUrl, diff --git a/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs b/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs index 156d1dd6..55478c37 100644 --- a/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs +++ b/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs @@ -89,7 +89,7 @@ public async Task UpdateTitleAndAddComicInfoAsync(Models.Database.SeriesEntity s continue; // Now check if the filename should be changed string prefix = $"[{sp.Provider}][{sp.Language}]"; - string safeName = MakeFileNameSafe(sp.Provider, sp.Scanlator, sp.Title, sp.Language, chap.Number, chap.Name, sp.ChapterCount); + string safeName = MakeFileNameSafe(sp.Provider, sp.Scanlator, series.Title, sp.Language, chap.Number, chap.Name, sp.ChapterCount); string extension = Path.GetExtension(chap.Filename) ?? ".cbz"; string newFileName = safeName + extension; if (!chap.Filename.StartsWith(prefix)) @@ -199,13 +199,36 @@ public async Task UpdateAllTitlesAndAddComicInfoAsync(ProgressReporter reporter, } /// - /// Gets a list of file names from an archive without knowing the archive type + /// Gets a list of file names from an archive without knowing the archive type. + /// Backwards-compatible wrapper around that + /// discards the success flag. Callers that need to distinguish a genuinely empty + /// archive from an unreadable one (e.g. integrity checks) should call + /// directly. /// /// Path to the archive file /// List of file names in the archive, or empty list if archive is not supported public static List GetArchiveFileNames(string archivePath) + { + return TryGetArchiveFileNames(archivePath, out _); + } + + /// + /// Gets a list of file names from an archive without knowing the archive type, and + /// reports whether the read attempt succeeded. + /// + /// Path to the archive file + /// + /// true when the archive was opened and enumerated without throwing (the + /// list may still be empty if the archive genuinely has no entries). + /// false when both opening strategies threw an exception, meaning the + /// returned list is unreliable and the file should be treated as unreadable + /// rather than as a malformed archive. + /// + /// List of file names in the archive, or empty list if archive is not supported + public static List TryGetArchiveFileNames(string archivePath, out bool readSucceeded) { var fileNames = new List(); + readSucceeded = false; if (string.IsNullOrEmpty(archivePath) || !File.Exists(archivePath)) { @@ -227,6 +250,7 @@ public static List GetArchiveFileNames(string archivePath) fileNames.Add(entry.Key); } } + readSucceeded = true; } catch { @@ -245,10 +269,14 @@ public static List GetArchiveFileNames(string archivePath) fileNames.Add(reader.Entry.Key); } } + readSucceeded = true; } catch { - // If both methods fail, return empty list + // Both opening strategies threw — likely a locked file, I/O error, + // or genuinely unsupported format. We cannot tell the difference, + // so report failure and let the caller decide. + readSucceeded = false; return new List(); } } @@ -317,7 +345,13 @@ public static ArchiveResult CheckArchive(string archivePath) { if (string.IsNullOrEmpty(archivePath) || !File.Exists(archivePath)) return ArchiveResult.NotFound; - var fileNames = GetArchiveFileNames(archivePath); + var fileNames = TryGetArchiveFileNames(archivePath, out bool readSucceeded); + // A read failure (locked file, transient I/O, unsupported container) is + // distinct from a genuinely empty archive — never let a transient failure + // be interpreted as "not an archive", which would trigger destructive + // cleanup of files that may still be perfectly valid on disk. + if (!readSucceeded) + return ArchiveResult.Unreadable; if (fileNames.Count == 0) return ArchiveResult.NotAnArchive; if (ArchiveItContainsImages(fileNames)) diff --git a/KaizokuBackend/Services/Helpers/LanguageDetector.cs b/KaizokuBackend/Services/Helpers/LanguageDetector.cs new file mode 100644 index 00000000..d5acfc5d --- /dev/null +++ b/KaizokuBackend/Services/Helpers/LanguageDetector.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Text; + +namespace KaizokuBackend.Services.Helpers +{ + /// + /// Lightweight, script-based language detector. Designed for short manga titles + /// where dominant Unicode script is a strong signal. Returns Mihon-style + /// lowercase ISO 639-1 codes (e.g. "en", "ru", "ja"). + /// + /// Note: this is a heuristic — it cannot distinguish between languages that share + /// a script (e.g. English vs. Spanish, both Latin). For those it returns "en" as + /// the safe default since most manga sources publish in English when using Latin. + /// + public static class LanguageDetector + { + /// + /// Detect the most likely language code from a title. + /// + /// Title or short text to inspect. + /// Code to return if no decisive script is found. + /// Lowercase Mihon-style language code, or . + public static string Detect(string? text, string fallback = "en") + { + if (string.IsNullOrWhiteSpace(text)) + return fallback; + + int cyrillic = 0; + int hangul = 0; + int hiraganaKatakana = 0; + int han = 0; + int thai = 0; + int arabic = 0; + int hebrew = 0; + int greek = 0; + int devanagari = 0; + int bengali = 0; + int latin = 0; + + foreach (var rune in text.EnumerateRunes()) + { + int v = rune.Value; + + if (!IsLetter(rune)) + continue; + + if (v >= 0x0400 && v <= 0x04FF) cyrillic++; + else if (v >= 0x0500 && v <= 0x052F) cyrillic++; // Cyrillic Supplement + else if ((v >= 0x1100 && v <= 0x11FF) || + (v >= 0x3130 && v <= 0x318F) || + (v >= 0xAC00 && v <= 0xD7AF)) hangul++; + else if ((v >= 0x3040 && v <= 0x309F) || + (v >= 0x30A0 && v <= 0x30FF) || + (v >= 0x31F0 && v <= 0x31FF)) hiraganaKatakana++; + else if ((v >= 0x4E00 && v <= 0x9FFF) || + (v >= 0x3400 && v <= 0x4DBF) || + (v >= 0xF900 && v <= 0xFAFF)) han++; + else if (v >= 0x0E00 && v <= 0x0E7F) thai++; + else if ((v >= 0x0600 && v <= 0x06FF) || + (v >= 0x0750 && v <= 0x077F) || + (v >= 0xFB50 && v <= 0xFDFF) || + (v >= 0xFE70 && v <= 0xFEFF)) arabic++; + else if (v >= 0x0590 && v <= 0x05FF) hebrew++; + else if ((v >= 0x0370 && v <= 0x03FF) || + (v >= 0x1F00 && v <= 0x1FFF)) greek++; + else if (v >= 0x0900 && v <= 0x097F) devanagari++; + else if (v >= 0x0980 && v <= 0x09FF) bengali++; + else if ((v >= 0x0041 && v <= 0x007A) || + (v >= 0x00C0 && v <= 0x024F)) latin++; + } + + // Asian-script titles often contain a mix; presence of hiragana/katakana + // is conclusive for Japanese even with Han characters present. + if (hiraganaKatakana > 0) return "ja"; + if (hangul > 0) return "ko"; + + // Pick the dominant script among the remaining categories. + var scores = new (string code, int count)[] + { + ("ru", cyrillic), + ("zh", han), + ("th", thai), + ("ar", arabic), + ("he", hebrew), + ("el", greek), + ("hi", devanagari), + ("bn", bengali), + ("en", latin), + }; + + int max = 0; + string winner = fallback; + foreach (var s in scores) + { + if (s.count > max) + { + max = s.count; + winner = s.code; + } + } + + return max > 0 ? winner : fallback; + } + + /// + /// Resolve the effective language for a stored entry. + /// If the explicit source language is specific (not "all" / empty), use it as-is. + /// Otherwise fall back to script detection from the title. + /// + public static string Resolve(string? sourceLanguage, string? title, string fallback = "en") + { + if (!string.IsNullOrWhiteSpace(sourceLanguage) && + !sourceLanguage.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + return sourceLanguage!; + } + return Detect(title, fallback); + } + + private static bool IsLetter(Rune rune) + { + var cat = Rune.GetUnicodeCategory(rune); + return cat == UnicodeCategory.LowercaseLetter + || cat == UnicodeCategory.UppercaseLetter + || cat == UnicodeCategory.TitlecaseLetter + || cat == UnicodeCategory.ModifierLetter + || cat == UnicodeCategory.OtherLetter; + } + } +} diff --git a/KaizokuBackend/Services/Helpers/NouisanceFixer20ExtraLarge.cs b/KaizokuBackend/Services/Helpers/NouisanceFixer20ExtraLarge.cs index bb5da614..95c91528 100644 --- a/KaizokuBackend/Services/Helpers/NouisanceFixer20ExtraLarge.cs +++ b/KaizokuBackend/Services/Helpers/NouisanceFixer20ExtraLarge.cs @@ -145,5 +145,47 @@ private static void EnsureSingleCover(SeriesProviderEntity coverProvider, IEnume provider.IsCover = provider.Id == coverProvider.Id; } } + + /// + /// One-time backfill for the Browse tab's language filter: + /// LatestSerieEntity rows written before per-title language detection store + /// the source's Language verbatim (often "all" for multi-language sources like + /// NovelCool). Re-detect the language from the title so the user's + /// PreferredLanguages filter can hide unwanted scripts. + /// + /// Number of rows whose Language was rewritten. + public async Task BackfillLatestSeriesLanguagesAsync(CancellationToken token) + { + // Process in batches to keep memory bounded on large libraries. + const int batchSize = 500; + int updated = 0; + while (!token.IsCancellationRequested) + { + List batch = await _db.LatestSeries + .Where(a => a.Language == null || a.Language == "" || a.Language == "all") + .Take(batchSize) + .ToListAsync(token) + .ConfigureAwait(false); + + if (batch.Count == 0) + break; + + foreach (LatestSerieEntity entry in batch) + { + // LanguageDetector.Detect always returns a concrete code (defaults to + // "en"), so this loop is guaranteed to make progress — the next pass + // won't re-select these rows. + entry.Language = LanguageDetector.Detect(entry.Title); + updated++; + } + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + if (batch.Count < batchSize) + break; + } + + return updated; + } } } diff --git a/KaizokuBackend/Services/Images/ThumbCacheService.cs b/KaizokuBackend/Services/Images/ThumbCacheService.cs index e7609871..ef8d1a73 100644 --- a/KaizokuBackend/Services/Images/ThumbCacheService.cs +++ b/KaizokuBackend/Services/Images/ThumbCacheService.cs @@ -30,6 +30,11 @@ public class ThumbCacheService private readonly static Dictionary _etagCache = new Dictionary(); private readonly static SemaphoreSlim _urlLock = new SemaphoreSlim(1); private readonly static SemaphoreSlim _eTagLock = new SemaphoreSlim(1); + // Per-URL semaphores prevent duplicate-row inserts when multiple concurrent + // callers pass the existence check before any of them commits. The key space + // is bounded by library size so entries are left alive for the process lifetime. + private readonly static ConcurrentDictionary _insertLocks = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); public ThumbCacheService(IOptions options, ILogger logger, @@ -49,48 +54,87 @@ IEnumerable imageProviders public async ValueTask GetEtagAsync(string key, CancellationToken token = default) { + // --- Phase 1: short lock — check in-memory cache --- + await _eTagLock.WaitAsync(token).ConfigureAwait(false); + EtagCacheEntity? cached; + try + { + _etagCache.TryGetValue(key, out cached); + } + finally + { + _eTagLock.Release(); + } + + if (cached != null) + return cached; + + // --- Phase 2: outside the lock — DB lookup --- + EtagCacheEntity? c = await _db.ETagCache.AsNoTracking() + .FirstOrDefaultAsync(e => e.Key == key, token).ConfigureAwait(false); + if (c == null) + { + _logger.LogWarning("ETag with key {key} not found in cache.", key); + return null; + } + + // --- Phase 3: short lock — write result into cache (tolerate concurrent insert) --- await _eTagLock.WaitAsync(token).ConfigureAwait(false); try { if (!_etagCache.ContainsKey(key)) - { - EtagCacheEntity? c = await _db.ETagCache.AsNoTracking().FirstOrDefaultAsync(e => e.Key == key, token).ConfigureAwait(false); - if (c == null) - { - _logger.LogWarning("ETag with key {key} not found in cache.", key); - return null; - } _etagCache[key] = c; - } - return _etagCache[key]; } finally { _eTagLock.Release(); } + + return c; } public async ValueTask GetKeyAsync(string url, CancellationToken token = default) { + if (string.IsNullOrEmpty(url)) + return string.Empty; + + // --- Phase 1: short lock — check in-memory cache --- + await _urlLock.WaitAsync(token).ConfigureAwait(false); + string? cached; + try + { + _urlCache.TryGetValue(url, out cached); + } + finally + { + _urlLock.Release(); + } + + if (cached != null) + return cached; + + // --- Phase 2: outside the lock — DB lookup / insert --- + EtagCacheEntity? c = await _db.ETagCache.AsNoTracking() + .FirstOrDefaultAsync(e => e.Url == url, token).ConfigureAwait(false); + if (c == null) + c = await AddInternalUrlAsync(url, null, token).ConfigureAwait(false); + if (c == null) + return string.Empty; + + // --- Phase 3: short lock — write result into cache --- await _urlLock.WaitAsync(token).ConfigureAwait(false); try { - if (string.IsNullOrEmpty(url)) - return string.Empty; if (!_urlCache.ContainsKey(url)) - { - EtagCacheEntity? c = await _db.ETagCache.AsNoTracking().FirstOrDefaultAsync(e => e.Url == url, token).ConfigureAwait(false); - if (c == null) - c = await AddInternalUrlAsync(url, null, token).ConfigureAwait(false); - if (c == null) - return string.Empty; - _urlCache[url] = c!.Key; - } - return _urlCache[url]; + _urlCache[url] = c.Key; + else + cached = _urlCache[url]; // another thread resolved it concurrently; use that key } finally { _urlLock.Release(); } + + return cached ?? c.Key; } public async ValueTask PopulateThumbsAsync(IThumb thumb, string prefix = "/api/image/", CancellationToken token = default) { @@ -99,52 +143,100 @@ public async ValueTask PopulateThumbsAsync(IThumb thumb, string prefix = "/api/i public async ValueTask PopulateThumbsAsync(IEnumerable thumbs, string prefix = "/api/image/", CancellationToken token = default) { List etags = []; + + // --- Phase 1: short lock — resolve cache hits, snapshot unresolved set --- + List unresolved; + Dictionary> unresolvedByUrl; await _urlLock.WaitAsync(token).ConfigureAwait(false); try { List all = thumbs.ToList(); - foreach(IThumb t in thumbs.ToList()) + unresolved = []; + foreach (IThumb t in all) { string? url = t?.ThumbnailUrl; - if (t==null || string.IsNullOrEmpty(url)) - { - all.Remove(t); + if (t == null || string.IsNullOrEmpty(url)) continue; - } if (_urlCache.TryGetValue(url, out string k)) - { t.ThumbnailUrl = prefix + k; - all.Remove(t); - } + else + unresolved.Add(t); } - Dictionary> allUrl = all.GroupBy(a => a.ThumbnailUrl, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + } + finally + { + _urlLock.Release(); + } + + if (unresolved.Count == 0) + return; + + // --- Phase 2: outside the lock — do all awaited DB work --- + unresolvedByUrl = unresolved + .GroupBy(a => a.ThumbnailUrl, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + // Batch-fetch existing DB entries for the unresolved URLs. + // SQLite's default SQLITE_LIMIT_VARIABLE_NUMBER is 999; chunking to 500 + // keeps each IN-clause well within that limit even on cold-cache loads. + List dbHits = []; + List unresolvedKeys = unresolvedByUrl.Keys.ToList(); + foreach (string[] chunk in unresolvedKeys.Chunk(500)) + { + List chunkHits = await _db.ETagCache.AsNoTracking() + .Where(e => chunk.Contains(e.Url)) + .ToListAsync(token).ConfigureAwait(false); + dbHits.AddRange(chunkHits); + } + + // Map DB hits back to thumbs and remove from remaining unresolved set. + foreach (EtagCacheEntity m in dbHits) + { + if (!unresolvedByUrl.TryGetValue(m.Url, out List? group)) + continue; + foreach (IThumb t in group) + t.ThumbnailUrl = prefix + m.Key; + unresolvedByUrl.Remove(m.Url); + etags.Add(m); + } + + // Insert genuinely new entries (AddInternalUrlAsync re-checks by Url before inserting). + List<(string OriginalUrl, EtagCacheEntity Entity)> newlyAdded = []; + foreach (KeyValuePair> kvp in unresolvedByUrl) + { + EtagCacheEntity? ee = await AddInternalUrlAsync(kvp.Key, null, token).ConfigureAwait(false); + if (ee == null) + continue; + foreach (IThumb t in kvp.Value) + t.ThumbnailUrl = prefix + ee.Key; + etags.Add(ee); + newlyAdded.Add((kvp.Key, ee)); + } - etags = await _db.ETagCache.AsNoTracking().Where(e => allUrl.Keys.Contains(e.Url)).ToListAsync(token).ConfigureAwait(false); - foreach(EtagCacheEntity m in etags) + // --- Phase 3: short lock — merge newly-resolved entries into _urlCache --- + if (newlyAdded.Count > 0 || dbHits.Count > 0) + { + await _urlLock.WaitAsync(token).ConfigureAwait(false); + try { - List allT = allUrl[m.Url]; - foreach (IThumb t in allT) + foreach (EtagCacheEntity m in dbHits) { - _urlCache[t.ThumbnailUrl] = m!.Key; - t.ThumbnailUrl = prefix + m!.Key; - all.Remove(t); + if (!_urlCache.ContainsKey(m.Url)) + _urlCache[m.Url] = m.Key; + } + foreach ((string origUrl, EtagCacheEntity ee) in newlyAdded) + { + if (!_urlCache.ContainsKey(origUrl)) + _urlCache[origUrl] = ee.Key; } } - - foreach (IThumb t in all) + finally { - EtagCacheEntity? ee = await AddInternalUrlAsync(t.ThumbnailUrl, null, token).ConfigureAwait(false); - if (ee == null) - continue; - _urlCache[t.ThumbnailUrl] = ee!.Key; - t.ThumbnailUrl = prefix + ee!.Key; - etags.Add(ee!); + _urlLock.Release(); } } - finally - { - _urlLock.Release(); - } + + // --- Phase 4: _eTagLock — unchanged pattern, only dictionary mutation --- if (etags.Count > 0) { await _eTagLock.WaitAsync(token).ConfigureAwait(false); @@ -207,23 +299,87 @@ public async Task CheckETagAsync(string key, string? etag, CancellationTok EtagCacheEntity? cac = await AddInternalUrlAsync(url, mihonProviderId, token).ConfigureAwait(false); return cac?.Url; } - - private async Task AddInternalUrlAsync(string url, string? mihonProviderId, CancellationToken token = default) + + /// + /// Batch-registers multiple thumbnail URLs in a single DB round-trip. + /// Skips URLs that already exist in the cache or have no matching image provider. + /// + public async Task AddUrlsBatchAsync(IEnumerable<(string Url, string? MihonProviderId)> urls, CancellationToken token = default) { + var validUrls = urls + .Where(u => !string.IsNullOrEmpty(u.Url) && GetProviderForUrl(u.Url) != null) + .DistinctBy(u => u.Url) + .ToList(); + + if (validUrls.Count == 0) + return; + try { - if (string.IsNullOrEmpty(url)) + var urlStrings = validUrls.Select(u => u.Url).ToList(); + + // SQLite's default SQLITE_LIMIT_VARIABLE_NUMBER is 999; chunking to 500 + // keeps each IN-clause well within that limit for large batch registrations. + var existingUrls = new List(); + foreach (string[] chunk in urlStrings.Chunk(500)) { - _logger.LogWarning("Url is null or empty."); - return null; + List chunkHits = await _db.ETagCache + .Where(e => chunk.Contains(e.Url)) + .Select(e => e.Url) + .ToListAsync(token).ConfigureAwait(false); + existingUrls.AddRange(chunkHits); } - IImageProvider? provider = GetProviderForUrl(url); - if (provider == null) - return null; - string key = Guid.NewGuid().ToString("N"); - var existingEntry = await _db.ETagCache.FirstOrDefaultAsync(e => e.Url == url, token).ConfigureAwait(false); + + var existingSet = new HashSet(existingUrls, StringComparer.OrdinalIgnoreCase); + var newEntries = validUrls + .Where(u => !existingSet.Contains(u.Url)) + .Select(u => new EtagCacheEntity + { + Key = Guid.NewGuid().ToString("N"), + Url = u.Url, + MihonProviderId = u.MihonProviderId, + NextUpdateUTC = DateTime.UtcNow.Add(GetCacheDuration()) + }) + .ToList(); + + if (newEntries.Count > 0) + { + await _db.ETagCache.AddRangeAsync(newEntries, token).ConfigureAwait(false); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error batch-adding {Count} cache entries: {Message}", validUrls.Count, ex.Message); + } + } + + private async Task AddInternalUrlAsync(string url, string? mihonProviderId, CancellationToken token = default) + { + if (string.IsNullOrEmpty(url)) + { + _logger.LogWarning("Url is null or empty."); + return null; + } + IImageProvider? provider = GetProviderForUrl(url); + if (provider == null) + return null; + + // Acquire the per-URL semaphore so that only one caller performs the + // existence check + insert for a given URL at a time. Callers for + // different URLs are not serialized (they get independent semaphores). + SemaphoreSlim urlSem = _insertLocks.GetOrAdd(url, _ => new SemaphoreSlim(1, 1)); + await urlSem.WaitAsync(token).ConfigureAwait(false); + try + { + // Re-check inside the gate in case a concurrent caller already inserted. + // AsNoTracking: this is a read-only existence check; avoids change-tracker + // accumulation under high concurrency (the entry, if new, is Added below). + var existingEntry = await _db.ETagCache.AsNoTracking().FirstOrDefaultAsync(e => e.Url == url, token).ConfigureAwait(false); if (existingEntry != null) return existingEntry; + + string key = Guid.NewGuid().ToString("N"); var newCacheEntry = new Models.Database.EtagCacheEntity { Key = key, @@ -231,14 +387,21 @@ public async Task CheckETagAsync(string key, string? etag, CancellationTok MihonProviderId = mihonProviderId, NextUpdateUTC = DateTime.UtcNow.Add(GetCacheDuration()) }; - await _db.ETagCache.AddAsync(newCacheEntry, token).ConfigureAwait(false); - await _db.SaveChangesAsync(token).ConfigureAwait(false); - return newCacheEntry; + try + { + await _db.ETagCache.AddAsync(newCacheEntry, token).ConfigureAwait(false); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + return newCacheEntry; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error adding cache entry for {url}: {ex.Message}"); + return null; + } } - catch (Exception ex) + finally { - _logger.LogError(ex, $"Error adding cache entry for {url}: {ex.Message}"); - return null; + urlSem.Release(); } } IImageProvider? GetProviderForUrl(string url) diff --git a/KaizokuBackend/Services/Jobs/Commands/HealthCheckSources.cs b/KaizokuBackend/Services/Jobs/Commands/HealthCheckSources.cs new file mode 100644 index 00000000..26aa9988 --- /dev/null +++ b/KaizokuBackend/Services/Jobs/Commands/HealthCheckSources.cs @@ -0,0 +1,43 @@ +using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Jobs.Models; +using KaizokuBackend.Services.Providers; +using System.Diagnostics.CodeAnalysis; + +namespace KaizokuBackend.Services.Jobs.Commands; + +public class HealthCheckSources : ICommand +{ + public JobType JobType => JobType.HealthCheckSources; + public Type? ParameterType => null; + + private readonly ProviderHealthCheckService _healthCheck; + private readonly ILogger _logger; + + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(HealthCheckSources))] + public HealthCheckSources(ProviderHealthCheckService healthCheck, ILogger logger) + { + _healthCheck = healthCheck; + _logger = logger; + } + + public async Task ExecuteAsync(JobInfo job, CancellationToken token = default) + { + try + { + _logger.LogInformation("Running scheduled health check for all sources..."); + var results = await _healthCheck.CheckAllProvidersAsync(token).ConfigureAwait(false); + + int passed = results.Count(r => r.Passed); + int failed = results.Count(r => !r.Passed); + _logger.LogInformation("Health check complete: {Passed} passed, {Failed} failed out of {Total} sources.", + passed, failed, results.Count); + + return JobResult.Success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running scheduled health check"); + return JobResult.Failed; + } + } +} diff --git a/KaizokuBackend/Services/Jobs/JobBusinessService.cs b/KaizokuBackend/Services/Jobs/JobBusinessService.cs index 84d37b93..050097b8 100644 --- a/KaizokuBackend/Services/Jobs/JobBusinessService.cs +++ b/KaizokuBackend/Services/Jobs/JobBusinessService.cs @@ -74,6 +74,35 @@ await _jobManagement.ScheduleRecurringJobAsync(JobType.UpdateExtensions, groupKe #endregion + #region Health Check Management + + public async Task ManageHealthCheckJobAsync(bool enable, CancellationToken token = default) + { + string key = nameof(JobType.HealthCheckSources); + string groupKey = nameof(JobType.HealthCheckSources); + + if (!enable) + { + await _jobManagement.DisableRecurringJobAsync(JobType.HealthCheckSources, key, token) + .ConfigureAwait(false); + } + else + { + // Run health checks every 6 hours + await _jobManagement.ScheduleRecurringJobAsync( + JobType.HealthCheckSources, + (string?)null, + key, + groupKey, + false, + TimeSpan.FromHours(6), + Priority.Low, + token).ConfigureAwait(false); + } + } + + #endregion + #region Source Management public async Task ManageSourceJobAsync(ProviderStorageEntity provider, bool enable, bool runNow = false, @@ -108,7 +137,7 @@ await _jobManagement.DisableRecurringJobAsync(JobType.GetLatest, mihonProviderId #region Helper Methods - private static string BuildProviderGroupKey(SeriesProviderEntity provider) + public static string BuildProviderGroupKey(SeriesProviderEntity provider) { return $"{provider.Provider}|{provider.Language}|{provider.Scanlator ?? ""}"; } diff --git a/KaizokuBackend/Services/Providers/ProviderCacheService.cs b/KaizokuBackend/Services/Providers/ProviderCacheService.cs index e6c440f2..204dcb44 100644 --- a/KaizokuBackend/Services/Providers/ProviderCacheService.cs +++ b/KaizokuBackend/Services/Providers/ProviderCacheService.cs @@ -25,8 +25,9 @@ public class ProviderCacheService private readonly SettingsService _settingsService; private readonly ILogger _logger; - private static List? _providers = []; - private static List _languages = []; + private static volatile List? _providers = []; + private static volatile List _languages = []; + private static readonly SemaphoreSlim _cacheLock = new(1, 1); public ProviderCacheService(AppDbContext db, JobBusinessService jobBusinessService, SettingsService settingsService, MihonBridgeService mihon, ILogger logger) @@ -39,13 +40,30 @@ public ProviderCacheService(AppDbContext db, JobBusinessService jobBusinessServi } /// - /// Gets cached providers + /// Gets cached providers. Uses a semaphore to prevent thundering-herd + /// when multiple concurrent requests trigger cache population. /// public async Task> GetCachedProvidersAsync(CancellationToken token = default) { if (_providers == null || _providers.Count == 0) { - await RefreshCacheAsync(false, token).ConfigureAwait(false); + await _cacheLock.WaitAsync(token).ConfigureAwait(false); + try + { + // Double-check after acquiring the lock + if (_providers == null || _providers.Count == 0) + { + await RefreshCacheAsync(false, token).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh provider cache. Returning empty provider list."); + } + finally + { + _cacheLock.Release(); + } } return _providers ?? []; } @@ -59,7 +77,22 @@ public async Task> GetAvailableLanguagesAsync(CancellationToken tok { if (_providers == null || _providers.Count == 0) { - await RefreshCacheAsync(false, token).ConfigureAwait(false); + await _cacheLock.WaitAsync(token).ConfigureAwait(false); + try + { + if (_providers == null || _providers.Count == 0) + { + await RefreshCacheAsync(false, token).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh provider cache. Returning empty language list."); + } + finally + { + _cacheLock.Release(); + } } return _languages ?? []; } @@ -79,18 +112,9 @@ public async Task> GetSourcesForLanguagesAsync( foreach (var provider in storages) { - if (languageSet.Count == 0) + if (languageSet.Count == 0 || provider.Language == "all" || languageSet.Contains(provider.Language)) { - if (!result.Contains(provider)) - result.Add(provider); - } - else - { - if (provider.Language == "all" || languageSet.Contains(provider.Language)) - { - if (!result.Contains(provider)) - result.Add(provider); - } + result.Add(provider); // HashSet.Add already handles duplicates } } return result.ToList(); @@ -150,9 +174,25 @@ public async Task ReconcileLocalAsync(string package, string[] prefLanguag } ProviderStorageEntity storage = new ProviderStorageEntity(); storage.MihonProviderId = entry.GetMihonProviderId(source); - ISourceInterop? interop = await _mihon.SourceFromProviderIdAsync(storage.MihonProviderId, token).ConfigureAwait(false); + ISourceInterop? interop = null; + try + { + interop = await _mihon.SourceFromProviderIdAsync(storage.MihonProviderId, token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load source interop for provider {ProviderId} (package: {Package}). " + + "Marking as broken — the extension may need to be rebuilt or updated.", + storage.MihonProviderId, package); + } storage.FillProviderStorage(entry, extension, source, interop, repoName, entry.RepositoryId); - if (storage.Language == "all") + if (interop == null) + { + // Extension failed to load — mark as broken so it's skipped in searches + storage.IsBroken = true; + storage.IsEnabled = false; + } + else if (storage.Language == "all") storage.IsEnabled = true; else if (prefLanguages.Contains(storage.Language, StringComparer.InvariantCultureIgnoreCase)) storage.IsEnabled = true; @@ -181,8 +221,15 @@ public async Task RefreshCacheAsync(bool skipSchedule = false, CancellationToken bool commit = false; foreach (string package in packages) { - RepositoryGroup? grp = extensions.FirstOrDefault(a => a.GetActiveEntry().Extension.Package.Equals(package, StringComparison.OrdinalIgnoreCase)); - commit |= await ReconcileLocalAsync(package, settings.PreferredLanguages, grp, storages, onlinerepos, token).ConfigureAwait(false); + try + { + RepositoryGroup? grp = extensions.FirstOrDefault(a => a.GetActiveEntry().Extension.Package.Equals(package, StringComparison.OrdinalIgnoreCase)); + commit |= await ReconcileLocalAsync(package, settings.PreferredLanguages, grp, storages, onlinerepos, token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reconcile extension package '{Package}'. Skipping — other extensions will continue loading.", package); + } } if (commit) await _db.SaveChangesAsync(token).ConfigureAwait(false); @@ -191,36 +238,46 @@ public async Task RefreshCacheAsync(bool skipSchedule = false, CancellationToken commit = false; foreach (ProviderStorageEntity storage in storages) { - var combo = extensions.FindSource(storage.MihonProviderId); - if (combo.Group == null) + try { - if (storage.IsEnabled) + var combo = extensions.FindSource(storage.MihonProviderId); + if (combo.Group == null) { - var onlinecombo = onlinerepos.FindSource(storage.MihonProviderId); - if (onlinecombo.Extension == null && !storage.IsDead) + if (storage.IsEnabled) { - storage.IsDead = true; - storage.IsEnabled = false; - commit = true; - await _db.SaveChangesAsync(token).ConfigureAwait(false); - } - else if (onlinecombo.Extension != null) - { - var repoGroup = await _mihon.AddExtensionAsync(onlinecombo.Extension,false, token).ConfigureAwait(false); - if (repoGroup != null) - { - commit |= await ReconcileLocalAsync(storage.SourcePackageName!, settings.PreferredLanguages, repoGroup, storages, onlinerepos, token).ConfigureAwait(false); - } - else + var onlinecombo = onlinerepos.FindSource(storage.MihonProviderId); + if (onlinecombo.Extension == null && !storage.IsDead) { + storage.IsDead = true; storage.IsEnabled = false; - storage.IsBroken = true; commit = true; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + else if (onlinecombo.Extension != null) + { + var repoGroup = await _mihon.AddExtensionAsync(onlinecombo.Extension,false, token).ConfigureAwait(false); + if (repoGroup != null) + { + commit |= await ReconcileLocalAsync(storage.SourcePackageName!, settings.PreferredLanguages, repoGroup, storages, onlinerepos, token).ConfigureAwait(false); + } + else + { + storage.IsEnabled = false; + storage.IsBroken = true; + commit = true; + } } - } } } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reconcile online provider '{ProviderId}'. Marking as broken.", + storage.MihonProviderId); + storage.IsEnabled = false; + storage.IsBroken = true; + commit = true; + } } if (commit) await _db.SaveChangesAsync(token).ConfigureAwait(false); @@ -299,6 +356,7 @@ private async Task UpdateExtensionJobsAsync(CancellationToken token = default) bool hasEnabledProviders = _providers.Any(p => p.IsActive); await _jobBusinessService.ManageExtensionUpdatesAsync(hasEnabledProviders, token).ConfigureAwait(false); + await _jobBusinessService.ManageHealthCheckJobAsync(hasEnabledProviders, token).ConfigureAwait(false); } diff --git a/KaizokuBackend/Services/Providers/ProviderHealthCheckService.cs b/KaizokuBackend/Services/Providers/ProviderHealthCheckService.cs new file mode 100644 index 00000000..62cf7f73 --- /dev/null +++ b/KaizokuBackend/Services/Providers/ProviderHealthCheckService.cs @@ -0,0 +1,186 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto; +using KaizokuBackend.Services.Bridge; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Services.Providers +{ + /// + /// Service for checking provider health by performing test searches + /// + public class ProviderHealthCheckService + { + private readonly AppDbContext _db; + private readonly MihonBridgeService _mihon; + private readonly ProviderCacheService _providerCache; + private readonly ILogger _logger; + + public ProviderHealthCheckService( + AppDbContext db, + MihonBridgeService mihon, + ProviderCacheService providerCache, + ILogger logger) + { + _db = db; + _mihon = mihon; + _providerCache = providerCache; + _logger = logger; + } + + /// + /// Health-checks a single provider by attempting a test search + /// + public async Task CheckProviderAsync(string mihonProviderId, CancellationToken token = default) + { + var provider = await _db.Providers.FirstOrDefaultAsync(p => p.MihonProviderId == mihonProviderId, token) + .ConfigureAwait(false); + + if (provider == null) + { + return new ProviderHealthResultDto + { + MihonProviderId = mihonProviderId, + Passed = false, + Error = "Provider not found", + CheckedAtUtc = DateTime.UtcNow + }; + } + + return await RunHealthCheckAsync(provider, skipCacheRefresh: false, token).ConfigureAwait(false); + } + + /// + /// Health-checks all sources belonging to a specific extension package + /// + public async Task> CheckByPackageAsync(string packageName, CancellationToken token = default) + { + var providers = await _db.Providers + .Where(p => p.SourcePackageName == packageName && p.IsEnabled && !p.IsDead) + .ToListAsync(token).ConfigureAwait(false); + + if (providers.Count == 0) + { + return [new ProviderHealthResultDto + { + MihonProviderId = packageName, + Passed = false, + Error = "No enabled sources found for this package", + CheckedAtUtc = DateTime.UtcNow + }]; + } + + var results = new List(); + foreach (var provider in providers) + { + if (token.IsCancellationRequested) break; + var result = await RunHealthCheckAsync(provider, skipCacheRefresh: true, token).ConfigureAwait(false); + results.Add(result); + } + + await _providerCache.RefreshCacheAsync(true, token).ConfigureAwait(false); + return results; + } + + /// + /// Health-checks all enabled/installed providers + /// + public async Task> CheckAllProvidersAsync(CancellationToken token = default) + { + var providers = await _db.Providers + .Where(p => p.IsEnabled && !p.IsDead) + .ToListAsync(token).ConfigureAwait(false); + + var results = new List(); + + // Check providers sequentially to avoid overloading sources + foreach (var provider in providers) + { + if (token.IsCancellationRequested) break; + + var result = await RunHealthCheckAsync(provider, skipCacheRefresh: true, token).ConfigureAwait(false); + results.Add(result); + } + + // Single cache refresh after all checks complete + await _providerCache.RefreshCacheAsync(true, token).ConfigureAwait(false); + + return results; + } + + /// + /// Runs the actual health check on a single provider — tries to initialize + /// the source interop and perform a minimal search. + /// + private async Task RunHealthCheckAsync(ProviderStorageEntity provider, bool skipCacheRefresh = false, CancellationToken token = default) + { + var result = new ProviderHealthResultDto + { + MihonProviderId = provider.MihonProviderId, + Name = provider.Name, + Language = provider.Language + }; + + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(15)); + + // Step 1: Try to get the source interop (tests extension loading) + var source = await _mihon.SourceFromProviderIdAsync(provider.MihonProviderId, timeoutCts.Token) + .ConfigureAwait(false); + + // Step 2: Try a minimal search (tests the actual source/website) + var searchResult = await source.SearchAsync(1, "a", timeoutCts.Token).ConfigureAwait(false); + + // If we get here without exception, the source is healthy + result.Passed = true; + result.CheckedAtUtc = DateTime.UtcNow; + + _logger.LogInformation("Health check PASSED for provider {Name} ({Id})", + provider.Name, provider.MihonProviderId); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + result.Passed = false; + result.Error = "Timed out after 15 seconds"; + result.CheckedAtUtc = DateTime.UtcNow; + + _logger.LogWarning("Health check FAILED for provider {Name} ({Id}): Timeout", + provider.Name, provider.MihonProviderId); + } + catch (HttpRequestException ex) + { + result.Passed = false; + result.Error = $"HTTP error: {ex.StatusCode}"; + result.CheckedAtUtc = DateTime.UtcNow; + + _logger.LogWarning("Health check FAILED for provider {Name} ({Id}): HTTP {StatusCode}", + provider.Name, provider.MihonProviderId, ex.StatusCode); + } + catch (Exception ex) + { + result.Passed = false; + result.Error = ex.Message; + result.CheckedAtUtc = DateTime.UtcNow; + + _logger.LogWarning(ex, "Health check FAILED for provider {Name} ({Id}): {Message}", + provider.Name, provider.MihonProviderId, ex.Message); + } + + // Persist health check result to DB + provider.LastHealthCheckUtc = result.CheckedAtUtc; + provider.LastHealthCheckPassed = result.Passed; + provider.LastHealthCheckError = result.Passed ? null : result.Error; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + // Refresh cache so the UI picks up the new status (skip when batching) + if (!skipCacheRefresh) + { + await _providerCache.RefreshCacheAsync(true, token).ConfigureAwait(false); + } + + return result; + } + } +} diff --git a/KaizokuBackend/Services/Providers/ProviderManagerService.cs b/KaizokuBackend/Services/Providers/ProviderManagerService.cs index 2c28f23c..f157cb9a 100644 --- a/KaizokuBackend/Services/Providers/ProviderManagerService.cs +++ b/KaizokuBackend/Services/Providers/ProviderManagerService.cs @@ -131,6 +131,9 @@ public async Task> GetProvidersAsync(CancellationToken token v.IsStorage = existing.IsStorage; v.IsDead = existing.IsDead; v.IsBroken = existing.IsBroken; + v.LastHealthCheckUtc = existing.LastHealthCheckUtc; + v.LastHealthCheckPassed = existing.LastHealthCheckPassed; + v.LastHealthCheckError = existing.LastHealthCheckError; v.IsInstaled = true; v.ActiveEntry = repo.ActiveEntry; v.Repositories = new List() { repoview }; diff --git a/KaizokuBackend/Services/Requests/MangaRequestService.cs b/KaizokuBackend/Services/Requests/MangaRequestService.cs new file mode 100644 index 00000000..6a77d5c8 --- /dev/null +++ b/KaizokuBackend/Services/Requests/MangaRequestService.cs @@ -0,0 +1,274 @@ +using KaizokuBackend.Data; +using KaizokuBackend.Hubs; +using KaizokuBackend.Models.Database; +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Series; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Services.Requests +{ + public class MangaRequestService + { + private readonly AppDbContext _db; + private readonly ILogger _logger; + private readonly SeriesCommandService _seriesCommand; + private readonly IHubContext _hubContext; + + public MangaRequestService(AppDbContext db, ILogger logger, + SeriesCommandService seriesCommand, + IHubContext hubContext) + { + _db = db; + _logger = logger; + _seriesCommand = seriesCommand; + _hubContext = hubContext; + } + + public async Task CreateAsync(CreateRequestDto dto, Guid userId, CancellationToken token = default) + { + // Check pending request limit + var settingsEntity = await _db.Settings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Name == "MaxPendingRequestsPerUser", token) + .ConfigureAwait(false); + + int maxPending = 10; + if (settingsEntity != null && int.TryParse(settingsEntity.Value, out var parsed)) + maxPending = parsed; + + var pendingCount = await _db.MangaRequests + .CountAsync(r => r.RequestedByUserId == userId && r.Status == RequestStatus.Pending, token) + .ConfigureAwait(false); + + if (pendingCount >= maxPending) + throw new InvalidOperationException($"You have reached the maximum number of pending requests ({maxPending})."); + + var entity = new MangaRequestEntity + { + Id = Guid.NewGuid(), + RequestedByUserId = userId, + Title = dto.Title.Trim(), + Description = dto.Description?.Trim(), + ThumbnailUrl = dto.ThumbnailUrl, + ProviderData = dto.ProviderData, + Status = RequestStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + _db.MangaRequests.Add(entity); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return await MapToDtoAsync(entity, token).ConfigureAwait(false); + } + + public async Task ApproveAsync(Guid requestId, Guid adminUserId, ApproveRequestDto dto, CancellationToken token = default) + { + var request = await _db.MangaRequests + .FirstOrDefaultAsync(r => r.Id == requestId, token) + .ConfigureAwait(false); + + if (request == null) + throw new InvalidOperationException("Request not found."); + + if (request.Status != RequestStatus.Pending) + throw new InvalidOperationException("Request is not pending."); + + request.Status = RequestStatus.Approved; + request.ReviewedByUserId = adminUserId; + request.ReviewedAt = DateTime.UtcNow; + request.ReviewNote = dto.ReviewNote; + + // Add series if data provided + if (dto.SeriesData != null) + { + try + { + await _seriesCommand.AddSeriesAsync(dto.SeriesData, token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add series from approved request {RequestId}", requestId); + } + } + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + // Send notification via SignalR + try + { + await _hubContext.Clients.All.SendAsync("RequestUpdated", new + { + requestId = request.Id, + status = "Approved", + userId = request.RequestedByUserId + }, token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send SignalR notification for request approval"); + } + + return await MapToDtoAsync(request, token).ConfigureAwait(false); + } + + public async Task DenyAsync(Guid requestId, Guid adminUserId, DenyRequestDto dto, CancellationToken token = default) + { + var request = await _db.MangaRequests + .FirstOrDefaultAsync(r => r.Id == requestId, token) + .ConfigureAwait(false); + + if (request == null) + throw new InvalidOperationException("Request not found."); + + if (request.Status != RequestStatus.Pending) + throw new InvalidOperationException("Request is not pending."); + + request.Status = RequestStatus.Denied; + request.ReviewedByUserId = adminUserId; + request.ReviewedAt = DateTime.UtcNow; + request.ReviewNote = dto.ReviewNote; + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + // Send notification via SignalR + try + { + await _hubContext.Clients.All.SendAsync("RequestUpdated", new + { + requestId = request.Id, + status = "Denied", + userId = request.RequestedByUserId + }, token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send SignalR notification for request denial"); + } + + return await MapToDtoAsync(request, token).ConfigureAwait(false); + } + + public async Task CancelAsync(Guid requestId, Guid userId, CancellationToken token = default) + { + var request = await _db.MangaRequests + .FirstOrDefaultAsync(r => r.Id == requestId, token) + .ConfigureAwait(false); + + if (request == null) + throw new InvalidOperationException("Request not found."); + + if (request.RequestedByUserId != userId) + throw new InvalidOperationException("You can only cancel your own requests."); + + if (request.Status != RequestStatus.Pending) + throw new InvalidOperationException("Only pending requests can be cancelled."); + + request.Status = RequestStatus.Cancelled; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + return await MapToDtoAsync(request, token).ConfigureAwait(false); + } + + public async Task> GetAllAsync(CancellationToken token = default) + { + var requests = await _db.MangaRequests + .AsNoTracking() + .Include(r => r.RequestedByUser) + .Include(r => r.ReviewedByUser) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(token) + .ConfigureAwait(false); + + return requests.Select(MapToDtoInternal).ToList(); + } + + public async Task> GetPendingAsync(CancellationToken token = default) + { + var requests = await _db.MangaRequests + .AsNoTracking() + .Include(r => r.RequestedByUser) + .Where(r => r.Status == RequestStatus.Pending) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(token) + .ConfigureAwait(false); + + return requests.Select(MapToDtoInternal).ToList(); + } + + public async Task> GetByUserAsync(Guid userId, CancellationToken token = default) + { + var requests = await _db.MangaRequests + .AsNoTracking() + .Include(r => r.RequestedByUser) + .Include(r => r.ReviewedByUser) + .Where(r => r.RequestedByUserId == userId) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(token) + .ConfigureAwait(false); + + return requests.Select(MapToDtoInternal).ToList(); + } + + public async Task GetPendingCountAsync(CancellationToken token = default) + { + return await _db.MangaRequests + .CountAsync(r => r.Status == RequestStatus.Pending, token) + .ConfigureAwait(false); + } + + private async Task MapToDtoAsync(MangaRequestEntity entity, CancellationToken token) + { + var requestedBy = await _db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == entity.RequestedByUserId, token) + .ConfigureAwait(false); + + string? reviewedByUsername = null; + if (entity.ReviewedByUserId.HasValue) + { + var reviewedBy = await _db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == entity.ReviewedByUserId.Value, token) + .ConfigureAwait(false); + reviewedByUsername = reviewedBy?.Username; + } + + return new MangaRequestDto + { + Id = entity.Id, + RequestedByUserId = entity.RequestedByUserId, + RequestedByUsername = requestedBy?.Username ?? "Unknown", + Title = entity.Title, + Description = entity.Description, + ThumbnailUrl = entity.ThumbnailUrl, + ProviderData = entity.ProviderData, + Status = entity.Status, + ReviewedByUserId = entity.ReviewedByUserId, + ReviewedByUsername = reviewedByUsername, + ReviewedAt = entity.ReviewedAt, + ReviewNote = entity.ReviewNote, + CreatedAt = entity.CreatedAt + }; + } + + private static MangaRequestDto MapToDtoInternal(MangaRequestEntity entity) + { + return new MangaRequestDto + { + Id = entity.Id, + RequestedByUserId = entity.RequestedByUserId, + RequestedByUsername = entity.RequestedByUser?.Username ?? "Unknown", + Title = entity.Title, + Description = entity.Description, + ThumbnailUrl = entity.ThumbnailUrl, + ProviderData = entity.ProviderData, + Status = entity.Status, + ReviewedByUserId = entity.ReviewedByUserId, + ReviewedByUsername = entity.ReviewedByUser?.Username, + ReviewedAt = entity.ReviewedAt, + ReviewNote = entity.ReviewNote, + CreatedAt = entity.CreatedAt + }; + } + } +} diff --git a/KaizokuBackend/Services/Search/SearchCommandService.cs b/KaizokuBackend/Services/Search/SearchCommandService.cs index f45554b0..da3ee047 100644 --- a/KaizokuBackend/Services/Search/SearchCommandService.cs +++ b/KaizokuBackend/Services/Search/SearchCommandService.cs @@ -79,10 +79,14 @@ await Parallel.ForEachAsync(validSeries, parallelOptions, async (ls, ct) => { try { - var source = await _mihon.SourceFromProviderIdAsync(ls.MihonProviderId!, token).ConfigureAwait(false); + // Per-provider timeout: 30 seconds to prevent one slow source blocking augmentation + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + + var source = await _mihon.SourceFromProviderIdAsync(ls.MihonProviderId!, timeoutCts.Token).ConfigureAwait(false); Manga m = ls.ToManga()!; - var fullData = await source.GetDetailsAsync(m, token).ConfigureAwait(false); - var chapterData = await source.GetChaptersAsync(m, token).ConfigureAwait(false); + var fullData = await source.GetDetailsAsync(m, timeoutCts.Token).ConfigureAwait(false); + var chapterData = await source.GetChaptersAsync(m, timeoutCts.Token).ConfigureAwait(false); if (fullData != null && chapterData != null && chapterData.Count > 0) { // Set default scanlator if not provided @@ -90,10 +94,14 @@ await Parallel.ForEachAsync(validSeries, parallelOptions, async (ls, ct) => { if (string.IsNullOrEmpty(a.Scanlator)) a.Scanlator = ls.Provider; - }); + }); seriesDetailsMap.TryAdd(ls.MihonId!, (fullData, chapterData)); } } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogWarning("Provider {Provider} timed out fetching details for {Title}, skipping.", ls.Provider, ls.Title); + } catch (HttpRequestException r) { _logger.LogWarning("Error fetching series details for {Title} from {Provider}: Http Error {StatusCode}.", ls.Title, ls.Provider, r.StatusCode); diff --git a/KaizokuBackend/Services/Search/SearchQueryService.cs b/KaizokuBackend/Services/Search/SearchQueryService.cs index 836614c3..443dabb5 100644 --- a/KaizokuBackend/Services/Search/SearchQueryService.cs +++ b/KaizokuBackend/Services/Search/SearchQueryService.cs @@ -112,19 +112,30 @@ public async Task> SearchSeriesAsync(List<(string keyword, var results = new ConcurrentBag<(string Keyword, ProviderStorageEntity Storage, MangaList Result)>(); var maxConcurrency = Math.Min(appSettings?.NumberOfSimultaneousSearches ?? 10, sources.Count); + // Overall search timeout — prevents the entire operation from running unbounded + using var overallCts = CancellationTokenSource.CreateLinkedTokenSource(token); + overallCts.CancelAfter(TimeSpan.FromSeconds(60)); + var overallToken = overallCts.Token; + await Parallel.ForEachAsync( sources, - new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, CancellationToken = token }, + new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, CancellationToken = overallToken }, async (source, ct) => { - //Try to match 3 times, for providers that are slow or have temporary issues - int retries = 3; - do + // Bail out early if the caller was already cancelled + ct.ThrowIfCancellationRequested(); + + // Try up to 3 times for providers that have temporary issues + for (int attempt = 0; attempt < 3; attempt++) { try { - var src = await _mihon.SourceFromProviderIdAsync(source.ps.MihonProviderId).ConfigureAwait(false); - var searchResult = await src.SearchAsync(1, source.keyword, ct).ConfigureAwait(false); + // Per-provider timeout: 15 seconds covering init + search + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(15)); + + var src = await _mihon.SourceFromProviderIdAsync(source.ps.MihonProviderId, timeoutCts.Token).ConfigureAwait(false); + var searchResult = await src.SearchAsync(1, source.keyword, timeoutCts.Token).ConfigureAwait(false); if (searchResult != null && searchResult.Mangas.Count > 0) { // Remove duplicates within the same source @@ -140,18 +151,19 @@ await Parallel.ForEachAsync( break; } } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogWarning("Provider {Name} timed out on attempt {Attempt}/3.", source.ps.Name, attempt + 1); + } catch (HttpRequestException r) { - _logger.LogWarning("Error searching provider {Name}: Http Error {StatusCode}.", source.ps.Name, r.StatusCode); + _logger.LogWarning("Error searching provider {Name} (attempt {Attempt}/3): Http Error {StatusCode}.", source.ps.Name, attempt + 1, r.StatusCode); } catch (Exception ex) { - _logger.LogError(ex, "Error searching provider {Name}: {Message}", source.ps.Name, ex.Message); + _logger.LogError(ex, "Error searching provider {Name} (attempt {Attempt}/3): {Message}", source.ps.Name, attempt + 1, ex.Message); } - - retries++; - } while (retries < 3); - + } }).ConfigureAwait(false); @@ -213,15 +225,27 @@ public async Task> SearchSeriesAsync(string keyword, List< var results = new ConcurrentBag<(string providerId, string lang, MangaList)>(); var maxConcurrency = Math.Min(appSettings?.NumberOfSimultaneousSearches ?? 10, sources.Count); + // Overall search timeout — prevents the entire operation from running unbounded + using var overallCts = CancellationTokenSource.CreateLinkedTokenSource(token); + overallCts.CancelAfter(TimeSpan.FromSeconds(60)); + var overallToken = overallCts.Token; + await Parallel.ForEachAsync( sourceDict.Keys, - new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, CancellationToken = token }, + new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, CancellationToken = overallToken }, async (providerId, ct) => { + // Bail out early if the caller (e.g. HTTP request) was already cancelled + ct.ThrowIfCancellationRequested(); + try { - var src = await _mihon.SourceFromProviderIdAsync(providerId).ConfigureAwait(false); - var searchResult = await src.SearchAsync(1, keyword, ct).ConfigureAwait(false); + // Per-provider timeout: 15 seconds covering init + search + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(15)); + + var src = await _mihon.SourceFromProviderIdAsync(providerId, timeoutCts.Token).ConfigureAwait(false); + var searchResult = await src.SearchAsync(1, keyword, timeoutCts.Token).ConfigureAwait(false); if (searchResult != null && searchResult.Mangas.Count > 0) { // Remove duplicates within the same source @@ -236,6 +260,10 @@ await Parallel.ForEachAsync( results.Add((providerId, src.Language, searchResult)); } } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogWarning("Provider {Name} timed out after 15s, skipping.", sourceDict[providerId].Name); + } catch (HttpRequestException r) { _logger.LogWarning("Error searching provider {Name}: Http Error {StatusCode}.", sourceDict[providerId].Name, r.StatusCode); @@ -250,16 +278,15 @@ await Parallel.ForEachAsync( var allSeries = new List<(ParsedManga Manga, string ProviderId, string Language)>(); foreach (var (providerId, lang, result) in results) { - + allSeries.AddRange(result.Mangas.Select(m => (m, providerId, lang))); } - foreach(var n in allSeries) - { - if (!string.IsNullOrEmpty(n.Manga.ThumbnailUrl)) - { - await _thumb.AddUrlAsync(n.Manga.ThumbnailUrl, n.ProviderId, token).ConfigureAwait(false); - } - } + // Batch-register all thumbnail URLs in a single DB round-trip + var thumbUrls = allSeries + .Where(n => !string.IsNullOrEmpty(n.Manga.ThumbnailUrl)) + .Select(n => (n.Manga.ThumbnailUrl, (string?)n.ProviderId)) + .ToList(); + await _thumb.AddUrlsBatchAsync(thumbUrls, token).ConfigureAwait(false); var linked = allSeries.FindAndLinkSimilarSeries(threshold); @@ -272,11 +299,16 @@ await Parallel.ForEachAsync( var finalResults = linked.DistinctBy(a => a.MihonId).OrderByLevenshteinDistance(a => a.Title, keyword).ToList(); - // Cache results for 30 seconds - _memoryCache.Set(cacheKey, finalResults, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30) }); + // Cache results for 5 minutes — search results don't go stale quickly + _memoryCache.Set(cacheKey, finalResults, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }); return finalResults; } + catch (OperationCanceledException) + { + _logger.LogWarning("Search for '{keyword}' was cancelled (client disconnect or overall timeout).", keyword); + return []; + } catch (Exception ex) { _logger.LogError(ex, "Error in SearchSeries"); diff --git a/KaizokuBackend/Services/Series/SeriesArchiveService.cs b/KaizokuBackend/Services/Series/SeriesArchiveService.cs index fe81273c..d6feadcb 100644 --- a/KaizokuBackend/Services/Series/SeriesArchiveService.cs +++ b/KaizokuBackend/Services/Series/SeriesArchiveService.cs @@ -4,12 +4,15 @@ using KaizokuBackend.Models.Database; using KaizokuBackend.Models.Dto; using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Bridge; using KaizokuBackend.Services.Helpers; using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Jobs.Models; using KaizokuBackend.Services.Jobs.Report; using KaizokuBackend.Services.Settings; using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Text.RegularExpressions; namespace KaizokuBackend.Services.Series { @@ -20,15 +23,17 @@ public class SeriesArchiveService { private readonly AppDbContext _db; private readonly SettingsService _settings; + private readonly MihonBridgeService _mihon; private readonly ArchiveHelperService _archiveHelper; private readonly JobHubReportService _reportingService; private readonly ILogger _logger; - public SeriesArchiveService(AppDbContext db, SettingsService settings, ArchiveHelperService archiveHelper, - JobHubReportService reportingService, ILogger logger) + public SeriesArchiveService(AppDbContext db, SettingsService settings, MihonBridgeService mihon, + ArchiveHelperService archiveHelper, JobHubReportService reportingService, ILogger logger) { _db = db; _settings = settings; + _mihon = mihon; _archiveHelper = archiveHelper; _reportingService = reportingService; _logger = logger; @@ -45,12 +50,12 @@ public async Task VerifyIntegrityAsync(Guid seriesId, SettingsDto settings = await _settings.GetSettingsAsync(token).ConfigureAwait(false); Models.Database.SeriesEntity? series = await _db.Series.Include(a => a.Sources).Where(a => a.Id == seriesId) .FirstOrDefaultAsync(token).ConfigureAwait(false); - + if (series == null) throw new ArgumentException("Invalid series Id"); - + string basePath = Path.Combine(settings.StorageFolder, series.StoragePath); - + // Remove empty unknown providers SeriesProviderEntity? sp = series.Sources.FirstOrDefault(a => a.IsUnknown && a.Chapters.All(a => string.IsNullOrEmpty(a.Filename))); @@ -61,12 +66,259 @@ public async Task VerifyIntegrityAsync(Guid seriesId, await _db.SaveChangesAsync(token).ConfigureAwait(false); } + // Check for truncated title and try to recover the full name from the source + await TryRecoverTruncatedTitleAsync(series, token).ConfigureAwait(false); + + // Reconcile chapters whose DB Filename is empty against files actually on disk. + // This recovers from prior states where Cleanup wiped Filename (e.g. due to a + // transient read failure that has since resolved) but the file remained. + await ReconcileOrphanedFilesAsync(series, basePath, token).ConfigureAwait(false); + List chaps = series.Sources.SelectMany(a => a.Chapters) .Where(a => !string.IsNullOrEmpty(a.Filename)).ToList(); - + return GetIntegrityResult(basePath, chaps); } + /// + /// Reconciles on-disk archive files against DB chapter rows for a single series, + /// linking files whose [provider][lang] tag matches a chapter row whose DB Filename + /// is empty or stale. Cheap, local-disk only — safe to call inline before Refresh + /// and Download-All flows. Loads the series internally. + /// + /// A task that completes when reconciliation finishes. + public async Task ReconcileOnDiskArchivesAsync(Guid seriesId, CancellationToken token = default) + { + SettingsDto settings = await _settings.GetSettingsAsync(token).ConfigureAwait(false); + SeriesEntity? series = await _db.Series.Include(a => a.Sources) + .FirstOrDefaultAsync(a => a.Id == seriesId, token).ConfigureAwait(false); + if (series == null) + return; + + if (string.IsNullOrWhiteSpace(series.StoragePath)) + return; + + string basePath = Path.Combine(settings.StorageFolder, series.StoragePath); + await ReconcileOrphanedFilesAsync(series, basePath, token).ConfigureAwait(false); + } + + /// + /// Reconciles on-disk archive files against DB chapter rows for an already-loaded + /// tracked series entity. Use this overload when the caller already has the series + /// loaded with tracking to share the same DbContext entity instances. + /// + /// A task that completes when reconciliation finishes. + public async Task ReconcileOnDiskArchivesAsync(SeriesEntity series, CancellationToken token = default) + { + if (string.IsNullOrWhiteSpace(series.StoragePath)) + return; + + SettingsDto settings = await _settings.GetSettingsAsync(token).ConfigureAwait(false); + string basePath = Path.Combine(settings.StorageFolder, series.StoragePath); + await ReconcileOrphanedFilesAsync(series, basePath, token).ConfigureAwait(false); + } + + // Mirrors the Kaizoku filename pattern used by SeriesScanner — `[provider(-scanlator)?][lang]? title chapter (chapterName?)`. + // We re-use the same parser so reconciliation succeeds even after a series title + // change, a chapter-count padding shift, or a chapter-name rewrite — none of those + // would match a regenerated expected filename, but all preserve provider+lang+chap#. + private static readonly Regex KaizokuFilenameRegex = new Regex( + "^\\[(?[^\\]]+)\\](?:\\[(?[^\\]]+)\\])?\\s+(?.+?)(?:\\s+(?<chapterNumber>-?\\d+(?:\\.\\d+)?))?\\s*(?:\\((?<chapterName>[^)]+)\\))?$", + RegexOptions.Compiled | RegexOptions.Singleline); + + private readonly struct ParsedFileKey + { + public string Provider { get; init; } + public string? Scanlator { get; init; } + public string Language { get; init; } + public decimal Chapter { get; init; } + } + + private static (ParsedFileKey key, bool ok) TryParseKaizokuFilename(string basename) + { + string stem = Path.GetFileNameWithoutExtension(basename); + Match m = KaizokuFilenameRegex.Match(stem); + if (!m.Success) + return (default, false); + + string providerGroup = m.Groups["provider"].Value.Trim(); + string[] providerParts = providerGroup.Split('-', 2); + string provider = providerParts[0].Trim(); + string? scanlator = providerParts.Length > 1 ? providerParts[1].Trim() : null; + string language = m.Groups.TryGetValue("lang", out var langGroup) && langGroup.Success + ? langGroup.Value.Trim() + : "en"; + + decimal? chapter = null; + if (m.Groups.TryGetValue("chapterNumber", out var chapGroup) && !string.IsNullOrEmpty(chapGroup.Value)) + { + if (decimal.TryParse(chapGroup.Value.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal parsed)) + chapter = parsed; + } + if (chapter == null && m.Groups.TryGetValue("chapterName", out var nameGroup) && !string.IsNullOrEmpty(nameGroup.Value)) + { + if (decimal.TryParse(nameGroup.Value.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal parsed)) + chapter = parsed; + } + if (chapter == null) + return (default, false); + + return (new ParsedFileKey + { + Provider = provider, + Scanlator = string.IsNullOrEmpty(scanlator) ? null : scanlator, + Language = language.ToLowerInvariant(), + Chapter = chapter.Value, + }, true); + } + + /// <summary> + /// Scans the series storage folder and re-links any archive whose embedded + /// <c>[provider][lang] ... chapter</c> tag matches a chapter row whose DB + /// <c>Filename</c> is currently empty. Uses the same Kaizoku filename regex the + /// import scanner uses, so matching is resilient to (a) series title changes, + /// (b) maxchap padding shifts when new chapters are added, and (c) chapter-name + /// rewrites by the provider. + /// + /// This is the primary recovery path for chapters incorrectly marked as missing — + /// e.g. after a previous Cleanup ran while a file was temporarily locked, after + /// a folder/title rename, or whenever the DB → file link was broken but the file + /// is still on disk. + /// </summary> + private async Task ReconcileOrphanedFilesAsync(SeriesEntity series, string basePath, CancellationToken token) + { + if (!Directory.Exists(basePath)) + return; + + List<string> filesOnDisk; + try + { + filesOnDisk = Directory.EnumerateFiles(basePath) + .Select(Path.GetFileName) + .Where(n => !string.IsNullOrEmpty(n)) + .Select(n => n!) + .ToList(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate storage folder for reconciliation: {Path}", basePath); + return; + } + + if (filesOnDisk.Count == 0) + return; + + // Set of basenames that exist on disk — used to validate existing DB links. + HashSet<string> diskBasenames = new HashSet<string>(filesOnDisk, StringComparer.OrdinalIgnoreCase); + + // Basenames already linked from any chapter row AND still present on disk — + // these are off-limits for re-linking, otherwise we'd point two chapter rows + // at the same archive. A DB Filename pointing at a non-existent file is NOT + // considered "linked" — those rows are candidates for re-linking too. + HashSet<string> alreadyLinked = series.Sources + .SelectMany(s => s.Chapters) + .Select(c => c.Filename) + .Where(f => !string.IsNullOrEmpty(f) && diskBasenames.Contains(f!)) + .Select(f => f!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Parse every disk file once. Files we can't parse (legacy/imported archives + // that don't match the Kaizoku pattern) are skipped — they were never reconcilable + // via this path anyway. + var parsedFiles = new List<(string Basename, ParsedFileKey Key)>(); + foreach (string name in filesOnDisk) + { + if (alreadyLinked.Contains(name)) + continue; + var (key, ok) = TryParseKaizokuFilename(name); + if (!ok) + continue; + parsedFiles.Add((name, key)); + } + + if (parsedFiles.Count == 0) + return; + + bool update = false; + + foreach (SeriesProviderEntity sp in series.Sources) + { + if (sp.IsUnknown) + continue; + + string providerLower = sp.Provider?.Trim().ToLowerInvariant() ?? ""; + string languageLower = sp.Language?.Trim().ToLowerInvariant() ?? "en"; + string? scanlatorLower = string.IsNullOrEmpty(sp.Scanlator) + ? null + : sp.Scanlator.Trim().ToLowerInvariant(); + + if (string.IsNullOrEmpty(providerLower)) + continue; + + // Candidates for re-linking: rows whose DB Filename is empty, OR whose + // current Filename points at a file that is no longer present on disk + // (stale link from a previous rename or filename-padding shift). + foreach (Chapter ch in sp.Chapters.Where(c => !c.IsDeleted + && c.Number.HasValue + && (string.IsNullOrEmpty(c.Filename) || !diskBasenames.Contains(c.Filename!)))) + { + decimal chapterNumber = ch.Number!.Value; + + // Prefer exact (provider, lang, scanlator, chapter) matches first, then + // fall back to ignoring scanlator if no exact match is found. + (string Basename, ParsedFileKey Key)? best = null; + foreach (var pf in parsedFiles) + { + if (pf.Key.Chapter != chapterNumber) continue; + if (!pf.Key.Provider.Equals(providerLower, StringComparison.InvariantCultureIgnoreCase)) continue; + if (!pf.Key.Language.Equals(languageLower, StringComparison.InvariantCultureIgnoreCase)) continue; + + bool scanlatorMatches = scanlatorLower == null + ? string.IsNullOrEmpty(pf.Key.Scanlator) + : pf.Key.Scanlator?.Equals(scanlatorLower, StringComparison.InvariantCultureIgnoreCase) == true; + + if (scanlatorMatches) + { + best = pf; + break; + } + // Remember a loose match in case nothing scanlator-exact shows up. + if (best == null) + best = pf; + } + + if (best == null) + continue; + + string match = best.Value.Basename; + ch.Filename = match; + ch.ShouldDownload = false; + ch.IsDeleted = false; + if (ch.DownloadDate == null) + { + try + { + ch.DownloadDate = File.GetLastWriteTimeUtc(Path.Combine(basePath, match)); + } + catch + { + // Non-fatal — leave DownloadDate null if we cannot stat the file. + } + } + alreadyLinked.Add(match); + parsedFiles.RemoveAll(p => p.Basename.Equals(match, StringComparison.OrdinalIgnoreCase)); + _db.Touch(sp, c => c.Chapters); + update = true; + _logger.LogInformation( + "Reconciled orphaned file \"{File}\" → series {Series} provider {Provider} chapter {Chapter}", + match, series.Title, sp.Provider, chapterNumber); + } + } + + if (update) + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + /// <summary> /// Cleans up corrupted series files and marks chapters for re-download /// </summary> @@ -89,9 +341,21 @@ public async Task CleanupSeriesAsync(Guid seriesId, CancellationToken token = de foreach (ArchiveIntegrityResultDto r in sr.BadFiles) { - if (r.Result == ArchiveResult.NoImages || r.Result == ArchiveResult.NotAnArchive) + // Unreadable means the archive could not be opened — likely a transient + // I/O issue (locked file, share hiccup) rather than corruption. Skip + // entirely so we do not delete a healthy file or wipe its DB linkage. + if (r.Result == ArchiveResult.Unreadable) + { + _logger.LogInformation( + "Skipping unreadable file during cleanup (transient failure assumed): {File}", + Path.Combine(basePath, r.Filename)); + continue; + } + + string finalName = Path.Combine(basePath, r.Filename); + bool isDeletable = r.Result == ArchiveResult.NoImages || r.Result == ArchiveResult.NotAnArchive; + if (isDeletable) { - string finalName = Path.Combine(basePath, r.Filename); try { File.Delete(finalName); @@ -101,9 +365,18 @@ public async Task CleanupSeriesAsync(Guid seriesId, CancellationToken token = de _logger.LogWarning("Unable to delete file {finalName}", finalName); } } - Chapter? chapter = chaps.FirstOrDefault(a => a.Filename == r.Filename); - + // Only sever the DB → file link when the file is genuinely gone from disk. + // If the delete failed (or was skipped) and the file still exists, leaving + // Filename intact preserves the user's ability to recover without losing + // the chapter history. + if (File.Exists(finalName)) + { + _logger.LogWarning( + "File still exists after cleanup attempt — preserving DB linkage: {File}", finalName); + continue; + } + foreach (SeriesProviderEntity s in series.Sources) { foreach (Chapter ch in s.Chapters.Where(a => a.Filename == r.Filename)) @@ -122,6 +395,163 @@ public async Task CleanupSeriesAsync(Guid seriesId, CancellationToken token = de await _db.SaveChangesAsync(token).ConfigureAwait(false); } + /// <summary> + /// Renames the storage folder (if the title changed) and all chapter files for a series + /// to use the correct title. Also clears the <see cref="SeriesEntity.NeedsRename"/> flag. + /// </summary> + /// <param name="seriesId">The series ID to rename files for</param> + /// <param name="token">Cancellation token</param> + public async Task RenameSeriesFilesAsync(Guid seriesId, CancellationToken token = default) + { + var series = await _db.Series.FirstOrDefaultAsync(s => s.Id == seriesId, token).ConfigureAwait(false); + if (series != null) + { + await RenameStorageFolderIfNeededAsync(series, token).ConfigureAwait(false); + } + + await _archiveHelper.UpdateTitleAndAddComicInfoAsync(seriesId, true, token).ConfigureAwait(false); + + // Clear the flag after a successful rename + if (series != null && series.NeedsRename) + { + series.NeedsRename = false; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + } + + /// <summary> + /// Renames the physical storage folder when the series title no longer matches the folder name. + /// This happens after a truncated title is recovered to its full version. + /// </summary> + private async Task RenameStorageFolderIfNeededAsync(SeriesEntity series, CancellationToken token) + { + SettingsDto settings = await _settings.GetSettingsAsync(token).ConfigureAwait(false); + string currentFullPath = Path.Combine(settings.StorageFolder, series.StoragePath); + + // Compute what the folder name should be based on the current (corrected) title + string expectedFolderName = series.Title.MakeFolderNameSafe(); + string currentFolderName = Path.GetFileName( + series.StoragePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + if (string.Equals(expectedFolderName, currentFolderName, StringComparison.Ordinal)) + return; // Already matches + + // Build the new path by replacing only the last segment (the folder name) + string parentPath = Path.GetDirectoryName(currentFullPath) + ?? settings.StorageFolder; + string newFullPath = Path.Combine(parentPath, expectedFolderName); + + if (!Directory.Exists(currentFullPath)) + { + _logger.LogWarning("Storage folder does not exist, skipping folder rename: {Path}", currentFullPath); + return; + } + + if (Directory.Exists(newFullPath)) + { + _logger.LogWarning("Target folder already exists, skipping folder rename: {Path}", newFullPath); + return; + } + + try + { + Directory.Move(currentFullPath, newFullPath); + + // Update the StoragePath in the DB (relative path from storage root) + string parentRelative = Path.GetDirectoryName(series.StoragePath) + ?? string.Empty; + series.StoragePath = string.IsNullOrEmpty(parentRelative) + ? expectedFolderName + : Path.Combine(parentRelative, expectedFolderName); + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + _logger.LogInformation("Renamed storage folder from \"{Old}\" to \"{New}\"", currentFullPath, newFullPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rename storage folder from \"{Old}\" to \"{New}\"", currentFullPath, newFullPath); + } + } + + /// <summary> + /// Queries the title-source provider for the full series title via GetDetailsAsync (which + /// includes HTML meta-tag recovery for truncated titles). Only updates the series title and + /// that specific provider's title — never touches other providers' titles. + /// Only triggers recovery when the current title is actually truncated (ends with "..."/"…") + /// or when the title-source provider's own title is truncated. + /// </summary> + private async Task TryRecoverTruncatedTitleAsync(SeriesEntity series, CancellationToken token) + { + if (string.IsNullOrEmpty(series.Title)) + return; + + // Find the provider that is the title source (IsTitle flag), falling back to first active + SeriesProviderEntity? titleProvider = series.Sources + .FirstOrDefault(s => s.IsTitle && !s.IsDisabled && !s.IsUninstalled && !s.IsUnknown && !string.IsNullOrEmpty(s.MihonProviderId)); + titleProvider ??= series.Sources + .FirstOrDefault(s => !s.IsDisabled && !s.IsUninstalled && !s.IsUnknown && !string.IsNullOrEmpty(s.MihonProviderId)); + if (titleProvider == null) + return; + + // Only attempt recovery if the series title OR the title provider's title looks truncated + bool seriesTruncated = IsTitleTruncated(series.Title); + bool providerTruncated = IsTitleTruncated(titleProvider.Title); + if (!seriesTruncated && !providerTruncated) + return; + + try + { + var src = await _mihon.SourceFromProviderIdAsync(titleProvider.MihonProviderId!, token).ConfigureAwait(false); + var manga = titleProvider.ToManga(); + if (manga == null) + return; + + var details = await src.GetDetailsAsync(manga, token).ConfigureAwait(false); + if (details == null || string.IsNullOrEmpty(details.Title)) + return; + + bool detailsTruncated = IsTitleTruncated(details.Title); + if (detailsTruncated) + return; // Source also returned truncated — nothing we can do + + bool changed = false; + + // Update the title provider's own title if it was truncated + if (providerTruncated && details.Title.Length > titleProvider.Title.Length) + { + _logger.LogInformation("Updated title-source provider {Provider} title: \"{Old}\" → \"{New}\"", + titleProvider.Provider, titleProvider.Title, details.Title); + titleProvider.Title = details.Title; + changed = true; + } + + // Update series title if the title-source provider now has a longer title + if (details.Title.Length > series.Title.Length) + { + string oldTitle = series.Title; + series.Title = details.Title; + series.NeedsRename = true; + changed = true; + _logger.LogInformation("Recovered full title for series {Id}: \"{OldTitle}\" → \"{NewTitle}\"", + series.Id, oldTitle, details.Title); + } + + if (changed) + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to recover truncated title for series {Id}", series.Id); + } + } + + private static bool IsTitleTruncated(string? title) + { + if (string.IsNullOrEmpty(title)) + return false; + return title.EndsWith("...") || title.EndsWith("\u2026"); + } + /// <summary> /// Updates all series titles and comic info files /// </summary> diff --git a/KaizokuBackend/Services/Series/SeriesCommandService.cs b/KaizokuBackend/Services/Series/SeriesCommandService.cs index 86016644..c10181f1 100644 --- a/KaizokuBackend/Services/Series/SeriesCommandService.cs +++ b/KaizokuBackend/Services/Series/SeriesCommandService.cs @@ -8,9 +8,9 @@ using KaizokuBackend.Services.Downloads; using KaizokuBackend.Services.Helpers; using KaizokuBackend.Services.Images; +using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Jobs.Models; using KaizokuBackend.Services.Settings; -using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using Mihon.ExtensionsBridge.Models.Abstractions; using Mihon.ExtensionsBridge.Models.Extensions; @@ -35,10 +35,13 @@ public class SeriesCommandService private readonly DownloadCommandService _downloadCommand; private readonly MihonBridgeService _mihon; private readonly ThumbCacheService _cache; + private readonly JobManagementService _jobManagement; + private readonly SeriesArchiveService _archiveService; public SeriesCommandService(AppDbContext db, SettingsService settings, ArchiveHelperService archiveHelper, SeriesProviderService providerService, ILogger<SeriesCommandService> logger, - DownloadCommandService downloadCommand, MihonBridgeService mihon, ThumbCacheService cache) + DownloadCommandService downloadCommand, MihonBridgeService mihon, ThumbCacheService cache, + JobManagementService jobManagement, SeriesArchiveService archiveService) { _db = db; _settings = settings; @@ -48,6 +51,8 @@ public SeriesCommandService(AppDbContext db, SettingsService settings, ArchiveHe _downloadCommand = downloadCommand; _mihon = mihon; _cache = cache; + _jobManagement = jobManagement; + _archiveService = archiveService; } /// <summary> @@ -401,7 +406,22 @@ public async Task<JobResult> GetChaptersAsync(Guid seriesProvider, CancellationT _logger.LogWarning("Series Provider {SeriesProvider} has no longer valid Mihon Id", seriesProvider); return JobResult.Failed; } - var series = await _db.Series.Include(a => a.Sources).Where(s => s.Id == serie.SeriesId).AsNoTracking().FirstAsync(token).ConfigureAwait(false); + var series = await _db.Series.Include(a => a.Sources).Where(s => s.Id == serie.SeriesId).FirstAsync(token).ConfigureAwait(false); + + // Link any on-disk archives to their DB chapter rows before evaluating which + // chapters are missing. Done here so the scheduled per-provider sync (every + // 2h by default) and any other GetChapters trigger auto-heal orphan files + // without requiring the user to click Refresh. + try + { + await _archiveService.ReconcileOnDiskArchivesAsync(series, token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, + "GetChaptersAsync: reconciliation failed for series {Id}; continuing with chapter fetch", series.Id); + } + List<ParsedChapter>? chapterData; ISourceInterop src; @@ -414,7 +434,16 @@ public async Task<JobResult> GetChaptersAsync(Guid seriesProvider, CancellationT _logger.LogError(e, "Unable to get Chapter from {mihonProviderId}", serie.MihonProviderId); return JobResult.Failed; } - + + // If the series title ends with "..." or "…", try to recover the full title. + // This catches newly added series with truncated search result titles. + // For older series where "..." was already stripped, the user can click Verify + // on the series page which does an unconditional check against the source. + if (IsTitleTruncated(series.Title)) + { + await TryRecoverTruncatedTitleAsync(series, serie, src, token).ConfigureAwait(false); + } + string provider = src.Name + " (" + src.Language + ")"; _logger.LogInformation("Getting chapters from Series {series} Provider {provider}", serie.Title, provider); chapterData = await _mihon.MihonErrorWrapperAsync( @@ -431,7 +460,175 @@ public async Task<JobResult> GetChaptersAsync(Guid seriesProvider, CancellationT return await _downloadCommand.QueueChapterDownloadsAsync(serie, chaps, token).ConfigureAwait(false); } - // Private helper methods + /// <summary> + /// Queues downloads for missing chapters of a series. When chapterNumbers is + /// null or empty all missing chapters (no Filename) across active providers are + /// enqueued. Otherwise only chapters matching the supplied numbers are enqueued. + /// Returns the count of chapter downloads enqueued. + /// </summary> + public async Task<int> QueueMissingChaptersAsync(Guid id, decimal[]? chapterNumbers, CancellationToken token = default) + { + // Tracking-enabled query (no AsNoTracking) because QueueChapterDownloadsAsync + // mutates Chapter.ShouldDownload via GenerateDownloadsFromChapterData. + Models.Database.SeriesEntity? series = await _db.Series + .Include(s => s.Sources) + .FirstOrDefaultAsync(s => s.Id == id, token) + .ConfigureAwait(false); + + if (series == null) + throw new KeyNotFoundException($"Series with ID {id} not found"); + + // Link any on-disk archives to their DB chapter rows before deciding what is + // "missing". Skipping this allowed Download-All to re-download files already + // present on disk. + // We pass the tracked `series` entity (not the Guid) so that reconcile mutates + // Chapter.Filename on the same EF-tracked instances. The downstream + // pairs.Any(p => !string.IsNullOrEmpty(p.Chapter.Filename)) check therefore + // sees the reconciled filenames without an additional DB round-trip. + try + { + await _archiveService.ReconcileOnDiskArchivesAsync(series, token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, + "QueueMissingChaptersAsync: reconciliation failed for series {Id}; continuing with missing-chapter scan", id); + } + + List<SeriesProviderEntity> activeProviders = series.Sources + .Where(sp => !sp.IsDisabled && !sp.IsUninstalled) + .ToList(); + + bool filterByNumber = chapterNumbers != null && chapterNumbers.Length > 0; + HashSet<decimal>? wantedSet = filterByNumber + ? new HashSet<decimal>(chapterNumbers!) + : null; + + int totalEnqueued = 0; + + // Group chapters by number and pick best provider per chapter. + // Title provider is preferred; fall back to lowest ProviderIndex. + var allPairs = activeProviders + .SelectMany(sp => sp.Chapters + .Where(c => !c.IsDeleted) + .Select(c => (Provider: sp, Chapter: c))) + .ToList(); + + var groups = allPairs + .GroupBy(pair => + pair.Chapter.Number.HasValue + ? (object)Math.Round(pair.Chapter.Number.Value, 2) + : (object)(pair.Provider.Id, pair.Chapter.Name)) + .ToList(); + + foreach (var group in groups) + { + var pairs = group.ToList(); + decimal? chapterNumber = pairs[0].Chapter.Number; + + // Apply number filter + if (filterByNumber) + { + if (!chapterNumber.HasValue || !wantedSet!.Contains(Math.Round(chapterNumber.Value, 2))) + continue; + } + else + { + // Only enqueue missing chapters + if (pairs.Any(p => !string.IsNullOrEmpty(p.Chapter.Filename))) + continue; + } + + // Pick the best provider for this chapter: + // prefer the title provider, then lowest ProviderIndex. + var best = pairs + .OrderByDescending(p => p.Provider.IsTitle ? 1 : 0) + .ThenBy(p => p.Chapter.ProviderIndex) + .First(); + + // Build a synthetic ParsedChapter so we can reuse GenerateDownloadsFromChapterData. + var parsed = new ParsedChapter + { + Index = best.Chapter.ProviderIndex, + ParsedNumber = best.Chapter.Number ?? 0m, + ParsedName = best.Chapter.Name, + Name = best.Chapter.Name ?? string.Empty, + RealUrl = best.Chapter.Url ?? string.Empty, + Scanlator = best.Provider.Scanlator ?? string.Empty, + DateUpload = best.Chapter.ProviderUploadDate.HasValue + ? new DateTimeOffset(best.Chapter.ProviderUploadDate.Value) + : DateTimeOffset.UtcNow, + }; + + List<ChapterDownload> chaps = series.GenerateDownloadsFromChapterData(best.Provider, new List<ParsedChapter> { parsed }); + if (chaps.Count > 0) + { + await _downloadCommand.QueueChapterDownloadsAsync(best.Provider, chaps, token).ConfigureAwait(false); + totalEnqueued += chaps.Count; + } + } + + // Persist any ShouldDownload flag mutations made by GenerateDownloadsFromChapterData + // so the next chapters query (and the periodic GetChapters job) sees them as Queued. + await _db.SaveChangesAsync(token).ConfigureAwait(false); + + _logger.LogInformation("QueueMissingChaptersAsync: enqueued {Count} chapters for series {Id}", totalEnqueued, id); + return totalEnqueued; + } + + /// <summary> + /// Enqueues an immediate GetChapters job at high priority for each active provider + /// of the given series. Returns the count of jobs enqueued. + /// </summary> + public async Task<int> ForceRefreshChaptersAsync(Guid id, CancellationToken token = default) + { + // Link any on-disk archives to their DB chapter rows before re-fetching from + // the provider. Done first so the subsequent AsNoTracking snapshot reflects + // the reconciled state. + try + { + await _archiveService.ReconcileOnDiskArchivesAsync(id, token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, + "ForceRefreshChaptersAsync: reconciliation failed for series {Id}; continuing with chapter refresh", id); + } + + Models.Database.SeriesEntity? series = await _db.Series + .Include(s => s.Sources) + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == id, token) + .ConfigureAwait(false); + + if (series == null) + throw new KeyNotFoundException($"Series with ID {id} not found"); + + List<SeriesProviderEntity> activeProviders = series.Sources + .Where(sp => !sp.IsDisabled && !sp.IsUninstalled) + .ToList(); + + int count = 0; + foreach (SeriesProviderEntity provider in activeProviders) + { + string groupKey = JobBusinessService.BuildProviderGroupKey(provider); + await _jobManagement.EnqueueJobAsync( + JobType.GetChapters, + provider.Id, + Priority.High, + provider.Id.ToString(), + groupKey, + series.Id.ToString(), + "Default", + token).ConfigureAwait(false); + count++; + } + + _logger.LogInformation("ForceRefreshChaptersAsync: enqueued {Count} GetChapters jobs for series {Id}", count, id); + return count; + } + + // Private helper methods private async Task<Models.Database.SeriesEntity?> FindExistingSeriesAsync(AugmentedResponseDto ProviderSeriesDetails, SettingsDto settings, Dictionary<string, Guid> paths, CancellationToken token) { @@ -596,6 +793,69 @@ private class ComboSeries public List<ParsedChapter> Chapters { get; set; } = []; } + /// <summary> + /// Returns true if a title appears to have been truncated by the source (ends with "..." or "…"). + /// </summary> + private static bool IsTitleTruncated(string? title) + { + if (string.IsNullOrEmpty(title)) + return false; + return title.EndsWith("...") || title.EndsWith("\u2026"); + } + + /// <summary> + /// Attempts to recover a full title for a series whose current title is truncated. + /// Calls GetDetailsAsync on the source to fetch the manga detail page, which now + /// includes HTML title recovery. If a longer title is found, updates the DB title + /// and sets <see cref="SeriesEntity.NeedsRename"/> so the user can rename files/folder. + /// </summary> + private async Task TryRecoverTruncatedTitleAsync( + Models.Database.SeriesEntity series, + SeriesProviderEntity provider, + ISourceInterop source, + CancellationToken token) + { + try + { + var manga = provider.ToManga(); + if (manga == null) + return; + + var details = await _mihon.MihonErrorWrapperAsync( + () => source.GetDetailsAsync(manga, token), + "Unable to recover title for {series}", series.Title).ConfigureAwait(false); + + if (details == null || string.IsNullOrEmpty(details.Title)) + return; + + // Only update if the new title is not truncated and is longer + if (!IsTitleTruncated(details.Title) && details.Title.Length > series.Title.Length) + { + string oldTitle = series.Title; + series.Title = details.Title; + series.NeedsRename = true; + + // Also update all provider titles that still have the truncated name + if (series.Sources != null) + { + foreach (var src2 in series.Sources) + { + if (src2.Title.Length < details.Title.Length) + src2.Title = details.Title; + } + } + + await _db.SaveChangesAsync(token).ConfigureAwait(false); + _logger.LogInformation("Recovered full title for series {Id}: \"{OldTitle}\" → \"{NewTitle}\"", + series.Id, oldTitle, details.Title); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to recover truncated title for series {Id}", series.Id); + } + } + } } diff --git a/KaizokuBackend/Services/Series/SeriesExtensions.cs b/KaizokuBackend/Services/Series/SeriesExtensions.cs index bbf858fd..92d50958 100644 --- a/KaizokuBackend/Services/Series/SeriesExtensions.cs +++ b/KaizokuBackend/Services/Series/SeriesExtensions.cs @@ -5,6 +5,7 @@ using KaizokuBackend.Services.Bridge; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using Action = KaizokuBackend.Models.Action; using Mihon.ExtensionsBridge.Models.Extensions; @@ -15,6 +16,7 @@ using Chapter = KaizokuBackend.Models.Chapter; using ImportEntity = KaizokuBackend.Models.Database.ImportEntity; using DbSeriesEntity = KaizokuBackend.Models.Database.SeriesEntity; +using KaizokuBackend.Services.Helpers; using KaizokuBackend.Services.Images; namespace KaizokuBackend.Services.Series; @@ -28,7 +30,11 @@ public static async Task<LatestSerieEntity> PopulateSeriesAsync(this LatestSerie { l.Artist = fs.Artist; l.Provider = source.Name; - l.Language = source.Language; + // Multi-language sources (e.g. NovelCool) report Language="all", which means + // results may be in any language. Detect the title's script so the Browse tab + // can honor the user's preferred-languages filter. Specific-language sources + // keep their explicit code. + l.Language = LanguageDetector.Resolve(source.Language, fs.Title); l.Status = (SeriesStatus)(int)fs.Status; l.Title = fs.Title; if (!string.IsNullOrWhiteSpace(fs.ThumbnailUrl)) @@ -917,6 +923,9 @@ public static SeriesProviderEntity ToSeriesProvider(this ImportProviderSnapshot public static void FillSeriesFromProviderSeriesDetails(this DbSeriesEntity dbSeries, ProviderSeriesDetails consolidatedSeries, decimal? startFromChapter) { + // Title recovery from truncated sources is now handled in SourceInterop.GetDetailsAsync + // (HTML meta tag extraction) and SeriesArchiveService.TryRecoverTruncatedTitleAsync + // (verify button + periodic checks). Just accept whatever title the provider gives us. dbSeries.Title = consolidatedSeries.Title; dbSeries.Description = consolidatedSeries.Description ?? string.Empty; dbSeries.ThumbnailUrl = consolidatedSeries.ThumbnailUrl ?? string.Empty; diff --git a/KaizokuBackend/Services/Series/SeriesProviderService.cs b/KaizokuBackend/Services/Series/SeriesProviderService.cs index ffb68a78..2f4ddc08 100644 --- a/KaizokuBackend/Services/Series/SeriesProviderService.cs +++ b/KaizokuBackend/Services/Series/SeriesProviderService.cs @@ -101,18 +101,38 @@ public async Task<bool> SetMatchAsync(ProviderMatchDto pm, CancellationToken tok SeriesProviderEntity mi = minfo[chap.MatchInfoId.Value]; Chapter? ch = unknown.Chapters.FirstOrDefault(a => Path.GetFileNameWithoutExtension(a.Filename) == chap.Filename); + + if (ch == null) + continue; + Chapter? dst = mi.Chapters.FirstOrDefault(a => a.Number == chap.ChapterNumber); - - if (ch != null && dst != null) + + // If the destination provider doesn't have this chapter number yet, create it + if (dst == null) + { + dst = new Chapter + { + Number = chap.ChapterNumber, + Name = chap.ChapterName, + ShouldDownload = false, + IsDeleted = false, + ProviderUploadDate = ch.ProviderUploadDate, + ProviderIndex = ch.ProviderIndex, + PageCount = ch.PageCount, + }; + mi.Chapters.Add(dst); + mi.Chapters = mi.Chapters.OrderBy(a => a.Number).ToList(); + } + { decimal? maxChap = mi.Chapters.Max(c => c.Number); - string filename = ArchiveHelperService.MakeFileNameSafe(mi.Provider, mi.Scanlator, mi.Title, + string filename = ArchiveHelperService.MakeFileNameSafe(mi.Provider, mi.Scanlator, series.Title, mi.Language, dst.Number, dst.Name, maxChap); string? extension = Path.GetExtension(ch.Filename); string newFilename = filename + extension; string originalPath = Path.Combine(settings.StorageFolder, series.StoragePath, ch.Filename ?? ""); string newPath = Path.Combine(settings.StorageFolder, series.StoragePath, newFilename); - + if (File.Exists(originalPath)) { try @@ -149,8 +169,20 @@ public async Task<bool> SetMatchAsync(ProviderMatchDto pm, CancellationToken tok _db.Touch(unknown, c => c.Chapters); } + // Update ContinueAfterChapter for destination providers that received new chapters if (update) { + foreach (SeriesProviderEntity destProvider in minfo.Values.Distinct()) + { + decimal? maxDownloaded = destProvider.Chapters + .Where(c => !string.IsNullOrEmpty(c.Filename) && !c.IsDeleted) + .Max(c => c.Number); + if (maxDownloaded.HasValue && maxDownloaded > destProvider.ContinueAfterChapter) + { + destProvider.ContinueAfterChapter = maxDownloaded; + } + } + await _db.SaveChangesAsync(token).ConfigureAwait(false); await series.SaveImportSeriesSnapshotToDirectoryAsync(Path.Combine(settings.StorageFolder, series.StoragePath), _logger, token); diff --git a/KaizokuBackend/Services/Series/SeriesQueryService.cs b/KaizokuBackend/Services/Series/SeriesQueryService.cs index 60c66e8d..22741c4c 100644 --- a/KaizokuBackend/Services/Series/SeriesQueryService.cs +++ b/KaizokuBackend/Services/Series/SeriesQueryService.cs @@ -7,6 +7,7 @@ using KaizokuBackend.Services.Settings; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Mihon.ExtensionsBridge.Core.Extensions; using System.Net; @@ -20,13 +21,27 @@ public class SeriesQueryService private readonly AppDbContext _db; private readonly SettingsService _settings; private readonly ProviderCacheService _providerCache; + private readonly IMemoryCache _cache; private readonly ILogger<SeriesQueryService> _logger; - public SeriesQueryService(AppDbContext db, SettingsService settings, ProviderCacheService providerCache, ILogger<SeriesQueryService> logger) + // Hard cap on rows scanned in-memory when a genre filter is applied. The + // Genre column is stored as a value-converted CSV string so EF cannot + // translate List<string> predicates; we stream rows in FetchDate-desc + // order and stop once we have enough matches or hit this cap. + private const int MaxGenreScanRows = 20_000; + + // Cache key + TTL for the distinct-genre aggregation. The Latest table + // only changes on provider fetches, so a short TTL is fine and saves us + // from scanning the whole table on every page load of the browse UI. + private const string LatestGenresCacheKey = "series.latest.genres"; + private static readonly TimeSpan LatestGenresCacheTtl = TimeSpan.FromMinutes(5); + + public SeriesQueryService(AppDbContext db, SettingsService settings, ProviderCacheService providerCache, IMemoryCache cache, ILogger<SeriesQueryService> logger) { - _db = db; + _db = db; _settings = settings; _providerCache = providerCache; + _cache = cache; _logger = logger; } @@ -87,12 +102,13 @@ public async Task<List<SeriesInfoDto>> GetLibraryAsync(CancellationToken token = /// </summary> /// <param name="start">Starting index for pagination</param> /// <param name="count">Number of items to return</param> - /// <param name="sourceid">Optional source ID filter</param> - /// <param name="keyword">Optional keyword filter</param> + /// <param name="mihonProviderId">Optional source (provider) ID filter</param> + /// <param name="keyword">Optional title keyword filter (LIKE %keyword%)</param> + /// <param name="genres">Optional list of tags. A row must carry ALL of them (AND semantics)</param> /// <param name="token">Cancellation token</param> /// <returns>List of latest series information</returns> public async Task<List<LatestSeriesDto>> GetLatestAsync(int start, int count, string? mihonProviderId = null, - string? keyword = null, CancellationToken token = default) + string? keyword = null, IReadOnlyList<string>? genres = null, CancellationToken token = default) { IQueryable<LatestSerieEntity> series = _db.LatestSeries; if (!string.IsNullOrEmpty(mihonProviderId)) @@ -100,15 +116,223 @@ public async Task<List<LatestSeriesDto>> GetLatestAsync(int start, int count, st series = series.Where(a => a.MihonProviderId == mihonProviderId); } + // Honor the user's PreferredLanguages even when a specific source is + // picked — multi-language sources (e.g. NovelCool reporting Language="all") + // would otherwise surface results in scripts the user doesn't want. + // Rows whose Language is empty or "all" are legacy entries written before + // per-title detection; they stay visible so existing data isn't suddenly + // hidden, and the startup backfill + next source refresh will retag them. + List<string> prefs = (await _settings.GetSettingsAsync(token).ConfigureAwait(false)) + .PreferredLanguages?.ToList() ?? new List<string>(); + if (prefs.Count == 0) + prefs.Add("en"); + series = series.Where(a => + a.Language == null + || a.Language == "" + || a.Language == "all" + || prefs.Contains(a.Language)); + if (!string.IsNullOrEmpty(keyword)) series = series.Where(a => EF.Functions.Like(a.Title, $"%{keyword}%")); series = series.OrderByDescending(a => a.FetchDate); - if (start > 0) - series = series.Skip(start); - return (await series.Take(count).ToListAsync(token).ConfigureAwait(false)) - .Select(a => a.ToSeriesInfo()).ToList(); + // Normalize incoming genre filter. Empty list / null means "no filter". + List<string>? normalizedGenres = null; + if (genres != null && genres.Count > 0) + { + normalizedGenres = genres + .Where(g => !string.IsNullOrWhiteSpace(g)) + .Select(g => g.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (normalizedGenres.Count == 0) normalizedGenres = null; + } + + if (normalizedGenres == null) + { + if (start > 0) + series = series.Skip(start); + + return (await series.Take(count).ToListAsync(token).ConfigureAwait(false)) + .Select(a => a.ToSeriesInfo()).ToList(); + } + + // Genre filtering — Genre is stored as a value-converted CSV column + // (List<string> ↔ string), so EF cannot translate a Contains/All + // predicate against it. We stream rows in FetchDate-desc order, + // filter client-side, and stop as soon as we've produced enough + // matches or hit MaxGenreScanRows. AND semantics: a row must + // carry every selected tag to match. + var taken = new List<LatestSerieEntity>(count); + var rangeStart = Math.Max(0, start); + int matched = 0; + int scanned = 0; + + await foreach (var row in series.AsAsyncEnumerable().WithCancellation(token)) + { + scanned++; + if (scanned > MaxGenreScanRows) break; + + if (row.Genre == null || row.Genre.Count == 0) continue; + + var rowSet = new HashSet<string>(row.Genre.Count, StringComparer.OrdinalIgnoreCase); + foreach (var g in row.Genre) + { + var trimmed = g?.Trim(); + if (!string.IsNullOrEmpty(trimmed)) rowSet.Add(trimmed); + } + + bool hasAll = true; + foreach (var want in normalizedGenres) + { + if (!rowSet.Contains(want)) { hasAll = false; break; } + } + if (!hasAll) continue; + + if (matched < rangeStart) { matched++; continue; } + + taken.Add(row); + matched++; + if (taken.Count >= count) break; + } + + return taken.Select(a => a.ToSeriesInfo()).ToList(); + } + + /// <summary> + /// Returns an aggregated chapter list for a series, grouping chapters across + /// all providers by chapter number and computing per-chapter download status. + /// </summary> + /// <param name="id">The series unique identifier.</param> + /// <param name="token">Cancellation token.</param> + /// <returns>Sorted list of ChapterDto, or null if the series is not found.</returns> + public async Task<List<ChapterDto>?> GetChaptersForSeriesAsync(Guid id, CancellationToken token = default) + { + Models.Database.SeriesEntity? series = await _db.Series + .Include(s => s.Sources) + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == id, token) + .ConfigureAwait(false); + + if (series == null) + return null; + + // Each provider carries chapters as a JSON column — no extra Include needed. + // Group active (non-deleted) chapters by chapter number rounded to 2 decimals. + // Chapters with a null number are grouped individually by (providerId, name). + + var result = new List<ChapterDto>(); + + // Collect all (provider, chapter) pairs + var allPairs = series.Sources + .SelectMany(sp => sp.Chapters + .Where(c => !c.IsDeleted) + .Select(c => (Provider: sp, Chapter: c))) + .ToList(); + + // Build grouping key: numbered chapters share a rounded key; null-number chapters + // are isolated so they don't collapse together. + var groups = allPairs + .GroupBy(pair => + pair.Chapter.Number.HasValue + ? (object)Math.Round(pair.Chapter.Number.Value, 2) + : (object)(pair.Provider.Id, pair.Chapter.Name)) + .ToList(); + + foreach (var group in groups) + { + var pairs = group.ToList(); + + // Prefer the entry with a Filename (downloaded) when picking scalar fields. + var downloaded = pairs.FirstOrDefault(p => !string.IsNullOrEmpty(p.Chapter.Filename)); + var representative = downloaded.Chapter != null ? downloaded.Chapter : pairs[0].Chapter; + + // Status calculation (Failed deferred — no direct signal on Chapter model). + ChapterDownloadStatus status; + if (pairs.Any(p => !string.IsNullOrEmpty(p.Chapter.Filename))) + status = ChapterDownloadStatus.Downloaded; + else if (pairs.Any(p => p.Chapter.ShouldDownload)) + status = ChapterDownloadStatus.Queued; + else + status = ChapterDownloadStatus.Missing; + + var dto = new ChapterDto + { + Number = representative.Number, + Name = representative.Name, + PageCount = representative.PageCount, + ProviderUploadDate = representative.ProviderUploadDate, + DownloadDate = representative.DownloadDate, + Filename = representative.Filename, + Status = status, + Providers = pairs.Select(p => new ChapterProviderDto + { + ProviderId = p.Provider.Id, + Provider = p.Provider.Provider, + Scanlator = p.Provider.Scanlator, + Language = p.Provider.Language, + Url = p.Chapter.Url, + ProviderIndex = p.Chapter.ProviderIndex, + IsDownloaded = !string.IsNullOrEmpty(p.Chapter.Filename) + }).ToList() + }; + + result.Add(dto); + } + + // Sort: numbered chapters ascending, null-number chapters at the end. + return result + .OrderBy(c => c.Number.HasValue ? 0 : 1) + .ThenBy(c => c.Number ?? decimal.MaxValue) + .ToList(); + } + + /// <summary> + /// Returns the distinct set of genres/tags present in the cached "Latest" + /// cloud catalogue (filtered by the user's preferred languages), along + /// with the count of titles carrying each tag. Used to populate the tag + /// filter on the browse screen. + /// </summary> + public async Task<List<LatestGenreDto>> GetLatestGenresAsync(CancellationToken token = default) + { + if (_cache.TryGetValue(LatestGenresCacheKey, out List<LatestGenreDto>? cached) && cached != null) + return cached; + + List<string> prefs = (await _settings.GetSettingsAsync(token).ConfigureAwait(false)) + .PreferredLanguages?.ToList() ?? new List<string>(); + if (prefs.Count == 0) prefs.Add("en"); + + // Project the Genre column only so we don't materialize the whole + // row. EF still applies the value converter, giving us a List<string> + // per row to fold into a frequency map. + List<List<string>> genreLists = await _db.LatestSeries.AsNoTracking() + .Where(a => a.Language == null || a.Language == "" || a.Language == "all" || prefs.Contains(a.Language)) + .Select(a => a.Genre) + .ToListAsync(token).ConfigureAwait(false); + + var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); + foreach (var glist in genreLists) + { + if (glist == null) continue; + foreach (var raw in glist) + { + var name = raw?.Trim(); + if (string.IsNullOrEmpty(name)) continue; + counts.TryGetValue(name, out int c); + counts[name] = c + 1; + } + } + + // Sort by popularity, then alphabetical for stability. + var result = counts + .Select(kv => new LatestGenreDto { Name = kv.Key, Count = kv.Value }) + .OrderByDescending(g => g.Count) + .ThenBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + _cache.Set(LatestGenresCacheKey, result, LatestGenresCacheTtl); + return result; } } } \ No newline at end of file diff --git a/KaizokuBackend/Services/ServiceExtensions.cs b/KaizokuBackend/Services/ServiceExtensions.cs index a26fbfa3..25f91ec1 100644 --- a/KaizokuBackend/Services/ServiceExtensions.cs +++ b/KaizokuBackend/Services/ServiceExtensions.cs @@ -1,4 +1,6 @@ -using KaizokuBackend.Migration; +using KaizokuBackend.Authorization; +using KaizokuBackend.Migration; +using KaizokuBackend.Services.Auth; using KaizokuBackend.Services.Background; using KaizokuBackend.Services.Bridge; using KaizokuBackend.Services.Daily; @@ -10,9 +12,11 @@ using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Jobs.Settings; using KaizokuBackend.Services.Providers; +using KaizokuBackend.Services.Requests; using KaizokuBackend.Services.Search; using KaizokuBackend.Services.Series; using KaizokuBackend.Services.Settings; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection.Extensions; namespace KaizokuBackend.Services @@ -85,7 +89,8 @@ public static IServiceCollection AddProviderServices(this IServiceCollection ser // Provider Cache and Storage services.TryAddScoped<ProviderCacheService>(); - + services.TryAddScoped<ProviderHealthCheckService>(); + return services; } @@ -103,7 +108,32 @@ public static IServiceCollection AddDownloadServices(this IServiceCollection ser // Download CQRS Services services.TryAddScoped<DownloadQueryService>(); services.TryAddScoped<DownloadCommandService>(); - + + return services; + } + + public static IServiceCollection AddAuthServices(this IServiceCollection services) + { + // Singleton auth-settings cache — allows middleware to read AuthenticationEnabled + // without a DB hit on every request. Updated by SettingsService after each save. + services.TryAddSingleton<IAuthSettingsCache, AuthSettingsCache>(); + + services.TryAddScoped<OpdsPathGenerator>(); + services.TryAddScoped<AuthService>(); + services.TryAddScoped<UserService>(); + services.TryAddScoped<UserInviteService>(); + services.TryAddScoped<PermissionService>(); + services.TryAddScoped<PermissionPresetService>(); + services.TryAddScoped<InviteLinkService>(); + services.TryAddScoped<UserPreferencesService>(); + services.TryAddScoped<MangaRequestService>(); + + // Authorization handlers — must use AddSingleton (not TryAdd) to replace the + // DefaultAuthorizationPolicyProvider already registered by AddAuthorization() + services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>(); + services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>(); + services.AddScoped<IAuthorizationHandler, AdminAuthorizationHandler>(); + return services; } } diff --git a/KaizokuBackend/Services/Settings/SettingsService.cs b/KaizokuBackend/Services/Settings/SettingsService.cs index 275637f1..d6ebee26 100644 --- a/KaizokuBackend/Services/Settings/SettingsService.cs +++ b/KaizokuBackend/Services/Settings/SettingsService.cs @@ -2,6 +2,7 @@ using KaizokuBackend.Models.Database; using KaizokuBackend.Models.Dto; using KaizokuBackend.Models.Enums; +using KaizokuBackend.Services.Auth; using KaizokuBackend.Services.Background; using KaizokuBackend.Services.Bridge; using KaizokuBackend.Services.Jobs; @@ -21,15 +22,16 @@ public class SettingsService private readonly IConfiguration _config; private readonly AppDbContext _db; private readonly IServiceScopeFactory _prov; + private readonly IAuthSettingsCache _authSettingsCache; private static SettingsDto? _settings; - public SettingsService(IConfiguration config, IServiceScopeFactory prov, AppDbContext db) + public SettingsService(IConfiguration config, IServiceScopeFactory prov, AppDbContext db, IAuthSettingsCache authSettingsCache) { _config = config; _db = db; _prov = prov; - + _authSettingsCache = authSettingsCache; } @@ -182,6 +184,17 @@ public async Task SetTimesSettingsAsync(EditableSettingsDto set, CancellationTok public async Task SaveSettingsAsync(EditableSettingsDto set, bool force = false, CancellationToken token = default) { + if (set.AuthenticationEnabled && _settings?.AuthenticationEnabled != true) + { + var adminHasPassword = await _db.Users + .AsNoTracking() + .AnyAsync(u => u.IsActive && u.Role == KaizokuBackend.Models.Enums.UserRole.Admin && u.PasswordHash != null, token) + .ConfigureAwait(false); + + if (!adminHasPassword) + throw new AuthLockoutException("Cannot enable authentication: no admin account has a password set."); + } + if (set.NumberOfSimultaneousDownloads != _settings?.NumberOfSimultaneousDownloads || set.ChapterDownloadFailRetries != _settings?.ChapterDownloadFailRetries || set.ChapterDownloadFailRetryTime != _settings?.ChapterDownloadFailRetryTime || @@ -267,8 +280,9 @@ await bridgeManager.SetPreferencesAsync(new Preferences if (needSave) await _db.SaveChangesAsync(token).ConfigureAwait(false); _settings = GetFromEditableSettings(set); + _authSettingsCache.Update(_settings.AuthenticationEnabled); } - + public async Task SaveSettingsAsync(SettingsDto settings, bool force, CancellationToken token = default) { // Convert Settings to EditableSettings since the existing logic works with EditableSettings @@ -299,7 +313,12 @@ public async Task SaveSettingsAsync(SettingsDto settings, bool force, Cancellati SocksProxyVersion = settings.SocksProxyVersion, SocksProxyUsername = settings.SocksProxyUsername, SocksProxyPassword = settings.SocksProxyPassword, - NsfwVisibility = settings.NsfwVisibility + NsfwVisibility = settings.NsfwVisibility, + MaxPendingRequestsPerUser = settings.MaxPendingRequestsPerUser, + DefaultPermissionPresetId = settings.DefaultPermissionPresetId, + RegistrationEnabled = settings.RegistrationEnabled, + AuthenticationEnabled = settings.AuthenticationEnabled, + ExternalDomain = settings.ExternalDomain }; @@ -335,7 +354,12 @@ public SettingsDto GetFromEditableSettings(EditableSettingsDto ed) SocksProxyVersion = ed.SocksProxyVersion, SocksProxyUsername = ed.SocksProxyUsername, SocksProxyPassword = ed.SocksProxyPassword, - NsfwVisibility = ed.NsfwVisibility + NsfwVisibility = ed.NsfwVisibility, + MaxPendingRequestsPerUser = ed.MaxPendingRequestsPerUser, + DefaultPermissionPresetId = ed.DefaultPermissionPresetId, + RegistrationEnabled = ed.RegistrationEnabled, + AuthenticationEnabled = ed.AuthenticationEnabled, + ExternalDomain = ed.ExternalDomain }; set.StorageFolder = _config["StorageFolder"] ?? string.Empty; @@ -361,6 +385,7 @@ public async ValueTask<SettingsDto> GetSettingsAsync(CancellationToken token = d } if (needSave) await SaveSettingsAsync(_settings, true, token).ConfigureAwait(false); + _authSettingsCache.Update(_settings.AuthenticationEnabled); return _settings; } } diff --git a/KaizokuBackend/Startup.cs b/KaizokuBackend/Startup.cs index ecfb7e9c..5fa717d5 100644 --- a/KaizokuBackend/Startup.cs +++ b/KaizokuBackend/Startup.cs @@ -2,19 +2,24 @@ using KaizokuBackend.Hubs; using KaizokuBackend.Models; using KaizokuBackend.Services; +using KaizokuBackend.Services.Auth; using KaizokuBackend.Services.Background; using KaizokuBackend.Utils; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Mihon.ExtensionsBridge.Core.Extensions; using Mihon.ExtensionsBridge.Models.Configuration; using Serilog; using Serilog.Extensions.Logging; using sun.java2d; +using System.Text; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace KaizokuBackend @@ -68,9 +73,13 @@ public void ConfigureServices(IServiceCollection services) .AllowAnyHeader() .AllowAnyMethod().AllowCredentials(); #else - policy.AllowAnyOrigin() + // Self-hosted app: users deploy behind arbitrary reverse proxies so we + // must accept any origin. Auth uses Bearer tokens (not cookies), which + // mitigates CSRF risk that AllowCredentials + wildcard origin would pose. + policy.SetIsOriginAllowed(_ => true) .AllowAnyHeader() - .AllowAnyMethod(); + .AllowAnyMethod() + .AllowCredentials(); #endif }); }); @@ -84,6 +93,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSignalR(); services.AddHttpContextAccessor(); services.AddMemoryCache(); + services.AddOutputCache(o => o.AddPolicy("images", b => b.Expire(TimeSpan.FromSeconds(300)).SetVaryByRouteValue("key"))); services.Configure<Paths>(a => { a.BridgeFolder = Configuration.GetValue<string>("BridgeFolder", "extensions"); @@ -93,8 +103,56 @@ public void ConfigureServices(IServiceCollection services) { options.CachePath = Configuration.GetValue<string>("ThumbCacheFolder", "thumbs"); options.AgeInDays = Configuration.GetValue<int>("CacheCheckInDays", 7); + options.MaxImageConcurrency = Configuration.GetValue<int>("MaxImageConcurrency", 12); }); + // JWT Authentication + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + // We can't resolve the key at ConfigureServices time since DB may not exist yet. + // Use IssuerSigningKeyResolver to lazily resolve the key from the database. + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = "KaizokuNET", + ValidAudience = "KaizokuNET", + ClockSkew = TimeSpan.FromMinutes(1) + }; + + // SignalR JWT auth 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("/progress")) + { + context.Token = accessToken; + } + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + return Task.CompletedTask; + } + }; + + }); + + // PostConfigure JWT options to resolve signing key from database at runtime + services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, KaizokuBackend.Authorization.JwtKeyResolver>(); + + services.AddAuthorization(); + services.AddExtensionsBridge(); // Add consolidated services @@ -106,6 +164,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDownloadServices(); services.AddHelperServices(); services.AddBackgroundServices(); + services.AddAuthServices(); // Configure ForwardedHeaders to support reverse proxy SSL termination. // Without this, Kestrel is unaware of the original HTTPS scheme when deployed @@ -121,7 +180,7 @@ public void ConfigureServices(IServiceCollection services) }); // Register AppDbContext with SQLite provider, using the connection string from configuration (now points to runtime/kaizoku.db) - services.AddDbContext<AppDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"))); + services.AddDbContextPool<AppDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("DefaultConnection")), poolSize: 64); services.AddHostedService<StartupHostedService>(); } @@ -153,6 +212,23 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) // Order matters for the following middleware app.UseRouting(); + // Bootstrap mode: maintains the _hasUsers cache used by /api/auth/status and + // first-user detection. No longer blocks requests — that is handled below. + app.UseMiddleware<KaizokuBackend.Authorization.BootstrapModeMiddleware>(); + + // JWT bearer validation: populates context.User (ClaimsPrincipal) from the + // Authorization: Bearer header when a valid token is present. + app.UseAuthentication(); + + // Resolves the current UserEntity into HttpContext.Items["User"]. + // In disabled mode uses the X-Kaizoku-User header; in enabled mode validates + // that context.User is authenticated and maps the UserId claim to the entity. + // Must run AFTER UseAuthentication (needs context.User) and BEFORE UseAuthorization. + app.UseMiddleware<KaizokuBackend.Services.Auth.AuthMiddleware>(); + + app.UseAuthorization(); + app.UseOutputCache(); + // Configure static file serving with proper MIME types for .txt files var provider = new FileExtensionContentTypeProvider(); // Add or update .txt mapping to ensure react/next.js fragments work diff --git a/KaizokuBackend/appsettings.json b/KaizokuBackend/appsettings.json index f037e32d..1f696fb1 100644 --- a/KaizokuBackend/appsettings.json +++ b/KaizokuBackend/appsettings.json @@ -1,6 +1,7 @@ { "StorageFolder": "", "BridgeFolder": "mihon", + "MaxImageConcurrency": 12, "ThumbCacheFolder": "thumbs", "TempFolder": "", "CacheCheckInDays": 7, @@ -29,6 +30,9 @@ "SocksProxyUsername":"", "SocksProxyPassword":"" }, + "Authentication": { + "ResetFirstAdminPassword": false + }, "ConnectionStrings": { "DefaultConnection": "Data Source=kaizoku.db" }, diff --git a/KaizokuBackend/scripts/entrypoint.sh b/KaizokuBackend/scripts/entrypoint.sh index f4d484ee..9b67bf51 100644 --- a/KaizokuBackend/scripts/entrypoint.sh +++ b/KaizokuBackend/scripts/entrypoint.sh @@ -46,5 +46,16 @@ fi # Add IKVM library directories to LD_LIBRARY_PATH export LD_LIBRARY_PATH="${IKVM_LIB_PATH}:${LD_LIBRARY_PATH}" +# Thread-resource backstop: IKVM JVM + JCEF Chromium + Xvfb are thread-heavy; +# without these limits the kernel can refuse pthread_create and OOM the process. +# ulimit -s 1024 (1024 KB) is the tested baseline that allows CLR/JVM/JCEF/Chromium +# to start cleanly on aarch64; operators may lower toward 512 only after validating +# that Chromium, JCEF, and the CLR all start without abort on their specific kernel. +# ulimit -u 4096 caps nproc so refusal is bounded and catchable. +# These are set here (before exec gosu) so they are inherited by the privilege- +# dropped process tree that gosu hands off to. +ulimit -s 1024 || echo "warn: could not set thread stack ulimit (non-fatal)" +ulimit -u 4096 || echo "warn: could not set nproc ulimit (non-fatal)" + # Run the app as the correct user exec gosu "$user_name" xvfb-run --auto-servernum /app/KaizokuBackend diff --git a/KaizokuFrontend/.npmrc b/KaizokuFrontend/.npmrc new file mode 100644 index 00000000..68e73cc3 --- /dev/null +++ b/KaizokuFrontend/.npmrc @@ -0,0 +1,4 @@ +auto-install-peers=true +shamefully-hoist=true +strict-peer-dependencies=false +build-from-source=false diff --git a/KaizokuFrontend/.pnpm-packagerc.json b/KaizokuFrontend/.pnpm-packagerc.json new file mode 100644 index 00000000..5c6c6319 --- /dev/null +++ b/KaizokuFrontend/.pnpm-packagerc.json @@ -0,0 +1,8 @@ +{ + "allowBuilds": { + "@tailwindcss/oxide": true, + "kaizoku": true, + "sharp": true, + "unrs-resolver": true + } +} diff --git a/KaizokuFrontend/.pnpm-scripts-allowlist.json b/KaizokuFrontend/.pnpm-scripts-allowlist.json new file mode 100644 index 00000000..5c6c6319 --- /dev/null +++ b/KaizokuFrontend/.pnpm-scripts-allowlist.json @@ -0,0 +1,8 @@ +{ + "allowBuilds": { + "@tailwindcss/oxide": true, + "kaizoku": true, + "sharp": true, + "unrs-resolver": true + } +} diff --git a/KaizokuFrontend/.pnpm-store-config b/KaizokuFrontend/.pnpm-store-config new file mode 100644 index 00000000..71ed82e0 --- /dev/null +++ b/KaizokuFrontend/.pnpm-store-config @@ -0,0 +1,3 @@ +shamefully-hoist=true +store-dir=.pnpm-store +ignore-scripts=false diff --git a/KaizokuFrontend/pnpm-lock.yaml b/KaizokuFrontend/pnpm-lock.yaml index ff9ed6b1..ec71ae4b 100644 --- a/KaizokuFrontend/pnpm-lock.yaml +++ b/KaizokuFrontend/pnpm-lock.yaml @@ -2432,6 +2432,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -3545,6 +3546,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me text-extensions@2.4.0: resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} diff --git a/KaizokuFrontend/pnpm-workspace.yaml b/KaizokuFrontend/pnpm-workspace.yaml index 64427fc4..02c4d54e 100644 --- a/KaizokuFrontend/pnpm-workspace.yaml +++ b/KaizokuFrontend/pnpm-workspace.yaml @@ -1,2 +1,7 @@ +allowBuilds: + '@tailwindcss/oxide': set this to true or false + kaizoku: set this to true or false + sharp: set this to true or false + unrs-resolver: set this to true or false ignoredBuiltDependencies: - kaizoku diff --git a/KaizokuFrontend/public/offline.html b/KaizokuFrontend/public/offline.html new file mode 100644 index 00000000..256eb136 --- /dev/null +++ b/KaizokuFrontend/public/offline.html @@ -0,0 +1,156 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> + <title>Offline — Kaizoku.NET + + + +
+ + + + +

You're offline

+

Kaizoku.NET needs an internet connection to load your manga library. Check your connection and try again.

+ + + +

Kaizoku.NET

+
+ + diff --git a/KaizokuFrontend/public/site.webmanifest b/KaizokuFrontend/public/site.webmanifest index 45dc8a20..94982260 100644 --- a/KaizokuFrontend/public/site.webmanifest +++ b/KaizokuFrontend/public/site.webmanifest @@ -1 +1,43 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "Kaizoku.NET", + "short_name": "Kaizoku", + "description": "Manga series management and downloader", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ], + "start_url": "/library", + "scope": "/", + "theme_color": "#7f1d3a", + "background_color": "#0b0a09", + "display": "standalone", + "orientation": "any", + "categories": ["entertainment", "utilities"], + "lang": "en", + "dir": "ltr" +} diff --git a/KaizokuFrontend/public/sw.js b/KaizokuFrontend/public/sw.js new file mode 100644 index 00000000..0d7efefc --- /dev/null +++ b/KaizokuFrontend/public/sw.js @@ -0,0 +1,202 @@ +// Kaizoku.NET Service Worker +// Cache versioning — increment CACHE_VERSION on each deploy to bust old caches +const CACHE_VERSION = "v2"; +const STATIC_CACHE = `kaizoku-static-${CACHE_VERSION}`; +const DYNAMIC_CACHE = `kaizoku-dynamic-${CACHE_VERSION}`; +const OFFLINE_URL = "/offline.html"; + +// Static assets to pre-cache on install +const STATIC_ASSETS = [ + "/", + "/offline.html", + "/kaizoku.net.png", + "/favicon.ico", + "/favicon-16x16.png", + "/favicon-32x32.png", + "/android-chrome-192x192.png", + "/android-chrome-512x512.png", + "/apple-touch-icon.png", + "/site.webmanifest", +]; + +// ─── Install ───────────────────────────────────────────────────────────────── + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(STATIC_CACHE) + .then((cache) => + // Cache each asset individually so a single failure (e.g. Cloudflare + // Access redirect) doesn't prevent the SW from installing. + Promise.all( + STATIC_ASSETS.map((url) => + cache.add(url).catch((err) => { + console.warn(`[SW] Failed to pre-cache ${url}:`, err.message); + }) + ) + ) + ) + .then(() => self.skipWaiting()) + ); +}); + +// ─── Activate ──────────────────────────────────────────────────────────────── + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((cacheNames) => { + return Promise.all( + cacheNames + .filter( + (name) => + name !== STATIC_CACHE && name !== DYNAMIC_CACHE + ) + .map((name) => caches.delete(name)) + ); + }) + .then(() => self.clients.claim()) + ); +}); + +// ─── Fetch ──────────────────────────────────────────────────────────────────── + +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== "GET") return; + + // Skip browser-extension and chrome-extension requests + if (!url.protocol.startsWith("http")) return; + + // Skip cross-origin requests (e.g. Cloudflare Access login redirects) + if (url.origin !== self.location.origin) return; + + // ── API requests: network-first, no cache fallback ─────────────────────── + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/hub/")) { + event.respondWith( + fetch(request).catch(() => { + // Return a JSON error for API failures while offline + return new Response( + JSON.stringify({ error: "You are offline. Please reconnect." }), + { + status: 503, + headers: { "Content-Type": "application/json" }, + } + ); + }) + ); + return; + } + + // ── Next.js data fetching (_next/data): network-first ──────────────────── + if (url.pathname.startsWith("/_next/data/")) { + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + return cached ?? new Response(JSON.stringify({}), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + }) + ); + return; + } + + // ── Static assets (_next/static, images, fonts): cache-first ──────────── + if ( + url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith("/_next/image/") || + /\.(png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|webmanifest)$/.test(url.pathname) + ) { + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request) + .then((response) => { + // Detect cross-origin redirects (e.g. Cloudflare Access login). + // response.redirected will be true and the URL will differ from + // the original request, but for opaque redirects the fetch may + // simply fail. Guard against non-OK responses too. + if ( + response.ok && + response.url && + new URL(response.url).origin === self.location.origin + ) { + const clone = response.clone(); + caches.open(STATIC_CACHE).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(() => { + // Network error or CORS failure (e.g. Cloudflare Access redirect). + // Return a minimal valid response so the browser doesn't retry + // endlessly. For webmanifest specifically, return an empty + // manifest so the PWA system stays quiet. + if (/\.webmanifest$/.test(url.pathname)) { + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/manifest+json" }, + }); + } + return new Response("", { status: 503 }); + }); + }) + ); + return; + } + + // ── Navigation requests: network-first, offline fallback ───────────────── + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + if (cached) return cached; + const offlinePage = await caches.match(OFFLINE_URL); + return offlinePage ?? new Response("Offline", { status: 503 }); + }) + ); + return; + } + + // ── Default: stale-while-revalidate ────────────────────────────────────── + event.respondWith( + caches.match(request).then((cached) => { + const fetchPromise = fetch(request).then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => cache.put(request, clone)); + } + return response; + }); + return cached ?? fetchPromise; + }) + ); +}); + +// ─── Message Handling ──────────────────────────────────────────────────────── + +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); diff --git a/KaizokuFrontend/src/app/cloud-latest/layout.tsx b/KaizokuFrontend/src/app/cloud-latest/layout.tsx index 9272051b..6cb9eb87 100644 --- a/KaizokuFrontend/src/app/cloud-latest/layout.tsx +++ b/KaizokuFrontend/src/app/cloud-latest/layout.tsx @@ -1,8 +1,7 @@ "use client"; -import React from 'react'; -import KzkHeader from "@/components/kzk/layout/header"; -import KzkSidebar from "@/components/kzk/layout/sidebar"; +import React from "react"; +import { PageLayout } from "@/components/kzk/layout/page-layout"; export default function CloudLatestLayout({ children, @@ -10,13 +9,6 @@ export default function CloudLatestLayout({ children: React.ReactNode; }) { return ( -
- -
- -
{children} -
-
-
+ {children} ); } diff --git a/KaizokuFrontend/src/app/cloud-latest/page.tsx b/KaizokuFrontend/src/app/cloud-latest/page.tsx index ae7bdaaa..9b8900c8 100644 --- a/KaizokuFrontend/src/app/cloud-latest/page.tsx +++ b/KaizokuFrontend/src/app/cloud-latest/page.tsx @@ -1,7 +1,10 @@ "use client"; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { Sparkles, Globe } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { createPortal } from "react-dom"; +import { getResponsiveCardDefault } from "@/lib/utils/responsive-card-default"; +import { Globe, Tag, X, Check, Search, Compass, Plus } from "lucide-react"; import { Select, SelectTrigger, @@ -9,24 +12,35 @@ import { SelectItem, SelectValue, } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; import ReactCountryFlag from "react-country-flag"; import { getCountryCodeForLanguage } from "@/lib/utils/language-country-mapping"; import { useSearch } from "@/contexts/search-context"; -import { useSearchSources, useLatest } from "@/lib/api/hooks/useSeries"; +import { RibbonSlot } from "@/components/kzk/layout/ribbon"; +import { useSearchSources, useLatest, useLatestGenres } from "@/lib/api/hooks/useSeries"; import { seriesService } from "@/lib/api/services/seriesService"; import { useQueryClient } from '@tanstack/react-query'; import { CloudLatestGrid } from "@/components/kzk/series/cloud-latest-grid"; -import { type LatestSeriesInfo } from "@/lib/api/types"; +import { InLibraryStatus, type LatestSeriesInfo, type LatestGenre } from "@/lib/api/types"; import { useDebounce } from "@/lib/hooks/useDebounce"; +import { + SpotlightHero, + type SpotlightItem, +} from "@/components/kzk/series/spotlight-hero"; +import { AddSeries } from "@/components/kzk/series/add-series"; const ITEMS_PER_PAGE = 40; // Increased to ensure screen fill +const MAX_VISIBLE_GENRES = 200; +const TAG_POPOVER_MAX_WIDTH_PX = 352; // matches w-[min(22rem,calc(100vw-2rem))] on the popover root // Calculate optimal items per page based on card width and screen size function calculateItemsPerPage(cardWidth: string): number { // Card width mappings (in rem, then converted to px) const cardWidths: Record = { "w-20": 5 * 16, // 80px - "w-32": 8 * 16, // 128px + "w-32": 8 * 16, // 128px "w-45": 11.25 * 16, // 180px "w-58": 14.5 * 16, // 232px "w-70": 17.5 * 16, // 280px @@ -40,46 +54,92 @@ function calculateItemsPerPage(cardWidth: string): number { // Estimate screen dimensions const screenWidth = typeof window !== "undefined" ? window.innerWidth : 1920; const screenHeight = typeof window !== "undefined" ? window.innerHeight : 1080; - + // Account for sidebar, padding, header (roughly 300px total) const availableWidth = screenWidth - 300; const availableHeight = screenHeight - 200; - + // Calculate columns and rows that fit on screen const columns = Math.floor((availableWidth + gap) / (cardWidthPx + gap)); const rows = Math.floor((availableHeight + gap) / (cardHeight + gap)); - + // We want to fetch 2-3 screens worth to ensure infinite scroll works const itemsPerScreen = Math.max(columns * rows, 12); // Minimum 12 items const optimalFetch = Math.max(itemsPerScreen * 2, 40); // At least 2 screens worth, minimum 40 - + return optimalFetch; } -export default function CloudLatestPage() { - // Session storage keys - const SESSION_KEYS = { - sourceId: "kzk_cloud_sourceId", - cardWidth: "kzk_cloud_cardWidth", - search: "kzk_cloud_search", - }; +// Session storage keys for the cloud-latest page. +const SESSION_KEYS = { + sourceId: "kzk_cloud_sourceId", + cardWidth: "kzk_cloud_cardWidth", + search: "kzk_cloud_search", + genres: "kzk_cloud_genres", +}; + +// Read a value from sessionStorage, returning fallback when absent or empty. +function getSessionValue(key: string, fallback: string | null): string | null { + if (typeof window === "undefined") return fallback; + const value = sessionStorage.getItem(key); + return value !== null && value !== "" ? value : fallback; +} - // Read initial values from sessionStorage - function getSessionValue(key: string, fallback: string | null): string | null { - if (typeof window === "undefined") return fallback; - const value = sessionStorage.getItem(key); - return value !== null && value !== "" ? value : fallback; +function getSessionGenres(): string[] { + if (typeof window === "undefined") return []; + try { + const raw = sessionStorage.getItem(SESSION_KEYS.genres); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter((v): v is string => typeof v === "string"); + } + return []; + } catch { + return []; } +} + +// Match the cache-key shape used by useLatest so manual setQueryData hits. +function buildGenreKey(genres: string[]): string[] { + return genres + .map((g) => g.trim().toLowerCase()) + .filter((g) => g.length > 0) + .sort(); +} + +export default function CloudLatestPage() { + const router = useRouter(); + + // Add Series modal (controlled) — opened from the spotlight CTA with the + // active spotlight item's title prefilled as the search keyword. + const [addModalOpen, setAddModalOpen] = useState(false); + const [addModalTitle, setAddModalTitle] = useState( + undefined, + ); const [selectedSourceId, setSelectedSourceIdState] = useState( getSessionValue(SESSION_KEYS.sourceId, null) ); - const [cardWidth, setCardWidthState] = useState(getSessionValue(SESSION_KEYS.cardWidth, "w-45")!); + const [cardWidth, setCardWidthState] = useState(getSessionValue(SESSION_KEYS.cardWidth, getResponsiveCardDefault())!); + const [selectedGenres, setSelectedGenresState] = useState(getSessionGenres()); const [items, setItems] = useState([]); + // The spotlight pool is derived from the FIRST page of results only — it + // must not change as the user scrolls and more pages arrive. We snapshot + // here whenever currentPage === 0 returns data. + const [firstPageItems, setFirstPageItems] = useState([]); const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); + // Tag-filter popover state + const [tagPopoverOpen, setTagPopoverOpen] = useState(false); + const [tagSearch, setTagSearch] = useState(""); + const tagPopoverRef = useRef(null); + const tagButtonRef = useRef(null); + const tagSearchInputRef = useRef(null); + const [tagPopoverPos, setTagPopoverPos] = useState<{ top: number; left: number; width: number } | null>(null); + // Track user activity for periodic refresh logic const lastActivityRef = useRef(Date.now()); const lastLatestDataRef = useRef(null); @@ -104,8 +164,37 @@ export default function CloudLatestPage() { sessionStorage.setItem(SESSION_KEYS.cardWidth, v); }; + const setSelectedGenres = useCallback( + (next: string[]) => { + setSelectedGenresState(next); + if (typeof window !== "undefined") { + sessionStorage.setItem(SESSION_KEYS.genres, JSON.stringify(next)); + } + }, + [SESSION_KEYS.genres] + ); + + const toggleGenre = useCallback( + (name: string) => { + setSelectedGenresState((prev) => { + const exists = prev.includes(name); + const next = exists ? prev.filter((g) => g !== name) : [...prev, name]; + if (typeof window !== "undefined") { + sessionStorage.setItem(SESSION_KEYS.genres, JSON.stringify(next)); + } + return next; + }); + }, + [SESSION_KEYS.genres] + ); + + const clearGenres = useCallback(() => { + setSelectedGenres([]); + }, [setSelectedGenres]); + const { debouncedSearchTerm } = useSearch(); const { data: sources } = useSearchSources(); + const { data: genresData, isLoading: isGenresLoading } = useLatestGenres(); // Card width options (same as main page) const cardWidthOptions = [ @@ -135,34 +224,50 @@ export default function CloudLatestPage() { return () => searchInput.removeEventListener("input", handler); }, [SESSION_KEYS.search]); + // Memoize a stable signature of selectedGenres so reset effect only fires + // on actual content change (not array identity churn). + const selectedGenresSignature = useMemo( + () => buildGenreKey(selectedGenres).join("|"), + [selectedGenres] + ); + // Reset pagination when filters change (but NOT for card width changes) useEffect(() => { setItems([]); + setFirstPageItems([]); setCurrentPage(0); setHasMore(true); - }, [debouncedSearchTerm, selectedSourceId]); // Removed debouncedCardWidth + }, [debouncedSearchTerm, selectedSourceId, selectedGenresSignature]); // Calculate dynamic items per page based on debounced card size for API calls const debouncedItemsPerPage = useMemo(() => { return calculateItemsPerPage(debouncedCardWidth); }, [debouncedCardWidth]); + // Genres to pass to the API/hook — undefined when none selected so callers + // can treat it as "no filter" and skip the param entirely. + const genresArg = useMemo( + () => (selectedGenres.length > 0 ? selectedGenres : undefined), + [selectedGenres] + ); + // Fetch latest series data const { data: latestData, isLoading, error } = useLatest( currentPage * debouncedItemsPerPage, debouncedItemsPerPage, selectedSourceId ?? undefined, debouncedSearchTerm ?? undefined, + genresArg, true ); // Check if we need to load more items when card size changes useEffect(() => { if (!items.length) return; // Don't trigger on initial load or filter changes - + const currentItemsOnScreen = items.length; const newRequiredItems = debouncedItemsPerPage; - + // If we need more items to fill the screen and we have more available if (currentItemsOnScreen < newRequiredItems && hasMore && !isLoading && !isLoadingMore) { setIsLoadingMore(true); @@ -177,7 +282,7 @@ export default function CloudLatestPage() { }; const events = ['mousedown', 'keypress', 'scroll', 'touchstart', 'click']; - + events.forEach(event => { document.addEventListener(event, updateActivity, true); }); @@ -195,44 +300,50 @@ export default function CloudLatestPage() { const now = Date.now(); const timeSinceLastActivity = now - lastActivityRef.current; const oneMinuteInMs = 60 * 1000; - + // Only refresh if user has been idle for at least 1 minute if (timeSinceLastActivity < oneMinuteInMs) return; try { // Get fresh data from server for the first page only + const refreshGenres = selectedGenres.length > 0 ? selectedGenres : undefined; const freshLatestData = await seriesService.getLatest( 0, // Always refresh first page debouncedItemsPerPage, selectedSourceId ?? undefined, - debouncedSearchTerm ?? undefined + debouncedSearchTerm ?? undefined, + refreshGenres ); - + // Compare with previous data using memo-like logic - const hasChanges = !lastLatestDataRef.current || + const hasChanges = !lastLatestDataRef.current || JSON.stringify(lastLatestDataRef.current) !== JSON.stringify(freshLatestData); if (hasChanges) { - // Update the query cache with fresh data - queryClient.setQueryData(['series', 'latest', { - offset: 0, - limit: debouncedItemsPerPage, - sourceId: selectedSourceId ?? undefined, - searchTerm: debouncedSearchTerm ?? undefined - }], freshLatestData); - + // Update the query cache with fresh data. The key shape MUST match + // useLatest's queryKey: ['series', 'latest', start, count, sourceId, keyword, genreKey] + const genreKey = buildGenreKey(selectedGenres); + queryClient.setQueryData( + ['series', 'latest', 0, debouncedItemsPerPage, selectedSourceId ?? undefined, debouncedSearchTerm ?? undefined, genreKey], + freshLatestData + ); + // Store the new data for next comparison lastLatestDataRef.current = freshLatestData; - - console.log('Latest data refreshed due to changes detected (user idle)'); + + if (process.env.NODE_ENV !== "production") { + console.log('Latest data refreshed due to changes detected (user idle)'); + } } } catch (error) { - console.error('Failed to refresh latest data:', error); + if (process.env.NODE_ENV !== "production") { + console.error('Failed to refresh latest data:', error); + } } }, 60000); // Check every 60 seconds return () => clearInterval(interval); - }, [selectedSourceId, debouncedSearchTerm, debouncedItemsPerPage, queryClient]); + }, [selectedSourceId, debouncedSearchTerm, debouncedItemsPerPage, selectedGenres, queryClient]); // Store latest data for comparison on each update useEffect(() => { @@ -245,13 +356,14 @@ export default function CloudLatestPage() { useEffect(() => { if (latestData) { if (currentPage === 0) { - // First page - replace all items + // First page - replace all items and snapshot for the spotlight pool setItems(latestData); + setFirstPageItems(latestData); } else { // Subsequent pages - append items setItems(prevItems => [...prevItems, ...latestData]); } - + // Since the Latest endpoint doesn't provide metadata about total count, // we infer hasMore from the response size: // - If we get exactly debouncedItemsPerPage items, there are likely more @@ -264,7 +376,7 @@ export default function CloudLatestPage() { // Load more function for infinite scroll const loadMore = useCallback(() => { if (!hasMore || isLoading || isLoadingMore) return; - + setIsLoadingMore(true); setCurrentPage(prev => prev + 1); }, [hasMore, isLoading, isLoadingMore]); @@ -275,15 +387,165 @@ export default function CloudLatestPage() { return [...sources].sort((a, b) => a.provider.localeCompare(b.provider)); }, [sources]); + // Spotlight pool — derived from the first page of latest results. Filters + // out series that are already in the library, shuffles randomly, and caps + // at 5. Random (not top-N-by-fetchDate) because the user will already see + // the freshest items in the grid directly below the hero — surfacing them + // again is redundant. Browse is for discovery, so randomness widens the + // chance of showing something the user wouldn't have scrolled into. + // Re-shuffles only when filters refresh the first page (source / genre / + // search change), so the hero stays stable while paginating. + const spotlightItems = useMemo(() => { + if (!firstPageItems || firstPageItems.length === 0) return []; + const notInLibrary = firstPageItems.filter( + (s) => s.inLibrary === InLibraryStatus.NotInLibrary, + ); + // Fisher–Yates shuffle on a copy, then take 7. Plain temp-swap (not the + // destructure idiom) so it cooperates with noUncheckedIndexedAccess. + const shuffled = [...notInLibrary]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = shuffled[i]!; + shuffled[i] = shuffled[j]!; + shuffled[j] = tmp; + } + return shuffled.slice(0, 7).map((s): SpotlightItem => ({ + // mihonId is the catalog id; we coerce to string for the SpotlightItem. + id: s.seriesId ?? s.mihonId, + title: s.title, + author: s.author, + description: s.description, + thumbnailUrl: s.thumbnailUrl, + status: s.status, + genres: s.genre, + availableChapters: s.chapterCount, + provider: s.provider, + sourceName: s.provider, + })); + }, [firstPageItems]); + + // Browse spotlight CTA: if the candidate already has a backing seriesId + // (e.g. became InLibrary mid-session), navigate to its detail page; + // otherwise open the AddSeries modal pre-filled with the title so the + // search step kicks off immediately. + const handleSpotlightAdd = useCallback( + (item: SpotlightItem) => { + const raw = firstPageItems.find( + (s) => (s.seriesId ?? s.mihonId) === item.id, + ); + if (raw?.seriesId) { + router.push(`/library/series?id=${raw.seriesId}`); + return; + } + setAddModalTitle(item.title); + setAddModalOpen(true); + }, + [firstPageItems, router], + ); + + // Filtered tag list for the popover. Backend already sorts by count desc, + // so we preserve that order and just cap at MAX_VISIBLE_GENRES. + const filteredGenres = useMemo(() => { + const list = genresData ?? []; + const term = tagSearch.trim().toLowerCase(); + const filtered = term + ? list.filter((g) => g.name.toLowerCase().includes(term)) + : list; + return filtered.slice(0, MAX_VISIBLE_GENRES); + }, [genresData, tagSearch]); + + // Click-outside + Escape to close the tag popover. + useEffect(() => { + if (!tagPopoverOpen) return; + + const onPointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (tagPopoverRef.current?.contains(target)) return; + if (tagButtonRef.current?.contains(target)) return; + setTagPopoverOpen(false); + }; + + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setTagPopoverOpen(false); + tagButtonRef.current?.focus(); + } + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("touchstart", onPointerDown); + document.addEventListener("keydown", onKey); + // Focus the search input shortly after open for keyboard users. + const t = window.setTimeout(() => tagSearchInputRef.current?.focus(), 0); + + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("touchstart", onPointerDown); + document.removeEventListener("keydown", onKey); + window.clearTimeout(t); + }; + }, [tagPopoverOpen]); + + // Compute & track the popover's fixed position relative to the trigger + // button. Recomputes on open, resize, and scroll so the popover stays + // anchored even when the user scrolls the page. + useEffect(() => { + if (!tagPopoverOpen) { + setTagPopoverPos(null); + return; + } + + let rafId: number | null = null; + + const compute = () => { + rafId = null; + const btn = tagButtonRef.current; + if (!btn) return; + const rect = btn.getBoundingClientRect(); + const popoverWidth = Math.min(TAG_POPOVER_MAX_WIDTH_PX, window.innerWidth - 32); + const maxLeft = window.innerWidth - popoverWidth - 16; + const left = Math.max(16, Math.min(rect.left, maxLeft)); + setTagPopoverPos({ + top: rect.bottom + 8, + left, + width: popoverWidth, + }); + }; + + const schedule = () => { + if (rafId !== null) return; + rafId = window.requestAnimationFrame(compute); + }; + + compute(); // immediate on open + window.addEventListener("resize", schedule); + window.addEventListener("scroll", schedule, true); + return () => { + window.removeEventListener("resize", schedule); + window.removeEventListener("scroll", schedule, true); + if (rafId !== null) window.cancelAnimationFrame(rafId); + }; + }, [tagPopoverOpen]); + + const tagButtonLabel = useMemo(() => { + if (selectedGenres.length === 0) return "Tags"; + if (selectedGenres.length === 1) return `Tag: ${selectedGenres[0]!}`; + return `Tags · ${selectedGenres.length}`; + }, [selectedGenres]); + return ( <> -
-
+ {/* Browse contextual ribbon — portaled into the command bar */} + +
+ {/* Source picker */} +
- - -
- {/* Card Size Select - immediately after title, to the left */} -
- + + {/* Tag popover — anchored to the trigger button; opens below the ribbon. */} +
+ + + {tagPopoverOpen && tagPopoverPos && createPortal( +
+
+ + setTagSearch(e.target.value)} + placeholder="Search tags…" + className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0" + /> +
+ +
+ {isGenresLoading ? ( +
+ Loading tags… +
+ ) : filteredGenres.length === 0 ? ( +
+ {(genresData?.length ?? 0) === 0 + ? "No tags available yet" + : "No tags match your search"} +
+ ) : ( +
    + {filteredGenres.map((g) => { + const isChecked = selectedGenres.includes(g.name); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + {selectedGenres.length > 0 && ( +
+ + {selectedGenres.length} selected + + +
+ )} +
, + document.body, + )} +
+ + {/* Right cluster: card size */} +
+
+ +
+
+ + + {/* Cinematic spotlight — only renders once the first page has resolved + to at least one non-InLibrary candidate. Hidden during the very + first load to avoid jumpy layout. */} + {isLoading && currentPage === 0 && firstPageItems.length === 0 ? ( +
+ ) : spotlightItems.length > 0 ? ( +
+
-
+ ) : null} + + {/* Controlled Add-Series modal launched by the spotlight CTA. The + existing AddSeries component already seeds its `searchKeyword` + form-state from the `title` prop, so passing the spotlight item's + title prefills the search step. The visible trigger is hidden — we + drive `open` externally. */} + { + setAddModalOpen(v); + if (!v) setAddModalTitle(undefined); + }} + triggerButton={} + /> + + {selectedGenres.length > 0 && ( +
+ {selectedGenres.map((name) => ( + + {name} + + + ))} + +
+ )}
+ Kaizoku.NET - + + + + + + + + + @@ -76,16 +103,21 @@ export default function RootLayout({ > + + {children} + + + diff --git a/KaizokuFrontend/src/app/library/page.tsx b/KaizokuFrontend/src/app/library/page.tsx index 0f5abf29..a382ada9 100644 --- a/KaizokuFrontend/src/app/library/page.tsx +++ b/KaizokuFrontend/src/app/library/page.tsx @@ -2,47 +2,85 @@ // All rendering happens on the client for static export compatibility -import { ListFilter } from "lucide-react"; +import { useState, useMemo, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Sparkles, ExternalLink } from "lucide-react"; import { AddSeries } from "@/components/kzk/series/add-series"; import { ListSeries } from "@/components/kzk/series/list-series"; -import { Button } from "@/components/ui/button"; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, - SelectLabel, - SelectSeparator, } from "@/components/ui/select"; -import KzkHeader from "@/components/kzk/layout/header"; -import KzkSidebar from "@/components/kzk/layout/sidebar"; -import { useState, useMemo, useEffect, useCallback } from "react"; +import { PageLayout } from "@/components/kzk/layout/page-layout"; +import { RibbonSlot } from "@/components/kzk/layout/ribbon"; import { SeriesStatus, type SeriesInfo } from "@/lib/api/types"; import { useLibrary } from "@/lib/api/hooks/useSeries"; +import { PullToRefresh } from "@/components/ui/pull-to-refresh"; +import { usePermission } from "@/hooks/use-permission"; +import { useQueryClient } from "@tanstack/react-query"; +import { getResponsiveCardDefault } from "@/lib/utils/responsive-card-default"; +import { + SpotlightHero, + type SpotlightItem, +} from "@/components/kzk/series/spotlight-hero"; + +// Session storage keys for the library page. +const SESSION_KEYS = { + tab: "kzk_tab", + genre: "kzk_genre", + provider: "kzk_provider", + orderBy: "kzk_orderBy", + cardWidth: "kzk_cardWidth", +}; + +// Read a value from sessionStorage, returning fallback when absent or empty. +function getSessionValue(key: string, fallback: string | null): string | null { + if (typeof window === "undefined") return fallback; + const value = sessionStorage.getItem(key); + return value !== null && value !== "" ? value : fallback; +} + +// Tiny relative-time helper — no external dependency. Mirrors the one used +// by the series-detail hero. +function formatRelative(dateString: string | null | undefined): string { + if (!dateString) return "never"; + const normalized = + dateString.includes("Z") || + dateString.includes("+") || + dateString.includes("-", 10) + ? dateString + : dateString + "Z"; + const diff = Date.now() - new Date(normalized).getTime(); + if (Number.isNaN(diff)) return "never"; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(months / 12)}y ago`; +} export default function RootPage() { - // Session storage keys - const SESSION_KEYS = { - tab: "kzk_tab", - genre: "kzk_genre", - provider: "kzk_provider", - orderBy: "kzk_orderBy", - cardWidth: "kzk_cardWidth", - }; - - // Read initial values from sessionStorage - function getSessionValue(key: string, fallback: string | null): string | null { - if (typeof window === "undefined") return fallback; - const value = sessionStorage.getItem(key); - return value !== null && value !== "" ? value : fallback; - } + const queryClient = useQueryClient(); + const canBrowseSources = usePermission('canBrowseSources'); + const router = useRouter(); + + const handleRefresh = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: ["series", "library"] }); + }, [queryClient]); const [tab, setTabState] = useState(getSessionValue(SESSION_KEYS.tab, "all")!); const [selectedGenre, setSelectedGenreState] = useState(getSessionValue(SESSION_KEYS.genre, null)); const [selectedProvider, setSelectedProviderState] = useState(getSessionValue(SESSION_KEYS.provider, null)); const [orderBy, setOrderByState] = useState(getSessionValue(SESSION_KEYS.orderBy, "title")!); - const [cardWidth, setCardWidthState] = useState(getSessionValue(SESSION_KEYS.cardWidth, "w-45")!); + const [cardWidth, setCardWidthState] = useState(getSessionValue(SESSION_KEYS.cardWidth, getResponsiveCardDefault())!); // Wrap setters to also update sessionStorage const setTab = (v: string) => { setTabState(v); sessionStorage.setItem(SESSION_KEYS.tab, v); }; @@ -51,34 +89,63 @@ export default function RootPage() { const setOrderBy = (v: string) => { setOrderByState(v); sessionStorage.setItem(SESSION_KEYS.orderBy, v); }; const setCardWidth = (v: string) => { setCardWidthState(v); sessionStorage.setItem(SESSION_KEYS.cardWidth, v); }; - const { data: library } = useLibrary(); + const { data: library, isLoading: isLibraryLoading } = useLibrary(); // Debug and deduplicate library data to prevent duplicate keys const deduplicatedLibrary = useMemo(() => { if (!library) return library; - - // Check for duplicates and log them + const seen = new Set(); const duplicates: string[] = []; const unique: SeriesInfo[] = []; - + library.forEach((series) => { if (seen.has(series.id)) { duplicates.push(series.title); - console.warn(`[Library] Duplicate series detected: ${series.title} (ID: ${series.id})`); + if (process.env.NODE_ENV !== "production") { + console.warn(`[Library] Duplicate series detected: ${series.title} (ID: ${series.id})`); + } } else { seen.add(series.id); unique.push(series); } }); - - if (duplicates.length > 0) { + + if (duplicates.length > 0 && process.env.NODE_ENV !== "production") { console.error(`[Library] Found ${duplicates.length} duplicate series:`, duplicates); } - + return unique; }, [library]); + // Spotlight pool: 5 most-recently-changed series, falling back to the + // first N from the (assumed insertion-ordered) library when too few have + // a `lastChangeUTC`. Drives the cinematic hero at the top of the page. + const spotlightItems = useMemo(() => { + if (!deduplicatedLibrary || deduplicatedLibrary.length === 0) return []; + const withChange = deduplicatedLibrary.filter((s) => !!s.lastChangeUTC); + const sorted = [...deduplicatedLibrary].sort((a, b) => { + const ta = a.lastChangeUTC ? new Date(a.lastChangeUTC).getTime() : 0; + const tb = b.lastChangeUTC ? new Date(b.lastChangeUTC).getTime() : 0; + return tb - ta; + }); + const pool = withChange.length >= 7 + ? sorted.slice(0, 7) + : sorted.slice(0, Math.min(7, sorted.length)); + return pool.map((s): SpotlightItem => ({ + id: s.id, + title: s.title, + author: s.author, + description: s.description, + thumbnailUrl: s.thumbnailUrl, + status: s.status, + genres: s.genre, + trackedChapters: s.chapterCount, + activeSources: s.providers?.length ?? 0, + lastDownload: formatRelative(s.lastChangeUTC), + })); + }, [deduplicatedLibrary]); + const cardWidthOptions = [ { value: "w-20", label: "XS", text: "text-[0.4rem]" }, { value: "w-32", label: "S", text: "text-xs" }, @@ -110,10 +177,8 @@ export default function RootPage() { // Sorting logic for ListSeries - memoized for performance const sortFn = useCallback((a: SeriesInfo, b: SeriesInfo) => { if (orderBy === "lastChange") { - // Descending by lastChangeUTC return (b.lastChangeUTC ? new Date(b.lastChangeUTC).getTime() : 0) - (a.lastChangeUTC ? new Date(a.lastChangeUTC).getTime() : 0); } - // Default: Alphabetical by title return a.title.localeCompare(b.title); }, [orderBy]); @@ -121,7 +186,7 @@ export default function RootPage() { const filterFn = useCallback((series: SeriesInfo) => { const matchesTab = tab === "completed" - ? series.status === SeriesStatus.COMPLETED || series.status === SeriesStatus.PUBLISHING_FINISHED + ? series.status === SeriesStatus.COMPLETED || series.status === SeriesStatus.PUBLISHING_FINISHED : tab === "active" ? series.status !== SeriesStatus.COMPLETED && series.status !== SeriesStatus.PUBLISHING_FINISHED && series.isActive && !series.pausedDownloads : tab === "paused" @@ -137,146 +202,198 @@ export default function RootPage() { // Count for each tab (with genre and provider filter applied) - memoized for performance const { allCount, activeCount, pausedCount, unassignedCount, completedCount } = useMemo(() => { if (!deduplicatedLibrary) return { allCount: 0, activeCount: 0, pausedCount: 0, unassignedCount: 0, completedCount: 0 }; - - // Base filter function for genre and provider - const baseFilter = (series: SeriesInfo) => + + const baseFilter = (series: SeriesInfo) => (!selectedGenre || series.genre?.includes(selectedGenre)) && (!selectedProvider || series.providers?.some((p) => p.provider === selectedProvider)); - + const baseFiltered = deduplicatedLibrary.filter(baseFilter); - + return { allCount: baseFiltered.length, - activeCount: baseFiltered.filter(series => - series.status !== SeriesStatus.COMPLETED && - series.status !== SeriesStatus.PUBLISHING_FINISHED && - series.isActive && + activeCount: baseFiltered.filter(series => + series.status !== SeriesStatus.COMPLETED && + series.status !== SeriesStatus.PUBLISHING_FINISHED && + series.isActive && !series.pausedDownloads ).length, pausedCount: baseFiltered.filter(series => series.pausedDownloads).length, unassignedCount: baseFiltered.filter(series => series.hasUnknown === true).length, - completedCount: baseFiltered.filter(series => - series.status === SeriesStatus.COMPLETED || + completedCount: baseFiltered.filter(series => + series.status === SeriesStatus.COMPLETED || series.status === SeriesStatus.PUBLISHING_FINISHED ).length, }; }, [deduplicatedLibrary, selectedGenre, selectedProvider]); return ( -
- -
- -
- {/* Filter row - wraps on mobile */} -
- {/* Status Filter - first select */} -
- -
- -
- -
-
- -
- {/* Right side controls */} -
- {/* Order Select */} -
- -
- {/* Card Size Select */} -
- -
- -
- -
-
-
-
- -
-
-
-
+ + {/* Library contextual ribbon — portaled into the command bar */} + +
+ {/* Status filter — single Select with status-colored dots and live + count badges. Mirrors the cinematic-galaxy mockup's "Status: All" + dropdown while keeping our existing Select primitive. */} +
+ +
+ + {/* Genres */} +
+ +
+ + {/* Sources (permission-gated) */} + {canBrowseSources && ( +
+ +
+ )} + + {/* Right cluster: sort, card size, add series */} +
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ + + {isLibraryLoading && !deduplicatedLibrary ? ( +
+ ) : spotlightItems.length > 0 ? ( +
+ + router.push(`/library/series?id=${item.id}`) + } + /> +
+ ) : null} + +
+ +
+ + ); } diff --git a/KaizokuFrontend/src/app/library/series/layout.tsx b/KaizokuFrontend/src/app/library/series/layout.tsx index c1330ed9..9326af0b 100644 --- a/KaizokuFrontend/src/app/library/series/layout.tsx +++ b/KaizokuFrontend/src/app/library/series/layout.tsx @@ -1,23 +1,16 @@ "use client"; -import React from 'react'; -import KzkHeader from "@/components/kzk/layout/header"; -import KzkSidebar from "@/components/kzk/layout/sidebar"; +import React from "react"; import { SeriesProvider, useSeriesContext } from "@/contexts/series-context"; +import { PageLayout } from "@/components/kzk/layout/page-layout"; function SeriesLayoutContent({ children }: { children: React.ReactNode }) { const { seriesTitle } = useSeriesContext(); - + return ( -
- -
- -
- {children} -
-
-
+ + {children} + ); } diff --git a/KaizokuFrontend/src/app/library/series/page.tsx b/KaizokuFrontend/src/app/library/series/page.tsx index 8342f210..4cdb8018 100644 --- a/KaizokuFrontend/src/app/library/series/page.tsx +++ b/KaizokuFrontend/src/app/library/series/page.tsx @@ -1,18 +1,13 @@ "use client"; import { useSearchParams, useRouter } from "next/navigation"; -import { useState, useEffect, useMemo, memo, useRef, Suspense } from "react"; -import { useSeriesById, useSetProviderMatch, useDeleteSeries, useUpdateSeries, useVerifyIntegrity, useCleanupSeries } from "@/lib/api/hooks/useSeries"; -import { useDownloadsForSeries } from "@/lib/api/hooks/useDownloads"; +import { useState, useEffect, useRef, Suspense } from "react"; +import { useSeriesById, useDeleteSeries, useUpdateSeries, useVerifyIntegrity, useCleanupSeries, useRenameSeriesFiles } from "@/lib/api/hooks/useSeries"; import { seriesService } from "@/lib/api/services/seriesService"; import { useQueryClient } from '@tanstack/react-query'; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, @@ -21,702 +16,19 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Download, Plus, Power, Search, Trash2, Pause, Play, ExternalLink, ShieldCheck, AlertTriangle, CheckCircle, Clock, Calendar } from "lucide-react"; -import Image from 'next/image'; -import { SeriesStatus, QueueStatus, ArchiveResult, type ProviderExtendedInfo, type DownloadInfo, type ProviderMatch, type ExistingSource, type SeriesExtendedInfo, type SeriesIntegrityResult, type ArchiveIntegrityResult } from "@/lib/api/types"; +import { Trash2, ShieldCheck, AlertTriangle, FileText } from "lucide-react"; +import { SeriesStatus, ArchiveResult, type ExistingSource, type SeriesExtendedInfo, type SeriesIntegrityResult } from "@/lib/api/types"; import { useSeriesContext } from "@/contexts/series-context"; -import ReactCountryFlag from "react-country-flag"; -import { getCountryCodeForLanguage } from "@/lib/utils/language-country-mapping"; -import { getStatusDisplay } from "@/lib/utils/series-status"; -import { ProviderMatchDialog } from "@/components/dialogs/provider-match-dialog"; -import { AddSeries } from "@/components/kzk/series/add-series"; -import { formatThumbnailUrl } from "@/lib/utils/thumbnail"; - - -// Provider Card Component -const ProviderCard = ({ provider, - useCover, - useTitle, - useStorage, - fromChapter, - seriesId, - onUseCoverChange, - onUseTitleChange, - onUseStorageChange, - onDisabledChange, - onDeleteProvider, - onFromChapterChange, - deletedProviderStates -}: { - provider: ProviderExtendedInfo; - useCover: boolean; - useTitle: boolean; - useStorage: boolean; - fromChapter: string; - seriesId: string; - onUseCoverChange: (providerId: string, enabled: boolean) => void; onUseTitleChange: (providerId: string, enabled: boolean) => void; - onUseStorageChange: (providerId: string, enabled: boolean) => void; - onDisabledChange: (providerId: string, disabled: boolean) => void; - onDeleteProvider: (providerId: string) => void; - onFromChapterChange: (providerId: string, value: string) => void; - deletedProviderStates: Record; -}) => { - const [isEnabled, setIsEnabled] = useState(!provider.isDisabled && !provider.isUninstalled); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const queryClient = useQueryClient(); - - // Sync isEnabled state with provider.isDisabled prop changes - useEffect(() => { - const newIsEnabled = !provider.isDisabled && !provider.isUninstalled; - setIsEnabled(newIsEnabled); - }, [provider.isDisabled, provider.isUninstalled, provider.id, provider.provider]); - - // Check if thumbnailUrl contains 'unknown' to disable cover functionality - const hasUnknownThumbnail = provider.thumbnailUrl?.toLowerCase().includes('unknown') ?? false; - const [matchDialogOpen, setMatchDialogOpen] = useState(false); - const [providerMatch, setProviderMatch] = useState(null); - const [isLoadingMatch, setIsLoadingMatch] = useState(false); - // Local state for fromChapter input to avoid updating on every keystroke - const [localFromChapter, setLocalFromChapter] = useState(fromChapter); - - // Sync localFromChapter state with fromChapter prop changes - useEffect(() => { - setLocalFromChapter(fromChapter); - }, [fromChapter]); - // Reset useCover to false when thumbnail contains 'unknown' - useEffect(() => { - if (hasUnknownThumbnail && useCover) { - onUseCoverChange(provider.id, false); - } - }, [hasUnknownThumbnail, useCover, onUseCoverChange, provider.id]); +import { usePermission } from "@/hooks/use-permission"; - // Only use the mutation hook, not the query hook - const setMatchMutation = useSetProviderMatch(); const handleEnableDisable = () => { - // If currently enabled (isEnabled=true), button shows "Disable", so clicking should disable it (isDisabled=true) - // If currently disabled (isEnabled=false), button shows "Enable", so clicking should enable it (isDisabled=false) - - // Current enabled state - const currentlyEnabled = isEnabled; - - // FIXED: When clicking "Enable" (currentlyEnabled is false), we want isDisabled=false - // When clicking "Disable" (currentlyEnabled is true), we want isDisabled=true - const newDisabledState = currentlyEnabled; // This is the correct logic! - - - // Send to parent to update backend - the UI state will be updated via props/useEffect - onDisabledChange(provider.id, newDisabledState); - };const handleMatch = async () => { - if (provider.isUnknown) { - setMatchDialogOpen(true); // Open dialog immediately - setIsLoadingMatch(true); - try { - // Import the service directly to call it manually - const { seriesService } = await import("@/lib/api/services/seriesService"); - const matchData = await seriesService.getMatch(provider.id); - setProviderMatch(matchData); - } catch (error) { - console.error("Failed to fetch match data:", error); - setProviderMatch(null); // Set to null on error - } finally { - setIsLoadingMatch(false); - } - } - }; const handleMatchSave = (updatedMatch: ProviderMatch) => { - setMatchMutation.mutate(updatedMatch, { - onSuccess: () => { - setMatchDialogOpen(false); - // Refetch series data instead of full page reload - queryClient.invalidateQueries({ - queryKey: ['series', 'detail', seriesId] - }); - // Also invalidate library cache since series data changed - queryClient.invalidateQueries({ - queryKey: ['series', 'library'] - }); - }, - onError: (error) => { - console.error("Failed to save match:", error); - } - }); - }; - const handleDelete = () => { - setShowDeleteConfirm(true); - }; +import { DownloadsPanel } from "@/components/kzk/series/detail/downloads-panel"; +import { SeriesHero } from "@/components/kzk/series/detail/series-hero"; +import { SourcesSection } from "@/components/kzk/series/detail/sources-section"; +import { SeriesRibbon } from "@/components/kzk/series/detail/series-ribbon"; +import { ChapterList } from "@/components/kzk/series/detail/chapter-list"; - const handleDeleteConfirm = () => { - onDeleteProvider(provider.id); - setShowDeleteConfirm(false); - }; - const handleDeleteCancel = () => { - setShowDeleteConfirm(false); - }; - - // Handle fromChapter input blur (submit on defocus) - const handleFromChapterBlur = () => { - if (localFromChapter !== fromChapter) { - onFromChapterChange(provider.id, localFromChapter); - } - }; - - // Handle fromChapter input key press (submit on Enter) - const handleFromChapterKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); // This will trigger onBlur - } - }; - - return ( - -
- {/* Action buttons - mobile/tablet: top row, desktop: top-right absolute */} -
- - {!provider.isUnknown && !provider.isUninstalled && ( - - )} - - {provider.isUnknown && ( - - )} -
- - {/* Continue After Chapter input - mobile/tablet: below buttons, desktop: top-right */} - {!provider.isUnknown && ( -
- Continue After Chapter: - setLocalFromChapter(e.target.value)} - placeholder="Start" - className="h-8 w-24 text-sm bg-background text-right tabular-nums font-mono" - disabled={provider.isDisabled} - onBlur={handleFromChapterBlur} - onKeyDown={handleFromChapterKeyDown} - /> -
- )} - - {/* Header section with thumbnail and info */} -
-
- {/* Provider Thumbnail */} -
- {provider.title} -
- -
- {provider.title} - { provider.url ? ( -
{ - e.stopPropagation(); - if (provider.url) { - window.open(provider.url, '_blank', 'noopener,noreferrer'); - } - }} - title="Click to open in the source" - > - {provider.provider}{(provider.provider != provider.scanlator && provider.scanlator) ? ` • ${provider.scanlator}` : ''} - - - {getStatusDisplay(provider.status).text} - -
- - ) : ( -
- {provider.provider}{(provider.provider != provider.scanlator && provider.scanlator) ? ` • ${provider.scanlator}` : ''} - - - {getStatusDisplay(provider.status).text} - -
- )} -
{/* Stats grid */} -
-
- - {provider.chapterList} - - {provider.lastChapter && ( - - - Last: {provider.lastChapter} - - {provider.lastChangeUTC && ( - - {(() => { - const utcString = provider.lastChangeUTC.includes('Z') || provider.lastChangeUTC.includes('+') || provider.lastChangeUTC.includes('-', 10) - ? provider.lastChangeUTC - : provider.lastChangeUTC + 'Z'; - const date = new Date(utcString); - return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; - })()} - - )} - - - )} - -
-
- - -
- {provider.author && ( -
- Author: - {provider.author} -
- )} - {provider.artist && ( -
- Artist: - {provider.artist} -
- )} -
-
- {provider.genre && provider.genre.length > 0 && ( - provider.genre.map((genre) => ( - - {genre} - - )) - )} -
-
- {provider.description && ( -

{provider.description}

- )} -
- {/* Switches */} {!provider.isUnknown && ( -
-
- onUseStorageChange(provider.id, checked)} - disabled={provider.isDisabled} - className="flex-shrink-0" - /> - -
onUseCoverChange(provider.id, checked)} - disabled={provider.isDisabled || hasUnknownThumbnail} - className="flex-shrink-0" - /> - -
-
- onUseTitleChange(provider.id, checked)} - disabled={provider.isDisabled} - className="flex-shrink-0" - /> - -
- -
- )} - - -
-
-
- {/* Provider Match Dialog */} - - - {/* Delete Confirmation Dialog */} - {showDeleteConfirm && ( -
-
-

Confirm Delete

-

- Are you sure you want to delete this source? This action cannot be undone. -

- - -
-
-
- )} -
- ); -}; - -// Helper function to get status icon -const getStatusIcon = (status: QueueStatus, isScheduledForFuture?: boolean) => { - // Special case for future scheduled downloads - if (isScheduledForFuture) { - return ; - } - - switch (status) { - case QueueStatus.RUNNING: - return ; - case QueueStatus.COMPLETED: - return ; - case QueueStatus.FAILED: - return ; - case QueueStatus.WAITING: - return ; - default: - return ; - } -}; - -// Download Item Component -const DownloadItem = ({ download }: { download: DownloadInfo }) => { - // Helper function to normalize UTC date strings - const normalizeUtcString = (dateString: string) => { - return dateString.includes('Z') || dateString.includes('+') || dateString.includes('-', 10) - ? dateString - : dateString + 'Z'; - }; - - // Determine which date to display and its label - const utcDateString = download.downloadDateUTC || download.scheduledDateUTC; - const dateLabel = download.downloadDateUTC ? 'Downloaded' : 'Scheduled'; - - // Create Date object from properly normalized UTC string - const displayDate = new Date(normalizeUtcString(utcDateString)); - const now = new Date(); - - // Status color mapping - matches getStatusDisplay badge styling - const getStatusColor = (status: QueueStatus) => { - switch (status) { - case QueueStatus.WAITING: - return 'bg-yellow-500 text-white'; - case QueueStatus.RUNNING: - return 'bg-blue-500 text-white'; - case QueueStatus.COMPLETED: - return 'bg-green-500 text-white'; - case QueueStatus.FAILED: - return 'bg-red-500 text-white'; - default: - return 'bg-gray-500 text-white'; - } - }; - - // Status text mapping - consistent with series status display - const getStatusText = (status: QueueStatus) => { - if (status === QueueStatus.WAITING) { - // If scheduled in the future, show 'Scheduled' instead of 'Waiting' - if (displayDate > now) return 'Scheduled'; - return 'Waiting'; - } - switch (status) { - case QueueStatus.RUNNING: - return 'Running'; - case QueueStatus.COMPLETED: - return 'Completed'; - case QueueStatus.FAILED: - return 'Failed'; - default: - return 'Unknown'; - } - }; - - // Determine if we should show the date/time - let showDate = false; - if (download.status === QueueStatus.WAITING) { - // Only show if scheduled in the future - showDate = displayDate > now; - } else if (download.status === QueueStatus.COMPLETED || download.status === QueueStatus.FAILED) { - showDate = true; - } - // Do not show for RUNNING status - - return ( - - -
- {download.title) => { - const target = e.target as HTMLImageElement; - target.src = '/kaizoku.net.png'; - }} - /> -
- - {download.title || 'Unknown Series'} - -

- {download.chapterTitle ? download.chapterTitle : `Chapter ${download.chapter}`} -

-
- {(download.provider || download.scanlator) && ( - download.url ? ( -

{ - e.stopPropagation(); - if (download.url) { - window.open(download.url, '_blank', 'noopener,noreferrer'); - } - }} - title="Click to open the chapter in the source" - > - - {download.provider} - {(download.provider !== download.scanlator && download.scanlator) ? ` • ${download.scanlator}` : ''} -

- ) : ( -

- {download.provider} - {(download.provider !== download.scanlator && download.scanlator) ? ` • ${download.scanlator}` : ''} -

- ) - )} - {getStatusIcon(download.status, download.status === QueueStatus.WAITING && displayDate > now)} - {showDate && ( -
- {download.status === QueueStatus.COMPLETED || download.status === QueueStatus.FAILED ? ( - <> - {displayDate.toLocaleDateString()}  - {displayDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - ) : ( - displayDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - )} -
- )} - {download.retries > 0 && ( -
- Retries: {download.retries} -
- )} -
-
-
-
-
- ); -}; - -/** - * Downloads Panel Component - Fully disconnected from ProviderExtendedInfo - * - * Features: - * - Uses new getDownloadsForSeries API endpoint - * - Live polling every 10 seconds for real-time updates - * - Memoized component to prevent unnecessary re-renders - * - Memoized sorted downloads array for performance - * - Loading indicator during data fetch - * - Error handling with fallback UI - * - Only re-renders when downloads data actually changes - * - Auto-refreshes series and providers when downloads complete - * - Properly handles series deletion to prevent infinite loops - */ -const DownloadsPanel = memo(({ seriesId, isDeleting }: { seriesId: string; isDeleting: boolean }) => { - const queryClient = useQueryClient(); - - // Track previous downloads state to detect completion - const previousDownloadsRef = useRef(null); - - // Fetch downloads with live polling every 10 seconds, but disable when deleting - const { data: downloads, isLoading: downloadsLoading, error: downloadsError } = useDownloadsForSeries(seriesId, { - refetchInterval: isDeleting ? false : 10000, // Stop polling when deleting - refetchIntervalInBackground: !isDeleting, // Stop background polling when deleting - staleTime: 5000, // Consider data stale after 5 seconds - enabled: !isDeleting, // Disable query entirely when deleting - }); - - // Detect when active downloads (waiting/running) complete and trigger series refresh - useEffect(() => { - // Skip all logic if we're in the process of deleting - if (isDeleting) { - return; - } - - if (!downloads || !previousDownloadsRef.current) { - // First load or no previous data - just store current state - previousDownloadsRef.current = downloads || null; - return; - } - - const previousDownloads = previousDownloadsRef.current; - const currentDownloads = downloads; - - // Check if previous downloads had waiting or running items - const previousActiveDownloads = previousDownloads.filter( - download => download.status === QueueStatus.WAITING || download.status === QueueStatus.RUNNING - ); - - // Check if current downloads have waiting or running items - const currentActiveDownloads = currentDownloads.filter( - download => download.status === QueueStatus.WAITING || download.status === QueueStatus.RUNNING - ); - - const hadActiveDownloads = previousActiveDownloads.length > 0; - const hasActiveDownloads = currentActiveDownloads.length > 0; - - // If we had active downloads before but don't now, trigger series refresh - if (hadActiveDownloads && !hasActiveDownloads) { - - // Small delay to ensure backend has processed the completion and updated series data - setTimeout(() => { - // Only refresh if we're not deleting the series - if (!isDeleting) { - // Refresh both series data and providers data - queryClient.invalidateQueries({ - queryKey: ['series', 'detail', seriesId] - }); - - // Also refresh sources/providers to get updated chapter counts and metadata - queryClient.invalidateQueries({ - queryKey: ['series', 'sources'] - }); - - } - }, 1000); - } - - // Update the previous state - previousDownloadsRef.current = downloads; - }, [downloads, seriesId, queryClient, isDeleting]); - - // Memoize sorted downloads to prevent unnecessary re-renders - const sortedDownloads = useMemo(() => { - if (!downloads?.length) return []; - - return [...downloads].sort((a, b) => { - //const dateA = a.downloadDateUTC ? new Date(a.downloadDateUTC) : new Date(a.scheduledDateUTC); - //const dateB = b.downloadDateUTC ? new Date(b.downloadDateUTC) : new Date(b.scheduledDateUTC); - const dateA = new Date(a.scheduledDateUTC); - const dateB = new Date(b.scheduledDateUTC); - return dateB.getTime() - dateA.getTime(); - }); - }, [downloads]); - - if (downloadsError) { - return ( - - - - - Latest Downloads - - - -
- -

Failed to load downloads

-
-
-
- ); - } - - return ( - - - - - Latest Downloads - {sortedDownloads.length > 0 && ( - - {sortedDownloads.length} - - )} - {downloadsLoading && ( -
- )} -
-
- - {sortedDownloads.length > 0 ? ( -
- {sortedDownloads.map((download, index) => ( - - ))} -
- ) : ( -
-
- -

No downloads yet

-
-
- )} -
-
- ); -}); - -DownloadsPanel.displayName = 'DownloadsPanel'; export default function SeriesPage() { return ( @@ -733,13 +45,19 @@ function SeriesPageContent() { const { setSeriesTitle } = useSeriesContext(); const queryClient = useQueryClient(); + // Permissions + const canEdit = usePermission('canEditSeries'); + const canDelete = usePermission('canDeleteSeries'); + const canManageDownloads = usePermission('canManageDownloads'); + // Track deletion state to prevent loops const [isDeleting, setIsDeleting] = useState(false); - const { data: series, isLoading, error } = useSeriesById(seriesId || '', !isDeleting); + const { data: series, isLoading, error, refetch } = useSeriesById(seriesId || '', !isDeleting); const deleteSeries = useDeleteSeries(); const updateSeriesMutation = useUpdateSeries(); const verifyIntegrity = useVerifyIntegrity(); + const renameFiles = useRenameSeriesFiles(); const cleanupSeries = useCleanupSeries(); // Provider switch state management @@ -1300,7 +618,6 @@ function SeriesPageContent() { // Determine the effective series status const effectiveStatus = series && (!hasActiveProviders || allProvidersDisabled) ? SeriesStatus.DISABLED : (series?.status ?? SeriesStatus.UNKNOWN); - const statusDisplay = getStatusDisplay(effectiveStatus); // Update series title in context when display title changes useEffect(() => { @@ -1495,12 +812,14 @@ function SeriesPageContent() { // Handler for verify integrity button click const handleVerifyIntegrityClick = async () => { if (!seriesId) return; - + try { - const result = await verifyIntegrity.mutateAsync(seriesId); setVerifyResult(result); - + + // Re-fetch series data — verify may have recovered a truncated title + await queryClient.invalidateQueries({ queryKey: ['series', 'detail', seriesId] }); + if (result.success) { // Show success dialog setShowVerifyDialog(true); @@ -1565,6 +884,8 @@ function SeriesPageContent() { return { text: 'No Images', color: 'text-yellow-600' }; case ArchiveResult.NotFound: return { text: 'Not Found', color: 'text-red-600' }; + case ArchiveResult.Unreadable: + return { text: 'Unreadable (skipped)', color: 'text-yellow-600' }; default: return { text: 'Unknown', color: 'text-gray-600' }; } @@ -1743,10 +1064,18 @@ function SeriesPageContent() { if (error || !series) { return ( -
+
{error ? "Error loading series" : "Series not found"}
+
+ + +
); } @@ -1761,203 +1090,106 @@ function SeriesPageContent() { lang: provider.lang })); - // Count of non-deleted providers - const visibleProvidersCount = series.providers.filter(provider => !providerDeletedStates[provider.id]).length; + // Visible (non-deleted) providers array + const visibleProviders = series.providers.filter(provider => !providerDeletedStates[provider.id]); + + // Inline rename handler shared by hero and rename notification banner + const handleRenameFiles = async () => { + if (!seriesId) return; + try { + await renameFiles.mutateAsync(seriesId); + setVerifyResult({ success: true, badFiles: [] }); + setShowVerifyDialog(true); + } catch { + // Error handled by mutation + } + }; return (<> - {/* Three-area layout */} -
{/* Left Column - Two rows (80% width) */} -
- {/* Top Left: Series Details */} - -
- {/* Poster */} -
- {displayTitle} -
- {/* Series Info */} -
- {/* Title + Status Badge Row */} -
- {displayTitle} - - {statusDisplay.text} - -
-
-
- - {series.chapterList} - - {series.lastChapter && ( - - - Last: {series.lastChapter} - - {series.lastChangeUTC && ( - - {new Date(series.lastChangeUTC).toLocaleDateString()} {new Date(series.lastChangeUTC).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - )} - - - )} -
-
- {series.author && ( -
- Author: - {series.author} -
- )} - {series.artist && ( -
- Artist: - {series.artist} -
- )} -
- - {series.genre && series.genre.length > 0 && ( -
- {series.genre.map((genre) => ( - - {genre} - - ))} -
- )} + {/* Ribbon — back nav + series title */} + router.push('/library')} + /> + + {/* New layout: full-width hero, then 12-col grid */} +
+ + {/* Cinematic hero */} + - {/* Description - Flexible area that fills available space */} - {series.description && ( -
-

{series.description}

-
- )} - - {/* Bottom Row: Path + Action Buttons */} -
- {/* Series Path Display */} - {series.path && ( -
-
- {series.path} -
-
- )} - - {/* Action Buttons - Delete, Verify, and Pause/Resume Downloads */} -
- {/* Delete Series Button */} - - - {/* Verify Integrity Button */} - - - {/* Pause/Resume Downloads Button */} - -
-
-
-
- {/* Bottom Left: Providers */} - - -
- - Sources - {visibleProvidersCount} - - - - Add Sources - - } - /> -
-
- -
{series.providers - .filter(provider => !providerDeletedStates[provider.id]) // Filter out deleted providers - .map((provider) => { - const switches = providerSwitches[provider.id] || { useTitle: false, useCover: false, useStorage: false }; - const isDisabled = provider.isUninstalled ? true : (providerDisabledStates[provider.id] ?? provider.isDisabled); - const currentFromChapter = providerFromChapters[provider.id] ?? provider.fromChapter?.toString() ?? ""; - - // Create updated provider object with current disabled state - const updatedProvider = { - ...provider, - isDisabled: isDisabled - }; - - return ( - ); - })} + {/* Rename notification — shown when title was recovered from a truncated source name */} + {series.needsRename && canEdit && ( +
+
+ + + The full title was recovered from the source. The storage folder and filenames still use the old truncated name. + + +
+
+ )} + + {/* Main content — Sources full-width, then ChapterList + aside grid */} +
+ + {/* Sources — full width, providers tile in 2-col grid at lg+ */} + + +
+ {/* ChapterList — main column */} +
+
- -
- {/* Right Column: Downloads (20% width) - Using new API */} - + {/* Right rail — Downloads (pinned on desktop) */} + {/* 112px = h-14 command bar (56) + h-12 ribbon (48) + 8px gap */} + +
+ +
{/* Delete Series Confirmation Dialog */} diff --git a/KaizokuFrontend/src/app/login/page.tsx b/KaizokuFrontend/src/app/login/page.tsx new file mode 100644 index 00000000..fb6cff4b --- /dev/null +++ b/KaizokuFrontend/src/app/login/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Eye, EyeOff, Loader2, AlertCircle } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { useAuth } from '@/contexts/auth-context'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export default function LoginPage() { + const { login, isAuthenticated, isLoading, needsSetup, isAuthEnabled } = useAuth(); + const router = useRouter(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [rememberMe, setRememberMe] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isLoading) return; + // No users exist yet — redirect to initial admin setup + if (needsSetup) { + router.replace('/setup'); + return; + } + // Auth disabled — profiles are selected, not logged into + if (!isAuthEnabled) { + router.replace('/user-select'); + return; + } + if (isAuthenticated) { + router.replace('/library'); + } + }, [isAuthenticated, isLoading, needsSetup, isAuthEnabled, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password) return; + + setError(null); + setIsSubmitting(true); + try { + await login(username.trim(), password, rememberMe); + router.push('/library'); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Login failed. Please try again.'; + if (msg.includes('401') || msg.includes('401')) { + setError('Invalid username or password.'); + } else if (msg.toLowerCase().includes('disabled') || msg.toLowerCase().includes('inactive')) { + setError('Your account has been disabled. Contact an administrator.'); + } else if (msg.toLowerCase().includes('rate')) { + setError('Too many attempts. Please wait a moment before trying again.'); + } else { + setError(msg); + } + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Subtle background decoration */} +