mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Laravel 13 support
This commit is contained in:
@@ -9,7 +9,7 @@ php artisan test --compact --filter=TestName # Run single test (PHPUnit only)
|
|||||||
./vendor/bin/pint --dirty # Format changed files
|
./vendor/bin/pint --dirty # Format changed files
|
||||||
php artisan tmux:start # Start processing engine
|
php artisan tmux:start # Start processing engine
|
||||||
npm run build # Required after frontend changes
|
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
|
## Architecture
|
||||||
|
|||||||
@@ -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)
|
- Intelligent local caching of metadata (TMDB, TVDB, TVMaze, Trakt, IMDB)
|
||||||
- Tmux engine for thread, database, and performance monitoring
|
- Tmux engine for thread, database, and performance monitoring
|
||||||
- Image and video sample support
|
- 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
|
- Full-text search via Elasticsearch or ManticoreSearch
|
||||||
- Dockerized development via Laravel Sail
|
- Dockerized development via Laravel Sail
|
||||||
- RESTful API compatible with newznab standard
|
- 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
|
## Prerequisites
|
||||||
|
|
||||||
- System administration experience (Linux recommended)
|
- 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)
|
- MariaDB 10.6+ or MySQL 8+ (PostgreSQL not supported)
|
||||||
- Composer 2.x
|
- Composer 2.x
|
||||||
- Node.js 18+ and npm for frontend assets
|
- Node.js 18+ and npm for frontend assets
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Facades;
|
namespace App\Facades;
|
||||||
|
|
||||||
|
use Elasticsearch\Client;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Mailerlite\LaravelElasticsearch\Manager;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elasticsearch Facade - provides static access to Elasticsearch client.
|
* 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 get(array $params)
|
||||||
* @method static array deleteByQuery(array $params)
|
* @method static array deleteByQuery(array $params)
|
||||||
*
|
*
|
||||||
* @see Manager
|
* @see Client
|
||||||
*/
|
*/
|
||||||
class Elasticsearch extends Facade // @phpstan-ignore missingType.iterableValue
|
class Elasticsearch extends Facade // @phpstan-ignore missingType.iterableValue
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use Illuminate\Contracts\View\View;
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Jrean\UserVerification\Facades\UserVerification;
|
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
class AdminUserController extends BasePageController
|
class AdminUserController extends BasePageController
|
||||||
@@ -326,9 +325,11 @@ class AdminUserController extends BasePageController
|
|||||||
{
|
{
|
||||||
if ($request->has('id')) {
|
if ($request->has('id')) {
|
||||||
$user = User::find($request->input('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');
|
return redirect()->back()->with('success', 'Email verification for '.$user->username.' sent');
|
||||||
}
|
}
|
||||||
@@ -340,9 +341,13 @@ class AdminUserController extends BasePageController
|
|||||||
{
|
{
|
||||||
if ($request->has('id')) {
|
if ($request->has('id')) {
|
||||||
$user = User::find($request->input('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');
|
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\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Jrean\UserVerification\Traits\VerifiesUsers;
|
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ class RegisterController extends Controller
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use RegistersUsers;
|
use RegistersUsers;
|
||||||
use VerifiesUsers;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where to redirect users after registration.
|
* Where to redirect users after registration.
|
||||||
@@ -52,7 +50,7 @@ class RegisterController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RegistrationStatusService $registrationStatusService
|
private readonly RegistrationStatusService $registrationStatusService
|
||||||
) {
|
) {
|
||||||
$this->middleware('guest', ['except' => ['getVerification', 'getVerificationError']]);
|
$this->middleware('guest');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +67,9 @@ class RegisterController extends Controller
|
|||||||
'notes' => $data['notes'],
|
'notes' => $data['notes'],
|
||||||
'invites' => $data['defaultinvites'],
|
'invites' => $data['defaultinvites'],
|
||||||
'api_token' => md5(Str::random(40)),
|
'api_token' => md5(Str::random(40)),
|
||||||
|
'verified' => false,
|
||||||
|
'email_verified_at' => null,
|
||||||
|
'verification_token' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$role = Role::query()->where('id', '=', $data['roles_id'])->first();
|
$role = Role::query()->where('id', '=', $data['roles_id'])->first();
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ use Illuminate\Support\Arr;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Jrean\UserVerification\Facades\UserVerification;
|
|
||||||
use PragmaRX\Google2FALaravel\Facade as Google2FA;
|
use PragmaRX\Google2FALaravel\Facade as Google2FA;
|
||||||
|
|
||||||
class ProfileController extends BasePageController
|
class ProfileController extends BasePageController
|
||||||
@@ -203,12 +202,8 @@ class ProfileController extends BasePageController
|
|||||||
if (! $this->userdata->hasRole('Admin')) {
|
if (! $this->userdata->hasRole('Admin')) {
|
||||||
if (! empty($request->input('email')) && $this->userdata->email !== $request->input('email')) {
|
if (! empty($request->input('email')) && $this->userdata->email !== $request->input('email')) {
|
||||||
$this->userdata->email = $request->input('email');
|
$this->userdata->email = $request->input('email');
|
||||||
|
$this->userdata->resetEmailVerification();
|
||||||
$verify_user = $this->userdata;
|
$this->userdata->sendEmailVerificationNotification();
|
||||||
|
|
||||||
UserVerification::generate($verify_user);
|
|
||||||
|
|
||||||
UserVerification::send($verify_user, 'User email verification required');
|
|
||||||
|
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -12,6 +12,9 @@ use App\Jobs\SendAccountWillExpireEmail;
|
|||||||
use App\Rules\ValidEmailDomain;
|
use App\Rules\ValidEmailDomain;
|
||||||
use App\Services\InvitationService;
|
use App\Services\InvitationService;
|
||||||
use Carbon\CarbonImmutable;
|
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\Builder;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@@ -32,7 +35,6 @@ use Illuminate\Support\Facades\Log;
|
|||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Jrean\UserVerification\Traits\UserVerification;
|
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
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 expiringSoon(int $days = 7)
|
||||||
* @method static Builder|User expired()
|
* @method static Builder|User expired()
|
||||||
*/
|
*/
|
||||||
final class User extends Authenticatable
|
final class User extends Authenticatable implements MustVerifyEmailContract
|
||||||
{
|
{
|
||||||
use HasFactory; // @phpstan-ignore missingType.generics
|
use HasFactory; // @phpstan-ignore missingType.generics
|
||||||
use HasRoles;
|
use HasRoles;
|
||||||
|
use MustVerifyEmailTrait;
|
||||||
use Notifiable;
|
use Notifiable;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
use UserVerification;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
@@ -345,6 +347,62 @@ final class User extends Authenticatable
|
|||||||
return $query->where('verified', true);
|
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.
|
* Scope to filter users by role.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,17 +9,12 @@ use App\Jobs\SendWelcomeEmail;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Jrean\UserVerification\Exceptions\ModelNotCompliantException;
|
|
||||||
use Jrean\UserVerification\Facades\UserVerification;
|
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
class UserServiceObserver
|
class UserServiceObserver
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle the user "created" event.
|
* Handle the user "created" event.
|
||||||
*
|
|
||||||
*
|
|
||||||
* @throws ModelNotCompliantException
|
|
||||||
*/
|
*/
|
||||||
public function created(User $user): void
|
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'))) {
|
if (! empty(config('mail.from.address') && File::isFile(base_path().'/_install/install.lock'))) {
|
||||||
SendNewRegisteredAccountMail::dispatch($user)->onQueue('newreg');
|
SendNewRegisteredAccountMail::dispatch($user)->onQueue('newreg');
|
||||||
SendWelcomeEmail::dispatch($user)->onQueue('welcomeemails');
|
SendWelcomeEmail::dispatch($user)->onQueue('welcomeemails');
|
||||||
UserVerification::generate($user);
|
$user->sendEmailVerificationNotification();
|
||||||
|
|
||||||
UserVerification::send($user, 'User email verification required', config('mail.from.address'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use App\Services\Search\Drivers\ElasticSearchDriver;
|
|||||||
use App\Services\Search\Drivers\ManticoreSearchDriver;
|
use App\Services\Search\Drivers\ManticoreSearchDriver;
|
||||||
use App\Services\Search\MediaSearchService;
|
use App\Services\Search\MediaSearchService;
|
||||||
use App\Services\Search\SearchService;
|
use App\Services\Search\SearchService;
|
||||||
|
use Elasticsearch\Client;
|
||||||
|
use Elasticsearch\ClientBuilder;
|
||||||
use Illuminate\Contracts\Foundation\Application;
|
use Illuminate\Contracts\Foundation\Application;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
@@ -59,6 +61,15 @@ class SearchServiceProvider extends ServiceProvider
|
|||||||
return new ElasticSearchDriver($config);
|
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)
|
// Register aliases (using prefixed names to avoid conflicts with other packages)
|
||||||
$this->app->alias(SearchService::class, 'search');
|
$this->app->alias(SearchService::class, 'search');
|
||||||
$this->app->alias(ManticoreSearchDriver::class, 'search.manticore');
|
$this->app->alias(ManticoreSearchDriver::class, 'search.manticore');
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-172
@@ -9,13 +9,10 @@ use App\Models\ConsoleInfo;
|
|||||||
use App\Models\Genre;
|
use App\Models\Genre;
|
||||||
use App\Models\Release;
|
use App\Models\Release;
|
||||||
use App\Models\Settings;
|
use App\Models\Settings;
|
||||||
use GuzzleHttp\Exception\ClientException;
|
use App\Services\IGDB\Exceptions\IgdbHttpException;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
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.
|
* ConsoleService - Console/Game processing service.
|
||||||
@@ -33,19 +30,11 @@ class ConsoleService
|
|||||||
|
|
||||||
public const int CONS_NTFND = -2;
|
public const int CONS_NTFND = -2;
|
||||||
|
|
||||||
protected const int MATCH_PERCENT = 60;
|
|
||||||
|
|
||||||
public bool $echoOutput;
|
public bool $echoOutput;
|
||||||
|
|
||||||
public ?string $pubkey;
|
|
||||||
|
|
||||||
public ?string $privkey;
|
|
||||||
|
|
||||||
public ?string $asstag;
|
|
||||||
|
|
||||||
public int $gameQty;
|
public int $gameQty;
|
||||||
|
|
||||||
public int $sleepTime;
|
public int $lookupThrottleMs;
|
||||||
|
|
||||||
public string $imgSavePath;
|
public string $imgSavePath;
|
||||||
|
|
||||||
@@ -56,20 +45,18 @@ class ConsoleService
|
|||||||
*/
|
*/
|
||||||
public array $failCache;
|
public array $failCache;
|
||||||
|
|
||||||
|
protected IGDBService $igdbService;
|
||||||
|
|
||||||
protected ReleaseImageService $imageService;
|
protected ReleaseImageService $imageService;
|
||||||
|
|
||||||
protected mixed $igdbSleep;
|
public function __construct(?ReleaseImageService $imageService = null, ?IGDBService $igdbService = null)
|
||||||
|
|
||||||
public function __construct(?ReleaseImageService $imageService = null)
|
|
||||||
{
|
{
|
||||||
$this->echoOutput = config('nntmux.echocli');
|
$this->echoOutput = config('nntmux.echocli');
|
||||||
$this->imageService = $imageService ?? new ReleaseImageService;
|
$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->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->imgSavePath = config('nntmux_settings.covers_path').'/console/';
|
||||||
$this->renamed = (int) Settings::settingValue('lookupgames') === 2;
|
$this->renamed = (int) Settings::settingValue('lookupgames') === 2;
|
||||||
|
|
||||||
@@ -369,7 +356,7 @@ class ConsoleService
|
|||||||
{
|
{
|
||||||
$consoleId = self::CONS_NTFND;
|
$consoleId = self::CONS_NTFND;
|
||||||
|
|
||||||
$igdb = $this->fetchIGDBProperties($gameInfo['title'], $gameInfo['node']);
|
$igdb = $this->fetchIGDBProperties($gameInfo['title'], $gameInfo['platform']);
|
||||||
if ($igdb !== false) {
|
if ($igdb !== false) {
|
||||||
if ($igdb['coverurl'] !== '') {
|
if ($igdb['coverurl'] !== '') {
|
||||||
$igdb['cover'] = 1;
|
$igdb['cover'] = 1;
|
||||||
@@ -402,99 +389,32 @@ class ConsoleService
|
|||||||
*/
|
*/
|
||||||
public function fetchIGDBProperties(string $gameInfo, string $gamePlatform): bool|array|\StdClass
|
public function fetchIGDBProperties(string $gameInfo, string $gamePlatform): bool|array|\StdClass
|
||||||
{
|
{
|
||||||
$bestMatch = false;
|
|
||||||
|
|
||||||
$gamePlatform = $this->replacePlatform($gamePlatform);
|
$gamePlatform = $this->replacePlatform($gamePlatform);
|
||||||
|
|
||||||
if (config('config.credentials.client_id') !== '' && config('config.credentials.client_secret') !== '') {
|
if (! $this->igdbService->isConfigured()) {
|
||||||
try {
|
return false;
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
$game = $this->igdbService->searchConsole($gameInfo, $gamePlatform);
|
||||||
|
if ($game === null) {
|
||||||
cli()->notice('IGDB found no valid results');
|
cli()->notice('IGDB found no valid results');
|
||||||
|
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
cli()->error('Error fetching IGDB properties: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -530,7 +450,7 @@ class ConsoleService
|
|||||||
|
|
||||||
foreach ($res as $arr) {
|
foreach ($res as $arr) {
|
||||||
$startTime = now()->timestamp;
|
$startTime = now()->timestamp;
|
||||||
$usedAmazon = false;
|
$usedExternalLookup = false;
|
||||||
$gameId = self::CONS_NTFND;
|
$gameId = self::CONS_NTFND;
|
||||||
$gameInfo = $this->parseTitle($arr['searchname']);
|
$gameInfo = $this->parseTitle($arr['searchname']);
|
||||||
|
|
||||||
@@ -550,7 +470,7 @@ class ConsoleService
|
|||||||
$gameId = -2;
|
$gameId = -2;
|
||||||
} elseif ($gameCheck === false) {
|
} elseif ($gameCheck === false) {
|
||||||
$gameId = $this->updateConsoleInfo($gameInfo);
|
$gameId = $this->updateConsoleInfo($gameInfo);
|
||||||
$usedAmazon = true;
|
$usedExternalLookup = true;
|
||||||
if ($gameId === self::CONS_NTFND) {
|
if ($gameId === self::CONS_NTFND) {
|
||||||
$this->failCache[] = $gameInfo['title'].$gameInfo['platform'];
|
$this->failCache[] = $gameInfo['title'].$gameInfo['platform'];
|
||||||
}
|
}
|
||||||
@@ -568,10 +488,10 @@ class ConsoleService
|
|||||||
// Update release.
|
// Update release.
|
||||||
Release::query()->where('id', $arr['id'])->update(['consoleinfo_id' => $gameId]);
|
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);
|
$diff = floor((now()->timestamp - $startTime) * 1000000);
|
||||||
if ($this->sleepTime * 1000 - $diff > 0 && $usedAmazon === true) {
|
if ($this->lookupThrottleMs * 1000 - $diff > 0 && $usedExternalLookup === true) {
|
||||||
usleep((int) ($this->sleepTime * 1000 - $diff));
|
usleep((int) ($this->lookupThrottleMs * 1000 - $diff));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif ($this->echoOutput) {
|
} elseif ($this->echoOutput) {
|
||||||
@@ -637,9 +557,7 @@ class ConsoleService
|
|||||||
$platform = 'XBOX360';
|
$platform = 'XBOX360';
|
||||||
}
|
}
|
||||||
|
|
||||||
$browseNode = $this->getBrowseNode($platform);
|
|
||||||
$result['platform'] = $platform;
|
$result['platform'] = $platform;
|
||||||
$result['node'] = $browseNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$result['release'] = $releaseName;
|
$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
|
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
|
// Protected Helper Methods
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use App\Models\GamesInfo;
|
|||||||
use App\Models\Genre;
|
use App\Models\Genre;
|
||||||
use App\Models\Release;
|
use App\Models\Release;
|
||||||
use App\Models\Settings;
|
use App\Models\Settings;
|
||||||
|
use App\Services\IGDB\Exceptions\IgdbHttpException;
|
||||||
use App\Services\Releases\ReleaseBrowseService;
|
use App\Services\Releases\ReleaseBrowseService;
|
||||||
use GuzzleHttp\Exception\ClientException;
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -69,8 +69,6 @@ class GamesService
|
|||||||
|
|
||||||
protected string $_classUsed = '';
|
protected string $_classUsed = '';
|
||||||
|
|
||||||
protected mixed $igdbSleep;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
@@ -461,9 +459,8 @@ class GamesService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (ClientException $e) {
|
} catch (IgdbHttpException $e) {
|
||||||
if ($e->getCode() === 429) {
|
if ($e->getStatusCode() === 429) {
|
||||||
$this->igdbSleep = now()->endOfMonth();
|
|
||||||
Log::warning('GamesService: IGDB rate limit exceeded');
|
Log::warning('GamesService: IGDB rate limit exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\IGDB\Models;
|
||||||
|
|
||||||
|
class Company extends BaseModel
|
||||||
|
{
|
||||||
|
protected const string ENDPOINT = 'companies';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\IGDB\Models;
|
||||||
|
|
||||||
|
class Game extends BaseModel
|
||||||
|
{
|
||||||
|
protected const string ENDPOINT = 'games';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\IGDB\Models;
|
||||||
|
|
||||||
|
class Platform extends BaseModel
|
||||||
|
{
|
||||||
|
protected const string ENDPOINT = 'platforms';
|
||||||
|
}
|
||||||
@@ -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
@@ -4,19 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Services\IGDB\Models\Company;
|
||||||
|
use App\Services\IGDB\Models\Game;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use MarcReichel\IGDBLaravel\Models\Company;
|
|
||||||
use MarcReichel\IGDBLaravel\Models\Game;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IGDBService - IGDB (Internet Game Database) API integration.
|
* IGDBService - IGDB (Internet Game Database) API integration.
|
||||||
*
|
*
|
||||||
* Note: MarcReichel\IGDBLaravel\Models\Game and Company use dynamic properties
|
* Note: Local IGDB resource models expose dynamic properties
|
||||||
* (name, id, etc.) that PHPStan cannot resolve.
|
* (name, id, etc.) that PHPStan cannot fully resolve.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Rate limiting and caching
|
* - Rate limiting and caching
|
||||||
@@ -44,13 +44,42 @@ class IGDBService
|
|||||||
// PC Platform IDs in IGDB
|
// PC Platform IDs in IGDB
|
||||||
protected const array PC_PLATFORM_IDS = [6, 13, 14, 3]; // PC Windows, DOS, Mac, Linux
|
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.
|
* Check if IGDB is configured.
|
||||||
*/
|
*/
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
return config('config.credentials.client_id') !== ''
|
return config('igdb.credentials.client_id') !== ''
|
||||||
&& config('config.credentials.client_secret') !== '';
|
&& config('igdb.credentials.client_secret') !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +108,7 @@ class IGDBService
|
|||||||
return $cached instanceof Game ? $cached : null;
|
return $cached instanceof Game ? $cached : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$game = $this->searchWithStrategies($title);
|
$game = $this->searchWithStrategies($title, self::PC_PLATFORM_IDS);
|
||||||
|
|
||||||
if ($game !== null) {
|
if ($game !== null) {
|
||||||
Cache::put($cacheKey, $game, self::GAME_CACHE_TTL);
|
Cache::put($cacheKey, $game, self::GAME_CACHE_TTL);
|
||||||
@@ -92,6 +121,49 @@ class IGDBService
|
|||||||
return $game;
|
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.
|
* Get complete game details from a Game object.
|
||||||
*
|
*
|
||||||
@@ -125,10 +197,10 @@ class IGDBService
|
|||||||
/**
|
/**
|
||||||
* Search IGDB using multiple strategies for better match rates.
|
* 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
|
// Strategy 1: Exact name search with PC platform filter
|
||||||
$game = $this->searchExact($title);
|
$game = $this->searchExact($title, $platformIds, $platformHint);
|
||||||
if ($game !== null) {
|
if ($game !== null) {
|
||||||
Log::debug('IGDBService: Exact match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound
|
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
|
// Strategy 2: Fuzzy search using IGDB's search endpoint
|
||||||
$game = $this->searchFuzzy($title);
|
$game = $this->searchFuzzy($title, $platformIds, $platformHint);
|
||||||
if ($game !== null) {
|
if ($game !== null) {
|
||||||
Log::debug('IGDBService: Fuzzy match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound
|
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
|
// Strategy 3: Search without special characters
|
||||||
$cleanTitle = preg_replace('/[^a-zA-Z0-9\s]/', '', $title);
|
$cleanTitle = preg_replace('/[^a-zA-Z0-9\s]/', '', $title);
|
||||||
if ($cleanTitle !== $title && $cleanTitle !== '') {
|
if ($cleanTitle !== $title && $cleanTitle !== '') {
|
||||||
$game = $this->searchFuzzy($cleanTitle);
|
$game = $this->searchFuzzy($cleanTitle, $platformIds, $platformHint);
|
||||||
if ($game !== null) {
|
if ($game !== null) {
|
||||||
Log::debug('IGDBService: Clean title match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound
|
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
|
// Strategy 4: Try with common subtitle patterns removed
|
||||||
$baseTitle = $this->extractBaseTitle($title);
|
$baseTitle = $this->extractBaseTitle($title);
|
||||||
if ($baseTitle !== $title && $baseTitle !== $cleanTitle && $baseTitle !== '') {
|
if ($baseTitle !== $title && $baseTitle !== $cleanTitle && $baseTitle !== '') {
|
||||||
$game = $this->searchFuzzy($baseTitle);
|
$game = $this->searchFuzzy($baseTitle, $platformIds, $platformHint);
|
||||||
if ($game !== null) {
|
if ($game !== null) {
|
||||||
Log::debug('IGDBService: Base title match found', ['title' => $title, 'matched' => $game->name]); // @phpstan-ignore property.notFound
|
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.
|
* 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 {
|
try {
|
||||||
$result = RateLimiter::attempt(
|
$results = RateLimiter::attempt(
|
||||||
self::RATE_LIMIT_KEY,
|
self::RATE_LIMIT_KEY,
|
||||||
self::REQUESTS_PER_MINUTE,
|
self::REQUESTS_PER_MINUTE,
|
||||||
function () use ($title) {
|
function () use ($platformIds, $title) {
|
||||||
return Game::where('name', $title)
|
$query = Game::where('name', $title)
|
||||||
->whereIn('platforms', self::PC_PLATFORM_IDS)
|
|
||||||
->with($this->getGameRelations())
|
->with($this->getGameRelations())
|
||||||
->orderByDesc('aggregated_rating_count')
|
->orderByDesc('aggregated_rating_count')
|
||||||
->first();
|
->limit(10);
|
||||||
|
|
||||||
|
if ($platformIds !== null) {
|
||||||
|
$query->whereIn('platforms', $platformIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
},
|
},
|
||||||
self::DECAY_SECONDS
|
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) {
|
} catch (\Exception $e) {
|
||||||
Log::warning('IGDBService: Exact search error', ['error' => $e->getMessage()]);
|
Log::warning('IGDBService: Exact search error', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
@@ -198,20 +279,24 @@ class IGDBService
|
|||||||
/**
|
/**
|
||||||
* Fuzzy search on IGDB using the search endpoint.
|
* 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 {
|
try {
|
||||||
$results = RateLimiter::attempt(
|
$results = RateLimiter::attempt(
|
||||||
self::RATE_LIMIT_KEY,
|
self::RATE_LIMIT_KEY,
|
||||||
self::REQUESTS_PER_MINUTE,
|
self::REQUESTS_PER_MINUTE,
|
||||||
function () use ($title) {
|
function () use ($platformIds, $title) {
|
||||||
return Game::search($title)
|
$query = Game::search($title)
|
||||||
->whereIn('platforms', self::PC_PLATFORM_IDS)
|
|
||||||
->where('category', 0) // Main game only (not DLC, expansion, etc.)
|
->where('category', 0) // Main game only (not DLC, expansion, etc.)
|
||||||
->with($this->getGameRelations())
|
->with($this->getGameRelations())
|
||||||
->orderByDesc('aggregated_rating_count')
|
->orderByDesc('aggregated_rating_count')
|
||||||
->limit(10)
|
->limit(10);
|
||||||
->get();
|
|
||||||
|
if ($platformIds !== null) {
|
||||||
|
$query->whereIn('platforms', $platformIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
},
|
},
|
||||||
self::DECAY_SECONDS
|
self::DECAY_SECONDS
|
||||||
);
|
);
|
||||||
@@ -220,7 +305,7 @@ class IGDBService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->findBestMatch($results, $title);
|
return $this->findBestMatch($results, $title, $platformHint);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::warning('IGDBService: Fuzzy search error', ['error' => $e->getMessage()]);
|
Log::warning('IGDBService: Fuzzy search error', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
@@ -255,7 +340,7 @@ class IGDBService
|
|||||||
/**
|
/**
|
||||||
* Find the best matching game from results.
|
* 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;
|
$bestMatch = null;
|
||||||
$bestScore = 0;
|
$bestScore = 0;
|
||||||
@@ -275,6 +360,11 @@ class IGDBService
|
|||||||
$score += 2;
|
$score += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($platformHint !== null && $platformHint !== '') {
|
||||||
|
$platformScore = $this->getPlatformMatchScore($game, $platformHint);
|
||||||
|
$score += $platformScore > 0 ? $platformScore : -10;
|
||||||
|
}
|
||||||
|
|
||||||
// Check alternative names if available
|
// Check alternative names if available
|
||||||
if (isset($game->alternative_names)) {
|
if (isset($game->alternative_names)) {
|
||||||
foreach ($game->alternative_names as $altName) {
|
foreach ($game->alternative_names as $altName) {
|
||||||
@@ -302,6 +392,33 @@ class IGDBService
|
|||||||
return $bestMatch;
|
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.
|
* Build game data array from IGDB Game model.
|
||||||
*
|
*
|
||||||
@@ -416,6 +533,93 @@ class IGDBService
|
|||||||
return array_filter($genres);
|
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.
|
* Get properly formatted IGDB image URL.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Rules\RecaptchaRule;
|
||||||
use App\Rules\TurnstileRule;
|
use App\Rules\TurnstileRule;
|
||||||
|
use App\Services\RecaptchaService;
|
||||||
use App\Services\TurnstileService;
|
use App\Services\TurnstileService;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ class CaptchaHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default to reCAPTCHA
|
// Default to reCAPTCHA
|
||||||
return app('captcha')->display($attributes);
|
return RecaptchaService::display($attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +88,7 @@ class CaptchaHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default to reCAPTCHA
|
// Default to reCAPTCHA
|
||||||
return app('captcha')->renderJs();
|
return RecaptchaService::renderJs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +132,7 @@ class CaptchaHelper
|
|||||||
return [
|
return [
|
||||||
$fieldName => [
|
$fieldName => [
|
||||||
'required',
|
'required',
|
||||||
'captcha',
|
new RecaptchaRule,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-9
@@ -8,16 +8,17 @@ use App\Http\Middleware\Google2FAMiddleware;
|
|||||||
use App\Http\Middleware\NoCacheForAuthenticatedUsers;
|
use App\Http\Middleware\NoCacheForAuthenticatedUsers;
|
||||||
use App\Http\Middleware\SetUserTimezone;
|
use App\Http\Middleware\SetUserTimezone;
|
||||||
use App\Http\Middleware\TrustedDevice2FAMiddleware;
|
use App\Http\Middleware\TrustedDevice2FAMiddleware;
|
||||||
|
use App\Http\Middleware\TrustProxies as AppTrustProxies;
|
||||||
use Creativeorange\Gravatar\GravatarServiceProvider;
|
use Creativeorange\Gravatar\GravatarServiceProvider;
|
||||||
|
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
|
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||||
use Illuminate\Http\Middleware\TrustProxies;
|
use Illuminate\Http\Middleware\TrustProxies as BaseTrustProxies;
|
||||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||||
use Jrean\UserVerification\Middleware\IsVerified;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Jrean\UserVerification\UserVerificationServiceProvider;
|
|
||||||
use Laravel\Tinker\TinkerServiceProvider;
|
use Laravel\Tinker\TinkerServiceProvider;
|
||||||
use Sentry\Laravel\Integration;
|
use Sentry\Laravel\Integration;
|
||||||
use Spatie\Permission\Middleware\PermissionMiddleware;
|
use Spatie\Permission\Middleware\PermissionMiddleware;
|
||||||
@@ -27,7 +28,6 @@ use Spatie\Permission\Middleware\RoleOrPermissionMiddleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withProviders([
|
->withProviders([
|
||||||
TinkerServiceProvider::class,
|
TinkerServiceProvider::class,
|
||||||
UserVerificationServiceProvider::class,
|
|
||||||
GravatarServiceProvider::class,
|
GravatarServiceProvider::class,
|
||||||
])
|
])
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -36,6 +36,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
channels: __DIR__.'/../routes/channels.php',
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
|
then: static function (): void {
|
||||||
|
Route::middleware('api')
|
||||||
|
->prefix('rss')
|
||||||
|
->group(base_path('routes/rss.php'));
|
||||||
|
},
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->redirectGuestsTo(fn () => route('login'));
|
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||||
@@ -55,14 +60,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->append([
|
$middleware->append([
|
||||||
CheckForMaintenanceMode::class,
|
PreventRequestsDuringMaintenance::class,
|
||||||
ForceJsonOnAPI::class,
|
ForceJsonOnAPI::class,
|
||||||
BlockAbusiveServices::class, // Block AIOStreams, Oracle Cloud, UsenetStreamer, Cloudflare WARP
|
BlockAbusiveServices::class, // Block AIOStreams, Oracle Cloud, UsenetStreamer, Cloudflare WARP
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->replace(
|
$middleware->replace(
|
||||||
TrustProxies::class,
|
BaseTrustProxies::class,
|
||||||
Monicahq\Cloudflare\Http\Middleware\TrustProxies::class
|
AppTrustProxies::class
|
||||||
);
|
);
|
||||||
|
|
||||||
$middleware->web([
|
$middleware->web([
|
||||||
@@ -79,7 +84,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'2fa' => Google2FAMiddleware::class,
|
'2fa' => Google2FAMiddleware::class,
|
||||||
'bindings' => SubstituteBindings::class,
|
'bindings' => SubstituteBindings::class,
|
||||||
'clearance' => ClearanceMiddleware::class,
|
'clearance' => ClearanceMiddleware::class,
|
||||||
'isVerified' => IsVerified::class,
|
'isVerified' => EnsureEmailIsVerified::class,
|
||||||
'permission' => PermissionMiddleware::class,
|
'permission' => PermissionMiddleware::class,
|
||||||
'role' => RoleMiddleware::class,
|
'role' => RoleMiddleware::class,
|
||||||
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Providers\CategorizationServiceProvider;
|
|||||||
use App\Providers\ForumServiceProvider;
|
use App\Providers\ForumServiceProvider;
|
||||||
use App\Providers\HorizonServiceProvider;
|
use App\Providers\HorizonServiceProvider;
|
||||||
use App\Providers\ProcessingServiceProvider;
|
use App\Providers\ProcessingServiceProvider;
|
||||||
use App\Providers\RouteServiceProvider;
|
|
||||||
use App\Providers\SearchServiceProvider;
|
use App\Providers\SearchServiceProvider;
|
||||||
use App\Providers\TvProcessingServiceProvider;
|
use App\Providers\TvProcessingServiceProvider;
|
||||||
use App\Providers\UserServiceProvider;
|
use App\Providers\UserServiceProvider;
|
||||||
@@ -19,7 +18,6 @@ return [
|
|||||||
ForumServiceProvider::class,
|
ForumServiceProvider::class,
|
||||||
HorizonServiceProvider::class,
|
HorizonServiceProvider::class,
|
||||||
ProcessingServiceProvider::class,
|
ProcessingServiceProvider::class,
|
||||||
RouteServiceProvider::class,
|
|
||||||
SearchServiceProvider::class,
|
SearchServiceProvider::class,
|
||||||
TvProcessingServiceProvider::class,
|
TvProcessingServiceProvider::class,
|
||||||
UserServiceProvider::class,
|
UserServiceProvider::class,
|
||||||
|
|||||||
+9
-14
@@ -44,39 +44,35 @@
|
|||||||
"ext-xmlwriter": "*",
|
"ext-xmlwriter": "*",
|
||||||
"ext-zlib": "*",
|
"ext-zlib": "*",
|
||||||
"aharen/omdbapi": "^2.1",
|
"aharen/omdbapi": "^2.1",
|
||||||
"anhskohbo/no-captcha": "^3.7.0",
|
|
||||||
"bacon/bacon-qr-code": "^3.0",
|
"bacon/bacon-qr-code": "^3.0",
|
||||||
"canihavesomecoffee/thetvdbapi": "^2.0",
|
"canihavesomecoffee/thetvdbapi": "^2.0",
|
||||||
"creativeorange/gravatar": "^1.0",
|
"creativeorange/gravatar": "^1.0",
|
||||||
"dariusiii/net_nntp": "^4.0",
|
"dariusiii/net_nntp": "^4.0",
|
||||||
"dariusiii/rarinfo": "^3.0",
|
"dariusiii/rarinfo": "^3.0",
|
||||||
"dariusiii/tv-maze-php-api": "^2.0.0",
|
"dariusiii/tv-maze-php-api": "^2.0.0",
|
||||||
|
"elasticsearch/elasticsearch": "^7.17",
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"geoip2/geoip2": "^3.1",
|
"geoip2/geoip2": "^3.1",
|
||||||
"guzzlehttp/guzzle": "^7.9",
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
"intervention/image": "^3.11",
|
"intervention/image": "^3.11",
|
||||||
"jrean/laravel-user-verification": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/framework": "^12.28",
|
"laravel/horizon": "^5.45",
|
||||||
"laravel/horizon": "^5.23",
|
"laravel/pulse": "^1.7",
|
||||||
"laravel/pulse": "^1.4",
|
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/scout": "^10.13",
|
"laravel/scout": "^11.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/blaze": "^1.0",
|
"livewire/blaze": "^1.0",
|
||||||
"livewire/livewire": "^3.6",
|
"livewire/livewire": "^3.6",
|
||||||
"livewire/volt": "^1.6",
|
"livewire/volt": "^1.6",
|
||||||
"mailerlite/laravel-elasticsearch": "^11.2",
|
|
||||||
"manticoresoftware/manticoresearch-php": "^4.0.0",
|
"manticoresoftware/manticoresearch-php": "^4.0.0",
|
||||||
"marcreichel/igdb-laravel": "^5.2",
|
|
||||||
"mhor/php-mediainfo": "^5.6",
|
"mhor/php-mediainfo": "^5.6",
|
||||||
"monicahq/laravel-cloudflare": "^4.0",
|
|
||||||
"monolog/monolog": "^3.8",
|
"monolog/monolog": "^3.8",
|
||||||
"nesbot/carbon": "^3.8",
|
"nesbot/carbon": "^3.8",
|
||||||
"php-ffmpeg/php-ffmpeg": "^1.3",
|
"php-ffmpeg/php-ffmpeg": "^1.3",
|
||||||
"pragmarx/google2fa-laravel": "^3.0",
|
"pragmarx/google2fa-laravel": "^3.0",
|
||||||
"predis/predis": "^v3.0.0",
|
"predis/predis": "^v3.0.0",
|
||||||
"propaganistas/laravel-disposable-email": "^2.4",
|
"propaganistas/laravel-disposable-email": "^2.4",
|
||||||
"riari/laravel-forum": "^7.0",
|
"riari/laravel-forum": "8.x-dev",
|
||||||
"sentry/sentry": "^4.21",
|
"sentry/sentry": "^4.21",
|
||||||
"sentry/sentry-laravel": "^4.13",
|
"sentry/sentry-laravel": "^4.13",
|
||||||
"spatie/laravel-directory-cleanup": "^1.10",
|
"spatie/laravel-directory-cleanup": "^1.10",
|
||||||
@@ -93,15 +89,14 @@
|
|||||||
"voku/simple_html_dom": "^4.8"
|
"voku/simple_html_dom": "^4.8"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"barryvdh/laravel-ide-helper": "^3.5",
|
"barryvdh/laravel-ide-helper": "^3.7",
|
||||||
"beyondcode/laravel-dump-server": "^2.1",
|
|
||||||
"captainhook/captainhook": "^5.4",
|
"captainhook/captainhook": "^5.4",
|
||||||
"captainhook/plugin-composer": "^5.2",
|
"captainhook/plugin-composer": "^5.2",
|
||||||
"driftingly/rector-laravel": "^2.0",
|
"driftingly/rector-laravel": "^2.0",
|
||||||
"ergebnis/composer-normalize": "^2.30",
|
"ergebnis/composer-normalize": "^2.30",
|
||||||
"friendsofphp/php-cs-fixer": "^3.70",
|
"friendsofphp/php-cs-fixer": "^3.70",
|
||||||
"jubeki/laravel-code-style": "^2.0",
|
"jubeki/laravel-code-style": "^2.0",
|
||||||
"larastan/larastan": "^3.2",
|
"larastan/larastan": "^3.9",
|
||||||
"laravel/boost": "^2.0",
|
"laravel/boost": "^2.0",
|
||||||
"laravel/pint": "^1.13",
|
"laravel/pint": "^1.13",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
|
|||||||
Generated
+286
-748
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ use App\Facades\Yenc;
|
|||||||
use Creativeorange\Gravatar\Facades\Gravatar;
|
use Creativeorange\Gravatar\Facades\Gravatar;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
use Jrean\UserVerification\Facades\UserVerification;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ return [
|
|||||||
'Google2FA' => PragmaRX\Google2FALaravel\Facade::class,
|
'Google2FA' => PragmaRX\Google2FALaravel\Facade::class,
|
||||||
'Gravatar' => Gravatar::class,
|
'Gravatar' => Gravatar::class,
|
||||||
'RedisManager' => Redis::class,
|
'RedisManager' => Redis::class,
|
||||||
'UserVerification' => UserVerification::class,
|
|
||||||
'Yenc' => Yenc::class,
|
'Yenc' => Yenc::class,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|
||||||
|
|||||||
+5
-15
@@ -1,6 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
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
|
* 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).
|
* The local IGDB client caches query responses for this many seconds.
|
||||||
* Here you can set how long each query should be cached (in seconds).
|
|
||||||
*
|
*
|
||||||
* To turn cache off set this value to 0
|
* To turn cache off set this value to 0
|
||||||
*
|
*
|
||||||
* Recommended: 86400 (24 hours) for game data that rarely changes
|
* Recommended: 86400 (24 hours) for game data that rarely changes
|
||||||
*/
|
*/
|
||||||
'cache_lifetime' => (int) env('IGDB_CACHE_LIFETIME', 86400),
|
'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
@@ -1,19 +1,51 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
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'),
|
||||||
|
],
|
||||||
|
|
||||||
'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'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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>
|
<testsuites>
|
||||||
<testsuite name="Install">
|
<testsuite name="Install">
|
||||||
<directory suffix="Test.php">tests/Install</directory>
|
<directory suffix="Test.php">tests/Install</directory>
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
<env name="QUEUE_DRIVER" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="MAIL_DRIVER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
</php>
|
</php>
|
||||||
<logging/>
|
<logging/>
|
||||||
<source>
|
<source>
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ return static function (RectorConfig $rectorConfig): void {
|
|||||||
|
|
||||||
// define sets of rules
|
// define sets of rules
|
||||||
$rectorConfig->sets([
|
$rectorConfig->sets([
|
||||||
LaravelSetList::LARAVEL_100,
|
LaravelSetList::LARAVEL_130,
|
||||||
SetList::PHP_84,
|
SetList::PHP_84,
|
||||||
SetList::CODE_QUALITY,
|
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
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ use App\Http\Controllers\AdultController;
|
|||||||
use App\Http\Controllers\AjaxController;
|
use App\Http\Controllers\AjaxController;
|
||||||
use App\Http\Controllers\Api\FileListApiController;
|
use App\Http\Controllers\Api\FileListApiController;
|
||||||
use App\Http\Controllers\ApiHelpController;
|
use App\Http\Controllers\ApiHelpController;
|
||||||
|
use App\Http\Controllers\Auth\EmailVerificationController;
|
||||||
use App\Http\Controllers\Auth\ForgotPasswordController;
|
use App\Http\Controllers\Auth\ForgotPasswordController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use App\Http\Controllers\Auth\RegisterController;
|
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::get('register', [RegisterController::class, 'showRegistrationForm'])->name('register');
|
||||||
Route::post('register', [RegisterController::class, 'register'])->name('register.post');
|
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'], 'forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('forgottenpassword')->withoutMiddleware(['auth']);
|
||||||
Route::match(['GET', 'POST'], 'password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request')->withoutMiddleware(['auth']);
|
Route::match(['GET', 'POST'], 'password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request')->withoutMiddleware(['auth']);
|
||||||
|
|||||||
@@ -25,10 +25,21 @@ class AdminContentControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
private string $databasePath;
|
private string $databasePath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string|false>
|
||||||
|
*/
|
||||||
|
private array $originalEnvironment = [];
|
||||||
|
|
||||||
public function createApplication()
|
public function createApplication()
|
||||||
{
|
{
|
||||||
$this->databasePath = sys_get_temp_dir().'/nntmux-admin-content-test.sqlite';
|
$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)) {
|
if (file_exists($this->databasePath)) {
|
||||||
unlink($this->databasePath);
|
unlink($this->databasePath);
|
||||||
}
|
}
|
||||||
@@ -41,16 +52,9 @@ class AdminContentControllerTest extends TestCase
|
|||||||
('title', 'NNTmux Test'),
|
('title', 'NNTmux Test'),
|
||||||
('home_link', '/')");
|
('home_link', '/')");
|
||||||
|
|
||||||
putenv('APP_ENV=testing');
|
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||||
putenv('DB_CONNECTION=sqlite');
|
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
|
||||||
putenv('DB_DATABASE='.$this->databasePath);
|
$this->setEnvironmentValue('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;
|
|
||||||
|
|
||||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||||
|
|
||||||
@@ -90,6 +94,24 @@ class AdminContentControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
parent::tearDown();
|
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
|
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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user