mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 23:01:29 +00:00
502 lines
17 KiB
PHP
502 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\ImageAssetProfile;
|
|
use App\Support\Data\ImageProcessingResult;
|
|
use Closure;
|
|
use GuzzleHttp\Psr7\Uri;
|
|
use GuzzleHttp\Psr7\UriResolver;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Image\Image as LaravelImage;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Image;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Fetch, normalize, store, locate, and delete release images.
|
|
*
|
|
* Destination directories are deliberately supplied by callers. This keeps
|
|
* the existing storage_path/public_path/COVERS_PATH ownership unchanged.
|
|
*/
|
|
class ReleaseImageService
|
|
{
|
|
/** Path to save ogg audio samples. */
|
|
public string $audSavePath;
|
|
|
|
/** Path to save video preview pictures. */
|
|
public string $imgSavePath;
|
|
|
|
/** Path to save downloaded sample pictures. */
|
|
public string $jpgSavePath;
|
|
|
|
/** Path to save movie covers. */
|
|
public string $movieImgSavePath;
|
|
|
|
/** Path to save video samples. */
|
|
public string $vidSavePath;
|
|
|
|
/** @var Closure(string): list<string> */
|
|
private Closure $hostResolver;
|
|
|
|
/**
|
|
* @param (Closure(string): list<string>)|null $hostResolver
|
|
*/
|
|
public function __construct(?Closure $hostResolver = null)
|
|
{
|
|
$this->audSavePath = storage_path('covers/audiosample/');
|
|
$this->imgSavePath = storage_path('covers/preview/');
|
|
$this->jpgSavePath = storage_path('covers/sample/');
|
|
$this->movieImgSavePath = storage_path('covers/movies/');
|
|
$this->vidSavePath = storage_path('covers/video/');
|
|
$this->hostResolver = $hostResolver ?? $this->defaultHostResolver(...);
|
|
}
|
|
|
|
public function saveRemoteImage(
|
|
string $imgName,
|
|
string $url,
|
|
string $destinationDirectory,
|
|
ImageAssetProfile $profile = ImageAssetProfile::Original,
|
|
): ImageProcessingResult {
|
|
$fetched = $this->fetchRemoteBytes($url);
|
|
|
|
if (! $fetched['success']) {
|
|
return ImageProcessingResult::failure($fetched['reason']);
|
|
}
|
|
|
|
return $this->processBytes(
|
|
$imgName,
|
|
$fetched['contents'],
|
|
$destinationDirectory,
|
|
$profile->maxWidth(),
|
|
$profile->maxHeight(),
|
|
);
|
|
}
|
|
|
|
public function saveLocalImage(
|
|
string $imgName,
|
|
string $sourcePath,
|
|
string $destinationDirectory,
|
|
ImageAssetProfile $profile = ImageAssetProfile::Original,
|
|
): ImageProcessingResult {
|
|
if (! File::isFile($sourcePath) || ! File::isReadable($sourcePath)) {
|
|
return ImageProcessingResult::failure('Image source is not a readable local file.');
|
|
}
|
|
|
|
$maxBytes = $this->maxSourceBytes();
|
|
$sourceSize = File::size($sourcePath);
|
|
if ($sourceSize === false || $sourceSize <= 0 || $sourceSize > $maxBytes) {
|
|
return ImageProcessingResult::failure('Image source size is invalid or exceeds the configured limit.');
|
|
}
|
|
|
|
try {
|
|
return $this->processBytes(
|
|
$imgName,
|
|
File::get($sourcePath),
|
|
$destinationDirectory,
|
|
$profile->maxWidth(),
|
|
$profile->maxHeight(),
|
|
);
|
|
} catch (Throwable $e) {
|
|
Log::debug('Unable to read local image source.', [
|
|
'path' => $sourcePath,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ImageProcessingResult::failure('Unable to read the local image source.');
|
|
}
|
|
}
|
|
|
|
public function saveUploadedImage(
|
|
string $imgName,
|
|
UploadedFile $upload,
|
|
string $destinationDirectory,
|
|
ImageAssetProfile $profile = ImageAssetProfile::Original,
|
|
): ImageProcessingResult {
|
|
if (! $upload->isValid()) {
|
|
return ImageProcessingResult::failure('The uploaded image is invalid.');
|
|
}
|
|
|
|
$path = $upload->getRealPath();
|
|
if ($path === false) {
|
|
return ImageProcessingResult::failure('The uploaded image has no readable temporary path.');
|
|
}
|
|
|
|
return $this->saveLocalImage($imgName, $path, $destinationDirectory, $profile);
|
|
}
|
|
|
|
/**
|
|
* Backwards-compatible adapter while callers move to explicit source APIs.
|
|
*
|
|
* @return int 1 on success, 0 on failure
|
|
*/
|
|
public function saveImage(
|
|
string $imgName,
|
|
string $imgLoc,
|
|
string $imgSavePath,
|
|
int $imgMaxWidth = 0,
|
|
int $imgMaxHeight = 0,
|
|
bool $saveThumb = false,
|
|
): int {
|
|
if ($imgLoc === '') {
|
|
return 0;
|
|
}
|
|
|
|
$scheme = strtolower((string) parse_url($imgLoc, PHP_URL_SCHEME));
|
|
$source = in_array($scheme, ['http', 'https'], true)
|
|
? $this->fetchRemoteBytes($imgLoc)
|
|
: $this->readLocalBytes($imgLoc);
|
|
|
|
if (! $source['success']) {
|
|
return 0;
|
|
}
|
|
|
|
$result = $this->processBytes(
|
|
$imgName,
|
|
$source['contents'],
|
|
$imgSavePath,
|
|
$imgMaxWidth > 0 ? $imgMaxWidth : null,
|
|
$imgMaxHeight > 0 ? $imgMaxHeight : null,
|
|
);
|
|
|
|
if ($result->success && $saveThumb && $result->path !== null) {
|
|
$thumbPath = $imgSavePath.$imgName.'_thumb.'.$this->outputExtension();
|
|
if (! File::copy($result->path, $thumbPath)) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return $result->success ? 1 : 0;
|
|
}
|
|
|
|
public function imagePath(string $directory, string $basename): ?string
|
|
{
|
|
foreach (array_unique([$this->outputExtension(), 'webp', 'jpg', 'jpeg']) as $extension) {
|
|
$path = rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$basename.'.'.$extension;
|
|
if (File::isReadable($path)) {
|
|
return $path;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function imageExists(string $directory, string $basename): bool
|
|
{
|
|
return $this->imagePath($directory, $basename) !== null;
|
|
}
|
|
|
|
public function outputExtension(): string
|
|
{
|
|
return $this->outputFormat() === 'webp' ? 'webp' : 'jpg';
|
|
}
|
|
|
|
/** Delete all generated assets for a release, including legacy images. */
|
|
public function delete(string $guid): void
|
|
{
|
|
$files = [
|
|
$this->audSavePath.$guid.'.ogg',
|
|
$this->vidSavePath.$guid.'.ogv',
|
|
];
|
|
|
|
foreach (['webp', 'jpg', 'jpeg'] as $extension) {
|
|
$files[] = $this->imgSavePath.$guid.'_thumb.'.$extension;
|
|
$files[] = $this->jpgSavePath.$guid.'_thumb.'.$extension;
|
|
}
|
|
|
|
File::delete($files);
|
|
}
|
|
|
|
/** @return array{success: bool, contents: string, reason: string} */
|
|
private function readLocalBytes(string $path): array
|
|
{
|
|
if (! File::isFile($path) || ! File::isReadable($path)) {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Image source is not a readable local file.'];
|
|
}
|
|
|
|
$size = File::size($path);
|
|
if ($size === false || $size <= 0 || $size > $this->maxSourceBytes()) {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Image source size is invalid or exceeds the configured limit.'];
|
|
}
|
|
|
|
try {
|
|
return ['success' => true, 'contents' => File::get($path), 'reason' => ''];
|
|
} catch (Throwable) {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Unable to read the local image source.'];
|
|
}
|
|
}
|
|
|
|
/** @return array{success: bool, contents: string, reason: string} */
|
|
private function fetchRemoteBytes(string $url): array
|
|
{
|
|
$currentUrl = $url;
|
|
$maxRedirects = max(0, (int) config('image.fetch_max_redirects', 5));
|
|
|
|
try {
|
|
for ($redirects = 0; $redirects <= $maxRedirects; $redirects++) {
|
|
$this->assertSafeRemoteUrl($currentUrl);
|
|
|
|
$response = Http::withHeaders([
|
|
'Accept' => 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.5',
|
|
'User-Agent' => 'NNTmux Image Fetcher',
|
|
])->withOptions([
|
|
'allow_redirects' => false,
|
|
'stream' => true,
|
|
])->connectTimeout(max(1, (int) config('image.fetch_connect_timeout', 5)))
|
|
->timeout(max(1, (int) config('image.fetch_timeout', 30)))
|
|
->get($currentUrl);
|
|
|
|
if ($response->status() >= 300 && $response->status() < 400) {
|
|
$location = $response->header('Location');
|
|
if ($location === null || $location === '' || $redirects === $maxRedirects) {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Remote image redirect limit was exceeded.'];
|
|
}
|
|
|
|
$currentUrl = (string) UriResolver::resolve(new Uri($currentUrl), new Uri($location));
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
Log::debug('Remote image request failed.', [
|
|
'url' => $this->redactedUrl($currentUrl),
|
|
'status' => $response->status(),
|
|
]);
|
|
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Remote image request failed.'];
|
|
}
|
|
|
|
$contentLength = $response->header('Content-Length');
|
|
if (is_numeric($contentLength) && (int) $contentLength > $this->maxSourceBytes()) {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Remote image exceeds the configured size limit.'];
|
|
}
|
|
|
|
$stream = $response->toPsrResponse()->getBody();
|
|
$contents = '';
|
|
while (! $stream->eof()) {
|
|
$remaining = $this->maxSourceBytes() - strlen($contents);
|
|
if ($remaining <= 0) {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Remote image exceeds the configured size limit.'];
|
|
}
|
|
|
|
$contents .= $stream->read(min(8192, $remaining + 1));
|
|
if (strlen($contents) > $this->maxSourceBytes()) {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Remote image exceeds the configured size limit.'];
|
|
}
|
|
}
|
|
|
|
if ($contents === '') {
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Remote image response was empty.'];
|
|
}
|
|
|
|
return ['success' => true, 'contents' => $contents, 'reason' => ''];
|
|
}
|
|
} catch (Throwable $e) {
|
|
Log::debug('Unable to fetch remote image.', [
|
|
'url' => $this->redactedUrl($currentUrl),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
return ['success' => false, 'contents' => '', 'reason' => 'Unable to fetch the remote image.'];
|
|
}
|
|
|
|
private function processBytes(
|
|
string $imgName,
|
|
string $contents,
|
|
string $destinationDirectory,
|
|
?int $maxWidth,
|
|
?int $maxHeight,
|
|
): ImageProcessingResult {
|
|
if (! $this->isValidBasename($imgName)) {
|
|
return ImageProcessingResult::failure('Image basename is invalid.');
|
|
}
|
|
|
|
if ($contents === '' || strlen($contents) > $this->maxSourceBytes()) {
|
|
return ImageProcessingResult::failure('Image source size is invalid or exceeds the configured limit.');
|
|
}
|
|
|
|
$temporaryPath = null;
|
|
|
|
try {
|
|
$image = Image::fromBytes($contents);
|
|
$width = $image->width();
|
|
$height = $image->height();
|
|
|
|
if ($width <= 0 || $height <= 0 || ($width * $height) > $this->maxSourcePixels()) {
|
|
return ImageProcessingResult::failure('Decoded image dimensions exceed the configured limit.');
|
|
}
|
|
|
|
$image = $image->orient();
|
|
$image = $this->resizeDown($image, $width, $height, $maxWidth, $maxHeight);
|
|
$encoded = $this->encode($image);
|
|
|
|
File::ensureDirectoryExists($destinationDirectory, 0775, true);
|
|
$directory = rtrim($destinationDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
|
$extension = $this->outputExtension();
|
|
$destinationPath = $directory.$imgName.'.'.$extension;
|
|
$temporaryPath = $directory.'.'.$imgName.'.'.bin2hex(random_bytes(8)).'.tmp.'.$extension;
|
|
|
|
if (File::put($temporaryPath, $encoded) === false) {
|
|
return ImageProcessingResult::failure('Unable to write the processed image.');
|
|
}
|
|
|
|
$dimensions = getimagesize($temporaryPath);
|
|
$mimeType = File::mimeType($temporaryPath);
|
|
$expectedMime = $extension === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
|
|
if (! is_array($dimensions) || $mimeType !== $expectedMime || ! File::isReadable($temporaryPath)) {
|
|
return ImageProcessingResult::failure('Processed image validation failed.');
|
|
}
|
|
|
|
if (! rename($temporaryPath, $destinationPath)) {
|
|
return ImageProcessingResult::failure('Unable to atomically publish the processed image.');
|
|
}
|
|
$temporaryPath = null;
|
|
File::chmod($destinationPath, 0644);
|
|
|
|
return ImageProcessingResult::success(
|
|
$destinationPath,
|
|
(int) $dimensions[0],
|
|
(int) $dimensions[1],
|
|
$expectedMime,
|
|
);
|
|
} catch (Throwable $e) {
|
|
Log::debug('Unable to process image.', [
|
|
'destination' => $destinationDirectory,
|
|
'name' => $imgName,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ImageProcessingResult::failure('Unable to decode or process the image.');
|
|
} finally {
|
|
if ($temporaryPath !== null) {
|
|
File::delete($temporaryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function resizeDown(
|
|
LaravelImage $image,
|
|
int $width,
|
|
int $height,
|
|
?int $maxWidth,
|
|
?int $maxHeight,
|
|
): LaravelImage {
|
|
if ($maxWidth === null || $maxHeight === null) {
|
|
return $image;
|
|
}
|
|
|
|
$ratio = min($maxHeight / $height, $maxWidth / $width, 1);
|
|
$newWidth = (int) floor($ratio * $width);
|
|
$newHeight = (int) floor($ratio * $height);
|
|
|
|
if ($ratio < 1 && $newWidth > 10 && $newHeight > 10) {
|
|
return $image->resize($newWidth, $newHeight);
|
|
}
|
|
|
|
return $image;
|
|
}
|
|
|
|
private function encode(LaravelImage $image): string
|
|
{
|
|
$quality = max(1, min(100, (int) config('image.output_quality', 82)));
|
|
|
|
return match ($this->outputFormat()) {
|
|
'webp' => $image->toWebp()->quality($quality)->toBytes(),
|
|
default => $image->toJpeg()->quality($quality)->toBytes(),
|
|
};
|
|
}
|
|
|
|
private function outputFormat(): string
|
|
{
|
|
$format = strtolower((string) config('image.output_format', 'webp'));
|
|
|
|
return in_array($format, ['jpg', 'jpeg'], true) ? 'jpg' : 'webp';
|
|
}
|
|
|
|
private function maxSourceBytes(): int
|
|
{
|
|
return max(1, (int) config('image.max_source_bytes', 20 * 1024 * 1024));
|
|
}
|
|
|
|
private function maxSourcePixels(): int
|
|
{
|
|
return max(1, (int) config('image.max_source_pixels', 40_000_000));
|
|
}
|
|
|
|
private function isValidBasename(string $basename): bool
|
|
{
|
|
return preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $basename) === 1;
|
|
}
|
|
|
|
private function assertSafeRemoteUrl(string $url): void
|
|
{
|
|
$parts = parse_url($url);
|
|
if (! is_array($parts)
|
|
|| ! isset($parts['scheme'], $parts['host'])
|
|
|| ! in_array(strtolower($parts['scheme']), ['http', 'https'], true)
|
|
|| isset($parts['user'])
|
|
|| isset($parts['pass'])
|
|
) {
|
|
throw new \InvalidArgumentException('Remote image URL is invalid.');
|
|
}
|
|
|
|
$addresses = ($this->hostResolver)($parts['host']);
|
|
if ($addresses === []) {
|
|
throw new \InvalidArgumentException('Remote image host did not resolve.');
|
|
}
|
|
|
|
foreach ($addresses as $address) {
|
|
if (filter_var(
|
|
$address,
|
|
FILTER_VALIDATE_IP,
|
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
|
) === false) {
|
|
throw new \InvalidArgumentException('Remote image host resolves to a non-public address.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function defaultHostResolver(string $host): array
|
|
{
|
|
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
|
return [$host];
|
|
}
|
|
|
|
$records = dns_get_record($host, DNS_A | DNS_AAAA);
|
|
if ($records === false) {
|
|
return [];
|
|
}
|
|
|
|
$addresses = [];
|
|
foreach ($records as $record) {
|
|
if (isset($record['ip'])) {
|
|
$addresses[] = $record['ip'];
|
|
}
|
|
if (isset($record['ipv6'])) {
|
|
$addresses[] = $record['ipv6'];
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($addresses));
|
|
}
|
|
|
|
private function redactedUrl(string $url): string
|
|
{
|
|
$parts = parse_url($url);
|
|
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
|
|
return '[invalid-url]';
|
|
}
|
|
|
|
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
|
|
|
return strtolower($parts['scheme']).'://'.$parts['host'].$port.($parts['path'] ?? '/');
|
|
}
|
|
}
|