Files
newznab-tmux-NNTmux/app/Http/Controllers/GetNzbController.php
T
2026-03-11 10:11:30 +01:00

390 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Release;
use App\Models\User;
use App\Models\UserDownload;
use App\Models\UsersRelease;
use App\Services\Nzb\NzbService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use STS\ZipStream\Builder;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GetNzbController extends BasePageController
{
private const int BUFFER_SIZE = 1000000;
private const string NZB_SUFFIX = '.nzb';
/**
* Download NZB file(s) for authenticated users
*
* @throws Exception
*/
public function getNzb(Request $request, ?string $guid = null): JsonResponse|Response|StreamedResponse|Builder
{
// Normalize guid parameter
$this->normalizeGuidParameter($request, $guid);
// Authenticate and authorize user
$userData = $this->authenticateUser($request);
if (! \is_array($userData)) {
return $userData; // Return error response
}
['uid' => $uid, 'userName' => $userName, 'maxDownloads' => $maxDownloads, 'rssToken' => $rssToken] = $userData;
// Check download limits
$downloadLimitError = $this->checkDownloadLimit($uid, $maxDownloads);
if ($downloadLimitError !== null) {
return $downloadLimitError;
}
// Validate and sanitize ID parameter
$releaseId = $this->validateAndSanitizeId($request);
if (! \is_string($releaseId)) {
return $releaseId; // Return error response
}
// Handle zip download request
if ($this->isZipRequest($request)) {
return $this->handleZipDownload($request, $uid, $userName, $maxDownloads, $releaseId);
}
// Handle single NZB download
return $this->handleSingleNzbDownload($request, $uid, $rssToken, $releaseId);
}
/**
* Normalize the guid parameter into the request
*/
private function normalizeGuidParameter(Request $request, ?string $guid): void
{
if ($guid !== null && ! $request->has('id')) {
$request->merge(['id' => $guid]);
}
}
/**
* Authenticate user via session or RSS token
*
* @return array<string, mixed>|Response
*/
private function authenticateUser(Request $request): array|Response
{
// Try session authentication first
if ($request->user()) {
return $this->getUserDataFromSession();
}
// Try RSS token authentication
return $this->getUserDataFromRssToken($request);
}
/**
* Get user data from authenticated session
*
* @return array<string, mixed>|Response
*/
private function getUserDataFromSession(): array|Response
{
if ($this->userdata->hasRole('Disabled')) {
return showApiError(101);
}
return [
'uid' => $this->userdata->id,
'userName' => $this->userdata->username,
'maxDownloads' => $this->userdata->role->downloadrequests,
'rssToken' => $this->userdata->api_token,
];
}
/**
* Get user data from RSS token
*
* @return array<string, mixed>|Response
*/
private function getUserDataFromRssToken(Request $request): array|Response
{
if ($request->missing('r')) {
return showApiError(200);
}
$user = User::getByRssToken($request->input('r'));
if (! $user) {
return showApiError(100);
}
if ($user->hasRole('Disabled')) {
return showApiError(101);
}
return [
'uid' => $user->id,
'userName' => $user->username,
'maxDownloads' => $user->role->downloadrequests,
'rssToken' => $user->api_token,
];
}
/**
* Check if user has exceeded download limits
*
*
* @throws Exception
*/
private function checkDownloadLimit(int $uid, int $maxDownloads): ?Response
{
$requests = UserDownload::getDownloadRequests($uid);
if ($requests > $maxDownloads) {
return showApiError(501);
}
return null;
}
/**
* Validate and sanitize the release ID parameter
*/
private function validateAndSanitizeId(Request $request): string|Response
{
$id = $request->input('id');
if (empty($id)) {
return showApiError(200, 'Parameter id is required');
}
// Remove .nzb suffix if present
$sanitizedId = str_ireplace(self::NZB_SUFFIX, '', $id);
$request->merge(['id' => $sanitizedId]);
return $sanitizedId;
}
/**
* Check if this is a zip download request
*/
private function isZipRequest(Request $request): bool
{
return $request->has('zip') && $request->input('zip') === '1';
}
/**
* Handle zip download of multiple releases
*
* @throws Exception
*/
private function handleZipDownload(
Request $request,
int $uid,
string $userName,
int $maxDownloads,
string $releaseId
): JsonResponse|Response|StreamedResponse|Builder {
$guids = explode(',', $releaseId);
$guidCount = \count($guids);
// Check if zip download would exceed limits
$requests = UserDownload::getDownloadRequests($uid);
if ($requests + $guidCount > $maxDownloads) {
return showApiError(501);
}
$zip = getStreamingZip($guids);
if ($zip === '') { // @phpstan-ignore identical.alwaysFalse
return response()->json(['message' => 'Unable to create .zip file'], 404);
}
// Update statistics
$this->updateZipDownloadStatistics($request, $uid, $guids);
Log::channel('zipped')->info("User {$userName} downloaded zipped files from site with IP: {$request->ip()}");
return $zip;
}
/**
* Update statistics for zip downloads (batched to avoid N+1 queries).
*
* @param list<string> $guids
*/
private function updateZipDownloadStatistics(Request $request, int $uid, array $guids): void
{
if ($guids === []) {
return;
}
$guidCount = \count($guids);
User::incrementGrabs($uid, $guidCount);
// Resolve guids to release ids in one query for batch operations
$releaseIds = Release::query()->whereIn('guid', $guids)->pluck('id')->all();
Release::updateGrabsByGuids($guids);
UserDownload::addDownloadRequestsBatch($uid, $releaseIds);
$shouldDeleteFromCart = $request->has('del') && (int) $request->input('del') === 1;
if ($shouldDeleteFromCart && $releaseIds !== []) {
UsersRelease::delCartByUserAndReleases($uid, $releaseIds);
}
}
/**
* Handle single NZB file download
*/
private function handleSingleNzbDownload(
Request $request,
int $uid,
string $rssToken,
string $releaseId
): Response|StreamedResponse {
// Get NZB file path and validate
$nzbPath = app(NzbService::class)->getNzbPath($releaseId);
if (! File::exists($nzbPath)) {
return showApiError(300, 'NZB file not found!');
}
// Get release data
$releaseData = Release::getByGuid($releaseId);
if ($releaseData === null) {
return showApiError(300, 'Release not found!');
}
// Update statistics
$this->updateDownloadStatistics($request, $uid, $releaseId, $releaseData->id);
// Build response headers
$headers = $this->buildNzbHeaders($releaseId, $uid, $rssToken, $releaseData);
// Stream modified NZB content
$cleanName = $this->sanitizeFilename($releaseData->searchname);
return response()->streamDownload(
fn () => $this->streamModifiedNzbContent($nzbPath, $uid),
$cleanName.self::NZB_SUFFIX,
$headers
);
}
/**
* Update download statistics for single NZB
*/
private function updateDownloadStatistics(Request $request, int $uid, string $releaseId, int $releaseDbId): void
{
Release::updateGrab($releaseId);
UserDownload::addDownloadRequest($uid, $releaseDbId);
User::incrementGrabs($uid);
if ($request->has('del') && (int) $request->input('del') === 1) {
UsersRelease::delCartByUserAndRelease($releaseId, $uid);
}
}
/**
* Build headers for NZB download response
*
* @return array<string, string>
*/
private function buildNzbHeaders(string $releaseId, int $uid, string $rssToken, Release $releaseData): array
{
$headers = [
'Content-Type' => 'application/x-nzb',
'Expires' => now()->addYear()->toRfc7231String(),
'X-DNZB-Failure' => url('/failed')."?guid={$releaseId}&userid={$uid}&api_token={$rssToken}",
'X-DNZB-Category' => e($releaseData->category_name),
'X-DNZB-Details' => url("/details/{$releaseId}"),
];
// Add optional metadata headers
if (! empty($releaseData->imdbid) && $releaseData->imdbid > 0) {
$headers['X-DNZB-MoreInfo'] = "http://www.imdb.com/title/tt{$releaseData->imdbid}";
} elseif (! empty($releaseData->tvdb) && $releaseData->tvdb > 0) {
$headers['X-DNZB-MoreInfo'] = "http://www.thetvdb.com/?tab=series&id={$releaseData->tvdb}";
}
if ((int) $releaseData->nfostatus === 1) {
$headers['X-DNZB-NFO'] = url("/nfo/{$releaseId}");
}
$headers['X-DNZB-RCode'] = '200';
$headers['X-DNZB-RText'] = 'OK, NZB content follows.';
return $headers;
}
/**
* Stream modified NZB content with user-specific modifications
*/
private function streamModifiedNzbContent(string $nzbPath, int $uid): void
{
if (! File::exists($nzbPath)) {
Log::warning('NZB file not found during streaming', ['path' => $nzbPath]);
echo '<?xml version="1.0" encoding="UTF-8"?><error>NZB file not found</error>';
return;
}
$fileHandle = @gzopen($nzbPath, 'rb');
if ($fileHandle === false) {
Log::warning('Failed to open NZB file for streaming', ['path' => $nzbPath]);
echo '<?xml version="1.0" encoding="UTF-8"?><error>Failed to read NZB file</error>';
return;
}
$buffer = '';
$lastChunk = false;
// Stream and modify content in chunks
while (! gzeof($fileHandle)) {
$chunk = gzread($fileHandle, self::BUFFER_SIZE);
if ($chunk === false) {
break;
}
// Combine with previous buffer to handle boundaries
$buffer .= $chunk;
// Check if this is the last chunk
if (gzeof($fileHandle)) { // @phpstan-ignore if.alwaysFalse
$lastChunk = true;
}
// Process buffer
if ($lastChunk) { // @phpstan-ignore if.alwaysFalse
// On last chunk, modify poster attributes
$buffer = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $buffer);
echo $buffer;
} else {
// For intermediate chunks, keep some data in buffer to handle boundaries
$safeLength = mb_strlen($buffer) - 1000; // Keep last 1KB in buffer
if ($safeLength > 0) {
$output = mb_substr($buffer, 0, $safeLength);
$output = preg_replace('/file poster="/', 'file poster="'.$uid.'-', $output);
echo $output;
$buffer = mb_substr($buffer, $safeLength);
}
}
}
gzclose($fileHandle);
}
/**
* Sanitize filename for download
*/
private function sanitizeFilename(string $filename): string
{
return str_replace([',', ' ', '/', '\\'], '_', $filename);
}
}