200 lines
6.9 KiB
PHP
200 lines
6.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\ImageManager\Model;
|
|
|
|
use Magento\Framework\App\Filesystem\DirectoryList;
|
|
use Magento\Framework\App\Request\Http;
|
|
use Magento\Framework\Exception\LocalizedException;
|
|
use Magento\Framework\Filesystem;
|
|
use Magento\MediaStorage\Model\File\UploaderFactory;
|
|
|
|
class MediaUploader
|
|
{
|
|
private const MEDIA_DIRECTORY = 'esun/image-manager';
|
|
private const PNG_SIGNATURE = "\x89PNG\r\n\x1a\n";
|
|
private const PNG_TEXT_CHUNKS = ['iTXt', 'tEXt', 'zTXt'];
|
|
|
|
public function __construct(
|
|
private readonly UploaderFactory $uploaderFactory,
|
|
private readonly Filesystem $filesystem,
|
|
private readonly Http $request
|
|
) {
|
|
}
|
|
|
|
public function save(string $field): ?string
|
|
{
|
|
$file = $this->request->getFiles($field);
|
|
if (!is_array($file)
|
|
|| empty($file['tmp_name'])
|
|
|| (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE
|
|
) {
|
|
return null;
|
|
}
|
|
if ((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
throw new LocalizedException(__('The %1 image could not be uploaded.', $field));
|
|
}
|
|
|
|
$this->sanitizePngUpload((string) $file['tmp_name']);
|
|
|
|
$relativeDirectory = self::MEDIA_DIRECTORY . '/' . $field . '/' . date('Y/m');
|
|
$media = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
|
|
$media->create($relativeDirectory);
|
|
$uploader = $this->uploaderFactory->create(['fileId' => $field]);
|
|
$uploader->setAllowedExtensions(['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
|
$uploader->setAllowRenameFiles(true);
|
|
$uploader->setFilesDispersion(true);
|
|
$saved = $uploader->save($media->getAbsolutePath($relativeDirectory));
|
|
if (!is_array($saved) || empty($saved['file'])) {
|
|
throw new LocalizedException(__('Unable to save the %1 image.', $field));
|
|
}
|
|
|
|
return trim($relativeDirectory . '/' . ltrim((string) $saved['file'], '/'), '/');
|
|
}
|
|
|
|
/**
|
|
* Strip optional PNG text chunks before Magento asks GD to validate the image.
|
|
*
|
|
* Some image exporters embed multi-megabyte iTXt payloads in tiny icons.
|
|
* libpng versions with stricter chunk limits reject those files before GD can
|
|
* inspect the actual pixels. Text chunks do not affect image rendering, so
|
|
* rebuilding the PNG without them keeps the original image and CRC-protected
|
|
* critical chunks intact while avoiding environment-dependent validation.
|
|
*/
|
|
public function sanitizePngUpload(string $path): void
|
|
{
|
|
if ($path === '' || !is_file($path)) {
|
|
return;
|
|
}
|
|
|
|
$source = @fopen($path, 'rb');
|
|
if ($source === false) {
|
|
return;
|
|
}
|
|
|
|
$signature = fread($source, 8);
|
|
if ($signature !== self::PNG_SIGNATURE) {
|
|
fclose($source);
|
|
return;
|
|
}
|
|
|
|
$temporary = tempnam(dirname($path), 'esun-png-');
|
|
if ($temporary === false) {
|
|
fclose($source);
|
|
throw new LocalizedException(__('Unable to prepare the PNG image for validation.'));
|
|
}
|
|
|
|
$target = @fopen($temporary, 'wb');
|
|
if ($target === false) {
|
|
fclose($source);
|
|
@unlink($temporary);
|
|
throw new LocalizedException(__('Unable to prepare the PNG image for validation.'));
|
|
}
|
|
|
|
$removedText = false;
|
|
$hasHeader = false;
|
|
$hasImageData = false;
|
|
$hasEnd = false;
|
|
|
|
try {
|
|
$this->writeAll($target, self::PNG_SIGNATURE);
|
|
while (!feof($source)) {
|
|
$header = fread($source, 8);
|
|
if ($header === '') {
|
|
break;
|
|
}
|
|
if (strlen($header) !== 8) {
|
|
throw new LocalizedException(__('The uploaded PNG image is incomplete.'));
|
|
}
|
|
|
|
$chunk = unpack('Nlength/a4type', $header);
|
|
$length = (int) ($chunk['length'] ?? -1);
|
|
$type = (string) ($chunk['type'] ?? '');
|
|
if ($length < 0 || !preg_match('/^[A-Za-z]{4}$/', $type)) {
|
|
throw new LocalizedException(__('The uploaded PNG image has an invalid chunk.'));
|
|
}
|
|
|
|
$skip = in_array($type, self::PNG_TEXT_CHUNKS, true);
|
|
if ($skip) {
|
|
$this->copyBytes($source, null, $length + 4);
|
|
$removedText = true;
|
|
} else {
|
|
$this->writeAll($target, $header);
|
|
$this->copyBytes($source, $target, $length + 4);
|
|
}
|
|
|
|
$hasHeader = $hasHeader || $type === 'IHDR';
|
|
$hasImageData = $hasImageData || $type === 'IDAT';
|
|
if ($type === 'IEND') {
|
|
$hasEnd = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$hasHeader || !$hasImageData || !$hasEnd) {
|
|
throw new LocalizedException(__('The uploaded PNG image is incomplete.'));
|
|
}
|
|
|
|
fclose($source);
|
|
$source = null;
|
|
fflush($target);
|
|
fclose($target);
|
|
$target = null;
|
|
|
|
if ($removedText && !@copy($temporary, $path)) {
|
|
throw new LocalizedException(__('Unable to prepare the PNG image for validation.'));
|
|
}
|
|
} finally {
|
|
if (is_resource($source)) {
|
|
fclose($source);
|
|
}
|
|
if (is_resource($target)) {
|
|
fclose($target);
|
|
}
|
|
@unlink($temporary);
|
|
}
|
|
}
|
|
|
|
/** @param resource $source @param resource|null $target */
|
|
private function copyBytes($source, $target, int $remaining): void
|
|
{
|
|
while ($remaining > 0) {
|
|
$data = fread($source, min(8192, $remaining));
|
|
if ($data === false || $data === '') {
|
|
throw new LocalizedException(__('The uploaded PNG image is incomplete.'));
|
|
}
|
|
if ($target !== null) {
|
|
$this->writeAll($target, $data);
|
|
}
|
|
$remaining -= strlen($data);
|
|
}
|
|
}
|
|
|
|
/** @param resource $target */
|
|
private function writeAll($target, string $data): void
|
|
{
|
|
$offset = 0;
|
|
$length = strlen($data);
|
|
while ($offset < $length) {
|
|
$written = fwrite($target, substr($data, $offset));
|
|
if ($written === false || $written === 0) {
|
|
throw new LocalizedException(__('Unable to prepare the PNG image for validation.'));
|
|
}
|
|
$offset += $written;
|
|
}
|
|
}
|
|
|
|
public function delete(string $path): void
|
|
{
|
|
$path = ltrim($path, '/');
|
|
if ($path === '' || !str_starts_with($path, self::MEDIA_DIRECTORY . '/')) {
|
|
return;
|
|
}
|
|
|
|
$media = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
|
|
if ($media->isExist($path)) {
|
|
$media->delete($path);
|
|
}
|
|
}
|
|
}
|