Laravel 13 support

This commit is contained in:
DariusIII
2026-03-26 10:48:12 +01:00
parent 925f2d5383
commit 6aee2ef718
39 changed files with 1741 additions and 1050 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
{
@@ -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');
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationController extends Controller
{
public function show(Request $request): View|RedirectResponse
{
if ($request->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');
}
}
@@ -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();
+2 -7
View File
@@ -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();
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Symfony\Component\HttpFoundation\Request;
class TrustProxies extends Middleware
{
/**
* Trust the upstream reverse proxy chain.
*
* This keeps Cloudflare / CDN deployments working without the external
* package while remaining compatible with Laravel 12 and 13.
*
* @var string|array<int, string>|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;
}
+61 -3
View File
@@ -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<string>
@@ -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.
*/
+1 -8
View File
@@ -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();
}
}
}
+11
View File
@@ -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');
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Services\RecaptchaService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class RecaptchaRule implements ValidationRule
{
/**
* Validate the reCAPTCHA response token.
*
* @param Closure(string): void $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || $value === '') {
$fail('Please complete the captcha verification.');
return;
}
if (! RecaptchaService::verify($value)) {
$fail('The captcha verification failed. Please try again.');
}
}
}
+28 -168
View File
@@ -9,13 +9,10 @@ use App\Models\ConsoleInfo;
use App\Models\Genre;
use App\Models\Release;
use App\Models\Settings;
use GuzzleHttp\Exception\ClientException;
use App\Services\IGDB\Exceptions\IgdbHttpException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use MarcReichel\IGDBLaravel\Models\Company;
use MarcReichel\IGDBLaravel\Models\Game;
use MarcReichel\IGDBLaravel\Models\Platform;
/**
* ConsoleService - Console/Game processing service.
@@ -33,19 +30,11 @@ class ConsoleService
public const int CONS_NTFND = -2;
protected const int MATCH_PERCENT = 60;
public bool $echoOutput;
public ?string $pubkey;
public ?string $privkey;
public ?string $asstag;
public int $gameQty;
public int $sleepTime;
public int $lookupThrottleMs;
public string $imgSavePath;
@@ -56,20 +45,18 @@ class ConsoleService
*/
public array $failCache;
protected IGDBService $igdbService;
protected ReleaseImageService $imageService;
protected mixed $igdbSleep;
public function __construct(?ReleaseImageService $imageService = null)
public function __construct(?ReleaseImageService $imageService = null, ?IGDBService $igdbService = null)
{
$this->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,100 +389,33 @@ 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');
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();
}
$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
// ========================================
+3 -6
View File
@@ -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');
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB;
use App\Services\IGDB\Exceptions\IgdbHttpException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class Client
{
protected const string TOKEN_CACHE_KEY = 'igdb:access_token';
/**
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>|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<int, array<string, mixed>> $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;
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB;
use ArrayAccess;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Carbon;
class DataNode implements Arrayable, ArrayAccess
{
/**
* @var array<string, mixed>
*/
protected array $attributes = [];
/**
* @param array<string, mixed> $attributes
* @param array<int, string> $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<string, mixed>
*/
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);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB\Exceptions;
use RuntimeException;
class IgdbHttpException extends RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode,
private readonly ?string $responseBody = null,
) {
parent::__construct($message, $statusCode);
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function getResponseBody(): ?string
{
return $this->responseBody;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB\Models;
use App\Services\IGDB\DataNode;
use App\Services\IGDB\QueryBuilder;
abstract class BaseModel extends DataNode
{
protected const string ENDPOINT = '';
/**
* @var array<int, string>
*/
protected const array DATE_FIELDS = [
'created_at',
'updated_at',
'change_date',
'start_date',
'published_at',
'first_release_date',
];
/**
* @param array<string, mixed> $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);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB\Models;
class Company extends BaseModel
{
protected const string ENDPOINT = 'companies';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB\Models;
class Game extends BaseModel
{
protected const string ENDPOINT = 'games';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB\Models;
class Platform extends BaseModel
{
protected const string ENDPOINT = 'platforms';
}
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Services\IGDB;
use App\Services\IGDB\Models\BaseModel;
use Illuminate\Support\Collection;
use InvalidArgumentException;
/**
* @template TModel of BaseModel
*/
class QueryBuilder
{
/**
* @var array<int, string>
*/
protected array $fields = ['*'];
/**
* @var array<int, string>
*/
protected array $whereClauses = [];
protected ?string $searchTerm = null;
protected ?string $sort = null;
protected ?int $limitValue = null;
/**
* @param class-string<TModel> $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<int, int|string> $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<int|string, array<int, string>|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<int, TModel>
*/
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, '\\"').'"',
};
}
}
+231 -27
View File
@@ -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<string, mixed>
*/
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<int, string>
*/
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.
*
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class RecaptchaService
{
/**
* Verify a Google reCAPTCHA token.
*/
public static function verify(string $token, ?string $remoteIp = null): bool
{
$secret = config('captcha.recaptcha.secret', config('captcha.secret'));
if ($secret === null || $secret === '') {
return false;
}
try {
$response = Http::asForm()->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<string, mixed> $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('<div %s></div>', trim($attributesString));
}
/**
* Render the Google reCAPTCHA JavaScript include.
*/
public static function renderJs(): string
{
return '<script src="https://www.google.com/recaptcha/api.js" async defer></script>';
}
}
+5 -3
View File
@@ -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,
],
];
}
+14 -9
View File
@@ -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,
-2
View File
@@ -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,
+9 -14
View File
@@ -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",
Generated
+286 -748
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -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(),
+5 -15
View File
@@ -1,6 +1,10 @@
<?php
return [
'base_url' => 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'),
];
+39 -7
View File
@@ -1,19 +1,51 @@
<?php
return [
'default' => 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'),
],
'sendmail' => '/usr/sbin/sendmail -bs',
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => ['smtp', 'log'],
],
],
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
'markdown' => [
'theme' => 'default',
'paths' => [
resource_path('views/vendor/mail'),
],
],
];
+3 -3
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.1/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<testsuites>
<testsuite name="Install">
<directory suffix="Test.php">tests/Install</directory>
@@ -18,8 +18,8 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="MAIL_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
</php>
<logging/>
<source>
+1 -1
View File
@@ -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,
]);
@@ -0,0 +1,41 @@
@extends('layouts.guest')
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 px-4">
<div class="w-full max-w-md bg-white dark:bg-gray-800 shadow-md rounded-lg p-8 space-y-6">
<div class="space-y-2 text-center">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Verify your email address</h1>
<p class="text-sm text-gray-600 dark:text-gray-300">
Before continuing, please check your inbox for the verification link we emailed to you.
</p>
</div>
@if (session('status') === 'resent')
<div class="rounded-md bg-green-50 dark:bg-green-900/30 p-4 text-sm text-green-700 dark:text-green-200">
A fresh verification link has been sent to your email address.
</div>
@endif
<form method="POST" action="{{ route('verification.send') }}" class="space-y-4">
@csrf
<button
type="submit"
class="w-full inline-flex justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Resend verification email
</button>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button
type="submit"
class="w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Sign out
</button>
</form>
</div>
</div>
@endsection
+4
View File
@@ -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']);
+32 -10
View File
@@ -25,10 +25,21 @@ class AdminContentControllerTest extends TestCase
{
private string $databasePath;
/**
* @var array<string, string|false>
*/
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
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\ConsoleService;
use App\Services\IGDB\Models\Game;
use App\Services\IGDBService;
use App\Services\ReleaseImageService;
use Mockery;
use ReflectionClass;
use Tests\TestCase;
class ConsoleServiceIgdbDelegationTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_fetch_igdb_properties_delegates_to_igdb_service_and_maps_console_genre_id(): void
{
$igdbService = Mockery::mock(IGDBService::class);
$game = new Game(['id' => 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'];
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\IGDB\Models\Game;
use App\Services\IGDBService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class IgdbQueryBuilderTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
config([
'igdb.base_url' => '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']);
}
}