Issues (479)

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 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
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
     * @return array<string, string>
300
     */
301
    private function buildNzbHeaders(string $releaseId, int $uid, string $rssToken, Release $releaseData): array
302
    {
303
        $headers = [
304
            'Content-Type' => 'application/x-nzb',
305
            'Expires' => now()->addYear()->toRfc7231String(),
306
            'X-DNZB-Failure' => url('/failed')."?guid={$releaseId}&userid={$uid}&api_token={$rssToken}",
307
            'X-DNZB-Category' => e($releaseData->category_name),
308
            'X-DNZB-Details' => url("/details/{$releaseId}"),
309
        ];
310
311
        // Add optional metadata headers
312
        if (! empty($releaseData->imdbid) && $releaseData->imdbid > 0) {
313
            $headers['X-DNZB-MoreInfo'] = "http://www.imdb.com/title/tt{$releaseData->imdbid}";
314
        } elseif (! empty($releaseData->tvdb) && $releaseData->tvdb > 0) {
315
            $headers['X-DNZB-MoreInfo'] = "http://www.thetvdb.com/?tab=series&id={$releaseData->tvdb}";
316
        }
317
318
        if ((int) $releaseData->nfostatus === 1) {
319
            $headers['X-DNZB-NFO'] = url("/nfo/{$releaseId}");
320
        }
321
322
        $headers['X-DNZB-RCode'] = '200';
323
        $headers['X-DNZB-RText'] = 'OK, NZB content follows.';
324
325
        return $headers;
326
    }
327
328
    /**
329
     * Stream modified NZB content with user-specific modifications
330
     */
331
    private function streamModifiedNzbContent(string $nzbPath, int $uid): void
332
    {
333
        $fileHandle = gzopen($nzbPath, 'rb');
334
        if ($fileHandle === false) {
335
            return;
336
        }
337
338
        $buffer = '';
339
        $lastChunk = false;
340
341
        // Stream and modify content in chunks
342
        while (! gzeof($fileHandle)) {
343
            $chunk = gzread($fileHandle, self::BUFFER_SIZE);
344
            if ($chunk === false) {
345
                break;
346
            }
347
348
            // Combine with previous buffer to handle boundaries
349
            $buffer .= $chunk;
350
351
            // Check if this is the last chunk
352
            if (gzeof($fileHandle)) {
353
                $lastChunk = true;
354
            }
355
356
            // Process buffer
357
            if ($lastChunk) {
358
                // On last chunk, modify poster attributes
359
                $buffer = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $buffer);
360
                echo $buffer;
361
            } else {
362
                // For intermediate chunks, keep some data in buffer to handle boundaries
363
                $safeLength = mb_strlen($buffer) - 1000; // Keep last 1KB in buffer
364
                if ($safeLength > 0) {
365
                    $output = mb_substr($buffer, 0, $safeLength);
366
                    $output = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $output);
367
                    echo $output;
368
                    $buffer = mb_substr($buffer, $safeLength);
369
                }
370
            }
371
        }
372
373
        gzclose($fileHandle);
374
    }
375
376
    /**
377
     * Sanitize filename for download
378
     */
379
    private function sanitizeFilename(string $filename): string
380
    {
381
        return str_replace([',', ' ', '/', '\\'], '_', $filename);
382
    }
383
}
384