# 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
// 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)
+
];
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;
*/
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)) {
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
\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,
--- /dev/null
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use Illuminate\Routing\Middleware\ThrottleRequests as Middleware;
+
+class ThrottleApiRequests extends Middleware
+{
+
+ /**
+ * Resolve the number of attempts if the user is authenticated or not.
+ */
+ protected function resolveMaxAttempts($request, $maxAttempts)
+ {
+ return (int) config('api.requests_per_minute');
+ }
+
+}
\ No newline at end of file
<server name="APP_URL" value="http://bookstack.dev"/>
<server name="DEBUGBAR_ENABLED" value="false"/>
<server name="SAML2_ENABLED" value="false"/>
+ <server name="API_REQUESTS_PER_MIN" value="180"/>
</php>
</phpunit>
$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
$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
$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();