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,
]);
}
}