diff --git a/.env.example.complete b/.env.example.complete index 716d614a3..e44644f08 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -260,4 +260,7 @@ ALLOW_ROBOTS=null # The default and maximum item-counts for listing API requests. API_DEFAULT_ITEM_COUNT=100 -API_MAX_ITEM_COUNT=500 \ No newline at end of file +API_MAX_ITEM_COUNT=500 + +# The number of API requests that can be made per minute by a single user. +API_REQUESTS_PER_MIN=180 \ No newline at end of file diff --git a/app/Config/api.php b/app/Config/api.php index dd54b2906..6afea2dc8 100644 --- a/app/Config/api.php +++ b/app/Config/api.php @@ -17,4 +17,7 @@ return [ // The maximum number of items that can be returned in a listing API request. 'max_item_count' => env('API_MAX_ITEM_COUNT', 500), + // The number of API requests that can be made per minute by a single user. + 'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180) + ]; diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 70a534975..284d731d7 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -7,6 +7,9 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -47,6 +50,10 @@ class Handler extends ExceptionHandler */ public function render($request, Exception $e) { + if ($this->isApiRequest($request)) { + return $this->renderApiException($e); + } + // Handle notify exceptions which will redirect to the // specified location then show a notification message. if ($this->isExceptionType($e, NotifyException::class)) { @@ -70,6 +77,41 @@ class Handler extends ExceptionHandler return parent::render($request, $e); } + /** + * Check if the given request is an API request. + */ + protected function isApiRequest(Request $request): bool + { + return strpos($request->path(), 'api/') === 0; + } + + /** + * Render an exception when the API is in use. + */ + protected function renderApiException(Exception $e): JsonResponse + { + $code = $e->getCode() === 0 ? 500 : $e->getCode(); + $headers = []; + if ($e instanceof HttpException) { + $code = $e->getStatusCode(); + $headers = $e->getHeaders(); + } + + $responseData = [ + 'error' => [ + 'message' => $e->getMessage(), + ] + ]; + + if ($e instanceof ValidationException) { + $responseData['error']['validation'] = $e->errors(); + $code = $e->status; + } + + $responseData['error']['code'] = $code; + return new JsonResponse($responseData, $code, $headers); + } + /** * Check the exception chain to compare against the original exception type. * @param Exception $e diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 978583a7f..c2016281a 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -32,7 +32,7 @@ class Kernel extends HttpKernel \BookStack\Http\Middleware\GlobalViewData::class, ], 'api' => [ - 'throttle:60,1', + \BookStack\Http\Middleware\ThrottleApiRequests::class, \BookStack\Http\Middleware\EncryptCookies::class, \BookStack\Http\Middleware\StartSessionIfCookieExists::class, \BookStack\Http\Middleware\ApiAuthenticate::class, diff --git a/app/Http/Middleware/ThrottleApiRequests.php b/app/Http/Middleware/ThrottleApiRequests.php new file mode 100644 index 000000000..d08840cd1 --- /dev/null +++ b/app/Http/Middleware/ThrottleApiRequests.php @@ -0,0 +1,18 @@ + + diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php index b6b6b72ac..2ab81814b 100644 --- a/tests/Api/ApiAuthTest.php +++ b/tests/Api/ApiAuthTest.php @@ -120,4 +120,29 @@ class ApiAuthTest extends TestCase $resp->assertJson($this->errorResponse("The email address for the account in use needs to be confirmed", 401)); } + public function test_rate_limit_headers_active_on_requests() + { + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-limit', 180); + $resp->assertHeader('x-ratelimit-remaining', 179); + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-remaining', 178); + } + + public function test_rate_limit_hit_gives_json_error() + { + config()->set(['api.requests_per_minute' => 1]); + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertStatus(200); + + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertStatus(429); + $resp->assertHeader('x-ratelimit-remaining', 0); + $resp->assertHeader('retry-after'); + $resp->assertJson([ + 'error' => [ + 'code' => 429, + ] + ]); + } } \ No newline at end of file diff --git a/tests/Api/ApiConfigTest.php b/tests/Api/ApiConfigTest.php index d9367741f..1b3da2f34 100644 --- a/tests/Api/ApiConfigTest.php +++ b/tests/Api/ApiConfigTest.php @@ -44,4 +44,15 @@ class ApiConfigTest extends TestCase $resp->assertJsonCount(2, 'data'); } + public function test_requests_per_min_alters_rate_limit() + { + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-limit', 180); + + config()->set(['api.requests_per_minute' => 10]); + + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-limit', 10); + } + } \ No newline at end of file diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 813e34360..a40e4c93b 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -38,6 +38,26 @@ class BooksApiTest extends TestCase $this->assertActivityExists('book_create', $newItem); } + public function test_book_name_needed_to_create() + { + $this->actingAsApiEditor(); + $details = [ + 'description' => 'A book created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson([ + "error" => [ + "message" => "The given data was invalid.", + "validation" => [ + "name" => ["The name field is required."] + ], + "code" => 422, + ], + ]); + } + public function test_read_endpoint() { $this->actingAsApiEditor();