Files
newznab-tmux-NNTmux/app/Services/ReleaseImageService.php
T
2026-07-16 15:57:20 +02:00

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'] ?? '/');
}
}