refactor(api): validate and throttle feedback endpoint
- Validate content (required string, min:10, max:2000) in OtherController@feedback - Register 'feedback' named rate limiter (3/min per user or IP) in RouteServiceProvider - Apply throttle:feedback middleware to POST /api/feedback - Forward to Discord with allowed_mentions.parse=[] and a 5s HTTP timeout Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1337e4351a
commit
e7bbd45408
4 changed files with 110 additions and 5 deletions
|
|
@ -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' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
96
tests/Feature/FeedbackEndpointTest.php
Normal file
96
tests/Feature/FeedbackEndpointTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake([
|
||||
'discord.com/*' => 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' => []];
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue