diff --git a/app/Http/Middleware/ClearanceMiddleware.php b/app/Http/Middleware/ClearanceMiddleware.php index 08390e1d1..26debca6a 100644 --- a/app/Http/Middleware/ClearanceMiddleware.php +++ b/app/Http/Middleware/ClearanceMiddleware.php @@ -2,6 +2,8 @@ namespace App\Http\Middleware; +use App\Models\Category; +use App\Models\RootCategory; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,66 +20,236 @@ class ClearanceMiddleware { $user = $request->user(); - if ($user->hasAnyRole(['Admin', 'Moderator']) && ! $request->is(['Admin', 'Admin/*'])) { + // Admin/Moderator bypass only applies to Admin section pages + // They should still respect their personal category permission settings + if ($request->is(['Admin', 'Admin/*'])) { + if (! $user->hasAnyRole(['Admin', 'Moderator'])) { + abort(403, 'Unauthorized access to admin area'); + } + return $next($request); } - if ($request->is(['Movies', 'browse/Movies', 'browse/Movies/*'])) { - if (! $user->hasDirectPermission('view movies') || ! $user->hasPermissionTo('view movies')) { - abort(401); - } else { - return $next($request); + // Get the path for pattern matching + $path = $request->path(); + + // Check for browse routes with subcategory - e.g., browse/Movies/HD + if (preg_match('#^browse/([^/]+)/([^/]+)$#i', $path, $matches)) { + $parentCategoryName = $matches[1]; + $subcategoryName = $matches[2]; + + // First check if the main category is blocked + $blockedCategory = $this->checkMainCategoryPermission($user, $parentCategoryName); + if ($blockedCategory) { + return $this->abortCategoryDisabled($blockedCategory); } + + // Then check if the subcategory is excluded (unless it's "All") + if (strtolower($subcategoryName) !== 'all') { + $blockedSubcategory = $this->checkSubcategoryExclusion($user, $parentCategoryName, $subcategoryName); + if ($blockedSubcategory) { + return $this->abortSubcategoryDisabled($parentCategoryName, $blockedSubcategory); + } + } + + return $next($request); } - if ($request->is(['Console', 'browse/Console', 'browse/Console/*'])) { - if (! $user->hasDirectPermission('view console') || ! $user->hasPermissionTo('view console')) { - abort(401); - } else { - return $next($request); + // Check for browse routes without subcategory - e.g., browse/Movies + if (preg_match('#^browse/([^/]+)$#i', $path, $matches)) { + $parentCategoryName = $matches[1]; + + $blockedCategory = $this->checkMainCategoryPermission($user, $parentCategoryName); + if ($blockedCategory) { + return $this->abortCategoryDisabled($blockedCategory); } + + return $next($request); } - if ($request->is(['Books', 'browse/Books', 'browse/Books/*'])) { - if (! $user->hasDirectPermission('view books') || ! $user->hasPermissionTo('view books')) { - abort(401); - } else { - return $next($request); + // Movies category + if ($this->matchesCategoryPath($path, 'Movies') + || $this->matchesCategoryPath($path, 'movie') + || $this->matchesCategoryPath($path, 'trending-movies') + || $this->matchesCategoryPath($path, 'movietrailers') + || $this->matchesCategoryPath($path, 'mymovies')) { + if (! $user->hasDirectPermission('view movies')) { + return $this->abortCategoryDisabled('Movies'); } + + return $next($request); } - if ($request->is(['Audio', 'browse/Audio', 'browse/Audio/*'])) { - if (! $user->hasDirectPermission('view audio') || ! $user->hasPermissionTo('view audio')) { - abort(401); - } else { - return $next($request); + // Console category + if ($this->matchesCategoryPath($path, 'Console')) { + if (! $user->hasDirectPermission('view console')) { + return $this->abortCategoryDisabled('Console'); } + + return $next($request); } - if ($request->is(['XXX', 'browse/XXX', 'browse/XXX/*'])) { - if (! $user->hasDirectPermission('view adult') || ! $user->hasPermissionTo('view adult')) { - abort(401); - } else { - return $next($request); + // Books category + if ($this->matchesCategoryPath($path, 'Books')) { + if (! $user->hasDirectPermission('view books')) { + return $this->abortCategoryDisabled('Books'); } + + return $next($request); } - if ($request->is(['Games', 'browse/PC', 'browse/PC/*'])) { - if (! $user->hasDirectPermission('view pc') || ! $user->hasPermissionTo('view pc')) { - abort(401); - } else { - return $next($request); + // Audio category + if ($this->matchesCategoryPath($path, 'Audio')) { + if (! $user->hasDirectPermission('view audio')) { + return $this->abortCategoryDisabled('Audio'); } + + return $next($request); } - if ($request->is(['TV', 'browse/TV', 'browse/TV/*'])) { - if (! $user->hasDirectPermission('view tv') || ! $user->hasPermissionTo('view tv')) { - abort(401); - } else { - return $next($request); + // Adult (XXX) category + if ($this->matchesCategoryPath($path, 'XXX')) { + if (! $user->hasDirectPermission('view adult')) { + return $this->abortCategoryDisabled('Adult'); } + + return $next($request); + } + + // PC/Games category + if ($this->matchesCategoryPath($path, 'Games') || $this->matchesCategoryPath($path, 'PC')) { + if (! $user->hasDirectPermission('view pc')) { + return $this->abortCategoryDisabled('PC'); + } + + return $next($request); + } + + // TV category + if ($this->matchesCategoryPath($path, 'TV') + || $this->matchesCategoryPath($path, 'series') + || $this->matchesCategoryPath($path, 'trending-tv') + || $this->matchesCategoryPath($path, 'myshows')) { + if (! $user->hasDirectPermission('view tv')) { + return $this->abortCategoryDisabled('TV'); + } + + return $next($request); } return $next($request); } + + /** + * Check if the user has permission to view a main category. + * + * @return string|null The blocked category name, or null if allowed + */ + protected function checkMainCategoryPermission($user, string $parentCategoryName): ?string + { + $categoryPermissions = [ + 'movies' => 'view movies', + 'console' => 'view console', + 'books' => 'view books', + 'audio' => 'view audio', + 'xxx' => 'view adult', + 'games' => 'view pc', + 'pc' => 'view pc', + 'tv' => 'view tv', + ]; + + $categoryDisplayNames = [ + 'movies' => 'Movies', + 'console' => 'Console', + 'books' => 'Books', + 'audio' => 'Audio', + 'xxx' => 'Adult', + 'games' => 'PC', + 'pc' => 'PC', + 'tv' => 'TV', + ]; + + $lowerName = strtolower($parentCategoryName); + if (isset($categoryPermissions[$lowerName])) { + if (! $user->hasDirectPermission($categoryPermissions[$lowerName])) { + return $categoryDisplayNames[$lowerName]; + } + } + + return null; + } + + /** + * Check if the user has excluded a specific subcategory. + * + * @return string|null The blocked subcategory name, or null if allowed + */ + protected function checkSubcategoryExclusion($user, string $parentCategoryName, string $subcategoryName): ?string + { + // Get the root category ID + $rootCategory = RootCategory::query() + ->whereRaw('LOWER(title) = ?', [strtolower($parentCategoryName)]) + ->first(); + + if (! $rootCategory) { + return null; + } + + // Get the subcategory + $subcategory = Category::query() + ->where('root_categories_id', $rootCategory->id) + ->whereRaw('LOWER(title) = ?', [strtolower($subcategoryName)]) + ->first(); + + if (! $subcategory) { + return null; + } + + // Check if this subcategory is in the user's exclusion list + $isExcluded = $user->excludedCategories() + ->where('categories_id', $subcategory->id) + ->exists(); + + if ($isExcluded) { + return $subcategory->title; + } + + return null; + } + + /** + * Check if the path matches a category pattern (case-insensitive). + */ + protected function matchesCategoryPath(string $path, string $category): bool + { + $lowerPath = strtolower($path); + $lowerCategory = strtolower($category); + + // Match: Category, Category/*, browse/Category, browse/Category/* + return $lowerPath === $lowerCategory + || str_starts_with($lowerPath, $lowerCategory.'/') + || $lowerPath === 'browse/'.$lowerCategory + || str_starts_with($lowerPath, 'browse/'.$lowerCategory.'/'); + } + + /** + * Abort with a category disabled response. + */ + protected function abortCategoryDisabled(string $category): Response + { + return response()->view('errors.category-disabled', [ + 'category' => $category, + ], 403); + } + + /** + * Abort with a subcategory disabled response. + */ + protected function abortSubcategoryDisabled(string $parentCategory, string $subcategory): Response + { + return response()->view('errors.category-disabled', [ + 'category' => $parentCategory.' - '.$subcategory, + 'isSubcategory' => true, + ], 403); + } } diff --git a/resources/views/errors/category-disabled.blade.php b/resources/views/errors/category-disabled.blade.php new file mode 100644 index 000000000..97c93efe1 --- /dev/null +++ b/resources/views/errors/category-disabled.blade.php @@ -0,0 +1,164 @@ + + + + + + + {{ __('Category Disabled') }} + + + + + + +
+
+
+ + + +
+ +

+ {{ $category }} {{ isset($isSubcategory) && $isSubcategory ? 'Subcategory' : 'Category' }} Disabled +

+ +

+ @if(isset($isSubcategory) && $isSubcategory) + You have excluded the {{ $category }} subcategory in your profile settings. + To access this content, please remove this subcategory from your exclusions in your profile. + @else + You have disabled viewing of the {{ $category }} category in your profile settings. + To access this content, please enable the category in your profile. + @endif +

+ + + Go to Profile Settings + + + + ← Back to Home + +
+
+ + + diff --git a/routes/web.php b/routes/web.php index 1792645f9..9805b8224 100644 --- a/routes/web.php +++ b/routes/web.php @@ -143,6 +143,13 @@ Route::middleware('isVerified')->group(function () { Route::match(['GET', 'POST'], 'Console/{id?}', [ConsoleController::class, 'show'])->name('Console'); Route::match(['GET', 'POST'], 'XXX/{id?}', [AdultController::class, 'show'])->name('XXX'); Route::match(['GET', 'POST'], 'Books/{id?}', [BooksController::class, 'index'])->name('Books'); + // TV-related routes + Route::match(['GET', 'POST'], 'series/{id?}', [SeriesController::class, 'index'])->name('series'); + Route::match(['GET', 'POST'], 'trending-tv', [SeriesController::class, 'showTrending'])->name('trending-tv'); + Route::match(['GET', 'POST'], 'myshows', [MyShowsController::class, 'show'])->name('myshows'); + Route::match(['GET', 'POST'], 'myshows/browse', [MyShowsController::class, 'browse'])->name('myshows.browse'); + // Movies-related routes + Route::match(['GET', 'POST'], 'mymovies', [MyMoviesController::class, 'show'])->name('mymovies'); }); Route::match(['GET', 'POST'], 'nfo/{id?}', [NfoController::class, 'showNfo'])->name('nfo'); @@ -159,12 +166,7 @@ Route::middleware('isVerified')->group(function () { Route::get('release-report/reasons', [\App\Http\Controllers\ReleaseReportController::class, 'getReasons'])->name('release-report.reasons'); Route::get('release-report/check', [\App\Http\Controllers\ReleaseReportController::class, 'checkReported'])->name('release-report.check'); - Route::match(['GET', 'POST'], 'mymovies', [MyMoviesController::class, 'show'])->name('mymovies'); - Route::match(['GET', 'POST'], 'myshows', [MyShowsController::class, 'show'])->name('myshows'); - Route::match(['GET', 'POST'], 'myshows/browse', [MyShowsController::class, 'browse'])->name('myshows.browse'); Route::get('api/release/{guid}/filelist', [\App\Http\Controllers\Api\FileListApiController::class, 'getFileList'])->name('api.filelist'); - Route::match(['GET', 'POST'], 'series/{id?}', [SeriesController::class, 'index'])->name('series'); - Route::match(['GET', 'POST'], 'trending-tv', [SeriesController::class, 'showTrending'])->name('trending-tv'); Route::match(['GET', 'POST'], 'ajax_profile', [AjaxController::class, 'profile'])->name('ajax_profile'); Route::match(['GET', 'POST'], '2fa', [PasswordSecurityController::class, 'show2faForm'])->name('2fa'); Route::get('2fa/enable', [PasswordSecurityController::class, 'showEnable2faForm'])->name('2fa.enable');