diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 5e735c48bc..63d2f16274 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -42,6 +42,7 @@ This file documents the platform's features, stack, and stories. Feature entries - [Orgs: group chats](#orgs-group-chats) - [Muting and blocking](#muting-and-blocking) - [Reporting commits](#reporting-commits) + - [Reporting messages and people](#reporting-messages-and-people) - [Appearance, color, and timezone](#appearance-color-and-timezone) - [Installing /dev/null as an app](#installing-devnull-as-an-app) - [Activity graphs and README embeds](#activity-graphs-and-readme-embeds) @@ -498,6 +499,12 @@ Report another person's visible, approved commit or reply privately from its con Sources: [report control](resources/js/components/commit-report-button.tsx), [report validation](app/Http/Requests/CreateCommitReportRequest.php), [reported-commit controller](app/Http/Controllers/ReportedCommitController.php). +### Reporting messages and people + +Through the API used by the mobile app, you can also privately report a Remotes message you can see, in a direct conversation or an organization's thread, or another person's account. Reports take the same reasons as commit reports and up to **500 characters** of optional detail, without images. You can't report your own messages or yourself. You can report a particular message or person once, and file up to five of these reports a minute. Reporting does not hide the message or affect the account by itself, and the person reported is not told who reported them. Moderators review these reports privately. + +Sources: [message report validation](app/Http/Requests/CreateApiMessageReportRequest.php), [user report validation](app/Http/Requests/CreateApiUserReportRequest.php). + ### Appearance, color, and timezone Settings → Appearance offers: @@ -656,7 +663,7 @@ Choose **Download JSON** in Settings → Profile to export your account data. Th - SSH key fingerprints, approved devices, and your timeout history; - active-session metadata: IP address, browser information, and last activity. -Other people's replies and received messages are not included as your authored data. Message images, message reactions, scheduled commits, and ban records are not included either. Reports you filed include their reason, details, and attachment references; reports filed by other people are excluded. Image and voice-note references are URLs, not an archive of the media files. Deleted commit entries do not restore content already erased after the undo window. +Other people's replies and received messages are not included as your authored data. Message images, message reactions, scheduled commits, and ban records are not included either. Reports you filed, on commits, messages and people, include their reason and details, and commit reports their attachment references; reports filed by other people are excluded. Image and voice-note references are URLs, not an archive of the media files. Deleted commit entries do not restore content already erased after the undo window. The export does not expose session IDs, authentication tokens, private SSH keys, or other people's poll choices. Keep the downloaded JSON private because it includes personal account information. Exports are limited to **three per hour**. diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 13b11294ce..984289dff5 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -12,10 +12,12 @@ use App\Models\ConversationParticipant; use App\Models\Device; use App\Models\Message; +use App\Models\MessageReport; use App\Models\Poll; use App\Models\PollOption; use App\Models\PushSubscription; use App\Models\User; +use App\Models\UserReport; use App\Models\UserTimeout; use App\Queries\CommitBuilder; use App\Services\CommitTranslations; @@ -55,6 +57,8 @@ * pending_follows: list, * messages: Generator, * reports: Traversable>, + * message_reports: Traversable, + * user_reports: Traversable, * remote_pins: array, * remote_mentions: Generator, * remote_mutes: array, @@ -76,7 +80,7 @@ public function handle(User $user): array { return [ - 'schema_version' => 8, + 'schema_version' => 9, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -139,6 +143,18 @@ public function handle(User $user): array 'attachments' => $report->toArray()['attachments'], 'created_at' => $report->created_at->toIso8601String(), ])->getIterator(), + 'message_reports' => MessageReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (MessageReport $report): array => [ + 'message_id' => $report->message_id, + 'reason' => $report->reason, + 'details' => $report->details, + 'created_at' => $report->created_at->toIso8601String(), + ])->getIterator(), + 'user_reports' => UserReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (UserReport $report): array => [ + 'user_id' => $report->user_id, + 'reason' => $report->reason, + 'details' => $report->details, + 'created_at' => $report->created_at->toIso8601String(), + ])->getIterator(), 'remote_mutes' => Conversation::query() ->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id') ->where('mine.user_id', $user->id) diff --git a/app/Actions/CreateMessageReport.php b/app/Actions/CreateMessageReport.php new file mode 100644 index 0000000000..cf5adaf81f --- /dev/null +++ b/app/Actions/CreateMessageReport.php @@ -0,0 +1,40 @@ +whereKey($user->id)->sharedLock()->firstOrFail(); + Message::query()->whereKey($message->id)->sharedLock()->firstOrFail(); + + $report = MessageReport::query()->createOrFirst([ + 'message_id' => $message->id, + 'reporter_id' => $user->id, + ], [ + 'reason' => $reason, + 'details' => $details, + ]); + + event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['message_reports' => [$report->id]])); + }); + } +} diff --git a/app/Actions/CreateUserReport.php b/app/Actions/CreateUserReport.php new file mode 100644 index 0000000000..f8645b3ecb --- /dev/null +++ b/app/Actions/CreateUserReport.php @@ -0,0 +1,39 @@ +whereKey($user->id)->sharedLock()->firstOrFail(); + User::query()->whereKey($reported->id)->sharedLock()->firstOrFail(); + + $report = UserReport::query()->createOrFirst([ + 'user_id' => $reported->id, + 'reporter_id' => $user->id, + ], [ + 'reason' => $reason, + 'details' => $details, + ]); + + event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['user_reports' => [$report->id]])); + }); + } +} diff --git a/app/Actions/UpdateMessageReportDismissedAt.php b/app/Actions/UpdateMessageReportDismissedAt.php new file mode 100644 index 0000000000..75a466c7a7 --- /dev/null +++ b/app/Actions/UpdateMessageReportDismissedAt.php @@ -0,0 +1,25 @@ +whereKey($report->id)->whereNull('dismissed_at')->update(['dismissed_at' => now()]); + + event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['message_reports' => [$report->id]])); + } +} diff --git a/app/Actions/UpdateUserReportDismissedAt.php b/app/Actions/UpdateUserReportDismissedAt.php new file mode 100644 index 0000000000..53f09773ad --- /dev/null +++ b/app/Actions/UpdateUserReportDismissedAt.php @@ -0,0 +1,25 @@ +whereKey($report->id)->whereNull('dismissed_at')->update(['dismissed_at' => now()]); + + event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['user_reports' => [$report->id]])); + } +} diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php index dd604800e6..cf2b23ec3d 100644 --- a/app/Enums/ApiErrorCode.php +++ b/app/Enums/ApiErrorCode.php @@ -23,13 +23,15 @@ enum ApiErrorCode: string case TimedOut = 'timed_out'; case Banned = 'banned'; case CommitRulesRequired = 'commit_rules_required'; + case OwnMessage = 'own_message'; + case OwnProfile = 'own_profile'; public function status(): int { return match ($this) { self::InvalidGrant => 400, self::InvalidSignature, self::DeviceNotApproved => 401, - self::UserBlocked, self::UserUnavailable, self::OwnCommit, self::TimedOut, self::Banned, self::CommitRulesRequired => 403, + self::UserBlocked, self::UserUnavailable, self::OwnCommit, self::TimedOut, self::Banned, self::CommitRulesRequired, self::OwnMessage, self::OwnProfile => 403, self::CommitUnavailable => 404, self::PasteisExhausted => 422, self::RateLimited => 429, diff --git a/app/Http/Controllers/Api/MessageReportController.php b/app/Http/Controllers/Api/MessageReportController.php new file mode 100644 index 0000000000..b0618bfe8a --- /dev/null +++ b/app/Http/Controllers/Api/MessageReportController.php @@ -0,0 +1,25 @@ +handle($user, $message, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/UserReportController.php b/app/Http/Controllers/Api/UserReportController.php new file mode 100644 index 0000000000..e01b7adbe3 --- /dev/null +++ b/app/Http/Controllers/Api/UserReportController.php @@ -0,0 +1,24 @@ +handle($currentUser, $user, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/MessageReportController.php b/app/Http/Controllers/MessageReportController.php new file mode 100644 index 0000000000..69e2343d59 --- /dev/null +++ b/app/Http/Controllers/MessageReportController.php @@ -0,0 +1,20 @@ +handle($report); + + return back(); + } +} diff --git a/app/Http/Controllers/ReportedUserController.php b/app/Http/Controllers/ReportedUserController.php new file mode 100644 index 0000000000..46765062a2 --- /dev/null +++ b/app/Http/Controllers/ReportedUserController.php @@ -0,0 +1,26 @@ + $query->messages(...), + 'userReports' => $query->users(...), + 'reasons' => CommitReport::REASONS, + ]); + } +} diff --git a/app/Http/Controllers/UserReportController.php b/app/Http/Controllers/UserReportController.php new file mode 100644 index 0000000000..0a5ee9f062 --- /dev/null +++ b/app/Http/Controllers/UserReportController.php @@ -0,0 +1,20 @@ +handle($report); + + return back(); + } +} diff --git a/app/Http/Requests/CreateApiMessageReportRequest.php b/app/Http/Requests/CreateApiMessageReportRequest.php new file mode 100644 index 0000000000..475ae2e0bf --- /dev/null +++ b/app/Http/Requests/CreateApiMessageReportRequest.php @@ -0,0 +1,51 @@ +user(); + assert($user instanceof User); + + $message = $this->route('message'); + assert($message instanceof Message); + + if ($message->event !== null || ! $message->isVisibleTo($user)) { + return Response::denyAsNotFound(); + } + + throw_if($message->user_id === $user->id, fn (): ApiException => new ApiException(ApiErrorCode::OwnMessage, __('You cannot report your own message.'))); + + return Response::allow(); + } + + /** + * Reports share the reasons of commit reports. + * + * @return array> + */ + public function rules(): array + { + return [ + 'reason' => ['required', 'string', Rule::in(array_keys(CommitReport::REASONS))], + 'details' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/CreateApiUserReportRequest.php b/app/Http/Requests/CreateApiUserReportRequest.php new file mode 100644 index 0000000000..b0881075f7 --- /dev/null +++ b/app/Http/Requests/CreateApiUserReportRequest.php @@ -0,0 +1,45 @@ +user(); + assert($user instanceof User); + + $reported = $this->route('user'); + assert($reported instanceof User); + + throw_if($user->is($reported), fn (): ApiException => new ApiException(ApiErrorCode::OwnProfile, __('You cannot report yourself.'))); + + return true; + } + + /** + * Reports share the reasons of commit reports. + * + * @return array> + */ + public function rules(): array + { + return [ + 'reason' => ['required', 'string', Rule::in(array_keys(CommitReport::REASONS))], + 'details' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/DeleteMessageReportRequest.php b/app/Http/Requests/DeleteMessageReportRequest.php new file mode 100644 index 0000000000..20afb9aa5f --- /dev/null +++ b/app/Http/Requests/DeleteMessageReportRequest.php @@ -0,0 +1,27 @@ +user(); + assert($user instanceof User); + + return $user->isAdmin(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeleteUserReportRequest.php b/app/Http/Requests/DeleteUserReportRequest.php new file mode 100644 index 0000000000..9885985b2f --- /dev/null +++ b/app/Http/Requests/DeleteUserReportRequest.php @@ -0,0 +1,27 @@ +user(); + assert($user instanceof User); + + return $user->isAdmin(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListReportedUsersRequest.php b/app/Http/Requests/ListReportedUsersRequest.php new file mode 100644 index 0000000000..90ccb0b436 --- /dev/null +++ b/app/Http/Requests/ListReportedUsersRequest.php @@ -0,0 +1,27 @@ +user(); + assert($user instanceof User); + + return $user->isAdmin(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Models/MessageReport.php b/app/Models/MessageReport.php new file mode 100644 index 0000000000..5053313e25 --- /dev/null +++ b/app/Models/MessageReport.php @@ -0,0 +1,49 @@ + */ + use HasFactory; + + use HasUuids; + + /** @return array */ + public function casts(): array + { + return ['message_id' => 'integer', 'dismissed_at' => 'datetime']; + } + + /** @return BelongsTo */ + public function message(): BelongsTo + { + return $this->belongsTo(Message::class); + } +} diff --git a/app/Models/UserReport.php b/app/Models/UserReport.php new file mode 100644 index 0000000000..74612a744f --- /dev/null +++ b/app/Models/UserReport.php @@ -0,0 +1,49 @@ + */ + use HasFactory; + + use HasUuids; + + /** @return array */ + public function casts(): array + { + return ['dismissed_at' => 'datetime']; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index df2e46465c..3177eb2810 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -158,6 +158,7 @@ private function bootRateLimitingDefaults(): void )); RateLimiter::for('commit-reports', fn (Request $request): Limit => Limit::perMinute(5)->by($this->userId($request))); + RateLimiter::for('reports', fn (Request $request): Limit => Limit::perMinute(5)->by($this->userId($request))); // Every sync costs four GitHub API requests, out of a shared hourly budget. The mobile app shares it, and gets the // same message with a code... diff --git a/app/Queries/ReportedUsersQuery.php b/app/Queries/ReportedUsersQuery.php new file mode 100644 index 0000000000..f157ed99c0 --- /dev/null +++ b/app/Queries/ReportedUsersQuery.php @@ -0,0 +1,40 @@ + */ + public function messages(): Collection + { + return MessageReport::query() + ->whereNull('dismissed_at') + ->select(['id', 'message_id', 'reason', 'details', 'created_at']) + ->with(['message' => fn (BelongsTo $query): BelongsTo => $query->select(['id', 'user_id', 'body', 'created_at'])->with('user')]) + ->oldest()->oldest('id') + ->limit(100) + ->get(); + } + + /** @return Collection */ + public function users(): Collection + { + return UserReport::query() + ->whereNull('dismissed_at') + ->select(['id', 'user_id', 'reason', 'details', 'created_at']) + ->with('user') + ->oldest()->oldest('id') + ->limit(100) + ->get(); + } +} diff --git a/database/factories/MessageReportFactory.php b/database/factories/MessageReportFactory.php new file mode 100644 index 0000000000..d7e4072470 --- /dev/null +++ b/database/factories/MessageReportFactory.php @@ -0,0 +1,29 @@ + + */ +final class MessageReportFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'message_id' => Message::factory(), + 'reporter_id' => User::factory(), + 'reason' => 'spam', + 'details' => null, + ]; + } +} diff --git a/database/factories/UserReportFactory.php b/database/factories/UserReportFactory.php new file mode 100644 index 0000000000..d63663ef15 --- /dev/null +++ b/database/factories/UserReportFactory.php @@ -0,0 +1,28 @@ + + */ +final class UserReportFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'reporter_id' => User::factory(), + 'reason' => 'spam', + 'details' => null, + ]; + } +} diff --git a/database/migrations/2026_11_07_175521_create_message_reports_table.php b/database/migrations/2026_11_07_175521_create_message_reports_table.php new file mode 100644 index 0000000000..ce631a6939 --- /dev/null +++ b/database/migrations/2026_11_07_175521_create_message_reports_table.php @@ -0,0 +1,27 @@ +uuid('id')->primary(); + $table->foreignId('message_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('reporter_id')->constrained('users')->cascadeOnDelete(); + $table->string('reason'); + $table->string('details', 500)->nullable(); + $table->timestamp('dismissed_at')->nullable(); + $table->timestamps(); + + $table->unique(['message_id', 'reporter_id']); + $table->index('reporter_id'); + $table->index(['dismissed_at', 'created_at', 'id']); + }); + } +}; diff --git a/database/migrations/2026_11_07_175522_create_user_reports_table.php b/database/migrations/2026_11_07_175522_create_user_reports_table.php new file mode 100644 index 0000000000..9f762abe6d --- /dev/null +++ b/database/migrations/2026_11_07_175522_create_user_reports_table.php @@ -0,0 +1,27 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('reporter_id')->constrained('users')->cascadeOnDelete(); + $table->string('reason'); + $table->string('details', 500)->nullable(); + $table->timestamp('dismissed_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'reporter_id']); + $table->index('reporter_id'); + $table->index(['dismissed_at', 'created_at', 'id']); + }); + } +}; diff --git a/resources/js/components/moderation-tabs.tsx b/resources/js/components/moderation-tabs.tsx index 135e09f7d9..52da092c32 100644 --- a/resources/js/components/moderation-tabs.tsx +++ b/resources/js/components/moderation-tabs.tsx @@ -2,12 +2,13 @@ import { index as bulk } from '@/actions/App/Http/Controllers/BulkActivityContro import { index as people } from '@/actions/App/Http/Controllers/ModeratedUserController'; import { show } from '@/actions/App/Http/Controllers/ModerationController'; import { index } from '@/actions/App/Http/Controllers/ReportedCommitController'; +import { index as peopleReports } from '@/actions/App/Http/Controllers/ReportedUserController'; import { PageTabs } from '@/components/page-tabs'; export function ModerationTabs({ active, }: { - active: 'pending' | 'reports' | 'people' | 'bulk'; + active: 'pending' | 'reports' | 'people-reports' | 'people' | 'bulk'; }) { return ( ; +}) { + const dismissMessage = (report: MessageReport) => + router + .optimistic((props: { messageReports: MessageReport[] }) => ({ + messageReports: props.messageReports.filter( + ({ id }) => id !== report.id, + ), + })) + .visit(dismissMessageReport(report.id), { preserveScroll: true }); + const dismissUser = (report: UserReport) => + router + .optimistic((props: { userReports: UserReport[] }) => ({ + userReports: props.userReports.filter( + ({ id }) => id !== report.id, + ), + })) + .visit(dismissUserReport(report.id), { preserveScroll: true }); + + return ( + <> + +
+
+

