NNTmux /
newznab-tmux
| 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
Bug
introduced
by
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 |