diff --git a/AGENTS.md b/AGENTS.md index 14cca5d4a..bd5c50e15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ php artisan test --compact --filter=TestName # Run single test (PHPUnit only) ./vendor/bin/pint --dirty # Format changed files php artisan tmux:start # Start processing engine npm run build # Required after frontend changes -php artisan route:cache # Refresh cached routes if new routes seem missing +3php artisan route:cache # Refresh cached routes if new routes seem missing ``` ## Architecture diff --git a/README.md b/README.md index 943fb772b..ff433817f 100755 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This project is a fork of [newznab plus](https://github.com/anth0/nnplus) and [n - Intelligent local caching of metadata (TMDB, TVDB, TVMaze, Trakt, IMDB) - Tmux engine for thread, database, and performance monitoring - Image and video sample support -- Modern frontend stack: Vite, Tailwind CSS, Vue 3 +- Modern frontend stack: Vite, Tailwind CSS, Alpine.js, Livewire - Full-text search via Elasticsearch or ManticoreSearch - Dockerized development via Laravel Sail - RESTful API compatible with newznab standard @@ -39,7 +39,7 @@ This project is a fork of [newznab plus](https://github.com/anth0/nnplus) and [n ## Prerequisites - System administration experience (Linux recommended) -- PHP 8.3+ with extensions: curl, json, pdo_mysql, openssl, mbstring, xml, zip, gd, intl, pcntl +- PHP 8.4+ with extensions: curl, json, pdo_mysql, openssl, mbstring, xml, zip, gd, intl, pcntl - MariaDB 10.6+ or MySQL 8+ (PostgreSQL not supported) - Composer 2.x - Node.js 18+ and npm for frontend assets diff --git a/app/Facades/Elasticsearch.php b/app/Facades/Elasticsearch.php index 0aaffeff5..ab1130cc9 100644 --- a/app/Facades/Elasticsearch.php +++ b/app/Facades/Elasticsearch.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Facades; +use Elasticsearch\Client; use Illuminate\Support\Facades\Facade; -use Mailerlite\LaravelElasticsearch\Manager; /** * Elasticsearch Facade - provides static access to Elasticsearch client. @@ -17,7 +17,7 @@ use Mailerlite\LaravelElasticsearch\Manager; * @method static array get(array $params) * @method static array deleteByQuery(array $params) * - * @see Manager + * @see Client */ class Elasticsearch extends Facade // @phpstan-ignore missingType.iterableValue { diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index f39c674e1..53132a516 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -13,7 +13,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; -use Jrean\UserVerification\Facades\UserVerification; use Spatie\Permission\Models\Role; class AdminUserController extends BasePageController @@ -326,9 +325,11 @@ class AdminUserController extends BasePageController { if ($request->has('id')) { $user = User::find($request->input('id')); - UserVerification::generate($user); + if ($user === null) { + return redirect()->back()->with('error', 'User is invalid'); + } - UserVerification::send($user, 'User email verification required'); + $user->sendEmailVerificationNotification(); return redirect()->back()->with('success', 'Email verification for '.$user->username.' sent'); } @@ -340,9 +341,13 @@ class AdminUserController extends BasePageController { if ($request->has('id')) { $user = User::find($request->input('id')); - User::query()->where('id', $request->input('id'))->update(['verified' => 1, 'email_verified_at' => now()]); + User::query()->where('id', $request->input('id'))->update([ + 'verified' => 1, + 'email_verified_at' => now(), + 'verification_token' => null, + ]); - return redirect()->back()->with('success', 'Email verification for '.$user->username.' sent'); + return redirect()->back()->with('success', 'Email verification for '.$user->username.' completed'); } return redirect()->back()->with('error', 'User is invalid'); diff --git a/app/Http/Controllers/Auth/EmailVerificationController.php b/app/Http/Controllers/Auth/EmailVerificationController.php new file mode 100644 index 000000000..6eb179f64 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationController.php @@ -0,0 +1,58 @@ +user()?->hasVerifiedEmail()) { + return redirect()->route('home'); + } + + return view('auth.verify-email'); + } + + public function verify(Request $request, int $id, string $hash): RedirectResponse + { + $user = User::query()->findOrFail($id); + + if (! hash_equals($hash, sha1($user->getEmailForVerification()))) { + abort(403); + } + + if (! $user->hasVerifiedEmail() && $user->markEmailAsVerified()) { + event(new Verified($user)); + } + + return redirect() + ->route('login') + ->with('success', 'Your email address has been verified. You can now sign in.'); + } + + public function resend(Request $request): RedirectResponse + { + $user = $request->user(); + + if ($user === null) { + return redirect()->route('login'); + } + + if ($user->hasVerifiedEmail()) { + return redirect()->route('home'); + } + + $user->sendEmailVerificationNotification(); + + return back()->with('status', 'resent'); + } +} diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index b592ce884..8b029aadf 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -22,7 +22,6 @@ use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; use Illuminate\Validation\ValidationException; -use Jrean\UserVerification\Traits\VerifiesUsers; use Spatie\Permission\Models\Role; use Throwable; @@ -40,7 +39,6 @@ class RegisterController extends Controller */ use RegistersUsers; - use VerifiesUsers; /** * Where to redirect users after registration. @@ -52,7 +50,7 @@ class RegisterController extends Controller public function __construct( private readonly RegistrationStatusService $registrationStatusService ) { - $this->middleware('guest', ['except' => ['getVerification', 'getVerificationError']]); + $this->middleware('guest'); } /** @@ -69,6 +67,9 @@ class RegisterController extends Controller 'notes' => $data['notes'], 'invites' => $data['defaultinvites'], 'api_token' => md5(Str::random(40)), + 'verified' => false, + 'email_verified_at' => null, + 'verification_token' => null, ]); $role = Role::query()->where('id', '=', $data['roles_id'])->first(); diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 2dd41fbdb..c486469b2 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -23,7 +23,6 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Validator; -use Jrean\UserVerification\Facades\UserVerification; use PragmaRX\Google2FALaravel\Facade as Google2FA; class ProfileController extends BasePageController @@ -203,12 +202,8 @@ class ProfileController extends BasePageController if (! $this->userdata->hasRole('Admin')) { if (! empty($request->input('email')) && $this->userdata->email !== $request->input('email')) { $this->userdata->email = $request->input('email'); - - $verify_user = $this->userdata; - - UserVerification::generate($verify_user); - - UserVerification::send($verify_user, 'User email verification required'); + $this->userdata->resetEmailVerification(); + $this->userdata->sendEmailVerificationNotification(); Auth::logout(); diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php new file mode 100644 index 000000000..214a838fa --- /dev/null +++ b/app/Http/Middleware/TrustProxies.php @@ -0,0 +1,31 @@ +|null + */ + protected $proxies = '*'; + + /** + * The headers used to detect proxy forwarding information. + */ + protected $headers = Request::HEADER_X_FORWARDED_FOR + | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PORT + | Request::HEADER_X_FORWARDED_PROTO + | Request::HEADER_X_FORWARDED_PREFIX + | Request::HEADER_X_FORWARDED_AWS_ELB; +} diff --git a/app/Models/User.php b/app/Models/User.php index e0256b4c4..d6ec1964a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -12,6 +12,9 @@ use App\Jobs\SendAccountWillExpireEmail; use App\Rules\ValidEmailDomain; use App\Services\InvitationService; use Carbon\CarbonImmutable; +use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait; +use Illuminate\Auth\Notifications\VerifyEmail; +use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; @@ -32,7 +35,6 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; -use Jrean\UserVerification\Traits\UserVerification; use Spatie\Permission\Models\Role; use Spatie\Permission\Traits\HasRoles; @@ -117,13 +119,13 @@ use Spatie\Permission\Traits\HasRoles; * @method static Builder|User expiringSoon(int $days = 7) * @method static Builder|User expired() */ -final class User extends Authenticatable +final class User extends Authenticatable implements MustVerifyEmailContract { use HasFactory; // @phpstan-ignore missingType.generics use HasRoles; + use MustVerifyEmailTrait; use Notifiable; use SoftDeletes; - use UserVerification; /** * @var list @@ -345,6 +347,62 @@ final class User extends Authenticatable return $query->where('verified', true); } + /** + * Determine if the user has verified their email address. + */ + public function hasVerifiedEmail(): bool + { + return $this->verified || $this->email_verified_at !== null; + } + + /** + * Mark the user's email as verified and keep legacy columns in sync. + */ + public function markEmailAsVerified(): bool + { + return $this->forceFill([ + 'email_verified_at' => now(), + 'verified' => true, + 'verification_token' => null, + ])->save(); + } + + /** + * Reset email verification state, typically after an email change. + */ + public function resetEmailVerification(): bool + { + return $this->forceFill([ + 'email_verified_at' => null, + 'verified' => false, + 'verification_token' => null, + ])->save(); + } + + /** + * Send the standard Laravel email verification notification. + */ + public function sendEmailVerificationNotification(): void + { + $this->notify(new VerifyEmail); + } + + /** + * Legacy compatibility helper used by existing login flows. + */ + public function isVerified(): bool + { + return $this->hasVerifiedEmail(); + } + + /** + * Legacy compatibility helper used by existing login flows. + */ + public function isPendingVerification(): bool + { + return ! $this->hasVerifiedEmail(); + } + /** * Scope to filter users by role. */ diff --git a/app/Observers/UserServiceObserver.php b/app/Observers/UserServiceObserver.php index 5975f851a..4deb2e8f1 100644 --- a/app/Observers/UserServiceObserver.php +++ b/app/Observers/UserServiceObserver.php @@ -9,17 +9,12 @@ use App\Jobs\SendWelcomeEmail; use App\Models\User; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; -use Jrean\UserVerification\Exceptions\ModelNotCompliantException; -use Jrean\UserVerification\Facades\UserVerification; use Spatie\Permission\Models\Role; class UserServiceObserver { /** * Handle the user "created" event. - * - * - * @throws ModelNotCompliantException */ public function created(User $user): void { @@ -36,9 +31,7 @@ class UserServiceObserver if (! empty(config('mail.from.address') && File::isFile(base_path().'/_install/install.lock'))) { SendNewRegisteredAccountMail::dispatch($user)->onQueue('newreg'); SendWelcomeEmail::dispatch($user)->onQueue('welcomeemails'); - UserVerification::generate($user); - - UserVerification::send($user, 'User email verification required', config('mail.from.address')); + $user->sendEmailVerificationNotification(); } } } diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php index 21da2c29d..cd23bdd94 100644 --- a/app/Providers/SearchServiceProvider.php +++ b/app/Providers/SearchServiceProvider.php @@ -10,6 +10,8 @@ use App\Services\Search\Drivers\ElasticSearchDriver; use App\Services\Search\Drivers\ManticoreSearchDriver; use App\Services\Search\MediaSearchService; use App\Services\Search\SearchService; +use Elasticsearch\Client; +use Elasticsearch\ClientBuilder; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -59,6 +61,15 @@ class SearchServiceProvider extends ServiceProvider return new ElasticSearchDriver($config); }); + $this->app->singleton('elasticsearch', function (): Client { + $config = config('search.drivers.elasticsearch', []); + $clientBuilder = ClientBuilder::create(); + $clientBuilder->setHosts($config['hosts'] ?? []); + $clientBuilder->setRetries($config['retries'] ?? 2); + + return $clientBuilder->build(); + }); + // Register aliases (using prefixed names to avoid conflicts with other packages) $this->app->alias(SearchService::class, 'search'); $this->app->alias(ManticoreSearchDriver::class, 'search.manticore'); diff --git a/app/Rules/RecaptchaRule.php b/app/Rules/RecaptchaRule.php new file mode 100644 index 000000000..27a6fa716 --- /dev/null +++ b/app/Rules/RecaptchaRule.php @@ -0,0 +1,30 @@ +echoOutput = config('nntmux.echocli'); $this->imageService = $imageService ?? new ReleaseImageService; + $this->igdbService = $igdbService ?? new IGDBService; - $this->pubkey = Settings::settingValue('amazonpubkey'); - $this->privkey = Settings::settingValue('amazonprivkey'); - $this->asstag = Settings::settingValue('amazonassociatetag'); $this->gameQty = (Settings::settingValue('maxgamesprocessed') !== '') ? (int) Settings::settingValue('maxgamesprocessed') : 150; - $this->sleepTime = (Settings::settingValue('amazonsleep') !== '') ? (int) Settings::settingValue('amazonsleep') : 1000; + $this->lookupThrottleMs = (Settings::settingValue('amazonsleep') !== '') ? (int) Settings::settingValue('amazonsleep') : 1000; $this->imgSavePath = config('nntmux_settings.covers_path').'/console/'; $this->renamed = (int) Settings::settingValue('lookupgames') === 2; @@ -369,7 +356,7 @@ class ConsoleService { $consoleId = self::CONS_NTFND; - $igdb = $this->fetchIGDBProperties($gameInfo['title'], $gameInfo['node']); + $igdb = $this->fetchIGDBProperties($gameInfo['title'], $gameInfo['platform']); if ($igdb !== false) { if ($igdb['coverurl'] !== '') { $igdb['cover'] = 1; @@ -402,99 +389,32 @@ class ConsoleService */ public function fetchIGDBProperties(string $gameInfo, string $gamePlatform): bool|array|\StdClass { - $bestMatch = false; - $gamePlatform = $this->replacePlatform($gamePlatform); - if (config('config.credentials.client_id') !== '' && config('config.credentials.client_secret') !== '') { - try { - $result = Game::where('name', $gameInfo)->get(); - if (! empty($result)) { - $bestMatchPct = 0; - foreach ($result as $res) { - similar_text(strtolower($gameInfo), strtolower($res->name), $percent); - if ($percent >= 90 && $percent > $bestMatchPct) { - $bestMatch = $res->id; - $bestMatchPct = $percent; - } - } - - if ($bestMatch !== false) { - $game = Game::with([ - 'cover' => ['url'], - 'screenshots' => ['url'], - 'involved_companies' => ['company', 'publisher'], - 'themes', - ])->where('id', $bestMatch)->first(); - - $publishers = []; - if (! empty($game->involved_companies)) { - foreach ($game->involved_companies as $publisher) { - if ($publisher['publisher'] === true) { - $company = Company::find($publisher['company']); - $publishers[] = $company['name']; - } - } - } - - $genres = []; - if (! empty($game->themes)) { - foreach ($game->themes as $theme) { - $genres[] = $theme['name']; - } - } - - $genreKey = $this->getGenreKey(implode(',', $genres)); - - $platform = ''; - if (! empty($game->platforms)) { - foreach ($game->platforms as $platforms) { - $percentCurrent = 0; - $gamePlatforms = Platform::where('id', $platforms)->get(); - foreach ($gamePlatforms as $gamePlat) { - similar_text($gamePlat['name'], $gamePlatform, $percent); - if ($percent >= 85 && $percent > $percentCurrent) { - $percentCurrent = $percent; - $platform = $gamePlat['name']; - break; - } - } - } - } - - return [ - 'title' => $game->name, - 'asin' => (string) $game->id, - 'review' => $game->summary ?? '', - 'coverurl' => ! empty($game->cover->url) ? 'https:'.$game->cover->url : '', - 'releasedate' => ! empty($game->first_release_date) ? $game->first_release_date->format('Y-m-d') : now()->format('Y-m-d'), - 'esrb' => ! empty($game->aggregated_rating) ? round($game->aggregated_rating).'%' : 'Not Rated', - 'url' => $game->url ?? '', - 'publisher' => ! empty($publishers) ? implode(',', $publishers) : 'Unknown', - 'platform' => $platform ?? '', - 'consolegenre' => ! empty($genres) ? implode(',', $genres) : 'Unknown', - 'consolegenreid' => $genreKey, - 'salesrank' => '', - ]; - } - - cli()->notice('IGDB returned no valid results'); - - return false; - } + if (! $this->igdbService->isConfigured()) { + return false; + } + try { + $game = $this->igdbService->searchConsole($gameInfo, $gamePlatform); + if ($game === null) { cli()->notice('IGDB found no valid results'); return false; - } catch (ClientException $e) { - if ($e->getCode() === 429) { - $this->igdbSleep = now()->endOfMonth(); - } - } catch (\Exception $e) { - cli()->error('Error fetching IGDB properties: '.$e->getMessage()); + } + $igdb = $this->igdbService->buildConsoleData($game, $gamePlatform); + $igdb['consolegenreid'] = $this->getGenreKey($igdb['consolegenre']); + + return $igdb; + } catch (IgdbHttpException $e) { + if ($e->getStatusCode() === 429) { return false; } + } catch (\Exception $e) { + cli()->error('Error fetching IGDB properties: '.$e->getMessage()); + + return false; } return false; @@ -530,7 +450,7 @@ class ConsoleService foreach ($res as $arr) { $startTime = now()->timestamp; - $usedAmazon = false; + $usedExternalLookup = false; $gameId = self::CONS_NTFND; $gameInfo = $this->parseTitle($arr['searchname']); @@ -550,7 +470,7 @@ class ConsoleService $gameId = -2; } elseif ($gameCheck === false) { $gameId = $this->updateConsoleInfo($gameInfo); - $usedAmazon = true; + $usedExternalLookup = true; if ($gameId === self::CONS_NTFND) { $this->failCache[] = $gameInfo['title'].$gameInfo['platform']; } @@ -568,10 +488,10 @@ class ConsoleService // Update release. Release::query()->where('id', $arr['id'])->update(['consoleinfo_id' => $gameId]); - // Sleep to not flood amazon. + // Throttle external lookups using the legacy amazonsleep setting. $diff = floor((now()->timestamp - $startTime) * 1000000); - if ($this->sleepTime * 1000 - $diff > 0 && $usedAmazon === true) { - usleep((int) ($this->sleepTime * 1000 - $diff)); + if ($this->lookupThrottleMs * 1000 - $diff > 0 && $usedExternalLookup === true) { + usleep((int) ($this->lookupThrottleMs * 1000 - $diff)); } } } elseif ($this->echoOutput) { @@ -637,9 +557,7 @@ class ConsoleService $platform = 'XBOX360'; } - $browseNode = $this->getBrowseNode($platform); $result['platform'] = $platform; - $result['node'] = $browseNode; } $result['release'] = $releaseName; @@ -649,11 +567,11 @@ class ConsoleService } // ======================================== - // Platform/Node Methods + // Platform Methods // ======================================== /** - * Replace platform name to Amazon equivalent. + * Normalize a parsed release platform name to the external lookup equivalent. */ public function replacePlatform(string $platform): string { @@ -678,64 +596,6 @@ class ConsoleService }; } - /** - * Get Amazon browse node ID for platform. - */ - public function getBrowseNode(string $platform): string - { - return match ($platform) { - 'PS2' => '301712', - 'PS3' => '14210751', - 'PS4' => '6427814011', - 'PSP' => '11075221', - 'PSVITA' => '3010556011', - 'PSX' => '294940', - 'WII', 'Wii' => '14218901', - 'WIIU', 'WiiU' => '3075112011', - 'XBOX360', 'X360' => '14220161', - 'XBOXONE' => '6469269011', - 'XBOX', 'X-BOX' => '537504', - 'NDS' => '11075831', - '3DS' => '2622269011', - 'GC', 'NGC' => '541022', - 'N64' => '229763', - 'SNES' => '294945', - 'NES' => '566458', - default => '468642', - }; - } - - // ======================================== - // Genre Methods - // ======================================== - - /** - * Match browse node to genre name. - */ - public function matchBrowseNode(string $nodeName): bool|string - { - $str = match ($nodeName) { - 'Action_shooter', 'Action_Games', 'Action_games' => 'Action', - 'Action/Adventure', 'Action\\Adventure', 'Adventure_games' => 'Adventure', - 'Boxing_games', 'Sports_games' => 'Sports', - 'Fantasy_action_games' => 'Fantasy', - 'Fighting_action_games' => 'Fighting', - 'Flying_simulation_games' => 'Flying', - 'Horror_action_games' => 'Horror', - 'Kids & Family' => 'Family', - 'Role_playing_games' => 'Role-Playing', - 'Shooter_action_games' => 'Shooter', - 'Singing_games' => 'Music', - 'Action', 'Adventure', 'Arcade', 'Board Games', 'Cards', 'Casino', - 'Collections', 'Family', 'Fantasy', 'Fighting', 'Flying', 'Horror', - 'Music', 'Puzzle', 'Racing', 'Rhythm', 'Role-Playing', 'Simulation', - 'Shooter', 'Shooting', 'Sports', 'Strategy', 'Trivia' => $nodeName, - default => '', - }; - - return ($str !== '') ? $str : false; - } - // ======================================== // Protected Helper Methods // ======================================== diff --git a/app/Services/GamesService.php b/app/Services/GamesService.php index e755e1ce5..a3a9b7c84 100644 --- a/app/Services/GamesService.php +++ b/app/Services/GamesService.php @@ -9,8 +9,8 @@ use App\Models\GamesInfo; use App\Models\Genre; use App\Models\Release; use App\Models\Settings; +use App\Services\IGDB\Exceptions\IgdbHttpException; use App\Services\Releases\ReleaseBrowseService; -use GuzzleHttp\Exception\ClientException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -69,8 +69,6 @@ class GamesService protected string $_classUsed = ''; - protected mixed $igdbSleep; - /** * @throws \Exception */ @@ -461,9 +459,8 @@ class GamesService return false; } } - } catch (ClientException $e) { - if ($e->getCode() === 429) { - $this->igdbSleep = now()->endOfMonth(); + } catch (IgdbHttpException $e) { + if ($e->getStatusCode() === 429) { Log::warning('GamesService: IGDB rate limit exceeded'); } } diff --git a/app/Services/IGDB/Client.php b/app/Services/IGDB/Client.php new file mode 100644 index 000000000..f3187ffa0 --- /dev/null +++ b/app/Services/IGDB/Client.php @@ -0,0 +1,108 @@ +> + */ + public function request(string $endpoint, string $query): array + { + $cacheLifetime = max(0, (int) config('igdb.cache_lifetime', 86400)); + $cacheKey = 'igdb:query:'.md5($endpoint.'|'.$query); + + if ($cacheLifetime > 0) { + /** @var array>|null $cached */ + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + } + + $response = Http::baseUrl(rtrim((string) config('igdb.base_url', 'https://api.igdb.com/v4'), '/')) + ->withHeaders([ + 'Accept' => 'application/json', + 'Client-ID' => $this->getClientId(), + ]) + ->withToken($this->getAccessToken()) + ->withBody($query, 'text/plain') + ->timeout(30) + ->post(ltrim($endpoint, '/')); + + if ($response->failed()) { + throw new IgdbHttpException( + message: 'IGDB request failed with status '.$response->status(), + statusCode: $response->status(), + responseBody: $response->body(), + ); + } + + /** @var array> $payload */ + $payload = $response->json() ?? []; + + if ($cacheLifetime > 0) { + Cache::put($cacheKey, $payload, $cacheLifetime); + } + + return $payload; + } + + protected function getClientId(): string + { + $clientId = (string) config('igdb.credentials.client_id', ''); + + if ($clientId === '') { + throw new IgdbHttpException('IGDB client ID is not configured.', 0); + } + + return $clientId; + } + + protected function getAccessToken(): string + { + $cachedToken = Cache::get(self::TOKEN_CACHE_KEY); + if (is_string($cachedToken) && $cachedToken !== '') { + return $cachedToken; + } + + $clientSecret = (string) config('igdb.credentials.client_secret', ''); + if ($clientSecret === '') { + throw new IgdbHttpException('IGDB client secret is not configured.', 0); + } + + $response = Http::asForm() + ->timeout(15) + ->post((string) config('igdb.token_url', 'https://id.twitch.tv/oauth2/token'), [ + 'client_id' => $this->getClientId(), + 'client_secret' => $clientSecret, + 'grant_type' => 'client_credentials', + ]); + + if ($response->failed()) { + throw new IgdbHttpException( + message: 'Unable to authenticate with Twitch for IGDB access.', + statusCode: $response->status(), + responseBody: $response->body(), + ); + } + + $token = (string) $response->json('access_token', ''); + if ($token === '') { + throw new IgdbHttpException('Twitch authentication response did not include an access token.', 0, $response->body()); + } + + $expiresIn = max(60, ((int) $response->json('expires_in', 3600)) - 60); + Cache::put(self::TOKEN_CACHE_KEY, $token, $expiresIn); + + return $token; + } +} diff --git a/app/Services/IGDB/DataNode.php b/app/Services/IGDB/DataNode.php new file mode 100644 index 000000000..49d024e95 --- /dev/null +++ b/app/Services/IGDB/DataNode.php @@ -0,0 +1,121 @@ + + */ + protected array $attributes = []; + + /** + * @param array $attributes + * @param array $dateFields + */ + public function __construct(array $attributes = [], protected array $dateFields = []) + { + foreach ($attributes as $key => $value) { + $this->attributes[$key] = $this->normalizeValue((string) $key, $value); + } + } + + public function __get(string $key): mixed + { + return $this->attributes[$key] ?? null; + } + + public function __set(string $key, mixed $value): void + { + $this->attributes[$key] = $this->normalizeValue($key, $value); + } + + public function __isset(string $key): bool + { + return array_key_exists($key, $this->attributes); + } + + public function offsetExists(mixed $offset): bool + { + return array_key_exists((string) $offset, $this->attributes); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->attributes[(string) $offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->attributes[(string) $offset] = $this->normalizeValue((string) $offset, $value); + } + + public function offsetUnset(mixed $offset): void + { + unset($this->attributes[(string) $offset]); + } + + /** + * @return array + */ + public function toArray(): array + { + $attributes = []; + + foreach ($this->attributes as $key => $value) { + $attributes[$key] = $this->normalizeArrayValue($value); + } + + return $attributes; + } + + protected function normalizeValue(string $key, mixed $value): mixed + { + if (in_array($key, $this->dateFields, true) && is_numeric($value)) { + return Carbon::createFromTimestamp((int) $value); + } + + if (! is_array($value)) { + return $value; + } + + if (! array_is_list($value)) { + return new self($value, $this->dateFields); + } + + return array_map(function (mixed $item): mixed { + if (! is_array($item)) { + return $item; + } + + return array_is_list($item) + ? array_map(fn (mixed $nested) => is_array($nested) && ! array_is_list($nested) + ? new self($nested, $this->dateFields) + : $nested, $item) + : new self($item, $this->dateFields); + }, $value); + } + + protected function normalizeArrayValue(mixed $value): mixed + { + if ($value instanceof self) { + return $value->toArray(); + } + + if ($value instanceof Carbon) { + return $value->toIso8601String(); + } + + if (! is_array($value)) { + return $value; + } + + return array_map(fn (mixed $item) => $this->normalizeArrayValue($item), $value); + } +} diff --git a/app/Services/IGDB/Exceptions/IgdbHttpException.php b/app/Services/IGDB/Exceptions/IgdbHttpException.php new file mode 100644 index 000000000..84f9aa10a --- /dev/null +++ b/app/Services/IGDB/Exceptions/IgdbHttpException.php @@ -0,0 +1,28 @@ +statusCode; + } + + public function getResponseBody(): ?string + { + return $this->responseBody; + } +} diff --git a/app/Services/IGDB/Models/BaseModel.php b/app/Services/IGDB/Models/BaseModel.php new file mode 100644 index 000000000..c18277755 --- /dev/null +++ b/app/Services/IGDB/Models/BaseModel.php @@ -0,0 +1,48 @@ + + */ + protected const array DATE_FIELDS = [ + 'created_at', + 'updated_at', + 'change_date', + 'start_date', + 'published_at', + 'first_release_date', + ]; + + /** + * @param array $attributes + */ + public function __construct(array $attributes = []) + { + parent::__construct($attributes, static::DATE_FIELDS); + } + + public static function query(): QueryBuilder + { + return new QueryBuilder(static::class); + } + + public static function endpoint(): string + { + return static::ENDPOINT; + } + + public static function __callStatic(string $method, array $parameters): mixed + { + return static::query()->{$method}(...$parameters); + } +} diff --git a/app/Services/IGDB/Models/Company.php b/app/Services/IGDB/Models/Company.php new file mode 100644 index 000000000..c9abc33db --- /dev/null +++ b/app/Services/IGDB/Models/Company.php @@ -0,0 +1,10 @@ + + */ + protected array $fields = ['*']; + + /** + * @var array + */ + protected array $whereClauses = []; + + protected ?string $searchTerm = null; + + protected ?string $sort = null; + + protected ?int $limitValue = null; + + /** + * @param class-string $modelClass + */ + public function __construct( + protected string $modelClass, + protected ?Client $client = null, + ) { + $this->client ??= app(Client::class); + } + + public function search(string $query): self + { + $this->searchTerm = $query; + + return $this; + } + + public function where(string $field, mixed $operator = null, mixed $value = null): self + { + if (func_num_args() === 2) { + $value = $operator; + $operator = '='; + } + + if (! is_string($operator) || $operator === '') { + throw new InvalidArgumentException('The IGDB where operator must be a non-empty string.'); + } + + $this->whereClauses[] = sprintf('%s %s %s', $field, $operator, $this->formatValue($value)); + + return $this; + } + + /** + * @param array $values + */ + public function whereIn(string $field, array $values): self + { + $formattedValues = implode(',', array_map($this->formatValue(...), $values)); + $this->whereClauses[] = sprintf('%s = (%s)', $field, $formattedValues); + + return $this; + } + + /** + * @param array|string> $relationships + */ + public function with(array $relationships): self + { + foreach ($relationships as $relationship => $fields) { + if (is_int($relationship)) { + $relationship = $fields; + $fields = ['*']; + } + + foreach ((array) $fields as $field) { + $this->fields[] = sprintf('%s.%s', $relationship, $field); + } + } + + return $this; + } + + public function orderBy(string $field, string $direction = 'asc'): self + { + $this->sort = $field.' '.strtolower($direction); + + return $this; + } + + public function orderByDesc(string $field): self + { + return $this->orderBy($field, 'desc'); + } + + public function limit(int $limit): self + { + $this->limitValue = max(1, $limit); + + return $this; + } + + /** + * @return Collection + */ + public function get(): Collection + { + $payload = $this->client?->request($this->modelClass::endpoint(), $this->toQuery()) ?? []; + + return collect($payload)->map(fn (array $attributes) => new $this->modelClass($attributes)); + } + + /** + * @return TModel|null + */ + public function first(): ?BaseModel + { + $query = clone $this; + $query->limit(1); + + /** @var TModel|null $result */ + $result = $query->get()->first(); + + return $result; + } + + /** + * @return TModel|null + */ + public function find(int|string $id): ?BaseModel + { + return $this->where('id', (int) $id)->first(); + } + + public function toQuery(): string + { + $segments = [ + 'fields '.implode(',', array_unique($this->fields)).';', + ]; + + if ($this->searchTerm !== null && $this->searchTerm !== '') { + $segments[] = 'search '.$this->formatValue($this->searchTerm).';'; + } + + if ($this->whereClauses !== []) { + $segments[] = 'where '.implode(' & ', array_unique($this->whereClauses)).';'; + } + + if ($this->sort !== null) { + $segments[] = 'sort '.$this->sort.';'; + } + + if ($this->limitValue !== null) { + $segments[] = 'limit '.$this->limitValue.';'; + } + + return implode("\n", $segments); + } + + protected function formatValue(mixed $value): string + { + return match (true) { + is_bool($value) => $value ? 'true' : 'false', + is_int($value), is_float($value) => (string) $value, + $value === null => 'null', + default => '"'.addcslashes((string) $value, '\\"').'"', + }; + } +} diff --git a/app/Services/IGDBService.php b/app/Services/IGDBService.php index c1af2fcf6..b37e013ce 100644 --- a/app/Services/IGDBService.php +++ b/app/Services/IGDBService.php @@ -4,19 +4,19 @@ declare(strict_types=1); namespace App\Services; +use App\Services\IGDB\Models\Company; +use App\Services\IGDB\Models\Game; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; -use MarcReichel\IGDBLaravel\Models\Company; -use MarcReichel\IGDBLaravel\Models\Game; /** * IGDBService - IGDB (Internet Game Database) API integration. * - * Note: MarcReichel\IGDBLaravel\Models\Game and Company use dynamic properties - * (name, id, etc.) that PHPStan cannot resolve. + * Note: Local IGDB resource models expose dynamic properties + * (name, id, etc.) that PHPStan cannot fully resolve. * * Features: * - Rate limiting and caching @@ -44,13 +44,42 @@ class IGDBService // PC Platform IDs in IGDB protected const array PC_PLATFORM_IDS = [6, 13, 14, 3]; // PC Windows, DOS, Mac, Linux + protected const array PLATFORM_ALIASES = [ + 'x360' => 'xbox 360', + 'xbox360' => 'xbox 360', + 'xbla' => 'xbox 360', + 'xbox one' => 'xbox one', + 'xboxone' => 'xbox one', + 'x-box' => 'xbox', + 'xbox' => 'xbox', + 'dsi' => 'nintendo ds', + 'nds' => 'nintendo ds', + '3ds' => 'nintendo 3ds', + 'ps2' => 'playstation2', + 'ps3' => 'playstation 3', + 'ps4' => 'playstation 4', + 'psp' => 'sony psp', + 'psvita' => 'playstation vita', + 'psx' => 'playstation', + 'psx2psp' => 'playstation', + 'wiiu' => 'nintendo wii u', + 'wii' => 'nintendo wii', + 'ngc' => 'gamecube', + 'gc' => 'gamecube', + 'n64' => 'nintendo 64', + 'nes' => 'nintendo nes', + 'super nintendo' => 'snes', + 'nintendo super nes' => 'snes', + 'snes' => 'snes', + ]; + /** * Check if IGDB is configured. */ public function isConfigured(): bool { - return config('config.credentials.client_id') !== '' - && config('config.credentials.client_secret') !== ''; + return config('igdb.credentials.client_id') !== '' + && config('igdb.credentials.client_secret') !== ''; } /** @@ -79,7 +108,7 @@ class IGDBService return $cached instanceof Game ? $cached : null; } - $game = $this->searchWithStrategies($title); + $game = $this->searchWithStrategies($title, self::PC_PLATFORM_IDS); if ($game !== null) { Cache::put($cacheKey, $game, self::GAME_CACHE_TTL); @@ -92,6 +121,49 @@ class IGDBService return $game; } + /** + * Search for a console game using an optional platform hint. + */ + public function searchConsole(string $title, string $platformHint): ?Game + { + if (! $this->isConfigured() || $title === '') { + return null; + } + + $cacheKey = 'igdb_console_search:'.md5(mb_strtolower($title.'|'.$platformHint)); + + if (Cache::has("igdb_console_search_failed:{$cacheKey}")) { + Log::debug('IGDBService: Skipping previously failed console search', [ + 'title' => $title, + 'platform' => $platformHint, + ]); + + return null; + } + + $cached = Cache::get($cacheKey); + if ($cached instanceof Game) { + Log::debug('IGDBService: Using cached console search result', [ + 'title' => $title, + 'platform' => $platformHint, + ]); + + return $cached; + } + + $game = $this->searchWithStrategies($title, null, $platformHint); + + if ($game instanceof Game) { + Cache::put($cacheKey, $game, self::GAME_CACHE_TTL); + + return $game; + } + + Cache::put("igdb_console_search_failed:{$cacheKey}", true, self::FAILED_LOOKUP_CACHE_TTL); + + return null; + } + /** * Get complete game details from a Game object. * @@ -125,10 +197,10 @@ class IGDBService /** * Search IGDB using multiple strategies for better match rates. */ - protected function searchWithStrategies(string $title): ?Game + protected function searchWithStrategies(string $title, ?array $platformIds = null, ?string $platformHint = null): ?Game { // Strategy 1: Exact name search with PC platform filter - $game = $this->searchExact($title); + $game = $this->searchExact($title, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Exact match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound @@ -136,7 +208,7 @@ class IGDBService } // Strategy 2: Fuzzy search using IGDB's search endpoint - $game = $this->searchFuzzy($title); + $game = $this->searchFuzzy($title, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Fuzzy match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound @@ -146,7 +218,7 @@ class IGDBService // Strategy 3: Search without special characters $cleanTitle = preg_replace('/[^a-zA-Z0-9\s]/', '', $title); if ($cleanTitle !== $title && $cleanTitle !== '') { - $game = $this->searchFuzzy($cleanTitle); + $game = $this->searchFuzzy($cleanTitle, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Clean title match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound @@ -157,7 +229,7 @@ class IGDBService // Strategy 4: Try with common subtitle patterns removed $baseTitle = $this->extractBaseTitle($title); if ($baseTitle !== $title && $baseTitle !== $cleanTitle && $baseTitle !== '') { - $game = $this->searchFuzzy($baseTitle); + $game = $this->searchFuzzy($baseTitle, $platformIds, $platformHint); if ($game !== null) { Log::debug('IGDBService: Base title match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound @@ -171,23 +243,32 @@ class IGDBService /** * Exact name search on IGDB with PC platform filter. */ - protected function searchExact(string $title): ?Game + protected function searchExact(string $title, ?array $platformIds = null, ?string $platformHint = null): ?Game { try { - $result = RateLimiter::attempt( + $results = RateLimiter::attempt( self::RATE_LIMIT_KEY, self::REQUESTS_PER_MINUTE, - function () use ($title) { - return Game::where('name', $title) - ->whereIn('platforms', self::PC_PLATFORM_IDS) + function () use ($platformIds, $title) { + $query = Game::where('name', $title) ->with($this->getGameRelations()) ->orderByDesc('aggregated_rating_count') - ->first(); + ->limit(10); + + if ($platformIds !== null) { + $query->whereIn('platforms', $platformIds); + } + + return $query->get(); }, self::DECAY_SECONDS ); - return $result instanceof Game ? $result : null; + if ($results === true || empty($results)) { + return null; + } + + return $this->findBestMatch($results, $title, $platformHint); } catch (\Exception $e) { Log::warning('IGDBService: Exact search error', ['error' => $e->getMessage()]); @@ -198,20 +279,24 @@ class IGDBService /** * Fuzzy search on IGDB using the search endpoint. */ - protected function searchFuzzy(string $title): ?Game + protected function searchFuzzy(string $title, ?array $platformIds = null, ?string $platformHint = null): ?Game { try { $results = RateLimiter::attempt( self::RATE_LIMIT_KEY, self::REQUESTS_PER_MINUTE, - function () use ($title) { - return Game::search($title) - ->whereIn('platforms', self::PC_PLATFORM_IDS) + function () use ($platformIds, $title) { + $query = Game::search($title) ->where('category', 0) // Main game only (not DLC, expansion, etc.) ->with($this->getGameRelations()) ->orderByDesc('aggregated_rating_count') - ->limit(10) - ->get(); + ->limit(10); + + if ($platformIds !== null) { + $query->whereIn('platforms', $platformIds); + } + + return $query->get(); }, self::DECAY_SECONDS ); @@ -220,7 +305,7 @@ class IGDBService return null; } - return $this->findBestMatch($results, $title); + return $this->findBestMatch($results, $title, $platformHint); } catch (\Exception $e) { Log::warning('IGDBService: Fuzzy search error', ['error' => $e->getMessage()]); @@ -255,7 +340,7 @@ class IGDBService /** * Find the best matching game from results. */ - protected function findBestMatch(mixed $results, string $title): ?Game + protected function findBestMatch(mixed $results, string $title, ?string $platformHint = null): ?Game { $bestMatch = null; $bestScore = 0; @@ -275,6 +360,11 @@ class IGDBService $score += 2; } + if ($platformHint !== null && $platformHint !== '') { + $platformScore = $this->getPlatformMatchScore($game, $platformHint); + $score += $platformScore > 0 ? $platformScore : -10; + } + // Check alternative names if available if (isset($game->alternative_names)) { foreach ($game->alternative_names as $altName) { @@ -302,6 +392,33 @@ class IGDBService return $bestMatch; } + /** + * Build console-specific game data from an IGDB Game model. + * + * @return array + */ + public function buildConsoleData(Game $game, string $platformHint): array + { + $genres = $this->extractGenres($game); + $publishers = $this->extractCompanyNames($game, 'publisher'); + + return [ + 'title' => $game->name, + 'asin' => (string) $game->id, + 'review' => (string) ($game->summary ?? ''), + 'coverurl' => $this->getImageUrl($game->cover ?? null, 'cover_big'), + 'releasedate' => $this->getReleaseDate($game), + 'esrb' => isset($game->aggregated_rating) && is_numeric($game->aggregated_rating) + ? round((float) $game->aggregated_rating).'%' + : $this->getAgeRating($game), + 'url' => $game->url ?? '', + 'publisher' => ! empty($publishers) ? implode(',', $publishers) : 'Unknown', + 'platform' => $this->resolvePlatformName($game, $platformHint), + 'consolegenre' => ! empty($genres) ? implode(',', $genres) : 'Unknown', + 'salesrank' => '', + ]; + } + /** * Build game data array from IGDB Game model. * @@ -416,6 +533,93 @@ class IGDBService return array_filter($genres); } + /** + * @return array + */ + protected function extractCompanyNames(Game $game, string $flag): array + { + $names = []; + if (empty($game->involved_companies)) { + return $names; + } + + $involvedCompanies = $game->involved_companies; + if ($involvedCompanies instanceof Collection) { + $involvedCompanies = $involvedCompanies->toArray(); + } + + foreach ($involvedCompanies as $company) { + $isMatch = is_array($company) ? ($company[$flag] ?? false) : ($company->{$flag} ?? false); + $companyId = is_array($company) ? ($company['company'] ?? null) : ($company->company ?? null); + + if ($isMatch !== true || ! $companyId) { + continue; + } + + $companyData = Company::find($companyId); + if ($companyData !== null && ! empty($companyData->name)) { + $names[] = $companyData->name; + } + } + + return array_values(array_unique($names)); + } + + protected function resolvePlatformName(Game $game, string $platformHint): string + { + $normalizedHint = $this->normalizePlatformHint($platformHint); + $fallback = ''; + + foreach ((array) ($game->platforms ?? []) as $platform) { + $name = is_array($platform) ? (string) ($platform['name'] ?? '') : (string) ($platform->name ?? ''); + $abbreviation = is_array($platform) ? (string) ($platform['abbreviation'] ?? '') : (string) ($platform->abbreviation ?? ''); + + if ($fallback === '' && $name !== '') { + $fallback = $name; + } + + foreach ([$name, $abbreviation] as $candidate) { + if ($candidate !== '' && $this->normalizePlatformHint($candidate) === $normalizedHint) { + return $name !== '' ? $name : $candidate; + } + } + } + + return $fallback; + } + + protected function getPlatformMatchScore(Game $game, string $platformHint): int + { + $normalizedHint = $this->normalizePlatformHint($platformHint); + if ($normalizedHint === '') { + return 0; + } + + foreach ((array) ($game->platforms ?? []) as $platform) { + $name = is_array($platform) ? (string) ($platform['name'] ?? '') : (string) ($platform->name ?? ''); + $abbreviation = is_array($platform) ? (string) ($platform['abbreviation'] ?? '') : (string) ($platform->abbreviation ?? ''); + + foreach ([$name, $abbreviation] as $candidate) { + if ($candidate === '') { + continue; + } + + if ($this->normalizePlatformHint($candidate) === $normalizedHint) { + return 15; + } + } + } + + return 0; + } + + protected function normalizePlatformHint(string $platform): string + { + $normalized = mb_strtolower(trim($platform)); + + return self::PLATFORM_ALIASES[$normalized] ?? $normalized; + } + /** * Get properly formatted IGDB image URL. * diff --git a/app/Services/RecaptchaService.php b/app/Services/RecaptchaService.php new file mode 100644 index 000000000..25d931b86 --- /dev/null +++ b/app/Services/RecaptchaService.php @@ -0,0 +1,75 @@ +post('https://www.google.com/recaptcha/api/siteverify', [ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $remoteIp ?? request()->ip(), + ]); + + $result = $response->json(); + + return isset($result['success']) && $result['success'] === true; + } catch (\Throwable $throwable) { + Log::error('reCAPTCHA verification failed: '.$throwable->getMessage()); + + return false; + } + } + + /** + * Render the Google reCAPTCHA widget. + * + * @param array $attributes + */ + public static function display(array $attributes = []): string + { + $sitekey = config('captcha.recaptcha.sitekey', config('captcha.sitekey')); + + if ($sitekey === null || $sitekey === '') { + return ''; + } + + $defaultAttributes = [ + 'class' => 'g-recaptcha', + 'data-sitekey' => $sitekey, + ]; + + $attributes = array_merge($defaultAttributes, $attributes); + $attributesString = ''; + + foreach ($attributes as $key => $value) { + $attributesString .= sprintf('%s="%s" ', $key, htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8')); + } + + return sprintf('
', trim($attributesString)); + } + + /** + * Render the Google reCAPTCHA JavaScript include. + */ + public static function renderJs(): string + { + return ''; + } +} diff --git a/app/Support/CaptchaHelper.php b/app/Support/CaptchaHelper.php index 5c873550b..654098f15 100644 --- a/app/Support/CaptchaHelper.php +++ b/app/Support/CaptchaHelper.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Support; +use App\Rules\RecaptchaRule; use App\Rules\TurnstileRule; +use App\Services\RecaptchaService; use App\Services\TurnstileService; use Illuminate\Support\Facades\Log; @@ -67,7 +69,7 @@ class CaptchaHelper } // Default to reCAPTCHA - return app('captcha')->display($attributes); + return RecaptchaService::display($attributes); } /** @@ -86,7 +88,7 @@ class CaptchaHelper } // Default to reCAPTCHA - return app('captcha')->renderJs(); + return RecaptchaService::renderJs(); } /** @@ -130,7 +132,7 @@ class CaptchaHelper return [ $fieldName => [ 'required', - 'captcha', + new RecaptchaRule, ], ]; } diff --git a/bootstrap/app.php b/bootstrap/app.php index 12cdea964..e81af0597 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,16 +8,17 @@ use App\Http\Middleware\Google2FAMiddleware; use App\Http\Middleware\NoCacheForAuthenticatedUsers; use App\Http\Middleware\SetUserTimezone; use App\Http\Middleware\TrustedDevice2FAMiddleware; +use App\Http\Middleware\TrustProxies as AppTrustProxies; use Creativeorange\Gravatar\GravatarServiceProvider; +use Illuminate\Auth\Middleware\EnsureEmailIsVerified; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; -use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; -use Illuminate\Http\Middleware\TrustProxies; +use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; +use Illuminate\Http\Middleware\TrustProxies as BaseTrustProxies; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\AuthenticateSession; -use Jrean\UserVerification\Middleware\IsVerified; -use Jrean\UserVerification\UserVerificationServiceProvider; +use Illuminate\Support\Facades\Route; use Laravel\Tinker\TinkerServiceProvider; use Sentry\Laravel\Integration; use Spatie\Permission\Middleware\PermissionMiddleware; @@ -27,7 +28,6 @@ use Spatie\Permission\Middleware\RoleOrPermissionMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withProviders([ TinkerServiceProvider::class, - UserVerificationServiceProvider::class, GravatarServiceProvider::class, ]) ->withRouting( @@ -36,6 +36,11 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', channels: __DIR__.'/../routes/channels.php', health: '/up', + then: static function (): void { + Route::middleware('api') + ->prefix('rss') + ->group(base_path('routes/rss.php')); + }, ) ->withMiddleware(function (Middleware $middleware) { $middleware->redirectGuestsTo(fn () => route('login')); @@ -55,14 +60,14 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->append([ - CheckForMaintenanceMode::class, + PreventRequestsDuringMaintenance::class, ForceJsonOnAPI::class, BlockAbusiveServices::class, // Block AIOStreams, Oracle Cloud, UsenetStreamer, Cloudflare WARP ]); $middleware->replace( - TrustProxies::class, - Monicahq\Cloudflare\Http\Middleware\TrustProxies::class + BaseTrustProxies::class, + AppTrustProxies::class ); $middleware->web([ @@ -79,7 +84,7 @@ return Application::configure(basePath: dirname(__DIR__)) '2fa' => Google2FAMiddleware::class, 'bindings' => SubstituteBindings::class, 'clearance' => ClearanceMiddleware::class, - 'isVerified' => IsVerified::class, + 'isVerified' => EnsureEmailIsVerified::class, 'permission' => PermissionMiddleware::class, 'role' => RoleMiddleware::class, 'role_or_permission' => RoleOrPermissionMiddleware::class, diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 3142f0ab3..2b9a0eb8c 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -6,7 +6,6 @@ use App\Providers\CategorizationServiceProvider; use App\Providers\ForumServiceProvider; use App\Providers\HorizonServiceProvider; use App\Providers\ProcessingServiceProvider; -use App\Providers\RouteServiceProvider; use App\Providers\SearchServiceProvider; use App\Providers\TvProcessingServiceProvider; use App\Providers\UserServiceProvider; @@ -19,7 +18,6 @@ return [ ForumServiceProvider::class, HorizonServiceProvider::class, ProcessingServiceProvider::class, - RouteServiceProvider::class, SearchServiceProvider::class, TvProcessingServiceProvider::class, UserServiceProvider::class, diff --git a/composer.json b/composer.json index 5122d1b4a..ac90c0de0 100644 --- a/composer.json +++ b/composer.json @@ -44,39 +44,35 @@ "ext-xmlwriter": "*", "ext-zlib": "*", "aharen/omdbapi": "^2.1", - "anhskohbo/no-captcha": "^3.7.0", "bacon/bacon-qr-code": "^3.0", "canihavesomecoffee/thetvdbapi": "^2.0", "creativeorange/gravatar": "^1.0", "dariusiii/net_nntp": "^4.0", "dariusiii/rarinfo": "^3.0", "dariusiii/tv-maze-php-api": "^2.0.0", + "elasticsearch/elasticsearch": "^7.17", "fakerphp/faker": "^1.23", "geoip2/geoip2": "^3.1", "guzzlehttp/guzzle": "^7.9", "intervention/image": "^3.11", - "jrean/laravel-user-verification": "^13.0", - "laravel/framework": "^12.28", - "laravel/horizon": "^5.23", - "laravel/pulse": "^1.4", + "laravel/framework": "^13.0", + "laravel/horizon": "^5.45", + "laravel/pulse": "^1.7", "laravel/sanctum": "^4.0", - "laravel/scout": "^10.13", - "laravel/tinker": "^2.10.1", + "laravel/scout": "^11.0", + "laravel/tinker": "^3.0", "livewire/blaze": "^1.0", "livewire/livewire": "^3.6", "livewire/volt": "^1.6", - "mailerlite/laravel-elasticsearch": "^11.2", "manticoresoftware/manticoresearch-php": "^4.0.0", - "marcreichel/igdb-laravel": "^5.2", "mhor/php-mediainfo": "^5.6", - "monicahq/laravel-cloudflare": "^4.0", "monolog/monolog": "^3.8", "nesbot/carbon": "^3.8", "php-ffmpeg/php-ffmpeg": "^1.3", "pragmarx/google2fa-laravel": "^3.0", "predis/predis": "^v3.0.0", "propaganistas/laravel-disposable-email": "^2.4", - "riari/laravel-forum": "^7.0", + "riari/laravel-forum": "8.x-dev", "sentry/sentry": "^4.21", "sentry/sentry-laravel": "^4.13", "spatie/laravel-directory-cleanup": "^1.10", @@ -93,15 +89,14 @@ "voku/simple_html_dom": "^4.8" }, "require-dev": { - "barryvdh/laravel-ide-helper": "^3.5", - "beyondcode/laravel-dump-server": "^2.1", + "barryvdh/laravel-ide-helper": "^3.7", "captainhook/captainhook": "^5.4", "captainhook/plugin-composer": "^5.2", "driftingly/rector-laravel": "^2.0", "ergebnis/composer-normalize": "^2.30", "friendsofphp/php-cs-fixer": "^3.70", "jubeki/laravel-code-style": "^2.0", - "larastan/larastan": "^3.2", + "larastan/larastan": "^3.9", "laravel/boost": "^2.0", "laravel/pint": "^1.13", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index 8c9552274..66006ba15 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": "ff1665442e9242cf8c078561a08de1ea", + "content-hash": "44f6f3e3d2487f1ea996039cc71f9e9e", "packages": [ { "name": "aharen/omdbapi", @@ -56,41 +56,42 @@ "time": "2023-12-12T13:10:07+00:00" }, { - "name": "anhskohbo/no-captcha", - "version": "3.7.0", + "name": "aimeos/laravel-nestedset", + "version": "7.1.1", "source": { "type": "git", - "url": "https://github.com/anhskohbo/no-captcha.git", - "reference": "87666572f0dbe1e3380a2e9ae7574bf3a2d0804e" + "url": "https://github.com/aimeos/laravel-nestedset.git", + "reference": "12a5ced6dc0da94bc3ee7aef871d20f9295db14f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/anhskohbo/no-captcha/zipball/87666572f0dbe1e3380a2e9ae7574bf3a2d0804e", - "reference": "87666572f0dbe1e3380a2e9ae7574bf3a2d0804e", + "url": "https://api.github.com/repos/aimeos/laravel-nestedset/zipball/12a5ced6dc0da94bc3ee7aef871d20f9295db14f", + "reference": "12a5ced6dc0da94bc3ee7aef871d20f9295db14f", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^6.2|^7.0", - "illuminate/support": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": ">=5.5.5" + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/events": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" }, "require-dev": { - "phpunit/phpunit": "~4.8|^9.5.10|^10.5" + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "ramsey/uuid": "^4.7" }, "type": "library", "extra": { "laravel": { - "aliases": { - "NoCaptcha": "Anhskohbo\\NoCaptcha\\Facades\\NoCaptcha" - }, "providers": [ - "Anhskohbo\\NoCaptcha\\NoCaptchaServiceProvider" + "Aimeos\\Nestedset\\NestedSetServiceProvider" ] } }, "autoload": { "psr-4": { - "Anhskohbo\\NoCaptcha\\": "src/" + "Aimeos\\Nestedset\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -99,25 +100,27 @@ ], "authors": [ { - "name": "anhskohbo", - "email": "anhskohbo@gmail.com" + "name": "Alexander Kalnoy", + "email": "lazychaser@gmail.com" + }, + { + "name": "Aimeos", + "email": "aimeos@aimeos.org" } ], - "description": "No CAPTCHA reCAPTCHA For Laravel.", + "description": "Nested Set Model for Laravel", "keywords": [ - "captcha", + "database", + "hierarchy", "laravel", - "laravel4", - "laravel5", - "laravel6", - "no-captcha", - "recaptcha" + "nested sets", + "nsm" ], "support": { - "issues": "https://github.com/anhskohbo/no-captcha/issues", - "source": "https://github.com/anhskohbo/no-captcha/tree/3.7.0" + "issues": "https://github.com/aimeos/laravel-nestedset/issues", + "source": "https://github.com/aimeos/laravel-nestedset/tree/7.1.1" }, - "time": "2025-02-25T18:53:34+00:00" + "time": "2026-03-08T17:13:37+00:00" }, { "name": "bacon/bacon-qr-code", @@ -2370,156 +2373,26 @@ }, "time": "2025-03-19T14:43:43+00:00" }, - { - "name": "jrean/laravel-user-verification", - "version": "v13.0.0", - "source": { - "type": "git", - "url": "https://github.com/jrean/laravel-user-verification.git", - "reference": "c9e3f45a28cf0dfc0e549327be5f210fcdd489f2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jrean/laravel-user-verification/zipball/c9e3f45a28cf0dfc0e549327be5f210fcdd489f2", - "reference": "c9e3f45a28cf0dfc0e549327be5f210fcdd489f2", - "shasum": "" - }, - "require": { - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": ">=7.2.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^1.12|^3.5", - "fzaninotto/faker": "^1.9|^1.5", - "mockery/mockery": "^1.3", - "phpunit/phpunit": "^7.0|^8.0|^9.0|^10.0|^11.0", - "satooshi/php-coveralls": "^2.0|^1.0", - "sllh/php-cs-fixer-styleci-bridge": "^2.1" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "UserVerification": "Jrean\\UserVerification\\Facades\\UserVerification" - }, - "providers": [ - "Jrean\\UserVerification\\UserVerificationServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Jrean\\UserVerification\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jean Ragouin", - "email": "go@askjong.com" - } - ], - "description": "User Email Verification For Laravel", - "keywords": [ - "email activation", - "email verification", - "framework", - "laravel", - "user activation", - "user verification" - ], - "support": { - "issues": "https://github.com/jrean/laravel-user-verification/issues", - "source": "https://github.com/jrean/laravel-user-verification/tree/v13.0.0" - }, - "time": "2025-03-25T16:26:47+00:00" - }, - { - "name": "kalnoy/nestedset", - "version": "v6.0.6", - "source": { - "type": "git", - "url": "https://github.com/lazychaser/laravel-nestedset.git", - "reference": "3cfc56a9759fb592bc903056166bfc0867f9e679" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/lazychaser/laravel-nestedset/zipball/3cfc56a9759fb592bc903056166bfc0867f9e679", - "reference": "3cfc56a9759fb592bc903056166bfc0867f9e679", - "shasum": "" - }, - "require": { - "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/events": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^8.0" - }, - "require-dev": { - "phpunit/phpunit": "7.*|8.*|9.*|^10.5" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Kalnoy\\Nestedset\\NestedSetServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "v5.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Kalnoy\\Nestedset\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alexander Kalnoy", - "email": "lazychaser@gmail.com" - } - ], - "description": "Nested Set Model for Laravel 5.7 and up", - "keywords": [ - "database", - "hierarchy", - "laravel", - "nested sets", - "nsm" - ], - "support": { - "issues": "https://github.com/lazychaser/laravel-nestedset/issues", - "source": "https://github.com/lazychaser/laravel-nestedset/tree/v6.0.6" - }, - "time": "2025-04-22T19:38:02+00:00" - }, { "name": "laravel/framework", - "version": "v12.55.1", + "version": "v13.2.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" + "reference": "9e48d1fe933e89de628dafa167d2c5778566d4cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", + "url": "https://api.github.com/repos/laravel/framework/zipball/9e48d1fe933e89de628dafa167d2c5778566d4cf", + "reference": "9e48d1fe933e89de628dafa167d2c5778566d4cf", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", - "egulias/email-validator": "^3.2.1|^4.0", + "egulias/email-validator": "^4.0", "ext-ctype": "*", "ext-filter": "*", "ext-hash": "*", @@ -2529,9 +2402,10 @@ "ext-tokenizer": "*", "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/promises": "^2.0.3", "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", - "laravel/serializable-closure": "^1.3|^2.0", + "laravel/serializable-closure": "^2.0.10", "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", @@ -2539,25 +2413,24 @@ "monolog/monolog": "^3.0", "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", - "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1", - "psr/log": "^1.0|^2.0|^3.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^7.2.0", - "symfony/error-handler": "^7.2.0", - "symfony/finder": "^7.2.0", - "symfony/http-foundation": "^7.2.0", - "symfony/http-kernel": "^7.2.0", - "symfony/mailer": "^7.2.0", - "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.33", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", "symfony/polyfill-php84": "^1.33", "symfony/polyfill-php85": "^1.33", - "symfony/process": "^7.2.0", - "symfony/routing": "^7.2.0", - "symfony/uid": "^7.2.0", - "symfony/var-dumper": "^7.2.0", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" @@ -2566,9 +2439,9 @@ "tightenco/collect": "<5.5.33" }, "provide": { - "psr/container-implementation": "1.1|2.0", - "psr/log-implementation": "1.0|2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0" + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" }, "replace": { "illuminate/auth": "self.version", @@ -2614,7 +2487,6 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^2.4", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", @@ -2624,22 +2496,22 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.9.0", - "pda/pheanstalk": "^5.0.6|^7.0.0", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.1.41", - "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3|^3.0", - "resend/resend-php": "^0.10.0|^1.0", - "symfony/cache": "^7.2.0", - "symfony/http-client": "^7.2.0", - "symfony/psr-http-message-bridge": "^7.2.0", - "symfony/translation": "^7.2.0" + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", - "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -2648,7 +2520,7 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", "laravel/tinker": "Required to use the tinker console command (^2.0).", @@ -2658,24 +2530,24 @@ "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", "mockery/mockery": "Required to use mocking (^1.6).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", - "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "12.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { @@ -2720,7 +2592,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-18T14:28:59+00:00" + "time": "2026-03-24T18:42:09+00:00" }, { "name": "laravel/horizon", @@ -3014,16 +2886,16 @@ }, { "name": "laravel/scout", - "version": "v10.25.0", + "version": "v11.1.0", "source": { "type": "git", "url": "https://github.com/laravel/scout.git", - "reference": "f28630dca44a63d80c15e596bedc5af42c8985d5" + "reference": "9865fca4129d79c271da6a89afb44359e9ee9020" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/scout/zipball/f28630dca44a63d80c15e596bedc5af42c8985d5", - "reference": "f28630dca44a63d80c15e596bedc5af42c8985d5", + "url": "https://api.github.com/repos/laravel/scout/zipball/9865fca4129d79c271da6a89afb44359e9ee9020", + "reference": "9865fca4129d79c271da6a89afb44359e9ee9020", "shasum": "" }, "require": { @@ -3062,7 +2934,7 @@ ] }, "branch-alias": { - "dev-master": "10.x-dev" + "dev-master": "11.x-dev" } }, "autoload": { @@ -3090,7 +2962,7 @@ "issues": "https://github.com/laravel/scout/issues", "source": "https://github.com/laravel/scout" }, - "time": "2026-03-10T16:16:46+00:00" + "time": "2026-03-18T14:50:59+00:00" }, { "name": "laravel/sentinel", @@ -3214,33 +3086,33 @@ }, { "name": "laravel/tinker", - "version": "v2.11.1", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + "reference": "cc74081282ba2e3dae1f0068ccb330370d24634e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "url": "https://api.github.com/repos/laravel/tinker/zipball/cc74081282ba2e3dae1f0068ccb330370d24634e", + "reference": "cc74081282ba2e3dae1f0068ccb330370d24634e", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + "phpunit/phpunit": "^10.5|^11.5" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." }, "type": "library", "extra": { @@ -3248,6 +3120,9 @@ "providers": [ "Laravel\\Tinker\\TinkerServiceProvider" ] + }, + "branch-alias": { + "dev-master": "3.x-dev" } }, "autoload": { @@ -3274,9 +3149,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.1" + "source": "https://github.com/laravel/tinker/tree/v3.0.0" }, - "time": "2026-02-06T14:12:35+00:00" + "time": "2026-03-17T14:53:17+00:00" }, { "name": "league/commonmark", @@ -4013,16 +3888,16 @@ }, { "name": "livewire/livewire", - "version": "v3.7.11", + "version": "v3.7.12", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6" + "reference": "7fcb612d1274980d80703efb5658e58d6d37ada9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6", + "url": "https://api.github.com/repos/livewire/livewire/zipball/7fcb612d1274980d80703efb5658e58d6d37ada9", + "reference": "7fcb612d1274980d80703efb5658e58d6d37ada9", "shasum": "" }, "require": { @@ -4077,7 +3952,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.11" + "source": "https://github.com/livewire/livewire/tree/v3.7.12" }, "funding": [ { @@ -4085,7 +3960,7 @@ "type": "github" } ], - "time": "2026-02-26T00:58:19+00:00" + "time": "2026-03-25T23:04:42+00:00" }, { "name": "livewire/volt", @@ -4236,83 +4111,6 @@ ], "time": "2025-12-10T09:58:31+00:00" }, - { - "name": "mailerlite/laravel-elasticsearch", - "version": "11.2.1", - "source": { - "type": "git", - "url": "https://github.com/mailerlite/laravel-elasticsearch.git", - "reference": "92b5553ea00fdaf5b9a4ab31cab8e5339109396c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/mailerlite/laravel-elasticsearch/zipball/92b5553ea00fdaf5b9a4ab31cab8e5339109396c", - "reference": "92b5553ea00fdaf5b9a4ab31cab8e5339109396c", - "shasum": "" - }, - "require": { - "elasticsearch/elasticsearch": "^7.11", - "ext-json": "*", - "guzzlehttp/psr7": "^1.7|^2.0", - "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.3|^8.0", - "psr/http-message": "^1.0|^2.0" - }, - "require-dev": { - "mockery/mockery": "^1.4.3", - "orchestra/testbench": "^6.45|^7.44|^8.25|^9.3|^10.0", - "phpunit/phpunit": "^9.4" - }, - "suggest": { - "aws/aws-sdk-php": "Required to connect to an Elasticsearch host on AWS (^3.80)" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Elasticsearch": "MailerLite\\LaravelElasticsearch\\Facade" - }, - "providers": [ - "MailerLite\\LaravelElasticsearch\\ServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "MailerLite\\LaravelElasticsearch\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "MailerLite", - "email": "info@mailerlite.com" - } - ], - "description": "An easy way to use the official PHP ElasticSearch client in your Laravel applications.", - "homepage": "https://github.com/mailerlite/laravel-elasticsearch", - "keywords": [ - "client", - "elasticsearch", - "laravel", - "search" - ], - "support": { - "issues": "https://github.com/mailerlite/laravel-elasticsearch/issues", - "source": "https://github.com/mailerlite/laravel-elasticsearch/tree/11.2.1" - }, - "funding": [ - { - "url": "https://github.com/mailerlite", - "type": "github" - } - ], - "time": "2026-03-07T08:40:02+00:00" - }, { "name": "manticoresoftware/manticoresearch-php", "version": "4.0.1", @@ -4383,75 +4181,6 @@ }, "time": "2025-03-31T12:51:13+00:00" }, - { - "name": "marcreichel/igdb-laravel", - "version": "5.3.1", - "source": { - "type": "git", - "url": "https://github.com/marcreichel/igdb-laravel.git", - "reference": "59632c0c76a20731dd40a4399c1ed0a8cea8aa0c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/marcreichel/igdb-laravel/zipball/59632c0c76a20731dd40a4399c1ed0a8cea8aa0c", - "reference": "59632c0c76a20731dd40a4399c1ed0a8cea8aa0c", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/guzzle": "~6.0|~7.0", - "illuminate/support": "^11.0|^12.0", - "nesbot/carbon": "^2.53.1|^3.0", - "php": "^8.2" - }, - "require-dev": { - "larastan/larastan": "^3.0.2", - "laravel/pint": "^1.13", - "nunomaduro/collision": "^8.0", - "orchestra/testbench": "^9.0|^10.0", - "pestphp/pest": "^3.7.4", - "pestphp/pest-plugin-type-coverage": "^3.2.3", - "rector/rector": "^2.0.7", - "roave/security-advisories": "dev-latest" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "MarcReichel\\IGDBLaravel\\IGDBLaravelServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "MarcReichel\\IGDBLaravel\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marc Reichel", - "email": "mail@marcreichel.de" - } - ], - "description": "A Laravel wrapper for version 4 of the IGDB API (Apicalypse) including webhook handling", - "keywords": [ - "api-wrapper", - "apicalypse", - "igdb", - "igdb-api", - "laravel", - "wrapper" - ], - "support": { - "issues": "https://github.com/marcreichel/igdb-laravel/issues", - "source": "https://github.com/marcreichel/igdb-laravel/tree/5.3.1" - }, - "time": "2025-06-03T05:44:58+00:00" - }, { "name": "maxmind-db/reader", "version": "v1.13.1", @@ -4624,93 +4353,6 @@ }, "time": "2026-01-06T22:20:55+00:00" }, - { - "name": "monicahq/laravel-cloudflare", - "version": "4.0.0", - "source": { - "type": "git", - "url": "https://github.com/monicahq/laravel-cloudflare.git", - "reference": "4bf313fbfdf3a9a4beb071cba3aa1351bc1703c5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/monicahq/laravel-cloudflare/zipball/4bf313fbfdf3a9a4beb071cba3aa1351bc1703c5", - "reference": "4bf313fbfdf3a9a4beb071cba3aa1351bc1703c5", - "shasum": "" - }, - "require": { - "illuminate/support": "^11.0 || ^12.0", - "php": "^8.2" - }, - "require-dev": { - "brainmaestro/composer-git-hooks": "^3.0", - "guzzlehttp/guzzle": "^6.3 || ^7.0", - "larastan/larastan": "^2.4 || ^3.0", - "laravel/pint": "^1.15", - "mockery/mockery": "^1.4", - "ocramius/package-versions": "^1.5 || ^2.1", - "orchestra/testbench": "^9.0 || ^10.0", - "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", - "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpstan/phpstan-strict-rules": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.0 || ^11.0", - "vimeo/psalm": "^6.0" - }, - "suggest": { - "guzzlehttp/guzzle": "Required to get cloudflares ip addresses (^6.5.5|^7.0)." - }, - "type": "library", - "extra": { - "hooks": { - "config": { - "stop-on-failure": [ - "pre-commit" - ] - }, - "pre-commit": [ - "files=$(git diff --staged --name-only);\"$(dirname \"$0\")/../../vendor/bin/pint\" $files; git add $files" - ] - }, - "laravel": { - "providers": [ - "Monicahq\\Cloudflare\\TrustedProxyServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Monicahq\\Cloudflare\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alexis Saettler", - "email": "alexis@saettler.org" - } - ], - "description": "Add Cloudflare ip addresses to trusted proxies for Laravel.", - "keywords": [ - "cloudflare", - "laravel", - "php", - "proxies" - ], - "support": { - "issues": "https://github.com/monicahq/laravel-cloudflare/issues", - "source": "https://github.com/monicahq/laravel-cloudflare" - }, - "funding": [ - { - "url": "https://github.com/asbiin", - "type": "github" - } - ], - "time": "2025-07-26T22:39:13+00:00" - }, { "name": "monolog/monolog", "version": "3.10.0", @@ -6981,26 +6623,26 @@ }, { "name": "riari/laravel-forum", - "version": "7.0.3", + "version": "8.x-dev", "source": { "type": "git", "url": "https://github.com/Team-Tea-Time/laravel-forum.git", - "reference": "c3b0affd7aa1289840f8298084b45d39ee6a4734" + "reference": "8cabd3769f8ae41c92a162606772d409cf23783b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Team-Tea-Time/laravel-forum/zipball/c3b0affd7aa1289840f8298084b45d39ee6a4734", - "reference": "c3b0affd7aa1289840f8298084b45d39ee6a4734", + "url": "https://api.github.com/repos/Team-Tea-Time/laravel-forum/zipball/8cabd3769f8ae41c92a162606772d409cf23783b", + "reference": "8cabd3769f8ae41c92a162606772d409cf23783b", "shasum": "" }, "require": { - "doctrine/dbal": "^4.0", - "kalnoy/nestedset": "^6.0", - "laravel/framework": "^12.0", - "php": "^8.2" + "aimeos/laravel-nestedset": "^7.1", + "doctrine/dbal": "^4.4", + "laravel/framework": "^13.0", + "php": "^8.3" }, "require-dev": { - "orchestra/testbench": "^10.0" + "orchestra/testbench": "^11.0" }, "type": "library", "extra": { @@ -7040,9 +6682,9 @@ ], "support": { "issues": "https://github.com/Team-Tea-Time/laravel-forum/issues", - "source": "https://github.com/Team-Tea-Time/laravel-forum/tree/7.0.3" + "source": "https://github.com/Team-Tea-Time/laravel-forum/tree/8.x" }, - "time": "2026-01-31T13:49:17+00:00" + "time": "2026-03-19T15:33:20+00:00" }, { "name": "sentry/sentry", @@ -8610,47 +8252,39 @@ }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/string": "^7.4|^8.0" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -8684,7 +8318,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v8.0.7" }, "funding": [ { @@ -8704,7 +8338,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-03-06T14:06:22+00:00" }, { "name": "symfony/css-selector", @@ -8844,33 +8478,32 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^7.4|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" + "symfony/deprecation-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -8902,7 +8535,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v8.0.4" }, "funding": [ { @@ -8922,7 +8555,7 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-01-23T11:07:10+00:00" }, { "name": "symfony/event-dispatcher", @@ -9157,23 +8790,23 @@ }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -9201,7 +8834,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v8.0.6" }, "funding": [ { @@ -9221,7 +8854,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-01-29T09:41:02+00:00" }, { "name": "symfony/http-client", @@ -9399,37 +9032,35 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "c5ecf7b07408dbc4a87482634307654190954ae8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c5ecf7b07408dbc4a87482634307654190954ae8", + "reference": "c5ecf7b07408dbc4a87482634307654190954ae8", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "doctrine/dbal": "<4.3" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -9457,7 +9088,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v8.0.7" }, "funding": [ { @@ -9477,78 +9108,63 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c04721f45723d8ce049fa3eee378b5a505272ac7", + "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" + "twig/twig": "<3.21" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" }, "type": "library", "autoload": { @@ -9576,7 +9192,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.7" }, "funding": [ { @@ -9596,43 +9212,39 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-03-06T16:58:46+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "a8971c86b25ff8557e844f08c1f6207d9b3e614c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a8971c86b25ff8557e844f08c1f6207d9b3e614c", + "reference": "a8971c86b25ff8557e844f08c1f6207d9b3e614c", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", + "php": ">=8.4", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" + "symfony/http-client-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -9660,7 +9272,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v8.0.6" }, "funding": [ { @@ -9680,44 +9292,41 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b", + "reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -9749,7 +9358,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v8.0.7" }, "funding": [ { @@ -9769,7 +9378,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/options-resolver", @@ -10996,34 +10605,29 @@ }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/deprecation-contracts": "^2.5|^3" }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" - }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -11057,7 +10661,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v8.0.6" }, "funding": [ { @@ -11077,7 +10681,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/serializer", @@ -11536,24 +11140,24 @@ }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/8b81bd3700f5c1913c22a3266a647aa1bb974435", + "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -11590,7 +11194,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v8.0.4" }, "funding": [ { @@ -11610,35 +11214,35 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-01-03T23:40:55+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -11677,7 +11281,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" }, "funding": [ { @@ -11697,7 +11301,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-02-15T10:53:29+00:00" }, { "name": "symfony/var-exporter", @@ -12284,86 +11888,18 @@ }, "time": "2026-03-05T20:09:01+00:00" }, - { - "name": "beyondcode/laravel-dump-server", - "version": "2.1.0", - "source": { - "type": "git", - "url": "https://github.com/beyondcode/laravel-dump-server.git", - "reference": "8e9af58a02a59d6a028167e2afc5b5b1e58eb822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/beyondcode/laravel-dump-server/zipball/8e9af58a02a59d6a028167e2afc5b5b1e58eb822", - "reference": "8e9af58a02a59d6a028167e2afc5b5b1e58eb822", - "shasum": "" - }, - "require": { - "illuminate/console": "5.6.*|5.7.*|5.8.*|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/http": "5.6.*|5.7.*|5.8.*|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "5.6.*|5.7.*|5.8.*|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": ">=7.2.5", - "symfony/var-dumper": "^5.0|^6.0|^7.0" - }, - "conflict": { - "spatie/laravel-ray": "*" - }, - "require-dev": { - "larapack/dd": "^1.0", - "phpunit/phpunit": "^7.0|^9.3|^10.5" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "BeyondCode\\DumpServer\\DumpServerServiceProvider" - ] - } - }, - "autoload": { - "files": [ - "helpers.php" - ], - "psr-4": { - "BeyondCode\\DumpServer\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marcel Pociot", - "email": "marcel@beyondco.de", - "homepage": "https://beyondco.de", - "role": "Developer" - } - ], - "description": "Symfony Var-Dump Server for Laravel", - "homepage": "https://github.com/beyondcode/laravel-dump-server", - "keywords": [ - "beyondcode", - "laravel-dump-server" - ], - "support": { - "issues": "https://github.com/beyondcode/laravel-dump-server/issues", - "source": "https://github.com/beyondcode/laravel-dump-server/tree/2.1.0" - }, - "time": "2025-02-26T08:27:59+00:00" - }, { "name": "captainhook/captainhook", - "version": "5.29.1", + "version": "5.29.2", "source": { "type": "git", "url": "https://github.com/captainhook-git/captainhook.git", - "reference": "739e317c6f6cd9bf86f5c4795795c93d7785c7b3" + "reference": "805d44b0de8ed143f4acfb8c6bcb788cdbc42963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/captainhook-git/captainhook/zipball/739e317c6f6cd9bf86f5c4795795c93d7785c7b3", - "reference": "739e317c6f6cd9bf86f5c4795795c93d7785c7b3", + "url": "https://api.github.com/repos/captainhook-git/captainhook/zipball/805d44b0de8ed143f4acfb8c6bcb788cdbc42963", + "reference": "805d44b0de8ed143f4acfb8c6bcb788cdbc42963", "shasum": "" }, "require": { @@ -12426,7 +11962,7 @@ ], "support": { "issues": "https://github.com/captainhook-git/captainhook/issues", - "source": "https://github.com/captainhook-git/captainhook/tree/5.29.1" + "source": "https://github.com/captainhook-git/captainhook/tree/5.29.2" }, "funding": [ { @@ -12434,7 +11970,7 @@ "type": "github" } ], - "time": "2026-03-25T00:09:07+00:00" + "time": "2026-03-25T19:32:25+00:00" }, { "name": "captainhook/plugin-composer", @@ -13962,16 +13498,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "a09bebcff238ed92fd123e460b27bd58bed22520" + "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/a09bebcff238ed92fd123e460b27bd58bed22520", - "reference": "a09bebcff238ed92fd123e460b27bd58bed22520", + "url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506", + "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506", "shasum": "" }, "require": { @@ -14024,7 +13560,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-23T16:01:08+00:00" + "time": "2026-03-25T16:37:40+00:00" }, { "name": "laravel/mcp", @@ -14861,11 +14397,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.43", + "version": "2.1.44", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d01bebe3edfd4d49b9666ee5b8271ddca561042f", - "reference": "d01bebe3edfd4d49b9666ee5b8271ddca561042f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", "shasum": "" }, "require": { @@ -14910,7 +14446,7 @@ "type": "github" } ], - "time": "2026-03-24T20:40:50+00:00" + "time": "2026-03-25T17:34:21+00:00" }, { "name": "phpunit/php-code-coverage", @@ -17425,7 +16961,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "riari/laravel-forum": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/config/app.php b/config/app.php index 0aabbd6e3..4e93ae845 100644 --- a/config/app.php +++ b/config/app.php @@ -5,7 +5,6 @@ use App\Facades\Yenc; use Creativeorange\Gravatar\Facades\Gravatar; use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Redis; -use Jrean\UserVerification\Facades\UserVerification; return [ @@ -30,7 +29,6 @@ return [ 'Google2FA' => PragmaRX\Google2FALaravel\Facade::class, 'Gravatar' => Gravatar::class, 'RedisManager' => Redis::class, - 'UserVerification' => UserVerification::class, 'Yenc' => Yenc::class, ])->toArray(), diff --git a/config/igdb.php b/config/igdb.php index a33d72c73..3e8ce02e2 100644 --- a/config/igdb.php +++ b/config/igdb.php @@ -1,6 +1,10 @@ env('IGDB_BASE_URL', 'https://api.igdb.com/v4'), + + 'token_url' => env('TWITCH_TOKEN_URL', 'https://id.twitch.tv/oauth2/token'), + /* * These are the credentials you got from https://dev.twitch.tv/console/apps */ @@ -10,25 +14,11 @@ return [ ], /* - * This package caches queries automatically (for 1 hour per default). - * Here you can set how long each query should be cached (in seconds). + * The local IGDB client caches query responses for this many seconds. * * To turn cache off set this value to 0 * * Recommended: 86400 (24 hours) for game data that rarely changes */ 'cache_lifetime' => (int) env('IGDB_CACHE_LIFETIME', 86400), - - /* - * Path where the webhooks should be handled. - */ - 'webhook_path' => 'igdb-webhook/handle', - - /* - * The webhook secret. - * - * This needs to be a string of your choice in order to use the webhook - * functionality. - */ - 'webhook_secret' => env('IGDB_WEBHOOK_SECRET'), ]; diff --git a/config/mail.php b/config/mail.php index 2b0c90385..fd2f75bbe 100644 --- a/config/mail.php +++ b/config/mail.php @@ -1,19 +1,51 @@ env('MAIL_MAILER', env('MAIL_DRIVER', 'smtp')), - 'driver' => env('MAIL_DRIVER', 'smtp'), + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'port' => env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN'), + ], - 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs'), + ], - 'port' => env('MAIL_PORT', 587), + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'array' => [ + 'transport' => 'array', + ], - 'username' => env('MAIL_USERNAME'), + 'failover' => [ + 'transport' => 'failover', + 'mailers' => ['smtp', 'log'], + ], + ], - 'password' => env('MAIL_PASSWORD'), + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], - 'sendmail' => '/usr/sbin/sendmail -bs', + 'markdown' => [ + 'theme' => 'default', + 'paths' => [ + resource_path('views/vendor/mail'), + ], + ], ]; diff --git a/phpunit.xml b/phpunit.xml index 08bceb8d7..57c5ca3e8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + tests/Install @@ -18,8 +18,8 @@ - - + + diff --git a/rector.php b/rector.php index c944dbcc8..2d62a1042 100644 --- a/rector.php +++ b/rector.php @@ -26,7 +26,7 @@ return static function (RectorConfig $rectorConfig): void { // define sets of rules $rectorConfig->sets([ - LaravelSetList::LARAVEL_100, + LaravelSetList::LARAVEL_130, SetList::PHP_84, SetList::CODE_QUALITY, ]); diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 000000000..b9afa3621 --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,41 @@ +@extends('layouts.guest') + +@section('content') +
+
+
+

Verify your email address

+

+ Before continuing, please check your inbox for the verification link we emailed to you. +

+
+ + @if (session('status') === 'resent') +
+ A fresh verification link has been sent to your email address. +
+ @endif + +
+ @csrf + +
+ +
+ @csrf + +
+
+
+@endsection + diff --git a/routes/web.php b/routes/web.php index cbf0fe774..b3353f7ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,6 +46,7 @@ use App\Http\Controllers\AdultController; use App\Http\Controllers\AjaxController; use App\Http\Controllers\Api\FileListApiController; use App\Http\Controllers\ApiHelpController; +use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\Auth\ForgotPasswordController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\RegisterController; @@ -92,6 +93,9 @@ Route::match(['GET', 'POST'], '/', [ContentController::class, 'show'])->name('ho Route::get('register', [RegisterController::class, 'showRegistrationForm'])->name('register'); Route::post('register', [RegisterController::class, 'register'])->name('register.post'); +Route::get('email/verify', [EmailVerificationController::class, 'show'])->middleware('auth')->name('verification.notice'); +Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware(['auth', 'throttle:6,1'])->name('verification.send'); +Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); Route::match(['GET', 'POST'], 'forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('forgottenpassword')->withoutMiddleware(['auth']); Route::match(['GET', 'POST'], 'password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request')->withoutMiddleware(['auth']); diff --git a/tests/Feature/AdminContentControllerTest.php b/tests/Feature/AdminContentControllerTest.php index d69164109..f636640ce 100644 --- a/tests/Feature/AdminContentControllerTest.php +++ b/tests/Feature/AdminContentControllerTest.php @@ -25,10 +25,21 @@ class AdminContentControllerTest extends TestCase { private string $databasePath; + /** + * @var array + */ + private array $originalEnvironment = []; + public function createApplication() { $this->databasePath = sys_get_temp_dir().'/nntmux-admin-content-test.sqlite'; + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + if (file_exists($this->databasePath)) { unlink($this->databasePath); } @@ -41,16 +52,9 @@ class AdminContentControllerTest extends TestCase ('title', 'NNTmux Test'), ('home_link', '/')"); - putenv('APP_ENV=testing'); - putenv('DB_CONNECTION=sqlite'); - putenv('DB_DATABASE='.$this->databasePath); - - $_ENV['APP_ENV'] = 'testing'; - $_ENV['DB_CONNECTION'] = 'sqlite'; - $_ENV['DB_DATABASE'] = $this->databasePath; - $_SERVER['APP_ENV'] = 'testing'; - $_SERVER['DB_CONNECTION'] = 'sqlite'; - $_SERVER['DB_DATABASE'] = $this->databasePath; + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); $app = require __DIR__.'/../../bootstrap/app.php'; @@ -90,6 +94,24 @@ class AdminContentControllerTest extends TestCase } parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; } public function test_admin_can_create_content_without_a_title(): void diff --git a/tests/Unit/ConsoleServiceIgdbDelegationTest.php b/tests/Unit/ConsoleServiceIgdbDelegationTest.php new file mode 100644 index 000000000..156e77ca3 --- /dev/null +++ b/tests/Unit/ConsoleServiceIgdbDelegationTest.php @@ -0,0 +1,90 @@ + 42, 'name' => 'Halo']); + + $igdbService->shouldReceive('isConfigured')->once()->andReturn(true); + $igdbService->shouldReceive('searchConsole')->once()->with('Halo', 'Xbox 360')->andReturn($game); + $igdbService->shouldReceive('buildConsoleData')->once()->with($game, 'Xbox 360')->andReturn([ + 'title' => 'Halo', + 'asin' => '42', + 'review' => 'Sci-fi shooter', + 'coverurl' => 'https://images.example.test/halo.jpg', + 'releasedate' => '2001-11-15', + 'esrb' => '94%', + 'url' => 'https://www.igdb.com/games/halo', + 'publisher' => 'Microsoft', + 'platform' => 'Xbox 360', + 'consolegenre' => 'Action', + 'salesrank' => '', + ]); + + /** @var ConsoleServiceTestDouble $service */ + $service = (new ReflectionClass(ConsoleServiceTestDouble::class))->newInstanceWithoutConstructor(); + + $service->initialize($igdbService); + + $result = $service->fetchIGDBProperties('Halo', 'X360'); + + $this->assertIsArray($result); + $this->assertSame('Xbox 360', $result['platform']); + $this->assertSame('Action', $result['consolegenre']); + $this->assertSame(7, $result['consolegenreid']); + } + + public function test_parse_title_no_longer_returns_legacy_browse_node(): void + { + /** @var ConsoleServiceTestDouble $service */ + $service = (new ReflectionClass(ConsoleServiceTestDouble::class))->newInstanceWithoutConstructor(); + + $result = $service->parseTitle('Halo.3.X360-PROPER'); + + $this->assertIsArray($result); + $this->assertSame('Halo 3', $result['title']); + $this->assertSame('X360', $result['platform']); + $this->assertArrayNotHasKey('node', $result); + } +} + +class ConsoleServiceTestDouble extends ConsoleService +{ + public function initialize(IGDBService $igdbService): void + { + $this->echoOutput = false; + $this->gameQty = 0; + $this->lookupThrottleMs = 0; + $this->imgSavePath = ''; + $this->renamed = false; + $this->failCache = []; + $this->igdbService = $igdbService; + $this->imageService = new ReleaseImageService; + } + + protected function loadGenres(): array + { + return [7 => 'action']; + } +} diff --git a/tests/Unit/IgdbQueryBuilderTest.php b/tests/Unit/IgdbQueryBuilderTest.php new file mode 100644 index 000000000..8957e1ea0 --- /dev/null +++ b/tests/Unit/IgdbQueryBuilderTest.php @@ -0,0 +1,143 @@ + 'https://api.igdb.test/v4', + 'igdb.token_url' => 'https://id.twitch.test/oauth2/token', + 'igdb.credentials.client_id' => 'client-id', + 'igdb.credentials.client_secret' => 'client-secret', + 'igdb.cache_lifetime' => 0, + ]); + } + + public function test_game_query_builder_uses_local_client_and_hydrates_nested_results(): void + { + Http::fake([ + 'https://id.twitch.test/oauth2/token' => Http::response([ + 'access_token' => 'test-token', + 'expires_in' => 3600, + ]), + 'https://api.igdb.test/v4/games' => Http::response([ + [ + 'id' => 42, + 'name' => 'Halo', + 'first_release_date' => 978307200, + 'cover' => [ + 'url' => '//images.igdb.com/igdb/image/upload/t_cover_big/test.jpg', + ], + 'themes' => [ + ['name' => 'Sci-Fi'], + ], + 'platforms' => [6, 14], + ], + ]), + ]); + + $results = Game::search('Halo') + ->whereIn('platforms', [6, 14]) + ->with([ + 'cover' => ['url'], + 'themes' => ['name'], + ]) + ->orderByDesc('aggregated_rating_count') + ->limit(10) + ->get(); + + $this->assertCount(1, $results); + $game = $results->first(); + $this->assertInstanceOf(Game::class, $game); + $this->assertSame('Halo', $game->name); + $this->assertSame('//images.igdb.com/igdb/image/upload/t_cover_big/test.jpg', $game->cover->url); + $this->assertSame('Sci-Fi', $game->themes[0]->name); + $this->assertSame('2001-01-01', $game->first_release_date->format('Y-m-d')); + + Http::assertSent(function (Request $request): bool { + if ($request->url() !== 'https://api.igdb.test/v4/games') { + return false; + } + + $body = $request->body(); + + return $request->hasHeader('Client-ID', 'client-id') + && $request->hasHeader('Authorization', 'Bearer test-token') + && str_contains($body, 'fields *,cover.url,themes.name;') + && str_contains($body, 'search "Halo";') + && str_contains($body, 'where platforms = (6,14);') + && str_contains($body, 'sort aggregated_rating_count desc;') + && str_contains($body, 'limit 10;'); + }); + } + + public function test_igdb_service_reads_the_local_igdb_configuration(): void + { + $service = new IGDBService; + + $this->assertTrue($service->isConfigured()); + + config([ + 'igdb.credentials.client_id' => '', + ]); + + $this->assertFalse($service->isConfigured()); + } + + public function test_igdb_service_can_build_console_data_using_platform_hint_matching(): void + { + Http::fake([ + 'https://id.twitch.test/oauth2/token' => Http::response([ + 'access_token' => 'test-token', + 'expires_in' => 3600, + ]), + 'https://api.igdb.test/v4/games' => Http::response([ + [ + 'id' => 42, + 'name' => 'Halo', + 'summary' => 'Sci-fi shooter', + 'aggregated_rating' => 94.2, + 'first_release_date' => 1005782400, + 'cover' => [ + 'image_id' => 'halo-cover', + ], + 'themes' => [ + ['name' => 'Action'], + ], + 'platforms' => [ + ['name' => 'Xbox 360', 'abbreviation' => 'X360'], + ['name' => 'PC (Microsoft Windows)', 'abbreviation' => 'PC'], + ], + ], + ]), + ]); + + $service = new IGDBService; + $game = $service->searchConsole('Halo', 'X360'); + + $this->assertNotNull($game); + + $consoleData = $service->buildConsoleData($game, 'Xbox 360'); + + $this->assertSame('42', $consoleData['asin']); + $this->assertSame('Xbox 360', $consoleData['platform']); + $this->assertSame('Action', $consoleData['consolegenre']); + $this->assertSame('94%', $consoleData['esrb']); + $this->assertSame('https://images.igdb.com/igdb/image/upload/t_cover_big/halo-cover.jpg', $consoleData['coverurl']); + $this->assertSame('2001-11-15', $consoleData['releasedate']); + } +}