Moderation

+

+ Oldest first +

+
+ +
+

+ Private reports on remotes messages and people. Time out or ban + from the profile, then dismiss the report. +

+ +
+ {messageReports.map((report) => ( + dismissMessage(report)} + > +

+ {report.message.body} +

+
+ ))} +
+ +
+ {userReports.map((report) => ( + dismissUser(report)} + /> + ))} +
+ + ); +} + +PeopleReports.layout = AppLayout; + +function Section({ + title, + count, + empty, + children, +}: { + title: string; + count: number; + empty: string; + children: ReactNode; +}) { + return ( +
+

+ {title} + + {count} + +

+ {count === 0 ? ( +
+ {empty} +
+ ) : ( +
    {children}
+ )} +
+ ); +} + +function Row({ + user, + report, + reasons, + onDismiss, + children, +}: { + user: User; + report: Report; + reasons: Record; + onDismiss: () => void; + children?: ReactNode; +}) { + return ( +
  • + + +

    + {user.name}{' '} + + @{user.handle} + +

    + + {children} +

    {reasons[report.reason]}

    + {report.details && ( +

    + {report.details} +

    + )} +

    + Reported{' '} + +

    +
    + +
    +
  • + ); +} diff --git a/routes/api.php b/routes/api.php index e90e010f25..843fd21732 100644 --- a/routes/api.php +++ b/routes/api.php @@ -40,6 +40,7 @@ use App\Http\Controllers\Api\MessageLinkPreviewController; use App\Http\Controllers\Api\MessagePinController; use App\Http\Controllers\Api\MessageReactionController; +use App\Http\Controllers\Api\MessageReportController; use App\Http\Controllers\Api\MessageVoiceController; use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\NotificationGroupController; @@ -83,6 +84,7 @@ use App\Http\Controllers\Api\UserPushPreferencesController; use App\Http\Controllers\Api\UserRemotePreferencesController; use App\Http\Controllers\Api\UserRemotesController; +use App\Http\Controllers\Api\UserReportController; use App\Http\Controllers\Api\UserSearchController; use App\Http\Controllers\Api\UserSettingsController; use App\Http\Controllers\Api\UserTermsController; @@ -345,6 +347,11 @@ Route::post('users/{user:handle}/mute', [UserMuteController::class, 'store'])->name('api-user-mute.store'); Route::delete('users/{user:handle}/mute', [UserMuteController::class, 'destroy'])->name('api-user-mute.destroy'); + // User Report... + Route::post('users/{user:handle}/reports', [UserReportController::class, 'store']) + ->middleware('throttle:reports') + ->name('api-user-report.store'); + // User Block... Route::get('me/blocked', [UserBlockController::class, 'index'])->name('api-user-block.index'); Route::post('users/{user:handle}/block', [UserBlockController::class, 'store'])->name('api-user-block.store'); @@ -467,6 +474,13 @@ ->name('api-message-reaction.destroy'); }); + // Remote Report: a message the user can't see gets the same answer as one that doesn't exist... + Route::post('remotes/messages/{message}/reports', [MessageReportController::class, 'store']) + ->whereNumber('message') + ->middleware('throttle:reports') + ->missing(fn (): never => abort(404, 'Not Found')) + ->name('api-message-report.store'); + // Remote Pins: an org's maintainers pinning and unpinning messages for everyone in it... Route::middleware('throttle:pins')->group(function (): void { Route::put('remotes/messages/{message}/pin', [MessagePinController::class, 'update']) diff --git a/routes/web.php b/routes/web.php index bf2b27185a..2ee769de7e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -75,6 +75,7 @@ use App\Http\Controllers\MessageLinkPreviewController; use App\Http\Controllers\MessagePinController; use App\Http\Controllers\MessageReactionController; +use App\Http\Controllers\MessageReportController; use App\Http\Controllers\MessageVoiceController; use App\Http\Controllers\MobileAuthorizationController; use App\Http\Controllers\ModeratedUserController; @@ -116,6 +117,7 @@ use App\Http\Controllers\RelatedCommitController; use App\Http\Controllers\RemoteCandidateController; use App\Http\Controllers\ReportedCommitController; +use App\Http\Controllers\ReportedUserController; use App\Http\Controllers\SafetyController; use App\Http\Controllers\ScheduledCommitController; use App\Http\Controllers\ScheduledCommitSubmissionController; @@ -151,6 +153,7 @@ use App\Http\Controllers\UserProfileController; use App\Http\Controllers\UserRemotePreferencesController; use App\Http\Controllers\UserRemotesController; +use App\Http\Controllers\UserReportController; use App\Http\Controllers\UserSitemapController; use App\Http\Controllers\UserTermsController; use App\Http\Controllers\UserTimeoutController; @@ -598,6 +601,9 @@ Route::get('moderation/reports/{report}/images/{image}', [CommitReportImageController::class, 'show'])->whereUuid('report')->whereIn('image', ['0', '1', '2'])->name('commit-report-image.show'); Route::get('moderation/reports', [ReportedCommitController::class, 'index'])->name('reported-commit.index'); Route::delete('moderation/reports/{report}', [CommitReportController::class, 'destroy'])->name('commit-report.destroy'); + Route::get('moderation/people-reports', [ReportedUserController::class, 'index'])->name('reported-user.index'); + Route::delete('moderation/message-reports/{report}', [MessageReportController::class, 'destroy'])->whereUuid('report')->name('message-report.destroy'); + Route::delete('moderation/user-reports/{report}', [UserReportController::class, 'destroy'])->whereUuid('report')->name('user-report.destroy'); Route::post('moderation/{commit}/approval', [CommitApprovalController::class, 'store']) ->name('commit-approval.store'); Route::post('moderation/{commit}/rejection', [CommitRejectionController::class, 'store']) diff --git a/tests/Feature/Controllers/Api/MessageReportControllerTest.php b/tests/Feature/Controllers/Api/MessageReportControllerTest.php new file mode 100644 index 0000000000..515f4ff2ce --- /dev/null +++ b/tests/Feature/Controllers/Api/MessageReportControllerTest.php @@ -0,0 +1,88 @@ + true, 'remotes.everyone' => true]); + + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); + $this->nuno = User::factory()->withRemotes()->create(); +}); + +it('reports a message privately, keeping the first report, and tells moderators', function (): void { + Event::fake([UsersUpdated::class]); + config(['app.admins' => ['moderator-demo']]); + $admin = User::factory()->create(['handle' => 'moderator-demo']); + $message = sendChatMessage($this->nuno, $this->user, 'buy my course'); + + signedApiRequest($this->keyPair, 'POST', route('api-message-report.store', $message), ['reason' => 'spam', 'details' => 'Unsolicited ad.']) + ->assertNoContent(); + signedApiRequest($this->keyPair, 'POST', route('api-message-report.store', $message), ['reason' => 'other']) + ->assertNoContent(); + + $report = MessageReport::query()->sole(); + + expect($report->reporter_id)->toBe($this->user->id) + ->and($report->message_id)->toBe($message->id) + ->and($report->reason)->toBe('spam') + ->and($report->details)->toBe('Unsolicited ad.'); + + Event::assertDispatched(UsersUpdated::class, fn (UsersUpdated $event): bool => $event->update === UserUpdate::Moderation + && $event->userIds === [$admin->id] + && $event->data === ['message_reports' => [$report->id]]); +}); + +it('refuses a missing or unknown reason, or details over 500 characters', function (array $data, string $field): void { + $message = sendChatMessage($this->nuno, $this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-message-report.store', $message), $data) + ->assertUnprocessable() + ->assertOnlyJsonValidationErrors($field); + + expect(MessageReport::query()->count())->toBe(0); +})->with([ + 'no reason' => [[], 'reason'], + 'unknown reason' => [['reason' => 'approve'], 'reason'], + 'details too long' => [['reason' => 'spam', 'details' => Str::repeat('x', 501)], 'details'], +]); + +it('may not report their own message, with a code', function (): void { + $message = sendChatMessage($this->user, $this->nuno); + + signedApiRequest($this->keyPair, 'POST', route('api-message-report.store', $message), ['reason' => 'spam']) + ->assertForbidden() + ->assertExactJson(['message' => 'You cannot report your own message.', 'code' => 'own_message']); + + expect(MessageReport::query()->count())->toBe(0); +}); + +it('answers a message the user cannot see, an org event, or a missing message as not found', function (): void { + $hidden = sendChatMessage($this->nuno, User::factory()->withRemotes()->create()); + $event = Message::factory()->create([ + 'conversation_id' => sendChatMessage($this->nuno, $this->user)->conversation_id, + 'user_id' => $this->nuno->id, + 'event' => OrgEvent::cases()[0], + ]); + + $missing = signedApiRequest($this->keyPair, 'POST', route('api-message-report.store', $hidden->id + 1000), ['reason' => 'spam']) + ->assertNotFound() + ->json('message'); + + foreach ([$hidden, $event] as $message) { + expect(signedApiRequest($this->keyPair, 'POST', route('api-message-report.store', $message), ['reason' => 'spam']) + ->assertNotFound() + ->json('message'))->toBe($missing); + } + + expect(MessageReport::query()->count())->toBe(0); +}); diff --git a/tests/Feature/Controllers/Api/UserReportControllerTest.php b/tests/Feature/Controllers/Api/UserReportControllerTest.php new file mode 100644 index 0000000000..05cfbcc338 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserReportControllerTest.php @@ -0,0 +1,63 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('reports a person privately, keeping the first report, and tells moderators', function (): void { + Event::fake([UsersUpdated::class]); + config(['app.admins' => ['moderator-demo']]); + $admin = User::factory()->create(['handle' => 'moderator-demo']); + $reported = User::factory()->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-user-report.store', $reported), ['reason' => 'harassment', 'details' => 'Hateful bio.']) + ->assertNoContent(); + signedApiRequest($this->keyPair, 'POST', route('api-user-report.store', $reported), ['reason' => 'spam']) + ->assertNoContent(); + + $report = UserReport::query()->sole(); + + expect($report->reporter_id)->toBe($this->user->id) + ->and($report->user_id)->toBe($reported->id) + ->and($report->reason)->toBe('harassment') + ->and($report->details)->toBe('Hateful bio.'); + + Event::assertDispatched(UsersUpdated::class, fn (UsersUpdated $event): bool => $event->update === UserUpdate::Moderation + && $event->userIds === [$admin->id] + && $event->data === ['user_reports' => [$report->id]]); +}); + +it('refuses a missing or unknown reason, or details over 500 characters', function (array $data, string $field): void { + signedApiRequest($this->keyPair, 'POST', route('api-user-report.store', User::factory()->create()), $data) + ->assertUnprocessable() + ->assertOnlyJsonValidationErrors($field); + + expect(UserReport::query()->count())->toBe(0); +})->with([ + 'no reason' => [[], 'reason'], + 'unknown reason' => [['reason' => 'approve'], 'reason'], + 'details too long' => [['reason' => 'spam', 'details' => Str::repeat('x', 501)], 'details'], +]); + +it('may not report themselves, with a code', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-user-report.store', $this->user), ['reason' => 'spam']) + ->assertForbidden() + ->assertExactJson(['message' => 'You cannot report yourself.', 'code' => 'own_profile']); + + expect(UserReport::query()->count())->toBe(0); +}); + +it('answers a handle nobody has as not found', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-user-report.store', ['user' => 'nobody-here']), ['reason' => 'spam']) + ->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 1b043d513a..8ea71cd1c5 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -17,6 +17,7 @@ use App\Models\CommitReport; use App\Models\Device; use App\Models\Message; +use App\Models\MessageReport; use App\Models\MessageVoice; use App\Models\Notification; use App\Models\Organization; @@ -24,6 +25,7 @@ use App\Models\PollVote; use App\Models\SshKey; use App\Models\User; +use App\Models\UserReport; use App\Models\UserTimeout; use App\Services\CommitTranslations; use Illuminate\Cache\RateLimiting\Limit; @@ -92,7 +94,7 @@ $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data)->toBe([ - 'schema_version' => 8, + 'schema_version' => 9, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -149,6 +151,8 @@ 'pending_follows' => [], 'messages' => [], 'reports' => [], + 'message_reports' => [], + 'user_reports' => [], 'remote_mutes' => [], 'remote_pins' => [], 'remote_mentions' => [], @@ -707,3 +711,23 @@ $export = resolve(CreateExport::class)->handle($user); expect($export['push_subscriptions'])->toBe([['id' => $subscription->id]]); }); + +it('exports the message and user reports the user filed, and not those about them', function (): void { + $this->freezeSecond(); + $user = User::factory()->create(); + $message = Message::factory()->create(); + $reported = User::factory()->create(); + MessageReport::factory()->for($message)->create(['reporter_id' => $user->id, 'reason' => 'harassment', 'details' => 'Rude message']); + UserReport::factory()->for($reported)->create(['reporter_id' => $user->id, 'reason' => 'spam']); + MessageReport::factory()->for(Message::factory()->for($user))->create(['details' => 'Someone reported my message']); + UserReport::factory()->for($user)->create(['details' => 'Someone reported me']); + + $content = $this->actingAs($user)->get(route('export'))->streamedContent(); + $data = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + expect($data['message_reports'])->toBe([ + ['message_id' => $message->id, 'reason' => 'harassment', 'details' => 'Rude message', 'created_at' => now()->toIso8601String()], + ])->and($data['user_reports'])->toBe([ + ['user_id' => $reported->id, 'reason' => 'spam', 'details' => null, 'created_at' => now()->toIso8601String()], + ])->and($content)->not->toContain('Someone reported'); +}); diff --git a/tests/Feature/Controllers/ReportedUserControllerTest.php b/tests/Feature/Controllers/ReportedUserControllerTest.php new file mode 100644 index 0000000000..6b429d52fc --- /dev/null +++ b/tests/Feature/Controllers/ReportedUserControllerTest.php @@ -0,0 +1,72 @@ + ['moderator-demo']]); + $this->admin = User::factory()->create(['handle' => 'moderator-demo']); +}); + +it('lists open message and user reports oldest first, with the message and its author, without who reported', function (): void { + $older = MessageReport::factory()->for(Message::factory()->create(['body' => 'buy my course']))->create(['created_at' => now()->subHour(), 'details' => 'Spam DM.']); + $newer = MessageReport::factory()->create(); + MessageReport::factory()->create(['dismissed_at' => now()]); + $person = UserReport::factory()->create(['reason' => 'harassment']); + UserReport::factory()->create(['dismissed_at' => now()]); + + $this->actingAs($this->admin)->get(route('reported-user.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page->component('moderation/people-reports') + ->where('reasons.harassment', 'Harassment or hate') + ->has('messageReports', 2) + ->where('messageReports.0.id', $older->id) + ->where('messageReports.0.details', 'Spam DM.') + ->where('messageReports.0.message.body', 'buy my course') + ->where('messageReports.0.message.user.handle', $older->message->user->handle) + ->missing('messageReports.0.reporter_id') + ->where('messageReports.1.id', $newer->id) + ->has('userReports', 1) + ->where('userReports.0.id', $person->id) + ->where('userReports.0.reason', 'harassment') + ->where('userReports.0.user.handle', $person->user->handle) + ->missing('userReports.0.reporter_id')); +}); + +it('dismisses message and user reports, telling moderators', function (): void { + Event::fake([UsersUpdated::class]); + $message = MessageReport::factory()->create(); + $user = UserReport::factory()->create(); + + $this->actingAs($this->admin)->fromRoute('reported-user.index') + ->delete(route('message-report.destroy', $message))->assertRedirectToRoute('reported-user.index'); + $this->fromRoute('reported-user.index') + ->delete(route('user-report.destroy', $user))->assertRedirectToRoute('reported-user.index'); + + expect($message->refresh()->dismissed_at)->not->toBeNull() + ->and($user->refresh()->dismissed_at)->not->toBeNull(); + Event::assertDispatched(UsersUpdated::class, fn (UsersUpdated $event): bool => $event->update === UserUpdate::Moderation && $event->data === ['message_reports' => [$message->id]]); + Event::assertDispatched(UsersUpdated::class, fn (UsersUpdated $event): bool => $event->update === UserUpdate::Moderation && $event->data === ['user_reports' => [$user->id]]); + $this->get(route('reported-user.index'))->assertInertia(fn ($page) => $page->has('messageReports', 0)->has('userReports', 0)); +}); + +it('keeps the queue and dismissals to moderators', function (): void { + $message = MessageReport::factory()->create(); + $user = UserReport::factory()->create(); + + foreach ([$message->message->user, $user->user, User::factory()->create()] as $member) { + $this->actingAs($member)->get(route('reported-user.index'))->assertForbidden(); + $this->delete(route('message-report.destroy', $message))->assertForbidden(); + $this->delete(route('user-report.destroy', $user))->assertForbidden(); + } + + expect($message->refresh()->dismissed_at)->toBeNull() + ->and($user->refresh()->dismissed_at)->toBeNull(); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 8a0ed8401e..df401d7cb4 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -19,6 +19,7 @@ use App\Models\LinkPreview; use App\Models\Message; use App\Models\MessageImage; +use App\Models\MessageReport; use App\Models\MessageVoice; use App\Models\Organization; use App\Models\Poll; @@ -26,6 +27,7 @@ use App\Models\PollVote; use App\Models\PushSubscription; use App\Models\User; +use App\Models\UserReport; use App\Queries\HeadlineSummaryQuery; use App\Services\CommitTranslations; use App\Services\TheAlgorithm; @@ -474,3 +476,18 @@ resolve(DeleteUser::class)->handle($user); expect(PushSubscription::query()->count())->toBe(0); }); + +it('erases the message and user reports filed by, about, or on messages of the deleted user', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + MessageReport::factory()->create(['reporter_id' => $user->id]); + MessageReport::factory()->for(Message::factory()->for($user))->create(['reporter_id' => $other->id]); + UserReport::factory()->create(['reporter_id' => $user->id]); + UserReport::factory()->for($user)->create(['reporter_id' => $other->id]); + $kept = [MessageReport::factory()->create(['reporter_id' => $other->id]), UserReport::factory()->create(['reporter_id' => $other->id])]; + + resolve(DeleteUser::class)->handle($user); + + expect(MessageReport::query()->pluck('id')->all())->toBe([$kept[0]->id]) + ->and(UserReport::query()->pluck('id')->all())->toBe([$kept[1]->id]); +});