From 064154ad124897b66d81452b0352a2144ef03cd4 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 14 Jul 2026 13:26:18 +0200 Subject: [PATCH] Try to gain API speed --- .env.example | 13 +++- app/Http/Controllers/Api/ApiController.php | 32 ++++---- app/Http/Controllers/Api/ApiV2Controller.php | 15 +++- app/Http/Middleware/MeasureApiPerformance.php | 60 +++++++++++++++ .../Middleware/ThrottleApiRequestsByToken.php | 17 ++-- app/Jobs/RecordApiUsage.php | 63 +++++++++++++++ app/Models/Release.php | 72 +++++++++-------- app/Observers/UserApiCacheObserver.php | 1 - app/Services/Api/ApiCapabilitiesService.php | 17 +++- app/Services/Api/ApiReleaseRowCache.php | 77 ++++++++++++------- app/Services/Api/ApiUsageService.php | 25 +++++- app/Services/RegistrationStatusService.php | 1 + bootstrap/app.php | 2 + config/horizon.php | 2 +- config/nntmux.php | 10 +++ phpstan-baseline.neon | 12 --- phpunit.xml | 1 + tests/Unit/ApiPerformanceHelpersTest.php | 64 +++++++++++++++ tests/Unit/ApiUsageServiceTest.php | 72 +++++++++++++++++ 19 files changed, 450 insertions(+), 106 deletions(-) create mode 100644 app/Http/Middleware/MeasureApiPerformance.php create mode 100644 app/Jobs/RecordApiUsage.php create mode 100644 tests/Unit/ApiUsageServiceTest.php diff --git a/.env.example b/.env.example index 2b6f18e22..0739977ca 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,8 @@ STATUS_PROBE_REDIS_ONLY_WHEN_USED=true # Horizon (Redis queue dashboard and workers) HORIZON_PREFIX=horizon: +# Priority-ordered queues consumed by Horizon. +HORIZON_QUEUES=api-audit,default SESSION_DRIVER=redis # Fast TCP probe before other middleware; switch cache+session to file when Redis host is unreachable (set false to disable) @@ -302,6 +304,16 @@ FORUM_FRONTEND_ENABLED=false STREAM_FORK_OUTPUT=false +# API hot-path caching, asynchronous audit persistence, and instrumentation. +API_RELEASE_CACHE_TTL=600 +API_RELEASE_CACHE_JITTER=60 +API_RELEASE_CACHE_STALE_TTL=900 +API_RELEASE_CACHE_LOCK_TTL=15 +API_ASYNC_AUDIT=true +API_AUDIT_QUEUE=api-audit +API_ACCESS_UPDATE_INTERVAL=60 +API_METRICS_SAMPLE_RATE=0.01 + TINYMCE_API_KEY= TMUX_USE_POWERLINE=true @@ -346,4 +358,3 @@ SAIL_XDEBUG_MODE=off #ELASTICSEARCH_HOST=elasticsearch #MAIL_HOST=mailpit #MAIL_PORT=1025 - diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 602905a2f..168809ded 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; -use App\Events\UserAccessedApi; use App\Http\Controllers\BasePageController; use App\Http\Controllers\GetNzbController; use App\Models\Category; @@ -12,7 +11,6 @@ use App\Models\Release; use App\Models\ReleaseNfo; use App\Models\Settings; use App\Models\User; -use App\Models\UserRequest; use App\Services\Api\ApiCapabilitiesService; use App\Services\Api\ApiQueryParameters; use App\Services\Api\ApiReleaseRowCache; @@ -49,7 +47,7 @@ class ApiController extends BasePageController private ?ApiQueryParameters $queryParameters = null; - private ?ApiUsageService $usageService = null; + private ApiUsageService $usageService; private ApiUserResolver $userResolver; @@ -153,7 +151,8 @@ class ApiController extends BasePageController $apiKey = $request->input('apikey'); // Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2) - $res = $this->userResolver->v1((string) $apiKey); + $resolved = $request->attributes->get('nntmux.api_user'); + $res = $resolved instanceof User ? $resolved : $this->userResolver->v1((string) $apiKey); if ($res === null) { return showApiError(100, 'Incorrect user credentials (wrong API key)'); @@ -177,9 +176,8 @@ class ApiController extends BasePageController $grabs = (int) ($userStats->grab_count ?? 0); } - // Record user access to the api, if its been called by a user (i.e. capabilities request do not require a user to be logged in or key provided). + // Quota enforcement happens before endpoint-specific request recording. if ($uid !== '') { - event(new UserAccessedApi($res, $request->ip())); if ($thisRequests > $maxRequests) { return showApiError(500, 'Request limit reached ('.$thisRequests.'/'.$maxRequests.')'); } @@ -218,7 +216,7 @@ class ApiController extends BasePageController return $sort; } $groupName = $this->group($request); - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $categoryID = $this->categoryID($request); $limit = $this->limit($request); @@ -278,7 +276,7 @@ class ApiController extends BasePageController if (! is_string($sort)) { return $sort; } - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $categoryID = $this->categoryID($request); $limit = $this->limit($request); @@ -380,7 +378,7 @@ class ApiController extends BasePageController if (! is_string($sort)) { return $sort; } - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $categoryID = $this->categoryID($request); $limit = $this->limit($request); @@ -469,7 +467,7 @@ class ApiController extends BasePageController return $sort; } $groupName = $this->group($request); - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $categoryID = $this->categoryID($request); $limit = $this->limit($request); @@ -533,7 +531,7 @@ class ApiController extends BasePageController return $sort; } $groupName = $this->group($request); - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $categoryID = $this->categoryID($request); $limit = $this->limit($request); @@ -599,7 +597,7 @@ class ApiController extends BasePageController if (! is_string($sort)) { return $sort; } - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $limit = $this->limit($request); $categoryID = $this->categoryID($request); $relData = $this->releaseRowCache->remember('v1', 'anime', [ @@ -632,7 +630,7 @@ class ApiController extends BasePageController if ($emptyParameterError !== null) { return $emptyParameterError; } - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { $request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, $res); @@ -648,7 +646,7 @@ class ApiController extends BasePageController return showApiError(200, 'Missing parameter (guid is required for single release details)'); } - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $guid = $request->input('id'); $data = $this->releaseRowCache->remember('v1', 'details', [ 'guid' => $guid, @@ -662,7 +660,7 @@ class ApiController extends BasePageController return showApiError(200, 'Missing parameter (id is required for retrieving an NFO)'); } - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $rel = Release::query()->where('guid', $request->input('id'))->first(['id', 'searchname']); if ($rel) { @@ -717,7 +715,7 @@ class ApiController extends BasePageController return showApiError(600, 'Failed to load upload (no file)'); } - UserRequest::addApiRequest($uid, $request->getRequestUri()); + $this->usageService->record($res, $request); $nzbFile = $request->file('file'); @@ -930,7 +928,7 @@ class ApiController extends BasePageController private function usage(): ApiUsageService { - return $this->usageService ?? new ApiUsageService; + return $this->usageService; } public function addCoverURL(mixed &$releases, callable $getCoverURL): void diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 0f3c39d77..46b840e4a 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -83,7 +83,8 @@ class ApiV2Controller extends BasePageController } $apiToken = $request->input('api_token'); - $user = $this->userResolver->v2((string) $apiToken); + $resolved = $request->attributes->get('nntmux.api_user'); + $user = $resolved instanceof User ? $resolved : $this->userResolver->v2((string) $apiToken); if (! $user || ! $user->hasVerifiedEmail()) { return apiJsonError(100); @@ -191,9 +192,15 @@ class ApiV2Controller extends BasePageController return $sort; } - public function capabilities(): JsonResponse + public function capabilities(Request $request): JsonResponse { - return $this->jsonResponse($this->capabilitiesService->v2()); + $response = $this->jsonResponse($this->capabilitiesService->v2()); + $response->setPublic(); + $response->setMaxAge(300); + $response->setEtag(hash('sha256', (string) $response->getContent())); + $response->isNotModified($request); + + return $response; } /** @@ -660,7 +667,7 @@ class ApiV2Controller extends BasePageController $guid = $request->input('id'); $relData = $this->releaseRowCache->remember('v2', 'details', [ 'guid' => $guid, - ], fn () => Release::getByGuidForApi($guid)); + ], fn () => Release::getByGuidForApi($guid, false)); if ($relData === null) { return $this->jsonResponse(['error' => 'No such item'], 404); diff --git a/app/Http/Middleware/MeasureApiPerformance.php b/app/Http/Middleware/MeasureApiPerformance.php new file mode 100644 index 000000000..2d500dffd --- /dev/null +++ b/app/Http/Middleware/MeasureApiPerformance.php @@ -0,0 +1,60 @@ +is('api/v1/*', 'api/v2/*') || ! $this->sampled()) { + return $next($request); + } + + $connection = DB::connection(); + $connection->flushQueryLog(); + $connection->enableQueryLog(); + $startedAt = hrtime(true); + $memoryAtStart = memory_get_usage(true); + + try { + $response = $next($request); + } finally { + $queries = $connection->getQueryLog(); + $connection->disableQueryLog(); + } + + $durationMs = (hrtime(true) - $startedAt) / 1_000_000; + $queryMs = array_sum(array_map(static fn (array $query): float => (float) ($query['time'] ?? 0), $queries)); + $metrics = [ + 'route' => $request->route()?->getActionName(), + 'status' => $response->getStatusCode(), + 'duration_ms' => round($durationMs, 2), + 'query_count' => count($queries), + 'query_ms' => round($queryMs, 2), + 'memory_delta_bytes' => max(0, memory_get_usage(true) - $memoryAtStart), + 'response_bytes' => strlen((string) $response->getContent()), + 'release_cache' => $request->attributes->get('nntmux.api_release_cache', 'unused'), + ]; + + Log::channel(config('logging.default'))->info('API performance', $metrics); + $response->headers->set('Server-Timing', sprintf('app;dur=%.2f, db;dur=%.2f;desc="%d queries"', $durationMs, $queryMs, count($queries))); + + return $response; + } + + private function sampled(): bool + { + $rate = min(1.0, max(0.0, (float) config('nntmux.api.metrics_sample_rate', 0.01))); + + return $rate > 0.0 && random_int(1, 1_000_000) <= (int) ($rate * 1_000_000); + } +} diff --git a/app/Http/Middleware/ThrottleApiRequestsByToken.php b/app/Http/Middleware/ThrottleApiRequestsByToken.php index 60fb825ab..6376fbcdd 100644 --- a/app/Http/Middleware/ThrottleApiRequestsByToken.php +++ b/app/Http/Middleware/ThrottleApiRequestsByToken.php @@ -6,11 +6,11 @@ namespace App\Http\Middleware; use App\Enums\UserRole; use App\Models\User; +use App\Services\Api\ApiUserResolver; use Closure; use Illuminate\Cache\RateLimiter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; use Symfony\Component\HttpFoundation\Response; class ThrottleApiRequestsByToken @@ -19,7 +19,10 @@ class ThrottleApiRequestsByToken private const int DECAY_SECONDS = 60; - public function __construct(private readonly RateLimiter $limiter) {} + public function __construct( + private readonly RateLimiter $limiter, + private readonly ApiUserResolver $userResolver, + ) {} /** * Handle an incoming request. @@ -34,6 +37,8 @@ class ThrottleApiRequestsByToken return $next($request); } + $request->attributes->set('nntmux.api_user', $user); + $maxAttempts = max(1, (int) ($user->rate_limit ?: self::DEFAULT_RATE_LIMIT)); $rateLimitKey = $this->rateLimitKey($user->id); @@ -60,11 +65,9 @@ class ThrottleApiRequestsByToken return null; } - $user = Cache::remember('api_rate_limit_user:'.md5($apiToken), 300, static function () use ($apiToken) { - return User::verifiedApiTokenQuery($apiToken) - ->select(['id', 'roles_id', 'api_token', 'rate_limit']) - ->first(); - }); + $user = $request->filled('api_token') + ? $this->userResolver->v2($apiToken) + : $this->userResolver->v1($apiToken); if ($user?->roles_id === UserRole::DISABLED->value) { return null; diff --git a/app/Jobs/RecordApiUsage.php b/app/Jobs/RecordApiUsage.php new file mode 100644 index 000000000..63481a9ba --- /dev/null +++ b/app/Jobs/RecordApiUsage.php @@ -0,0 +1,63 @@ +onQueue((string) config('nntmux.api.audit_queue', 'api-audit')); + } + + public function handle(): void + { + UserRequest::query()->insert([ + 'users_id' => $this->userId, + 'request' => $this->requestUri, + 'timestamp' => $this->occurredAt, + ]); + + $lockKey = 'api_access_update:'.$this->userId; + $interval = max(1, (int) config('nntmux.api.access_update_interval', 60)); + try { + if (! Cache::add($lockKey, true, $interval)) { + return; + } + } catch (Throwable) { + // Cache degradation must not retry and duplicate the durable audit row. + } + + $update = ['apiaccess' => $this->occurredAt]; + if ($this->ip !== null) { + $update['host'] = $this->ip; + } + + DB::table('users')->where('id', $this->userId)->update($update); + } +} diff --git a/app/Models/Release.php b/app/Models/Release.php index a50fa6802..df46ec33b 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -552,26 +552,43 @@ class Release extends Model * Lighter version of getByGuid() optimized for API details responses. * Skips video.tvInfo, releaseGroup, and fields not used by DetailsTransformer/XML_Response. */ - public static function getByGuidForApi(mixed $guid): mixed + public static function getByGuidForApi(mixed $guid, bool $includeV1CompatibilityFields = true): mixed { $categoryNameExpression = DB::connection()->getDriverName() === 'sqlite' ? "cp.title || ' > ' || c.title AS category_name" : "CONCAT(cp.title, ' > ', c.title) AS category_name"; - $query = DB::table('releases') - ->select([ - 'releases.id', - 'releases.searchname', - 'releases.guid', - 'releases.postdate', - 'releases.categories_id', - 'releases.size', - 'releases.totalpart', - 'releases.fromname', - 'releases.passwordstatus', - 'releases.grabs', - 'releases.comments', - 'releases.adddate', + $columns = [ + 'releases.id', + 'releases.searchname', + 'releases.guid', + 'releases.postdate', + 'releases.categories_id', + 'releases.size', + 'releases.totalpart', + 'releases.fromname', + 'releases.passwordstatus', + 'releases.grabs', + 'releases.comments', + 'releases.adddate', + DB::raw($categoryNameExpression), + 'v.tvdb', + 'v.trakt', + 'v.tvrage', + 'v.tvmaze', + 'v.imdb', + 'v.tmdb', + 'm.imdbid', + 'm.tmdbid', + 'm.traktid', + 'tve.title', + 'tve.series', + 'tve.episode', + 'tve.firstaired', + ]; + + if ($includeV1CompatibilityFields) { + array_push($columns, 'releases.videos_id', 'releases.tv_episodes_id', 'releases.haspreview', @@ -582,25 +599,14 @@ class Release extends Model 'c.root_categories_id as parentid', 'cp.title as parent_category', 'c.title as sub_category', - DB::raw($categoryNameExpression), 'g.name as group_name', - 'v.tvdb', - 'v.trakt', - 'v.tvrage', - 'v.tvmaze', - 'v.imdb', - 'v.tmdb', - 'm.imdbid', - 'm.tmdbid', - 'm.traktid', - 'tve.title', - 'tve.series', - 'tve.episode', - 'tve.firstaired', - ]) + ); + } + + $query = DB::table('releases') + ->select($columns) ->leftJoin('categories as c', 'c.id', '=', 'releases.categories_id') ->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id') - ->leftJoin('usenet_groups as g', 'g.id', '=', 'releases.groups_id') ->leftJoin('videos as v', function ($join): void { $join->on('releases.videos_id', '=', 'v.id') ->where('releases.videos_id', '>', 0); @@ -614,6 +620,10 @@ class Release extends Model ->where('releases.movieinfo_id', '>', 0); }); + if ($includeV1CompatibilityFields) { + $query->leftJoin('usenet_groups as g', 'g.id', '=', 'releases.groups_id'); + } + if (is_array($guid)) { $query->whereIn('releases.guid', $guid); } else { diff --git a/app/Observers/UserApiCacheObserver.php b/app/Observers/UserApiCacheObserver.php index 0cfe4dfbe..dd518bbc3 100644 --- a/app/Observers/UserApiCacheObserver.php +++ b/app/Observers/UserApiCacheObserver.php @@ -43,7 +43,6 @@ class UserApiCacheObserver $tokenHash = md5($apiToken); Cache::forget('api_user:'.$tokenHash); - Cache::forget('api_rate_limit_user:'.$tokenHash); Cache::forget('api_user_stats:'.$user->id); Cache::forget(User::categoryExclusionCacheKey($user->id)); } diff --git a/app/Services/Api/ApiCapabilitiesService.php b/app/Services/Api/ApiCapabilitiesService.php index 52fd7bf4c..a996eee39 100644 --- a/app/Services/Api/ApiCapabilitiesService.php +++ b/app/Services/Api/ApiCapabilitiesService.php @@ -42,7 +42,7 @@ final readonly class ApiCapabilitiesService ], ]); - $status = $this->registrationStatus->resolve(); + $status = $this->registrationStatus(); $data['registration'] = $this->registration($status); $data['categories'] = $includeCatalogs ? Category::getForMenu() : null; $data['groups'] = $includeCatalogs ? $this->groups() : null; @@ -79,7 +79,7 @@ final readonly class ApiCapabilitiesService ]; }); - $status = $this->registrationStatus->resolve(); + $status = $this->registrationStatus(); $data['registration'] = $this->registration($status); return $data; @@ -126,4 +126,17 @@ final readonly class ApiCapabilitiesService 'open' => $status['is_open'] ? 'yes' : 'no', ]; } + + /** @return array{available: bool, is_open: bool} */ + private function registrationStatus(): array + { + return Cache::remember('api_registration_status', 15, function (): array { + $status = $this->registrationStatus->resolve(); + + return [ + 'available' => $status['available'], + 'is_open' => $status['is_open'], + ]; + }); + } } diff --git a/app/Services/Api/ApiReleaseRowCache.php b/app/Services/Api/ApiReleaseRowCache.php index 70ce7cc61..0af548fdf 100644 --- a/app/Services/Api/ApiReleaseRowCache.php +++ b/app/Services/Api/ApiReleaseRowCache.php @@ -14,16 +14,6 @@ class ApiReleaseRowCache private const NULL_SENTINEL = ['__nntmux_api_release_row_cache_null' => true]; - private const BASE_TTL_SECONDS = 600; - - private const MAX_TTL_JITTER_SECONDS = 60; - - private const LOCK_TTL_SECONDS = 10; - - private const WAIT_ATTEMPTS = 30; - - private const WAIT_MICROSECONDS = 100_000; - /** * Cache release rows only. User-specific response fields should be built after this returns. * @@ -36,44 +26,44 @@ class ApiReleaseRowCache [$hit, $cached] = $this->getCachedValue($cacheKey); if ($hit) { + $this->markRequest('hit'); $this->logCacheEvent('hit', $apiVersion, $scope); return $cached; } - $lockKey = $cacheKey.':lock'; - if (Cache::add($lockKey, true, self::LOCK_TTL_SECONDS)) { + $lock = Cache::lock($cacheKey.':lock', max(1, (int) config('nntmux.api.release_cache_lock_ttl', 15))); + if ($lock->get()) { try { [$hit, $cached] = $this->getCachedValue($cacheKey); if ($hit) { + $this->markRequest('hit_after_lock'); $this->logCacheEvent('hit_after_lock', $apiVersion, $scope); return $cached; } $this->logCacheEvent('miss_lock_owner', $apiVersion, $scope); + $this->markRequest('miss'); $rows = $callback(); $this->putCachedValue($cacheKey, $rows); return $rows; } finally { - Cache::forget($lockKey); + $lock->release(); } } - $this->logCacheEvent('lock_wait', $apiVersion, $scope); - for ($attempt = 0; $attempt < self::WAIT_ATTEMPTS; $attempt++) { - usleep(self::WAIT_MICROSECONDS); + [$staleHit, $stale] = $this->getCachedValue($cacheKey.':stale'); + if ($staleHit) { + $this->markRequest('stale'); + $this->logCacheEvent('stale_while_refresh', $apiVersion, $scope); - [$hit, $cached] = $this->getCachedValue($cacheKey); - if ($hit) { - $this->logCacheEvent('hit_after_wait', $apiVersion, $scope); - - return $cached; - } + return $stale; } - $this->logCacheEvent('miss_after_wait', $apiVersion, $scope); + $this->logCacheEvent('miss_lock_contended', $apiVersion, $scope); + $this->markRequest('miss_contended'); $rows = $callback(); $this->putCachedValue($cacheKey, $rows); @@ -88,13 +78,16 @@ class ApiReleaseRowCache return 'api_release_rows:'.$apiVersion.':'.$scope.':'.md5(serialize([ 'driver' => Search::getCurrentDriver(), 'release_version' => Cache::get('releases:cache_version', 1), - 'parameters' => $parameters, + 'parameters' => $this->normalize($parameters), ])); } private function ttlSeconds(): int { - return self::BASE_TTL_SECONDS + mt_rand(0, self::MAX_TTL_JITTER_SECONDS); + $ttl = max(1, (int) config('nntmux.api.release_cache_ttl', 600)); + $jitter = max(0, (int) config('nntmux.api.release_cache_jitter', 60)); + + return $ttl + ($jitter > 0 ? random_int(0, $jitter) : 0); } /** @@ -116,11 +109,34 @@ class ApiReleaseRowCache private function putCachedValue(string $cacheKey, mixed $value): void { + $cached = $value === null ? self::NULL_SENTINEL : $value; + $staleTtl = max($this->ttlSeconds(), (int) config('nntmux.api.release_cache_stale_ttl', 900)); Cache::put( $cacheKey, - $value === null ? self::NULL_SENTINEL : $value, + $cached, $this->ttlSeconds() ); + Cache::put($cacheKey.':stale', $cached, $staleTtl); + } + + /** @param array $parameters + * @return array + */ + private function normalize(array $parameters): array + { + ksort($parameters); + foreach ($parameters as &$value) { + if (is_array($value)) { + $value = array_is_list($value) ? array_values(array_unique($value, SORT_REGULAR)) : $this->normalize($value); + if (array_is_list($value)) { + sort($value); + } + } elseif (is_string($value)) { + $value = trim($value); + } + } + + return $parameters; } private function logCacheEvent(string $event, string $apiVersion, string $scope): void @@ -134,4 +150,13 @@ class ApiReleaseRowCache 'scope' => $scope, ]); } + + private function markRequest(string $status): void + { + if (app()->runningInConsole()) { + return; + } + + request()->attributes->set('nntmux.api_release_cache', $status); + } } diff --git a/app/Services/Api/ApiUsageService.php b/app/Services/Api/ApiUsageService.php index 013ce14c5..8311b2fd6 100644 --- a/app/Services/Api/ApiUsageService.php +++ b/app/Services/Api/ApiUsageService.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace App\Services\Api; -use App\Events\UserAccessedApi; +use App\Jobs\RecordApiUsage; use App\Models\User; -use App\Models\UserRequest; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Throwable; final class ApiUsageService { @@ -30,7 +30,24 @@ final class ApiUsageService public function record(User $user, Request $request): void { - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $job = new RecordApiUsage( + $user->id, + $request->getRequestUri(), + $request->ip(), + now()->toDateTimeString(), + ); + + if ((bool) config('nntmux.api.async_audit', true)) { + try { + dispatch($job); + } catch (Throwable) { + // Preserve audit and quota correctness when the queue backend is unavailable. + $job->handle(); + } + + return; + } + + $job->handle(); } } diff --git a/app/Services/RegistrationStatusService.php b/app/Services/RegistrationStatusService.php index d2cbf812e..55cda397a 100644 --- a/app/Services/RegistrationStatusService.php +++ b/app/Services/RegistrationStatusService.php @@ -348,5 +348,6 @@ class RegistrationStatusService private function forgetDashboardSnapshot(): void { Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); + Cache::forget('api_registration_status'); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 867c51f0e..9b61b7891 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,6 +8,7 @@ use App\Http\Middleware\EnforceSessionToken; use App\Http\Middleware\EnsureAuthenticatedUsersAreVerified; use App\Http\Middleware\ForceJsonOnAPI; use App\Http\Middleware\Google2FAMiddleware; +use App\Http\Middleware\MeasureApiPerformance; use App\Http\Middleware\NoCacheForAuthenticatedUsers; use App\Http\Middleware\SetUserTimezone; use App\Http\Middleware\ThrottleApiRequestsByToken; @@ -60,6 +61,7 @@ return Application::configure(basePath: dirname(__DIR__)) PreventRequestsDuringMaintenance::class, ForceJsonOnAPI::class, BlockAbusiveServices::class, // Block AIOStreams, Oracle Cloud, UsenetStreamer, Cloudflare WARP + MeasureApiPerformance::class, ]); $middleware->replace( diff --git a/config/horizon.php b/config/horizon.php index 777026bb3..7228967fa 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -199,7 +199,7 @@ return [ 'defaults' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['default'], + 'queue' => array_values(array_filter(array_map('trim', explode(',', env('HORIZON_QUEUES', 'api-audit,default'))))), 'balance' => 'auto', 'autoScalingStrategy' => 'time', 'maxProcesses' => 1, diff --git a/config/nntmux.php b/config/nntmux.php index feb478f05..b8c55a090 100644 --- a/config/nntmux.php +++ b/config/nntmux.php @@ -21,6 +21,16 @@ return [ 'purge_inactive_users' => env('PURGE_INACTIVE_USERS', false), 'purge_inactive_users_days' => env('PURGE_INACTIVE_USERS_DAYS', 180), 'mysql_search_fallback' => env('MYSQL_SEARCH_FALLBACK', false), // Disable MySQL LIKE fallback when Manticore/Elasticsearch return no results + 'api' => [ + 'release_cache_ttl' => (int) env('API_RELEASE_CACHE_TTL', 600), + 'release_cache_jitter' => (int) env('API_RELEASE_CACHE_JITTER', 60), + 'release_cache_stale_ttl' => (int) env('API_RELEASE_CACHE_STALE_TTL', 900), + 'release_cache_lock_ttl' => (int) env('API_RELEASE_CACHE_LOCK_TTL', 15), + 'async_audit' => (bool) env('API_ASYNC_AUDIT', true), + 'audit_queue' => env('API_AUDIT_QUEUE', 'api-audit'), + 'access_update_interval' => (int) env('API_ACCESS_UPDATE_INTERVAL', 60), + 'metrics_sample_rate' => (float) env('API_METRICS_SAMPLE_RATE', 0.01), + ], 'block_proxy_indexer_apps' => (bool) env('BLOCK_PROXY_INDEXER_APPS', false), 'block_proxy_indexer_app_user_agents' => env('BLOCK_PROXY_INDEXER_APP_USER_AGENTS', 'Prowlarr/,NZBHydra2'), diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 00c4e5c0b..af9ae7833 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -372,18 +372,6 @@ parameters: count: 1 path: app/Http/Controllers/CoverController.php - - - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$roles_id\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Middleware/ThrottleApiRequestsByToken.php - - - - message: '#^Method App\\Http\\Middleware\\ThrottleApiRequestsByToken\:\:resolveUser\(\) should return App\\Models\\User\|null but returns Illuminate\\Database\\Eloquent\\Model\|null\.$#' - identifier: return.type - count: 1 - path: app/Http/Middleware/ThrottleApiRequestsByToken.php - - message: '#^Method App\\Http\\Requests\\Admin\\RegistrationPeriodRequest\:\:after\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/phpunit.xml b/phpunit.xml index 57c5ca3e8..7d6f32206 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,7 @@ + diff --git a/tests/Unit/ApiPerformanceHelpersTest.php b/tests/Unit/ApiPerformanceHelpersTest.php index 069e68133..be18fb1c1 100644 --- a/tests/Unit/ApiPerformanceHelpersTest.php +++ b/tests/Unit/ApiPerformanceHelpersTest.php @@ -56,6 +56,34 @@ class ApiPerformanceHelpersTest extends TestCase $this->assertSame(1, $calls); } + public function test_release_row_cache_canonicalizes_equivalent_parameters(): void + { + Search::shouldReceive('getCurrentDriver')->twice()->andReturn('testing'); + + $calls = 0; + $cache = new ApiReleaseRowCache; + $first = $cache->remember('v2', 'search', [ + 'category' => [5030, 5040, 5030], + 'id' => ' ubuntu ', + ], function () use (&$calls): array { + $calls++; + + return ['result']; + }); + $second = $cache->remember('v2', 'search', [ + 'id' => 'ubuntu', + 'category' => [5040, 5030], + ], function () use (&$calls): array { + $calls++; + + return ['different']; + }); + + $this->assertSame(['result'], $first); + $this->assertSame(['result'], $second); + $this->assertSame(1, $calls); + } + public function test_get_by_guid_for_api_returns_plain_row_without_model_hydration(): void { $this->createReleaseDetailsSchema(); @@ -104,6 +132,42 @@ class ApiPerformanceHelpersTest extends TestCase $this->assertSame('alt.binaries.test', $row->group_name); } + public function test_v2_details_query_omits_v1_compatibility_fields(): void + { + $this->createReleaseDetailsSchema(); + + DB::table('root_categories')->insert(['id' => 5000, 'title' => 'TV']); + DB::table('categories')->insert(['id' => 5030, 'title' => 'SD', 'root_categories_id' => 5000]); + DB::table('releases')->insert([ + 'id' => 1, + 'searchname' => 'Ubuntu.Release', + 'guid' => 'release-guid', + 'postdate' => '2026-01-02 00:00:00', + 'categories_id' => 5030, + 'size' => 123456, + 'totalpart' => 10, + 'passwordstatus' => 0, + 'grabs' => 2, + 'comments' => 1, + 'adddate' => '2026-01-03 00:00:00', + 'videos_id' => 0, + 'tv_episodes_id' => 0, + 'haspreview' => 0, + 'nfostatus' => 0, + 'movieinfo_id' => 0, + 'musicinfo_id' => 0, + 'consoleinfo_id' => 0, + ]); + + $row = Release::getByGuidForApi('release-guid', false); + + $this->assertInstanceOf(\stdClass::class, $row); + $this->assertSame('TV > SD', $row->category_name); + $this->assertObjectNotHasProperty('group_name', $row); + $this->assertObjectNotHasProperty('haspreview', $row); + $this->assertObjectNotHasProperty('nfostatus', $row); + } + public function test_release_data_fast_array_matches_existing_data_output(): void { $release = (object) [ diff --git a/tests/Unit/ApiUsageServiceTest.php b/tests/Unit/ApiUsageServiceTest.php new file mode 100644 index 000000000..4b3ffb963 --- /dev/null +++ b/tests/Unit/ApiUsageServiceTest.php @@ -0,0 +1,72 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'cache.default' => 'array', + ]); + DB::purge(); + DB::reconnect(); + Cache::flush(); + + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->dateTime('apiaccess')->nullable(); + $table->string('host')->nullable(); + }); + Schema::create('user_requests', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('users_id'); + $table->text('request'); + $table->dateTime('timestamp'); + }); + DB::table('users')->insert(['id' => 1]); + } + + public function test_async_record_dispatches_id_only_audit_job(): void + { + Queue::fake(); + config(['nntmux.api.async_audit' => true]); + $user = new User; + $user->id = 1; + $request = Request::create('/api/v2/search?api_token=secret&id=test', 'GET'); + + (new ApiUsageService)->record($user, $request); + + Queue::assertPushed(RecordApiUsage::class, static fn (RecordApiUsage $job): bool => $job->userId === 1 && $job->requestUri === '/api/v2/search?api_token=secret&id=test' + ); + $this->assertSame(0, DB::table('user_requests')->count()); + } + + public function test_audit_job_persists_each_request_and_coalesces_user_updates(): void + { + config(['nntmux.api.access_update_interval' => 60]); + + (new RecordApiUsage(1, '/api/v2/search?id=one', '192.0.2.1', '2026-07-14 10:00:00'))->handle(); + (new RecordApiUsage(1, '/api/v2/search?id=two', '192.0.2.2', '2026-07-14 10:00:01'))->handle(); + + $this->assertSame(2, DB::table('user_requests')->count()); + $this->assertSame('192.0.2.1', DB::table('users')->where('id', 1)->value('host')); + } +}