diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 8f2ba25c8..49468b597 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -147,11 +147,15 @@ public function disable_api(Request $request) public function feedback(Request $request) { - $content = $request->input('content'); + $data = $request->validate([ + 'content' => ['required', 'string', 'min:10', 'max:2000'], + ]); + $webhook_url = config('constants.webhooks.feedback_discord_webhook'); if ($webhook_url) { - Http::post($webhook_url, [ - 'content' => $content, + Http::timeout(5)->post($webhook_url, [ + 'content' => $data['content'], + 'allowed_mentions' => ['parse' => []], ]); } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 2150126cd..4068572c8 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -54,5 +54,9 @@ protected function configureRateLimiting(): void RateLimiter::for('5', function (Request $request) { return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('feedback', function (Request $request) { + return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()); + }); } } diff --git a/routes/api.php b/routes/api.php index 0d3edcced..161d08c13 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,7 +26,8 @@ Route::get('/health', [OtherController::class, 'healthcheck']); }); -Route::post('/feedback', [OtherController::class, 'feedback']); +Route::post('/feedback', [OtherController::class, 'feedback']) + ->middleware('throttle:feedback'); Route::group([ 'middleware' => ['auth:sanctum', 'api.ability:write'], @@ -218,7 +219,7 @@ try { $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); - } catch (\Exception $e) { + } catch (Exception $e) { return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); diff --git a/tests/Feature/FeedbackEndpointTest.php b/tests/Feature/FeedbackEndpointTest.php new file mode 100644 index 000000000..a2c603def --- /dev/null +++ b/tests/Feature/FeedbackEndpointTest.php @@ -0,0 +1,96 @@ + Http::response([], 204), + ]); +}); + +it('rejects feedback with missing content', function () { + $response = $this->postJson('/api/feedback', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with content too short', function () { + $response = $this->postJson('/api/feedback', ['content' => 'short']); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with content too long', function () { + $response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with non-string content', function () { + $response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('accepts valid feedback and forwards to discord with mentions disabled', function () { + config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test'); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This is a valid feedback message for testing purposes.', + ]); + + $response->assertStatus(200) + ->assertJson(['message' => 'Feedback sent.']); + + Http::assertSent(function ($request) { + return $request->url() === 'https://discord.com/api/webhooks/test' + && $request['content'] === 'This is a valid feedback message for testing purposes.' + && $request['allowed_mentions'] === ['parse' => []]; + }); +}); + +it('does not forward to discord when webhook url is not configured', function () { + config()->set('constants.webhooks.feedback_discord_webhook', null); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This is a valid feedback message for testing purposes.', + ]); + + $response->assertStatus(200); + + Http::assertNothingSent(); +}); + +it('throttles feedback endpoint after 3 requests per minute', function () { + config()->set('constants.webhooks.feedback_discord_webhook', null); + + for ($i = 0; $i < 3; $i++) { + $response = $this->postJson('/api/feedback', [ + 'content' => "Valid feedback message number {$i} for testing.", + ]); + $response->assertStatus(200); + } + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This fourth request should be throttled.', + ]); + $response->assertStatus(429); +}); + +it('disables discord mention parsing regardless of content', function () { + config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test'); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.', + ]); + + $response->assertStatus(200); + + Http::assertSent(function ($request) { + return $request['allowed_mentions'] === ['parse' => []]; + }); +});