Passed
Push — master ( d45e5d...188227 )
by Darko
10:26
created

GetNzbController   A

Complexity

Total Complexity 27

Size/Duplication

Total Lines 160
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 27
eloc 84
c 0
b 0
f 0
dl 0
loc 160
rs 10
1
<?php
2
3
namespace App\Http\Controllers;
4
5
use App\Models\Release;
6
use App\Models\User;
7
use App\Models\UserDownload;
8
use App\Models\UsersRelease;
9
use Blacklight\NZB;
10
use Blacklight\utility\Utility;
11
use Exception;
12
use Illuminate\Contracts\Foundation\Application;
13
use Illuminate\Contracts\Routing\ResponseFactory;
14
use Illuminate\Http\JsonResponse;
15
use Illuminate\Http\Request;
16
use Illuminate\Http\Response;
17
use Illuminate\Support\Facades\File;
18
use Illuminate\Support\Facades\Log;
19
use Symfony\Component\HttpFoundation\StreamedResponse;
20
use ZipStream\ZipStream;
21
22
class GetNzbController extends BasePageController
23
{
24
    private const int BUFFER_SIZE = 1000000;
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 24 at column 22
Loading history...
25
26
    private const string NZB_SUFFIX = '.nzb';
27
28
    /**
29
     * Download NZB file(s) for authenticated users
30
     *
31
     * @return Application|ResponseFactory|\Illuminate\Foundation\Application|JsonResponse|Response|ZipStream|StreamedResponse
32
     *
33
     * @throws Exception
34
     */
35
    public function getNzb(Request $request, ?string $guid = null)
36
    {
37
        // Normalize guid parameter
38
        $this->normalizeGuidParameter($request, $guid);
39
40
        // Authenticate and authorize user
41
        $userData = $this->authenticateUser($request);
42
        if (! \is_array($userData)) {
43
            return $userData; // Return error response
44
        }
45
46
        ['uid' => $uid, 'userName' => $userName, 'maxDownloads' => $maxDownloads, 'rssToken' => $rssToken] = $userData;
47
48
        // Check download limits
49
        $downloadLimitError = $this->checkDownloadLimit($uid, $maxDownloads);
50
        if ($downloadLimitError !== null) {
51
            return $downloadLimitError;
52
        }
53
54
        // Validate and sanitize ID parameter
55
        $releaseId = $this->validateAndSanitizeId($request);
56
        if (! \is_string($releaseId)) {
57
            return $releaseId; // Return error response
58
        }
59
60
        // Handle zip download request
61
        if ($this->isZipRequest($request)) {
62
            return $this->handleZipDownload($request, $uid, $userName, $maxDownloads, $releaseId);
63
        }
64
65
        // Handle single NZB download
66
        return $this->handleSingleNzbDownload($request, $uid, $rssToken, $releaseId);
67
    }
68
69
    /**
70
     * Normalize the guid parameter into the request
71
     */
72
    private function normalizeGuidParameter(Request $request, ?string $guid): void
73
    {
74
        if ($guid !== null && ! $request->has('id')) {
75
            $request->merge(['id' => $guid]);
76
        }
77
    }
78
79
    /**
80
     * Authenticate user via session or RSS token
81
     *
82
     * @return array<string, mixed>|Response
83
     */
84
    private function authenticateUser(Request $request)
85
    {
86
        // Try session authentication first
87
        if ($request->user()) {
88
            return $this->getUserDataFromSession();
89
        }
90
91
        // Try RSS token authentication
92
        return $this->getUserDataFromRssToken($request);
93
    }
94
95
    /**
96
     * Get user data from authenticated session
97
     *
98
     * @return array<string, mixed>|Response
99
     */
100
    private function getUserDataFromSession()
101
    {
102
        if ($this->userdata->hasRole('Disabled')) {
103
            return Utility::showApiError(101);
104
        }
105
106
        return [
107
            'uid' => $this->userdata->id,
108
            'userName' => $this->userdata->username,
109
            'maxDownloads' => $this->userdata->role->downloadrequests,
110
            'rssToken' => $this->userdata->api_token,
111
        ];
112
    }
113
114
    /**
115
     * Get user data from RSS token
116
     *
117
     * @return array<string, mixed>|Response
118
     */
119
    private function getUserDataFromRssToken(Request $request)
120
    {
121
        if ($request->missing('r')) {
122
            return Utility::showApiError(200);
123
        }
124
125
        $user = User::getByRssToken($request->input('r'));
126
        if (! $user) {
127
            return Utility::showApiError(100);
128
        }
129
130
        if ($user->hasRole('Disabled')) {
131
            return Utility::showApiError(101);
132
        }
133
134
        return [
135
            'uid' => $user->id,
136
            'userName' => $user->username,
137
            'maxDownloads' => $user->role->downloadrequests,
138
            'rssToken' => $user->api_token,
139
        ];
140
    }
141
142
    /**
143
     * Check if user has exceeded download limits
144
     *
145
     * @return Response|null
146
     *
147
     * @throws Exception
148
     */
149
    private function checkDownloadLimit(int $uid, int $maxDownloads): mixed
150
    {
151
        $requests = UserDownload::getDownloadRequests($uid);
152
        if ($requests > $maxDownloads) {
153
            return Utility::showApiError(501);
154
        }
155
156
        return null;
157
    }
158
159
    /**
160
     * Validate and sanitize the release ID parameter
161
     *
162
     * @return string|Response
163
     */
164
    private function validateAndSanitizeId(Request $request)
165
    {
166
        $id = $request->input('id');
167
168
        if (empty($id)) {
169
            return Utility::showApiError(200, 'Parameter id is required');
170
        }
171
172
        // Remove .nzb suffix if present
173
        $sanitizedId = str_ireplace(self::NZB_SUFFIX, '', $id);
174
        $request->merge(['id' => $sanitizedId]);
175
176
        return $sanitizedId;
177
    }
178
179
    /**
180
     * Check if this is a zip download request
181
     */
182
    private function isZipRequest(Request $request): bool
183
    {
184
        return $request->has('zip') && $request->input('zip') === '1';
185
    }
186
187
    /**
188
     * Handle zip download of multiple releases
189
     *
190
     * @return JsonResponse|ZipStream|StreamedResponse
191
     *
192
     * @throws Exception
193
     */
194
    private function handleZipDownload(
195
        Request $request,
196
        int $uid,
197
        string $userName,
198
        int $maxDownloads,
199
        string $releaseId
200
    ) {
201
        $guids = explode(',', $releaseId);
202
        $guidCount = \count($guids);
203
204
        // Check if zip download would exceed limits
205
        $requests = UserDownload::getDownloadRequests($uid);
206
        if ($requests + $guidCount > $maxDownloads) {
207
            return Utility::showApiError(501);
208
        }
209
210
        $zip = getStreamingZip($guids);
211
        if ($zip === '') {
212
            return response()->json(['message' => 'Unable to create .zip file'], 404);
213
        }
214
215
        // Update statistics
216
        $this->updateZipDownloadStatistics($request, $uid, $guids);
217
218
        Log::channel('zipped')->info("User {$userName} downloaded zipped files from site with IP: {$request->ip()}");
219
220
        return $zip;
221
    }
222
223
    /**
224
     * Update statistics for zip downloads
225
     */
226
    private function updateZipDownloadStatistics(Request $request, int $uid, array $guids): void
227
    {
228
        $guidCount = \count($guids);
229
        User::incrementGrabs($uid, $guidCount);
230
231
        $shouldDeleteFromCart = $request->has('del') && (int) $request->input('del') === 1;
232
233
        foreach ($guids as $guid) {
234
            Release::updateGrab($guid);
235
            UserDownload::addDownloadRequest($uid, $guid);
236
237
            if ($shouldDeleteFromCart) {
238
                UsersRelease::delCartByUserAndRelease($guid, $uid);
239
            }
240
        }
241
    }
242
243
    /**
244
     * Handle single NZB file download
245
     *
246
     * @return Response|StreamedResponse
247
     */
248
    private function handleSingleNzbDownload(
249
        Request $request,
250
        int $uid,
251
        string $rssToken,
252
        string $releaseId
253
    ) {
254
        // Get NZB file path and validate
255
        $nzbPath = (new NZB)->getNZBPath($releaseId);
256
        if (! File::exists($nzbPath)) {
257
            return Utility::showApiError(300, 'NZB file not found!');
258
        }
259
260
        // Get release data
261
        $releaseData = Release::getByGuid($releaseId);
262
        if ($releaseData === null) {
263
            return Utility::showApiError(300, 'Release not found!');
264
        }
265
266
        // Update statistics
267
        $this->updateDownloadStatistics($request, $uid, $releaseId, $releaseData['id']);
268
269
        // Build response headers
270
        $headers = $this->buildNzbHeaders($releaseId, $uid, $rssToken, $releaseData);
271
272
        // Stream modified NZB content
273
        $cleanName = $this->sanitizeFilename($releaseData['searchname']);
274
275
        return response()->streamDownload(
276
            fn () => $this->streamModifiedNzbContent($nzbPath, $uid),
277
            $cleanName.self::NZB_SUFFIX,
278
            $headers
279
        );
280
    }
281
282
    /**
283
     * Update download statistics for single NZB
284
     */
285
    private function updateDownloadStatistics(Request $request, int $uid, string $releaseId, int $releaseDbId): void
286
    {
287
        Release::updateGrab($releaseId);
288
        UserDownload::addDownloadRequest($uid, $releaseDbId);
289
        User::incrementGrabs($uid);
290
291
        if ($request->has('del') && (int) $request->input('del') === 1) {
292
            UsersRelease::delCartByUserAndRelease($releaseId, $uid);
293
        }
294
    }
295
296
    /**
297
     * Build headers for NZB download response
298
     *
299
     * @param  array<string, mixed>  $releaseData
300
     * @return array<string, string>
301
     */
302
    private function buildNzbHeaders(string $releaseId, int $uid, string $rssToken, array $releaseData): array
303
    {
304
        $headers = [
305
            'Content-Type' => 'application/x-nzb',
306
            'Expires' => now()->addYear()->toRfc7231String(),
307
            'X-DNZB-Failure' => url('/failed')."?guid={$releaseId}&userid={$uid}&api_token={$rssToken}",
308
            'X-DNZB-Category' => e($releaseData['category_name']),
309
            'X-DNZB-Details' => url("/details/{$releaseId}"),
310
        ];
311
312
        // Add optional metadata headers
313
        if (! empty($releaseData['imdbid']) && $releaseData['imdbid'] > 0) {
314
            $headers['X-DNZB-MoreInfo'] = "http://www.imdb.com/title/tt{$releaseData['imdbid']}";
315
        } elseif (! empty($releaseData['tvdb']) && $releaseData['tvdb'] > 0) {
316
            $headers['X-DNZB-MoreInfo'] = "http://www.thetvdb.com/?tab=series&id={$releaseData['tvdb']}";
317
        }
318
319
        if ((int) $releaseData['nfostatus'] === 1) {
320
            $headers['X-DNZB-NFO'] = url("/nfo/{$releaseId}");
321
        }
322
323
        $headers['X-DNZB-RCode'] = '200';
324
        $headers['X-DNZB-RText'] = 'OK, NZB content follows.';
325
326
        return $headers;
327
    }
328
329
    /**
330
     * Stream modified NZB content with user-specific modifications
331
     */
332
    private function streamModifiedNzbContent(string $nzbPath, int $uid): void
333
    {
334
        $fileHandle = gzopen($nzbPath, 'rb');
335
        if ($fileHandle === false) {
336
            return;
337
        }
338
339
        $buffer = '';
340
        $lastChunk = false;
341
342
        // Stream and modify content in chunks
343
        while (! gzeof($fileHandle)) {
344
            $chunk = gzread($fileHandle, self::BUFFER_SIZE);
345
            if ($chunk === false) {
346
                break;
347
            }
348
349
            // Combine with previous buffer to handle boundaries
350
            $buffer .= $chunk;
351
352
            // Check if this is the last chunk
353
            if (gzeof($fileHandle)) {
354
                $lastChunk = true;
355
            }
356
357
            // Process buffer
358
            if ($lastChunk) {
359
                // On last chunk, modify poster attributes
360
                $buffer = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $buffer);
361
                echo $buffer;
362
            } else {
363
                // For intermediate chunks, keep some data in buffer to handle boundaries
364
                $safeLength = mb_strlen($buffer) - 1000; // Keep last 1KB in buffer
365
                if ($safeLength > 0) {
366
                    $output = mb_substr($buffer, 0, $safeLength);
367
                    $output = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $output);
368
                    echo $output;
369
                    $buffer = mb_substr($buffer, $safeLength);
370
                }
371
            }
372
        }
373
374
        gzclose($fileHandle);
375
    }
376
377
    /**
378
     * Sanitize filename for download
379
     */
380
    private function sanitizeFilename(string $filename): string
381
    {
382
        return str_replace([',', ' ', '/', '\\'], '_', $filename);
383
    }
384
}
385