Files
esun3d/app/code/Esun/ImageManager/Model/MediaUploader.php
T
2026-09-15 10:35:56 +08:00

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);
}
}
}