diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 52a6a4829..5ed075257 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Jobs\SendAccountDeletedEmail; use App\Models\ReleaseComment; +use App\Models\RootCategory; use App\Models\User; use App\Models\UserDownload; use App\Models\UserRequest; @@ -174,6 +175,15 @@ class ProfileController extends BasePageController } } + // Update excluded subcategories + $excludedCategories = $request->input('excluded_categories', []); + // Ensure all values are integers + $excludedCategories = array_map('intval', array_filter($excludedCategories, 'is_numeric')); + $this->userdata->syncExcludedCategories($excludedCategories); + + // Clear the category exclusions cache for this user + \Illuminate\Support\Facades\Cache::forget('user_category_exclusions_'.$userid); + // Handle Console permission if ($request->has('viewconsole')) { if (! $this->userdata->hasDirectPermission('view console')) { @@ -295,6 +305,7 @@ class ProfileController extends BasePageController 'error' => $errorStr, 'user' => $this->userdata, 'userexccat' => User::getCategoryExclusionById($userid), + 'userExcludedCategories' => $this->userdata->excludedCategories()->pluck('categories_id')->toArray(), 'success_2fa' => $success_2fa, 'error_2fa' => $error_2fa, 'google2fa_url' => $google2fa_url, @@ -303,6 +314,9 @@ class ProfileController extends BasePageController 'publicview' => false, 'privileged' => $this->userdata->hasRole('Admin') || $this->userdata->hasRole('Moderator'), 'userinvitedby' => ($this->userdata->invitedby && $this->userdata->invitedby !== '') ? User::find($this->userdata->invitedby) : null, + 'categoriesWithSubs' => RootCategory::with(['categories' => function ($query) { + $query->where('status', 1)->orderBy('title'); + }])->where('status', 1)->orderBy('title')->get(), 'meta_title' => 'Edit User Profile', 'meta_keywords' => 'edit,profile,user,details', 'meta_description' => 'Edit User Profile for '.$this->userdata->username, diff --git a/app/Models/User.php b/app/Models/User.php index 414560a2e..d7919193b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -240,6 +240,11 @@ final class User extends Authenticatable return $this->hasOne(PasswordSecurity::class); } + public function excludedCategories(): HasMany + { + return $this->hasMany(UserExcludedCategory::class, 'users_id'); + } + // ===== Backward Compatibility Aliases ===== public function request(): HasMany @@ -1379,6 +1384,9 @@ final class User extends Authenticatable /** * Get excluded category IDs for a user. * + * This includes both permission-based exclusions (entire root categories) + * and user-selected subcategory exclusions from user_excluded_categories table. + * * @return array * * @throws \Exception @@ -1409,9 +1417,45 @@ final class User extends Authenticatable } } - return Category::whereIn('root_categories_id', $excludedRoots) + // Get all subcategories that belong to excluded root categories + $permissionExclusions = Category::whereIn('root_categories_id', $excludedRoots) ->pluck('id') ->toArray(); + + // Get user's custom excluded subcategories from pivot table + $userExclusions = $user->excludedCategories() + ->pluck('categories_id') + ->toArray(); + + // Filter out user exclusions that belong to already excluded root categories + // to avoid duplicates + $filteredUserExclusions = array_filter($userExclusions, function ($categoryId) use ($excludedRoots) { + $category = Category::find($categoryId); + + return $category && ! in_array($category->root_categories_id, $excludedRoots); + }); + + // Merge permission-based exclusions with user-selected subcategory exclusions + return array_unique(array_merge($permissionExclusions, $filteredUserExclusions)); + } + + /** + * Sync user's excluded subcategories. + * + * @param array $categoryIds Array of category IDs to exclude + */ + public function syncExcludedCategories(array $categoryIds): void + { + // Delete existing exclusions + $this->excludedCategories()->delete(); + + // Insert new exclusions + foreach ($categoryIds as $categoryId) { + UserExcludedCategory::create([ + 'users_id' => $this->id, + 'categories_id' => (int) $categoryId, + ]); + } } /** diff --git a/app/Models/UserExcludedCategory.php b/app/Models/UserExcludedCategory.php new file mode 100644 index 000000000..2d60aa512 --- /dev/null +++ b/app/Models/UserExcludedCategory.php @@ -0,0 +1,50 @@ + 'integer', + 'categories_id' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'users_id'); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class, 'categories_id'); + } +} diff --git a/database/migrations/2026_01_23_150053_create_user_excluded_categories_table.php b/database/migrations/2026_01_23_150053_create_user_excluded_categories_table.php new file mode 100644 index 000000000..b06c7e821 --- /dev/null +++ b/database/migrations/2026_01_23_150053_create_user_excluded_categories_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('categories_id'); + $table->timestamps(); + + $table->foreign('users_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + + $table->foreign('categories_id') + ->references('id') + ->on('categories') + ->onDelete('cascade'); + + $table->unique(['users_id', 'categories_id']); + $table->index('users_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_excluded_categories'); + } +}; diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php index 1592b5c8f..ca76a4c1f 100644 --- a/resources/views/profile/edit.blade.php +++ b/resources/views/profile/edit.blade.php @@ -238,6 +238,36 @@ + +
+

