diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 177edd378..247b7509f 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -46,11 +46,13 @@ class Handler extends ExceptionHandler */ public function report(Throwable $e): void { - $handlerLogging = app()->make(HandlerLoggingInterface::class); + if (app()->has(HandlerLoggingInterface::class)) { + $handlerLogging = app()->make(HandlerLoggingInterface::class); - if ($e instanceof TooManyRequestsHttpException) { - $user = Auth::user(); - $handlerLogging->tooManyRequests(request()?->ip() ?? 'unknown IP', $user?->id, $user?->name, $e); + if ($e instanceof TooManyRequestsHttpException) { + $user = Auth::user(); + $handlerLogging->tooManyRequests(request()?->ip() ?? 'unknown IP', request()?->path(), $user?->id, $user?->name, $e); + } } parent::report($e); diff --git a/app/Exceptions/Logging/HandlerLogging.php b/app/Exceptions/Logging/HandlerLogging.php index b8026eb6a..3649a9384 100644 --- a/app/Exceptions/Logging/HandlerLogging.php +++ b/app/Exceptions/Logging/HandlerLogging.php @@ -7,7 +7,7 @@ class HandlerLogging extends RollbarStructuredLogging implements HandlerLoggingInterface { - public function tooManyRequests(string $ip, ?int $userId, ?string $username, Throwable $throwable): void + public function tooManyRequests(string $ip, string $uri, ?int $userId, ?string $username, Throwable $throwable): void { $this->error(__METHOD__, get_defined_vars()); } diff --git a/app/Exceptions/Logging/HandlerLoggingInterface.php b/app/Exceptions/Logging/HandlerLoggingInterface.php index 2b9a1fb73..4d1579137 100644 --- a/app/Exceptions/Logging/HandlerLoggingInterface.php +++ b/app/Exceptions/Logging/HandlerLoggingInterface.php @@ -4,5 +4,5 @@ interface HandlerLoggingInterface { - public function tooManyRequests(string $ip, ?int $userId, ?string $username, \Throwable $throwable): void; + public function tooManyRequests(string $ip, string $uri, ?int $userId, ?string $username, \Throwable $throwable): void; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 55a0a546d..fa9715fc0 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -10,7 +10,9 @@ use App\Http\Middleware\OnlyAjax; use App\Http\Middleware\ReadOnlyMode; use App\Http\Middleware\RedirectIfAuthenticated; +use App\Http\Middleware\TracksUserIpAddress; use App\Http\Middleware\TrimStrings; +use App\Http\Middleware\TrustProxies; use App\Http\Middleware\VerifyCsrfToken; use App\Http\Middleware\ViewCacheBuster; use BeyondCode\ServerTiming\Middleware\ServerTimingMiddleware; @@ -58,6 +60,7 @@ class Kernel extends HttpKernel ShareErrorsFromSession::class, VerifyCsrfToken::class, SubstituteBindings::class, + TrustProxies::class, ], 'api' => [ @@ -66,6 +69,7 @@ class Kernel extends HttpKernel 'debug_info_context_logger' => DebugInfoContextLogger::class, 'read_only_mode' => ReadOnlyMode::class, 'authentication' => ApiAuthentication::class, + TrustProxies::class, ], ]; @@ -89,5 +93,6 @@ class Kernel extends HttpKernel 'debugbarmessagelogger' => DebugBarMessageLogger::class, 'debug_info_context_logger' => DebugInfoContextLogger::class, 'read_only_mode' => ReadOnlyMode::class, + 'track_ip' => TracksUserIpAddress::class, ]; } diff --git a/app/Http/Middleware/TracksUserIpAddress.php b/app/Http/Middleware/TracksUserIpAddress.php new file mode 100644 index 000000000..e5284ecd2 --- /dev/null +++ b/app/Http/Middleware/TracksUserIpAddress.php @@ -0,0 +1,38 @@ +ajax() && Auth::check()) { + /** @var User $user */ + $user = Auth::user(); + UserIpAddress::upsert( + [ + 'user_id' => $user->id, + 'ip_address' => $request->ip(), + 'count' => 1, // Default value for new rows + 'updated_at' => now(), // Example of tracking when a row is updated + ], + ['user_id', 'ip_address'], + ['count' => DB::raw('count + 1'), 'updated_at'] // Update these columns if a conflict occurs + ); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 3391630ec..ba73b97df 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -2,6 +2,8 @@ namespace App\Http\Middleware; +use App\Service\Cloudflare\CloudflareServiceInterface; +use Closure; use Illuminate\Http\Middleware\TrustProxies as Middleware; use Illuminate\Http\Request; @@ -25,4 +27,24 @@ class TrustProxies extends Middleware Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + public function __construct( + private readonly CloudflareServiceInterface $cloudflareService + ) { + } + + public function handle(Request $request, Closure $next) + { + // https://developers.cloudflare.com/fundamentals/reference/http-request-headers/ + if (app()->isProduction()) { + // Ensure that we know the original IP address that made the request + // https://khalilst.medium.com/get-real-client-ip-behind-cloudflare-in-laravel-189cb89059ff + Request::setTrustedProxies( + $this->cloudflareService->getIpRanges(), + $this->headers + ); + } + + return parent::handle($request, $next); + } } diff --git a/app/Models/Team.php b/app/Models/Team.php index d456cbacd..6d2a06b7b 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; @@ -23,6 +24,9 @@ * @property string $invite_code * @property string $default_role * + * @property Carbon $updated_at + * @property Carbon $created_at + * * @property Collection $teamUsers * @property Collection $members * @property Collection $dungeonroutes @@ -32,13 +36,12 @@ class Team extends Model { use HasIconFile; + use GeneratesPublicKey; protected $visible = ['name', 'description', 'public_key']; protected $fillable = ['default_role']; - use GeneratesPublicKey; - /** * https://stackoverflow.com/a/34485411/771270 */ diff --git a/app/Models/UserIpAddress.php b/app/Models/UserIpAddress.php new file mode 100644 index 000000000..ef0768ac4 --- /dev/null +++ b/app/Models/UserIpAddress.php @@ -0,0 +1,37 @@ +belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/UserReport.php b/app/Models/UserReport.php index 48069f577..24ded56e3 100644 --- a/app/Models/UserReport.php +++ b/app/Models/UserReport.php @@ -17,7 +17,8 @@ * @property string $message * @property bool $contact_ok * @property string $status - * @property User $author + * + * @property User $user * * @mixin Eloquent */ @@ -27,6 +28,6 @@ class UserReport extends Model public function user(): BelongsTo { - return $this->belongsTo(User::class, 'user_id'); + return $this->belongsTo(User::class); } } diff --git a/app/Overrides/CustomRateLimiter.php b/app/Overrides/CustomRateLimiter.php new file mode 100644 index 000000000..12f4e39aa --- /dev/null +++ b/app/Overrides/CustomRateLimiter.php @@ -0,0 +1,21 @@ +isProduction()); /** @var User|null $user */ @@ -41,13 +45,6 @@ public function boot(): void 'correlationId' => correlationId(), ], ]); - - // Ensure that we know the original IP address that made the request - // https://khalilst.medium.com/get-real-client-ip-behind-cloudflare-in-laravel-189cb89059ff - Request::setTrustedProxies( - ['REMOTE_ADDR'], - Request::HEADER_X_FORWARDED_FOR - ); } /** @@ -55,6 +52,11 @@ public function boot(): void */ public function register(): void { - // + // Bind our custom rate limiter + $this->app->extend(RateLimiter::class, function ($command, $app) { + return new CustomRateLimiter($app->make('cache')->driver( + $app['config']->get('cache.limiter') + )); + }); } } diff --git a/app/Providers/KeystoneGuruServiceProvider.php b/app/Providers/KeystoneGuruServiceProvider.php index 49ca98554..cd6b3f0bd 100644 --- a/app/Providers/KeystoneGuruServiceProvider.php +++ b/app/Providers/KeystoneGuruServiceProvider.php @@ -28,6 +28,8 @@ use App\Service\Cache\DevCacheService; use App\Service\ChallengeModeRunData\ChallengeModeRunDataService; use App\Service\ChallengeModeRunData\ChallengeModeRunDataServiceInterface; +use App\Service\Cloudflare\CloudflareService; +use App\Service\Cloudflare\CloudflareServiceInterface; use App\Service\CombatLog\CombatLogDataExtractionService; use App\Service\CombatLog\CombatLogDataExtractionServiceInterface; use App\Service\CombatLog\CombatLogMappingVersionService; @@ -207,6 +209,7 @@ public function register(): void $this->app->bind(WowheadServiceInterface::class, WowheadService::class); // $this->app->bind(RaiderIOApiServiceInterface::class, RaiderIOApiService::class); $this->app->bind(RaiderIOApiServiceInterface::class, RaiderIOKeystoneGuruApiService::class); + $this->app->bind(CloudflareServiceInterface::class, CloudflareService::class); // Depends on CombatLogService, SeasonService, WowheadService $this->app->bind(CombatLogDataExtractionServiceInterface::class, CombatLogDataExtractionService::class); diff --git a/app/Providers/LoggingServiceProvider.php b/app/Providers/LoggingServiceProvider.php index ebf5435b1..67f9238bf 100644 --- a/app/Providers/LoggingServiceProvider.php +++ b/app/Providers/LoggingServiceProvider.php @@ -12,6 +12,8 @@ use App\Service\Cache\Logging\CacheServiceLoggingInterface; use App\Service\ChallengeModeRunData\Logging\ChallengeModeRunDataServiceLogging; use App\Service\ChallengeModeRunData\Logging\ChallengeModeRunDataServiceLoggingInterface; +use App\Service\Cloudflare\Logging\CloudflareServiceLogging; +use App\Service\Cloudflare\Logging\CloudflareServiceLoggingInterface; use App\Service\CombatLog\Builders\Logging\CreateRouteBodyCombatLogEventsBuilderLogging; use App\Service\CombatLog\Builders\Logging\CreateRouteBodyCombatLogEventsBuilderLoggingInterface; use App\Service\CombatLog\Builders\Logging\CreateRouteBodyCorrectionBuilderLogging; @@ -99,6 +101,9 @@ public function register(): void // Challenge Mode Run Data $this->app->bind(ChallengeModeRunDataServiceLoggingInterface::class, ChallengeModeRunDataServiceLogging::class); + // Cloudflare + $this->app->bind(CloudflareServiceLoggingInterface::class, CloudflareServiceLogging::class); + // Combat log /// Builders $this->app->bind(DungeonRouteBuilderLoggingInterface::class, DungeonRouteBuilderLogging::class); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 62f1c414d..eda6ea392 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -19,7 +19,7 @@ class RouteServiceProvider extends ServiceProvider */ public function boot(): void { - // + $this->configureRateLimiting(); parent::boot(); } @@ -32,9 +32,6 @@ public function map(): void $this->mapApiRoutes(); $this->mapWebRoutes(); - - // - $this->configureRateLimiting(); } @@ -101,6 +98,9 @@ protected function configureRateLimiting(): void private function noLimitForExemptions(Request $request): ?Limit { + // Temporarily disable this! + return Limit::none(); + /** @var User|null $user */ $user = $request->user(); @@ -115,6 +115,7 @@ private function userKey(Request $request): string { /** @var User|null $user */ $user = $request->user(); + return $user?->id ?: $request->ip(); } } diff --git a/app/Service/Cloudflare/CloudflareService.php b/app/Service/Cloudflare/CloudflareService.php new file mode 100644 index 000000000..9a8164425 --- /dev/null +++ b/app/Service/Cloudflare/CloudflareService.php @@ -0,0 +1,101 @@ +getIpRangesV4($useCache), $this->getIpRangesV6($useCache)); + } + + public function getIpRangesV4(bool $useCache = true): array + { + return $this->cacheService->rememberWhen($useCache, 'cloudflare:ip-ranges-v4', function () { + $response = $this->curlGet(sprintf('%s/ips-v4', self::CLOUDFLARE_BASE_URL)); + + return $this->validateIpAddressRanges($response, FILTER_FLAG_IPV4); + }); + } + + public function getIpRangesV6(bool $useCache = true): array + { + return $this->cacheService->rememberWhen($useCache, 'cloudflare:ip-ranges-v6', function () { + $response = $this->curlGet(sprintf('%s/ips-v6', self::CLOUDFLARE_BASE_URL)); + + return $this->validateIpAddressRanges($response, FILTER_FLAG_IPV6); + }); + } + + private function validateIpAddressRanges(string $ipAddresses, int $options): array + { + $result = []; + + // Comes from the internet - so don't use PHP_EOL, filter to remove empty lines + $ipRanges = array_filter(explode("\n", $ipAddresses)); + + foreach ($ipRanges as $ipRange) { + if ($this->validateCidr($ipRange)) { + $result[] = $ipRange; + } else { + $this->log->getIpRangesInvalidIpAddress($ipRange); + } + } + + return $result; + } + + /** + * @param string $cidr + * @return bool + * https://gist.github.com/pavinjosdev/cb1d636ea9dc2bd201d54107d10650c5 + */ + private function validateCidr(string $cidr): bool + { + + $parts = explode('/', $cidr); + if (count($parts) != 2) { + return false; + } + + $ip = $parts[0]; + $netmask = $parts[1]; + + if (!preg_match("/^\d+$/", $netmask)) { + return false; + } + + $netmask = intval($parts[1]); + + if ($netmask < 0) { + return false; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $netmask <= 32; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return $netmask <= 128; + } + + return false; + } +} diff --git a/app/Service/Cloudflare/CloudflareServiceInterface.php b/app/Service/Cloudflare/CloudflareServiceInterface.php new file mode 100644 index 000000000..7e82fe6a6 --- /dev/null +++ b/app/Service/Cloudflare/CloudflareServiceInterface.php @@ -0,0 +1,12 @@ +warning(__METHOD__, get_defined_vars()); + } +} diff --git a/app/Service/Cloudflare/Logging/CloudflareServiceLoggingInterface.php b/app/Service/Cloudflare/Logging/CloudflareServiceLoggingInterface.php new file mode 100644 index 000000000..9751e8201 --- /dev/null +++ b/app/Service/Cloudflare/Logging/CloudflareServiceLoggingInterface.php @@ -0,0 +1,8 @@ +getResolvedEnemy() !== null; $mapIconAttributes[] = array_merge([ - 'mapping_version_id' => $mappingVersion->id, + 'mapping_version_id' => null, 'floor_id' => $currentFloor->id, 'dungeon_route_id' => $dungeonRoute?->id ?? null, 'team_id' => null, diff --git a/config/database.php b/config/database.php index fca14cf35..ec4469418 100644 --- a/config/database.php +++ b/config/database.php @@ -204,7 +204,7 @@ 'prefix' => env('REDIS_PREFIX', sprintf('%s-%s-cache:', Str::slug(env('APP_NAME', 'laravel')), - Str::slug(config('app.type')) + Str::slug(env('APP_TYPE', 'local')) ) ), ], diff --git a/database/migrations/2024_11_20_151928_create_user_ip_addresses_table.php b/database/migrations/2024_11_20_151928_create_user_ip_addresses_table.php new file mode 100644 index 000000000..1a58b07af --- /dev/null +++ b/database/migrations/2024_11_20_151928_create_user_ip_addresses_table.php @@ -0,0 +1,34 @@ +id(); + $table->integer('user_id'); + $table->integer('count'); + $table->string('ip_address'); + $table->timestamps(); + + $table->index(['user_id', 'ip_address']); + $table->index(['ip_address']); + $table->unique(['user_id', 'ip_address']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_ip_addresses'); + } +}; diff --git a/database/seeders/releases/v11.7.2.json b/database/seeders/releases/v11.7.2.json new file mode 100644 index 000000000..7b3d3205d --- /dev/null +++ b/database/seeders/releases/v11.7.2.json @@ -0,0 +1,31 @@ +{ + "id": 262, + "release_changelog_id": 269, + "version": "v11.7.2", + "title": "Map menu layout improvements on mobile", + "backup_db": 1, + "silent": 0, + "spotlight": 0, + "released": 0, + "created_at": "2024-11-22T11:08:53+00:00", + "updated_at": "2024-11-22T11:08:53+00:00", + "changelog": { + "id": 269, + "release_id": 262, + "description": null, + "changes": [ + { + "release_changelog_id": 269, + "release_changelog_category_id": 1, + "ticket_id": 2620, + "change": "Added support for Cloudflare proxy IPs." + }, + { + "release_changelog_id": 269, + "release_changelog_category_id": 5, + "ticket_id": 2624, + "change": "Map menu on mobile looks a lot better now." + } + ] + } +} diff --git a/resources/assets/js/custom/admin/adminpanelcontrols.js b/resources/assets/js/custom/admin/adminpanelcontrols.js index 81eea03f7..beaa2193d 100644 --- a/resources/assets/js/custom/admin/adminpanelcontrols.js +++ b/resources/assets/js/custom/admin/adminpanelcontrols.js @@ -31,6 +31,11 @@ class AdminPanelControls extends MapControl { this.moveStep = 2; this.map.leafletMap.on('mousemove', function (mouseMoveEvent) { + // When switching between normal mode and mobile mode, the latlng may be undefined + // probably because mobile does not have a mouse + if (mouseMoveEvent.latlng === undefined) { + return; + } let lat = _.round(mouseMoveEvent.latlng.lat, 3); let lng = _.round(mouseMoveEvent.latlng.lng, 3); diff --git a/resources/views/common/layout/header.blade.php b/resources/views/common/layout/header.blade.php index 8a9c63f81..1f4a22093 100644 --- a/resources/views/common/layout/header.blade.php +++ b/resources/views/common/layout/header.blade.php @@ -130,7 +130,7 @@ class="navbar navbar-second fixed-top navbar-expand-lg {{ $theme === 'lux' ? 'na data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> {{ $headerText }} -