diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index baf26e770..602905a2f 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -8,15 +8,17 @@ 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\ReleaseNfo; use App\Models\Settings; -use App\Models\UsenetGroup; use App\Models\User; use App\Models\UserRequest; +use App\Services\Api\ApiCapabilitiesService; +use App\Services\Api\ApiQueryParameters; use App\Services\Api\ApiReleaseRowCache; -use App\Services\RegistrationStatusService; +use App\Services\Api\ApiUsageService; +use App\Services\Api\ApiUserResolver; +use App\Services\Api\V1\ApiV1Presenter; use App\Services\Releases\ReleaseBrowseService; use App\Services\Releases\ReleaseSearchService; use App\Support\FilenameSanitizer; @@ -31,7 +33,6 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -46,15 +47,35 @@ class ApiController extends BasePageController protected ApiReleaseRowCache $releaseRowCache; + private ?ApiQueryParameters $queryParameters = null; + + private ?ApiUsageService $usageService = null; + + private ApiUserResolver $userResolver; + + private ApiCapabilitiesService $capabilitiesService; + + private ApiV1Presenter $presenter; + public function __construct( ReleaseSearchService $releaseSearchService, ReleaseBrowseService $releaseBrowseService, - ?ApiReleaseRowCache $releaseRowCache = null + ?ApiReleaseRowCache $releaseRowCache = null, + ?ApiQueryParameters $queryParameters = null, + ?ApiUsageService $usageService = null, + ?ApiUserResolver $userResolver = null, + ?ApiCapabilitiesService $capabilitiesService = null, + ?ApiV1Presenter $presenter = null, ) { parent::__construct(); $this->releaseSearchService = $releaseSearchService; $this->releaseBrowseService = $releaseBrowseService; $this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class); + $this->queryParameters = $queryParameters ?? app(ApiQueryParameters::class); + $this->usageService = $usageService ?? app(ApiUsageService::class); + $this->userResolver = $userResolver ?? app(ApiUserResolver::class); + $this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class); + $this->presenter = $presenter ?? app(ApiV1Presenter::class); } /** @@ -132,12 +153,7 @@ class ApiController extends BasePageController $apiKey = $request->input('apikey'); // Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2) - $userCacheKey = 'api_user:'.md5((string) $apiKey); - $res = Cache::remember($userCacheKey, 300, function () use ($apiKey) { - return User::verifiedApiTokenQuery($apiKey) - ->with('role') - ->first(); - }); + $res = $this->userResolver->v1((string) $apiKey); if ($res === null) { return showApiError(100, 'Incorrect user credentials (wrong API key)'); @@ -768,35 +784,8 @@ class ApiController extends BasePageController public function output(mixed $data, array $params, bool $xml, int $offset, string $type = '', array $headers = []) { $this->type = $type; - $options = [ - 'Parameters' => $params, - 'Data' => $data, - 'Server' => $this->getForMenu(), - 'Offset' => $offset, - 'Type' => $type, - ]; - $xmlResponse = new XML_Response($options); - - if ($xml) { - $response = $xmlResponse->returnXML(); - $contentType = 'text/xml'; - } else { - $arrayData = $xmlResponse->returnArray(); - if ($arrayData === false) { - return showApiError(201); - } - $response = json_encode($arrayData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); - $contentType = 'application/json'; - } - if ($response === false) { - return showApiError(201); - } - - return response($response, 200, array_merge([ - 'Content-type' => $contentType, - 'Content-Length' => (string) \strlen($response), - ], $headers)); + return $this->presenter->output($data, $params, $xml, $offset, $type, $headers); } /** @@ -811,72 +800,7 @@ class ApiController extends BasePageController { $includeCats = $this->type === 'caps'; - // Cache the server info blob (without categories) for 10 minutes - $serverInfo = Cache::remember('api_v1_server_menu', 600, function () { - $serverroot = url('/'); - - return [ - 'server' => [ - 'title' => config('app.name'), - 'strapline' => Settings::settingValue('strapline'), - 'email' => config('mail.from.address'), - 'meta' => Settings::settingValue('metakeywords'), - 'url' => $serverroot, - 'image' => $serverroot.'/assets/images/tmux_logo.png', - ], - 'limits' => [ - 'max' => 100, - 'default' => 100, - ], - 'searching' => [ - 'search' => ['available' => 'yes', 'supportedParams' => 'q,group,minsize,maxsize,maxage,cat,limit,offset,attrs,extended,del,sort'], - 'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], - 'movie-search' => ['available' => 'yes', 'supportedParams' => 'q,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], - 'audio-search' => ['available' => 'yes', 'supportedParams' => 'q,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort'], - 'book-search' => ['available' => 'yes', 'supportedParams' => 'q,title,author,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort'], - 'anime-search' => ['available' => 'yes', 'supportedParams' => 'q,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], - ], - ]; - }); - - $registrationStatus = app(RegistrationStatusService::class)->resolve(); - $serverInfo['registration'] = [ - 'available' => $registrationStatus['available'] ? 'yes' : 'no', - 'open' => $registrationStatus['is_open'] ? 'yes' : 'no', - ]; - - // Only load categories for caps requests (also cached via Category::getForMenu) - $serverInfo['categories'] = $includeCats ? Category::getForMenu() : null; - $serverInfo['groups'] = $includeCats - ? (Schema::hasTable('usenet_groups') - ? UsenetGroup::query() - ->where('active', 1) - ->orderBy('name') - ->get(['name', 'description', 'last_updated']) - ->map(static fn (UsenetGroup $group): array => [ - 'name' => $group->name, - 'description' => (string) ($group->description ?? ''), - 'lastupdate' => $group->last_updated ? Carbon::parse($group->last_updated)->toRfc2822String() : '', - ]) - ->all() - : []) - : null; - $serverInfo['genres'] = $includeCats - ? (Schema::hasTable('genres') - ? Genre::query() - ->enabled() - ->orderBy('title') - ->get(['id', 'title', 'type']) - ->map(static fn (Genre $genre): array => [ - 'id' => $genre->id, - 'name' => $genre->title, - 'categoryid' => (int) ($genre->type ?? 0), - ]) - ->all() - : []) - : null; - - return $serverInfo; + return $this->capabilitiesService->v1($includeCats); } /** @@ -901,39 +825,11 @@ class ApiController extends BasePageController /** * Verify cat parameter. * - * @return array + * @return array */ public function categoryID(Request $request): array { - $categoryID = [-1]; - if (! $request->has('cat')) { - return $categoryID; - } - - $rawCategoryIDs = $request->input('cat'); - if (is_array($rawCategoryIDs)) { - $categoryIDs = implode(',', array_values(array_filter(array_map( - static fn (mixed $categoryId): string => urldecode(trim((string) $categoryId)), - $rawCategoryIDs - ), static fn (string $categoryId): bool => $categoryId !== ''))); - } elseif (is_scalar($rawCategoryIDs)) { - $categoryIDs = urldecode(trim((string) $rawCategoryIDs)); - } else { - return $categoryID; - } - - if ($categoryIDs === '') { - return $categoryID; - } - - // Append Web-DL category ID if HD present for SickBeard / Sonarr compatibility. - if (str_contains($categoryIDs, (string) Category::TV_HD) && ! str_contains($categoryIDs, (string) Category::TV_WEBDL) && (int) Settings::settingValue('catwebdl') === 0) { - $categoryIDs .= (','.Category::TV_WEBDL); - } - - $categoryID = array_values(array_filter(array_map('trim', explode(',', $categoryIDs)), static fn (string $categoryId): bool => $categoryId !== '')); - - return $categoryID; + return $this->parameters()->categories($request); } /** @@ -943,15 +839,7 @@ class ApiController extends BasePageController */ public function group(Request $request): string|int|bool { - $groupName = -1; - if ($request->has('group')) { - $group = UsenetGroup::isValidGroup($request->input('group')); - if ($group !== false) { - $groupName = $group; - } - } - - return $groupName; + return $this->parameters()->group($request); } /** @@ -959,12 +847,7 @@ class ApiController extends BasePageController */ public function limit(Request $request): int { - $limit = 100; - if ($request->has('limit') && is_numeric($request->input('limit'))) { - $limit = (int) $request->input('limit'); - } - - return $limit; + return $this->parameters()->limit($request); } /** @@ -972,12 +855,7 @@ class ApiController extends BasePageController */ public function offset(Request $request): int { - $offset = 0; - if ($request->has('offset') && is_numeric($request->input('offset'))) { - $offset = (int) $request->input('offset'); - } - - return $offset; + return $this->parameters()->offset($request); } /** @@ -1042,19 +920,17 @@ class ApiController extends BasePageController */ public function getCachedUserStats(int $userId): object { - $cacheKey = 'api_user_stats:'.$userId; + return $this->usage()->statistics($userId); + } - return Cache::remember($cacheKey, 60, function () use ($userId) { - $oneDayAgo = now()->subDay()->toDateTimeString(); + private function parameters(): ApiQueryParameters + { + return $this->queryParameters ?? new ApiQueryParameters; + } - return DB::selectOne(' - SELECT - (SELECT COUNT(*) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_count, - (SELECT COUNT(*) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_count, - (SELECT MIN(timestamp) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_time, - (SELECT MIN(timestamp) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_time - ', [$userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo]); - }); + private function usage(): ApiUsageService + { + return $this->usageService ?? new ApiUsageService; } 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 8eb4f11f8..0f3c39d77 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -4,22 +4,18 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; -use App\Data\Api\CategoryData; -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; -use App\Models\UserRequest; +use App\Services\Api\ApiCapabilitiesService; +use App\Services\Api\ApiQueryParameters; use App\Services\Api\ApiReleaseRowCache; -use App\Services\RegistrationStatusService; +use App\Services\Api\ApiUsageService; +use App\Services\Api\ApiUserResolver; +use App\Services\Api\V2\ApiV2Presenter; use App\Services\Releases\ReleaseBrowseService; use App\Services\Releases\ReleaseSearchService; use Illuminate\Contracts\Foundation\Application; @@ -31,36 +27,49 @@ use Illuminate\Http\Response; use Illuminate\Routing\Redirector; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; class ApiV2Controller extends BasePageController { - private const JSON_ENCODING_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; - - private ApiController $api; - private ReleaseSearchService $releaseSearchService; private ReleaseBrowseService $releaseBrowseService; private ApiReleaseRowCache $releaseRowCache; + private ApiQueryParameters $queryParameters; + + private ApiUsageService $usageService; + + private ApiUserResolver $userResolver; + + private ApiV2Presenter $presenter; + + private ApiCapabilitiesService $capabilitiesService; + /** * @var array */ private array $resolvedUserStats = []; public function __construct( - ApiController $api, ReleaseSearchService $releaseSearchService, ReleaseBrowseService $releaseBrowseService, - ?ApiReleaseRowCache $releaseRowCache = null + ?ApiReleaseRowCache $releaseRowCache = null, + ?ApiQueryParameters $queryParameters = null, + ?ApiUsageService $usageService = null, + ?ApiUserResolver $userResolver = null, + ?ApiV2Presenter $presenter = null, + ?ApiCapabilitiesService $capabilitiesService = null, ) { - $this->api = $api; $this->releaseSearchService = $releaseSearchService; $this->releaseBrowseService = $releaseBrowseService; $this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class); + $this->queryParameters = $queryParameters ?? app(ApiQueryParameters::class); + $this->usageService = $usageService ?? app(ApiUsageService::class); + $this->userResolver = $userResolver ?? app(ApiUserResolver::class); + $this->presenter = $presenter ?? app(ApiV2Presenter::class); + $this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class); } /** @@ -74,14 +83,7 @@ class ApiV2Controller extends BasePageController } $apiToken = $request->input('api_token'); - $userCacheKey = 'api_user:'.md5((string) $apiToken); - - $user = Cache::remember($userCacheKey, 300, function () use ($apiToken) { - return User::query() - ->whereApiToken((string) $apiToken) - ->with('role') - ->first(); - }); + $user = $this->userResolver->v2((string) $apiToken); if (! $user || ! $user->hasVerifiedEmail()) { return apiJsonError(100); @@ -125,13 +127,12 @@ class ApiV2Controller extends BasePageController private function userStatsFor(User $user): object { - return $this->resolvedUserStats[$user->id] ??= $this->api->getCachedUserStats($user->id); + return $this->resolvedUserStats[$user->id] ??= $this->usageService->statistics($user->id); } private function recordApiRequest(User $user, Request $request): void { - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->usageService->record($user, $request); } /** @@ -139,7 +140,7 @@ class ApiV2Controller extends BasePageController */ private function jsonResponse(array $data, int $status = 200): JsonResponse { - return response()->json($data, $status, [], self::JSON_ENCODING_OPTIONS); + return $this->presenter->json($data, $status); } /** @@ -155,21 +156,7 @@ class ApiV2Controller extends BasePageController */ private function buildSearchResponse(iterable $rows, User $user): JsonResponse { - $rowsArray = is_array($rows) ? $rows : iterator_to_array($rows, false); - $total = (int) ($rowsArray[0]->_totalrows ?? 0); - $detailsBaseUrl = url('/details').'/'; - $getNzbBaseUrl = url('/getnzb'); - - $results = []; - foreach ($rowsArray as $row) { - $results[] = ReleaseData::toArrayFromRelease($row, $user, $detailsBaseUrl, $getNzbBaseUrl); - } - - return $this->jsonResponse(array_merge( - ['Total' => $total], - $this->buildUserStatsResponse($user), - ['results' => $results], - )); + return $this->presenter->search($rows, $user, $this->buildUserStatsResponse($user)); } private function parseMaxAge(Request $request): int|JsonResponse @@ -206,69 +193,7 @@ class ApiV2Controller extends BasePageController public function capabilities(): JsonResponse { - // Cache the full capabilities response for 10 minutes - $capabilities = Cache::remember('api_v2_capabilities', 600, function () { - $category = Category::getForApi(); - - return [ - 'server' => [ - 'title' => config('app.name'), - 'strapline' => Settings::settingValue('strapline'), - 'email' => config('mail.from.address'), - 'url' => url('/'), - ], - 'limits' => [ - 'max' => 100, - 'default' => 100, - ], - 'searching' => [ - 'search' => ['available' => 'yes', 'supportedParams' => 'id,group,minsize,maxsize,maxage,cat,limit,offset,sort'], - 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,sort'], - 'movie-search' => ['available' => 'yes', 'supportedParams' => 'id,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,sort'], - 'audio-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], - '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 (RootCategory $rootCategory): array => CategoryData::fromCategory($rootCategory)->toArray()) - ->values() - ->all(), - 'groups' => Schema::hasTable('usenet_groups') - ? UsenetGroup::query() - ->where('active', 1) - ->orderBy('name') - ->get(['name', 'description', 'last_updated']) - ->map(static fn (UsenetGroup $group): array => [ - 'name' => $group->name, - 'description' => (string) ($group->description ?? ''), - 'lastupdate' => $group->last_updated ? Carbon::parse($group->last_updated)->toRfc2822String() : '', - ]) - ->values() - ->all() - : [], - 'genres' => Schema::hasTable('genres') - ? Genre::query() - ->enabled() - ->orderBy('title') - ->get(['id', 'title', 'type']) - ->map(static fn (Genre $genre): array => [ - 'id' => $genre->id, - 'name' => $genre->title, - 'categoryid' => (int) ($genre->type ?? 0), - ]) - ->values() - ->all() - : [], - ]; - }); - - $registrationStatus = app(RegistrationStatusService::class)->resolve(); - $capabilities['registration'] = [ - 'available' => $registrationStatus['available'] ? 'yes' : 'no', - 'open' => $registrationStatus['is_open'] ? 'yes' : 'no', - ]; - - return $this->jsonResponse($capabilities); + return $this->jsonResponse($this->capabilitiesService->v2()); } /** @@ -292,9 +217,9 @@ class ApiV2Controller extends BasePageController if ($searchName === '' && ! imdb_id_is_valid($imdbId) && $tmdbId <= 0 && $traktId <= 0) { return $this->jsonResponse(['error' => 'Specify id (query), imdbid, tmdbid, or traktid'], 400); } - $offset = $this->api->offset($request); - $limit = $this->api->limit($request); - $categoryID = $this->api->categoryID($request); + $offset = $this->queryParameters->offset($request); + $limit = $this->queryParameters->limit($request); + $categoryID = $this->queryParameters->categories($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; @@ -352,9 +277,9 @@ class ApiV2Controller extends BasePageController return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400); } - $offset = $this->api->offset($request); - $limit = $this->api->limit($request); - $categoryID = $this->api->categoryID($request); + $offset = $this->queryParameters->offset($request); + $limit = $this->queryParameters->limit($request); + $categoryID = $this->queryParameters->categories($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; @@ -366,7 +291,7 @@ class ApiV2Controller extends BasePageController $minSize = max(0, (int) $request->input('minsize', 0)); $catExclusions = User::getCachedCategoryExclusionById($user->id); - $groupName = $this->api->group($request); + $groupName = $this->queryParameters->group($request); $searchName = (string) $request->input('id', ''); if ($searchName === '') { @@ -429,9 +354,9 @@ class ApiV2Controller extends BasePageController return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400); } - $offset = $this->api->offset($request); - $limit = $this->api->limit($request); - $categoryID = $this->api->categoryID($request); + $offset = $this->queryParameters->offset($request); + $limit = $this->queryParameters->limit($request); + $categoryID = $this->queryParameters->categories($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; @@ -443,7 +368,7 @@ class ApiV2Controller extends BasePageController $minSize = max(0, (int) $request->input('minsize', 0)); $catExclusions = User::getCachedCategoryExclusionById($user->id); - $groupName = $this->api->group($request); + $groupName = $this->queryParameters->group($request); $searchName = (string) $request->input('id', ''); if ($searchName === '') { @@ -509,9 +434,9 @@ class ApiV2Controller extends BasePageController return $this->jsonResponse(['error' => 'Specify id (query), anidbid, or anilistid'], 400); } - $offset = $this->api->offset($request); - $limit = $this->api->limit($request); - $categoryID = $this->api->categoryID($request); + $offset = $this->queryParameters->offset($request); + $limit = $this->queryParameters->limit($request); + $categoryID = $this->queryParameters->categories($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; @@ -561,7 +486,7 @@ class ApiV2Controller extends BasePageController $this->recordApiRequest($user, $request); - $offset = $this->api->offset($request); + $offset = $this->queryParameters->offset($request); $catExclusions = User::getCachedCategoryExclusionById($user->id); $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; $maxAge = $this->parseMaxAge($request); @@ -572,12 +497,12 @@ class ApiV2Controller extends BasePageController if (! is_string($sort)) { return $sort; } - $groupName = $this->api->group($request); + $groupName = $this->queryParameters->group($request); if (is_array($groupName)) { $groupName = $groupName[0] ?? -1; } - $categoryID = $this->api->categoryID($request); - $limit = $this->api->limit($request); + $categoryID = $this->queryParameters->categories($request); + $limit = $this->queryParameters->limit($request); $searchName = $request->input('id'); $relData = $this->releaseRowCache->remember('v2', 'search', [ @@ -634,16 +559,6 @@ class ApiV2Controller extends BasePageController $catExclusions = User::getCachedCategoryExclusionById($user->id); $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; - $this->api->verifyEmptyParameter($request, 'id'); - $this->api->verifyEmptyParameter($request, 'vid'); - $this->api->verifyEmptyParameter($request, 'tvdbid'); - $this->api->verifyEmptyParameter($request, 'traktid'); - $this->api->verifyEmptyParameter($request, 'rid'); - $this->api->verifyEmptyParameter($request, 'tvmazeid'); - $this->api->verifyEmptyParameter($request, 'imdbid'); - $this->api->verifyEmptyParameter($request, 'tmdbid'); - $this->api->verifyEmptyParameter($request, 'season'); - $this->api->verifyEmptyParameter($request, 'ep'); if (! $this->hasTvSearchParameters($request)) { return $this->jsonResponse(['error' => 'Specify id (query), vid, tvdbid, traktid, rid, tvmazeid, imdbid, or tmdbid'], 400); } @@ -676,9 +591,9 @@ class ApiV2Controller extends BasePageController $airDate = str_replace('/', '-', $year[0].'-'.$episode); } - $offset = $this->api->offset($request); - $limit = $this->api->limit($request); - $categoryID = $this->api->categoryID($request); + $offset = $this->queryParameters->offset($request); + $limit = $this->queryParameters->limit($request); + $categoryID = $this->queryParameters->categories($request); $airDate = $airDate ?? ''; $searchName = $request->input('id') ?? ''; @@ -751,12 +666,7 @@ class ApiV2Controller extends BasePageController return $this->jsonResponse(['error' => 'No such item'], 404); } - return $this->jsonResponse(DetailsData::toArrayFromRelease( - $relData, - $user, - url('/details').'/', - url('/getnzb') - )); + return $this->presenter->details($relData, $user); } private function hasTvSearchParameters(Request $request): bool diff --git a/app/Services/Api/ApiCapabilitiesService.php b/app/Services/Api/ApiCapabilitiesService.php new file mode 100644 index 000000000..52fd7bf4c --- /dev/null +++ b/app/Services/Api/ApiCapabilitiesService.php @@ -0,0 +1,129 @@ + */ + public function v1(bool $includeCatalogs): array + { + $data = Cache::remember('api_v1_server_menu', 600, static fn (): array => [ + 'server' => [ + 'title' => config('app.name'), + 'strapline' => Settings::settingValue('strapline'), + 'email' => config('mail.from.address'), + 'meta' => Settings::settingValue('metakeywords'), + 'url' => url('/'), + 'image' => url('/').'/assets/images/tmux_logo.png', + ], + 'limits' => ['max' => 100, 'default' => 100], + 'searching' => [ + 'search' => ['available' => 'yes', 'supportedParams' => 'q,group,minsize,maxsize,maxage,cat,limit,offset,attrs,extended,del,sort'], + 'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], + 'movie-search' => ['available' => 'yes', 'supportedParams' => 'q,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], + 'audio-search' => ['available' => 'yes', 'supportedParams' => 'q,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort'], + 'book-search' => ['available' => 'yes', 'supportedParams' => 'q,title,author,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort'], + 'anime-search' => ['available' => 'yes', 'supportedParams' => 'q,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], + ], + ]); + + $status = $this->registrationStatus->resolve(); + $data['registration'] = $this->registration($status); + $data['categories'] = $includeCatalogs ? Category::getForMenu() : null; + $data['groups'] = $includeCatalogs ? $this->groups() : null; + $data['genres'] = $includeCatalogs ? $this->genres() : null; + + return $data; + } + + /** @return array */ + public function v2(): array + { + $data = Cache::remember('api_v2_capabilities', 600, function (): array { + return [ + 'server' => [ + 'title' => config('app.name'), + 'strapline' => Settings::settingValue('strapline'), + 'email' => config('mail.from.address'), + 'url' => url('/'), + ], + 'limits' => ['max' => 100, 'default' => 100], + 'searching' => [ + 'search' => ['available' => 'yes', 'supportedParams' => 'id,group,minsize,maxsize,maxage,cat,limit,offset,sort'], + 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,sort'], + 'movie-search' => ['available' => 'yes', 'supportedParams' => 'id,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,sort'], + 'audio-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], + '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::getForApi() + ->map(static fn (RootCategory $category): array => CategoryData::fromCategory($category)->toArray()) + ->values()->all(), + 'groups' => $this->groups(), + 'genres' => $this->genres(), + ]; + }); + + $status = $this->registrationStatus->resolve(); + $data['registration'] = $this->registration($status); + + return $data; + } + + /** @return list> */ + private function groups(): array + { + if (! Schema::hasTable('usenet_groups')) { + return []; + } + + return UsenetGroup::query()->where('active', 1)->orderBy('name') + ->get(['name', 'description', 'last_updated']) + ->map(static fn (UsenetGroup $group): array => [ + 'name' => $group->name, + 'description' => (string) ($group->description ?? ''), + 'lastupdate' => $group->last_updated ? Carbon::parse($group->last_updated)->toRfc2822String() : '', + ])->values()->all(); + } + + /** @return list> */ + private function genres(): array + { + if (! Schema::hasTable('genres')) { + return []; + } + + return Genre::query()->enabled()->orderBy('title')->get(['id', 'title', 'type']) + ->map(static fn (Genre $genre): array => [ + 'id' => $genre->id, + 'name' => $genre->title, + 'categoryid' => (int) ($genre->type ?? 0), + ])->values()->all(); + } + + /** @param array{available: bool, is_open: bool} $status + * @return array{available: string, open: string} + */ + private function registration(array $status): array + { + return [ + 'available' => $status['available'] ? 'yes' : 'no', + 'open' => $status['is_open'] ? 'yes' : 'no', + ]; + } +} diff --git a/app/Services/Api/ApiQueryParameters.php b/app/Services/Api/ApiQueryParameters.php new file mode 100644 index 000000000..ebc51baa1 --- /dev/null +++ b/app/Services/Api/ApiQueryParameters.php @@ -0,0 +1,96 @@ + */ + public function categories(Request $request): array + { + if (! $request->has('cat')) { + return [-1]; + } + + $raw = $request->input('cat'); + if (is_array($raw)) { + $value = implode(',', array_values(array_filter(array_map( + static fn (mixed $id): string => urldecode(trim((string) $id)), + $raw + ), static fn (string $id): bool => $id !== ''))); + } elseif (is_scalar($raw)) { + $value = urldecode(trim((string) $raw)); + } else { + return [-1]; + } + + if ($value === '') { + return [-1]; + } + + if (str_contains($value, (string) Category::TV_HD) + && ! str_contains($value, (string) Category::TV_WEBDL) + && (int) Settings::settingValue('catwebdl') === 0) { + $value .= ','.Category::TV_WEBDL; + } + + return array_values(array_filter( + array_map('trim', explode(',', $value)), + static fn (string $id): bool => $id !== '' + )); + } + + public function group(Request $request): string|int|bool + { + if (! $request->has('group')) { + return -1; + } + + $group = UsenetGroup::isValidGroup($request->input('group')); + + return $group === false ? -1 : $group; + } + + public function limit(Request $request): int + { + return $request->has('limit') && is_numeric($request->input('limit')) + ? (int) $request->input('limit') + : 100; + } + + public function offset(Request $request): int + { + return $request->has('offset') && is_numeric($request->input('offset')) + ? (int) $request->input('offset') + : 0; + } + + public function minimumSize(Request $request): int + { + return $request->has('minsize') && $request->input('minsize') > 0 + ? (int) $request->input('minsize') + : 0; + } + + public function maximumAge(Request $request): int + { + return (int) $request->input('maxage', -1); + } + + public function sort(Request $request): string + { + return strtolower(trim((string) $request->input('sort', 'posted_desc'))); + } + + public function hasValidSort(Request $request): bool + { + return ! $request->has('sort') + || preg_match('/^(cat|name|size|files|stats|posted)_(asc|desc)$/', $this->sort($request)) === 1; + } +} diff --git a/app/Services/Api/ApiUsageService.php b/app/Services/Api/ApiUsageService.php new file mode 100644 index 000000000..013ce14c5 --- /dev/null +++ b/app/Services/Api/ApiUsageService.php @@ -0,0 +1,36 @@ +subDay()->toDateTimeString(); + + return DB::selectOne('SELECT + (SELECT COUNT(*) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_count, + (SELECT COUNT(*) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_count, + (SELECT MIN(timestamp) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_time, + (SELECT MIN(timestamp) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_time', + [$userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo] + ); + }); + } + + public function record(User $user, Request $request): void + { + UserRequest::addApiRequest($user->id, $request->getRequestUri()); + event(new UserAccessedApi($user, $request->ip())); + } +} diff --git a/app/Services/Api/ApiUserResolver.php b/app/Services/Api/ApiUserResolver.php new file mode 100644 index 000000000..8ddc137eb --- /dev/null +++ b/app/Services/Api/ApiUserResolver.php @@ -0,0 +1,29 @@ +load('role'); + + return $user; + }); + } + + public function v2(string $token): ?User + { + return Cache::remember('api_user:'.md5($token), 300, static fn (): ?User => User::query() + ->whereApiToken($token) + ->with('role') + ->first()); + } +} diff --git a/app/Services/Api/V1/ApiV1Presenter.php b/app/Services/Api/V1/ApiV1Presenter.php new file mode 100644 index 000000000..d91352dcb --- /dev/null +++ b/app/Services/Api/V1/ApiV1Presenter.php @@ -0,0 +1,56 @@ + $parameters + * @param array $headers + */ + public function output( + mixed $data, + array $parameters, + bool $xml, + int $offset, + string $type = '', + array $headers = [] + ): Response { + $response = new XML_Response([ + 'Parameters' => $parameters, + 'Data' => $data, + 'Server' => $this->capabilities->v1($type === 'caps'), + 'Offset' => $offset, + 'Type' => $type, + ]); + + if ($xml) { + $body = $response->returnXML(); + $contentType = 'text/xml'; + } else { + $array = $response->returnArray(); + if ($array === false) { + return showApiError(201); + } + $body = json_encode($array, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $contentType = 'application/json'; + } + + if ($body === false) { + return showApiError(201); + } + + return response($body, 200, array_merge([ + 'Content-type' => $contentType, + 'Content-Length' => (string) strlen($body), + ], $headers)); + } +} diff --git a/app/Services/Api/V2/ApiV2Presenter.php b/app/Services/Api/V2/ApiV2Presenter.php new file mode 100644 index 000000000..4708458da --- /dev/null +++ b/app/Services/Api/V2/ApiV2Presenter.php @@ -0,0 +1,65 @@ + $data */ + public function json(array $data, int $status = 200): JsonResponse + { + return response()->json($data, $status, [], self::JSON_ENCODING_OPTIONS); + } + + /** + * @param iterable $rows + * @param array $usage + */ + public function search(iterable $rows, User $user, array $usage): JsonResponse + { + $rows = is_array($rows) ? $rows : iterator_to_array($rows, false); + $results = []; + foreach ($rows as $row) { + $results[] = ReleaseData::toArrayFromRelease($row, $user, url('/details').'/', url('/getnzb')); + } + + return $this->json(array_merge( + ['Total' => (int) ($rows[0]->_totalrows ?? 0)], + $usage, + ['results' => $results], + )); + } + + public function details(Release|\stdClass $release, User $user): JsonResponse + { + return $this->json(DetailsData::toArrayFromRelease( + $release, + $user, + url('/details').'/', + url('/getnzb') + )); + } + + /** @return array */ + public function usage(User $user, object $statistics): array + { + return [ + 'apiCurrent' => (int) ($statistics->api_count ?? 0), + 'apiMax' => $user->role->apirequests, + 'grabCurrent' => (int) ($statistics->grab_count ?? 0), + 'grabMax' => $user->role->downloadrequests, + 'apiOldestTime' => $statistics->api_time ? Carbon::parse($statistics->api_time)->toRfc2822String() : '', + 'grabOldestTime' => $statistics->grab_time ? Carbon::parse($statistics->grab_time)->toRfc2822String() : '', + ]; + } +} diff --git a/docs/nntmux_api_v2.md b/docs/nntmux_api_v2.md index 7acc23676..0b11a998b 100644 --- a/docs/nntmux_api_v2.md +++ b/docs/nntmux_api_v2.md @@ -65,32 +65,27 @@ JSON sorting response snippet (`sort=size_desc`): ```http HTTP/1.1 200 OK Content-Type: application/json -X-Total-Count: 2 -X-Api-Current: 0 -X-Api-Max: 100 -X-Grab-Current: 0 -X-Grab-Max: 100 -X-Api-Oldest-Time: -X-Grab-Oldest-Time: - -[ - { "title": "Ubuntu ISO x64", "size": 734003200 }, - { "title": "Ubuntu ISO x86", "size": 367001600 } -] +{ + "Total": 2, + "apiCurrent": 0, + "apiMax": 100, + "grabCurrent": 0, + "grabMax": 100, + "apiOldestTime": "", + "grabOldestTime": "", + "results": [ + { "title": "Ubuntu ISO x64", "size": 734003200 }, + { "title": "Ubuntu ISO x86", "size": 367001600 } + ] +} ``` Releases are ordered largest-to-smallest because `sort=size_desc`. -> **Breaking change (April 2026):** the legacy `Results` (capital R) JSON -> envelope — previously produced by `spatie/laravel-fractal` — has been -> removed entirely. Search endpoints now return a bare top-level JSON array of -> `App\Data\Api\ReleaseData` payloads. Pagination total and per-user API/grab -> quotas have moved to response headers (`X-Total-Count`, `X-Api-Current`, -> `X-Api-Max`, `X-Grab-Current`, `X-Grab-Max`, `X-Api-Oldest-Time`, -> `X-Grab-Oldest-Time`). Movie/TV-only fields (`tvdbid`, `imdbid`, `season`, …) -> are omitted from each release object when not applicable to its category, -> instead of being emitted as `null`. TypeScript definitions are auto-generated -> to `resources/js/types/generated.d.ts`. +> The legacy `Results` (capital R) Fractal field has been replaced by the +> lower-case `results` field. Search responses retain pagination and quota +> metadata in the top-level JSON object. Movie/TV-only fields (`tvdbid`, +> `imdbid`, `season`, …) are omitted when they do not apply to a release. ## Endpoints @@ -213,7 +208,9 @@ Returns a single release object (not envelope). Download field name is `link` (n ## Error Response Conventions -- Missing/invalid token: JSON `403` +- Missing token: JSON `400` +- Invalid token: JSON `401` +- Disabled account: JSON `403` - Invalid `maxage`: JSON `400` - Invalid `sort`: JSON `400` - Missing required endpoint parameter (`id`, etc.): JSON `400` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5e1871fce..00c4e5c0b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -276,42 +276,6 @@ parameters: count: 1 path: app/Http/Controllers/Admin/DeletedUsersController.php - - - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ApiController.php - - - - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$is_disabled\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ApiController.php - - - - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$role\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/ApiController.php - - - - message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:hasRole\(\)\.$#' - identifier: method.notFound - count: 1 - path: app/Http/Controllers/Api/ApiController.php - - - - message: '#^Method App\\Http\\Controllers\\Api\\ApiController\:\:categoryID\(\) should return array\ but returns array\\.$#' - identifier: return.type - count: 3 - path: app/Http/Controllers/Api/ApiController.php - - - - message: '#^Method App\\Http\\Controllers\\Api\\ApiController\:\:categoryID\(\) should return array\ but returns list\\.$#' - identifier: return.type - count: 1 - path: app/Http/Controllers/Api/ApiController.php - - message: '#^Call to function is_array\(\) with bool\|int\|string will always evaluate to false\.$#' identifier: function.impossibleType diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php index c1f0cbfdc..03894b077 100644 --- a/tests/Feature/ApiRequestMatrixTest.php +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -135,7 +135,7 @@ class ApiRequestMatrixTest extends TestCase $releaseBrowseService = Mockery::mock(ReleaseBrowseService::class); $releaseBrowseService->shouldNotReceive('getBrowseRangeForApi'); - $controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService); + $controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService); $firstResponse = $controller->apiSearch($request); $secondResponse = $controller->apiSearch($request); @@ -178,7 +178,7 @@ class ApiRequestMatrixTest extends TestCase $releaseBrowseService = Mockery::mock(ReleaseBrowseService::class); $releaseBrowseService->shouldNotReceive('getBrowseRangeForApi'); - $controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService); + $controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService); $response = $controller->apiSearch($request); $content = $response->getContent(); @@ -493,7 +493,7 @@ class ApiRequestMatrixTest extends TestCase ) ->andReturn(collect()); - $controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService); + $controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService); $response = $controller->audio($request); @@ -527,7 +527,7 @@ class ApiRequestMatrixTest extends TestCase ) ->andReturn(collect()); - $controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService); + $controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService); $response = $controller->books($request); @@ -564,7 +564,7 @@ class ApiRequestMatrixTest extends TestCase ) ->andReturn(collect()); - $controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService); + $controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService); $response = $controller->{$expectation['method']}($request); $this->assertSame(200, $response->getStatusCode()); diff --git a/tests/Unit/ApiQueryParametersTest.php b/tests/Unit/ApiQueryParametersTest.php new file mode 100644 index 000000000..bddf2c7bc --- /dev/null +++ b/tests/Unit/ApiQueryParametersTest.php @@ -0,0 +1,57 @@ +parameters = new ApiQueryParameters; + } + + public function test_common_defaults_match_both_api_versions(): void + { + $request = Request::create('/', 'GET'); + + self::assertSame([-1], $this->parameters->categories($request)); + self::assertSame(100, $this->parameters->limit($request)); + self::assertSame(0, $this->parameters->offset($request)); + self::assertSame(0, $this->parameters->minimumSize($request)); + self::assertSame(-1, $this->parameters->maximumAge($request)); + self::assertSame('posted_desc', $this->parameters->sort($request)); + self::assertTrue($this->parameters->hasValidSort($request)); + } + + public function test_numeric_pagination_and_sort_are_normalized(): void + { + $request = Request::create('/', 'GET', [ + 'limit' => '25', + 'offset' => '50', + 'minsize' => '1024', + 'maxage' => '7', + 'sort' => ' NAME_ASC ', + ]); + + self::assertSame(25, $this->parameters->limit($request)); + self::assertSame(50, $this->parameters->offset($request)); + self::assertSame(1024, $this->parameters->minimumSize($request)); + self::assertSame(7, $this->parameters->maximumAge($request)); + self::assertSame('name_asc', $this->parameters->sort($request)); + self::assertTrue($this->parameters->hasValidSort($request)); + } + + public function test_invalid_sort_is_reported_without_building_a_response(): void + { + $request = Request::create('/', 'GET', ['sort' => 'unexpected']); + + self::assertFalse($this->parameters->hasValidSort($request)); + } +}