diff --git a/.env.example b/.env.example index 12d3601..aa5a23f 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +# Due to the hub import script, only MySQL is supported at this time. DB_CONNECTION=mysql DB_HOST=mysql DB_PORT=3306 diff --git a/.gitignore b/.gitignore index 7fe978f..1891c29 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ yarn-error.log /.fleet /.idea /.vscode +.DS_Store diff --git a/app/Jobs/ImportHubData.php b/app/Jobs/ImportHubData.php index 857ea44..26d1200 100644 --- a/app/Jobs/ImportHubData.php +++ b/app/Jobs/ImportHubData.php @@ -20,8 +20,8 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use League\HTMLToMarkdown\HtmlConverter; use Stevebauman\Purify\Facades\Purify; @@ -33,7 +33,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue public function handle(): void { // Stream some data locally so that we don't have to keep accessing the Hub's database. Use MySQL temporary - // tables to store the data to save on memory; we don't want this to be a memory hog. + // tables to store the data to save on memory; we don't want this to be a hog. $this->bringFileOptionsLocal(); $this->bringFileContentLocal(); $this->bringFileVersionLabelsLocal(); @@ -60,9 +60,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue */ protected function bringFileOptionsLocal(): void { - if (Schema::hasTable('temp_file_option_values')) { - DB::statement('DROP TABLE temp_file_option_values'); - } + DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values'); DB::statement('CREATE TEMPORARY TABLE temp_file_option_values ( fileID INT, optionID INT, @@ -88,9 +86,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue */ protected function bringFileContentLocal(): void { - if (Schema::hasTable('temp_file_content')) { - DB::statement('DROP TABLE temp_file_content'); - } + DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); DB::statement('CREATE TEMPORARY TABLE temp_file_content ( fileID INT, subject VARCHAR(255), @@ -118,9 +114,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue */ protected function bringFileVersionLabelsLocal(): void { - if (Schema::hasTable('temp_file_version_labels')) { - DB::statement('DROP TABLE temp_file_version_labels'); - } + DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels'); DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels ( labelID INT, objectID INT @@ -145,9 +139,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue */ protected function bringFileVersionContentLocal(): void { - if (Schema::hasTable('temp_file_version_content')) { - DB::statement('DROP TABLE temp_file_version_content'); - } + DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content'); DB::statement('CREATE TEMPORARY TABLE temp_file_version_content ( versionID INT, description TEXT @@ -173,10 +165,12 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue { DB::connection('mysql_hub') ->table('wcf1_user') - ->select('userID', 'username', 'email', 'password', 'registrationDate') + ->select('userID', 'username', 'email', 'password', 'registrationDate', 'banned', 'banReason', 'banExpires') ->chunkById(250, function (Collection $users) { $insertData = []; + $bannedUsers = []; + foreach ($users as $user) { $insertData[] = [ 'hub_id' => (int) $user->userID, @@ -186,14 +180,36 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue 'created_at' => $this->cleanRegistrationDate($user->registrationDate), 'updated_at' => now('UTC')->toDateTimeString(), ]; + + // If the user is banned, add them to an array of banned users. + if ($user->banned) { + $bannedUsers[] = [ + 'hub_id' => (int) $user->userID, + 'comment' => $user->banReason ?? '', + 'expired_at' => $this->cleanUnbannedAtDate($user->banExpires), + ]; + } } if (! empty($insertData)) { - DB::table('users')->upsert( - $insertData, - ['hub_id'], - ['name', 'email', 'password', 'created_at', 'updated_at'] - ); + DB::table('users')->upsert($insertData, ['hub_id'], [ + 'name', + 'email', + 'password', + 'created_at', + 'updated_at', + ]); + } + + // Ban the users in the new local database. + if (! empty($bannedUsers)) { + foreach ($bannedUsers as $bannedUser) { + $user = User::whereHubId($bannedUser['hub_id'])->first(); + $user->ban([ + 'comment' => $bannedUser['comment'], + 'expired_at' => $bannedUser['expired_at'], + ]); + } } }, 'userID'); } @@ -207,7 +223,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue // If it's not Bcrypt, they'll have to reset their password. Tough luck. $clean = str_ireplace(['invalid:', 'bcrypt:', 'bcrypt::', 'cryptmd5:', 'cryptmd5::'], '', $password); - // If the password hash starts with $2, it's a valid Bcrypt hash. Otherwise, it's invalid. + // At this point, if the password hash starts with $2, it's a valid Bcrypt hash. Otherwise, it's invalid. return str_starts_with($clean, '$2') ? $clean : ''; } @@ -226,6 +242,46 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue return $date->toDateTimeString(); } + /** + * Clean the banned_at date from the Hub database. + */ + protected function cleanUnbannedAtDate(?string $unbannedAt): ?string + { + // If the input is null, return null + if ($unbannedAt === null) { + return null; + } + + // Explicit check for the Unix epoch start date + if (Str::contains($unbannedAt, '1970-01-01')) { + return null; + } + + // Use validator to check for a valid date format + $validator = Validator::make(['date' => $unbannedAt], [ + 'date' => 'date_format:Y-m-d H:i:s', + ]); + if ($validator->fails()) { + // If the date format is invalid, return null + return null; + } + + // Validate the date using Carbon + try { + $date = Carbon::parse($unbannedAt); + + // Additional check to ensure the date is not a default or zero date + if ($date->year == 0 || $date->month == 0 || $date->day == 0) { + return null; + } + + return $date->toDateTimeString(); + } catch (\Exception $e) { + // If the date is not valid, return null + return null; + } + } + /** * Import the licenses from the Hub database to the local database. */ @@ -512,7 +568,14 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue */ public function failed(Exception $exception): void { - // Disconnect from the 'mysql_hub' database connection + // Drop the temporary tables. + DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values'); + DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); + DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels'); + DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content'); + + // This *should* drop the temporary tables as well, but I like to be sure. DB::connection('mysql_hub')->disconnect(); + DB::disconnect(); } } diff --git a/app/Models/User.php b/app/Models/User.php index cb51c9d..7dd6916 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,9 +11,11 @@ use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Jetstream\HasProfilePhoto; use Laravel\Sanctum\HasApiTokens; use Laravel\Scout\Searchable; +use Mchev\Banhammer\Traits\Bannable; class User extends Authenticatable implements MustVerifyEmail { + use Bannable; use HasApiTokens; use HasFactory; use HasProfilePhoto; @@ -38,14 +40,6 @@ class User extends Authenticatable implements MustVerifyEmail 'profile_photo_url', ]; - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } - public function mods(): HasMany { return $this->hasMany(Mod::class); @@ -63,4 +57,12 @@ class User extends Authenticatable implements MustVerifyEmail { return ! is_null($this->email_verified_at); } + + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index d654276..c2e983d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Mchev\Banhammer\Middleware\IPBanned; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -12,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { - // + $middleware->append(IPBanned::class); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/composer.json b/composer.json index 94878a1..2eeb80d 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "league/flysystem-aws-s3-v3": "3.0", "league/html-to-markdown": "^5.1", "livewire/livewire": "^3.0", + "mchev/banhammer": "^2.3", "meilisearch/meilisearch-php": "^1.8", "stevebauman/purify": "^6.2" }, diff --git a/composer.lock b/composer.lock index f07b22c..cb76441 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "35e09d2f7ca6d5c8de5ed54e14d13e58", + "content-hash": "f9f3d2b898b0a2e762d3c042c4d38f3a", "packages": [ { "name": "aws/aws-crt-php", @@ -3447,6 +3447,74 @@ ], "time": "2024-05-21T13:39:04+00:00" }, + { + "name": "mchev/banhammer", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/mchev/banhammer.git", + "reference": "9821abe9a2be60742b43b880091481c2a113d938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mchev/banhammer/zipball/9821abe9a2be60742b43b880091481c2a113d938", + "reference": "9821abe9a2be60742b43b880091481c2a113d938", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0", + "phpunit/phpunit": "^9.5|^10.5|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Mchev\\Banhammer\\BanhammerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Mchev\\Banhammer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "mchev", + "email": "martin@pegase.io", + "role": "Developer" + } + ], + "description": "Banhammer for Laravel allows you to ban any Model by key and by IP.", + "homepage": "https://github.com/mchev/banhammer", + "keywords": [ + "IP", + "ban", + "bannable", + "bans-for-laravel", + "country", + "laravel", + "mchev" + ], + "support": { + "issues": "https://github.com/mchev/banhammer/issues", + "source": "https://github.com/mchev/banhammer/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/mchev", + "type": "github" + } + ], + "time": "2024-05-24T10:09:58+00:00" + }, { "name": "meilisearch/meilisearch-php", "version": "v1.8.0", diff --git a/config/ban.php b/config/ban.php new file mode 100644 index 0000000..c84b709 --- /dev/null +++ b/config/ban.php @@ -0,0 +1,95 @@ + 'bans', + + /* + |-------------------------------------------------------------------------- + | Model Name + |-------------------------------------------------------------------------- + | + | Specify the model which you want to use as your Ban model. + | + */ + + 'model' => \Mchev\Banhammer\Models\Ban::class, + + /* + |-------------------------------------------------------------------------- + | Where to Redirect Banned Users + |-------------------------------------------------------------------------- + | + | Define the URL to which users will be redirected when attempting to log in + | after being banned. If not defined, the banned user will be redirected to + | the previous page they tried to access. + | + */ + + 'fallback_url' => null, // Examples: null (default), "/oops", "/login" + + /* + |-------------------------------------------------------------------------- + | 403 Message + |-------------------------------------------------------------------------- + | + | The message that will be displayed if no fallback URL is defined for banned users. + | + */ + + 'messages' => [ + 'user' => 'Your account has been banned.', + 'ip' => 'Access from your IP address is restricted.', + 'country' => 'Access from your country is restricted.', + ], + + /* + |-------------------------------------------------------------------------- + | Block by Country + |-------------------------------------------------------------------------- + | + | Determine whether to block users based on their country. This setting uses + | the value of BANHAMMER_BLOCK_BY_COUNTRY from the environment. Enabling this + | feature may result in up to 45 HTTP requests per minute with the free version + | of https://ip-api.com/. + | + */ + + 'block_by_country' => env('BANHAMMER_BLOCK_BY_COUNTRY', false), + + /* + |-------------------------------------------------------------------------- + | List of Blocked Countries + |-------------------------------------------------------------------------- + | + | Specify the countries where users will be blocked if 'block_by_country' is true. + | Add country codes to the array to restrict access from those countries. + | + */ + + 'blocked_countries' => [], // Examples: ['US', 'CA', 'GB', 'FR', 'ES', 'DE'] + + /* + |-------------------------------------------------------------------------- + | Cache Duration for IP Geolocation + |-------------------------------------------------------------------------- + | + | This configuration option determines the duration, in minutes, for which + | the IP geolocation data will be stored in the cache. This helps prevent + | excessive requests and enables the middleware to efficiently determine + | whether to block a request based on the user's country. + | + */ + 'cache_duration' => 120, // Duration in minutes + +]; diff --git a/database/migrations/2024_06_15_000000_create_bans_table.php b/database/migrations/2024_06_15_000000_create_bans_table.php new file mode 100644 index 0000000..99ff97e --- /dev/null +++ b/database/migrations/2024_06_15_000000_create_bans_table.php @@ -0,0 +1,35 @@ +id(); + $table->nullableMorphs('bannable'); + $table->nullableMorphs('created_by'); + $table->text('comment')->nullable(); + $table->string('ip', 45)->nullable(); + $table->timestamp('expired_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index('ip'); + $table->index('expired_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists(config('ban.table')); + } +}; diff --git a/database/migrations/2024_06_15_000001_metas_field_to_bans_table.php b/database/migrations/2024_06_15_000001_metas_field_to_bans_table.php new file mode 100644 index 0000000..67e9b8b --- /dev/null +++ b/database/migrations/2024_06_15_000001_metas_field_to_bans_table.php @@ -0,0 +1,32 @@ +json('metas')->nullable(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn(config('ban.table'), 'metas')) { + Schema::table(config('ban.table'), function (Blueprint $table) { + $table->dropColumn('metas'); + }); + } + } +}; diff --git a/routes/web.php b/routes/web.php index 84dc290..57d33f6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,17 +3,21 @@ use App\Http\Controllers\ModController; use Illuminate\Support\Facades\Route; -Route::get('/', function () { - return view('home'); -})->name('home'); +Route::middleware(['auth.banned'])->group(function () { -Route::controller(ModController::class)->group(function () { - Route::get('/mods', 'index')->name('mods'); - Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show'); -}); + Route::get('/', function () { + return view('home'); + })->name('home'); + + Route::controller(ModController::class)->group(function () { + Route::get('/mods', 'index')->name('mods'); + Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show'); + }); + + Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () { + Route::get('/dashboard', function () { + return view('dashboard'); + })->name('dashboard'); + }); -Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () { - Route::get('/dashboard', function () { - return view('dashboard'); - })->name('dashboard'); });