mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Try to gain API speed
This commit is contained in:
+12
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class MeasureApiPerformance
|
||||
{
|
||||
/** @param Closure(Request): Response $next */
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\UserRequest;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\Attributes\Backoff;
|
||||
use Illuminate\Queue\Attributes\FailOnTimeout;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
#[Backoff([1, 5, 30])]
|
||||
#[FailOnTimeout]
|
||||
final class RecordApiUsage implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 4;
|
||||
|
||||
public int $timeout = 15;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $userId,
|
||||
public readonly string $requestUri,
|
||||
public readonly ?string $ip,
|
||||
public readonly string $occurredAt,
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
+26
-16
@@ -552,14 +552,13 @@ 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([
|
||||
$columns = [
|
||||
'releases.id',
|
||||
'releases.searchname',
|
||||
'releases.guid',
|
||||
@@ -572,18 +571,7 @@ class Release extends Model
|
||||
'releases.grabs',
|
||||
'releases.comments',
|
||||
'releases.adddate',
|
||||
'releases.videos_id',
|
||||
'releases.tv_episodes_id',
|
||||
'releases.haspreview',
|
||||
'releases.nfostatus',
|
||||
'releases.movieinfo_id',
|
||||
'releases.musicinfo_id',
|
||||
'releases.consoleinfo_id',
|
||||
'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',
|
||||
@@ -597,10 +585,28 @@ class Release extends Model
|
||||
'tve.series',
|
||||
'tve.episode',
|
||||
'tve.firstaired',
|
||||
])
|
||||
];
|
||||
|
||||
if ($includeV1CompatibilityFields) {
|
||||
array_push($columns,
|
||||
'releases.videos_id',
|
||||
'releases.tv_episodes_id',
|
||||
'releases.haspreview',
|
||||
'releases.nfostatus',
|
||||
'releases.movieinfo_id',
|
||||
'releases.musicinfo_id',
|
||||
'releases.consoleinfo_id',
|
||||
'c.root_categories_id as parentid',
|
||||
'cp.title as parent_category',
|
||||
'c.title as sub_category',
|
||||
'g.name as group_name',
|
||||
);
|
||||
}
|
||||
|
||||
$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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> $parameters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,5 +348,6 @@ class RegistrationStatusService
|
||||
private function forgetDashboardSnapshot(): void
|
||||
{
|
||||
Cache::forget(AdminDashboardSnapshotService::CACHE_KEY);
|
||||
Cache::forget('api_registration_status');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="API_ASYNC_AUDIT" value="false"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
</php>
|
||||
<logging/>
|
||||
|
||||
@@ -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) [
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Jobs\RecordApiUsage;
|
||||
use App\Models\User;
|
||||
use App\Services\Api\ApiUsageService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ApiUsageServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.default' => '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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user