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 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
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 |