diff --git a/app/Community/Actions/BuildShowForumTopicPagePropsAction.php b/app/Community/Actions/BuildShowForumTopicPagePropsAction.php index c679341c3b..b7a0821a69 100644 --- a/app/Community/Actions/BuildShowForumTopicPagePropsAction.php +++ b/app/Community/Actions/BuildShowForumTopicPagePropsAction.php @@ -13,6 +13,7 @@ use App\Data\UserData; use App\Data\UserPermissionsData; use App\Models\ForumTopic; +use App\Models\ForumTopicComment; use App\Models\User; use App\Policies\ForumTopicCommentPolicy; use App\Support\Shortcode\Shortcode; @@ -62,14 +63,21 @@ public function execute( hubIds: $entities['hubIds'], ); - // Get accessible team accounts for the current user. $accessibleTeamAccounts = null; + $replyableTeamAccounts = null; $accessibleTeamIds = []; if ($user) { $accessibleTeamIds = (new ForumTopicCommentPolicy())->getAccessibleTeamIds($user); if (!empty($accessibleTeamIds)) { - $teamUsers = User::whereIn('id', $accessibleTeamIds)->get(); + $teamUsers = User::whereIn('id', $accessibleTeamIds)->with('roles')->get(); $accessibleTeamAccounts = $teamUsers->map(fn ($teamUser) => UserData::fromUser($teamUser)->include('id')); + + $replyEligibleTeamUsers = $teamUsers->filter( + fn (User $teamUser) => $user->can('create', [ForumTopicComment::class, $topic, $teamUser]), + ); + $replyableTeamAccounts = $replyEligibleTeamUsers->isNotEmpty() + ? $replyEligibleTeamUsers->values()->map(fn ($teamUser) => UserData::fromUser($teamUser)->include('id')) + : null; } } @@ -108,6 +116,7 @@ function ($comment, $index) use ($updatedBodies, $user, $accessibleTeamIds) { $props = new ShowForumTopicPagePropsData( accessibleTeamAccounts: $accessibleTeamAccounts, + replyableTeamAccounts: $replyableTeamAccounts, can: UserPermissionsData::fromUser($user, forumTopic: $topic)->include( 'authorizeForumTopicComments', 'createForumTopicComments', diff --git a/app/Data/ShowForumTopicPagePropsData.php b/app/Data/ShowForumTopicPagePropsData.php index 32ac86e498..cc200a4871 100644 --- a/app/Data/ShowForumTopicPagePropsData.php +++ b/app/Data/ShowForumTopicPagePropsData.php @@ -14,6 +14,7 @@ class ShowForumTopicPagePropsData extends Data { /** * @param Collection|null $accessibleTeamAccounts + * @param Collection|null $replyableTeamAccounts */ public function __construct( public UserPermissionsData $can, @@ -23,6 +24,7 @@ public function __construct( public PaginatedData $paginatedForumTopicComments, public string $metaDescription, public ?Collection $accessibleTeamAccounts = null, + public ?Collection $replyableTeamAccounts = null, ) { } } diff --git a/app/Helpers/database/forum.php b/app/Helpers/database/forum.php index 6df1fa8b6b..ef8964be19 100644 --- a/app/Helpers/database/forum.php +++ b/app/Helpers/database/forum.php @@ -11,6 +11,7 @@ use App\Models\ForumTopicComment; use App\Models\Game; use App\Models\User; +use App\Policies\ForumTopicCommentPolicy; use App\Support\Shortcode\Shortcode; use Illuminate\Support\Collection; @@ -205,20 +206,25 @@ function submitTopicComment( return $latestPost; } + $topic = ForumTopic::findOrFail($topicId); + + // Comments by an effective author who satisfies the topic's role + // whitelist are trusted & auto-authorized. + $isAuthorized = ($user->ManuallyVerified ?? false) + || (new ForumTopicCommentPolicy())->matchesCommentRoleAllowlist($user, $topic); + $newComment = new ForumTopicComment([ 'forum_topic_id' => $topicId, 'body' => $commentPayload, 'author_id' => $user->id, 'sent_by_id' => $sentByUser?->id, - 'is_authorized' => $user->ManuallyVerified ?? false, + 'is_authorized' => $isAuthorized, ]); $newComment->save(); - $topic = ForumTopic::find($topicId); - setLatestCommentInForumTopic($topic, $newComment->id); - if ($user->ManuallyVerified ?? false) { + if ($isAuthorized) { // if user has any notifications pending for this post, assume they're no longer needed $notificationService = new SubscriptionNotificationService(); $notificationService->resetNotification($user->id, SubscriptionSubjectType::ForumTopic, $topic->id); diff --git a/app/Models/ForumTopic.php b/app/Models/ForumTopic.php index 2a56f77ff1..8ef768c9b1 100644 --- a/app/Models/ForumTopic.php +++ b/app/Models/ForumTopic.php @@ -68,6 +68,7 @@ protected static function boot(): void 'author_id', 'latest_comment_id', 'required_permissions', + 'comment_role_id', 'pinned_at', 'locked_at', ]; diff --git a/app/Policies/ForumTopicCommentPolicy.php b/app/Policies/ForumTopicCommentPolicy.php index d5026d0f76..eea23ec611 100644 --- a/app/Policies/ForumTopicCommentPolicy.php +++ b/app/Policies/ForumTopicCommentPolicy.php @@ -49,26 +49,43 @@ public function view(?User $user, ForumTopicComment $comment): bool return false; } + public function viewUserPosts(User $currentUser, User $targetUser): bool + { + return !$targetUser->isBlocking($currentUser); + } + public function create(User $user, ForumTopic $topic, ?User $teamAccount = null): bool { + if ($user->isMuted()) { + return false; + } + // Users are able to create forum topic replies on behalf of team accounts, // assuming the correct role is attached to the user. if ($teamAccount) { - return $this->canActAsTeamAccount($user, $teamAccount); - } + if (!$this->canActAsTeamAccount($user, $teamAccount)) { + return false; + } - /* - * verified and unverified users may comment - * muted, suspended, banned may not comment - */ - if ($user->isMuted()) { - return false; + // A topic lock still applies, even when posting on behalf of a team account. + if ($topic->is_locked && !$user->can('lock', $topic)) { + return false; + } + + // When a comment role allowlist exists for the topic, it must be satisfied + // by the effective author (the team account, since that's whose name will + // appear on the comment). + if ($topic->comment_role_id !== null + && !$this->matchesCommentRoleAllowlist($teamAccount, $topic) + ) { + return false; + } + + return true; } - /* - * users may not reply to locked topics, unless they have - * the ability to lock/unlock topics themselves. - */ + // Users may not reply to locked topics, unless they have + // the ability to lock/unlock topics themselves. if ($topic->is_locked && !$user->can('lock', $topic)) { return false; } @@ -77,6 +94,16 @@ public function create(User $user, ForumTopic $topic, ?User $teamAccount = null) return false; } + // A topic with a comment-role allowlist only accepts + // replies from users in those roles. + if ($topic->comment_role_id !== null) { + if ($this->manage($user)) { + return true; + } + + return $this->matchesCommentRoleAllowlist($user, $topic); + } + return true; } @@ -135,8 +162,12 @@ public function forceDelete(User $user, ForumTopicComment $comment): bool return false; } - public function viewUserPosts(User $currentUser, User $targetUser): bool + public function matchesCommentRoleAllowlist(User $user, ForumTopic $topic): bool { - return !$targetUser->isBlocking($currentUser); + if ($topic->comment_role_id === null) { + return false; + } + + return $user->roles->contains('id', $topic->comment_role_id); } } diff --git a/database/migrations/2026_05_12_000000_add_comment_role_id_to_forum_topics_table.php b/database/migrations/2026_05_12_000000_add_comment_role_id_to_forum_topics_table.php new file mode 100644 index 0000000000..b61ae8bd7b --- /dev/null +++ b/database/migrations/2026_05_12_000000_add_comment_role_id_to_forum_topics_table.php @@ -0,0 +1,29 @@ +unsignedBigInteger('comment_role_id')->nullable()->after('required_permissions'); + + $table->foreign('comment_role_id') + ->references('id') + ->on('auth_roles') + ->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::table('forum_topics', function (Blueprint $table) { + $table->dropForeign(['comment_role_id']); + $table->dropColumn('comment_role_id'); + }); + } +}; diff --git a/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.test.tsx b/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.test.tsx index a6e59dc592..ce77b56d56 100644 --- a/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.test.tsx +++ b/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.test.tsx @@ -150,7 +150,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user: createAuthenticatedUser() }, forumTopic: createForumTopic(), - accessibleTeamAccounts: [teamAccount1, teamAccount2], // !! + replyableTeamAccounts: [teamAccount1, teamAccount2], // !! }, }); @@ -165,7 +165,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user: createAuthenticatedUser() }, forumTopic: createForumTopic(), - accessibleTeamAccounts: null, // !! + replyableTeamAccounts: null, // !! }, }); @@ -187,7 +187,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user }, forumTopic: createForumTopic(), - accessibleTeamAccounts: [teamAccount], // !! + replyableTeamAccounts: [teamAccount], // !! }, }); @@ -218,7 +218,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user }, forumTopic: createForumTopic(), - accessibleTeamAccounts: [teamAccount], // !! + replyableTeamAccounts: [teamAccount], // !! }, }); @@ -245,7 +245,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user }, forumTopic: createForumTopic(), - accessibleTeamAccounts: [teamAccount], // !! + replyableTeamAccounts: [teamAccount], // !! }, }); @@ -274,7 +274,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user: createAuthenticatedUser() }, forumTopic: topic, - accessibleTeamAccounts: [teamAccount], // !! + replyableTeamAccounts: [teamAccount], // !! }, }); @@ -312,7 +312,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user: createAuthenticatedUser() }, forumTopic: topic, - accessibleTeamAccounts: [teamAccount], // !! + replyableTeamAccounts: [teamAccount], // !! }, }); @@ -355,7 +355,7 @@ describe('Component: QuickReplyForm', () => { pageProps: { auth: { user: createAuthenticatedUser({ displayName: 'Scott' }) }, forumTopic: createForumTopic(), - accessibleTeamAccounts: [teamAccount1, teamAccount2, teamAccount3], // !! unsorted + replyableTeamAccounts: [teamAccount1, teamAccount2, teamAccount3], // !! unsorted }, }); diff --git a/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.tsx b/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.tsx index 8d06607a54..d48ab377e4 100644 --- a/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.tsx +++ b/resources/js/features/forums/components/QuickReplyForm/QuickReplyForm.tsx @@ -27,7 +27,7 @@ interface QuickReplyFormProps { } export const QuickReplyForm: FC = ({ onPreview }) => { - const { auth, forumTopic, accessibleTeamAccounts } = + const { auth, forumTopic, replyableTeamAccounts } = usePageProps(); const { t } = useTranslation(); @@ -41,13 +41,13 @@ export const QuickReplyForm: FC = ({ onPreview }) => { const watchedPostAsUser = watchedPostAsUserId !== 'self' - ? accessibleTeamAccounts?.find((ta) => ta.id === Number(watchedPostAsUserId)) + ? replyableTeamAccounts?.find((ta) => ta.id === Number(watchedPostAsUserId)) : null; // Sort team accounts alphabetically by display name. - const sortedTeamAccounts = accessibleTeamAccounts - ? [...accessibleTeamAccounts].sort((a, b) => a.displayName.localeCompare(b.displayName)) - : null; + const sortedTeamAccounts = (replyableTeamAccounts ?? []) + .slice() + .sort((a, b) => a.displayName.localeCompare(b.displayName)); const formRef = useRef(null); useSubmitOnMetaEnter({ @@ -72,7 +72,7 @@ export const QuickReplyForm: FC = ({ onPreview }) => {
- {sortedTeamAccounts?.length ? ( + {sortedTeamAccounts.length ? ( = ({ onPreview }) => { /> ) : null} - {!watchedPostAsUser && accessibleTeamAccounts?.length ? ( + {!watchedPostAsUser && replyableTeamAccounts?.length ? ( {auth.user.displayName}; metaDescription: string; accessibleTeamAccounts: Array | null; +replyableTeamAccounts: Array | null; }; export type StaticData = { numGames: number; diff --git a/tests/Feature/Community/Controllers/Api/ForumTopicCommentApiControllerTest.php b/tests/Feature/Community/Controllers/Api/ForumTopicCommentApiControllerTest.php new file mode 100644 index 0000000000..86a0d9d638 --- /dev/null +++ b/tests/Feature/Community/Controllers/Api/ForumTopicCommentApiControllerTest.php @@ -0,0 +1,73 @@ +create(['email_verified_at' => now()]); + actingAs($regularUser); + + $topic = ForumTopic::factory()->create([ + 'comment_role_id' => Role::findByName(Role::SET_DESIGNER)->id, + ]); + + // ACT + $response = postJson( + route('api.forum-topic-comment.create', ['topic' => $topic]), + ['body' => 'hello world'], + ); + + // ASSERT + $response->assertForbidden(); +}); + +it('given a post is attributed to a whitelisted team account, succeeds and auto-authorizes the comment', function () { + // ARRANGE + $setDesigner = User::factory()->create(['email_verified_at' => now()]); + $setDesigner->assignRole(Role::SET_DESIGNER); + actingAs($setDesigner); + + $setDesignersAccount = User::factory()->create([ + 'username' => 'SetDesigners', + 'ManuallyVerified' => false, + ]); + $setDesignersAccount->assignRole(Role::SET_DESIGNER); + + $topic = ForumTopic::factory()->create([ + 'comment_role_id' => Role::findByName(Role::SET_DESIGNER)->id, + ]); + + // ACT + $response = postJson( + route('api.forum-topic-comment.create', ['topic' => $topic]), + ['body' => 'announcement', 'postAsUserId' => (string) $setDesignersAccount->id], + ); + + // ASSERT + $response->assertOk(); + $response->assertJson(['success' => true]); + + assertDatabaseHas('forum_topic_comments', [ + 'forum_topic_id' => $topic->id, + 'author_id' => $setDesignersAccount->id, + 'sent_by_id' => $setDesigner->id, + 'is_authorized' => 1, // !! + ]); +}); diff --git a/tests/Feature/Community/Controllers/ForumTopicControllerTest.php b/tests/Feature/Community/Controllers/ForumTopicControllerTest.php index 34e115441f..9769e6c2fa 100644 --- a/tests/Feature/Community/Controllers/ForumTopicControllerTest.php +++ b/tests/Feature/Community/Controllers/ForumTopicControllerTest.php @@ -8,7 +8,9 @@ use App\Models\ForumCategory; use App\Models\ForumTopic; use App\Models\ForumTopicComment; +use App\Models\Role; use App\Models\User; +use Database\Seeders\RolesTableSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Inertia\Testing\AssertableInertia as Assert; use Tests\TestCase; @@ -117,4 +119,39 @@ public function testShowDisplaysTopicForAuthorizedUsers(): void ->has('paginatedForumTopicComments') ); } + + public function testShowIncludesSentByForHistoricalTeamAccountPosts(): void + { + // Arrange + $this->seed(RolesTableSeeder::class); + + $admin = User::factory()->create([ + 'preferences_bitfield' => 63, + 'unread_messages' => 0, + 'email_verified_at' => now(), + ]); + $admin->assignRole(Role::ADMINISTRATOR); + + $radminAccount = User::factory()->create(['username' => 'RAdmin']); + $originalAuthor = User::factory()->create(); + + $topic = ForumTopic::factory()->create(['required_permissions' => 0]); + + ForumTopicComment::factory()->create([ + 'forum_topic_id' => $topic->id, + 'author_id' => $radminAccount->id, + 'sent_by_id' => $originalAuthor->id, + 'is_authorized' => true, + ]); + + // Act + $response = $this->actingAs($admin)->get(route('forum-topic.show', $topic)); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->has('paginatedForumTopicComments.items.0.sentBy') + ->where('paginatedForumTopicComments.items.0.sentBy.displayName', $originalAuthor->display_name) + ); + } }