diff --git a/app/ComplaintRecord.php b/app/ComplaintRecord.php new file mode 100644 index 000000000..7dc435ab2 --- /dev/null +++ b/app/ComplaintRecord.php @@ -0,0 +1,37 @@ +dispatched_at = Carbon::now(); + } +} diff --git a/app/Http/Controllers/ComplaintController.php b/app/Http/Controllers/ComplaintController.php new file mode 100644 index 000000000..d59941c15 --- /dev/null +++ b/app/Http/Controllers/ComplaintController.php @@ -0,0 +1,110 @@ +recaptchaValidation = $recaptchaValidation; + } + + /** + * Handle a complaint report page request for the application. + * + * @param \Illuminate\Http\Request $request + */ + public function sendMessage(Request $request): \Illuminate\Http\JsonResponse + { + $validator = $this->validator($request->all()); + + if ($validator->fails()) { + $failed = $validator->failed(); + + if (isset($failed['recaptcha'])) { + abort(401); + } else { + abort(400); + } + } + + $validated = $validator->safe(); + + $complaintRecord = new ComplaintRecord; + $complaintRecord->name = $validated['name']; + $complaintRecord->mail_address = $validated['email']; + $complaintRecord->reason = $validated['message']; + $complaintRecord->offending_urls = $validated['url']; + $complaintRecord->save(); + + if (! empty($complaintRecord->mail_address)) { + Notification::route('mail', [ + $complaintRecord->mail_address, + ])->notify( + new ComplaintNotificationExternal( + $complaintRecord->offending_urls, + $complaintRecord->reason, + $complaintRecord->name, + $complaintRecord->mail_address, + ) + ); + } + + Notification::route('mail', [ + config('app.complaint-mail-recipient'), + ])->notify( + new ComplaintNotification( + $complaintRecord->offending_urls, + $complaintRecord->reason, + $complaintRecord->name, + $complaintRecord->mail_address, + ) + ); + + $complaintRecord->markAsDispatched(); + $complaintRecord->save(); + + return response()->json('Success', 200); + } + + /** + * Get a validator for an incoming complaint report page request. + */ + protected function validator(array $data): \Illuminate\Validation\Validator + { + $data['name'] = $data['name'] ?? ''; + $data['email'] = $data['email'] ?? ''; + + $validation = [ + 'recaptcha' => ['required', 'string', 'bail', $this->recaptchaValidation], + 'name' => ['nullable', 'string', 'max:300'], + 'message' => ['required', 'string', 'max:1000'], + 'url' => ['required', 'string', 'max:1000'], + + 'email' => [ + 'nullable', + 'max:300', + Rule::when( + !empty($data['email']), + ['email:rfc'] + ), + ], + ]; + + return Validator::make($data, $validation); + } +} diff --git a/app/Notifications/ComplaintNotification.php b/app/Notifications/ComplaintNotification.php new file mode 100644 index 000000000..98ff38141 --- /dev/null +++ b/app/Notifications/ComplaintNotification.php @@ -0,0 +1,88 @@ +offendingUrls = $offendingUrls; + $this->reason = $reason; + $this->name = $name; + $this->mailAddress = $mailAddress; + } + + /** + * Get the notification's channels. + * + * @param mixed $notifiable + * @return array|string + */ + public function via($notifiable) + { + return ['database', 'mail']; + } + + /** + * Build the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + $name = $this->name; + $mailAddress = $this->mailAddress; + + if (empty($name)) { + $name = 'None'; + } + + if (empty($mailAddress)) { + $mailAddress = 'None'; + } + + $mailFrom = config('app.complaint-mail-sender'); + $mailSubject = config('app.name') . ': Report of Illegal Content'; + + return (new MailMessage) + ->from($mailFrom) + ->subject($mailSubject) + ->line(Lang::get('A message via the wikibase.cloud form for reporting illegal content has been submitted.')) + ->line(Lang::get('Reporter name: ') . $name) + ->line(Lang::get('Reporter email address: ') . $mailAddress) + ->line(Lang::get('Reason why the information in question is illegal content:')) + ->line($this->reason) + ->line(Lang::get('URL(s) for the content in question:')) + ->line($this->offendingUrls) + ->line('---'); + } + + public function toDatabase($notifiable) { + $mail = $this->toMail($notifiable); + + return (new DatabaseMessage($mail->toArray())); + } +} diff --git a/app/Notifications/ComplaintNotificationExternal.php b/app/Notifications/ComplaintNotificationExternal.php new file mode 100644 index 000000000..fb01370df --- /dev/null +++ b/app/Notifications/ComplaintNotificationExternal.php @@ -0,0 +1,37 @@ +name; + + if (empty($name)) { + $name = 'None'; + } + + $mailFrom = config('app.complaint-mail-sender'); + $mailSubject = config('app.name') . ': Report of Illegal Content'; + + return (new MailMessage) + ->from($mailFrom) + ->subject($mailSubject) + ->line(Lang::get('Your message via the wikibase.cloud form for reporting illegal content has been submitted.')) + ->line('---'); + } +} diff --git a/config/app.php b/config/app.php index e2cce9376..52b70f6cc 100644 --- a/config/app.php +++ b/config/app.php @@ -10,6 +10,9 @@ 'contact-mail-recipient' => env('WBSTACK_CONTACT_MAIL_RECIPIENT', 'wikibase-cloud-owner@lists.wikimedia.org'), 'contact-mail-sender' => env('WBSTACK_CONTACT_MAIL_SENDER', 'contact-@wikibase.cloud'), + 'complaint-mail-recipient' => env('WBSTACK_COMPLAINT_MAIL_RECIPIENT', 'dsa-meldung@wikimedia.de'), + 'complaint-mail-sender' => env('WBSTACK_COMPLAINT_MAIL_SENDER', 'dsa@wikibase.cloud'), + /* |-------------------------------------------------------------------------- | Application Name diff --git a/database/factories/ComplaintRecordFactory.php b/database/factories/ComplaintRecordFactory.php new file mode 100644 index 000000000..90d8de135 --- /dev/null +++ b/database/factories/ComplaintRecordFactory.php @@ -0,0 +1,33 @@ + + */ +class ComplaintRecordFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = ComplaintRecord::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'dispatched' => null, + ]; + } +} diff --git a/database/migrations/2025_07_18_103841_create_complaint_records_table.php b/database/migrations/2025_07_18_103841_create_complaint_records_table.php new file mode 100644 index 000000000..44f4f8219 --- /dev/null +++ b/database/migrations/2025_07_18_103841_create_complaint_records_table.php @@ -0,0 +1,32 @@ +id(); + $table->timestamps(); + $table->timestamp('dispatched_at')->nullable(); + $table->string('name')->nullable(); + $table->string('mail_address')->nullable(); + $table->text('reason'); + $table->text('offending_urls'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('complaint_records'); + } +}; diff --git a/routes/api.php b/routes/api.php index 015ffd585..9c7aa117e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,7 @@ $router->post('user/forgotPassword', ['uses' => 'Auth\ForgotPasswordController@sendResetLinkEmail']); $router->post('user/resetPassword', ['uses' => 'Auth\ResetPasswordController@reset']); $router->post('contact/sendMessage', ['uses' => 'ContactController@sendMessage']); + $router->post('complaint/sendMessage', ['uses' => 'ComplaintController@sendMessage']); $router->post('auth/login', ['uses' => 'Auth\LoginController@postLogin'])->name('login'); // Authed diff --git a/tests/Routes/Complaint/SendMessageTest.php b/tests/Routes/Complaint/SendMessageTest.php new file mode 100644 index 000000000..998f66a99 --- /dev/null +++ b/tests/Routes/Complaint/SendMessageTest.php @@ -0,0 +1,226 @@ + '', + 'email' => '', + 'message' => '', + 'url' => '', + 'recaptcha' => '', + ]; + + protected $postDataTemplateFilled = [ + 'name' => 'Jane Doe', + 'email' => 'jane.doe@example.com', + 'message' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + 'url' => 'https://example.com/1, https://example.com/2, https://example.com/3', + 'recaptcha' => 'fake-token', + ]; + + private function mockReCaptchaValidation($passes=true) + { + // replace injected ReCaptchaValidation class with mock (ComplaintController::$recaptchaValidation) + $mockRule = $this->createMock(ReCaptchaValidation::class); + $mockRule->method('passes') + ->willReturn($passes); + + $this->app->instance(ReCaptchaValidation::class, $mockRule); + } + private function assertRecordCount(int $count) + { + $this->assertEquals(ComplaintRecord::count(), $count); + } + + private function assertComplaintMarkedAsDispatched() + { + $complaintRecord = ComplaintRecord::first(); + $this->assertNotEmpty($complaintRecord->dispatched_at); + } + + private function assertComplaintNotMarkedAsDispatched() + { + $complaintRecord = ComplaintRecord::first(); + $this->assertEmpty($complaintRecord->dispatched_at); + } + + private function assertComplaintRecorded() + { + $this->assertRecordCount(1); + } + + private function assertComplaintNotRecorded() + { + $this->assertRecordCount(0); + } + + public function testRecordOnMailFail() + { + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + + try { + $response = $this->json('POST', $this->route, $data); + } catch(\Symfony\Component\Mailer\Exception\TransportException $e) { + return; + } + + $this->assertNotEquals($response->status(), 200); + + $this->assertComplaintRecorded(); + $this->assertComplaintNotMarkedAsDispatched(); + } + + public function testSendMessage_NoData() + { + Notification::fake(); + $this->mockReCaptchaValidation(false); + + $data = $this->postDataTemplateEmpty; + + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(401); + + Notification::assertNothingSent(); + $this->assertComplaintNotRecorded(); + } + + public function testSendMessage_InvalidMailAddressRfc() + { + Notification::fake(); + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + $data['email'] = "invalid-mail-address"; + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(400); + + $this->assertEquals(ComplaintRecord::count(), 0); + + Notification::assertNothingSent(); + $this->assertComplaintNotRecorded(); + } + + public function testSendMessage_InvalidMailAddressMulti() + { + Notification::fake(); + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + $data['email'] = "mail@example.com, foo@bar.com"; + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(400); + + Notification::assertNothingSent(); + $this->assertComplaintNotRecorded(); + } + + public function testSendMessage_ReasonTooLong() + { + Notification::fake(); + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + $data['message'] = str_repeat("Hi!", 10000); + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(400); + + Notification::assertNothingSent(); + $this->assertComplaintNotRecorded(); + } + + public function testSendMessage_NameTooLong() + { + Notification::fake(); + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + $data['name'] = str_repeat("Hi!", 10000); + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(400); + + Notification::assertNothingSent(); + $this->assertComplaintNotRecorded(); + } + + public function testSendMessage_OffendingUrlsTooLong() + { + Notification::fake(); + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + $data['url'] = str_repeat("Hi!", 10000); + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(400); + + Notification::assertNothingSent(); + $this->assertComplaintNotRecorded(); + } + + public function testSendMessage_NoNameNorMailAddress() + { + Notification::fake(); + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + $data['name'] = ''; + $data['email'] = ''; + + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(200); + + Notification::assertCount(1); + $this->assertComplaintRecorded(); + $this->assertComplaintMarkedAsDispatched(); + } + + public function testSendMessage_Success() + { + Notification::fake(); + $this->mockReCaptchaValidation(); + + $data = $this->postDataTemplateFilled; + + $response = $this->json('POST', $this->route, $data); + $response->assertStatus(200); + Notification::assertSentTo(new AnonymousNotifiable(), ComplaintNotification::class, function ($notification) { + $this->assertSame( + "dsa@wikibase.cloud", + $notification->toMail(new AnonymousNotifiable())->from[0] + ); + return true; + }); + + Notification::assertCount(2); + $this->assertComplaintRecorded(); + $this->assertComplaintMarkedAsDispatched(); + } + + public function testSendMessage_RecaptchaFailure() + { + Notification::fake(); + $this->mockReCaptchaValidation(false); + + $response = $this->json('POST', $this->route, $this->postDataTemplateFilled); + $response->assertStatus(401); + + Notification::assertNothingSent(); + $this->assertComplaintNotRecorded(); + } +}