Improve security

This commit is contained in:
DariusIII
2026-06-12 10:11:15 +02:00
parent 20cf45d532
commit d970bbffc6
25 changed files with 1077 additions and 231 deletions
-2
View File
@@ -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),
+4 -1
View File
@@ -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)');
+6 -2
View File
@@ -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;
}
}
+44 -3
View File
@@ -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}"),
];
+26
View File
@@ -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);
}
}
-46
View File
@@ -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));
}
}
-50
View File
@@ -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,
]);
}
}
+3 -2
View File
@@ -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;
+8 -1
View File
@@ -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
View File
@@ -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,
]);
}
}
-4
View File
@@ -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');
}
});
}
};
-42
View File
@@ -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
View File
@@ -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
View File
@@ -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,
]);
}
}
-3
View File
@@ -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'],