diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index a4dcaec55..00623ed3b 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -8,6 +8,7 @@ use Illuminate\Auth\AuthenticationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Http\Exceptions\ThrottleRequestsException; use Illuminate\Http\Request; use Illuminate\Session\TokenMismatchException; use Illuminate\Validation\ValidationException; @@ -16,6 +17,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Teapot\StatusCode; +use Teapot\StatusCode\RFC\RFC6585; use Throwable; class Handler extends ExceptionHandler @@ -82,8 +84,12 @@ public function render($request, Throwable $e) ], StatusCode::NOT_FOUND); } else if ($e instanceof NotFoundHttpException) { return response()->json(['message' => __('exceptions.handler.api_route_not_found')], StatusCode::NOT_FOUND); + } else if ($e instanceof ThrottleRequestsException) { + return response()->json(['message' => __('exceptions.handler.too_many_requests')], RFC6585::TOO_MANY_REQUESTS); } else if (!config('app.debug')) { return response()->json(['message' => __('exceptions.handler.internal_server_error')], StatusCode::INTERNAL_SERVER_ERROR); + } else { + return response()->json(['message' => $e->getMessage()], $e->getCode()); } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index fa9715fc0..ed88b48d3 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -56,20 +56,19 @@ class Kernel extends HttpKernel EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, + TrustProxies::class, // \Illuminate\Session\Middleware\AuthenticateSession::class, ShareErrorsFromSession::class, VerifyCsrfToken::class, SubstituteBindings::class, - TrustProxies::class, ], 'api' => [ - ThrottleRequests::class . ':60,1', + 'authentication' => ApiAuthentication::class, + TrustProxies::class, 'bindings', 'debug_info_context_logger' => DebugInfoContextLogger::class, 'read_only_mode' => ReadOnlyMode::class, - 'authentication' => ApiAuthentication::class, - TrustProxies::class, ], ]; diff --git a/app/Http/Requests/Api/V1/CombatLog/Route/CombatLogRouteRequest.php b/app/Http/Requests/Api/V1/CombatLog/Route/CombatLogRouteRequest.php index 7ad3f2e7c..d802634ca 100644 --- a/app/Http/Requests/Api/V1/CombatLog/Route/CombatLogRouteRequest.php +++ b/app/Http/Requests/Api/V1/CombatLog/Route/CombatLogRouteRequest.php @@ -47,14 +47,14 @@ public function rules(): array 'metadata.wowInstanceId' => ['nullable', 'int'], 'settings.temporary' => ['nullable', 'bool'], 'settings.debugIcons' => ['nullable', 'bool'], - 'roster.numMembers' => ['nullable', 'int'], // @TODO make required after raider.io supports it - 'roster.averageItemLevel' => ['nullable', 'numeric'], // @TODO make required after raider.io supports it - 'roster.characterIds' => ['nullable', 'array'], // @TODO make required after raider.io supports it - 'roster.characterIds.*' => ['nullable', 'int'], // @TODO make required after raider.io supports it - 'roster.specIds' => ['nullable', 'array'], // @TODO make required after raider.io supports it - 'roster.specIds.*' => ['nullable', 'int'], // @TODO make required after raider.io supports it - 'roster.classIds' => ['nullable', 'array'], // @TODO make required after raider.io supports it - 'roster.classIds.*' => ['nullable', 'int'], // @TODO make required after raider.io supports it + 'roster.numMembers' => ['nullable', 'int'], // @TODO make required after raider.io supports it + 'roster.averageItemLevel' => ['nullable', 'numeric'], // @TODO make required after raider.io supports it + 'roster.characterIds' => ['nullable', 'array'], // @TODO make required after raider.io supports it + 'roster.characterIds.*' => ['nullable', 'int'], // @TODO make required after raider.io supports it + 'roster.specIds' => ['nullable', 'array'], // @TODO make required after raider.io supports it + 'roster.specIds.*' => ['nullable', 'int'], // @TODO make required after raider.io supports it + 'roster.classIds' => ['nullable', 'array'], // @TODO make required after raider.io supports it + 'roster.classIds.*' => ['nullable', 'int'], // @TODO make required after raider.io supports it 'challengeMode.start' => ['required', $dateFormat], 'challengeMode.end' => ['required', $dateFormat], 'challengeMode.durationMs' => ['required', 'int'], @@ -65,7 +65,7 @@ public function rules(): array 'challengeMode.level' => ['required', 'int'], 'challengeMode.numDeaths' => ['nullable', 'int'], // @TODO make required after raider.io supports it 'challengeMode.affixes' => ['required', 'array'], - 'challengeMode.affixes.*' => ['required', Rule::exists(Affix::class, 'affix_id')], + 'challengeMode.affixes.*' => ['required', 'integer'], // #1818 Rule::exists(Affix::class, 'affix_id')], 'npcs' => ['required', 'array', new CombatLogRouteNpcChronologicalRule()], 'npcs.*.npcId' => ['required', 'integer'], // #1818 Rule::exists('npcs', 'id') 'npcs.*.spawnUid' => ['required', 'string', 'max:10'], @@ -81,15 +81,15 @@ public function rules(): array 'spells.*.coord.x' => 'numeric', 'spells.*.coord.y' => 'numeric', 'spells.*.coord.uiMapId' => Rule::exists(Floor::class, 'ui_map_id'), - 'playerDeaths' => 'nullable|array', // @TODO make required after raider.io supports it - 'playerDeaths.*.characterId' => 'integer', // @TODO make required after raider.io supports it - 'playerDeaths.*.classId' => 'integer', // @TODO make required after raider.io supports it - 'playerDeaths.*.specId' => 'integer', // @TODO make required after raider.io supports it - 'playerDeaths.*.itemLevel' => 'numeric', // @TODO make required after raider.io supports it - 'playerDeaths.*.diedAt' => $dateFormat, // @TODO make required after raider.io supports it - 'playerDeaths.*.coord.x' => 'numeric', // @TODO make required after raider.io supports it - 'playerDeaths.*.coord.y' => 'numeric', // @TODO make required after raider.io supports it - 'playerDeaths.*.coord.uiMapId' => Rule::exists(Floor::class, 'ui_map_id'), // @TODO make required after raider.io supports it + 'playerDeaths' => 'nullable|array', // @TODO make required after raider.io supports it + 'playerDeaths.*.characterId' => 'integer', // @TODO make required after raider.io supports it + 'playerDeaths.*.classId' => 'integer', // @TODO make required after raider.io supports it + 'playerDeaths.*.specId' => 'integer', // @TODO make required after raider.io supports it + 'playerDeaths.*.itemLevel' => 'numeric', // @TODO make required after raider.io supports it + 'playerDeaths.*.diedAt' => $dateFormat, // @TODO make required after raider.io supports it + 'playerDeaths.*.coord.x' => 'numeric', // @TODO make required after raider.io supports it + 'playerDeaths.*.coord.y' => 'numeric', // @TODO make required after raider.io supports it + 'playerDeaths.*.coord.uiMapId' => Rule::exists(Floor::class, 'ui_map_id'), // @TODO make required after raider.io supports it ]; } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 6233826ea..f192daa8d 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -12,16 +12,19 @@ class RouteServiceProvider extends ServiceProvider { - private const RATE_LIMIT_OVERRIDE = null; + private const RATE_LIMIT_OVERRIDE_HTTP = null; + private const RATE_LIMIT_OVERRIDE_PER_MINUTE_API = null; /** * Define your route model bindings, pattern filters, etc. */ public function boot(): void { + parent::boot(); + $this->configureRateLimiting(); - parent::boot(); + $this->configureApiRateLimiting(); } /** @@ -54,7 +57,7 @@ protected function mapWebRoutes(): void protected function mapApiRoutes(): void { Route::prefix('api') - ->middleware('api') + ->middleware(['api', 'throttle:api-general']) ->group(base_path('routes/api.php')); } @@ -64,35 +67,51 @@ protected function mapApiRoutes(): void protected function configureRateLimiting(): void { RateLimiter::for('create-dungeonroute', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 20)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 20)->by($this->userKey($request)); }); RateLimiter::for('create-tag', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 60)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 60)->by($this->userKey($request)); }); RateLimiter::for('create-team', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 5)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 5)->by($this->userKey($request)); }); RateLimiter::for('create-reports', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 60)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 60)->by($this->userKey($request)); }); RateLimiter::for('create-user', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 10)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 10)->by($this->userKey($request)); }); // Heavy GET requests RateLimiter::for('search-dungeonroute', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 300)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 300)->by($this->userKey($request)); }); // This consumes the same resources as creating a route - so we limit it RateLimiter::for('mdt-details', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 60)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 60)->by($this->userKey($request)); }); RateLimiter::for('mdt-export', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 60)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 60)->by($this->userKey($request)); }); RateLimiter::for('simulate', function (Request $request) { - return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE ?? 120)->by($this->userKey($request)); + return $this->noLimitForExemptions($request) ?? Limit::perHour(self::RATE_LIMIT_OVERRIDE_HTTP ?? 120)->by($this->userKey($request)); + }); + } + + private function configureApiRateLimiting(): void + { + RateLimiter::for('api-general', function (Request $request) { + return $this->noLimitForExemptionsApi($request) ?? Limit::perMinute(self::RATE_LIMIT_OVERRIDE_PER_MINUTE_API ?? 600)->by($this->userKey($request)); + }); + RateLimiter::for('api-combatlog-create-dungeonroute', function (Request $request) { + return $this->noLimitForExemptionsApi($request) ?? Limit::perMinute(self::RATE_LIMIT_OVERRIDE_PER_MINUTE_API ?? 120)->by($this->userKey($request)); + }); + RateLimiter::for('api-combatlog-correct-event', function (Request $request) { + return $this->noLimitForExemptionsApi($request) ?? Limit::perMinute(self::RATE_LIMIT_OVERRIDE_PER_MINUTE_API ?? 5)->by($this->userKey($request)); + }); + RateLimiter::for('api-create-dungeonroute-thumbnail', function (Request $request) { + return $this->noLimitForExemptionsApi($request) ?? Limit::perMinute(self::RATE_LIMIT_OVERRIDE_PER_MINUTE_API ?? 30)->by($this->userKey($request)); }); } @@ -108,6 +127,18 @@ private function noLimitForExemptions(Request $request): ?Limit return null; } + private function noLimitForExemptionsApi(Request $request): ?Limit + { + /** @var User|null $user */ + $user = $request->user(); + + if ($user?->hasRole(Role::ROLE_ADMIN)) { + return Limit::none(); + } + + return null; + } + private function userKey(Request $request): string { /** @var User|null $user */ diff --git a/docker-compose/app/Dockerfile b/docker-compose/app/Dockerfile index e971731cd..dcfaf2654 100644 --- a/docker-compose/app/Dockerfile +++ b/docker-compose/app/Dockerfile @@ -84,8 +84,8 @@ RUN /root/.cargo/bin/cargo install --git https://github.com/Zireael-N/cli-weakau # Install actual LUA language RUN apt-get update && apt-get install -y lua5.3 liblua5.3 && \ ln -s /usr/include/lua5.3/ /usr/include/lua && \ - cp /usr/lib/x86_64-linux-gnu/liblua5.3.a /usr/lib/liblua.a && \ - cp /usr/lib/x86_64-linux-gnu/liblua5.3.so.0.0.0 /usr/lib/liblua.so && \ + cp /usr/lib/*-linux-gnu/liblua5.3.a /usr/lib/liblua.a && \ + cp /usr/lib/*-linux-gnu/liblua5.3.so.0.0.0 /usr/lib/liblua.so && \ ln /usr/include/lua5.3/lua.h /usr/include/lauxlib.h && \ ln /usr/include/lua5.3/lua.h /usr/include/lua.h && \ ln /usr/include/lua5.3/lua.h /usr/include/luaconf.h diff --git a/lang/en_US/exceptions.php b/lang/en_US/exceptions.php index 339ea4afd..d66ae57f8 100644 --- a/lang/en_US/exceptions.php +++ b/lang/en_US/exceptions.php @@ -7,6 +7,7 @@ 'api_route_not_found' => 'API route not found', 'internal_server_error' => 'Internal server error', 'unauthenticated' => 'Unauthenticated', + 'too_many_requests' => 'Too many requests', ], ]; diff --git a/routes/api.php b/routes/api.php index 11ffb4215..079e9370e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,17 +17,19 @@ */ Route::prefix('v1')->group(static function () { Route::prefix('combatlog')->group(static function () { - Route::prefix('route')->group(static function () { + Route::middleware('throttle:api-combatlog-create-dungeonroute')->prefix('route')->group(static function () { Route::post('/', (new APICombatLogController())->createRoute(...))->name('api.v1.combatlog.route.create'); }); - Route::prefix('event')->group(static function () { + Route::middleware('throttle:api-combatlog-correct-event')->prefix('event')->group(static function () { Route::post('correct', (new APICombatLogController())->correctEvents(...))->name('api.v1.combatlog.event.correct'); }); }); Route::prefix('route')->group(static function () { Route::get('/', (new APIDungeonRouteController())->get(...))->name('api.v1.route.list'); - Route::post('/{dungeonRoute}/thumbnail', (new APIDungeonRouteController())->createThumbnails(...))->name('api.v1.route.thumbnail.create'); + Route::middleware('throttle:api-create-dungeonroute-thumbnail')->group(static function () { + Route::post('/{dungeonRoute}/thumbnail', (new APIDungeonRouteController())->createThumbnails(...))->name('api.v1.route.thumbnail.create'); + }); Route::get('/thumbnailJob/{dungeonRouteThumbnailJob}', (new APIDungeonRouteThumbnailJobController())->get(...))->name('api.v1.thumbnailjob.get'); });