Make webp default format for images

This commit is contained in:
DariusIII
2026-07-16 15:57:20 +02:00
parent 17be0e11d2
commit b27fb8d86b
60 changed files with 1154 additions and 1136 deletions
+430 -152
View File
@@ -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'] ?? '/');
}
}