mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Make webp default format for images
This commit is contained in:
@@ -4,220 +4,498 @@ 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 Spatie\LaravelImageOptimizer\Facades\ImageOptimizer;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Resize/save/delete images to disk.
|
||||
* Service for handling release images (covers, backdrops, previews).
|
||||
* Manages image storage and deletion for releases.
|
||||
* 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.
|
||||
*/
|
||||
/** Path to save ogg audio samples. */
|
||||
public string $audSavePath;
|
||||
|
||||
/**
|
||||
* Path to save video preview jpg pictures.
|
||||
*/
|
||||
/** Path to save video preview pictures. */
|
||||
public string $imgSavePath;
|
||||
|
||||
/**
|
||||
* Path to save large jpg pictures(xxx).
|
||||
*/
|
||||
/** Path to save downloaded sample pictures. */
|
||||
public string $jpgSavePath;
|
||||
|
||||
/**
|
||||
* Path to save movie jpg covers.
|
||||
*/
|
||||
/** Path to save movie covers. */
|
||||
public string $movieImgSavePath;
|
||||
|
||||
/**
|
||||
* Path to save video ogv files.
|
||||
*/
|
||||
/** Path to save video samples. */
|
||||
public string $vidSavePath;
|
||||
|
||||
/** @var Closure(string): list<string> */
|
||||
private Closure $hostResolver;
|
||||
|
||||
/**
|
||||
* ReleaseImageService constructor.
|
||||
* @param (Closure(string): list<string>)|null $hostResolver
|
||||
*/
|
||||
public function __construct()
|
||||
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(...);
|
||||
}
|
||||
|
||||
protected function fetchImage(string $imgLoc): bool|LaravelImage
|
||||
{
|
||||
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 {
|
||||
// Create context with timeout settings for file_get_contents
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 30,
|
||||
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'follow_location' => true,
|
||||
'max_redirects' => 5,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'allow_self_signed' => false,
|
||||
],
|
||||
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(),
|
||||
]);
|
||||
|
||||
$imageData = @file_get_contents($imgLoc, false, $context);
|
||||
return ImageProcessingResult::failure('Unable to read the local image source.');
|
||||
}
|
||||
}
|
||||
|
||||
// Check HTTP response headers if available
|
||||
if (! empty($http_response_header)) {
|
||||
$statusLine = $http_response_header[0] ?? '';
|
||||
if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $statusLine, $matches)) {
|
||||
$httpCode = (int) $matches[1];
|
||||
if ($httpCode >= 400) {
|
||||
Log::debug('HTTP error fetching image from '.$imgLoc.': '.$statusLine);
|
||||
cli()->notice('HTTP error fetching image: '.$statusLine);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($imageData === false) {
|
||||
$error = error_get_last();
|
||||
$errorMsg = $error !== null ? $error['message'] : 'Unknown error fetching image';
|
||||
Log::debug('Failed to fetch image from '.$imgLoc.': '.$errorMsg);
|
||||
cli()->notice('Failed to fetch image from '.$imgLoc.': '.$errorMsg);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($imageData)) {
|
||||
Log::debug('Empty image data received from '.$imgLoc);
|
||||
cli()->notice('Empty image data received from '.$imgLoc);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return Image::fromBytes($imageData);
|
||||
} catch (Throwable $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
cli()->notice('Data not available on server');
|
||||
} elseif ($e->getCode() === 503) {
|
||||
cli()->notice('Service unavailable');
|
||||
} else {
|
||||
Log::debug('Exception fetching image from '.$imgLoc.': '.$e->getMessage());
|
||||
cli()->notice('Unable to fetch image: '.$e->getMessage());
|
||||
}
|
||||
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.');
|
||||
}
|
||||
|
||||
return false;
|
||||
$path = $upload->getRealPath();
|
||||
if ($path === false) {
|
||||
return ImageProcessingResult::failure('The uploaded image has no readable temporary path.');
|
||||
}
|
||||
|
||||
return $this->saveLocalImage($imgName, $path, $destinationDirectory, $profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an image to disk, optionally resizing it.
|
||||
* Backwards-compatible adapter while callers move to explicit source APIs.
|
||||
*
|
||||
* @param string $imgName What to name the new image.
|
||||
* @param string $imgLoc URL or location on the disk the original image is in.
|
||||
* @param string $imgSavePath Folder to save the new image in.
|
||||
* @param int $imgMaxWidth Max width to resize image to. (OPTIONAL)
|
||||
* @param int $imgMaxHeight Max height to resize image to. (OPTIONAL)
|
||||
* @param bool $saveThumb Save a thumbnail of this image? (OPTIONAL)
|
||||
* @return int 1 on success, 0 on failure Used on site to check if there is an image.
|
||||
* @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
|
||||
{
|
||||
// Guard against empty image locations to avoid 'Path cannot be empty'
|
||||
if (empty($imgLoc)) {
|
||||
public function saveImage(
|
||||
string $imgName,
|
||||
string $imgLoc,
|
||||
string $imgSavePath,
|
||||
int $imgMaxWidth = 0,
|
||||
int $imgMaxHeight = 0,
|
||||
bool $saveThumb = false,
|
||||
): int {
|
||||
if ($imgLoc === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cover = $this->fetchImage($imgLoc);
|
||||
$scheme = strtolower((string) parse_url($imgLoc, PHP_URL_SCHEME));
|
||||
$source = in_array($scheme, ['http', 'https'], true)
|
||||
? $this->fetchRemoteBytes($imgLoc)
|
||||
: $this->readLocalBytes($imgLoc);
|
||||
|
||||
if ($cover === false) {
|
||||
if (! $source['success']) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$coverPath = $imgSavePath.$imgName.'.jpg';
|
||||
$result = $this->processBytes(
|
||||
$imgName,
|
||||
$source['contents'],
|
||||
$imgSavePath,
|
||||
$imgMaxWidth > 0 ? $imgMaxWidth : null,
|
||||
$imgMaxHeight > 0 ? $imgMaxHeight : null,
|
||||
);
|
||||
|
||||
try {
|
||||
$shouldSaveThumb = false;
|
||||
|
||||
// Check if we need to resize it.
|
||||
if ($imgMaxWidth !== 0 && $imgMaxHeight !== 0) {
|
||||
$width = $cover->width();
|
||||
$height = $cover->height();
|
||||
if ($width !== 0 || $height !== 0) {
|
||||
$ratio = min($imgMaxHeight / $height, $imgMaxWidth / $width);
|
||||
// New dimensions
|
||||
$new_width = (int) ($ratio * $width);
|
||||
$new_height = (int) ($ratio * $height);
|
||||
if ($new_width < $width && $new_width > 10 && $new_height > 10) {
|
||||
$cover = $cover->resize($new_width, $new_height);
|
||||
$shouldSaveThumb = $saveThumb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$jpeg = $cover->toJpeg()->quality(100)->toBytes();
|
||||
|
||||
if ($shouldSaveThumb && ! $this->writeOptimizedImage($imgSavePath.$imgName.'_thumb.jpg', $jpeg)) {
|
||||
if ($result->success && $saveThumb && $result->path !== null) {
|
||||
$thumbPath = $imgSavePath.$imgName.'_thumb.'.$this->outputExtension();
|
||||
if (! File::copy($result->path, $thumbPath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (! $this->writeOptimizedImage($coverPath, $jpeg)) {
|
||||
return 0;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::debug('Unable to process image '.$imgLoc.' for '.$coverPath.': '.$e->getMessage());
|
||||
|
||||
return 0;
|
||||
}
|
||||
// Check if it's on the drive.
|
||||
if (! File::isReadable($coverPath)) {
|
||||
Log::debug('Image was not readable after save: '.$coverPath);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
return $result->success ? 1 : 0;
|
||||
}
|
||||
|
||||
private function writeOptimizedImage(string $path, string $contents): bool
|
||||
public function imagePath(string $directory, string $basename): ?string
|
||||
{
|
||||
if (File::put($path, $contents) === false) {
|
||||
Log::debug('Unable to write image to '.$path);
|
||||
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
ImageOptimizer::optimize($path);
|
||||
|
||||
if (! File::isReadable($path)) {
|
||||
Log::debug('Image was not readable after save: '.$path);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete images for the release.
|
||||
*
|
||||
* @param string $guid The GUID of the release.
|
||||
*/
|
||||
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
|
||||
{
|
||||
$thumb = $guid.'_thumb.jpg';
|
||||
$files = [
|
||||
$this->audSavePath.$guid.'.ogg',
|
||||
$this->vidSavePath.$guid.'.ogv',
|
||||
];
|
||||
|
||||
File::delete([$this->audSavePath.$guid.'.ogg', $this->imgSavePath.$thumb, $this->jpgSavePath.$thumb, $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'] ?? '/');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user