Issues (995)

app/Http/Controllers/GetNzbController.php (1 issue)

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