diff --git a/.env.example b/.env.example index e745063..cdc4fa4 100644 --- a/.env.example +++ b/.env.example @@ -3,14 +3,12 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_TIMEZONE=UTC -APP_URL=http://localhost - +APP_URL=http://forge.test APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US -APP_MAINTENANCE_DRIVER=file -APP_MAINTENANCE_STORE=database +VITE_APP_NAME="${APP_NAME}" # Much higher in production. BCRYPT_ROUNDS=12 @@ -21,23 +19,25 @@ LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=mysql -DB_HOST=127.0.0.1 +DB_HOST=localhost DB_PORT=3306 -DB_DATABASE=laravel +DB_DATABASE=forge DB_USERNAME=root DB_PASSWORD= -# This is only needed if you are running the app:import-woltlab-data command. +# This is only needed if you are running the app:import-hub command. # For normal development you should just seed the database with fake data: # `php artisan migrate:fresh --seed` -DB_WOLTLAB_CONNECTION=mysql -DB_WOLTLAB_HOST=127.0.0.1 -DB_WOLTLAB_PORT=3306 -DB_WOLTLAB_DATABASE=laravel -DB_WOLTLAB_USERNAME=root -DB_WOLTLAB_PASSWORD= +DB_HUB_CONNECTION=mysql +DB_HUB_HOST=localhost +DB_HUB_PORT=3306 +DB_HUB_DATABASE=forge +DB_HUB_USERNAME=root +DB_HUB_PASSWORD= -SESSION_DRIVER=database +SESSION_DRIVER=redis +SESSION_STORE=redis +SESSION_CONNECTION=default SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -45,36 +45,30 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database -CACHE_STORE=database -CACHE_PREFIX= +CACHE_STORE=redis +QUEUE_CONNECTION=redis -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 +REDIS_CACHE_CONNECTION=cache +REDIS_QUEUE_CONNECTION=queue +REDIS_QUEUE=queue SCOUT_DRIVER=meilisearch +SCOUT_QUEUE=true + MEILISEARCH_HOST=http://127.0.0.1:7700 -MEILISEARCH_KEY=FORGE +MEILISEARCH_KEY=LARAVEL-HERD MEILISEARCH_NO_ANALYTICS=true -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_HOST=127.0.0.1 MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null +MAIL_USERNAME=${APP_NAME} MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="no-reply@sp-tarkov.com" MAIL_FROM_NAME="${APP_NAME}" -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" +NOVA_LICENSE_KEY= diff --git a/app/Console/Commands/ImportHub.php b/app/Console/Commands/ImportHub.php new file mode 100644 index 0000000..7c8cb45 --- /dev/null +++ b/app/Console/Commands/ImportHub.php @@ -0,0 +1,487 @@ +newLine(); + + $totalTime = Benchmark::value(function () { + $loadDataTime = Benchmark::value(function () { + $this->loadData(); + }); + $this->info('Execution time: '.round($loadDataTime[1], 2).'ms'); + $this->newLine(); + + $importUsersTime = Benchmark::value(function () { + $this->importUsers(); + }); + $this->info('Execution time: '.round($importUsersTime[1], 2).'ms'); + $this->newLine(); + + $importLicensesTime = Benchmark::value(function () { + $this->importLicenses(); + }); + $this->info('Execution time: '.round($importLicensesTime[1], 2).'ms'); + $this->newLine(); + + $importSptVersionsTime = Benchmark::value(function () { + $this->importSptVersions(); + }); + $this->info('Execution time: '.round($importSptVersionsTime[1], 2).'ms'); + $this->newLine(); + + $importModsTime = Benchmark::value(function () { + $this->importMods(); + }); + $this->info('Execution time: '.round($importModsTime[1], 2).'ms'); + $this->newLine(); + + $importModVersionsTime = Benchmark::value(function () { + $this->importModVersions(); + }); + $this->info('Execution time: '.round($importModVersionsTime[1], 2).'ms'); + $this->newLine(); + }); + + // Disconnect from the Hub database, clearing temporary tables. + DB::connection('mysql_hub')->disconnect(); + + $this->newLine(); + $this->info('Data imported successfully'); + $this->info('Total execution time: '.round($totalTime[1], 2).'ms'); + + $this->newLine(); + $this->info('Refreshing Meilisearch indexes...'); + $this->call('scout:delete-all-indexes'); + $this->call('scout:sync-index-settings'); + $this->call('scout:import', ['model' => '\App\Models\Mod']); + + $this->newLine(); + $this->info('Done'); + } + + protected function loadData(): void + { + // We're just going to dump a few things in memory to escape the N+1 problem. + $this->output->write('Loading data into memory... '); + $this->bringFileOptionsLocal(); + $this->bringFileContentLocal(); + $this->bringFileVersionLabelsLocal(); + $this->bringFileVersionContentLocal(); + $this->info('Done.'); + } + + protected function importUsers(): void + { + $totalInserted = 0; + + foreach (DB::connection('mysql_hub')->table('wcf1_user')->orderBy('userID')->cursor() as $wolt) { + $registrationDate = Carbon::parse($wolt->registrationDate, 'UTC'); + if ($registrationDate->isFuture()) { + $registrationDate = now('UTC'); + } + $registrationDate->setTimezone('UTC'); + + $insertData = [ + 'hub_id' => $wolt->userID, + 'name' => $wolt->username, + 'email' => mb_convert_case($wolt->email, MB_CASE_LOWER, 'UTF-8'), + 'password' => $this->cleanPasswordHash($wolt->password), + 'created_at' => $registrationDate, + 'updated_at' => now('UTC')->toDateTimeString(), + ]; + + User::upsert($insertData, ['hub_id'], ['name', 'email', 'password', 'created_at', 'updated_at']); + $totalInserted++; + + // Log every 2500 users processed + if ($totalInserted % 2500 == 0) { + $this->line('Processed 2500 users. Total processed so far: '.$totalInserted); + } + } + + $this->info('Total users processed: '.$totalInserted); + } + + protected function cleanPasswordHash(string $password): string + { + // The hub passwords are hashed sometimes with a prefix of the hash type. We only want the hash. + // If it's not Bcrypt, they'll have to reset their password. Tough luck. + return str_replace(['Bcrypt:', 'cryptMD5:', 'cryptMD5::'], '', $password); + } + + protected function importLicenses(): void + { + $totalInserted = 0; + + DB::connection('mysql_hub') + ->table('filebase1_license') + ->chunkById(100, function (Collection $licenses) use (&$totalInserted) { + $insertData = []; + foreach ($licenses as $license) { + $insertData[] = [ + 'hub_id' => $license->licenseID, + 'name' => $license->licenseName, + 'link' => $license->licenseURL, + ]; + } + + if (! empty($insertData)) { + DB::table('licenses')->upsert($insertData, ['hub_id'], ['name', 'link']); + $totalInserted += count($insertData); + $this->line('Processed '.count($insertData).' licenses. Total processed so far: '.$totalInserted); + } + + unset($insertData); + unset($licenses); + }, 'licenseID'); + + $this->info('Total licenses processed: '.$totalInserted); + } + + protected function importSptVersions(): void + { + $totalInserted = 0; + + DB::connection('mysql_hub') + ->table('wcf1_label') + ->where('groupID', 1) + ->chunkById(100, function (Collection $versions) use (&$totalInserted) { + $insertData = []; + foreach ($versions as $version) { + $insertData[] = [ + 'hub_id' => $version->labelID, + 'version' => $version->label, + 'color_class' => $this->translateColour($version->cssClassName), + ]; + } + + if (! empty($insertData)) { + DB::table('spt_versions')->upsert($insertData, ['hub_id'], ['version', 'color_class']); + $totalInserted += count($insertData); + $this->line('Processed '.count($insertData).' SPT Versions. Total processed so far: '.$totalInserted); + } + + unset($insertData); + unset($versions); + }, 'labelID'); + + $this->info('Total licenses processed: '.$totalInserted); + } + + protected function translateColour(string $colour = ''): string + { + return match ($colour) { + 'green' => 'green', + 'slightly-outdated' => 'lime', + 'yellow' => 'yellow', + 'red' => 'red', + default => 'gray', + }; + } + + protected function importMods(): void + { + $command = $this; + $totalInserted = 0; + + $curl = curl_init(); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); + + DB::connection('mysql_hub') + ->table('filebase1_file') + ->chunkById(100, function (Collection $mods) use (&$command, &$curl, &$totalInserted) { + + foreach ($mods as $mod) { + + $modContent = DB::table('temp_file_content') + ->where('fileID', $mod->fileID) + ->orderBy('fileID', 'desc') + ->first(); + + $optionSourceCode = DB::table('temp_file_option_values') + ->select('optionValue as source_code_link') + ->where('fileID', $mod->fileID) + ->whereIn('optionID', [5, 1]) + ->whereNot('optionValue', '') + ->orderByDesc('optionID') + ->first(); + + $optionContainsAi = DB::table('temp_file_option_values') + ->select('optionValue as contains_ai') + ->where('fileID', $mod->fileID) + ->where('optionID', 7) + ->whereNot('optionValue', '') + ->first(); + + $optionContainsAds = DB::table('temp_file_option_values') + ->select('optionValue as contains_ads') + ->where('fileID', $mod->fileID) + ->where('optionID', 3) + ->whereNot('optionValue', '') + ->first(); + + $versionLabel = DB::table('temp_file_version_labels') + ->select('labelID') + ->where('objectID', $mod->fileID) + ->orderBy('labelID', 'desc') + ->first(); + + if (empty($versionLabel)) { + continue; + } + + $insertData[] = [ + 'hub_id' => (int) $mod->fileID, + 'user_id' => User::whereHubId($mod->userID)->value('id'), + 'name' => $modContent?->subject ?? '', + 'slug' => Str::slug($modContent?->subject) ?? '', + 'teaser' => Str::limit($modContent?->teaser) ?? '', + 'description' => $this->convertModDescription($modContent?->message ?? ''), + 'thumbnail' => $this->fetchModThumbnail($command, $curl, $mod->fileID, $mod->iconHash, $mod->iconExtension), + 'license_id' => License::whereHubId($mod->licenseID)->value('id'), + 'source_code_link' => $optionSourceCode?->source_code_link ?? '', + 'featured' => (bool) $mod->isFeatured, + 'contains_ai_content' => (bool) $optionContainsAi?->contains_ai ?? false, + 'contains_ads' => (bool) $optionContainsAds?->contains_ads ?? false, + 'disabled' => (bool) $mod->isDisabled, + 'created_at' => Carbon::parse($mod->time, 'UTC'), + 'updated_at' => Carbon::parse($mod->lastChangeTime, 'UTC'), + ]; + } + + if (! empty($insertData)) { + Mod::upsert($insertData, ['hub_id'], ['user_id', 'name', 'slug', 'teaser', 'description', 'thumbnail', 'license_id', 'source_code_link', 'featured', 'contains_ai_content', 'disabled', 'created_at', 'updated_at']); + $totalInserted += count($insertData); + $command->line('Processed '.count($insertData).' mods. Total processed so far: '.$totalInserted); + } + + unset($insertData); + unset($mods); + }, 'fileID'); + + curl_close($curl); + + $this->info('Total mods processed: '.$totalInserted); + } + + protected function bringFileOptionsLocal(): void + { + DB::statement('CREATE TEMPORARY TABLE temp_file_option_values ( + fileID INT, + optionID INT, + optionValue VARCHAR(255) + )'); + + DB::connection('mysql_hub') + ->table('filebase1_file_option_value') + ->orderBy('fileID') + ->chunk(200, function ($options) { + foreach ($options as $option) { + DB::table('temp_file_option_values')->insert([ + 'fileID' => $option->fileID, + 'optionID' => $option->optionID, + 'optionValue' => $option->optionValue, + ]); + } + }); + } + + protected function bringFileContentLocal(): void + { + DB::statement('CREATE TEMPORARY TABLE temp_file_content ( + fileID INT, + subject VARCHAR(255), + teaser VARCHAR(255), + message LONGTEXT + )'); + + DB::connection('mysql_hub') + ->table('filebase1_file_content') + ->orderBy('fileID') + ->chunk(200, function ($contents) { + foreach ($contents as $content) { + DB::table('temp_file_content')->insert([ + 'fileID' => $content->fileID, + 'subject' => $content->subject, + 'teaser' => $content->teaser, + 'message' => $content->message, + ]); + } + }); + } + + protected function fetchModThumbnail($command, $curl, string $fileID, string $thumbnailHash, string $thumbnailExtension): string + { + if (empty($fileID) || empty($thumbnailHash) || empty($thumbnailExtension)) { + return ''; + } + + // Only the first two characters of the icon hash. + $hashShort = substr($thumbnailHash, 0, 2); + + $hubUrl = "https://hub.sp-tarkov.com/files/images/file/$hashShort/$fileID.$thumbnailExtension"; + $localPath = "mods/$thumbnailHash.$thumbnailExtension"; + + // Check to make sure the image doesn't already exist. + if (Storage::disk('public')->exists($localPath)) { + return "/storage/$localPath"; + } + + $command->output->write("Downloading mod thumbnail: $hubUrl... "); + curl_setopt($curl, CURLOPT_URL, $hubUrl); + $image = curl_exec($curl); + if ($image === false) { + $command->error('Error: '.curl_error($curl)); + } else { + Storage::disk('public')->put($localPath, $image); + $command->info('Done.'); + } + + // Return the path to the saved thumbnail. + return "/storage/$localPath"; + } + + protected function importModVersions(): void + { + $command = $this; + $totalInserted = 0; + + DB::connection('mysql_hub') + ->table('filebase1_file_version') + ->chunkById(500, function (Collection $versions) use (&$command, &$totalInserted) { + + foreach ($versions as $version) { + + $versionContent = DB::table('temp_file_version_content') + ->select('description') + ->where('versionID', $version->versionID) + ->orderBy('versionID', 'desc') + ->first(); + + $optionVirusTotal = DB::table('temp_file_option_values') + ->select('optionValue as virus_total_link') + ->where('fileID', $version->fileID) + ->whereIn('optionID', [6, 2]) + ->whereNot('optionValue', '') + ->orderByDesc('optionID') + ->first(); + + $versionLabel = DB::table('temp_file_version_labels') + ->select('labelID') + ->where('objectID', $version->fileID) + ->orderBy('labelID', 'desc') + ->first(); + + $modId = Mod::whereHubId($version->fileID)->value('id'); + + if (empty($versionLabel) || empty($modId)) { + continue; + } + + $insertData[] = [ + 'hub_id' => $version->versionID, + 'mod_id' => $modId, + 'version' => $version->versionNumber, + 'description' => $this->convertModDescription($versionContent->description ?? ''), + 'link' => $version->downloadURL, + 'spt_version_id' => SptVersion::whereHubId($versionLabel->labelID)->value('id'), + 'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '', + 'downloads' => max((int) $version->downloads, 0), // Ensure the value is at least 0 + 'disabled' => (bool) $version->isDisabled, + 'created_at' => Carbon::parse($version->uploadTime, 'UTC'), + 'updated_at' => Carbon::parse($version->uploadTime, 'UTC'), + ]; + } + + if (! empty($insertData)) { + ModVersion::upsert($insertData, ['hub_id'], ['mod_id', 'version', 'description', 'link', 'spt_version_id', 'virus_total_link', 'downloads', 'created_at', 'updated_at']); + $totalInserted += count($insertData); + $command->line('Processed '.count($insertData).' mod versions. Total processed so far: '.$totalInserted); + } + + unset($insertData); + unset($version); + }, 'versionID'); + + $this->info('Total mod versions processed: '.$totalInserted); + } + + protected function bringFileVersionLabelsLocal(): void + { + DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels ( + labelID INT, + objectID INT + )'); + + DB::connection('mysql_hub') + ->table('wcf1_label_object') + ->where('objectTypeID', 387) + ->orderBy('labelID') + ->chunk(200, function ($options) { + foreach ($options as $option) { + DB::table('temp_file_version_labels')->insert([ + 'labelID' => $option->labelID, + 'objectID' => $option->objectID, + ]); + } + }); + } + + protected function bringFileVersionContentLocal(): void + { + DB::statement('CREATE TEMPORARY TABLE temp_file_version_content ( + versionID INT, + description TEXT + )'); + + DB::connection('mysql_hub') + ->table('filebase1_file_version_content') + ->orderBy('versionID') + ->chunk(200, function ($options) { + foreach ($options as $option) { + DB::table('temp_file_version_content')->insert([ + 'versionID' => $option->versionID, + 'description' => $option->description, + ]); + } + }); + } + + protected function convertModDescription(string $description): string + { + // Alright, hear me out... Shut up. + $converter = new HtmlConverter(); + + return $converter->convert(Purify::clean($description)); + } +} diff --git a/app/Console/Commands/ImportWoltlabData.php b/app/Console/Commands/ImportWoltlabData.php deleted file mode 100644 index 588ab1e..0000000 --- a/app/Console/Commands/ImportWoltlabData.php +++ /dev/null @@ -1,467 +0,0 @@ -newLine(); - - $totalTime = Benchmark::value(function () { - $loadDataTime = Benchmark::value(function () { - $this->loadData(); - }); - $this->info('Execution time: '.round($loadDataTime[1], 2).'ms'); - $this->newLine(); - - $importUsersTime = Benchmark::value(function () { - $this->importUsers(); - }); - $this->info('Execution time: '.round($importUsersTime[1], 2).'ms'); - $this->newLine(); - - $importLicensesTime = Benchmark::value(function () { - $this->importLicenses(); - }); - $this->info('Execution time: '.round($importLicensesTime[1], 2).'ms'); - $this->newLine(); - - $importSptVersionsTime = Benchmark::value(function () { - $this->importSptVersions(); - }); - $this->info('Execution time: '.round($importSptVersionsTime[1], 2).'ms'); - $this->newLine(); - - $importModsTime = Benchmark::value(function () { - $this->importMods(); - }); - $this->info('Execution time: '.round($importModsTime[1], 2).'ms'); - $this->newLine(); - - $importModVersionsTime = Benchmark::value(function () { - $this->importModVersions(); - }); - $this->info('Execution time: '.round($importModVersionsTime[1], 2).'ms'); - $this->newLine(); - }); - - $this->newLine(); - $this->info('Data imported successfully'); - $this->info('Total execution time: '.round($totalTime[1], 2).'ms'); - } - - protected function loadData(): void - { - // We're just going to dump a few things in memory to escape the N+1 problem. - $this->output->write('Loading data into memory... '); - $this->fileOptionValues = $this->getFileOptionValues(); - $this->fileContent = $this->getFileContent(); - $this->fileVersionLabels = $this->getFileVersionLabels(); - $this->fileVersionContent = $this->getFileVersionContent(); - $this->info('Done.'); - } - - protected function importUsers(): void - { - $totalInserted = 0; - - DB::connection('mysql_woltlab')->table('wcf1_user')->chunkById(2500, function (Collection $users) use (&$totalInserted) { - $insertData = []; - foreach ($users as $wolt) { - $registrationDate = Carbon::parse($wolt->registrationDate, 'UTC'); - if ($registrationDate->isFuture()) { - $registrationDate = now('UTC'); - } - $registrationDate->setTimezone('UTC'); - - $insertData[] = [ - 'hub_id' => $wolt->userID, - 'name' => $wolt->username, - 'email' => mb_convert_case($wolt->email, MB_CASE_LOWER, 'UTF-8'), - 'password' => $this->cleanPasswordHash($wolt->password), - 'created_at' => $registrationDate, - 'updated_at' => now('UTC')->toDateTimeString(), - ]; - } - - if (! empty($insertData)) { - User::upsert($insertData, ['hub_id'], ['name', 'email', 'password', 'created_at', 'updated_at']); - $totalInserted += count($insertData); - $this->line('Processed '.count($insertData).' users. Total processed so far: '.$totalInserted); - } - - unset($insertData); - unset($users); - }, 'userID'); - - $this->info('Total users processed: '.$totalInserted); - } - - protected function cleanPasswordHash(string $password): string - { - // The WoltLab password hash sometimes? has a prefix of the hash type. We only want the hash. - return str_replace(['Bcrypt:', 'cryptMD5:', 'cryptMD5::'], '', $password); - } - - protected function importLicenses(): void - { - $totalInserted = 0; - - DB::connection('mysql_woltlab')->table('filebase1_license')->chunkById(100, function (Collection $licenses) use (&$totalInserted) { - $insertData = []; - foreach ($licenses as $license) { - $insertData[] = [ - 'hub_id' => $license->licenseID, - 'name' => $license->licenseName, - 'link' => $license->licenseURL, - ]; - } - - if (! empty($insertData)) { - DB::table('licenses')->upsert($insertData, ['hub_id'], ['name', 'link']); - $totalInserted += count($insertData); - $this->line('Processed '.count($insertData).' licenses. Total processed so far: '.$totalInserted); - } - - unset($insertData); - unset($licenses); - }, 'licenseID'); - - $this->info('Total licenses processed: '.$totalInserted); - } - - protected function importSptVersions(): void - { - $totalInserted = 0; - - DB::connection('mysql_woltlab')->table('wcf1_label')->where('groupID', 1)->chunkById(100, function (Collection $versions) use (&$totalInserted) { - $insertData = []; - foreach ($versions as $version) { - $insertData[] = [ - 'hub_id' => $version->labelID, - 'version' => $version->label, - 'color_class' => $this->translateColour($version->cssClassName), - ]; - } - - if (! empty($insertData)) { - DB::table('spt_versions')->upsert($insertData, ['hub_id'], ['version', 'color_class']); - $totalInserted += count($insertData); - $this->line('Processed '.count($insertData).' SPT Versions. Total processed so far: '.$totalInserted); - } - - unset($insertData); - unset($versions); - }, 'labelID'); - - $this->info('Total licenses processed: '.$totalInserted); - } - - protected function translateColour(string $colour = ''): string - { - return match ($colour) { - 'green' => 'green', - 'slightly-outdated' => 'lime', - 'yellow' => 'yellow', - 'red' => 'red', - default => 'gray', - }; - } - - protected function importMods(): void - { - $command = $this; - $totalInserted = 0; - - $curl = curl_init(); - curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); - - DB::connection('mysql_woltlab')->table('filebase1_file')->chunkById(100, function (Collection $mods) use (&$command, &$curl, &$totalInserted) { - - foreach ($mods as $mod) { - $modContent = $this->fileContent[$mod->fileID] ?? []; - $modOptions = $this->fileOptionValues[$mod->fileID] ?? []; - $versionLabel = $this->fileVersionLabels[$mod->fileID] ?? []; - - if (empty($versionLabel)) { - continue; - } - - $insertData[] = [ - 'hub_id' => (int) $mod->fileID, - 'user_id' => User::whereHubId($mod->userID)->value('id'), - 'name' => $modContent ? $modContent->subject : '', - 'slug' => $modContent ? Str::slug($modContent->subject) : '', - 'teaser' => $modContent ? (strlen($modContent->teaser) > 100 ? Str::take($modContent->teaser, 97).'...' : $modContent->teaser) : '', - 'description' => $this->convertModDescription($modContent?->message ?? ''), - 'thumbnail' => $this->fetchModThumbnail($command, $curl, $mod->fileID, $mod->iconHash, $mod->iconExtension), - 'license_id' => License::whereHubId($mod->licenseID)->value('id'), - 'source_code_link' => $this->fetchSourceLinkValue($modOptions), - 'featured' => (bool) $mod->isFeatured, - 'contains_ai_content' => $this->fetchContainsAiContentValue($modOptions), - 'contains_ads' => $this->fetchContainsAdsValue($modOptions), - 'disabled' => (bool) $mod->isDisabled, - 'created_at' => Carbon::parse($mod->time, 'UTC'), - 'updated_at' => Carbon::parse($mod->lastChangeTime, 'UTC'), - ]; - } - - if (! empty($insertData)) { - Mod::upsert($insertData, ['hub_id'], ['user_id', 'name', 'slug', 'teaser', 'description', 'thumbnail', 'license_id', 'source_code_link', 'featured', 'contains_ai_content', 'disabled', 'created_at', 'updated_at']); - $totalInserted += count($insertData); - $command->line('Processed '.count($insertData).' mods. Total processed so far: '.$totalInserted); - } - - unset($insertData); - unset($mods); - }, 'fileID'); - - curl_close($curl); - - $this->info('Total mods processed: '.$totalInserted); - } - - protected function getFileOptionValues(): array - { - // Fetch all the data from the `filebase1_file_option_value` table. - $options = DB::connection('mysql_woltlab')->table('filebase1_file_option_value')->get(); - - // Convert the collection into an associative array - $optionValues = []; - foreach ($options as $option) { - $optionValues[$option->fileID][] = $option; - } - - return $optionValues; - } - - protected function getFileContent(): array - { - $content = []; - - // Fetch select data from the `filebase1_file_content` table. - DB::connection('mysql_woltlab') - ->table('filebase1_file_content') - ->select(['fileID', 'subject', 'teaser', 'message']) - ->orderBy('fileID', 'desc') - ->chunk(200, function ($contents) use (&$content) { - foreach ($contents as $contentItem) { - $content[$contentItem->fileID] = $contentItem; - } - }); - - return $content; - } - - protected function fetchSourceLinkValue(array $options): string - { - // Iterate over the options and find the 'optionID' of 5 or 1. Those records will contain the source code link - // in the 'optionValue' column. The 'optionID' of 5 should take precedence over 1. If neither are found, return - // an empty string. - foreach ($options as $option) { - if ($option->optionID == 5 && ! empty($option->optionValue)) { - return $option->optionValue; - } - if ($option->optionID == 1 && ! empty($option->optionValue)) { - return $option->optionValue; - } - } - - return ''; - } - - protected function fetchContainsAiContentValue(array $options): bool - { - // Iterate over the options and find the 'optionID' of 7. That record will contain the AI flag. - foreach ($options as $option) { - if ($option->optionID == 7) { - return (bool) $option->optionValue; - } - } - - return false; - } - - protected function fetchContainsAdsValue(array $options): bool - { - // Iterate over the options and find the 'optionID' of 3. That record will contain the Ad flag. - foreach ($options as $option) { - if ($option->optionID == 3) { - return (bool) $option->optionValue; - } - } - - return false; - } - - protected function fetchModThumbnail($command, &$curl, string $fileID, string $thumbnailHash, string $thumbnailExtension): string - { - if (empty($fileID) || empty($thumbnailHash) || empty($thumbnailExtension)) { - return ''; - } - - // Only the first two characters of the icon hash. - $hashShort = substr($thumbnailHash, 0, 2); - - $hubUrl = "https://hub.sp-tarkov.com/files/images/file/$hashShort/$fileID.$thumbnailExtension"; - $localPath = "mods/$thumbnailHash.$thumbnailExtension"; - - // Check to make sure the image doesn't already exist. - if (Storage::disk('public')->exists($localPath)) { - return "/storage/$localPath"; - } - - $command->output->write("Downloading mod thumbnail: $hubUrl... "); - curl_setopt($curl, CURLOPT_URL, $hubUrl); - $image = curl_exec($curl); - if ($image === false) { - $command->error('Error: '.curl_error($curl)); - } else { - Storage::disk('public')->put($localPath, $image); - $command->info('Done.'); - } - - // Return the path to the saved thumbnail. - return "/storage/$localPath"; - } - - protected function getFileVersionContent(): array - { - $content = []; - - // Fetch select data from the `filebase1_file_version_content` table. - DB::connection('mysql_woltlab') - ->table('filebase1_file_version_content') - ->select(['versionID', 'description']) - ->orderBy('versionID', 'desc') - ->chunk(100, function ($contents) use (&$content) { - foreach ($contents as $contentItem) { - $content[$contentItem->versionID] = $content; - } - }); - - return $content; - } - - protected function getFileVersionLabels(): array - { - $labels = []; - - // Fetch select data from the `wcf1_label_object` table. - DB::connection('mysql_woltlab') - ->table('wcf1_label_object') - ->select(['labelID', 'objectID']) - ->where('objectTypeID', 387) - ->orderBy('labelID', 'desc') - ->chunk(100, function ($labelData) use (&$labels) { - foreach ($labelData as $labelItem) { - $labels[$labelItem->objectID] = $labelItem->labelID; - } - }); - - return $labels; - } - - protected function importModVersions(): void - { - $command = $this; - $totalInserted = 0; - - DB::connection('mysql_woltlab')->table('filebase1_file_version')->chunkById(500, function (Collection $versions) use (&$command, &$totalInserted) { - - foreach ($versions as $version) { - $versionContent = $this->fileVersionContent[$version->versionID] ?? []; - $modOptions = $this->fileOptionValues[$version->fileID] ?? []; - $versionLabel = $this->fileVersionLabels[$version->fileID] ?? []; - - $modId = Mod::whereHubId($version->fileID)->value('id'); - - if (empty($versionLabel) || empty($modId)) { - continue; - } - - $insertData[] = [ - 'hub_id' => $version->versionID, - 'mod_id' => $modId, - 'version' => $version->versionNumber, - 'description' => $this->convertModDescription($versionContent['description'] ?? ''), - 'link' => $version->downloadURL, - 'spt_version_id' => SptVersion::whereHubId($versionLabel)->value('id'), - 'virus_total_link' => $this->fetchVirusTotalLink($modOptions), - 'downloads' => max((int) $version->downloads, 0), // Ensure the value is at least 0 - 'disabled' => (bool) $version->isDisabled, - 'created_at' => Carbon::parse($version->uploadTime, 'UTC'), - 'updated_at' => Carbon::parse($version->uploadTime, 'UTC'), - ]; - } - - if (! empty($insertData)) { - ModVersion::upsert($insertData, ['hub_id'], ['mod_id', 'version', 'description', 'link', 'spt_version_id', 'virus_total_link', 'downloads', 'created_at', 'updated_at']); - $totalInserted += count($insertData); - $command->line('Processed '.count($insertData).' mod versions. Total processed so far: '.$totalInserted); - } - - unset($insertData); - unset($version); - }, 'versionID'); - - $this->info('Total mod versions processed: '.$totalInserted); - } - - protected function fetchVirusTotalLink(array $options): string - { - // Iterate over the options and find the 'optionID' of 6 or 2. Those records will contain the Virus Total link - // in the 'optionValue' column. The 'optionID' of 6 should take precedence over 1. If neither are found, return - // an empty string. - foreach ($options as $option) { - if ($option->optionID == 6 && ! empty($option->optionValue)) { - return $option->optionValue; - } - if ($option->optionID == 2 && ! empty($option->optionValue)) { - return $option->optionValue; - } - } - - return ''; - } - - protected function convertModDescription(string $description): string - { - // Alright, hear me out... Shut up. - $converter = new HtmlConverter(); - - return $converter->convert(Purify::clean($description)); - } -} diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index edf3ad8..7ed6ceb 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -12,8 +12,8 @@ class GlobalSearch extends Component public function render(): View { - $results = $this->query ? Mod::search($this->query)->get() : []; - + $results = $this->query ? Mod::search($this->query)->get() : collect(); + return view('livewire.global-search', [ 'results' => $results, ]); diff --git a/app/Models/Mod.php b/app/Models/Mod.php index a7d3cf7..2dca7ef 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -23,6 +23,7 @@ class Mod extends Model 'user_id', 'name', 'slug', + 'teaser', 'description', 'license_id', 'source_code_link', diff --git a/config/database.php b/config/database.php index 450f87e..c457ab1 100644 --- a/config/database.php +++ b/config/database.php @@ -59,6 +59,26 @@ return [ ]) : [], ], + 'mysql_hub' => [ + 'driver' => 'mysql', + 'url' => env('DB_HUB_URL'), + 'host' => env('DB_HUB_HOST', '127.0.0.1'), + 'port' => env('DB_HUB_PORT', '3306'), + 'database' => env('DB_HUB_DATABASE', 'laravel'), + 'username' => env('DB_HUB_USERNAME', 'root'), + 'password' => env('DB_HUB_PASSWORD', ''), + 'unix_socket' => env('DB_HUB_SOCKET', ''), + 'charset' => env('DB_HUB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_HUB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), @@ -108,27 +128,6 @@ return [ // 'encrypt' => env('DB_ENCRYPT', 'yes'), // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), ], - - 'mysql_woltlab' => [ - 'driver' => 'mysql', - 'url' => env('DB_WOLTLAB_URL'), - 'host' => env('DB_WOLTLAB_HOST', '127.0.0.1'), - 'port' => env('DB_WOLTLAB_PORT', '3306'), - 'database' => env('DB_WOLTLAB_DATABASE', 'laravel'), - 'username' => env('DB_WOLTLAB_USERNAME', 'root'), - 'password' => env('DB_WOLTLAB_PASSWORD', ''), - 'unix_socket' => env('DB_WOLTLAB_SOCKET', ''), - 'charset' => env('DB_WOLTLAB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_WOLTLAB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - ], /* @@ -185,6 +184,15 @@ return [ 'database' => env('REDIS_CACHE_DB', '1'), ], + 'queue' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '2'), + ], + ], ]; diff --git a/phpstan.neon b/phpstan.neon index 1905b7b..73a77ce 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,4 +4,4 @@ includes: parameters: paths: - app/ - level: 5 + level: 4 diff --git a/resources/css/app.css b/resources/css/app.css index ed8482f..404993b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -11,6 +11,11 @@ button[type="button"]:not([role="menuitem"]) { @apply border border-transparent rounded-md py-2 px-4 text-white dark:text-gray-100 bg-gray-900 dark:bg-gray-700 hover:bg-gray-800 dark:hover:bg-black dark:hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-600 dark:focus:ring-gray-500 transition-all duration-200 } +nav button[type="submit"]:not([role="menuitem"]), +nav button[type="button"]:not([role="menuitem"]) { + @apply border border-transparent rounded-md py-2 px-4 text-white dark:text-gray-100 bg-gray-900 dark:bg-gray-700 hover:bg-gray-800 dark:hover:bg-gray-900 dark:hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-600 dark:focus:ring-gray-500 transition-all duration-200 +} + input[type="checkbox"] { @apply text-gray-800 dark:text-gray-300 focus:ring-gray-600 dark:focus:ring-gray-500 border-gray-300 dark:border-gray-700 rounded; } @@ -47,39 +52,51 @@ main a:not(.mod-list-component):not(.tab) { b, strong { @apply font-bold; } + i, em { @apply italic } + h1, h2, h3, h4, h5, h6 { @apply font-bold mt-4 mb-2 text-black dark:text-white; } + h1 { @apply text-2xl; } + h2 { @apply text-xl; } + h3 { @apply text-lg; } + h4 { @apply text-base; } + h5 { @apply text-sm; } + h6 { @apply text-xs; } + p { @apply my-2 text-gray-800 dark:text-gray-300; } + ul { @apply list-disc mb-2; } + ol { @apply list-decimal mb-2; } + li { @apply my-2 ml-7 text-gray-800 dark:text-gray-300; } diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php index b9725e6..3d3cb24 100644 --- a/resources/views/components/application-logo.blade.php +++ b/resources/views/components/application-logo.blade.php @@ -1,5 +1 @@ - +{{-- Logo --}} diff --git a/resources/views/components/authentication-card-logo.blade.php b/resources/views/components/authentication-card-logo.blade.php index 515aec0..f611bd0 100644 --- a/resources/views/components/authentication-card-logo.blade.php +++ b/resources/views/components/authentication-card-logo.blade.php @@ -1,11 +1,3 @@ - + {{-- Add the logo here. --}} diff --git a/resources/views/components/responsive-nav-link.blade.php b/resources/views/components/responsive-nav-link.blade.php index 64b4b0e..19e13eb 100644 --- a/resources/views/components/responsive-nav-link.blade.php +++ b/resources/views/components/responsive-nav-link.blade.php @@ -2,7 +2,7 @@ @php $classes = ($active ?? false) - ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 dark:bg-gray-800 focus:outline-none focus:text-indigo-800 dark:focus:text-gray-300 focus:bg-indigo-100 dark:focus:bg-gray-700 focus:border-indigo-700 dark:focus:border-gray-700 transition duration-150 ease-in-out' + ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-grey-400 text-start text-base font-medium text-gray-700 bg-gray-50 dark:bg-gray-800 focus:outline-none focus:text-gray-800 dark:focus:text-gray-300 focus:bg-gray-100 dark:focus:bg-gray-700 focus:border-gray-700 dark:focus:border-gray-700 transition duration-150 ease-in-out' : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-800 dark:focus:text-gray-300 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out'; @endphp diff --git a/resources/views/components/welcome.blade.php b/resources/views/components/welcome.blade.php index 298e56e..3f2ae73 100644 --- a/resources/views/components/welcome.blade.php +++ b/resources/views/components/welcome.blade.php @@ -1,96 +1,5 @@ -
- Laravel Jetstream provides a beautiful, robust starting point for your next Laravel application. Laravel is designed - to help you build your application using a development environment that is simple, powerful, and enjoyable. We believe - you should love expressing your creativity through programming, so we have spent time carefully crafting the Laravel - ecosystem to be a breath of fresh air. We hope you love it. -
-- Laravel has wonderful documentation covering every aspect of the framework. Whether you're new to the framework or have previous experience, we recommend reading all of the documentation from beginning to end. -
- - -- Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process. -
- - -- Laravel Jetstream is built with Tailwind, an amazing utility first CSS framework that doesn't get in your way. You'll be amazed how easily you can build and maintain fresh, modern designs with this wonderful framework at your fingertips. -
-- Authentication and registration views are included with Laravel Jetstream, as well as support for user email verification and resetting forgotten passwords. So, you're free to get started with what matters most: building your application. -
-I don't know why you're here. There's not much to see yet.