From 126919d9880ae1f7be9ab80fd1ddebd7b6c739a4 Mon Sep 17 00:00:00 2001 From: Quickkill0 <76577322+Quickkill0@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:13:44 -0700 Subject: [PATCH 01/56] feat: multi-user auth, PWA support, provider health checks, and title recovery (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add multi-user auth, permissions, requests system - JWT authentication with BCrypt password hashing (access + refresh tokens) - User system with Admin/User roles and 13 granular permissions - Permission presets, invite link system, manga request workflow - Authorization middleware with dynamic policy provider - Bootstrap mode for initial setup when no users exist - SignalR hub JWT authentication - Login, register, setup pages and admin settings UI - Auth context provider with auto token refresh - API client interceptor for Bearer token management * feat: UI/UX overhaul — collapsible sidebar, mobile responsiveness, PWA support - Collapsible sidebar with pin/unpin and framer-motion animations - Frosted-glass header with breadcrumbs and centered search - Mobile: swipe-to-open sidebar, floating action button, pull-to-refresh - Responsive layouts across all pages (library, queue, settings, cloud-latest) - Provider-match dialog mobile responsiveness - PWA: service worker, offline fallback, install prompt, full manifest - Shimmer skeleton loading states - Download management UI and sidebar counters fix - Safe area insets for notched devices * feat: recover truncated manga titles and rename series files - Bridge-level title recovery: detect truncated titles (ending "..."/"…"), fetch detail page HTML, extract full title from og:title/twitter:title using LCP algorithm across multiple meta tag candidates - Automatic recovery during chapter checks and manual verify - NeedsRename flag + DB migration for tracking pending renames - Rename series files endpoint for updating storage paths after recovery - Update provider source titles when recovering truncated series title * build: rebuild Android.Compat.dll and add CI workflows - Rebuild Android.Compat.dll with updated dependencies - Replace Android.Compat.dll build workflow with LFS verification check - Update gradle build scripts and dependency versions * feat: provider health checks, download management, and backend improvements - Provider health check system with DB columns and migration - Download management controller and action enums - Improved provider cache service and dead provider handling - Startup hosted service improvements - Search query and command service refinements - Settings and image services updates - Various controller authorization and endpoint additions * chore: gitignore AI-generated artifacts * build: rebuild Android.Compat.dll Triggered by changes to AndroidCompat build configuration. Kotlin 2.3.0 * fix: sync provider titles when series title already recovered The verify recovery would skip updating provider titles when the series title had already been fixed in a prior deploy (srcLen == dbLen). Now provider titles are always synced against the best available title, regardless of whether the series title itself needed recovery. * fix: title recovery only triggers on truncated titles, respects IsTitle provider Two bugs fixed: 1. Recovery was comparing lengths only, treating any longer source title as a "recovery" — now requires the title to actually be truncated (ends with "..."/"…") before attempting recovery. 2. Was overwriting ALL provider titles with the recovered title — now only updates the IsTitle provider's title, never touches others. * fix: cache LFS objects in Docker CI to avoid bandwidth exhaustion Each build was downloading the 400MB Android.Compat.dll from LFS, burning through the 1GB/month free bandwidth in ~3 builds. Now uses actions/cache to persist LFS objects between runs. * fix: use GitHub Release for Android.Compat.dll instead of LFS in CI Docker build now downloads the DLL from the android-compat-v1 release instead of git lfs pull, avoiding LFS bandwidth limits entirely. The rebuild workflow auto-updates the release asset when the DLL changes. * fix: mobile drawers expand fully like add-series sheet Drawers using ResponsiveModal and RequestSeriesDialog were too short on mobile because they lacked flex-col layout and proper scroll containment. - Add flex flex-col to DrawerContent so flex-1 children fill height - Bump request-series drawer from 85dvh to 92dvh for consistency - Add overscroll-contain and data-vaul-no-drag for proper scroll behavior * `refactor: move max-h drawer constraint to DrawerContent base styles` - Set `h-[92dvh] flex flex-col` as default in `DrawerContent` component - Remove redundant `max-h-[92dvh] flex flex-col` classes from all `DrawerContent` usages across dialogs and drawers * chore: remove Git LFS in favor of CI-downloaded binaries - Remove LFS tracking for `.jar` and `.dll` files from `.gitattributes` - Add binary dependencies to `.gitignore` (downloaded by CI, not stored in repo) - Delete tracked LFS binary files (Android.Compat.dll, IKVM dlls, gradle-wrapper.jar) - Update docker-build workflow to skip LFS checkout - Update rebuild-androidcompat workflow to remove DLL commit step and LFS checkout - Switch IKVM references from local `libs/` DLLs to `PackageReference` (NuGet) - Use explicit Gradle version `9.2.1` via `gradle/actions/setup-gradle` instead of wrapper --------- Co-authored-by: github-actions[bot] Co-authored-by: Quickkill0 --- .gitattributes | 2 - .github/workflows/docker-build.yml | 21 +- .github/workflows/rebuild-androidcompat.yml | 118 +++ .gitignore | 12 +- .../Authorization/BootstrapModeMiddleware.cs | 73 ++ .../Authorization/JwtKeyResolver.cs | 49 + .../PermissionAuthorizationHandler.cs | 58 ++ .../Authorization/PermissionPolicyProvider.cs | 51 + .../Authorization/PermissionRequirement.cs | 25 + KaizokuBackend/Controllers/AuthController.cs | 185 ++++ .../Controllers/DownloadsController.cs | 74 ++ .../Controllers/ImagesController.cs | 3 + .../Controllers/InviteController.cs | 103 ++ .../Controllers/PermissionPresetController.cs | 144 +++ .../Controllers/ProviderController.cs | 72 +- .../Controllers/RequestController.cs | 173 ++++ .../Controllers/SearchController.cs | 14 + .../Controllers/SeriesController.cs | 33 +- .../Controllers/SettingsController.cs | 2 + .../Controllers/SetupWizardController.cs | 5 +- KaizokuBackend/Controllers/UserController.cs | 397 +++++++ KaizokuBackend/Data/AppDbContext.cs | 114 ++- .../AddProviderHealthCheckColumns.cs | 46 + .../EFMigrations/AddSeriesNeedsRename.cs | 27 + .../Extensions/FileSystemExtensions.cs | 2 +- KaizokuBackend/Extensions/ModelExtensions.cs | 1 + KaizokuBackend/Hubs/ProgressHub.cs | 10 +- KaizokuBackend/KaizokuBackend.csproj | 3 + .../Models/Database/InviteLinkEntity.cs | 24 + .../Models/Database/MangaRequestEntity.cs | 28 + .../Models/Database/PermissionPresetEntity.cs | 30 + .../Models/Database/ProviderStorageEntity.cs | 3 + .../Models/Database/SeriesEntity.cs | 8 + KaizokuBackend/Models/Database/UserEntity.cs | 26 + .../Models/Database/UserPermissionEntity.cs | 27 + .../Models/Database/UserPreferencesEntity.cs | 20 + .../Models/Database/UserSessionEntity.cs | 22 + .../Models/Dto/Auth/AuthResponseDto.cs | 26 + .../Models/Dto/Auth/InviteLinkDto.cs | 61 ++ KaizokuBackend/Models/Dto/Auth/LoginDto.cs | 16 + .../Models/Dto/Auth/MangaRequestDto.cs | 78 ++ .../Models/Dto/Auth/PermissionDto.cs | 49 + .../Models/Dto/Auth/PermissionPresetDto.cs | 40 + .../Models/Dto/Auth/RefreshTokenDto.cs | 38 + KaizokuBackend/Models/Dto/Auth/RegisterDto.cs | 22 + KaizokuBackend/Models/Dto/Auth/UserDto.cs | 73 ++ .../Models/Dto/Auth/UserPreferencesDto.cs | 24 + KaizokuBackend/Models/Dto/BaseSeriesDto.cs | 3 + .../Models/Dto/EditableSettingsDto.cs | 9 + KaizokuBackend/Models/Dto/ExtensionDto.cs | 6 + KaizokuBackend/Models/Dto/LinkedSeriesDto.cs | 1 + .../Models/Dto/ProviderHealthResultDto.cs | 27 + .../Models/Enums/DownloadManageAction.cs | 16 + KaizokuBackend/Models/Enums/JobType.cs | 3 +- KaizokuBackend/Models/Enums/RequestStatus.cs | 10 + KaizokuBackend/Models/Enums/UserRole.cs | 8 + KaizokuBackend/Services/Auth/AuthService.cs | 384 +++++++ .../Services/Auth/InviteLinkService.cs | 198 ++++ .../Services/Auth/PasswordPolicy.cs | 48 + .../Services/Auth/PermissionPresetService.cs | 178 ++++ .../Services/Auth/PermissionService.cs | 149 +++ .../Services/Auth/UserPreferencesService.cs | 78 ++ KaizokuBackend/Services/Auth/UserService.cs | 422 ++++++++ .../Background/StartupHostedService.cs | 173 +++- .../Downloads/DownloadCommandService.cs | 72 +- .../Services/Downloads/DownloadsExtensions.cs | 6 +- .../Services/Helpers/ArchiveHelperService.cs | 2 +- .../Services/Images/ThumbCacheService.cs | 46 + .../Jobs/Commands/HealthCheckSources.cs | 43 + .../Services/Jobs/JobBusinessService.cs | 29 + .../Providers/ProviderCacheService.cs | 138 ++- .../Providers/ProviderHealthCheckService.cs | 186 ++++ .../Providers/ProviderManagerService.cs | 3 + .../Services/Requests/MangaRequestService.cs | 274 +++++ .../Services/Search/SearchCommandService.cs | 16 +- .../Services/Search/SearchQueryService.cs | 82 +- .../Services/Series/SeriesArchiveService.cs | 175 +++- .../Services/Series/SeriesCommandService.cs | 76 +- .../Services/Series/SeriesExtensions.cs | 4 + .../Services/Series/SeriesProviderService.cs | 40 +- KaizokuBackend/Services/ServiceExtensions.cs | 30 +- .../Services/Settings/SettingsService.cs | 10 +- KaizokuBackend/Startup.cs | 68 +- KaizokuFrontend/public/offline.html | 156 +++ KaizokuFrontend/public/site.webmanifest | 44 +- KaizokuFrontend/public/sw.js | 202 ++++ .../src/app/cloud-latest/layout.tsx | 14 +- KaizokuFrontend/src/app/cloud-latest/page.tsx | 3 +- KaizokuFrontend/src/app/layout.tsx | 19 +- KaizokuFrontend/src/app/library/page.tsx | 273 +++-- .../src/app/library/series/layout.tsx | 19 +- .../src/app/library/series/page.tsx | 801 ++++++++------- KaizokuFrontend/src/app/login/page.tsx | 226 ++++ KaizokuFrontend/src/app/providers/layout.tsx | 15 +- KaizokuFrontend/src/app/queue/layout.tsx | 15 +- KaizokuFrontend/src/app/queue/page.tsx | 969 ++++++++---------- KaizokuFrontend/src/app/register/page.tsx | 411 ++++++++ KaizokuFrontend/src/app/requests/layout.tsx | 12 + KaizokuFrontend/src/app/requests/page.tsx | 11 + KaizokuFrontend/src/app/settings/layout.tsx | 15 +- KaizokuFrontend/src/app/settings/page.tsx | 99 +- KaizokuFrontend/src/app/setup/page.tsx | 276 +++++ .../auth/password-change-dialog.tsx | 335 ++++++ .../src/components/auth/require-auth.tsx | 58 ++ .../components/auth/require-permission.tsx | 35 + .../dialogs/provider-match-dialog.tsx | 428 +++++--- .../components/kzk/import-wizard/index.tsx | 26 +- .../src/components/kzk/layout/fab.tsx | 95 ++ .../src/components/kzk/layout/header.tsx | 331 +++++- .../src/components/kzk/layout/page-layout.tsx | 44 + .../src/components/kzk/layout/sidebar.tsx | 654 +++++++++--- .../src/components/kzk/provider-manager.tsx | 119 ++- .../kzk/provider-preferences-requester.tsx | 271 +++-- .../kzk/series/add-series/index.tsx | 65 +- .../add-series/steps/confirm-series-step.tsx | 3 +- .../kzk/series/add-series/steps/index.tsx | 444 +++++--- .../add-series/steps/request-series-step.tsx | 141 +++ .../add-series/steps/search-series-step.tsx | 162 +-- .../kzk/series/cloud-latest-details-modal.tsx | 340 ++++-- .../kzk/series/cloud-latest-grid.tsx | 73 +- .../kzk/series/list-series/index.tsx | 38 +- .../kzk/series/request-series-dialog.tsx | 173 ++++ .../src/components/kzk/settings/about-tab.tsx | 71 ++ .../components/kzk/settings/general-tab.tsx | 22 + .../kzk/settings/permissions-tab.tsx | 330 ++++++ .../kzk/settings/preferences-tab.tsx | 267 +++++ .../components/kzk/settings/requests-tab.tsx | 351 +++++++ .../src/components/kzk/settings/users-tab.tsx | 707 +++++++++++++ .../setup-wizard/search-series-requester.tsx | 340 +++--- .../steps/confirm-imports-step.tsx | 24 +- .../components/providers/query-provider.tsx | 2 +- .../src/components/pwa/install-prompt.tsx | 157 +++ .../src/components/pwa/sw-provider.tsx | 26 + KaizokuFrontend/src/components/ui/drawer.tsx | 55 +- .../src/components/ui/pull-to-refresh.tsx | 214 ++++ .../src/components/ui/responsive-modal.tsx | 134 +++ .../src/components/ui/textarea.tsx | 21 + KaizokuFrontend/src/contexts/auth-context.tsx | 192 ++++ KaizokuFrontend/src/hooks/use-permission.ts | 22 + KaizokuFrontend/src/lib/api/auth-types.ts | 195 ++++ KaizokuFrontend/src/lib/api/client.ts | 157 ++- KaizokuFrontend/src/lib/api/hooks/useAuth.ts | 55 + .../src/lib/api/hooks/useDownloads.ts | 45 +- .../src/lib/api/hooks/useInvites.ts | 41 + .../src/lib/api/hooks/usePermissionPresets.ts | 62 ++ .../src/lib/api/hooks/useRequests.ts | 66 ++ .../src/lib/api/hooks/useSearch.ts | 9 +- .../src/lib/api/hooks/useSeries.ts | 17 +- .../src/lib/api/hooks/useSettings.ts | 12 +- KaizokuFrontend/src/lib/api/hooks/useUsers.ts | 157 +++ .../src/lib/api/services/authService.ts | 36 + .../src/lib/api/services/downloadsService.ts | 23 + .../src/lib/api/services/inviteService.ts | 20 + .../api/services/permissionPresetService.ts | 28 + .../src/lib/api/services/providerService.ts | 29 +- .../src/lib/api/services/requestService.ts | 34 + .../src/lib/api/services/searchService.ts | 4 +- .../src/lib/api/services/seriesService.ts | 30 +- .../src/lib/api/services/userService.ts | 87 ++ .../src/lib/api/signalr/progressHub.ts | 86 +- KaizokuFrontend/src/lib/api/types.ts | 14 + KaizokuFrontend/src/lib/auth-token-store.ts | 52 + KaizokuFrontend/src/lib/sw-register.ts | 71 ++ .../src/lib/utils/responsive-card-default.ts | 11 + KaizokuFrontend/src/styles/globals.css | 110 ++ KaizokuFrontend/tsconfig.json | 4 +- .../AndroidCompat/build.gradle.kts | 7 - .../build.gradle.kts | 9 - .../gradle/libs.versions.toml | 10 +- .../gradle/wrapper/gradle-wrapper.jar | 3 - ...roid.Compatibility.Layer.CILPatcher.csproj | 7 +- .../libs/IKVM.Java.dll | 3 - .../libs/IKVM.Runtime.dll | 3 - .../Runtime/ExtensionLoadException.cs | 12 + .../Runtime/JarExtensionInterop.cs | 13 +- .../Runtime/SourceInterop.cs | 145 ++- .../libs/Android.Compat.dll | 3 - 177 files changed, 15231 insertions(+), 2234 deletions(-) create mode 100644 .github/workflows/rebuild-androidcompat.yml create mode 100644 KaizokuBackend/Authorization/BootstrapModeMiddleware.cs create mode 100644 KaizokuBackend/Authorization/JwtKeyResolver.cs create mode 100644 KaizokuBackend/Authorization/PermissionAuthorizationHandler.cs create mode 100644 KaizokuBackend/Authorization/PermissionPolicyProvider.cs create mode 100644 KaizokuBackend/Authorization/PermissionRequirement.cs create mode 100644 KaizokuBackend/Controllers/AuthController.cs create mode 100644 KaizokuBackend/Controllers/InviteController.cs create mode 100644 KaizokuBackend/Controllers/PermissionPresetController.cs create mode 100644 KaizokuBackend/Controllers/RequestController.cs create mode 100644 KaizokuBackend/Controllers/UserController.cs create mode 100644 KaizokuBackend/EFMigrations/AddProviderHealthCheckColumns.cs create mode 100644 KaizokuBackend/EFMigrations/AddSeriesNeedsRename.cs create mode 100644 KaizokuBackend/Models/Database/InviteLinkEntity.cs create mode 100644 KaizokuBackend/Models/Database/MangaRequestEntity.cs create mode 100644 KaizokuBackend/Models/Database/PermissionPresetEntity.cs create mode 100644 KaizokuBackend/Models/Database/UserEntity.cs create mode 100644 KaizokuBackend/Models/Database/UserPermissionEntity.cs create mode 100644 KaizokuBackend/Models/Database/UserPreferencesEntity.cs create mode 100644 KaizokuBackend/Models/Database/UserSessionEntity.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/AuthResponseDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/InviteLinkDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/LoginDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/MangaRequestDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/PermissionDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/PermissionPresetDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/RefreshTokenDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/RegisterDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/UserDto.cs create mode 100644 KaizokuBackend/Models/Dto/Auth/UserPreferencesDto.cs create mode 100644 KaizokuBackend/Models/Dto/ProviderHealthResultDto.cs create mode 100644 KaizokuBackend/Models/Enums/DownloadManageAction.cs create mode 100644 KaizokuBackend/Models/Enums/RequestStatus.cs create mode 100644 KaizokuBackend/Models/Enums/UserRole.cs create mode 100644 KaizokuBackend/Services/Auth/AuthService.cs create mode 100644 KaizokuBackend/Services/Auth/InviteLinkService.cs create mode 100644 KaizokuBackend/Services/Auth/PasswordPolicy.cs create mode 100644 KaizokuBackend/Services/Auth/PermissionPresetService.cs create mode 100644 KaizokuBackend/Services/Auth/PermissionService.cs create mode 100644 KaizokuBackend/Services/Auth/UserPreferencesService.cs create mode 100644 KaizokuBackend/Services/Auth/UserService.cs create mode 100644 KaizokuBackend/Services/Jobs/Commands/HealthCheckSources.cs create mode 100644 KaizokuBackend/Services/Providers/ProviderHealthCheckService.cs create mode 100644 KaizokuBackend/Services/Requests/MangaRequestService.cs create mode 100644 KaizokuFrontend/public/offline.html create mode 100644 KaizokuFrontend/public/sw.js create mode 100644 KaizokuFrontend/src/app/login/page.tsx create mode 100644 KaizokuFrontend/src/app/register/page.tsx create mode 100644 KaizokuFrontend/src/app/requests/layout.tsx create mode 100644 KaizokuFrontend/src/app/requests/page.tsx create mode 100644 KaizokuFrontend/src/app/setup/page.tsx create mode 100644 KaizokuFrontend/src/components/auth/password-change-dialog.tsx create mode 100644 KaizokuFrontend/src/components/auth/require-auth.tsx create mode 100644 KaizokuFrontend/src/components/auth/require-permission.tsx create mode 100644 KaizokuFrontend/src/components/kzk/layout/fab.tsx create mode 100644 KaizokuFrontend/src/components/kzk/layout/page-layout.tsx create mode 100644 KaizokuFrontend/src/components/kzk/series/add-series/steps/request-series-step.tsx create mode 100644 KaizokuFrontend/src/components/kzk/series/request-series-dialog.tsx create mode 100644 KaizokuFrontend/src/components/kzk/settings/about-tab.tsx create mode 100644 KaizokuFrontend/src/components/kzk/settings/general-tab.tsx create mode 100644 KaizokuFrontend/src/components/kzk/settings/permissions-tab.tsx create mode 100644 KaizokuFrontend/src/components/kzk/settings/preferences-tab.tsx create mode 100644 KaizokuFrontend/src/components/kzk/settings/requests-tab.tsx create mode 100644 KaizokuFrontend/src/components/kzk/settings/users-tab.tsx create mode 100644 KaizokuFrontend/src/components/pwa/install-prompt.tsx create mode 100644 KaizokuFrontend/src/components/pwa/sw-provider.tsx create mode 100644 KaizokuFrontend/src/components/ui/pull-to-refresh.tsx create mode 100644 KaizokuFrontend/src/components/ui/responsive-modal.tsx create mode 100644 KaizokuFrontend/src/components/ui/textarea.tsx create mode 100644 KaizokuFrontend/src/contexts/auth-context.tsx create mode 100644 KaizokuFrontend/src/hooks/use-permission.ts create mode 100644 KaizokuFrontend/src/lib/api/auth-types.ts create mode 100644 KaizokuFrontend/src/lib/api/hooks/useAuth.ts create mode 100644 KaizokuFrontend/src/lib/api/hooks/useInvites.ts create mode 100644 KaizokuFrontend/src/lib/api/hooks/usePermissionPresets.ts create mode 100644 KaizokuFrontend/src/lib/api/hooks/useRequests.ts create mode 100644 KaizokuFrontend/src/lib/api/hooks/useUsers.ts create mode 100644 KaizokuFrontend/src/lib/api/services/authService.ts create mode 100644 KaizokuFrontend/src/lib/api/services/inviteService.ts create mode 100644 KaizokuFrontend/src/lib/api/services/permissionPresetService.ts create mode 100644 KaizokuFrontend/src/lib/api/services/requestService.ts create mode 100644 KaizokuFrontend/src/lib/api/services/userService.ts create mode 100644 KaizokuFrontend/src/lib/auth-token-store.ts create mode 100644 KaizokuFrontend/src/lib/sw-register.ts create mode 100644 KaizokuFrontend/src/lib/utils/responsive-card-default.ts delete mode 100644 Mihon.ExtensionsBridge.Net/Android.Compatibility.Layer/gradle/wrapper/gradle-wrapper.jar delete mode 100644 Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher/libs/IKVM.Java.dll delete mode 100644 Mihon.ExtensionsBridge.Net/IKVM.Android.Compatibility.Layer.CILPatcher/libs/IKVM.Runtime.dll create mode 100644 Mihon.ExtensionsBridge.Net/Mihon.ExtensionsBridge.Core/Runtime/ExtensionLoadException.cs delete mode 100644 Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll diff --git a/.gitattributes b/.gitattributes index bd209d92..51b1a474 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1 @@ *.* linguist-language=C# -*.jar filter=lfs diff=lfs merge=lfs -text -*.dll filter=lfs diff=lfs merge=lfs -text 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/rebuild-androidcompat.yml b/.github/workflows/rebuild-androidcompat.yml new file mode 100644 index 00000000..0ba1a954 --- /dev/null +++ b/.github/workflows/rebuild-androidcompat.yml @@ -0,0 +1,118 @@ +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: Update release asset + run: | + DLL="Mihon.ExtensionsBridge.Net/libs/Android.Compat.dll" + SHA=$(sha256sum "$DLL" | cut -d' ' -f1) + gh release upload android-compat-v1 "$DLL" --clobber + gh release edit android-compat-v1 \ + --notes "Pre-built Android.Compat.dll for CI builds. SHA256: $SHA" + echo "Updated release asset with SHA256: $SHA" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.gitignore b/.gitignore index 9e4b3593..ec900002 100644 --- a/.gitignore +++ b/.gitignore @@ -314,4 +314,14 @@ 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 diff --git a/KaizokuBackend/Authorization/BootstrapModeMiddleware.cs b/KaizokuBackend/Authorization/BootstrapModeMiddleware.cs new file mode 100644 index 00000000..7bfa1a34 --- /dev/null +++ b/KaizokuBackend/Authorization/BootstrapModeMiddleware.cs @@ -0,0 +1,73 @@ +using KaizokuBackend.Data; +using Microsoft.EntityFrameworkCore; + +namespace KaizokuBackend.Authorization +{ + /// + /// Middleware that enables bootstrap mode when no users exist in the database. + /// In bootstrap mode, the setup wizard and auth/setup endpoints are accessible without authentication, + /// and all other protected endpoints return 403 with a setup-required message. + /// + 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) + { + var path = context.Request.Path.Value?.ToLowerInvariant() ?? string.Empty; + + // Always allow these paths without any check + if (path.StartsWith("/api/auth/status") || + path.StartsWith("/api/auth/setup") || + path.StartsWith("/api/auth/login") || + path.StartsWith("/api/auth/register") || + path.StartsWith("/api/auth/refresh") || + path.StartsWith("/api/invites/validate/")) + { + await _next(context); + return; + } + + // Check if users exist (cached to avoid DB hit on every request) + if (_hasUsers == null || _hasUsers == false) + { + _hasUsers = await db.Users.AnyAsync(); + } + + if (_hasUsers == false) + { + // In bootstrap mode, allow setup wizard and static files + if (path.StartsWith("/api/setup") || + path.StartsWith("/api/settings") || + !path.StartsWith("/api/")) + { + await _next(context); + return; + } + + // Block all other API endpoints + context.Response.StatusCode = 403; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"error\":\"Setup required. Please create an admin account first.\"}"); + return; + } + + await _next(context); + } + + /// + /// Invalidate the cached user check so it re-queries on next request. + /// Call this after the first user is created. + /// + 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/Controllers/AuthController.cs b/KaizokuBackend/Controllers/AuthController.cs new file mode 100644 index 00000000..62a54c03 --- /dev/null +++ b/KaizokuBackend/Controllers/AuthController.cs @@ -0,0 +1,185 @@ +using KaizokuBackend.Models.Dto.Auth; +using KaizokuBackend.Services.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KaizokuBackend.Controllers +{ + [ApiController] + [Route("api/auth")] + [Produces("application/json")] + public class AuthController : ControllerBase + { + private readonly AuthService _authService; + private readonly UserService _userService; + private readonly ILogger _logger; + private static readonly SemaphoreSlim _setupLock = new(1, 1); + + public AuthController(AuthService authService, UserService userService, ILogger logger) + { + _authService = authService; + _userService = userService; + _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" }); + } + } + + [HttpGet("status")] + [AllowAnonymous] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public async Task GetStatusAsync(CancellationToken token = default) + { + try + { + var hasUsers = await _userService.AnyUsersExistAsync(token).ConfigureAwait(false); + return Ok(new { requiresSetup = !hasUsers }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking auth status"); + return StatusCode(500, new { error = "An error occurred checking auth status" }); + } + } + + [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 _setupLock.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." }); + + 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 + { + _setupLock.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..e45df104 100644 --- a/KaizokuBackend/Controllers/ImagesController.cs +++ b/KaizokuBackend/Controllers/ImagesController.cs @@ -3,12 +3,14 @@ using KaizokuBackend.Services.Downloads; using KaizokuBackend.Services.Images; using KaizokuBackend.Services.Images.Providers; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; 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 { @@ -23,6 +25,7 @@ public ImagesController(ILogger logger, ThumbCacheService thum _logger = logger; } [HttpGet("{key?}")] + [AllowAnonymous] [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status304NotModified)] [ProducesResponseType(StatusCodes.Status400BadRequest)] 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..603c3e99 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,6 +165,7 @@ 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) @@ -164,6 +190,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 +214,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 +241,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 +271,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 +296,7 @@ public async Task> UpdateSeriesAsync([FromBody] } [HttpDelete] + [Authorize(Policy = "RequirePermission:CanDeleteSeries")] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(400)] [ProducesResponseType(500)] diff --git a/KaizokuBackend/Controllers/SettingsController.cs b/KaizokuBackend/Controllers/SettingsController.cs index 1f3ce41f..88758c77 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; 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..6cdb785e --- /dev/null +++ b/KaizokuBackend/Controllers/UserController.cs @@ -0,0 +1,397 @@ +using KaizokuBackend.Models.Dto.Auth; +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 PermissionService _permissionService; + private readonly UserPreferencesService _preferencesService; + private readonly ILogger _logger; + private readonly IConfiguration _config; + + public UserController(UserService userService, PermissionService permissionService, + UserPreferencesService preferencesService, ILogger logger, IConfiguration config) + { + _userService = userService; + _permissionService = permissionService; + _preferencesService = preferencesService; + _logger = logger; + _config = config; + } + + 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(UserDto), 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 storagePath = _config["StorageFolder"] ?? _config["runtimeDirectory"] ?? "."; + var avatarUrl = await _userService.UploadAvatarAsync(userId, file, storagePath, token).ConfigureAwait(false); + return Ok(new { avatarPath = 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 path = await _userService.GetAvatarPathAsync(id, token).ConfigureAwait(false); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) + return NotFound(); + + var ext = Path.GetExtension(path).ToLowerInvariant(); + var contentType = ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + _ => "application/octet-stream" + }; + + var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return File(stream, contentType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving avatar"); + return StatusCode(500, "An error occurred while retrieving the avatar."); + } + } + } +} diff --git a/KaizokuBackend/Data/AppDbContext.cs b/KaizokuBackend/Data/AppDbContext.cs index 9da45099..f360af05 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,107 @@ 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(); + entity.Property(u => u.Email).UseCollation("BINARY").IsRequired(); + entity.Property(u => u.DisplayName).UseCollation("BINARY").IsRequired(); + entity.Property(u => u.PasswordHash).UseCollation("BINARY").IsRequired(); + entity.Property(u => u.Salt).UseCollation("BINARY").IsRequired(); + entity.Property(u => u.Role).IsRequired().HasConversion(); + entity.Property(u => u.AvatarPath).UseCollation("BINARY").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.Email).IsUnique().HasDatabaseName("IX_User_Email"); + 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/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..ebdf423a 100644 --- a/KaizokuBackend/Extensions/ModelExtensions.cs +++ b/KaizokuBackend/Extensions/ModelExtensions.cs @@ -266,6 +266,7 @@ public static SeriesExtendedDto ToSeriesExtendedInfo(this SeriesEntity s, Settin Path = EnvironmentSetup.IsDocker ? s.StoragePath : 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/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..902da041 --- /dev/null +++ b/KaizokuBackend/Models/Database/UserEntity.cs @@ -0,0 +1,26 @@ +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; + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public string Salt { get; set; } = string.Empty; + public UserRole Role { get; set; } = UserRole.User; + public string? AvatarPath { 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/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..b2a75e14 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/LoginDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class LoginDto + { + [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..388dd5cb --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/RefreshTokenDto.cs @@ -0,0 +1,38 @@ +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("email")] + public string Email { 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; } + } + + 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..633f0d09 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/RegisterDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace KaizokuBackend.Models.Dto.Auth +{ + public class RegisterDto + { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { 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/UserDto.cs b/KaizokuBackend/Models/Dto/Auth/UserDto.cs new file mode 100644 index 00000000..5c1e1843 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/UserDto.cs @@ -0,0 +1,73 @@ +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("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("role")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public UserRole Role { get; set; } + + [JsonPropertyName("avatarPath")] + public string? AvatarPath { 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("email")] + public string? Email { 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..a417d9d3 --- /dev/null +++ b/KaizokuBackend/Models/Dto/Auth/UserPreferencesDto.cs @@ -0,0 +1,24 @@ +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; + } + + public class UpdatePreferencesDto : UserPreferencesDto + { + } +} 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/EditableSettingsDto.cs b/KaizokuBackend/Models/Dto/EditableSettingsDto.cs index 66715cea..7b5f077c 100644 --- a/KaizokuBackend/Models/Dto/EditableSettingsDto.cs +++ b/KaizokuBackend/Models/Dto/EditableSettingsDto.cs @@ -65,6 +65,15 @@ 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; + } 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/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/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/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/Services/Auth/AuthService.cs b/KaizokuBackend/Services/Auth/AuthService.cs new file mode 100644 index 00000000..e25ff8de --- /dev/null +++ b/KaizokuBackend/Services/Auth/AuthService.cs @@ -0,0 +1,384 @@ +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; + + public AuthService(AppDbContext db, ILogger logger, + InviteLinkService inviteLinkService, PermissionService permissionService) + { + _db = db; + _logger = logger; + _inviteLinkService = inviteLinkService; + _permissionService = permissionService; + } + + 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/email + var existingUser = await _db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Username == dto.Username || u.Email == dto.Email, token) + .ConfigureAwait(false); + + if (existingUser != null) + { + if (existingUser.Username == dto.Username) + throw new InvalidOperationException("Username is already taken."); + throw new InvalidOperationException("Email is already in use."); + } + + // Validate password against policy + var passwordError = PasswordPolicy.Validate(dto.Password); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + // Create user + var salt = GenerateSalt(); + var user = new UserEntity + { + Id = Guid.NewGuid(), + Username = dto.Username.Trim(), + Email = dto.Email.Trim().ToLowerInvariant(), + DisplayName = string.IsNullOrWhiteSpace(dto.DisplayName) ? dto.Username : dto.DisplayName.Trim(), + PasswordHash = HashPassword(dto.Password, salt), + Salt = salt, + Role = UserRole.User, + 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 usernameOrEmail = dto.UsernameOrEmail.Trim(); + var user = await _db.Users + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Username == usernameOrEmail || u.Email == usernameOrEmail.ToLowerInvariant(), token) + .ConfigureAwait(false); + + if (user == null) + throw new InvalidOperationException("Invalid username/email or password."); + + if (!user.IsActive) + throw new InvalidOperationException("Account is disabled."); + + if (!VerifyPassword(dto.Password, user.PasswordHash, user.Salt)) + throw new InvalidOperationException("Invalid username/email 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); + } + + 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 = new List + { + new Claim("UserId", user.Id.ToString()), + new Claim("Username", user.Username), + new Claim("Email", user.Email), + new Claim("Role", user.Role.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()), + }; + + 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, + Email = user.Email, + DisplayName = user.DisplayName, + Role = user.Role, + AvatarPath = user.AvatarPath, + 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, + Email = user.Email, + DisplayName = user.DisplayName, + Role = user.Role, + AvatarPath = user.AvatarPath, + 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/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/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/UserPreferencesService.cs b/KaizokuBackend/Services/Auth/UserPreferencesService.cs new file mode 100644 index 00000000..62a63c28 --- /dev/null +++ b/KaizokuBackend/Services/Auth/UserPreferencesService.cs @@ -0,0 +1,78 @@ +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, + Theme = dto.Theme, + DefaultLanguage = dto.DefaultLanguage, + CardSize = dto.CardSize, + NsfwVisibility = dto.NsfwVisibility + }; + _db.UserPreferences.Add(prefs); + } + else + { + prefs.Theme = dto.Theme; + prefs.DefaultLanguage = dto.DefaultLanguage; + prefs.CardSize = dto.CardSize; + prefs.NsfwVisibility = dto.NsfwVisibility; + } + + 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..93449615 --- /dev/null +++ b/KaizokuBackend/Services/Auth/UserService.cs @@ -0,0 +1,422 @@ +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; + + public UserService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + 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; + } + + 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 || u.Email == dto.Email.ToLowerInvariant(), token) + .ConfigureAwait(false); + + if (existing != null) + { + if (existing.Username == dto.Username) + throw new InvalidOperationException("Username is already taken."); + throw new InvalidOperationException("Email is already in use."); + } + + var passwordError = PasswordPolicy.Validate(dto.Password); + if (passwordError != null) + throw new InvalidOperationException(passwordError); + + var salt = GenerateSalt(); + var user = new UserEntity + { + Id = Guid.NewGuid(), + Username = dto.Username.Trim(), + Email = dto.Email.Trim().ToLowerInvariant(), + DisplayName = string.IsNullOrWhiteSpace(dto.DisplayName) ? dto.Username : dto.DisplayName.Trim(), + PasswordHash = HashPassword(dto.Password, salt), + Salt = salt, + Role = dto.Role, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsActive = true + }; + + _db.Users.Add(user); + + var permissions = new UserPermissionEntity { UserId = user.Id }; + 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.Email != null) + { + var emailLower = dto.Email.Trim().ToLowerInvariant(); + var emailExists = await _db.Users.AnyAsync(u => u.Email == emailLower && u.Id != id, token).ConfigureAwait(false); + if (emailExists) + throw new InvalidOperationException("Email is already in use."); + user.Email = emailLower; + } + + if (dto.Role.HasValue) + user.Role = dto.Role.Value; + + 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) + { + var user = await _db.Users.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(); + + if (dto.Email != null) + { + var emailLower = dto.Email.Trim().ToLowerInvariant(); + var emailExists = await _db.Users.AnyAsync(u => u.Email == emailLower && u.Id != userId, token).ConfigureAwait(false); + if (emailExists) throw new InvalidOperationException("Email is already in use."); + user.Email = emailLower; + } + + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + return AuthService.MapUserDto(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 (!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 AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".webp" + }; + + private static readonly HashSet AllowedAvatarContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/png", "image/jpeg", "image/gif", "image/webp" + }; + + public async Task UploadAvatarAsync(Guid userId, IFormFile file, string avatarStoragePath, 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."); + + // Validate both extension and content type to prevent malicious uploads + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext) || !AllowedAvatarExtensions.Contains(ext)) + throw new InvalidOperationException("Invalid file type. Allowed: PNG, JPG, GIF, WEBP."); + + if (!string.IsNullOrEmpty(file.ContentType) && !AllowedAvatarContentTypes.Contains(file.ContentType)) + throw new InvalidOperationException("Invalid content type. Only image files are allowed."); + + // Delete old avatar if exists + if (!string.IsNullOrEmpty(user.AvatarPath) && File.Exists(user.AvatarPath)) + { + File.Delete(user.AvatarPath); + } + + var fileName = $"{userId}{ext}"; + var directory = Path.Combine(avatarStoragePath, "avatars"); + Directory.CreateDirectory(directory); + var filePath = Path.Combine(directory, fileName); + + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await file.CopyToAsync(stream, token).ConfigureAwait(false); + } + + user.AvatarPath = filePath; + 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."); + + if (!string.IsNullOrEmpty(user.AvatarPath) && File.Exists(user.AvatarPath)) + { + File.Delete(user.AvatarPath); + } + + user.AvatarPath = null; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + public async Task GetAvatarPathAsync(Guid userId, CancellationToken token = default) + { + var user = await _db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == userId, token) + .ConfigureAwait(false); + return user?.AvatarPath; + } + + public async Task AnyUsersExistAsync(CancellationToken token = default) + { + return await _db.Users.AnyAsync(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); + } + + private static UserDetailDto MapToDetailDto(UserEntity user) + { + return new UserDetailDto + { + Id = user.Id, + Username = user.Username, + Email = user.Email, + DisplayName = user.DisplayName, + Role = user.Role, + AvatarPath = user.AvatarPath, + 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() + }; + } + } +} diff --git a/KaizokuBackend/Services/Background/StartupHostedService.cs b/KaizokuBackend/Services/Background/StartupHostedService.cs index e36ec3ae..8c3025f3 100644 --- a/KaizokuBackend/Services/Background/StartupHostedService.cs +++ b/KaizokuBackend/Services/Background/StartupHostedService.cs @@ -110,6 +110,7 @@ public async Task StartAsync(CancellationToken cancellationToken) await settingsService.SetTimesSettingsAsync(settings, cancellationToken).ConfigureAwait(false); AppDbContext db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + await EnsureAuthTablesAsync(db, 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); @@ -120,9 +121,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 +149,166 @@ public async Task StartAsync(CancellationToken cancellationToken) } } + /// + /// Ensures auth/multi-user tables exist for existing databases that were created before the multi-user feature. + /// For new installations, EnsureCreatedAsync already creates these tables. + /// + private async Task EnsureAuthTablesAsync(AppDbContext db, CancellationToken cancellationToken) + { + try + { + // Check if Users table already 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) + return; + + _logger.LogInformation("Creating auth tables for multi-user support..."); + + await db.Database.ExecuteSqlRawAsync(@" + CREATE TABLE IF NOT EXISTS ""Users"" ( + ""Id"" TEXT NOT NULL PRIMARY KEY, + ""Username"" TEXT NOT NULL COLLATE BINARY, + ""Email"" TEXT NOT NULL COLLATE BINARY, + ""DisplayName"" TEXT NOT NULL COLLATE BINARY, + ""PasswordHash"" TEXT NOT NULL COLLATE BINARY, + ""Salt"" TEXT NOT NULL COLLATE BINARY, + ""Role"" INTEGER NOT NULL, + ""AvatarPath"" TEXT COLLATE BINARY, + ""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); + await db.Database.ExecuteSqlRawAsync(@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_User_Email"" ON ""Users"" (""Email"");", cancellationToken).ConfigureAwait(false); + + 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."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating auth tables"); + throw; + } + } + 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..8cf1a9ab 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)) diff --git a/KaizokuBackend/Services/Images/ThumbCacheService.cs b/KaizokuBackend/Services/Images/ThumbCacheService.cs index e7609871..8a72649c 100644 --- a/KaizokuBackend/Services/Images/ThumbCacheService.cs +++ b/KaizokuBackend/Services/Images/ThumbCacheService.cs @@ -207,6 +207,52 @@ public async Task CheckETagAsync(string key, string? etag, CancellationTok EtagCacheEntity? cac = await AddInternalUrlAsync(url, mihonProviderId, token).ConfigureAwait(false); return cac?.Url; } + + /// + /// 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 + { + var urlStrings = validUrls.Select(u => u.Url).ToList(); + var existingUrls = await _db.ETagCache + .Where(e => urlStrings.Contains(e.Url)) + .Select(e => e.Url) + .ToListAsync(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) { 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..586df1e9 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, 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..01534f87 100644 --- a/KaizokuBackend/Services/Series/SeriesArchiveService.cs +++ b/KaizokuBackend/Services/Series/SeriesArchiveService.cs @@ -4,6 +4,7 @@ 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; @@ -20,15 +21,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 +48,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,9 +64,12 @@ 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); + List chaps = series.Sources.SelectMany(a => a.Chapters) .Where(a => !string.IsNullOrEmpty(a.Filename)).ToList(); - + return GetIntegrityResult(basePath, chaps); } @@ -122,6 +128,163 @@ public async Task CleanupSeriesAsync(Guid seriesId, CancellationToken token = de await _db.SaveChangesAsync(token).ConfigureAwait(false); } + /// + /// Renames the storage folder (if the title changed) and all chapter files for a series + /// to use the correct title. Also clears the flag. + /// + /// The series ID to rename files for + /// Cancellation token + 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); + } + } + + /// + /// 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. + /// + 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); + } + } + + /// + /// 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. + /// + 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"); + } + /// /// Updates all series titles and comic info files /// diff --git a/KaizokuBackend/Services/Series/SeriesCommandService.cs b/KaizokuBackend/Services/Series/SeriesCommandService.cs index 86016644..e60194ac 100644 --- a/KaizokuBackend/Services/Series/SeriesCommandService.cs +++ b/KaizokuBackend/Services/Series/SeriesCommandService.cs @@ -401,7 +401,7 @@ public async Task 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); List? chapterData; ISourceInterop src; @@ -414,7 +414,16 @@ public async Task 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( @@ -596,6 +605,69 @@ private class ComboSeries public List Chapters { get; set; } = []; } + /// + /// Returns true if a title appears to have been truncated by the source (ends with "..." or "…"). + /// + private static bool IsTitleTruncated(string? title) + { + if (string.IsNullOrEmpty(title)) + return false; + return title.EndsWith("...") || title.EndsWith("\u2026"); + } + + /// + /// 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 so the user can rename files/folder. + /// + 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..65aa998a 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; @@ -917,6 +918,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 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 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/ServiceExtensions.cs b/KaizokuBackend/Services/ServiceExtensions.cs index a26fbfa3..3cd99483 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(); - + services.TryAddScoped(); + return services; } @@ -103,7 +108,26 @@ public static IServiceCollection AddDownloadServices(this IServiceCollection ser // Download CQRS Services services.TryAddScoped(); services.TryAddScoped(); - + + return services; + } + + public static IServiceCollection AddAuthServices(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + // Authorization handlers — must use AddSingleton (not TryAdd) to replace the + // DefaultAuthorizationPolicyProvider already registered by AddAuthorization() + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/KaizokuBackend/Services/Settings/SettingsService.cs b/KaizokuBackend/Services/Settings/SettingsService.cs index 275637f1..22bd7f8c 100644 --- a/KaizokuBackend/Services/Settings/SettingsService.cs +++ b/KaizokuBackend/Services/Settings/SettingsService.cs @@ -299,7 +299,10 @@ 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 }; @@ -335,7 +338,10 @@ 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 }; set.StorageFolder = _config["StorageFolder"] ?? string.Empty; diff --git a/KaizokuBackend/Startup.cs b/KaizokuBackend/Startup.cs index ecfb7e9c..86183ab3 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 }); }); @@ -95,6 +104,53 @@ public void ConfigureServices(IServiceCollection services) options.AgeInDays = Configuration.GetValue("CacheCheckInDays", 7); }); + // 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, KaizokuBackend.Authorization.JwtKeyResolver>(); + + services.AddAuthorization(); + services.AddExtensionsBridge(); // Add consolidated services @@ -106,6 +162,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 @@ -153,6 +210,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) // Order matters for the following middleware app.UseRouting(); + // Bootstrap mode: when no users exist, only setup endpoints are accessible. + // MUST run before UseAuthentication so that the setup wizard isn't blocked by 401. + app.UseMiddleware(); + + app.UseAuthentication(); + app.UseAuthorization(); + // 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/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 @@ + + + + + + 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 d4fd8da3..0d561ee2 100644 --- a/KaizokuFrontend/src/app/cloud-latest/page.tsx +++ b/KaizokuFrontend/src/app/cloud-latest/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { getResponsiveCardDefault } from "@/lib/utils/responsive-card-default"; import { Sparkles, Globe } from "lucide-react"; import { Select, @@ -74,7 +75,7 @@ export default function CloudLatestPage() { 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 [items, setItems] = useState([]); const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(true); diff --git a/KaizokuFrontend/src/app/layout.tsx b/KaizokuFrontend/src/app/layout.tsx index 558f7987..aed45afc 100644 --- a/KaizokuFrontend/src/app/layout.tsx +++ b/KaizokuFrontend/src/app/layout.tsx @@ -4,6 +4,7 @@ import "@/styles/globals.css"; import React from "react"; import { GeistSans } from "geist/font/sans"; +import { Toaster } from "sonner"; import { ThemeProvider } from "@/components/theme/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -14,6 +15,9 @@ import { ClientSideSetupWizard } from "@/components/kzk/setup-wizard/client-wrap import { ImportWizard } from "@/components/kzk/import-wizard"; import { FontLoader } from "@/components/ui/font-loader"; import { SearchProvider } from "@/contexts/search-context"; +import { AuthProvider } from "@/contexts/auth-context"; +import { ServiceWorkerProvider } from "@/components/pwa/sw-provider"; +import { PWAInstallPrompt } from "@/components/pwa/install-prompt"; export default function RootLayout({ children, @@ -24,7 +28,15 @@ export default function RootLayout({ Kaizoku.NET - + + + + + + + + + @@ -76,16 +88,21 @@ export default function RootLayout({ > + + {children} + + + diff --git a/KaizokuFrontend/src/app/library/page.tsx b/KaizokuFrontend/src/app/library/page.tsx index 11b45cab..a96c8727 100644 --- a/KaizokuFrontend/src/app/library/page.tsx +++ b/KaizokuFrontend/src/app/library/page.tsx @@ -2,26 +2,32 @@ // All rendering happens on the client for static export compatibility -import { ListFilter } from "lucide-react"; +import { useState, useMemo, useCallback } from "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 { 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"; export default function RootPage() { + const queryClient = useQueryClient(); + const canBrowseSources = usePermission('canBrowseSources'); + + const handleRefresh = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: ["series", "library"] }); + }, [queryClient]); + // Session storage keys const SESSION_KEYS = { tab: "kzk_tab", @@ -42,7 +48,7 @@ export default function RootPage() { 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); }; @@ -56,12 +62,11 @@ export default function RootPage() { // 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); @@ -71,11 +76,11 @@ export default function RootPage() { unique.push(series); } }); - + if (duplicates.length > 0) { console.error(`[Library] Found ${duplicates.length} duplicate series:`, duplicates); } - + return unique; }, [library]); @@ -110,10 +115,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 +124,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 +140,136 @@ 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 */} -
- -
+ + +
+ {/* Filter row */} +
+ {/* Status Filter */} +
+ +
+ +
+ +
+ + {canBrowseSources && ( +
+ +
+ )} + + {/* Right side controls */} +
+ {/* Order Select */} +
+ +
+ + {/* Card Size Select */} +
+ +
+ +
+ +
+
+
-
- -
-
-
-
- -
-
+ {/* Series grid */} +
+ +
-
+ + ); } 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..d0881f3e 100644 --- a/KaizokuFrontend/src/app/library/series/page.tsx +++ b/KaizokuFrontend/src/app/library/series/page.tsx @@ -2,7 +2,7 @@ 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 { useSeriesById, useSetProviderMatch, useDeleteSeries, useUpdateSeries, useVerifyIntegrity, useCleanupSeries, useRenameSeriesFiles } from "@/lib/api/hooks/useSeries"; import { useDownloadsForSeries } from "@/lib/api/hooks/useDownloads"; import { seriesService } from "@/lib/api/services/seriesService"; import { useQueryClient } from '@tanstack/react-query'; @@ -21,13 +21,14 @@ 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 { Download, Plus, Power, Search, Trash2, Pause, Play, ExternalLink, ShieldCheck, AlertTriangle, CheckCircle, Clock, Calendar, FileText } 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 { 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 { usePermission, useIsAdmin } from "@/hooks/use-permission"; import { ProviderMatchDialog } from "@/components/dialogs/provider-match-dialog"; import { AddSeries } from "@/components/kzk/series/add-series"; import { formatThumbnailUrl } from "@/lib/utils/thumbnail"; @@ -46,7 +47,8 @@ const ProviderCard = ({ provider, onDisabledChange, onDeleteProvider, onFromChapterChange, - deletedProviderStates + deletedProviderStates, + canEdit = true, }: { provider: ProviderExtendedInfo; useCover: boolean; @@ -60,6 +62,7 @@ const ProviderCard = ({ provider, onDeleteProvider: (providerId: string) => void; onFromChapterChange: (providerId: string, value: string) => void; deletedProviderStates: Record; + canEdit?: boolean; }) => { const [isEnabled, setIsEnabled] = useState(!provider.isDisabled && !provider.isUninstalled); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -167,213 +170,190 @@ const ProviderCard = ({ provider, }; 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} + +
+
+ {/* Small provider thumbnail */} +
+ {provider.title} { + const target = e.target as HTMLImageElement; + if (target.src !== window.location.origin + '/kaizoku.net.png') { + target.src = '/kaizoku.net.png'; + } + }} />
- )} - {/* Header section with thumbnail and info */} -
-
- {/* Provider Thumbnail */} -
- {provider.title} + {/* Content area */} +
+ {/* Title row */} +
+ {provider.title}
-
- {provider.title} - { provider.url ? ( -
{ + {/* Row 1: Provider identity + chapter info */} +
+ {/* Provider name with optional link */} + {provider.url ? ( +
- + title="Open in source" + > + {provider.provider}{(provider.provider != provider.scanlator && provider.scanlator) ? ` • ${provider.scanlator}` : ''} + + ) : ( -
- {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} + + + {provider.isUninstalled && ( + + + Uninstalled -
)} -
{/* 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' })}`; - })()} - - )} + Ch. {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'; + return new Date(utcString).toLocaleDateString(); + })()} )} + + )} +
-
-
- - -
- {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}

+ {/* Row 2: Controls — compact inline layout */} + {canEdit && ( +
+ {!provider.isUnknown && ( + <> + + + + + + +
+ After: + setLocalFromChapter(e.target.value)} + placeholder="0" + className="h-6 w-16 text-[11px] bg-background text-right tabular-nums font-mono" + disabled={provider.isDisabled} + onBlur={handleFromChapterBlur} + onKeyDown={handleFromChapterKeyDown} + /> +
+ )} -
- {/* 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" - /> - -
-
- )} +
+ + {/* Action buttons — always inline */} +
+ {!provider.isUnknown && !provider.isUninstalled && ( + + )} + {provider.isUnknown && ( + + )} -
-
+ +
+
+ )} +
+
+ {/* Provider Match Dialog */} Confirm Delete

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

+

+
@@ -660,8 +641,8 @@ const DownloadsPanel = memo(({ seriesId, isDeleting }: { seriesId: string; isDel if (downloadsError) { return ( - - + + Latest Downloads @@ -678,8 +659,8 @@ const DownloadsPanel = memo(({ seriesId, isDeleting }: { seriesId: string; isDel } return ( - - + + Latest Downloads @@ -733,13 +714,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 @@ -1495,12 +1482,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); @@ -1743,10 +1732,18 @@ function SeriesPageContent() { if (error || !series) { return ( -
+
{error ? "Error loading series" : "Series not found"}
+
+ + +
); } @@ -1765,199 +1762,259 @@ function SeriesPageContent() { const visibleProvidersCount = series.providers.filter(provider => !providerDeletedStates[provider.id]).length; return (<> - {/* Three-area layout */} -
{/* Left Column - Two rows (80% width) */} -
- {/* Top Left: Series Details */} - -
- {/* Poster */} -
- {displayTitle} + {/* New layout: full-width hero, then sources + downloads side-by-side */} +
+ + {/* Hero Card - full width, compact */} + + +
+ {/* Poster */} +
+ {displayTitle} +
+ + {/* Series Info */} +
+ {/* Title + Status Badge Row */} +
+ {displayTitle} + + {statusDisplay.text} + +
+ + {/* Chapter info row — plain text, no bubbles */} +
+ Ch. {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 Info */} -
- {/* Title + Status Badge Row */} -
- {displayTitle} - - {statusDisplay.text} - + + {/* Author / Artist — one compact line */} + {(series.author || series.artist) && ( +
+ {series.author && By {series.author}} + {series.author && series.artist && ·} + {series.artist && Art by {series.artist}}
-
-
- - {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} -
- )} + )} + + {/* Genre tags — smaller on mobile */} + {series.genre && series.genre.length > 0 && ( +
+ {series.genre.map((genre) => ( + + {genre} + + ))}
+ )} - {series.genre && series.genre.length > 0 && ( -
- {series.genre.map((genre) => ( - - {genre} - - ))} + {/* Description - clamped */} + {series.description && ( +
+

{series.description}

+
+ )} + + {/* Path — hidden on mobile */} + {series.path && ( +
+
+ {series.path}
- )} +
+ )} - {/* Description - Flexible area that fills available space */} - {series.description && ( -
-

{series.description}

-
+ {/* Action Buttons */} + {(canDelete || canEdit || canManageDownloads) && ( +
+ {canDelete && ( + )} - {/* Bottom Row: Path + Action Buttons */} -
- {/* Series Path Display */} - {series.path && ( -
-
- {series.path} -
-
- )} + {canEdit && ( + + )} - {/* Action Buttons - Delete, Verify, and Pause/Resume Downloads */} -
- {/* Delete Series Button */} - + {canEdit && ( + + )} - {/* Verify Integrity Button */} - + {canManageDownloads && ( + + )} +
+ )} +
+
+ + - {/* Pause/Resume Downloads Button */} - -
-
-
-
- {/* Bottom Left: Providers */} - + {/* 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. + + +
+ )} + + {/* Below hero: Sources (2/3) + Downloads (1/3) */} +
+ + {/* Sources section — spans 2 columns */} +
Sources {visibleProvidersCount} - - - Add Sources - - } - /> + {canEdit && ( + + + 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 ( - ); - })} -
+
+ {series.providers + .filter(provider => !providerDeletedStates[provider.id]) + .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() ?? ""; + + const updatedProvider = { + ...provider, + isDisabled: isDisabled + }; + + return ( + + ); + })} +
-
+
- {/* Right Column: Downloads (20% width) - Using new API */} - + {/* Downloads panel — 1 column, scrollable */} +
+ +
+
{/* 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..a0ceaa4a --- /dev/null +++ b/KaizokuFrontend/src/app/login/page.tsx @@ -0,0 +1,226 @@ +"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 } = useAuth(); + const router = useRouter(); + + const [usernameOrEmail, setUsernameOrEmail] = 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; + } + if (isAuthenticated) { + router.replace('/library'); + } + }, [isAuthenticated, isLoading, needsSetup, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!usernameOrEmail.trim() || !password) return; + + setError(null); + setIsSubmitting(true); + try { + await login(usernameOrEmail.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 */} +