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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'failed',
|
||||
'admin/*',
|
||||
'btcpay/webhook',
|
||||
'contact-us',
|
||||
'cart',
|
||||
'cart/*',
|
||||
]);
|
||||
|
||||
$middleware->prepend(DegradeWhenRedisUnreachable::class);
|
||||
|
||||
@@ -10,9 +10,15 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Clean up any partially-created table from a previously failed run
|
||||
// (the table can be created before the foreign-key ALTER statement runs).
|
||||
Schema::dropIfExists('trusted_devices');
|
||||
|
||||
Schema::create('trusted_devices', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
// users.id is INT UNSIGNED in this schema, so the FK column must match
|
||||
// its type or MariaDB raises errno 150 "incorrectly formed" constraint.
|
||||
$table->unsignedInteger('user_id');
|
||||
$table->string('token_hash', 64)->unique();
|
||||
$table->timestamp('expires_at')->index();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
@@ -20,6 +26,8 @@ return new class extends Migration
|
||||
$table->string('user_agent', 500)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
|
||||
$table->index(['user_id', 'expires_at']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('password_reset_tokens')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('users', 'resetguid')) {
|
||||
$table->string('resetguid')->nullable()->after('password');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('users', 'resetguid')) {
|
||||
$table->dropColumn('resetguid');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -318,36 +318,12 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ApiController.php
|
||||
|
||||
-
|
||||
message: '#^Unreachable statement \- code above always terminates\.$#'
|
||||
identifier: deadCode.unreachable
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ApiController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ApiInformController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ApiV2Controller.php
|
||||
|
||||
-
|
||||
message: '#^Call to function is_array\(\) with bool\|int\|string will always evaluate to false\.$#'
|
||||
identifier: function.impossibleType
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ApiV2Controller.php
|
||||
|
||||
-
|
||||
message: '#^Cannot call method map\(\) on list\<mixed\>\.$#'
|
||||
identifier: method.nonObject
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ApiV2Controller.php
|
||||
|
||||
-
|
||||
message: '#^Offset 0 on \*NEVER\* on left side of \?\? always exists and is not nullable\.$#'
|
||||
identifier: nullCoalesce.offset
|
||||
@@ -444,18 +420,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Controllers/CoverController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: app/Http/Controllers/GetNzbController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\User\:\:\$is_disabled\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/RssController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$roles_id\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -474,12 +438,6 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Requests/Admin/RegistrationPeriodRequest.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Category\:\:getForApi\(\) should return list\<mixed\> but returns Illuminate\\Database\\Eloquent\\Collection\<int, App\\Models\\RootCategory\>\.$#'
|
||||
identifier: return.type
|
||||
count: 1
|
||||
path: app/Models/Category.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Category\:\:getForMenu\(\) should return array\<string, mixed\> but returns list\<mixed\>\.$#'
|
||||
identifier: return.type
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
@component('mail::message')
|
||||
# Password reset successful
|
||||
|
||||
Dear {{ $username }},
|
||||
|
||||
Your password has been successfully reset.
|
||||
|
||||
@component('mail::panel')
|
||||
**Your new temporary password:**
|
||||
|
||||
`{{ $newPass }}`
|
||||
@endcomponent
|
||||
|
||||
For your security, we strongly recommend logging in and changing this password to something memorable as soon as possible.
|
||||
|
||||
If you did not request this reset, please contact us immediately.
|
||||
|
||||
Best regards,<br>
|
||||
The {{ $site }} Team
|
||||
@endcomponent
|
||||
+1
-1
@@ -52,7 +52,7 @@ Route::get('release/{id}/mediainfo', function ($id) {
|
||||
'audio' => $audio ?: null,
|
||||
'subs' => $subs ? $subs->subs : null, // @phpstan-ignore property.notFound
|
||||
]);
|
||||
});
|
||||
})->middleware('throttle:60,1');
|
||||
|
||||
Route::fallback(function () {
|
||||
return response()->json(['message' => 'Not Found!'], 404);
|
||||
|
||||
+12
-6
@@ -107,6 +107,9 @@ Route::get('forgottenpassword', [ForgotPasswordController::class, 'showLinkReque
|
||||
Route::post('forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->middleware('throttle:6,1')->withoutMiddleware(['auth']);
|
||||
Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request')->withoutMiddleware(['auth']);
|
||||
Route::post('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->middleware('throttle:6,1')->withoutMiddleware(['auth']);
|
||||
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset')->withoutMiddleware(['auth']);
|
||||
Route::post('password/update', [ResetPasswordController::class, 'reset'])->middleware('throttle:6,1')->name('password.update')->withoutMiddleware(['auth']);
|
||||
Route::get('resetpassword', [ResetPasswordController::class, 'showLegacyResetForm'])->middleware('throttle:6,1')->name('resetpassword')->withoutMiddleware(['auth']);
|
||||
Route::match(['GET', 'POST'], 'terms-and-conditions', [TermsController::class, 'terms'])->name('terms-and-conditions');
|
||||
Route::match(['GET', 'POST'], 'privacy-policy', [PrivacyPolicyController::class, 'privacyPolicy'])->name('privacy-policy');
|
||||
|
||||
@@ -114,12 +117,13 @@ Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');
|
||||
Route::post('login', [LoginController::class, 'login'])->name('login.post');
|
||||
Route::post('logout', [LoginController::class, 'logout'])->name('logout');
|
||||
Route::get('passkeys/authentication-options', GeneratePasskeyAuthenticationOptionsController::class)
|
||||
->middleware('throttle:6,1')
|
||||
->name('passkeys.authentication_options');
|
||||
Route::post('passkeys/authenticate', PasskeyLoginController::class)->name('passkeys.login');
|
||||
Route::post('passkeys/authenticate', PasskeyLoginController::class)->middleware('throttle:6,1')->name('passkeys.login');
|
||||
|
||||
Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->name('2fa.verify');
|
||||
Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->name('2fa.post');
|
||||
Route::post('2faVerify', [PasswordSecurityController::class, 'verify2fa'])->name('2faVerify');
|
||||
Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->middleware('throttle:6,1')->name('2fa.verify');
|
||||
Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->middleware('throttle:6,1')->name('2fa.post');
|
||||
Route::post('2faVerify', [PasswordSecurityController::class, 'verify2fa'])->middleware('throttle:6,1')->name('2faVerify');
|
||||
|
||||
// Search autocomplete and suggest API routes (no auth required for better UX)
|
||||
Route::get('api/search/autocomplete', [SearchSuggestController::class, 'autocomplete'])->name('api.search.autocomplete');
|
||||
@@ -139,7 +143,6 @@ Route::middleware(['auth', 'isVerified'])->group(function () {
|
||||
Route::delete('passkeys/{passkey}', [PasskeyManagementController::class, 'destroy'])
|
||||
->name('passkeys.destroy');
|
||||
|
||||
Route::match(['GET', 'POST'], 'resetpassword', [ResetPasswordController::class, 'reset'])->name('resetpassword');
|
||||
Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile');
|
||||
|
||||
Route::prefix('browse')->group(function () {
|
||||
@@ -164,7 +167,10 @@ Route::middleware(['auth', 'isVerified'])->group(function () {
|
||||
Route::match(['GET', 'POST'], 'apiv2help', [ApiHelpController::class, 'apiv2'])->name('apiv2help');
|
||||
Route::match(['GET', 'POST'], 'browsegroup', [BrowseGroupController::class, 'show'])->name('browsegroup');
|
||||
Route::match(['GET', 'POST'], 'content', [ContentController::class, 'show'])->name('content');
|
||||
Route::match(['GET', 'POST'], 'failed', [FailedReleasesController::class, 'failed'])->name('failed');
|
||||
Route::match(['GET', 'POST'], 'failed', [FailedReleasesController::class, 'failed'])
|
||||
->middleware('throttle:60,1')
|
||||
->withoutMiddleware(['auth', 'isVerified'])
|
||||
->name('failed');
|
||||
|
||||
Route::middleware('clearance')->group(function () {
|
||||
Route::match(['GET', 'POST'], 'Games', [GamesController::class, 'show'])->name('Games');
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Mail\ForgottenPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetSecurityTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
|
||||
'cache.default' => 'array',
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
'hashing.bcrypt.rounds' => 4,
|
||||
'queue.default' => 'sync',
|
||||
'session.driver' => 'array',
|
||||
'captcha.enabled' => false,
|
||||
'captcha.recaptcha.enabled' => false,
|
||||
'captcha.turnstile.enabled' => false,
|
||||
]);
|
||||
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
|
||||
PasswordRule::defaults(fn () => PasswordRule::min(8));
|
||||
|
||||
$this->createSchema();
|
||||
$this->seedSettings();
|
||||
}
|
||||
|
||||
public function test_password_reset_link_request_sends_tokenized_link_and_clears_legacy_guid(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$user = $this->createUser('forgotten@example.test', ['resetguid' => 'legacy-reset-guid']);
|
||||
|
||||
$response = $this->post(route('forgottenpassword'), [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertRedirect(route('forgottenpassword'))
|
||||
->assertSessionHas('success', 'Password reset email has been sent!');
|
||||
|
||||
$this->assertDatabaseHas('password_reset_tokens', [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
$this->assertNull($user->fresh()->resetguid);
|
||||
|
||||
Mail::assertSent(ForgottenPassword::class, function (ForgottenPassword $mail) use ($user): bool {
|
||||
$query = parse_url($mail->resetLink, PHP_URL_QUERY);
|
||||
parse_str((string) $query, $params);
|
||||
|
||||
return $mail->hasTo($user->email)
|
||||
&& str_contains($mail->resetLink, '/password/reset/')
|
||||
&& ($params['email'] ?? null) === $user->email
|
||||
&& ! array_key_exists('password', $params)
|
||||
&& ! str_contains($mail->resetLink, 'legacy-reset-guid');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_legacy_guid_redirects_to_secure_reset_form_and_invalidates_guid(): void
|
||||
{
|
||||
$user = $this->createUser('legacy@example.test', ['resetguid' => 'legacy-guid']);
|
||||
|
||||
$response = $this->get(route('resetpassword', ['guid' => 'legacy-guid']));
|
||||
|
||||
$response->assertStatus(302);
|
||||
$location = (string) $response->headers->get('Location');
|
||||
|
||||
$this->assertStringContainsString('/password/reset/', $location);
|
||||
$this->assertStringContainsString('email=legacy%40example.test', $location);
|
||||
$this->assertDatabaseHas('password_reset_tokens', [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
$this->assertNull($user->fresh()->resetguid);
|
||||
|
||||
$this->get(route('resetpassword', ['guid' => 'legacy-guid']))
|
||||
->assertRedirect(route('password.request'))
|
||||
->assertSessionHas('error', 'Bad reset code provided.');
|
||||
}
|
||||
|
||||
public function test_legacy_reset_rejects_api_token_alias_as_reset_guid(): void
|
||||
{
|
||||
$apiToken = 'api-token-is-not-a-reset-code';
|
||||
$user = $this->createUser('api-token-reset@example.test');
|
||||
$user->forceFill([
|
||||
'api_token' => $apiToken,
|
||||
'resetguid' => $apiToken,
|
||||
])->saveQuietly();
|
||||
|
||||
$response = $this->get(route('resetpassword', ['guid' => $apiToken]));
|
||||
|
||||
$response
|
||||
->assertRedirect(route('password.request'))
|
||||
->assertSessionHas('error', 'Bad reset code provided.');
|
||||
|
||||
$this->assertDatabaseMissing('password_reset_tokens', [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
$this->assertSame($apiToken, $user->fresh()->api_token);
|
||||
$this->assertSame($apiToken, $user->fresh()->resetguid);
|
||||
}
|
||||
|
||||
public function test_token_password_reset_updates_password_clears_guid_and_fires_event(): void
|
||||
{
|
||||
Event::fake([PasswordReset::class]);
|
||||
$user = $this->createUser('reset@example.test', ['resetguid' => 'legacy-guid']);
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
||||
$response = $this->post(route('password.update'), [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
|
||||
$user->refresh();
|
||||
$this->assertTrue(Hash::check('new-secure-password', $user->password));
|
||||
$this->assertNull($user->resetguid);
|
||||
$this->assertNotNull($user->remember_token);
|
||||
$this->assertDatabaseMissing('password_reset_tokens', [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
Event::assertDispatched(
|
||||
PasswordReset::class,
|
||||
fn (PasswordReset $event): bool => $event->user instanceof User && $event->user->is($user),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_invalid_token_does_not_change_password_or_clear_legacy_guid(): void
|
||||
{
|
||||
$user = $this->createUser('invalid-token@example.test', ['resetguid' => 'legacy-guid']);
|
||||
$originalPassword = $user->password;
|
||||
|
||||
$response = $this->from(route('password.reset', ['token' => 'invalid-token', 'email' => $user->email]))
|
||||
->post(route('password.update'), [
|
||||
'token' => 'invalid-token',
|
||||
'email' => $user->email,
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertRedirect(route('password.reset', ['token' => 'invalid-token', 'email' => $user->email]))
|
||||
->assertSessionHasErrors(['email']);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame($originalPassword, $user->password);
|
||||
$this->assertSame('legacy-guid', $user->resetguid);
|
||||
}
|
||||
|
||||
protected function createSchema(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->string('name')->primary();
|
||||
$table->text('value')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('roles', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->boolean('isdefault')->default(false);
|
||||
$table->unsignedInteger('defaultinvites')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('permissions', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('username');
|
||||
$table->string('email')->unique();
|
||||
$table->string('password');
|
||||
$table->string('resetguid')->nullable();
|
||||
$table->unsignedInteger('roles_id')->default(1);
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->string('api_token')->nullable();
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->boolean('can_post')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->timestamp('lastlogin')->nullable();
|
||||
$table->string('host')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->string('session_token', 60)->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('model_has_roles', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['role_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('model_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['permission_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('role_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->primary(['permission_id', 'role_id']);
|
||||
});
|
||||
|
||||
Schema::create('user_activities', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->string('username');
|
||||
$table->string('activity_type', 50);
|
||||
$table->text('description');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
protected function seedSettings(): void
|
||||
{
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'title', 'value' => 'NNTmux Test'],
|
||||
['name' => 'home_link', 'value' => '/'],
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function createUser(string $email, array $overrides = []): User
|
||||
{
|
||||
$role = Role::query()->firstOrCreate(
|
||||
['name' => 'User', 'guard_name' => 'web'],
|
||||
['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1],
|
||||
);
|
||||
|
||||
return User::query()->create(array_merge([
|
||||
'username' => 'user_'.md5($email),
|
||||
'email' => $email,
|
||||
'password' => Hash::make('old-secure-password'),
|
||||
'roles_id' => $role->id,
|
||||
'rate_limit' => 60,
|
||||
'api_token' => md5($email),
|
||||
'verified' => true,
|
||||
'email_verified_at' => now(),
|
||||
'lastlogin' => now(),
|
||||
], $overrides));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace Tests\Feature;
|
||||
use App\Facades\Search;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
class FailedReleasesControllerSecurityTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
|
||||
'cache.default' => 'array',
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
$this->createSchema();
|
||||
$this->seedSettings();
|
||||
}
|
||||
public function test_failed_callback_rejects_userid_without_session_or_api_token(): void
|
||||
{
|
||||
$this->createUser(1, 'rss-token');
|
||||
Search::shouldReceive('searchReleases')->never();
|
||||
$this->get(route('failed', [
|
||||
'guid' => 'failed-guid',
|
||||
'userid' => 1,
|
||||
]))->assertStatus(401)
|
||||
->assertHeader('X-DNZB-RCode', 401);
|
||||
}
|
||||
public function test_failed_callback_uses_api_token_user_and_preserves_token_for_alternate_download(): void
|
||||
{
|
||||
$this->createUser(1, 'rss-token');
|
||||
$this->createUser(999, 'other-token');
|
||||
$this->createRelease(10, 'failed-guid', 'Show.Name.1080p.WEB-DL-GROUP', 5000, '2026-06-11 00:00:00');
|
||||
$this->createRelease(20, 'alternate-guid', 'Show.Name.1080p.PROPER-GROUP', 5000, '2026-06-12 00:00:00');
|
||||
Search::shouldReceive('searchReleases')
|
||||
->once()
|
||||
->with('Show.Name.1080p', 10)
|
||||
->andReturn([20]);
|
||||
$response = $this->get(route('failed', [
|
||||
'guid' => 'failed-guid',
|
||||
'userid' => 999,
|
||||
'api_token' => 'rss-token',
|
||||
]));
|
||||
$response->assertOk();
|
||||
$this->assertSame(
|
||||
url('/getnzb').'?id=alternate-guid&r=rss-token',
|
||||
$response->headers->get('Location'),
|
||||
);
|
||||
$this->assertDatabaseHas('dnzb_failures', [
|
||||
'release_id' => 10,
|
||||
'users_id' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
$this->assertDatabaseMissing('dnzb_failures', [
|
||||
'release_id' => 10,
|
||||
'users_id' => 999,
|
||||
]);
|
||||
}
|
||||
private function createSchema(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->string('name')->primary();
|
||||
$table->text('value')->nullable();
|
||||
});
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('username');
|
||||
$table->string('email')->unique();
|
||||
$table->string('password');
|
||||
$table->unsignedInteger('roles_id')->default(1);
|
||||
$table->string('api_token')->nullable();
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
Schema::create('releases', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('guid')->unique();
|
||||
$table->string('searchname');
|
||||
$table->unsignedInteger('categories_id');
|
||||
$table->timestamp('postdate')->nullable();
|
||||
});
|
||||
Schema::create('dnzb_failures', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('release_id');
|
||||
$table->unsignedInteger('users_id');
|
||||
$table->boolean('failed')->default(true);
|
||||
});
|
||||
}
|
||||
private function seedSettings(): void
|
||||
{
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'innerfileblacklist', 'value' => ''],
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
]);
|
||||
}
|
||||
private function createUser(int $id, string $apiToken): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
'id' => $id,
|
||||
'username' => 'user'.$id,
|
||||
'email' => 'user'.$id.'@example.test',
|
||||
'password' => 'unused',
|
||||
'roles_id' => 1,
|
||||
'api_token' => $apiToken,
|
||||
'verified' => true,
|
||||
'email_verified_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
private function createRelease(int $id, string $guid, string $searchName, int $categoryId, string $postDate): void
|
||||
{
|
||||
DB::table('releases')->insert([
|
||||
'id' => $id,
|
||||
'guid' => $guid,
|
||||
'searchname' => $searchName,
|
||||
'categories_id' => $categoryId,
|
||||
'postdate' => $postDate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -69,7 +68,6 @@ class MailRenderingTest extends TestCase
|
||||
|
||||
yield 'WelcomeEmail' => [static fn () => new WelcomeEmail($userFactory())];
|
||||
yield 'ForgottenPassword' => [static fn () => new ForgottenPassword('https://example.test/reset/abc123')];
|
||||
yield 'PasswordReset' => [static fn () => new PasswordReset($userFactory(), 'TempPass!2026')];
|
||||
yield 'NewAccountCreatedEmail' => [static fn () => new NewAccountCreatedEmail($userFactory())];
|
||||
yield 'AccountChange' => [static fn () => new AccountChange($userFactory())];
|
||||
yield 'AccountWillExpire' => [static fn () => new AccountWillExpire($userFactory(), 5)];
|
||||
@@ -205,7 +203,6 @@ class MailRenderingTest extends TestCase
|
||||
$cases = [
|
||||
[new WelcomeEmail($user), '[NNTmux] Welcome to NNTmux'],
|
||||
[new ForgottenPassword('http://example.test/x'), '[NNTmux] Reset your password'],
|
||||
[new PasswordReset($user, 'tmp'), '[NNTmux] Your password has been reset'],
|
||||
[new NewAccountCreatedEmail($user), '[NNTmux] New account registered'],
|
||||
[new AccountChange($user), '[NNTmux] Account level changed'],
|
||||
[new AccountWillExpire($user, 1), '[NNTmux] Your account is about to expire'],
|
||||
|
||||
Reference in New Issue
Block a user