ApiController   F
last analyzed

Complexity

Total Complexity 106

Size/Duplication

Total Lines 537
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 106
eloc 325
c 0
b 0
f 0
dl 0
loc 537
rs 2

11 Methods

Rating   Name   Duplication   Size   Complexity  
A addCoverURL() 0 6 5
A getForMenu() 0 30 3
A offset() 0 8 3
A group() 0 11 3
A verifyEmptyParameter() 0 4 3
A output() 0 27 3
A limit() 0 8 3
A maxAge() 0 14 4
A categoryID() 0 13 5
F api() 0 342 73
A __construct() 0 7 1

How to fix   Complexity   

Complex Class

Complex classes like ApiController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ApiController, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace App\Http\Controllers\Api;
4
5
use App\Events\UserAccessedApi;
6
use App\Http\Controllers\BasePageController;
7
use App\Models\Category;
8
use App\Models\Release;
9
use App\Models\ReleaseNfo;
10
use App\Models\Settings;
11
use App\Models\UsenetGroup;
12
use App\Models\User;
13
use App\Models\UserDownload;
14
use App\Models\UserRequest;
15
use App\Services\Releases\ReleaseBrowseService;
16
use App\Services\Releases\ReleaseSearchService;
17
use Illuminate\Contracts\Foundation\Application;
18
use Illuminate\Http\RedirectResponse;
19
use Illuminate\Http\Request;
20
use Illuminate\Routing\Redirector;
21
use Illuminate\Support\Carbon;
22
use Illuminate\Support\Facades\File;
23
use Illuminate\Support\Facades\Log;
24
use Illuminate\Support\Str;
25
use Symfony\Component\HttpFoundation\StreamedResponse;
26
27
class ApiController extends BasePageController
28
{
29
    private string $type;
30
31
    private ReleaseSearchService $releaseSearchService;
32
33
    private ReleaseBrowseService $releaseBrowseService;
34
35
    public function __construct(
36
        ReleaseSearchService $releaseSearchService,
37
        ReleaseBrowseService $releaseBrowseService
38
    ) {
39
        parent::__construct();
40
        $this->releaseSearchService = $releaseSearchService;
41
        $this->releaseBrowseService = $releaseBrowseService;
42
    }
43
44
    /**
45
     * @return Application|\Illuminate\Foundation\Application|RedirectResponse|Redirector|StreamedResponse|void
46
     *
47
     * @throws \Throwable
48
     */
49
    public function api(Request $request)
50
    {
51
        // API functions.
52
        $function = 's';
53
        if ($request->has('t')) {
54
            switch ($request->input('t')) {
55
                case 'd':
56
                case 'details':
57
                    $function = 'd';
58
                    break;
59
                case 'g':
60
                case 'get':
61
                    $function = 'g';
62
                    break;
63
                case 's':
64
                case 'search':
65
                    break;
66
                case 'c':
67
                case 'caps':
68
                    $function = 'c';
69
                    break;
70
                case 'tv':
71
                case 'tvsearch':
72
                    $function = 'tv';
73
                    break;
74
                case 'm':
75
                case 'movie':
76
                    $function = 'm';
77
                    break;
78
                case 'gn':
79
                case 'n':
80
                case 'nfo':
81
                case 'info':
82
                    $function = 'n';
83
                    break;
84
                case 'nzbadd':
85
                    $function = 'nzbAdd';
86
                    break;
87
                default:
88
                    return showApiError(202, 'No such function ('.$request->input('t').')');
89
            }
90
        } else {
91
            return showApiError(200, 'Missing parameter (t)');
92
        }
93
94
        $uid = $apiKey = $oldestGrabTime = $thisOldestTime = '';
95
        $res = $catExclusions = [];
96
        $maxRequests = $thisRequests = $maxDownloads = $grabs = 0;
97
98
        // Page is accessible only by the apikey
99
100
        if ($function !== 'c' && $function !== 'r') {
101
            if ($request->missing('apikey') || ($request->has('apikey') && empty($request->input('apikey')))) {
102
                return showApiError(200, 'Missing parameter (apikey)');
103
            }
104
105
            $apiKey = $request->input('apikey');
106
            $res = User::getByRssToken($apiKey);
107
            if ($res === null) {
108
                return showApiError(100, 'Incorrect user credentials (wrong API key)');
109
            }
110
111
            if ($res->hasRole('Disabled')) {
112
                return showApiError(101);
113
            }
114
115
            $uid = $res->id;
116
            $catExclusions = User::getCategoryExclusionForApi($request);
117
            $maxRequests = $res->role->apirequests;
118
            $maxDownloads = $res->role->downloadrequests;
119
            $time = UserRequest::whereUsersId($uid)->min('timestamp');
120
            $thisOldestTime = $time !== null ? Carbon::createFromTimeString($time)->toRfc2822String() : '';
121
            $grabTime = UserDownload::whereUsersId($uid)->min('timestamp');
122
            $oldestGrabTime = $grabTime !== null ? Carbon::createFromTimeString($grabTime)->toRfc2822String() : '';
123
        }
124
125
        // 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).
126
        if ($uid !== '') {
127
            event(new UserAccessedApi($res));
128
            $thisRequests = UserRequest::getApiRequests($uid);
129
            $grabs = UserDownload::getDownloadRequests($uid);
130
            if ($thisRequests > $maxRequests) {
131
                return showApiError(500, 'Request limit reached ('.$thisRequests.'/'.$maxRequests.')');
132
            }
133
        }
134
135
        // Set Query Parameters based on Request objects
136
        $outputXML = ! ($request->has('o') && $request->input('o') === 'json');
137
        $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
138
        $offset = $this->offset($request);
139
140
        // Set API Parameters based on Request objects
141
        $params['extended'] = $request->has('extended') && (int) $request->input('extended') === 1 ? '1' : '0';
0 ignored issues
show
Comprehensibility Best Practice introduced by
$params was never initialized. Although not strictly required by PHP, it is generally a good practice to add $params = array(); before regardless.
Loading history...
142
        $params['del'] = $request->has('del') && (int) $request->input('del') === 1 ? '1' : '0';
143
        $params['uid'] = $uid;
144
        $params['token'] = $apiKey;
145
        $params['apilimit'] = $maxRequests;
146
        $params['requests'] = $thisRequests;
147
        $params['downloadlimit'] = $maxDownloads;
148
        $params['grabs'] = $grabs;
149
        $params['oldestapi'] = $thisOldestTime;
150
        $params['oldestgrab'] = $oldestGrabTime;
151
152
        switch ($function) {
153
            // Search releases.
154
            case 's':
155
                $this->verifyEmptyParameter($request, 'q');
156
                $maxAge = $this->maxAge($request);
157
                $groupName = $this->group($request);
158
                UserRequest::addApiRequest($apiKey, $request->getRequestUri());
159
                $categoryID = $this->categoryID($request);
160
                $limit = $this->limit($request);
161
                $searchArr = [
162
                    'searchname' => $request->input('q') ?? -1,
163
                    'name' => -1,
164
                    'fromname' => -1,
165
                    'filename' => -1,
166
                ];
167
168
                if ($request->has('q')) {
169
                    $relData = $this->releaseSearchService->search(
170
                        $searchArr,
171
                        $groupName,
172
                        -1,
173
                        -1,
174
                        -1,
175
                        -1,
176
                        $offset,
177
                        $limit,
178
                        '',
179
                        $maxAge,
0 ignored issues
show
Bug introduced by
It seems like $maxAge can also be of type Illuminate\Http\Response; however, parameter $maxAge of App\Services\Releases\Re...SearchService::search() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

179
                        /** @scrutinizer ignore-type */ $maxAge,
Loading history...
180
                        $catExclusions,
181
                        'basic',
182
                        $categoryID,
183
                        $minSize
184
                    );
185
                } else {
186
                    $relData = $this->releaseBrowseService->getBrowseRange(
187
                        1,
188
                        $categoryID,
189
                        $offset,
190
                        $limit,
191
                        '',
192
                        $maxAge,
0 ignored issues
show
Bug introduced by
It seems like $maxAge can also be of type Illuminate\Http\Response; however, parameter $maxAge of App\Services\Releases\Re...rvice::getBrowseRange() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

192
                        /** @scrutinizer ignore-type */ $maxAge,
Loading history...
193
                        $catExclusions,
194
                        $groupName,
195
                        $minSize
196
                    );
197
                }
198
                $this->output($relData, $params, $outputXML, $offset, 'api');
199
                break;
200
                // Search tv releases.
201
            case 'tv':
202
                $this->verifyEmptyParameter($request, 'q');
203
                $this->verifyEmptyParameter($request, 'vid');
204
                $this->verifyEmptyParameter($request, 'tvdbid');
205
                $this->verifyEmptyParameter($request, 'traktid');
206
                $this->verifyEmptyParameter($request, 'rid');
207
                $this->verifyEmptyParameter($request, 'tvmazeid');
208
                $this->verifyEmptyParameter($request, 'imdbid');
209
                $this->verifyEmptyParameter($request, 'tmdbid');
210
                $this->verifyEmptyParameter($request, 'season');
211
                $this->verifyEmptyParameter($request, 'ep');
212
                $maxAge = $this->maxAge($request);
213
                UserRequest::addApiRequest($apiKey, $request->getRequestUri());
214
215
                $siteIdArr = [
216
                    'id' => $request->input('vid') ?? '0',
217
                    'tvdb' => $request->input('tvdbid') ?? '0',
218
                    'trakt' => $request->input('traktid') ?? '0',
219
                    'tvrage' => $request->input('rid') ?? '0',
220
                    'tvmaze' => $request->input('tvmazeid') ?? '0',
221
                    'imdb' => Str::replace('tt', '', $request->input('imdbid')) ?? '0',
222
                    'tmdb' => $request->input('tmdbid') ?? '0',
223
                ];
224
225
                // Process season only queries or Season and Episode/Airdate queries
226
227
                $series = $request->input('season') ?? '';
228
                $episode = $request->input('ep') ?? '';
229
230
                if (preg_match('#^(19|20)\d{2}$#', $series, $year) && str_contains($episode, '/')) {
231
                    $airDate = str_replace('/', '-', $year[0].'-'.$episode);
232
                }
233
234
                $relData = $this->releaseSearchService->tvSearch(
235
                    $siteIdArr,
236
                    $series,
237
                    $episode,
238
                    $airDate ?? '',
239
                    $this->offset($request),
240
                    $this->limit($request),
241
                    $request->input('q') ?? '',
242
                    $this->categoryID($request),
243
                    $maxAge,
244
                    $minSize,
245
                    $catExclusions
246
                );
247
248
                $this->output($relData, $params, $outputXML, $offset, 'api');
249
                break;
250
251
                // Search movie releases.
252
            case 'm':
253
                $this->verifyEmptyParameter($request, 'q');
254
                $this->verifyEmptyParameter($request, 'imdbid');
255
                $maxAge = $this->maxAge($request);
256
                UserRequest::addApiRequest($apiKey, $request->getRequestUri());
257
258
                $imdbId = $request->has('imdbid') && $request->filled('imdbid') ? (int) $request->input('imdbid') : -1;
259
                $tmdbId = $request->has('tmdbid') && $request->filled('tmdbid') ? (int) $request->input('tmdbid') : -1;
260
                $traktId = $request->has('traktid') && $request->filled('traktid') ? (int) $request->input('traktid') : -1;
261
262
                $relData = $this->releaseSearchService->moviesSearch(
263
                    $imdbId,
264
                    $tmdbId,
265
                    $traktId,
266
                    $this->offset($request),
267
                    $this->limit($request),
268
                    $request->input('q') ?? '',
269
                    $this->categoryID($request),
270
                    $maxAge,
271
                    $minSize,
272
                    $catExclusions
273
                );
274
275
                $this->addCoverURL(
276
                    $relData,
277
                    function ($release) {
278
                        return getCoverURL(['type' => 'movies', 'id' => $release->imdbid]);
279
                    }
280
                );
281
282
                $this->output($relData, $params, $outputXML, $offset, 'api');
283
                break;
284
285
                // Get NZB.
286
            case 'g':
287
                $this->verifyEmptyParameter($request, 'g');
288
                UserRequest::addApiRequest($apiKey, $request->getRequestUri());
289
                $relData = Release::checkGuidForApi($request->input('id'));
290
                if ($relData) {
291
                    return redirect(url('/getnzb?r='.$apiKey.'&id='.$request->input('id').(($request->has('del') && $request->input('del') === '1') ? '&del=1' : '')));
292
                }
293
294
                return showApiError(300, 'No such item (the guid you provided has no release in our database)');
295
296
                // Get individual NZB details.
297
            case 'd':
298
                if ($request->missing('id')) {
299
                    return showApiError(200, 'Missing parameter (guid is required for single release details)');
300
                }
301
302
                UserRequest::addApiRequest($apiKey, $request->getRequestUri());
303
                $data = Release::getByGuid($request->input('id'));
304
305
                $this->output($data, $params, $outputXML, $offset, 'api');
306
                break;
307
308
                // Get an NFO file for an individual release.
309
            case 'n':
310
                if ($request->missing('id')) {
311
                    return showApiError(200, 'Missing parameter (id is required for retrieving an NFO)');
312
                }
313
314
                UserRequest::addApiRequest($apiKey, $request->getRequestUri());
315
                $rel = Release::query()->where('guid', $request->input('id'))->first(['id', 'searchname']);
316
317
                if ($rel && $rel->isNotEmpty()) {
318
                    $data = ReleaseNfo::getReleaseNfo($rel->id);
319
                    if (! empty($data)) {
320
                        if ($request->has('o') && $request->input('o') === 'file') {
321
                            return response()->streamDownload(function () use ($data) {
322
                                echo $data['nfo'];
323
                            }, $rel['searchname'].'.nfo', ['Content-type:' => 'application/octet-stream']);
324
                        }
325
326
                        echo nl2br(cp437toUTF($data['nfo']));
327
                    } else {
328
                        return showApiError(300, 'Release does not have an NFO file associated.');
329
                    }
330
                } else {
331
                    return showApiError(300, 'Release does not exist.');
332
                }
333
                break;
334
                //
335
                // nzb add request
336
                // curl -X POST -F "file=@./The.File.nzb" "https://www.tabula-rasa.pw/api/V1/api?t=nzbadd&apikey=xxx"
337
                //
338
            case 'nzbAdd':
339
                if (! User::canPost($uid)) {
340
                    return response('User does not have permission to post', 403);
341
                }
342
343
                if ($request->missing('file')) {
344
                    return response('Missing parameter (file is required for adding an NZB)', 400);
345
                }
346
                if ($request->missing('apikey')) {
347
                    return response('Missing parameter (apikey is required for adding an NZB)', 400);
348
                }
349
350
                if (! $request->hasFile('file')) {
351
                    return response('Missing parameter (file is required for adding an NZB)', 400);
352
                }
353
354
                UserRequest::addApiRequest($apiKey, $request->getRequestUri());
355
356
                $nzbFile = $request->file('file');
357
358
                // Save the file to the server, get the name without the extension.
359
                if (File::isFile($nzbFile)) {
360
                    // We need to check if file is an actual nzb file.
361
                    if ($nzbFile->getClientOriginalExtension() !== 'nzb') {
362
                        return response('File is not an NZB file', 400);
363
                    }
364
                    // Check if the file is proper xml nzb file.
365
                    if (! isValidNewznabNzb($nzbFile->getContent())) {
366
                        return response('File is not a valid Newznab NZB file', 400);
367
                    }
368
                    if (! File::isDirectory(config('nntmux.nzb_upload_folder'))) {
369
                        @File::makeDirectory(config('nntmux.nzb_upload_folder'), 0775, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for makeDirectory(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

369
                        /** @scrutinizer ignore-unhandled */ @File::makeDirectory(config('nntmux.nzb_upload_folder'), 0775, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
370
                    }
371
372
                    if (File::put(config('nntmux.nzb_upload_folder').$nzbFile->getClientOriginalName(), $nzbFile->getContent())) {
373
                        Log::channel('nzb_upload')->info('NZB file uploaded by API: '.$nzbFile->getClientOriginalName());
374
375
                        return response('NZB file uploaded successfully', 200);
376
                    }
377
378
                    Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: '.$nzbFile->getClientOriginalName());
379
                } else {
380
                    Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: '.$nzbFile->getClientOriginalName());
381
382
                    return response('NZB file upload failed', 500);
383
                }
384
385
                break;
386
387
                // Capabilities request.
388
            case 'c':
389
                $this->output([], $params, $outputXML, $offset, 'caps');
390
                break;
391
        }
392
    }
393
394
    /**
395
     * @throws \Exception
396
     */
397
    public function output($data, array $params, bool $xml, int $offset, string $type = '')
398
    {
399
        $this->type = $type;
400
        $options = [
401
            'Parameters' => $params,
402
            'Data' => $data,
403
            'Server' => $this->getForMenu(),
404
            'Offset' => $offset,
405
            'Type' => $type,
406
        ];
407
408
        // Generate the XML Response
409
        $response = (new XML_Response($options))->returnXML();
410
411
        if ($xml) {
412
            header('Content-type: text/xml');
413
        } else {
414
            // JSON encode the XMLWriter response
415
            $response = json_encode(xml_to_array($response), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT + JSON_UNESCAPED_SLASHES);
0 ignored issues
show
Bug introduced by
It seems like $response can also be of type false; however, parameter $xml of xml_to_array() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

415
            $response = json_encode(xml_to_array(/** @scrutinizer ignore-type */ $response), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT + JSON_UNESCAPED_SLASHES);
Loading history...
416
            header('Content-type: application/json');
417
        }
418
        if ($response === false) {
419
            return showApiError(201);
420
        } else {
421
            header('Content-Length: '.\strlen($response));
422
            echo $response;
423
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
424
        }
425
    }
426
427
    /**
428
     * Collect and return various capability information for usage in API.
429
     *
430
     *
431
     * @throws \Exception
432
     */
433
    public function getForMenu(): array
434
    {
435
        $serverroot = url('/');
436
437
        return [
438
            'server' => [
439
                'title' => config('app.name'),
440
                'strapline' => Settings::settingValue('strapline'),
441
                'email' => config('mail.from.address'),
442
                'meta' => Settings::settingValue('metakeywords'),
443
                'url' => $serverroot,
444
                'image' => $serverroot.'/assets/images/tmux_logo.png',
445
            ],
446
            'limits' => [
447
                'max' => 100,
448
                'default' => 100,
449
            ],
450
            'registration' => [
451
                'available' => 'yes',
452
                'open' => (int) Settings::settingValue('registerstatus') === 0 ? 'yes' : 'no',
453
            ],
454
            'searching' => [
455
                'search' => ['available' => 'yes', 'supportedParams' => 'q'],
456
                'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'],
457
                'movie-search' => ['available' => 'yes', 'supportedParams' => 'q,imdbid, tmdbid, traktid'],
458
                'audio-search' => ['available' => 'no',  'supportedParams' => ''],
459
            ],
460
            'categories' => $this->type === 'caps'
461
                ? Category::getForMenu()
462
                : null,
463
        ];
464
    }
465
466
    /**
467
     * @return Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Foundation\Application|\Illuminate\Http\Response|int
468
     */
469
    public function maxAge(Request $request)
470
    {
471
        $maxAge = -1;
472
        if ($request->has('maxage')) {
473
            if (! $request->filled('maxage')) {
474
                return showApiError(201, 'Incorrect parameter (maxage must not be empty)');
475
            } elseif (! is_numeric($request->input('maxage'))) {
476
                return showApiError(201, 'Incorrect parameter (maxage must be numeric)');
477
            } else {
478
                $maxAge = (int) $request->input('maxage');
479
            }
480
        }
481
482
        return $maxAge;
483
    }
484
485
    /**
486
     * Verify cat parameter.
487
     */
488
    public function categoryID(Request $request): array
489
    {
490
        $categoryID[] = -1;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$categoryID was never initialized. Although not strictly required by PHP, it is generally a good practice to add $categoryID = array(); before regardless.
Loading history...
491
        if ($request->has('cat')) {
492
            $categoryIDs = urldecode($request->input('cat'));
493
            // Append Web-DL category ID if HD present for SickBeard / Sonarr compatibility.
494
            if (str_contains($categoryIDs, (string) Category::TV_HD) && ! str_contains($categoryIDs, (string) Category::TV_WEBDL) && (int) Settings::settingValue('catwebdl') === 0) {
495
                $categoryIDs .= (','.Category::TV_WEBDL);
496
            }
497
            $categoryID = explode(',', $categoryIDs);
498
        }
499
500
        return $categoryID;
501
    }
502
503
    /**
504
     * Verify groupName parameter.
505
     *
506
     *
507
     * @throws \Exception
508
     */
509
    public function group(Request $request): string|int|bool
510
    {
511
        $groupName = -1;
512
        if ($request->has('group')) {
513
            $group = UsenetGroup::isValidGroup($request->input('group'));
514
            if ($group !== false) {
515
                $groupName = $group;
516
            }
517
        }
518
519
        return $groupName;
520
    }
521
522
    /**
523
     * Verify limit parameter.
524
     */
525
    public function limit(Request $request): int
526
    {
527
        $limit = 100;
528
        if ($request->has('limit') && is_numeric($request->input('limit'))) {
529
            $limit = (int) $request->input('limit');
530
        }
531
532
        return $limit;
533
    }
534
535
    /**
536
     * Verify offset parameter.
537
     */
538
    public function offset(Request $request): int
539
    {
540
        $offset = 0;
541
        if ($request->has('offset') && is_numeric($request->input('offset'))) {
542
            $offset = (int) $request->input('offset');
543
        }
544
545
        return $offset;
546
    }
547
548
    /**
549
     * Check if a parameter is empty.
550
     */
551
    public function verifyEmptyParameter(Request $request, string $parameter)
552
    {
553
        if ($request->has($parameter) && $request->isNotFilled($parameter)) {
554
            return showApiError(201, 'Incorrect parameter ('.$parameter.' must not be empty)');
555
        }
556
    }
557
558
    public function addCoverURL(&$releases, callable $getCoverURL): void
559
    {
560
        if ($releases && \count($releases)) {
561
            foreach ($releases as $key => $release) {
562
                if (isset($release->id)) {
563
                    $release->coverurl = $getCoverURL($release);
564
                }
565
            }
566
        }
567
    }
568
}
569