Exclude Subcategories

+

+ Select specific subcategories you don't want to see in browse, search, API, and RSS results. + These will be hidden even if you have access to the parent category. +

+
+ @foreach($categoriesWithSubs as $rootCategory) +
+

+ {{ $rootCategory->title }} +

+
+ @foreach($rootCategory->categories as $subcategory) + + @endforeach +
+
+ @endforeach +
+
+
diff --git a/tests/Feature/UserExcludedCategoryTest.php b/tests/Feature/UserExcludedCategoryTest.php new file mode 100644 index 000000000..dd00ce143 --- /dev/null +++ b/tests/Feature/UserExcludedCategoryTest.php @@ -0,0 +1,292 @@ + 'sqlite', 'database.connections.sqlite.database' => ':memory:']); + DB::purge(); + DB::reconnect(); + + // Create minimal tables needed for testing + $this->createTestTables(); + + // Create the basic User role with all view permissions + $this->userRole = Role::firstOrCreate( + ['name' => 'User'], + [ + 'guard_name' => 'web', + 'addyears' => 0, + 'apirequests' => 10, + 'downloadrequests' => 5, + 'defaultinvites' => 0, + 'isdefault' => 1, + 'donation' => 0, + 'canpreview' => 0, + ] + ); + + // Create view permissions + $permissions = [ + 'view console', 'view movies', 'view audio', 'view pc', + 'view tv', 'view adult', 'view books', 'view other', + ]; + foreach ($permissions as $permName) { + Permission::firstOrCreate(['name' => $permName, 'guard_name' => 'web']); + } + $this->userRole->syncPermissions($permissions); + + // Create a test user + $this->user = $this->createTestUser($this->userRole->id); + $this->user->assignRole($this->userRole); + + // Give user direct permissions + foreach ($permissions as $permName) { + $this->user->givePermissionTo($permName); + } + } + + protected function tearDown(): void + { + DB::disconnect(); + parent::tearDown(); + } + + private function createTestTables(): void + { + // Users table (complete schema like RoleUpgradeTest) + DB::statement('CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + roles_id INTEGER DEFAULT 1, + rolechangedate DATETIME NULL, + pending_roles_id INTEGER NULL, + pending_role_start_date DATETIME NULL, + api_token VARCHAR(255) NULL, + grabs INTEGER DEFAULT 0, + invites INTEGER DEFAULT 0, + notes TEXT DEFAULT "", + movieview INTEGER DEFAULT 1, + xxxview INTEGER DEFAULT 0, + musicview INTEGER DEFAULT 1, + consoleview INTEGER DEFAULT 1, + bookview INTEGER DEFAULT 1, + gameview INTEGER DEFAULT 1, + verified INTEGER DEFAULT 1, + can_post INTEGER DEFAULT 1, + rate_limit INTEGER DEFAULT 60, + email_verified_at DATETIME NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL + )'); + + // Roles table + DB::statement('CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + guard_name VARCHAR(255) NOT NULL DEFAULT "web", + addyears INTEGER DEFAULT 0, + apirequests INTEGER DEFAULT 10, + downloadrequests INTEGER DEFAULT 5, + defaultinvites INTEGER DEFAULT 0, + isdefault INTEGER DEFAULT 0, + donation INTEGER DEFAULT 0, + canpreview INTEGER DEFAULT 0, + created_at DATETIME, + updated_at DATETIME + )'); + + // Permissions table + DB::statement('CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + guard_name VARCHAR(255) NOT NULL DEFAULT "web", + created_at DATETIME, + updated_at DATETIME + )'); + + // Model has permissions + DB::statement('CREATE TABLE IF NOT EXISTS model_has_permissions ( + permission_id INTEGER NOT NULL, + model_type VARCHAR(255) NOT NULL, + model_id INTEGER NOT NULL, + PRIMARY KEY (permission_id, model_id, model_type) + )'); + + // Model has roles + DB::statement('CREATE TABLE IF NOT EXISTS model_has_roles ( + role_id INTEGER NOT NULL, + model_type VARCHAR(255) NOT NULL, + model_id INTEGER NOT NULL, + PRIMARY KEY (role_id, model_id, model_type) + )'); + + // Role has permissions + DB::statement('CREATE TABLE IF NOT EXISTS role_has_permissions ( + permission_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY (permission_id, role_id) + )'); + + // Root categories table + DB::statement('CREATE TABLE IF NOT EXISTS root_categories ( + id INTEGER PRIMARY KEY, + title VARCHAR(255) NOT NULL, + status INTEGER DEFAULT 1, + disablepreview INTEGER DEFAULT 0, + created_at DATETIME, + updated_at DATETIME + )'); + + // Categories table + DB::statement('CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY, + title VARCHAR(255) NOT NULL, + root_categories_id INTEGER NOT NULL, + status INTEGER DEFAULT 1, + description VARCHAR(255), + disablepreview INTEGER DEFAULT 0, + minsizetoformrelease INTEGER DEFAULT 0, + maxsizetoformrelease INTEGER DEFAULT 0 + )'); + + // User excluded categories table + DB::statement('CREATE TABLE IF NOT EXISTS user_excluded_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + users_id INTEGER NOT NULL, + categories_id INTEGER NOT NULL, + created_at DATETIME, + updated_at DATETIME, + UNIQUE(users_id, categories_id) + )'); + + // Insert test root categories + DB::table('root_categories')->insert([ + ['id' => 2000, 'title' => 'Movies', 'status' => 1], + ['id' => 5000, 'title' => 'TV', 'status' => 1], + ]); + + // Insert test subcategories + DB::table('categories')->insert([ + ['id' => 2030, 'title' => 'SD', 'root_categories_id' => 2000, 'status' => 1], + ['id' => 2040, 'title' => 'HD', 'root_categories_id' => 2000, 'status' => 1], + ['id' => 2070, 'title' => 'DVD', 'root_categories_id' => 2000, 'status' => 1], + ['id' => 5030, 'title' => 'SD', 'root_categories_id' => 5000, 'status' => 1], + ['id' => 5040, 'title' => 'HD', 'root_categories_id' => 5000, 'status' => 1], + ]); + } + + private function createTestUser(int $roleId): User + { + $username = 'testuser_'.Str::random(5); + $email = 'test_'.Str::random(5).'@example.com'; + $apiToken = Str::random(64); + + // Insert directly to bypass observers + DB::table('users')->insert([ + 'username' => $username, + 'email' => $email, + 'password' => bcrypt('password'), + 'api_token' => $apiToken, + 'roles_id' => $roleId, + 'verified' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return User::where('email', $email)->first(); + } + + public function test_user_can_exclude_subcategories(): void + { + // Exclude Movies->DVD category + $this->user->syncExcludedCategories([2070]); + + $exclusions = $this->user->excludedCategories()->pluck('categories_id')->toArray(); + + $this->assertContains(2070, $exclusions); + $this->assertCount(1, $exclusions); + } + + public function test_user_can_exclude_multiple_subcategories(): void + { + // Exclude Movies->DVD and Movies->SD + $this->user->syncExcludedCategories([2070, 2030]); + + $exclusions = $this->user->excludedCategories()->pluck('categories_id')->toArray(); + + $this->assertContains(2070, $exclusions); + $this->assertContains(2030, $exclusions); + $this->assertCount(2, $exclusions); + } + + public function test_sync_excluded_categories_replaces_existing(): void + { + // First set DVD + $this->user->syncExcludedCategories([2070]); + + // Then sync to HD only + $this->user->syncExcludedCategories([2040]); + + $exclusions = $this->user->excludedCategories()->pluck('categories_id')->toArray(); + + $this->assertNotContains(2070, $exclusions); + $this->assertContains(2040, $exclusions); + $this->assertCount(1, $exclusions); + } + + public function test_get_category_exclusion_by_id_includes_user_exclusions(): void + { + // Exclude Movies->DVD subcategory + $this->user->syncExcludedCategories([2070]); + + $exclusions = User::getCategoryExclusionById($this->user->id); + + $this->assertContains(2070, $exclusions); + } + + public function test_clearing_exclusions_works(): void + { + // First add exclusions + $this->user->syncExcludedCategories([2070, 2030]); + + // Then clear them + $this->user->syncExcludedCategories([]); + + $exclusions = $this->user->excludedCategories()->pluck('categories_id')->toArray(); + + $this->assertEmpty($exclusions); + } + + public function test_excluded_categories_relationship(): void + { + UserExcludedCategory::create([ + 'users_id' => $this->user->id, + 'categories_id' => 2070, + ]); + + $this->assertCount(1, $this->user->excludedCategories); + $this->assertEquals(2070, $this->user->excludedCategories->first()->categories_id); + } +}