From 60451adddba856f93d2b4a03dab2e7887fa8f6d0 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Mon, 3 Oct 2022 03:05:58 +0200 Subject: [PATCH] Add image controller and util classes --- Controller/ImageController.php | 330 +++++++++++++++++++++++++++++++++ Util/ImageUtil.php | 239 ++++++++++++++++++++++++ 2 files changed, 569 insertions(+) create mode 100644 Controller/ImageController.php create mode 100644 Util/ImageUtil.php diff --git a/Controller/ImageController.php b/Controller/ImageController.php new file mode 100644 index 0000000..8351653 --- /dev/null +++ b/Controller/ImageController.php @@ -0,0 +1,330 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Rapsys\PackBundle\Controller; + +use Symfony\Component\HttpFoundation\HeaderUtils; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\RequestContext; +use Symfony\Contracts\Service\ServiceSubscriberInterface; + +use Rapsys\PackBundle\Util\ImageUtil; +use Rapsys\PackBundle\Util\SluggerUtil; + +/** + * {@inheritdoc} + */ +class ImageController extends AbstractController implements ServiceSubscriberInterface { + /** + * The cache path + */ + protected string $cache; + + /** + * The ContainerInterface instance + * + * @var ContainerInterface + */ + protected $container; + + /** + * The ImageUtil instance + */ + protected ImageUtil $image; + + /** + * The public path + */ + protected string $public; + + /** + * The SluggerUtil instance + */ + protected SluggerUtil $slugger; + + /** + * Creates a new image controller + * + * @param ContainerInterface $container The ContainerInterface instance + * @param ImageUtil $image The MapUtil instance + * @param SluggerUtil $slugger The SluggerUtil instance + * @param string $cache The cache path + * @param string $public The public path + */ + function __construct(ContainerInterface $container, ImageUtil $image, SluggerUtil $slugger, string $cache = '../var/cache/image', string $public = './bundles/rapsyspack/image') { + //Set cache + $this->cache = $cache; + + //Set container + $this->container = $container; + + //Set image + $this->image = $image; + + //Set public + $this->public = $public; + + //Set slugger + $this->slugger = $slugger; + } + + /** + * Return captcha image + * + * @param Request $request The Request instance + * @param string $hash The hash + * @param int $updated The updated timestamp + * @param string $equation The shorted equation + * @param int $width The width + * @param int $height The height + * @return Response The rendered image + */ + public function captcha(Request $request, string $hash, int $updated, string $equation, int $width, int $height): Response { + //Without matching hash + if ($hash !== $this->slugger->serialize([$updated, $equation, $width, $height])) { + //Throw new exception + throw new NotFoundHttpException(sprintf('Unable to match captcha hash: %s', $hash)); + } + + //Set hashed tree + $hashed = array_reverse(str_split(strval($updated))); + + //Set captcha + $captcha = $this->public.'/'.$hashed[0].'/'.$hashed[1].'/'.$hashed[2].'/'.$updated.'/'.$equation.'/'.$width.'x'.$height.'.jpeg'; + + //Unshort equation + $equation = $this->slugger->unshort($equation); + + //Without captcha up to date file + if (!is_file($captcha) || !($mtime = stat($captcha)['mtime']) || $mtime < $updated) { + //Without existing captcha path + if (!is_dir($dir = dirname($captcha))) { + //Create filesystem object + $filesystem = new Filesystem(); + + try { + //Create path + //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) + //XXX: on CoW filesystems execute a chattr +C before filling + $filesystem->mkdir($dir, 0775); + } catch (IOExceptionInterface $e) { + //Throw error + throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e); + } + } + + //Create image instance + $image = new \Imagick(); + + //Add imagick draw instance + //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916 + $draw = new \ImagickDraw(); + + //Set text antialias + $draw->setTextAntialias(true); + + //Set stroke antialias + $draw->setStrokeAntialias(true); + + //Set text alignment + $draw->setTextAlignment(\Imagick::ALIGN_CENTER); + + //Set gravity + $draw->setGravity(\Imagick::GRAVITY_CENTER); + + //Set fill color + $draw->setFillColor($this->image->captchaFill); + + //Set stroke color + $draw->setStrokeColor($this->image->captchaStroke); + + //Set font size + $draw->setFontSize($this->image->captchaFontSize/1.5); + + //Set stroke width + $draw->setStrokeWidth($this->image->captchaStrokeWidth / 2); + + //Set rotation + $draw->rotate($rotate = (rand(25, 75)*(rand(0,1)?-.1:.1))); + + //Get font metrics + $metrics2 = $image->queryFontMetrics($draw, strval('stop spam')); + + //Add annotation + $draw->annotation($width / 2 - ceil(rand(intval(-$metrics2['textWidth']), intval($metrics2['textWidth'])) / 2) - abs($rotate), ceil($metrics2['textHeight'] + $metrics2['descender'] + $metrics2['ascender']) / 2 - $this->image->captchaStrokeWidth - $rotate, strval('stop spam')); + + //Set rotation + $draw->rotate(-$rotate); + + //Set font size + $draw->setFontSize($this->image->captchaFontSize); + + //Set stroke width + $draw->setStrokeWidth($this->image->captchaStrokeWidth); + + //Set rotation + $draw->rotate($rotate = (rand(25, 50)*(rand(0,1)?-.1:.1))); + + //Get font metrics + $metrics = $image->queryFontMetrics($draw, strval($equation)); + + //Add annotation + $draw->annotation($width / 2, ceil($metrics['textHeight'] + $metrics['descender'] + $metrics['ascender']) / 2 - $this->image->captchaStrokeWidth, strval($equation)); + + //Set rotation + $draw->rotate(-$rotate); + + //Add new image + #$image->newImage(intval(ceil($metrics['textWidth'])), intval(ceil($metrics['textHeight'] + $metrics['descender'])), new \ImagickPixel($this->image->captchaBackground), 'jpeg'); + $image->newImage($width, $height, new \ImagickPixel($this->image->captchaBackground), 'jpeg'); + + //Draw on image + $image->drawImage($draw); + + //Strip image exif data and properties + $image->stripImage(); + + //Set compression quality + $image->setImageCompressionQuality(70); + + //Save captcha + if (!$image->writeImage($captcha)) { + //Throw error + throw new \Exception(sprintf('Unable to write image "%s"', $captcha)); + } + + //Set mtime + $mtime = stat($captcha)['mtime']; + } + + //Read captcha from cache + $response = new BinaryFileResponse($captcha); + + //Set file name + $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'captcha-stop-spam-'.str_replace([' ', '*', '+'], ['-', 'mul', 'add'], $equation).'-'.$width.'x'.$height.'.jpeg'); + + //Set etag + $response->setEtag(md5($hash)); + + //Set last modified + $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime))); + + //Set as public + $response->setPublic(); + + //Return 304 response if not modified + $response->isNotModified($request); + + //Return response + return $response; + } + + /** + * Return thumb image + * + * @param Request $request The Request instance + * @param string $hash The hash + * @param int $updated The updated timestamp + * @param string $path The image path + * @param int $width The width + * @param int $height The height + * @return Response The rendered image + */ + public function thumb(Request $request, string $hash, int $updated, string $path, int $width, int $height): Response { + //Without matching hash + if ($hash !== $this->slugger->serialize([$updated, $path, $width, $height])) { + //Throw new exception + throw new NotFoundHttpException(sprintf('Unable to match thumb hash: %s', $hash)); + } + + //Set hashed tree + $hashed = array_reverse(str_split(strval($updated))); + + //Set thumb + $thumb = $this->public.'/'.$hashed[0].'/'.$hashed[1].'/'.$hashed[2].'/'.$updated.'/'.$path.'/'.$width.'x'.$height.'.jpeg'; + + //Unshort path + $path = $this->slugger->unshort($path); + + //Without thumb up to date file + if (!is_file($thumb) || !($mtime = stat($thumb)['mtime']) || $mtime < $updated) { + //Without existing thumb path + if (!is_dir($dir = dirname($thumb))) { + //Create filesystem object + $filesystem = new Filesystem(); + + try { + //Create path + //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) + //XXX: on CoW filesystems execute a chattr +C before filling + $filesystem->mkdir($dir, 0775); + } catch (IOExceptionInterface $e) { + //Throw error + throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e); + } + } + + //Create image instance + $image = new \Imagick(); + + //Read image + $image->readImage(realpath($path)); + + //Crop using aspect ratio + //XXX: for better result upload image directly in aspect ratio :) + $image->cropThumbnailImage($width, $height); + + //Strip image exif data and properties + $image->stripImage(); + + //Set compression quality + //TODO: ajust that + $image->setImageCompressionQuality(70); + + //Save thumb + if (!$image->writeImage($thumb)) { + //Throw error + throw new \Exception(sprintf('Unable to write image "%s"', $thumb)); + } + + //Set mtime + $mtime = stat($thumb)['mtime']; + } + + //Read thumb from cache + $response = new BinaryFileResponse($thumb); + + //Set file name + $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'thumb-'.str_replace('/', '_', $path).'-'.$width.'x'.$height.'.jpeg'); + + //Set etag + $response->setEtag(md5($hash)); + + //Set last modified + $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime))); + + //Set as public + $response->setPublic(); + + //Return 304 response if not modified + $response->isNotModified($request); + + //Return response + return $response; + } +} diff --git a/Util/ImageUtil.php b/Util/ImageUtil.php new file mode 100644 index 0000000..d1b15cb --- /dev/null +++ b/Util/ImageUtil.php @@ -0,0 +1,239 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Rapsys\PackBundle\Util; + +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Routing\RouterInterface; + +/** + * Helps manage map + */ +class ImageUtil { + /** + * The captcha width + */ + const captchaWidth = 192; + + /** + * The captcha height + */ + const captchaHeight = 52; + + /** + * The captcha background color + */ + const captchaBackground = 'white'; + + /** + * The captcha fill color + */ + const captchaFill = '#cff'; + + /** + * The captcha font size + */ + const captchaFontSize = 45; + + /** + * The captcha stroke color + */ + const captchaStroke = '#00c3f9'; + + /** + * The captcha stroke width + */ + const captchaStrokeWidth = 2; + + /** + * The thumb width + */ + const thumbWidth = 640; + + /** + * The thumb height + */ + const thumbHeight = 640; + + /** + * The cache path + */ + protected string $cache; + + /** + * The public path + */ + protected string $public; + + /** + * The RouterInterface instance + */ + protected RouterInterface $router; + + /** + * The SluggerUtil instance + */ + protected SluggerUtil $slugger; + + /** + * Creates a new map util + * + * @param RouterInterface $router The RouterInterface instance + * @param SluggerUtil $slugger The SluggerUtil instance + */ + function __construct(RouterInterface $router, SluggerUtil $slugger, string $cache = '../var/cache/image', string $public = './bundles/rapsyspack/image', $captchaBackground = self::captchaBackground, $captchaFill = self::captchaFill, $captchaFontSize = self::captchaFontSize, $captchaStroke = self::captchaStroke, $captchaStrokeWidth = self::captchaStrokeWidth) { + //Set cache + $this->cache = $cache; + + //set captcha background + $this->captchaBackground = $captchaBackground; + + //set captcha fill + $this->captchaFill = $captchaFill; + + //set captcha font size + $this->captchaFontSize = $captchaFontSize; + + //set captcha stroke + $this->captchaStroke = $captchaStroke; + + //set captcha stroke width + $this->captchaStrokeWidth = $captchaStrokeWidth; + + //Set public + $this->public = $public; + + //Set router + $this->router = $router; + + //Set slugger + $this->slugger = $slugger; + } + + /** + * Get captcha data + * + * @param int $updated The updated timestamp + * @param int $width The width + * @param int $height The height + * @return array The captcha data + */ + public function getCaptcha(int $updated, int $width = self::captchaWidth, int $height = self::captchaHeight): array { + //Set a + $a = rand(0, 9); + + //Set b + $b = rand(0, 5); + + //Set c + $c = rand(0, 9); + + //Set equation + $equation = $a.' * '.$b.' + '.$c; + + //Short path + $short = $this->slugger->short($equation); + + //Set hash + $hash = $this->slugger->serialize([$updated, $short, $width, $height]); + + //Return array + return [ + 'token' => $this->slugger->hash(strval($a * $b + $c)), + 'value' => strval($a * $b + $c), + 'equation' => str_replace([' ', '*', '+'], ['-', 'mul', 'add'], $equation), + 'src' => $this->router->generate('rapsys_pack_captcha', ['hash' => $hash, 'updated' => $updated, 'equation' => $short, 'width' => $width, 'height' => $height]), + 'width' => $width, + 'height' => $height + ]; + } + + /** + * Get thumb data + * + * @param string $caption The caption + * @param int $updated The updated timestamp + * @param string $path The path + * @param int $width The width + * @param int $height The height + * @return array The thumb data + */ + public function getThumb(string $caption, int $updated, string $path, int $width = self::thumbWidth, int $height = self::thumbHeight): array { + //Get image width and height + list($imageWidth, $imageHeight) = getimagesize($path); + + //Short path + $short = $this->slugger->short($path); + + //Set link hash + $link = $this->slugger->serialize([$updated, $short, $imageWidth, $imageHeight]); + + //Set src hash + $src = $this->slugger->serialize([$updated, $short, $width, $height]); + + //Return array + return [ + 'caption' => $caption, + 'link' => $this->router->generate('rapsys_pack_thumb', ['hash' => $link, 'updated' => $updated, 'path' => $short, 'width' => $imageWidth, 'height' => $imageHeight]), + 'src' => $this->router->generate('rapsys_pack_thumb', ['hash' => $src, 'updated' => $updated, 'path' => $short, 'width' => $width, 'height' => $height]), + 'width' => $width, + 'height' => $height + ]; + } + + /** + * Remove image + * + * @param int $updated The updated timestamp + * @param string $path The path + * @return array The thumb clear success + */ + public function remove(int $updated, string $path): bool { + //Set hash tree + $hash = array_reverse(str_split(strval($updated))); + + //Set dir + $dir = $this->public.'/'.$hash[0].'/'.$hash[1].'/'.$hash[2].'/'.$updated.'/'.$this->slugger->short($path); + + //Set removes + $removes = []; + + //With dir + if (is_dir($dir)) { + //Add tree to remove + $removes[] = $dir; + + //Iterate on each file + foreach(array_merge($removes, array_diff(scandir($dir), ['..', '.'])) as $file) { + //With file + if (is_file($dir.'/'.$file)) { + //Add file to remove + $removes[] = $dir.'/'.$file; + } + } + } + + //Create filesystem object + $filesystem = new Filesystem(); + + try { + //Remove list + $filesystem->remove($removes); + } catch (IOExceptionInterface $e) { + //Throw error + throw new \Exception(sprintf('Unable to delete thumb directory "%s"', $dir), 0, $e); + } + + //Return success + return true; + } +} -- 2.41.1