mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Improve security
This commit is contained in:
@@ -15,7 +15,6 @@ use App\Mail\ForgottenPassword;
|
||||
use App\Mail\IncidentDetected;
|
||||
use App\Mail\InvitationMail;
|
||||
use App\Mail\NewAccountCreatedEmail;
|
||||
use App\Mail\PasswordReset;
|
||||
use App\Mail\WelcomeEmail;
|
||||
use App\Models\Invitation;
|
||||
use App\Models\ServiceIncident;
|
||||
@@ -56,7 +55,6 @@ class MailPreview extends Command
|
||||
$mailables = [
|
||||
'welcome' => fn (): Mailable => new WelcomeEmail($user),
|
||||
'forgotten_password' => fn (): Mailable => new ForgottenPassword('https://example.test/reset/abc123'),
|
||||
'password_reset' => fn (): Mailable => new PasswordReset($user, 'TempPass!2026'),
|
||||
'new_account_created' => fn (): Mailable => new NewAccountCreatedEmail($user),
|
||||
'account_change' => fn (): Mailable => new AccountChange($user),
|
||||
'account_will_expire' => fn (): Mailable => new AccountWillExpire($user, 5),
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\UserAccessedApi;
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Http\Controllers\GetNzbController;
|
||||
use App\Models\Category;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Release;
|
||||
@@ -510,7 +511,9 @@ class ApiController extends BasePageController
|
||||
UserRequest::addApiRequest($uid, $request->getRequestUri());
|
||||
$relData = Release::checkGuidForApi($request->input('id'));
|
||||
if ($relData) {
|
||||
return redirect(url('/getnzb?r='.rawurlencode((string) $apiKey).'&id='.rawurlencode((string) $request->input('id')).(($request->has('del') && $request->input('del') === '1') ? '&del=1' : '')));
|
||||
$request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, User::findOrFail($uid));
|
||||
|
||||
return app(GetNzbController::class)->getNzb($request);
|
||||
}
|
||||
|
||||
return showApiError(300, 'No such item (the guid you provided has no release in our database)');
|
||||
|
||||
@@ -9,9 +9,11 @@ use App\Data\Api\DetailsData;
|
||||
use App\Data\Api\ReleaseData;
|
||||
use App\Events\UserAccessedApi;
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Http\Controllers\GetNzbController;
|
||||
use App\Models\Category;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Release;
|
||||
use App\Models\RootCategory;
|
||||
use App\Models\Settings;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Models\User;
|
||||
@@ -194,7 +196,7 @@ class ApiV2Controller extends BasePageController
|
||||
'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'],
|
||||
'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,sort'],
|
||||
],
|
||||
'categories' => $category->map(static fn ($c) => CategoryData::fromCategory($c))->values(),
|
||||
'categories' => $category->map(static fn (RootCategory $rootCategory): CategoryData => CategoryData::fromCategory($rootCategory))->values(),
|
||||
'groups' => Schema::hasTable('usenet_groups')
|
||||
? UsenetGroup::query()
|
||||
->where('active', 1)
|
||||
@@ -613,7 +615,9 @@ class ApiV2Controller extends BasePageController
|
||||
UserRequest::addApiRequest($user->id, $request->getRequestUri());
|
||||
$relData = Release::checkGuidForApi($request->input('id'));
|
||||
if ($relData) {
|
||||
return redirect('/getnzb?r='.rawurlencode((string) $request->input('api_token')).'&id='.rawurlencode((string) $request->input('id')).(($request->has('del') && $request->input('del') === '1') ? '&del=1' : ''));
|
||||
$request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, $user);
|
||||
|
||||
return app(GetNzbController::class)->getNzb($request);
|
||||
}
|
||||
|
||||
return response()->json(['data' => 'No such item (the guid you provided has no release in our database)'], 404);
|
||||
|
||||
@@ -9,8 +9,8 @@ use App\Jobs\SendPasswordForgottenEmail;
|
||||
use App\Models\User;
|
||||
use App\Support\CaptchaHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
@@ -45,15 +45,12 @@ class ForgotPasswordController extends Controller
|
||||
return view('auth.passwords.email');
|
||||
}
|
||||
|
||||
// Handle POST request
|
||||
$sent = '';
|
||||
$email = $request->input('email') ?? '';
|
||||
$rssToken = $request->input('apikey') ?? '';
|
||||
$email = (string) $request->input('email', '');
|
||||
|
||||
if (empty($email) && empty($rssToken)) {
|
||||
if ($email === '') {
|
||||
return redirect()
|
||||
->route('forgottenpassword')
|
||||
->withErrors(['error' => 'Missing parameter (email and/or apikey) to send password reset'])
|
||||
->withErrors(['email' => 'Please enter your email address to send a password reset link.'])
|
||||
->withInput($request->except(CaptchaHelper::getResponseFieldName()));
|
||||
}
|
||||
|
||||
@@ -68,26 +65,35 @@ class ForgotPasswordController extends Controller
|
||||
}
|
||||
|
||||
// Check whether the user exists, but always return the same success message
|
||||
// to avoid account/API-key enumeration.
|
||||
$ret = ! empty($rssToken) ? User::findByRssToken($rssToken) : User::findByEmail($email);
|
||||
if ($ret === null) {
|
||||
// to avoid account enumeration.
|
||||
$user = User::findByEmail($email);
|
||||
if ($user === null) {
|
||||
return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!');
|
||||
}
|
||||
|
||||
// Check if user is soft deleted
|
||||
$user = User::withTrashed()->find($ret['id']);
|
||||
if ($user && $user->trashed()) {
|
||||
if (User::withTrashed()->find($user->id)?->trashed()) {
|
||||
return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!');
|
||||
}
|
||||
|
||||
// Generate a forgottenpassword guid, store it in the user table
|
||||
$guid = Str::random(32);
|
||||
User::updatePassResetGuid($ret['id'], $guid);
|
||||
$this->sendResetLink($user);
|
||||
|
||||
// Send the email
|
||||
$resetLink = url('/').'/resetpassword?guid='.$guid;
|
||||
SendPasswordForgottenEmail::dispatch($ret, $resetLink);
|
||||
// Clear any legacy GUID once a broker token has been issued.
|
||||
User::updatePassResetGuid($user->id, null);
|
||||
|
||||
return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!');
|
||||
}
|
||||
|
||||
private function sendResetLink(User $user): void
|
||||
{
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
||||
SendPasswordForgottenEmail::dispatch(
|
||||
$user,
|
||||
route('password.reset', [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendPasswordResetEmail;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\RedirectsUsers;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
@@ -29,16 +33,13 @@ class ResetPasswordController extends Controller
|
||||
$this->middleware('guest');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function reset(Request $request): mixed
|
||||
public function showLegacyResetForm(Request $request): mixed
|
||||
{
|
||||
if ($request->missing('guid')) {
|
||||
return redirect()->route('password.request')->with('error', 'No reset code provided.');
|
||||
}
|
||||
|
||||
$user = User::findByResetGuid($request->input('guid'));
|
||||
$user = User::findByResetGuid((string) $request->input('guid'));
|
||||
if ($user === null) {
|
||||
return redirect()->route('password.request')->with('error', 'Bad reset code provided.');
|
||||
}
|
||||
@@ -48,15 +49,43 @@ class ResetPasswordController extends Controller
|
||||
return redirect()->route('password.request')->with('error', 'This account has been deactivated.');
|
||||
}
|
||||
|
||||
// Reset the password, inform the user, send out the email
|
||||
User::updatePassResetGuid($user->id, '');
|
||||
$newpass = User::generatePassword();
|
||||
User::updatePassword($user->id, $newpass);
|
||||
$token = Password::broker()->createToken($user);
|
||||
User::updatePassResetGuid($user->id, null);
|
||||
|
||||
SendPasswordResetEmail::dispatch($user, $newpass);
|
||||
return redirect()->route('password.reset', [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('login')
|
||||
->with('success', 'Your password has been reset to '.$newpass.' and sent to your e-mail address.');
|
||||
public function reset(Request $request): mixed
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required', 'string'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', PasswordRule::defaults()],
|
||||
]);
|
||||
|
||||
$status = Password::broker()->reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user, string $password): void {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'remember_token' => Str::random(60),
|
||||
'resetguid' => null,
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
},
|
||||
);
|
||||
|
||||
if ($status === Password::PASSWORD_RESET) {
|
||||
return redirect()->route('login')->with('success', __($status));
|
||||
}
|
||||
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
|
||||
public function showResetForm(Request $request, mixed $token = null): mixed
|
||||
|
||||
@@ -15,28 +15,42 @@ class FailedReleasesController extends BasePageController
|
||||
{
|
||||
public function failed(Request $request): Application|Response|\Illuminate\Contracts\Foundation\Application|ResponseFactory
|
||||
{
|
||||
if ($request->missing('api_token')) {
|
||||
if ($request->missing('guid')) {
|
||||
return response('Bad request, please supply all parameters!', 400)->withHeaders(['X-DNZB-RCode' => 400, 'X-DNZB-RText' => 'Bad request, please supply all parameters!']);
|
||||
}
|
||||
|
||||
$res = User::findVerifiedByApiToken((string) $request->input('api_token'));
|
||||
if ($res === null) {
|
||||
$user = $this->resolveUser($request);
|
||||
if ($user === null) {
|
||||
return response('Unauthorised, wrong rss key!', 401)->withHeaders(['X-DNZB-RCode' => 401, 'X-DNZB-RText' => 'Unauthorised, wrong rss key!']);
|
||||
}
|
||||
|
||||
$uid = $res['id'];
|
||||
$rssToken = $res['api_token'];
|
||||
$alt = Release::getAlternate($request->input('guid'), $user->id);
|
||||
|
||||
if (isset($uid, $rssToken) && $request->has('guid')) {
|
||||
$alt = Release::getAlternate($request->input('guid'), $uid);
|
||||
|
||||
if (empty($alt)) {
|
||||
return response('No NZB found for alternate match!', 404)->withHeaders(['X-DNZB-RCode' => 404, 'X-DNZB-RText' => 'No NZB found for alternate match.']);
|
||||
}
|
||||
|
||||
return response('Success')->withHeaders(['Location' => url('/').'/getnzb?id='.$alt['guid'].'&r='.$rssToken]);
|
||||
if (empty($alt)) {
|
||||
return response('No NZB found for alternate match!', 404)->withHeaders(['X-DNZB-RCode' => 404, 'X-DNZB-RText' => 'No NZB found for alternate match.']);
|
||||
}
|
||||
|
||||
return response('Bad request, please supply all parameters!', 400)->withHeaders(['X-DNZB-RCode' => 400, 'X-DNZB-RText' => 'Bad request, please supply all parameters!']);
|
||||
$locationParams = ['id' => $alt['guid']];
|
||||
if ($request->filled('api_token')) {
|
||||
$locationParams['r'] = $user->api_token;
|
||||
}
|
||||
|
||||
return response('Success')->withHeaders([
|
||||
'Location' => url('/getnzb').'?'.http_build_query($locationParams, '', '&', PHP_QUERY_RFC3986),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveUser(Request $request): ?User
|
||||
{
|
||||
$sessionUser = $request->user();
|
||||
if ($sessionUser instanceof User) {
|
||||
return $sessionUser;
|
||||
}
|
||||
|
||||
if ($request->filled('api_token')) {
|
||||
return User::findVerifiedByApiToken((string) $request->input('api_token'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class GetNzbController extends BasePageController
|
||||
{
|
||||
public const string REQUEST_USER_ATTRIBUTE = 'nntmux.getnzb.user';
|
||||
|
||||
private const int BUFFER_SIZE = 1000000;
|
||||
|
||||
private const string NZB_SUFFIX = '.nzb';
|
||||
@@ -82,6 +84,11 @@ class GetNzbController extends BasePageController
|
||||
*/
|
||||
private function authenticateUser(Request $request): array|Response
|
||||
{
|
||||
$resolvedUser = $request->attributes->get(self::REQUEST_USER_ATTRIBUTE);
|
||||
if ($resolvedUser instanceof User) {
|
||||
return $this->getUserDataFromResolvedUser($resolvedUser);
|
||||
}
|
||||
|
||||
// Try session authentication first
|
||||
if ($request->user()) {
|
||||
return $this->getUserDataFromSession();
|
||||
@@ -91,6 +98,31 @@ class GetNzbController extends BasePageController
|
||||
return $this->getUserDataFromRssToken($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data from a user resolved by an upstream API controller.
|
||||
*
|
||||
* @return array<string, mixed>|Response
|
||||
*/
|
||||
private function getUserDataFromResolvedUser(User $user): array|Response
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
return showApiError(100);
|
||||
}
|
||||
|
||||
if ($user->is_disabled || $user->hasRole('Disabled')) {
|
||||
return showApiError(101);
|
||||
}
|
||||
|
||||
$user->loadMissing('role');
|
||||
|
||||
return [
|
||||
'uid' => $user->id,
|
||||
'userName' => $user->username,
|
||||
'maxDownloads' => $user->role->downloadrequests,
|
||||
'rssToken' => $user->api_token,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data from authenticated session
|
||||
*
|
||||
@@ -269,7 +301,7 @@ class GetNzbController extends BasePageController
|
||||
$this->updateDownloadStatistics($request, $uid, $releaseId, $releaseData->id);
|
||||
|
||||
// Build response headers
|
||||
$headers = $this->buildNzbHeaders($releaseId, $uid, $rssToken, $releaseData);
|
||||
$headers = $this->buildNzbHeaders($request, $releaseId, $uid, $rssToken, $releaseData);
|
||||
|
||||
// Stream modified NZB content
|
||||
$cleanName = FilenameSanitizer::sanitize($releaseData->searchname, "release-{$releaseId}");
|
||||
@@ -312,12 +344,21 @@ class GetNzbController extends BasePageController
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildNzbHeaders(string $releaseId, int $uid, string $rssToken, Release $releaseData): array
|
||||
private function buildNzbHeaders(Request $request, string $releaseId, int $uid, string $rssToken, Release $releaseData): array
|
||||
{
|
||||
$failureParams = [
|
||||
'guid' => $releaseId,
|
||||
'userid' => $uid,
|
||||
];
|
||||
|
||||
if (! $request->user() && $rssToken !== '') {
|
||||
$failureParams['api_token'] = $rssToken;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/x-nzb',
|
||||
'Expires' => now()->addYear()->toRfc7231String(),
|
||||
'X-DNZB-Failure' => url('/failed')."?guid={$releaseId}&userid={$uid}&api_token={$rssToken}",
|
||||
'X-DNZB-Failure' => url('/failed').'?'.http_build_query($failureParams, '', '&', PHP_QUERY_RFC3986),
|
||||
'X-DNZB-Category' => e($releaseData->category_name),
|
||||
'X-DNZB-Details' => url("/details/{$releaseId}"),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Facades\Search;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ReindexReleaseJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $releaseId,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
Search::updateRelease($this->releaseId);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Mail\PasswordReset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendPasswordResetEmail implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $newPass;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(User $user, string $newPass)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->newPass = $newPass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Mail::to($this->user->email)->send(new PasswordReset($this->user, $this->newPass));
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\HasBrandedSubject;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PasswordReset extends Mailable
|
||||
{
|
||||
use HasBrandedSubject, Queueable, SerializesModels;
|
||||
|
||||
public string $username;
|
||||
|
||||
public string $newPass;
|
||||
|
||||
public string $site;
|
||||
|
||||
public ?string $preheader;
|
||||
|
||||
private string $siteEmail;
|
||||
|
||||
public function __construct(User $user, string $newPass)
|
||||
{
|
||||
$this->username = (string) $user->username;
|
||||
$this->newPass = $newPass;
|
||||
$this->siteEmail = (string) config('mail.from.address');
|
||||
$this->site = (string) config('app.name');
|
||||
$this->preheader = 'Your password has been reset — temporary credentials inside.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function build(): static
|
||||
{
|
||||
return $this->from($this->siteEmail)
|
||||
->brandedSubject('Your password has been reset')
|
||||
->markdown('emails.markdown.passwordReset', [
|
||||
'username' => $this->username,
|
||||
'newPass' => $this->newPass,
|
||||
'site' => $this->site,
|
||||
'preheader' => $this->preheader,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -553,11 +553,12 @@ class Category extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<mixed>
|
||||
* @return Collection<int, RootCategory>
|
||||
*/
|
||||
public static function getForApi(): mixed
|
||||
public static function getForApi(): Collection
|
||||
{
|
||||
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long'));
|
||||
/** @var Collection<int, RootCategory>|null $result */
|
||||
$result = Cache::get(md5('ForApi'));
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
|
||||
@@ -616,7 +616,14 @@ class Release extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::query()->leftJoin('dnzb_failures as df', 'df.release_id', '=', 'releases.id')->whereIn('releases.id', $searchResult)->where('df.release_id', '=', null)->where('releases.categories_id', $rel['categories_id'])->where('id', '<>', $rel['id'])->orderByDesc('releases.postdate')->first(['guid']);
|
||||
return self::query()
|
||||
->leftJoin('dnzb_failures as df', 'df.release_id', '=', 'releases.id')
|
||||
->whereIn('releases.id', $searchResult)
|
||||
->whereNull('df.release_id')
|
||||
->where('releases.categories_id', $rel['categories_id'])
|
||||
->where('releases.id', '<>', $rel['id'])
|
||||
->orderByDesc('releases.postdate')
|
||||
->first(['releases.guid']);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+9
-2
@@ -13,6 +13,7 @@ use App\Rules\ValidEmailDomain;
|
||||
use App\Services\InvitationService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -93,6 +94,7 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
* @property string|null $role_name Computed from join with roles table
|
||||
* @property int|null $num Computed count value
|
||||
* @property string|null $mth Computed month value
|
||||
* @property-read bool $is_disabled Computed via Attribute accessor
|
||||
* @property bool|null $is_role_expired Computed via Attribute accessor
|
||||
* @property int|null $days_until_expiry Computed via Attribute accessor
|
||||
* @property int|null $count Computed count from aggregate queries
|
||||
@@ -122,7 +124,7 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
* @method static Builder|User expiringSoon(int $days = 7)
|
||||
* @method static Builder|User expired()
|
||||
*/
|
||||
final class User extends Authenticatable implements HasPasskeys, MustVerifyEmailContract
|
||||
final class User extends Authenticatable implements CanResetPasswordContract, HasPasskeys, MustVerifyEmailContract
|
||||
{
|
||||
use HasFactory; // @phpstan-ignore missingType.generics
|
||||
use HasRoles;
|
||||
@@ -811,7 +813,12 @@ final class User extends Authenticatable implements HasPasskeys, MustVerifyEmail
|
||||
*/
|
||||
public static function findByResetGuid(string $guid): ?static
|
||||
{
|
||||
return static::whereResetguid($guid)->first();
|
||||
if (static::whereApiToken($guid)->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::whereResetguid($guid)
|
||||
->first();
|
||||
}
|
||||
|
||||
// ===== User Management Methods =====
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Search;
|
||||
|
||||
use App\Enums\SecondarySearchIndex;
|
||||
use App\Facades\Search;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use JsonException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Dispatches compacted search outbox rows to the active search driver.
|
||||
*
|
||||
* The outbox is written outside PHP (database triggers), so this class accepts a
|
||||
* small set of action/entity aliases to keep the consumer tolerant of table and
|
||||
* model naming differences while still routing only to the existing Search API.
|
||||
*/
|
||||
final class SearchIndexOutboxDispatcher
|
||||
{
|
||||
/**
|
||||
* Keep only the latest row for each logical search document in a batch.
|
||||
*
|
||||
* @param array<int, object> $rows
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public static function compactRows(array $rows): array
|
||||
{
|
||||
usort(
|
||||
$rows,
|
||||
static fn (object $a, object $b): int => self::rowId($a) <=> self::rowId($b)
|
||||
);
|
||||
|
||||
/** @var array<string, object> $compacted */
|
||||
$compacted = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$compacted[self::documentKey($row)] = $row;
|
||||
}
|
||||
|
||||
$latestRows = array_values($compacted);
|
||||
|
||||
usort(
|
||||
$latestRows,
|
||||
static fn (object $a, object $b): int => self::rowId($a) <=> self::rowId($b)
|
||||
);
|
||||
|
||||
return $latestRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one compacted outbox row.
|
||||
*/
|
||||
public function dispatchRow(object $row): void
|
||||
{
|
||||
$payload = self::payload($row);
|
||||
$entityId = self::entityId($row, $payload);
|
||||
$entityType = self::normalizeEntityType($row->entity_type ?? '');
|
||||
$action = self::normalizeAction($row->action ?? '');
|
||||
|
||||
if ($entityId <= 0) {
|
||||
Log::warning('Search outbox row skipped because it has no entity id', [
|
||||
'row_id' => self::rowId($row),
|
||||
'entity_type' => $row->entity_type ?? null,
|
||||
'action' => $row->action ?? null,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
match ($entityType) {
|
||||
'release' => $this->dispatchRelease($action, $entityId, $payload),
|
||||
'predb' => $this->dispatchPredb($action, $entityId, $payload),
|
||||
'movie' => $this->dispatchMovie($action, $entityId, $payload),
|
||||
'tvshow' => $this->dispatchTvShow($action, $entityId, $payload),
|
||||
'secondary' => $this->dispatchSecondaryRow($action, $entityId, $payload, $row),
|
||||
default => $this->dispatchSecondaryEntity($action, $entityType, $entityId, $payload, $row),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatchRelease(string $action, int $entityId, array $payload): void
|
||||
{
|
||||
match ($action) {
|
||||
'delete' => Search::deleteRelease($entityId),
|
||||
'insert', 'upsert' => empty($payload)
|
||||
? Search::updateRelease($entityId)
|
||||
: Search::insertRelease(self::withId($payload, $entityId)),
|
||||
'update' => Search::updateRelease($entityId),
|
||||
default => self::logUnknownAction($action, 'release', $entityId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatchPredb(string $action, int $entityId, array $payload): void
|
||||
{
|
||||
if ($action === 'delete') {
|
||||
Search::deletePreDb($entityId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($payload)) {
|
||||
Log::warning('Search outbox predb row skipped because it has no payload', [
|
||||
'action' => $action,
|
||||
'entity_id' => $entityId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
'insert', 'upsert' => Search::insertPredb(self::withId($payload, $entityId)),
|
||||
'update' => Search::updatePreDb(self::withId($payload, $entityId)),
|
||||
default => self::logUnknownAction($action, 'predb', $entityId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatchMovie(string $action, int $entityId, array $payload): void
|
||||
{
|
||||
match ($action) {
|
||||
'delete' => Search::deleteMovie($entityId),
|
||||
'insert', 'upsert' => empty($payload)
|
||||
? Search::updateMovie($entityId)
|
||||
: Search::insertMovie(self::withId($payload, $entityId)),
|
||||
'update' => Search::updateMovie($entityId),
|
||||
default => self::logUnknownAction($action, 'movie', $entityId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatchTvShow(string $action, int $entityId, array $payload): void
|
||||
{
|
||||
match ($action) {
|
||||
'delete' => Search::deleteTvShow($entityId),
|
||||
'insert', 'upsert' => empty($payload)
|
||||
? Search::updateTvShow($entityId)
|
||||
: Search::insertTvShow(self::withId($payload, $entityId)),
|
||||
'update' => Search::updateTvShow($entityId),
|
||||
default => self::logUnknownAction($action, 'tvshow', $entityId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatchSecondaryRow(string $action, int $entityId, array $payload, object $row): void
|
||||
{
|
||||
$index = self::secondaryIndexFromPayload($payload);
|
||||
|
||||
if ($index === null) {
|
||||
Log::warning('Search outbox secondary row skipped because it has no index', [
|
||||
'row_id' => self::rowId($row),
|
||||
'entity_id' => $entityId,
|
||||
'action' => $action,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatchSecondary($action, $index, $entityId, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatchSecondaryEntity(string $action, string $entityType, int $entityId, array $payload, object $row): void
|
||||
{
|
||||
$index = self::secondaryIndexFromEntityType($entityType);
|
||||
|
||||
if ($index === null) {
|
||||
Log::warning('Search outbox row skipped because it has an unknown entity type', [
|
||||
'row_id' => self::rowId($row),
|
||||
'entity_type' => $row->entity_type ?? null,
|
||||
'normalized_entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'action' => $action,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatchSecondary($action, $index, $entityId, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatchSecondary(string $action, SecondarySearchIndex $index, int $entityId, array $payload): void
|
||||
{
|
||||
if ($action === 'delete') {
|
||||
Search::deleteSecondary($index, $entityId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$document = self::secondaryDocument($payload);
|
||||
|
||||
match ($action) {
|
||||
'insert', 'upsert' => empty($document)
|
||||
? Search::updateSecondary($index, $entityId)
|
||||
: Search::insertSecondary($index, $entityId, $document),
|
||||
'update' => empty($document)
|
||||
? Search::updateSecondary($index, $entityId)
|
||||
: Search::insertSecondary($index, $entityId, $document),
|
||||
default => self::logUnknownAction($action, $index->value, $entityId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function payload(object $row): array
|
||||
{
|
||||
$payload = $row->payload ?? null;
|
||||
|
||||
if ($payload === null || $payload === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($payload)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if ($payload instanceof stdClass) {
|
||||
return (array) $payload;
|
||||
}
|
||||
|
||||
if (! is_string($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $e) {
|
||||
Log::warning('Search outbox row has invalid JSON payload', [
|
||||
'row_id' => self::rowId($row),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private static function entityId(object $row, array $payload): int
|
||||
{
|
||||
$entityId = $row->entity_id ?? ($payload['id'] ?? null);
|
||||
|
||||
return is_numeric($entityId) ? (int) $entityId : 0;
|
||||
}
|
||||
|
||||
private static function rowId(object $row): int
|
||||
{
|
||||
$id = $row->id ?? 0;
|
||||
|
||||
return is_numeric($id) ? (int) $id : 0;
|
||||
}
|
||||
|
||||
private static function documentKey(object $row): string
|
||||
{
|
||||
$payload = self::payload($row);
|
||||
$entityType = self::normalizeEntityType($row->entity_type ?? '');
|
||||
$entityId = self::entityId($row, $payload);
|
||||
$secondaryIndex = $entityType === 'secondary'
|
||||
? self::secondaryIndexFromPayload($payload)
|
||||
: self::secondaryIndexFromEntityType($entityType);
|
||||
|
||||
if ($secondaryIndex !== null) {
|
||||
return 'secondary:'.$secondaryIndex->value.':'.$entityId;
|
||||
}
|
||||
|
||||
return $entityType.':'.$entityId;
|
||||
}
|
||||
|
||||
private static function normalizeEntityType(mixed $entityType): string
|
||||
{
|
||||
$type = strtolower((string) $entityType);
|
||||
$type = str_replace('\\', '/', $type);
|
||||
$type = basename($type);
|
||||
$type = str_replace(['-', ' '], '_', $type);
|
||||
|
||||
return match ($type) {
|
||||
'release', 'releases' => 'release',
|
||||
'pre', 'pres', 'predb', 'predbs' => 'predb',
|
||||
'movie', 'movies', 'movieinfo', 'movie_info' => 'movie',
|
||||
'tv', 'video', 'videos', 'tvshow', 'tvshows', 'tv_show', 'tv_shows' => 'tvshow',
|
||||
'secondary', 'secondary_search', 'secondary_search_index' => 'secondary',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
|
||||
private static function normalizeAction(mixed $action): string
|
||||
{
|
||||
$value = strtolower((string) $action);
|
||||
$value = str_replace(['-', ' '], '_', $value);
|
||||
|
||||
return match ($value) {
|
||||
'insert', 'inserted', 'create', 'created' => 'insert',
|
||||
'upsert', 'upserted', 'replace', 'replaced', 'save', 'saved', 'index', 'indexed' => 'upsert',
|
||||
'update', 'updated' => 'update',
|
||||
'delete', 'deleted', 'destroy', 'destroyed', 'remove', 'removed' => 'delete',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function withId(array $payload, int $id): array
|
||||
{
|
||||
$payload['id'] ??= $id;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private static function secondaryIndexFromPayload(array $payload): ?SecondarySearchIndex
|
||||
{
|
||||
foreach (['index', 'secondary', 'secondary_index', 'secondarySearchIndex', 'type', 'entity', 'entity_type'] as $key) {
|
||||
if (isset($payload[$key]) && is_scalar($payload[$key])) {
|
||||
$index = self::secondaryIndexFromEntityType((string) $payload[$key]);
|
||||
|
||||
if ($index !== null) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function secondaryIndexFromEntityType(string $entityType): ?SecondarySearchIndex
|
||||
{
|
||||
return match (self::normalizeEntityType($entityType)) {
|
||||
'music', 'musicinfo', 'music_info' => SecondarySearchIndex::Music,
|
||||
'book', 'books', 'bookinfo', 'book_info' => SecondarySearchIndex::Books,
|
||||
'game', 'games', 'gamesinfo', 'games_info' => SecondarySearchIndex::Games,
|
||||
'console', 'consoleinfo', 'console_info' => SecondarySearchIndex::Console,
|
||||
'steam', 'steamapp', 'steamapps', 'steam_app', 'steam_apps' => SecondarySearchIndex::Steam,
|
||||
'anime', 'anidb', 'anidbtitle', 'anidbtitles', 'anidb_title', 'anidb_titles', 'anidbinfo', 'anidb_info' => SecondarySearchIndex::Anime,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function secondaryDocument(array $payload): array
|
||||
{
|
||||
if (isset($payload['document']) && is_array($payload['document'])) {
|
||||
return $payload['document'];
|
||||
}
|
||||
|
||||
foreach (['index', 'secondary', 'secondary_index', 'secondarySearchIndex', 'type', 'entity', 'entity_type'] as $metadataKey) {
|
||||
unset($payload[$metadataKey]);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private static function logUnknownAction(string $action, string $entityType, int $entityId): void
|
||||
{
|
||||
Log::warning('Search outbox row skipped because it has an unknown action', [
|
||||
'action' => $action,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user