]> Raphaël G. Git Repositories - packbundle/commitdiff
Add captcha option master 0.5.0
authorRaphaël Gertz <git@rapsys.eu>
Tue, 2 Apr 2024 03:42:10 +0000 (05:42 +0200)
committerRaphaël Gertz <git@rapsys.eu>
Tue, 2 Apr 2024 03:42:10 +0000 (05:42 +0200)
33 files changed:
Asset/PathPackage.php [deleted file]
Command.php [new file with mode: 0644]
Command/RangeCommand.php [new file with mode: 0644]
Context/NullContext.php [new file with mode: 0644]
Context/RequestStackContext.php [new file with mode: 0644]
Controller/ImageController.php [new file with mode: 0644]
Controller/MapController.php [new file with mode: 0644]
DependencyInjection/Configuration.php
DependencyInjection/RapsysPackExtension.php
Extension/PackExtension.php [new file with mode: 0644]
Filter/CPackFilter.php [moved from Twig/Filter/CPackFilter.php with 66% similarity]
Filter/FilterInterface.php [new file with mode: 0644]
Filter/JPackFilter.php [moved from Twig/Filter/JPackFilter.php with 65% similarity]
Form/CaptchaType.php [new file with mode: 0644]
Package/PathPackage.php [new file with mode: 0644]
Parser/TokenParser.php [moved from Twig/PackTokenParser.php with 61% similarity]
README.md
RapsysPackBundle.php
Resources/config/packages/rapsys_pack.yaml [deleted file]
Resources/config/packages/rapsyspack.yaml [new file with mode: 0644]
Resources/config/routes/rapsyspack.yaml [new file with mode: 0644]
Resources/public/facebook/.keep [moved from Resources/public/img/.keep with 100% similarity]
Resources/public/image/.keep [new file with mode: 0644]
Resources/public/map/.keep [new file with mode: 0644]
Subscriber/FacebookSubscriber.php [new file with mode: 0644]
Twig/Filter/FilterInterface.php [deleted file]
Twig/PackExtension.php [deleted file]
Util/FacebookUtil.php [new file with mode: 0644]
Util/ImageUtil.php [new file with mode: 0644]
Util/IntlUtil.php [new file with mode: 0644]
Util/MapUtil.php [new file with mode: 0644]
Util/SluggerUtil.php [new file with mode: 0644]
composer.json

diff --git a/Asset/PathPackage.php b/Asset/PathPackage.php
deleted file mode 100644 (file)
index 50c6172..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-namespace Rapsys\PackBundle\Asset;
-
-use Symfony\Component\Asset\Context\ContextInterface;
-use Symfony\Component\Asset\Package;
-use Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface;
-
-/**
- * (@inheritdoc)
- */
-class PathPackage extends Package {
-       //The base path
-       protected $basePath;
-
-       /**
-        * {@inheritdoc}
-        */
-       public function __construct(string $basePath, VersionStrategyInterface $versionStrategy, ContextInterface $context = null) {
-               parent::__construct($versionStrategy, $context);
-
-               if (!$basePath) {
-                       $this->basePath = '/';
-               } else {
-                       if ('/' != $basePath[0]) {
-                               $basePath = '/'.$basePath;
-                       }
-
-                       $this->basePath = rtrim($basePath, '/').'/';
-               }
-       }
-
-       /**
-        * @todo Try retrive public dir from the member function BundleNameBundle::getPublicDir() return value ?
-        * @xxx see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
-        * {@inheritdoc}
-        */
-       public function getUrl($path) {
-               //Match url starting with a bundle name
-               if (preg_match('%^@([A-Z][a-zA-Z]*?)(?:Bundle/Resources/public)?/(.*)$%', $path, $matches)) {
-                       //Handle empty or without replacement pattern basePath
-                       if (empty($this->basePath) || strpos($this->basePath, '%s') === false) {
-                               //Set path from hardcoded format
-                               $path = '/bundles/'.strtolower($matches[1]).'/'.$matches[2];
-                       //Proceed with basePath pattern replacement
-                       } else {
-                               //Set path from basePath pattern
-                               //XXX: basePath has a trailing / added by constructor
-                               $path = sprintf($this->basePath, strtolower($matches[1])).$matches[2];
-                       }
-               }
-
-               //Return parent getUrl result
-               return parent::getUrl($path);
-       }
-}
diff --git a/Command.php b/Command.php
new file mode 100644 (file)
index 0000000..0097d12
--- /dev/null
@@ -0,0 +1,74 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle;
+
+use Rapsys\PackBundle\RapsysPackBundle;
+
+use Symfony\Component\Console\Command\Command as BaseCommand;
+use Symfony\Component\DependencyInjection\Container;
+
+/**
+ * {@inheritdoc}
+ */
+class Command extends BaseCommand {
+       /**
+        * {@inheritdoc}
+        */
+       public function __construct(protected ?string $name = null) {
+               //Fix name
+               $this->name = $this->name ?? static::getName();
+
+               //Call parent constructor
+               parent::__construct($this->name);
+
+               //With description
+               if (!empty($this->description)) {
+                       //Set description
+                       $this->setDescription($this->description);
+               }
+
+               //With help
+               if (!empty($this->help)) {
+                       //Set help
+                       $this->setHelp($this->help);
+               }
+       }
+
+       /**
+        * {@inheritdoc}
+        *
+        * Return the command name
+        */
+       public function getName(): string {
+               //With namespace
+               if ($npos = strrpos(static::class, '\\')) {
+                       //Set name pos
+                       $npos++;
+               //Without namespace
+               } else {
+                       $npos = 0;
+               }
+
+               //With trailing command
+               if (substr(static::class, -strlen('Command'), strlen('Command')) === 'Command') {
+                       //Set bundle pos
+                       $bpos = strlen(static::class) - $npos - strlen('Command');
+               //Without bundle
+               } else {
+                       //Set bundle pos
+                       $bpos = strlen(static::class) - $npos;
+               }
+
+               //Return command alias
+               return RapsysPackBundle::getAlias().':'.strtolower(substr(static::class, $npos, $bpos));
+       }
+}
diff --git a/Command/RangeCommand.php b/Command/RangeCommand.php
new file mode 100644 (file)
index 0000000..7c3270d
--- /dev/null
@@ -0,0 +1,129 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Command;
+
+use Rapsys\PackBundle\Command;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * {@inheritdoc}
+ *
+ * Shuffle printable character range
+ */
+class RangeCommand extends Command {
+       /**
+        * Set description
+        *
+        * Shown with bin/console list
+        */
+       protected string $description = 'Outputs a shuffled printable characters range';
+
+       /**
+        * Set help
+        *
+        * Shown with bin/console --help rapsyspack:range
+        */
+       protected string $help = 'This command outputs a shuffled printable characters range';
+
+       /**
+        * {@inheritdoc}
+        */
+       public function __construct(protected string $file = '.env.local', protected ?string $name = null) {
+               //Call parent constructor
+               parent::__construct($this->name);
+
+               //Add argument
+               $this->addArgument('file', InputArgument::OPTIONAL, 'Environment file', $this->file);
+       }
+
+       /**
+        * {@inheritdoc}
+        *
+        * Output a shuffled printable characters range
+        */
+       protected function execute(InputInterface $input, OutputInterface $output): int {
+               //Printable character range
+               $ranges = range(' ', '~');
+
+               //Range shuffled
+               $shuffles = [];
+
+               //Shuffle range array
+               do {
+                       //Set start offset
+                       $offset = rand(0, ($count = count($ranges)) - 1);
+                       //Set length
+                       $length = rand(1, $count - $offset < ($ceil = (int)ceil(($count+count($shuffles))/rand(5,10))) ? $count - $offset : rand(2, $ceil));
+                       //Splice array
+                       $slices = array_splice($ranges, $offset, $length);
+                       //When reverse
+                       if (rand(0, 1)) {
+                               //Reverse sliced array
+                               $slices = array_reverse($slices);
+                       }
+                       //Append sliced array
+                       $shuffles = array_merge($shuffles, $slices);
+               } while (!empty($ranges));
+
+               //With writeable file
+               if (is_file($file = $input->getArgument('file')) && is_writeable($file)) {
+                       //Get file content
+                       if (($content = file_get_contents($file, false)) === false) {
+                               //Display error
+                               error_log(sprintf('Unable to get %s content', $file), 0);
+
+                               //Return failure
+                               return self::FAILURE;
+                       }
+
+                       //Set string
+                       $string = 'RAPSYSPACK_RANGE="'.strtr(implode($shuffles), ['\\' => '\\\\', '"' => '\\"', '$' => '\\$']).'"';
+
+                       //With match
+                       if (preg_match('/^RAPSYSPACK_RANGE=.*$/m', $content, $matches, PREG_OFFSET_CAPTURE)) {
+                               //Replace matches
+                               $content = preg_replace('/^(RAPSYSPACK_RANGE=.*)$/m', '#$1'."\n".strtr($string, ['\\' => '\\\\', '\\$' => '\\\\$']), $content);
+                       //Without match
+                       } else {
+                               $content .= "\n".$string;
+                       }
+
+                       //Write file content
+                       if (file_put_contents($file, $content) === false) {
+                               //Display error
+                               error_log(sprintf('Unable to put %s content', $file), 0);
+
+                               //Return failure
+                               return self::FAILURE;
+                       }
+
+                       //Print content
+                       echo $content;
+               //Without writeable file
+               } else {
+                       //Print instruction
+                       echo '# Set in '.$file."\n";
+
+                       //Print rapsys pack range variable
+                       echo 'RAPSYSPACK_RANGE=';
+
+                       //Print shuffled range
+                       var_export(implode($shuffles));
+               }
+
+               //Return success
+               return self::SUCCESS;
+       }
+}
diff --git a/Context/NullContext.php b/Context/NullContext.php
new file mode 100644 (file)
index 0000000..9f728cb
--- /dev/null
@@ -0,0 +1,28 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Context;
+
+use Symfony\Component\Asset\Context\NullContext as BaseNullContext;
+
+/**
+ * {@inheritdoc}
+ */
+class NullContext extends BaseNullContext {
+       /**
+        * Returns the base url
+        *
+        * @return string The base url
+        */
+       public function getBaseUrl(): string {
+               return '';
+       }
+}
diff --git a/Context/RequestStackContext.php b/Context/RequestStackContext.php
new file mode 100644 (file)
index 0000000..366e167
--- /dev/null
@@ -0,0 +1,44 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Context;
+
+use Symfony\Component\Asset\Context\RequestStackContext as BaseRequestStackContext;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * {@inheritdoc}
+ */
+class RequestStackContext extends BaseRequestStackContext {
+       /**
+        * {@inheritdoc}
+        */
+       public function __construct(protected RequestStack $requestStack, protected string $basePath = '', protected bool $secure = false) {
+               //Call parent constructor
+               parent::__construct($requestStack, $basePath, $secure);
+       }
+
+       /**
+        * Returns the base url
+        *
+        * @return string The base url
+        */
+       public function getBaseUrl(): string {
+               //Without request
+               if (!$request = $this->requestStack->getMainRequest()) {
+                       //Return base path
+                       return $this->basePath;
+               }
+
+               //Return base uri
+               return $request->getSchemeAndHttpHost().$request->getBaseUrl();
+       }
+}
diff --git a/Controller/ImageController.php b/Controller/ImageController.php
new file mode 100644 (file)
index 0000000..13865a7
--- /dev/null
@@ -0,0 +1,291 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Controller;
+
+use Rapsys\PackBundle\Util\ImageUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Psr\Container\ContainerInterface;
+
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\HeaderUtils;
+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;
+
+/**
+ * {@inheritdoc}
+ */
+class ImageController extends AbstractController implements ServiceSubscriberInterface {
+       /**
+        * 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 $path The public path
+        * @param string $prefix The prefix
+        */
+       function __construct(protected ContainerInterface $container, protected ImageUtil $image, protected SluggerUtil $slugger, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'image') {
+       }
+
+       /**
+        * 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->path.'/'.$this->prefix.'/'.$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->getFill());
+
+                       //Set stroke color
+                       $draw->setStrokeColor($this->image->getStroke());
+
+                       //Set font size
+                       $draw->setFontSize($this->image->getFontSize() / 1.5);
+
+                       //Set stroke width
+                       $draw->setStrokeWidth($this->image->getStrokeWidth() / 3);
+
+                       //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->getStrokeWidth() - $rotate, strval('stop spam'));
+
+                       //Set rotation
+                       $draw->rotate(-$rotate);
+
+                       //Set font size
+                       $draw->setFontSize($this->image->getFontSize());
+
+                       //Set stroke width
+                       $draw->setStrokeWidth($this->image->getStrokeWidth());
+
+                       //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->getStrokeWidth(), 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->getBackground()), 'jpeg');
+                       $image->newImage($width, $height, new \ImagickPixel($this->image->getBackground()), '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->path.'/'.$this->prefix.'/'.$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/Controller/MapController.php b/Controller/MapController.php
new file mode 100644 (file)
index 0000000..8d38aa6
--- /dev/null
@@ -0,0 +1,507 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Controller;
+
+use Rapsys\PackBundle\Util\MapUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Psr\Container\ContainerInterface;
+
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\HeaderUtils;
+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;
+
+/**
+ * {@inheritdoc}
+ */
+class MapController extends AbstractController implements ServiceSubscriberInterface {
+       /**
+        * The stream context instance
+        */
+       protected mixed $ctx;
+
+       /**
+        * Creates a new osm controller
+        *
+        * @param ContainerInterface $container The ContainerInterface instance
+        * @param MapUtil $map The MapUtil instance
+        * @param SluggerUtil $slugger The SluggerUtil instance
+        * @param string $cache The cache path
+        * @param string $path The public path
+        * @param string $prefix The prefix
+        * @param string $url The tile server url
+        */
+       function __construct(protected ContainerInterface $container, protected MapUtil $map, protected SluggerUtil $slugger, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'map', protected string $url = MapUtil::osm) {
+               //Set ctx
+               $this->ctx = stream_context_create(
+                       [
+                               'http' => [
+                                       #'header' => ['Referer: https://www.openstreetmap.org/'],
+                                       'max_redirects' => $_ENV['RAPSYSPACK_REDIRECT'] ?? 20,
+                                       'timeout' => $_ENV['RAPSYSPACK_TIMEOUT'] ?? (($timeout = ini_get('default_socket_timeout')) !== false && $timeout !== "" ? (float)$timeout : 60),
+                                       'user_agent' => $_ENV['RAPSYSPACK_AGENT'] ?? (($agent = ini_get('user_agent')) !== false && $agent !== "" ? (string)$agent : RapsysPackBundle::getAlias().'/'.RapsysPackBundle::getVersion())
+                               ]
+                       ]
+               );
+       }
+
+       /**
+        * Return map image
+        *
+        * @param Request $request The Request instance
+        * @param string $hash The hash
+        * @param int $updated The updated timestamp
+        * @param float $latitude The latitude
+        * @param float $longitude The longitude
+        * @param int $zoom The zoom
+        * @param int $width The width
+        * @param int $height The height
+        * @return Response The rendered image
+        */
+       public function map(Request $request, string $hash, int $updated, float $latitude, float $longitude, int $zoom, int $width, int $height): Response {
+               //Without matching hash
+               if ($hash !== $this->slugger->hash([$updated, $latitude, $longitude, $zoom, $width, $height])) {
+                       //Throw new exception
+                       throw new NotFoundHttpException(sprintf('Unable to match map hash: %s', $hash));
+               }
+
+               //Set map
+               $map = $this->path.'/'.$this->prefix.'/'.$zoom.'/'.$latitude.'/'.$longitude.'/'.$width.'x'.$height.'.jpeg';
+
+               //Without multi up to date file
+               if (!is_file($map) || !($mtime = stat($map)['mtime']) || $mtime < $updated) {
+                       //Without existing map path
+                       if (!is_dir($dir = dirname($map))) {
+                               //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 new image
+                       $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
+
+                       //Create tile instance
+                       $tile = new \Imagick();
+
+                       //Get tile xy
+                       $centerX = $this->map->longitudeToX($longitude, $zoom);
+                       $centerY = $this->map->latitudeToY($latitude, $zoom);
+
+                       //Calculate start xy
+                       $startX = floor(floor($centerX) - $width / MapUtil::tz);
+                       $startY = floor(floor($centerY) - $height / MapUtil::tz);
+
+                       //Calculate end xy
+                       $endX = ceil(ceil($centerX) + $width / MapUtil::tz);
+                       $endY = ceil(ceil($centerY) + $height / MapUtil::tz);
+
+                       for($x = $startX; $x <= $endX; $x++) {
+                               for($y = $startY; $y <= $endY; $y++) {
+                                       //Set cache path
+                                       $cache = $this->cache.'/'.$this->prefix.'/'.$zoom.'/'.$x.'/'.$y.'.png';
+
+                                       //Without cache image
+                                       if (!is_file($cache)) {
+                                               //Set tile url
+                                               $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->url);
+
+                                               //Without cache path
+                                               if (!is_dir($dir = dirname($cache))) {
+                                                       //Create filesystem object
+                                                       $filesystem = new Filesystem();
+
+                                                       try {
+                                                               //Create path
+                                                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                                                               $filesystem->mkdir($dir, 0775);
+                                                       } catch (IOExceptionInterface $e) {
+                                                               //Throw error
+                                                               throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                                                       }
+                                               }
+
+                                               //Store tile in cache
+                                               file_put_contents($cache, file_get_contents($tileUri, false, $this->ctx));
+                                       }
+
+                                       //Set dest x
+                                       $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $x)));
+
+                                       //Set dest y
+                                       $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $y)));
+
+                                       //Read tile from cache
+                                       $tile->readImage($cache);
+
+                                       //Compose image
+                                       $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
+
+                                       //Clear tile
+                                       $tile->clear();
+                               }
+                       }
+
+                       //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('#cff');
+
+                       //Set stroke color
+                       $draw->setStrokeColor('#00c3f9');
+
+                       //Set stroke width
+                       $draw->setStrokeWidth(2);
+
+                       //Draw circle
+                       $draw->circle($width/2 - 5, $height/2 - 5, $width/2 + 5, $height/2 + 5);
+
+                       //Draw on image
+                       $image->drawImage($draw);
+
+                       //Strip image exif data and properties
+                       $image->stripImage();
+
+                       //Add latitude
+                       //XXX: not supported by imagick :'(
+                       $image->setImageProperty('exif:GPSLatitude', $this->map->latitudeToSexagesimal($latitude));
+
+                       //Add longitude
+                       //XXX: not supported by imagick :'(
+                       $image->setImageProperty('exif:GPSLongitude', $this->map->longitudeToSexagesimal($longitude));
+
+                       //Add description
+                       //XXX: not supported by imagick :'(
+                       #$image->setImageProperty('exif:Description', $caption);
+
+                       //Set progressive jpeg
+                       $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
+
+                       //Set compression quality
+                       //TODO: ajust that
+                       $image->setImageCompressionQuality(70);
+
+                       //Save image
+                       if (!$image->writeImage($map)) {
+                               //Throw error
+                               throw new \Exception(sprintf('Unable to write image "%s"', $path));
+                       }
+
+                       //Set mtime
+                       $mtime = stat($map)['mtime'];
+               }
+
+               //Read map from cache
+               $response = new BinaryFileResponse($map);
+
+               //Set file name
+               $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'map-'.$latitude.','.$longitude.'-'.$zoom.'-'.$width.'x'.$height.'.jpeg');
+
+               //Set etag
+               $response->setEtag(md5(serialize([$updated, $latitude, $longitude, $zoom, $width, $height])));
+
+               //Set last modified
+               $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
+
+               //Disable robot index
+               $response->headers->set('X-Robots-Tag', 'noindex');
+
+               //Set as public
+               $response->setPublic();
+
+               //Return 304 response if not modified
+               $response->isNotModified($request);
+
+               //Return response
+               return $response;
+       }
+
+       /**
+        * Return multi map image
+        *
+        * @param Request $request The Request instance
+        * @param string $hash The hash
+        * @param int $updated The updated timestamp
+        * @param float $latitude The latitude
+        * @param float $longitude The longitude
+        * @param string $coordinates The coordinates
+        * @param int $zoom The zoom
+        * @param int $width The width
+        * @param int $height The height
+        * @return Response The rendered image
+        */
+       public function multiMap(Request $request, string $hash, int $updated, float $latitude, float $longitude, string $coordinates, int $zoom, int $width, int $height): Response {
+               //Without matching hash
+               if ($hash !== $this->slugger->hash([$updated, $latitude, $longitude, $coordinate = $this->slugger->hash($coordinates), $zoom, $width, $height])) {
+                       //Throw new exception
+                       throw new NotFoundHttpException(sprintf('Unable to match multi map hash: %s', $hash));
+               }
+
+               //Set multi
+               $map = $this->path.'/'.$this->prefix.'/'.$zoom.'/'.$latitude.'/'.$longitude.'/'.$coordinate.'/'.$width.'x'.$height.'.jpeg';
+
+               //Without multi up to date file
+               if (!is_file($map) || !($mtime = stat($map)['mtime']) || $mtime < $updated) {
+                       //Without existing multi path
+                       if (!is_dir($dir = dirname($map))) {
+                               //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 new image
+                       $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
+
+                       //Create tile instance
+                       $tile = new \Imagick();
+
+                       //Get tile xy
+                       $centerX = $this->map->longitudeToX($longitude, $zoom);
+                       $centerY = $this->map->latitudeToY($latitude, $zoom);
+
+                       //Calculate start xy
+                       $startX = floor(floor($centerX) - $width / MapUtil::tz);
+                       $startY = floor(floor($centerY) - $height / MapUtil::tz);
+
+                       //Calculate end xy
+                       $endX = ceil(ceil($centerX) + $width / MapUtil::tz);
+                       $endY = ceil(ceil($centerY) + $height / MapUtil::tz);
+
+                       for($x = $startX; $x <= $endX; $x++) {
+                               for($y = $startY; $y <= $endY; $y++) {
+                                       //Set cache path
+                                       $cache = $this->cache.'/'.$this->prefix.'/'.$zoom.'/'.$x.'/'.$y.'.png';
+
+                                       //Without cache image
+                                       if (!is_file($cache)) {
+                                               //Set tile url
+                                               $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->url);
+
+                                               //Without cache path
+                                               if (!is_dir($dir = dirname($cache))) {
+                                                       //Create filesystem object
+                                                       $filesystem = new Filesystem();
+
+                                                       try {
+                                                               //Create path
+                                                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                                                               $filesystem->mkdir($dir, 0775);
+                                                       } catch (IOExceptionInterface $e) {
+                                                               //Throw error
+                                                               throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                                                       }
+                                               }
+
+                                               //Store tile in cache
+                                               file_put_contents($cache, file_get_contents($tileUri, false, $this->ctx));
+                                       }
+
+                                       //Set dest x
+                                       $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $x)));
+
+                                       //Set dest y
+                                       $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $y)));
+
+                                       //Read tile from cache
+                                       $tile->readImage($cache);
+
+                                       //Compose image
+                                       $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
+
+                                       //Clear tile
+                                       $tile->clear();
+                               }
+                       }
+
+                       //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);
+
+                       //Convert to array
+                       $coordinates = array_reverse(array_map(function ($v) { $p = strpos($v, ','); return ['latitude' => floatval(substr($v, 0, $p)), 'longitude' => floatval(substr($v, $p + 1))]; }, explode('-', $coordinates)), true);
+
+                       //Iterate on locations
+                       foreach($coordinates as $id => $coordinate) {
+                               //Set dest x
+                               $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $this->map->longitudeToX(floatval($coordinate['longitude']), $zoom))));
+
+                               //Set dest y
+                               $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $this->map->latitudeToY(floatval($coordinate['latitude']), $zoom))));
+
+                               //Set fill color
+                               $draw->setFillColor($this->map->getFill());
+
+                               //Set font size
+                               $draw->setFontSize($this->map->getFontSize());
+
+                               //Set stroke color
+                               $draw->setStrokeColor($this->map->getStroke());
+
+                               //Set circle radius
+                               $radius = $this->map->getRadius();
+
+                               //Set stroke width
+                               $stroke = $this->map->getStrokeWidth();
+
+                               //With matching position
+                               if ($coordinate['latitude'] === $latitude && $coordinate['longitude'] == $longitude) {
+                                       //Set fill color
+                                       $draw->setFillColor($this->map->getHighFill());
+
+                                       //Set font size
+                                       $draw->setFontSize($this->map->getHighFontSize());
+
+                                       //Set stroke color
+                                       $draw->setStrokeColor($this->map->getHighStroke());
+
+                                       //Set circle radius
+                                       $radius = $this->map->getHighRadius();
+
+                                       //Set stroke width
+                                       $stroke = $this->map->getHighStrokeWidth();
+                               }
+
+                               //Set stroke width
+                               $draw->setStrokeWidth($stroke);
+
+                               //Draw circle
+                               $draw->circle($destX - $radius, $destY - $radius, $destX + $radius, $destY + $radius);
+
+                               //Set fill color
+                               $draw->setFillColor($draw->getStrokeColor());
+
+                               //Set stroke width
+                               $draw->setStrokeWidth($stroke / 4);
+
+                               //Get font metrics
+                               #$metrics = $image->queryFontMetrics($draw, strval($id));
+
+                               //Add annotation
+                               $draw->annotation($destX - $radius, $destY + $stroke, strval($id));
+                       }
+
+                       //Draw on image
+                       $image->drawImage($draw);
+
+                       //Strip image exif data and properties
+                       $image->stripImage();
+
+                       //Add latitude
+                       //XXX: not supported by imagick :'(
+                       $image->setImageProperty('exif:GPSLatitude', $this->map->latitudeToSexagesimal($latitude));
+
+                       //Add longitude
+                       //XXX: not supported by imagick :'(
+                       $image->setImageProperty('exif:GPSLongitude', $this->map->longitudeToSexagesimal($longitude));
+
+                       //Add description
+                       //XXX: not supported by imagick :'(
+                       #$image->setImageProperty('exif:Description', $caption);
+
+                       //Set progressive jpeg
+                       $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
+
+                       //Set compression quality
+                       //TODO: ajust that
+                       $image->setImageCompressionQuality(70);
+
+                       //Save image
+                       if (!$image->writeImage($map)) {
+                               //Throw error
+                               throw new \Exception(sprintf('Unable to write image "%s"', $path));
+                       }
+
+                       //Set mtime
+                       $mtime = stat($map)['mtime'];
+               }
+
+               //Read map from cache
+               $response = new BinaryFileResponse($map);
+
+               //Set file name
+               $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'multimap-'.$latitude.','.$longitude.'-'.$zoom.'-'.$width.'x'.$height.'.jpeg');
+
+               //Set etag
+               $response->setEtag(md5(serialize([$updated, $latitude, $longitude, $zoom, $width, $height])));
+
+               //Set last modified
+               $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
+
+               //Disable robot index
+               $response->headers->set('X-Robots-Tag', 'noindex');
+
+               //Set as public
+               $response->setPublic();
+
+               //Return 304 response if not modified
+               $response->isNotModified($request);
+
+               //Return response
+               return $response;
+       }
+}
index 22ae9d9d0126c8843be70de5200ea9b2aeebf1c5..60b68b6ecee1cb99749c3d7559db583bf49966e3 100644 (file)
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
 
 namespace Rapsys\PackBundle\DependencyInjection;
 
+use Rapsys\PackBundle\RapsysPackBundle;
+
 use Symfony\Component\Config\Definition\Builder\TreeBuilder;
 use Symfony\Component\Config\Definition\ConfigurationInterface;
+use Symfony\Component\DependencyInjection\Container;
 use Symfony\Component\Process\ExecutableFinder;
 
 /**
+ * {@inheritdoc}
+ *
  * This is the class that validates and merges configuration from your app/config files.
  *
- * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/configuration.html}
+ * @link http://symfony.com/doc/current/cookbook/bundles/configuration.html
  */
 class Configuration implements ConfigurationInterface {
        /**
         * {@inheritdoc}
         */
-       public function getConfigTreeBuilder() {
+       public function getConfigTreeBuilder(): TreeBuilder {
                //Get TreeBuilder object
-               $treeBuilder = new TreeBuilder('rapsys_pack');
+               $treeBuilder = new TreeBuilder($alias = RapsysPackBundle::getAlias());
 
                //Get ExecutableFinder object
                $finder = new ExecutableFinder();
 
-               /**
-                * XXX: Note about the output schemes
-                *
-                * The output files are written based on the output.<ext> scheme with the * replaced by the hashed path of packed files
-                *
-                * The following service configuration make twig render the output file path with the right '/' basePath prefix:
-                * services:
-                *     assets.pack_package:
-                *         class: Rapsys\PackBundle\Asset\PathPackage
-                *         arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
-                *     rapsys_pack.twig.pack_extension:
-                *         class: Rapsys\PackBundle\Twig\PackExtension
-                *         arguments: [ '@file_locator', '@service_container', '@assets.pack_package' ]
-                *         tags: [ twig.extension ]
-                */
-
                //The bundle default values
                $defaults = [
-                       'config' => [
-                               'name' => 'asset_url',
-                               'scheme' => 'https://',
-                               'timeout' => (int)ini_get('default_socket_timeout'),
-                               'agent' => (string)ini_get('user_agent')?:'rapsys_pack/0.1.3',
-                               'redirect' => 5
-                       ],
-                       'output' => [
-                               'css' => '@RapsysPack/css/*.pack.css',
-                               'js' =>  '@RapsysPack/js/*.pack.js',
-                               'img' => '@RapsysPack/img/*.pack.jpg'
-                       ],
                        'filters' => [
                                'css' => [
-                                       'class' => 'Rapsys\PackBundle\Twig\Filter\CPackFilter',
-                                       'args' => [
-                                               $finder->find('cpack', '/usr/local/bin/cpack'),
-                                               'minify'
-                                       ]
-                               ],
-                               'js' => [
-                                       'class' => 'Rapsys\PackBundle\Twig\Filter\JPackFilter',
-                                       'args' => [
-                                               $finder->find('jpack', '/usr/local/bin/jpack'),
-                                               'best'
+                                       0 => [
+                                               'class' => 'Rapsys\PackBundle\Filter\CPackFilter',
+                                               'args' => [
+                                                       $finder->find('cpack', '/usr/local/bin/cpack'),
+                                                       'minify'
+                                               ]
                                        ]
                                ],
                                'img' => [
-                                       'class' => 'Rapsys\PackBundle\Twig\Filter\IPackFilter',
-                                       'args' => []
+                                       0 => [
+                                               'class' => 'Rapsys\PackBundle\Filter\IPackFilter',
+                                               'args' => []
+                                       ]
                                ],
-                       ]
+                               'js' => [
+                                       0 => [
+                                               'class' => 'Rapsys\PackBundle\Filter\JPackFilter',
+                                               'args' => [
+                                                       $finder->find('jpack', '/usr/local/bin/jpack'),
+                                                       'best'
+                                               ]
+                                       ]
+                               ]
+                       ],
+                       #TODO: migrate to public.path, public.url and router->generateUrl ?
+                       #XXX: that would means dropping the PathPackage stuff and use static route like rapsyspack_facebook
+                       'output' => [
+                               'css' => '@RapsysPack/css/*.pack.css',
+                               'img' => '@RapsysPack/img/*.pack.jpg',
+                               'js' =>  '@RapsysPack/js/*.pack.js'
+                       ],
+                       'path' => dirname(__DIR__).'/Resources/public',
+                       'token' => 'asset_url'
                ];
 
-               //Here we define the parameters that are allowed to configure the bundle.
-               //XXX: see https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php for default value and description
-               //XXX: see http://symfony.com/doc/current/components/config/definition.html
-               //XXX: see https://github.com/symfony/assetic-bundle/blob/master/DependencyInjection/Configuration.php#L63
-               //XXX: see php bin/console config:dump-reference rapsys_pack to dump default config
-               //XXX: see php bin/console debug:config rapsys_pack to dump config
+               /**
+                * Defines parameters allowed to configure the bundle
+                *
+                * @link https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
+                * @link http://symfony.com/doc/current/components/config/definition.html
+                * @link https://github.com/symfony/assetic-bundle/blob/master/DependencyInjection/Configuration.php#L63
+                *
+                * @see bin/console config:dump-reference rapsyspack to dump default config
+                * @see bin/console debug:config rapsyspack to dump config
+                */
                $treeBuilder
                        //Parameters
                        ->getRootNode()
                                ->addDefaultsIfNotSet()
                                ->children()
-                                       ->arrayNode('config')
-                                               ->addDefaultsIfNotSet()
-                                               ->children()
-                                                       ->scalarNode('name')->cannotBeEmpty()->defaultValue($defaults['config']['name'])->end()
-                                                       ->scalarNode('scheme')->cannotBeEmpty()->defaultValue($defaults['config']['scheme'])->end()
-                                                       ->integerNode('timeout')->min(0)->max(300)->defaultValue($defaults['config']['timeout'])->end()
-                                                       ->scalarNode('agent')->cannotBeEmpty()->defaultValue($defaults['config']['agent'])->end()
-                                                       ->integerNode('redirect')->min(1)->max(30)->defaultValue($defaults['config']['redirect'])->end()
-                                               ->end()
-                                       ->end()
-                                       ->arrayNode('output')
-                                               ->addDefaultsIfNotSet()
-                                               ->children()
-                                                       ->scalarNode('css')->cannotBeEmpty()->defaultValue($defaults['output']['css'])->end()
-                                                       ->scalarNode('js')->cannotBeEmpty()->defaultValue($defaults['output']['js'])->end()
-                                                       ->scalarNode('img')->cannotBeEmpty()->defaultValue($defaults['output']['img'])->end()
-                                               ->end()
-                                       ->end()
                                        ->arrayNode('filters')
                                                ->addDefaultsIfNotSet()
                                                ->children()
                                                        ->arrayNode('css')
-                                                               #XXX: undocumented, see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+                                                               /**
+                                                                * Undocumented
+                                                                *
+                                                                * @see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+                                                                */
                                                                ->addDefaultChildrenIfNoneSet()
                                                                ->arrayPrototype()
                                                                        ->children()
                                                                                ->scalarNode('class')
                                                                                        ->isRequired()
                                                                                        ->cannotBeEmpty()
-                                                                                       ->defaultValue($defaults['filters']['css']['class'])
+                                                                                       ->defaultValue($defaults['filters']['css'][0]['class'])
                                                                                ->end()
                                                                                ->arrayNode('args')
-                                                                                       /*->isRequired()*/
-                                                                                       ->treatNullLike(array())
-                                                                                       ->defaultValue($defaults['filters']['css']['args'])
+                                                                                       //->isRequired()
+                                                                                       ->treatNullLike([])
+                                                                                       ->defaultValue($defaults['filters']['css'][0]['args'])
                                                                                        ->scalarPrototype()->end()
                                                                                ->end()
                                                                        ->end()
                                                                ->end()
                                                        ->end()
-                                                       ->arrayNode('js')
-                                                               #XXX: undocumented, see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+                                                       ->arrayNode('img')
+                                                               /**
+                                                                * Undocumented
+                                                                *
+                                                                * @see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+                                                                */
                                                                ->addDefaultChildrenIfNoneSet()
                                                                ->arrayPrototype()
                                                                        ->children()
                                                                                ->scalarNode('class')
                                                                                        ->isRequired()
                                                                                        ->cannotBeEmpty()
-                                                                                       ->defaultValue($defaults['filters']['js']['class'])
+                                                                                       ->defaultValue($defaults['filters']['img'][0]['class'])
                                                                                ->end()
                                                                                ->arrayNode('args')
-                                                                                       ->treatNullLike(array())
-                                                                                       ->defaultValue($defaults['filters']['js']['args'])
+                                                                                       ->treatNullLike([])
+                                                                                       ->defaultValue($defaults['filters']['img'][0]['args'])
                                                                                        ->scalarPrototype()->end()
                                                                                ->end()
                                                                        ->end()
                                                                ->end()
                                                        ->end()
-                                                       ->arrayNode('img')
-                                                               #XXX: undocumented, see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+                                                       ->arrayNode('js')
+                                                               /**
+                                                                * Undocumented
+                                                                *
+                                                                * @see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+                                                                */
                                                                ->addDefaultChildrenIfNoneSet()
                                                                ->arrayPrototype()
                                                                        ->children()
                                                                                ->scalarNode('class')
                                                                                        ->isRequired()
                                                                                        ->cannotBeEmpty()
-                                                                                       ->defaultValue($defaults['filters']['img']['class'])
+                                                                                       ->defaultValue($defaults['filters']['js'][0]['class'])
                                                                                ->end()
                                                                                ->arrayNode('args')
-                                                                                       ->treatNullLike(array())
-                                                                                       ->defaultValue($defaults['filters']['img']['args'])
+                                                                                       ->treatNullLike([])
+                                                                                       ->defaultValue($defaults['filters']['js'][0]['args'])
                                                                                        ->scalarPrototype()->end()
                                                                                ->end()
                                                                        ->end()
@@ -163,6 +162,16 @@ class Configuration implements ConfigurationInterface {
                                                        ->end()
                                                ->end()
                                        ->end()
+                                       ->arrayNode('output')
+                                               ->addDefaultsIfNotSet()
+                                               ->children()
+                                                       ->scalarNode('css')->cannotBeEmpty()->defaultValue($defaults['output']['css'])->end()
+                                                       ->scalarNode('img')->cannotBeEmpty()->defaultValue($defaults['output']['img'])->end()
+                                                       ->scalarNode('js')->cannotBeEmpty()->defaultValue($defaults['output']['js'])->end()
+                                               ->end()
+                                       ->end()
+                                       ->scalarNode('path')->cannotBeEmpty()->defaultValue($defaults['path'])->end()
+                                       ->scalarNode('token')->cannotBeEmpty()->defaultValue($defaults['token'])->end()
                                ->end()
                        ->end();
 
index 4605e96f9ee071672366eede1a43a4b93c480086..fc9efcebcb19db954d30711c3c7682d1248af9e4 100644 (file)
@@ -1,7 +1,18 @@
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
 
 namespace Rapsys\PackBundle\DependencyInjection;
 
+use Rapsys\PackBundle\RapsysPackBundle;
+
 use Symfony\Component\DependencyInjection\ContainerBuilder;
 use Symfony\Component\DependencyInjection\Extension\Extension;
 
@@ -9,32 +20,48 @@ use Symfony\Component\DependencyInjection\Extension\Extension;
  * This is the class that loads and manages your bundle configuration.
  *
  * @link http://symfony.com/doc/current/cookbook/bundles/extension.html
+ *
+ * {@inheritdoc}
  */
 class RapsysPackExtension extends Extension {
        /**
         * {@inheritdoc}
         */
-       public function load(array $configs, ContainerBuilder $container) {
+       public function load(array $configs, ContainerBuilder $container): void {
                //Load configuration
                $configuration = $this->getConfiguration($configs, $container);
 
                //Process the configuration to get merged config
                $config = $this->processConfiguration($configuration, $configs);
 
+               //Set bundle alias
+               $alias = RapsysPackBundle::getAlias();
+
                //Detect when no user configuration is provided
                if ($configs === [[]]) {
                        //Prepend default config
-                       $container->prependExtensionConfig($this->getAlias(), $config);
+                       $container->prependExtensionConfig($alias, $config);
                }
 
                //Save configuration in parameters
-               $container->setParameter($this->getAlias(), $config);
+               $container->setParameter($alias, $config);
+
+               //Set rapsyspack.alias key
+               $container->setParameter($alias.'.alias', $alias);
+
+               //Set rapsyspack.path key
+               $container->setParameter($alias.'.path', $config['path']);
+
+               //Set rapsyspack.version key
+               $container->setParameter($alias.'.version', RapsysPackBundle::getVersion());
        }
 
        /**
         * {@inheritdoc}
+        *
+        * @xxx Required by kernel to load renamed alias configuration
         */
-       public function getAlias() {
-               return 'rapsys_pack';
+       public function getAlias(): string {
+               return RapsysPackBundle::getAlias();
        }
 }
diff --git a/Extension/PackExtension.php b/Extension/PackExtension.php
new file mode 100644 (file)
index 0000000..7107548
--- /dev/null
@@ -0,0 +1,70 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Extension;
+
+use Rapsys\PackBundle\Parser\TokenParser;
+use Rapsys\PackBundle\RapsysPackBundle;
+use Rapsys\PackBundle\Util\IntlUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Symfony\Component\Asset\PackageInterface;
+use Symfony\Component\HttpKernel\Config\FileLocator;
+
+use Twig\Extension\AbstractExtension;
+
+/**
+ * {@inheritdoc}
+ */
+class PackExtension extends AbstractExtension {
+       /**
+        * {@inheritdoc}
+        *
+        * @link https://twig.symfony.com/doc/2.x/advanced.html
+        */
+       public function __construct(protected IntlUtil $intl, protected FileLocator $locator, protected PackageInterface $package, protected SluggerUtil $slugger, protected array $parameters) {
+       }
+
+       /**
+        * Returns a filter array to add to the existing list.
+        *
+        * @return \Twig\TwigFilter[]
+        */
+       public function getTokenParsers(): array {
+               return [
+                       new TokenParser($this->locator, $this->package, $this->parameters['token'], 'stylesheet', $this->parameters['output']['css'], $this->parameters['filters']['css']),
+                       new TokenParser($this->locator, $this->package, $this->parameters['token'], 'javascript', $this->parameters['output']['js'], $this->parameters['filters']['js']),
+                       new TokenParser($this->locator, $this->package, $this->parameters['token'], 'image', $this->parameters['output']['img'], $this->parameters['filters']['img'])
+               ];
+       }
+
+       /**
+        * Returns a filter array to add to the existing list.
+        *
+        * @return \Twig\TwigFilter[]
+        */
+       public function getFilters(): array {
+               return [
+                       new \Twig\TwigFilter('lcfirst', 'lcfirst'),
+                       new \Twig\TwigFilter('ucfirst', 'ucfirst'),
+                       new \Twig\TwigFilter('hash', [$this->slugger, 'hash']),
+                       new \Twig\TwigFilter('unshort', [$this->slugger, 'unshort']),
+                       new \Twig\TwigFilter('short', [$this->slugger, 'short']),
+                       new \Twig\TwigFilter('slug', [$this->slugger, 'slug']),
+                       new \Twig\TwigFilter('intldate', [$this->intl, 'date'], ['needs_environment' => true]),
+                       new \Twig\TwigFilter('intlnumber', [$this->intl, 'number']),
+                       new \Twig\TwigFilter('intlcurrency', [$this->intl, 'currency']),
+                       new \Twig\TwigFilter('download', 'file_get_contents', [false, null]),
+                       new \Twig\TwigFilter('base64_encode', 'base64_encode'),
+                       new \Twig\TwigFilter('base64_decode', 'base64_decode')
+               ];
+       }
+}
similarity index 66%
rename from Twig/Filter/CPackFilter.php
rename to Filter/CPackFilter.php
index ca38c3be37033ade64bffe8fb8b0b29e69cf5ddf..f56bb959710ca851672c1529f211c1bdceae01fb 100644 (file)
@@ -1,42 +1,34 @@
-<?php
+<?php declare(strict_types=1);
 
-namespace Rapsys\PackBundle\Twig\Filter;
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Filter;
 
-use Rapsys\PackBundle\Twig\Filter\FilterInterface;
 use Twig\Error\Error;
+use Twig\Source;
 
+/**
+ * {@inheritdoc}
+ */
 class CPackFilter implements FilterInterface {
-       //Default bin
-       private $bin;
-
-       //Default compress type
-       private $compress;
-
-       //Twig template filename
-       private $fileName;
-
-       //Twig template line
-       private $line;
-
-       //Configure the object
-       //XXX: compress can be minify or pretty
-       public function __construct($fileName, $line, $bin = 'cpack', $compress = 'minify') {
-               //Set fileName
-               $this->fileName = $fileName;
-
-               //Set line
-               $this->line = $line;
-
-               //Set bin
-               $this->bin = $bin;
-
-               //Set compress
-               $this->compress = $compress;
-
+       /**
+        * Setup cpack filter
+        *
+        * @xxx compress can be minify or pretty
+        */
+       public function __construct(protected Source $fileName, protected int $line, protected string $bin = 'cpack', protected string $compress = 'minify') {
                //Deal with compress
                if (!empty($this->compress)) {
                        //Append minify parameter
                        if ($this->compress == 'minify') {
+                               //TODO: protect binary call by converting bin to array ?
                                $this->bin .= ' --minify';
                        //Unknown compress type
                        //XXX: default compression is pretty
@@ -47,7 +39,10 @@ class CPackFilter implements FilterInterface {
                }
        }
 
-       public function process($content) {
+       /**
+        * {@inheritdoc}
+        */
+       public function process(string $content): string {
                //Create descriptors
                $descriptorSpec = array(
                        0 => array('pipe', 'r'),
@@ -58,7 +53,7 @@ class CPackFilter implements FilterInterface {
                //Open process
                if (is_resource($proc = proc_open($this->bin, $descriptorSpec, $pipes))) {
                        //Set stderr as non blocking
-                       stream_set_blocking($pipes[2], 0);
+                       stream_set_blocking($pipes[2], false);
 
                        //Send content to stdin
                        fwrite($pipes[0], $content);
diff --git a/Filter/FilterInterface.php b/Filter/FilterInterface.php
new file mode 100644 (file)
index 0000000..f3b2987
--- /dev/null
@@ -0,0 +1,27 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Filter;
+
+/**
+ * Filter interface definition
+ *
+ * @todo do we need something else ? (like a constructor that read parameters or else)
+ */
+interface FilterInterface {
+       /**
+        * Process function
+        *
+        * @param string $content The content to process
+        * @return string The processed content
+        */
+       public function process(string $content): string;
+}
similarity index 65%
rename from Twig/Filter/JPackFilter.php
rename to Filter/JPackFilter.php
index ca4555d71affc37e70dc88acfdf5a87135fcad57..9d758e4ff9f0c960af8c4631f6e6c3ef6e022873 100644 (file)
@@ -1,48 +1,42 @@
-<?php
+<?php declare(strict_types=1);
 
-namespace Rapsys\PackBundle\Twig\Filter;
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Filter;
 
-use Rapsys\PackBundle\Twig\Filter\FilterInterface;
 use Twig\Error\Error;
+use Twig\Source;
 
+/**
+ * {@inheritdoc}
+ */
 class JPackFilter implements FilterInterface {
-       //Default bin
-       private $bin;
-
-       //Default compress type
-       private $compress;
-
-       //Twig template filename
-       private $fileName;
-
-       //Twig template line
-       private $line;
-
-       //Configure the object
-       //XXX: can be clean, shrink, obfuscate or best
-       public function __construct($fileName, $line, $bin = 'jpack', $compress = 'best') {
-               //Set fileName
-               $this->fileName = $fileName;
-
-               //Set line
-               $this->line = $line;
-
-               //Set bin
-               $this->bin = $bin;
-
-               //Set compress
-               $this->compress = $compress;
-
+       /**
+        * Setup jpack filter
+        *
+        * @xxx compress can be clean, shrink, obfuscate or best
+        */
+       public function __construct(protected Source $fileName, protected int $line, protected string $bin = 'jpack', protected string $compress = 'best') {
                //Deal with compress
                if (!empty($this->compress)) {
                        //Append clean parameter
                        if ($this->compress == 'clean') {
+                               //TODO: protect binary call by converting bin to array ?
                                $this->bin .= ' --clean';
                        //Append shrink parameter
                        } elseif ($this->compress == 'shrink') {
+                               //TODO: protect binary call by converting bin to array ?
                                $this->bin .= ' --shrink';
                        //Append obfuscate parameter
                        } elseif ($this->compress == 'obfuscate') {
+                               //TODO: protect binary call by converting bin to array ?
                                $this->bin .= ' --obfuscate';
                        //Unknown compress type
                        //XXX: default compression is best
@@ -53,7 +47,10 @@ class JPackFilter implements FilterInterface {
                }
        }
 
-       public function process($content) {
+       /**
+        * {@inheritdoc}
+        */
+       public function process(string $content): string {
                //Create descriptors
                $descriptorSpec = array(
                        0 => array('pipe', 'r'),
@@ -64,7 +61,7 @@ class JPackFilter implements FilterInterface {
                //Open process
                if (is_resource($proc = proc_open($this->bin, $descriptorSpec, $pipes))) {
                        //Set stderr as non blocking
-                       stream_set_blocking($pipes[2], 0);
+                       stream_set_blocking($pipes[2], false);
 
                        //Send content to stdin
                        fwrite($pipes[0], $content);
diff --git a/Form/CaptchaType.php b/Form/CaptchaType.php
new file mode 100644 (file)
index 0000000..c3a5f69
--- /dev/null
@@ -0,0 +1,116 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Form;
+
+use Rapsys\PackBundle\Util\ImageUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\HiddenType;
+use Symfony\Component\Form\Extension\Core\Type\IntegerType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormError;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * Captcha Type class definition
+ *
+ * @see https://symfony.com/doc/current/form/create_custom_field_type.html
+ */
+class CaptchaType extends AbstractType {
+       /**
+        * Constructor
+        *
+        * @param ?ImageUtil $image The image instance
+        * @param ?SluggerUtil $slugger The slugger instance
+        * @param ?TranslatorInterface $translator The translator instance
+        */
+       public function __construct(protected ?ImageUtil $image = null, protected ?SluggerUtil $slugger = null, protected ?TranslatorInterface $translator = null) {
+       }
+
+       /**
+        * {@inheritdoc}
+        *
+        * Build form
+        */
+       public function buildForm(FormBuilderInterface $builder, array $options): void {
+               //With image, slugger and translator
+               if (!empty($options['captcha']) && $this->image !== null && $this->slugger !== null && $this->translator !== null) {
+                       //Set captcha
+                       $captcha = $this->image->getCaptcha((new \DateTime('-1 year'))->getTimestamp());
+
+                       //Add captcha token
+                       $builder->add('_captcha_token', HiddenType::class, ['data' => $captcha['token'], 'empty_data' => $captcha['token'], 'mapped' => false]);
+
+                       //Add captcha
+                       $builder->add('captcha', IntegerType::class, ['label_attr' => ['class' => 'captcha'], 'label' => '<img src="'.htmlentities($captcha['src']).'" alt="'.htmlentities($captcha['equation']).'" />', 'label_html' => true, 'mapped' => false, 'translation_domain' => false]);
+
+                       //Add event listener on captcha
+                       $builder->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'validateCaptcha']);
+               }
+       }
+
+       /**
+        * {@inheritdoc}
+        */
+       public function configureOptions(OptionsResolver $resolver): void {
+               //Call parent configure options
+               parent::configureOptions($resolver);
+
+               //Set defaults
+               $resolver->setDefaults(['captcha' => false]);
+
+               //Add extra captcha option
+               $resolver->setAllowedTypes('captcha', 'boolean');
+       }
+
+       /**
+        * Validate captcha
+        *
+        * @param FormEvent $event The form event
+        */
+       public function validateCaptcha(FormEvent $event): void {
+               //Get form
+               $form = $event->getForm();
+
+               //Get event data
+               $data = $event->getData();
+
+               //Set token
+               $token = $form->get('_captcha_token')->getConfig()->getData();
+
+               //Without captcha
+               if (empty($data['captcha'])) {
+                       //Add error on captcha
+                       $form->addError(new FormError($this->translator->trans('Captcha is empty')));
+
+                       //Reset captcha token
+                       $data['_captcha_token'] = $token;
+
+                       //Set event data
+                       $event->setData($data);
+               //With invalid captcha
+               } elseif ($this->slugger->hash($data['captcha']) !== $data['_captcha_token']) {
+                       //Add error on captcha
+                       $form->addError(new FormError($this->translator->trans('Captcha is invalid')));
+
+                       //Reset captcha token
+                       $data['_captcha_token'] = $token;
+
+                       //Set event data
+                       $event->setData($data);
+               }
+       }
+}
diff --git a/Package/PathPackage.php b/Package/PathPackage.php
new file mode 100644 (file)
index 0000000..c53b10f
--- /dev/null
@@ -0,0 +1,100 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Package;
+
+use Rapsys\PackBundle\Context\NullContext;
+
+use Symfony\Component\Asset\Context\ContextInterface;
+use Symfony\Component\Asset\Package;
+use Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface;
+
+/**
+ * {@inheritdoc}
+ */
+class PathPackage extends Package {
+       /**
+        * The base url
+        */
+       protected string $baseUrl;
+
+       /**
+        * {@inheritdoc}
+        */
+       public function __construct(protected string $basePath, protected VersionStrategyInterface $versionStrategy, protected ?ContextInterface $context = null) {
+               //Without context use a null context
+               $this->context = $this->context ?? new NullContext();
+
+               //Call parent constructor
+               parent::__construct($this->versionStrategy, $this->context);
+
+               //Without base path
+               if (empty($basePath)) {
+                       //Set base path
+                       $this->basePath = '/';
+               //With base path
+               } else {
+                       //With relative base path
+                       if ('/' != $basePath[0]) {
+                               //Set base path as absolute
+                               $basePath = '/'.$basePath;
+                       }
+
+                       //Set base path
+                       $this->basePath = rtrim($basePath, '/').'/';
+               }
+
+               //Set base url
+               $this->baseUrl = $this->context->getBaseUrl();
+       }
+
+       /**
+        * {@inheritdoc}
+        *
+        * Returns an absolute or root-relative public path
+        *
+        * Transform @BundleBundle to bundle and remove /Resources/public fragment from path
+        * This bundle name conversion and bundle prefix are the same as in asset:install command
+        *
+        * @link https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
+        * @see vendor/symfony/framework-bundle/Command/AssetsInstallCommand.php +113
+        * @see vendor/symfony/framework-bundle/Command/AssetsInstallCommand.php +141
+        */
+       public function getUrl(string $path): string {
+               //Match url starting with a bundle name
+               if (preg_match('%^@([A-Z][a-zA-Z]*?)(?:Bundle/Resources/public)?/(.*)$%', $path, $matches)) {
+                       //Handle empty or without replacement pattern basePath
+                       if (empty($this->basePath) || strpos($this->basePath, '%s') === false) {
+                               //Set path from hardcoded format
+                               $path = '/bundles/'.strtolower($matches[1]).'/'.$matches[2];
+                       //Proceed with basePath pattern replacement
+                       } else {
+                               //Set path from basePath pattern
+                               //XXX: basePath has a trailing / added by constructor
+                               $path = sprintf($this->basePath, strtolower($matches[1])).$matches[2];
+                       }
+               }
+
+               //Return parent getUrl result
+               return parent::getUrl($path);
+       }
+
+       /**
+        * Returns an absolute public path.
+        *
+        * @param string $path A path
+        * @return string The absolute public path
+        */
+       public function getAbsoluteUrl(string $path): string {
+               //Return concated base url and url from path
+               return $this->baseUrl.self::getUrl($path);
+       }
+}
similarity index 61%
rename from Twig/PackTokenParser.php
rename to Parser/TokenParser.php
index c4bbe4cb7c49a3b0237a444611432bb3c4ac7052..67e87eb52f6bad193e0add9d6b8fc9f1a8aac6d5 100644 (file)
@@ -1,9 +1,23 @@
-<?php
+<?php declare(strict_types=1);
 
-namespace Rapsys\PackBundle\Twig;
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Parser;
+
+use Rapsys\PackBundle\RapsysPackBundle;
 
-use Symfony\Component\HttpKernel\Config\FileLocator;
 use Symfony\Component\Asset\PackageInterface;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpKernel\Config\FileLocator;
+
 use Twig\Error\Error;
 use Twig\Node\Expression\AssignNameExpression;
 use Twig\Node\Node;
@@ -13,50 +27,37 @@ use Twig\Source;
 use Twig\Token;
 use Twig\TokenParser\AbstractTokenParser;
 
-class PackTokenParser extends AbstractTokenParser {
-       ///The tag name
-       protected $tag;
+/**
+ * {@inheritdoc}
+ */
+class TokenParser extends AbstractTokenParser {
+       /**
+        * The stream context instance
+        */
+       protected mixed $ctx;
 
        /**
         * Constructor
         *
-        * @param FileLocator      locator The FileLocator instance
-        * @param PackageInterface package The Assets Package instance
-        * @param array            config  The config path
-        * @param string           tag     The tag name
-        * @param string           output  The default output string
-        * @param array            filters The default filters array
+        * @param FileLocator $locator The FileLocator instance
+        * @param PackageInterface $package The Assets Package instance
+        * @param string $token The token name
+        * @param string $tag The tag name
+        * @param string $output The default output string
+        * @param array $filters The default filter array
         */
-       public function __construct(FileLocator $locator, PackageInterface $package, $config, $tag, $output, $filters) {
-               //Save locator
-               $this->locator = $locator;
-
-               //Save assets package
-               $this->package = $package;
-
-               //Set name
-               $this->name = $config['name'];
-
-               //Set scheme
-               $this->scheme = $config['scheme'];
-
-               //Set timeout
-               $this->timeout = $config['timeout'];
-
-               //Set agent
-               $this->agent = $config['agent'];
-
-               //Set redirect
-               $this->redirect = $config['redirect'];
-
-               //Set tag
-               $this->tag = $tag;
-
-               //Set output
-               $this->output = $output;
-
-               //Set filters
-               $this->filters = $filters;
+       public function __construct(protected FileLocator $locator, protected PackageInterface $package, protected string $token, protected string $tag, protected string $output, protected array $filters) {
+               //Set ctx
+               $this->ctx = stream_context_create(
+                       [
+                               'http' => [
+                                       #'header' => ['Referer: https://www.openstreetmap.org/'],
+                                       'max_redirects' => $_ENV['RAPSYSPACK_REDIRECT'] ?? 20,
+                                       'timeout' => $_ENV['RAPSYSPACK_TIMEOUT'] ?? (($timeout = ini_get('default_socket_timeout')) !== false && $timeout !== "" ? (float)$timeout : 60),
+                                       'user_agent' => $_ENV['RAPSYSPACK_AGENT'] ?? (($agent = ini_get('user_agent')) !== false && $agent !== "" ? (string)$agent : RapsysPackBundle::getAlias().'/'.RapsysPackBundle::getVersion())
+                               ]
+                       ]
+               );
        }
 
        /**
@@ -64,38 +65,31 @@ class PackTokenParser extends AbstractTokenParser {
         *
         * @return string This tag name
         */
-       public function getTag() {
+       public function getTag(): string {
                return $this->tag;
        }
 
        /**
         * Parse the token
         *
-        * @param Token token The \Twig\Token instance
+        * @xxx Skip filter when debug mode is enabled is not possible
+        * @xxx This code is only run once when twig cache is enabled
+        * @xxx Twig cache value is not avaible in container parameters, maybe in twig env ?
         *
+        * @param Token $token The \Twig\Token instance
         * @return Node The PackNode
-        *
-        * @todo see if we can't add a debug mode behaviour
-        *
-        * If twig.debug or env=dev (or rapsys_pack.config.debug?) is set, it should be possible to loop on each input
-        * and process the captured body without applying requested filter.
-        *
-        * @todo list:
-        * - detect debug mode
-        * - retrieve fixe link from input s%@(Name)Bundle/Resources/public(/somewhere/file.ext)%/bundles/\L\1\E\2%
-        * - for each inputs:
-        *   - generate a set asset_url=x
-        *   - generate a body
         */
-       public function parse(Token $token) {
+       public function parse(Token $token): Node {
+               //Get parser
                $parser = $this->parser;
+
+               //Get parser stream
                $stream = $this->parser->getStream();
 
+               //Set inputs array
                $inputs = [];
-               $name = $this->name;
-               $output = $this->output;
-               $filters = $this->filters;
 
+               //Set content
                $content = '';
 
                //Process the token block until end
@@ -109,19 +103,19 @@ class PackTokenParser extends AbstractTokenParser {
                                //filter='yui_js'
                                $stream->next();
                                $stream->expect(Token::OPERATOR_TYPE, '=');
-                               $filters = array_merge($filters, array_filter(array_map('trim', explode(',', $stream->expect(Token::STRING_TYPE)->getValue()))));
+                               $this->filters = array_merge($this->filters, array_filter(array_map('trim', explode(',', $stream->expect(Token::STRING_TYPE)->getValue()))));
                        //The output token
                        } elseif ($stream->test(Token::NAME_TYPE, 'output')) {
                                //output='js/packed/*.js' OR output='js/core.js'
                                $stream->next();
                                $stream->expect(Token::OPERATOR_TYPE, '=');
-                               $output = $stream->expect(Token::STRING_TYPE)->getValue();
-                       //The name token
-                       } elseif ($stream->test(Token::NAME_TYPE, 'name')) {
+                               $this->output = $stream->expect(Token::STRING_TYPE)->getValue();
+                       //The token name
+                       } elseif ($stream->test(Token::NAME_TYPE, 'token')) {
                                //name='core_js'
                                $stream->next();
                                $stream->expect(Token::OPERATOR_TYPE, '=');
-                               $name = $stream->expect(Token::STRING_TYPE)->getValue();
+                               $this->token = $stream->expect(Token::STRING_TYPE)->getValue();
                        //Unexpected token
                        } else {
                                $token = $stream->getCurrent();
@@ -138,12 +132,10 @@ class PackTokenParser extends AbstractTokenParser {
                //Process end block
                $stream->expect(Token::BLOCK_END_TYPE);
 
-               //TODO: debug mode should be inserted here before the output variable is rewritten
-
                //Replace star with sha1
-               if (($pos = strpos($output, '*')) !== false) {
-                       //XXX: assetic use substr(sha1(serialize($inputs).serialize($filters).serialize($options)), 0, 7)
-                       $output = substr($output, 0, $pos).sha1(serialize($inputs).serialize($filters)).substr($output, $pos + 1);
+               if (($pos = strpos($this->output, '*')) !== false) {
+                       //XXX: assetic use substr(sha1(serialize($inputs).serialize($this->filters).serialize($this->output)), 0, 7)
+                       $this->output = substr($this->output, 0, $pos).sha1(serialize($inputs).serialize($this->filters)).substr($this->output, $pos + 1);
                }
 
                //Process inputs
@@ -151,7 +143,7 @@ class PackTokenParser extends AbstractTokenParser {
                        //Deal with generic url
                        if (strpos($inputs[$k], '//') === 0) {
                                //Fix url
-                               $inputs[$k] = $this->scheme.substr($inputs[$k], 2);
+                               $inputs[$k] = ($_ENV['RAPSYSPACK_SCHEME'] ?? 'https').'://'.substr($inputs[$k], 2);
                        //Deal with non url path
                        } elseif (strpos($inputs[$k], '://') === false) {
                                //Check if we have a bundle path
@@ -164,6 +156,7 @@ class PackTokenParser extends AbstractTokenParser {
                                if (strpos($inputs[$k], '*') !== false || (($a = strpos($inputs[$k], '{')) !== false && ($b = strpos($inputs[$k], ',', $a)) !== false && strpos($inputs[$k], '}', $b) !== false)) {
                                        //Get replacement
                                        $replacement = glob($inputs[$k], GLOB_NOSORT|GLOB_BRACE);
+
                                        //Check that these are working files
                                        foreach($replacement as $input) {
                                                //Check that it's a file
@@ -171,8 +164,10 @@ class PackTokenParser extends AbstractTokenParser {
                                                        throw new Error(sprintf('Input path "%s" from "%s" is not a file', $input, $inputs[$k]), $token->getLine(), $stream->getSourceContext());
                                                }
                                        }
+
                                        //Replace with glob path
                                        array_splice($inputs, $k, 1, $replacement);
+
                                        //Fix current key
                                        $k += count($replacement) - 1;
                                //Check that it's a file
@@ -182,25 +177,15 @@ class PackTokenParser extends AbstractTokenParser {
                        }
                }
 
-               //Init context
-               $ctx = stream_context_create(
-                       [
-                               'http' => [
-                                       'timeout' => $this->timeout,
-                                       'user_agent' => $this->agent,
-                                       'redirect' => $this->redirect,
-                               ]
-                       ]
-               );
-
                //Check inputs
                if (!empty($inputs)) {
                        //Retrieve files content
                        foreach($inputs as $input) {
                                //Try to retrieve content
-                               if (($data = file_get_contents($input, false, $ctx)) === false) {
+                               if (($data = file_get_contents($input, false, $this->ctx)) === false) {
                                        throw new Error(sprintf('Unable to retrieve input path "%s"', $input), $token->getLine(), $stream->getSourceContext());
                                }
+
                                //Append content
                                $content .= $data;
                        }
@@ -214,22 +199,27 @@ class PackTokenParser extends AbstractTokenParser {
                }
 
                //Check filters
-               if (!empty($filters)) {
+               if (!empty($this->filters)) {
                        //Apply all filters
-                       foreach($filters as $filter) {
+                       foreach($this->filters as $filter) {
                                //Init args
                                $args = [$stream->getSourceContext(), $token->getLine()];
+
                                //Check if args is available
                                if (!empty($filter['args'])) {
                                        //Append args if provided
                                        $args += $filter['args'];
                                }
+
                                //Init reflection
                                $reflection = new \ReflectionClass($filter['class']);
+
                                //Set instance args
                                $tool = $reflection->newInstanceArgs($args);
+
                                //Process content
                                $content = $tool->process($content);
+
                                //Remove object
                                unset($tool, $reflection);
                        }
@@ -240,61 +230,45 @@ class PackTokenParser extends AbstractTokenParser {
                }
 
                //Retrieve asset uri
-               //XXX: this path is the merge of services.assets.path_package.arguments[0] and rapsys_pack.output.(css,img,js)
-               if (($outputUrl = $this->package->getUrl($output)) === false) {
-                       throw new Error(sprintf('Unable to get url for asset: %s', $output), $token->getLine(), $stream->getSourceContext());
+               //XXX: this path is the merge of services.assets.path_package.arguments[0] and rapsyspack.output.(css,img,js)
+               if (($outputUrl = $this->package->getUrl($this->output)) === false) {
+                       throw new Error(sprintf('Unable to get url for asset: %s', $this->output), $token->getLine(), $stream->getSourceContext());
                }
 
                //Check if we have a bundle path
-               if ($output[0] == '@') {
+               if ($this->output[0] == '@') {
                        //Resolve it
-                       $output = $this->getLocated($output, $token->getLine(), $stream->getSourceContext());
+                       $this->output = $this->getLocated($this->output, $token->getLine(), $stream->getSourceContext());
                }
 
+               //Get filesystem
+               $filesystem = new Filesystem();
+
                //Create output dir if not present
-               if (!is_dir($dir = dirname($output))) {
+               if (!is_dir($dir = dirname($this->output))) {
                        try {
-                               //XXX: set as 0777, symfony umask (0022) will reduce rights (0755)
-                               if (mkdir($dir, 0777, true) === false) {
-                                       throw new \Exception();
-                               }
-                       } catch (\Exception $e) {
+                               //Create dir
+                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                               $filesystem->mkdir($dir, 0775);
+                       } catch (IOExceptionInterface $e) {
+                               //Throw error
                                throw new Error(sprintf('Output directory "%s" do not exists and unable to create it', $dir), $token->getLine(), $stream->getSourceContext(), $e);
                        }
                }
 
                //Send file content
-               //XXX: to avoid partial content in reverse cache we use atomic rotation write, unlink and move
                try {
-                       if (file_put_contents($output.'.new', $content) === false) {
-                               throw new \Exception();
-                       }
-               } catch(\Exception $e) {
-                       throw new Error(sprintf('Unable to write to: %s', $output.'.new'), $token->getLine(), $stream->getSourceContext(), $e);
-               }
-
-               //Remove old file
-               if (is_file($output)) {
-                       try {
-                               if (unlink($output) === false) {
-                                       throw new \Exception();
-                               }
-                       } catch (\Exception $e) {
-                               throw new Error(sprintf('Unable to unlink: %s', $output), $token->getLine(), $stream->getSourceContext(), $e);
-                       }
-               }
-
-               //Rename it
-               try {
-                       if (rename($output.'.new', $output) === false) {
-                               throw new \Exception();
-                       }
-               } catch (\Exception $e) {
-                       throw new Error(sprintf('Unable to rename: %s to %s', $output.'.new', $output), $token->getLine(), $stream->getSourceContext(), $e);
+                       //Write content to file
+                       //XXX: this call is (maybe) atomic
+                       //XXX: see https://symfony.com/doc/current/components/filesystem.html#dumpfile
+                       $filesystem->dumpFile($this->output, $content);
+               } catch (IOExceptionInterface $e) {
+                       //Throw error
+                       throw new Error(sprintf('Unable to write to: %s', $this->output), $token->getLine(), $stream->getSourceContext(), $e);
                }
 
                //Set name in context key
-               $ref = new AssignNameExpression($name, $token->getLine());
+               $ref = new AssignNameExpression($this->token, $token->getLine());
 
                //Set output in context value
                $value = new TextNode($outputUrl, $token->getLine());
@@ -311,28 +285,25 @@ class PackTokenParser extends AbstractTokenParser {
        /**
         * Test for tag end
         *
-        * @param Token token The \Twig\Token instance
-        *
-        * @return bool
+        * @param Token $token The \Twig\Token instance
+        * @return bool The token end test result
         */
-       public function testEndTag(Token $token) {
+       public function testEndTag(Token $token): bool {
                return $token->test(['end'.$this->getTag()]);
        }
 
        /**
         * Get path from bundled file
         *
-        * @param string     file   The bundled file path
-        * @param int        lineno The template line where the error occurred
-        * @param Source     source The source context where the error occurred
-        * @param \Exception prev   The previous exception
+        * @see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
         *
+        * @param string $file The bundled file path
+        * @param int $lineno The template line where the error occurred
+        * @param Source $source The source context where the error occurred
+        * @param Exception $prev The previous exception
         * @return string The resolved file path
-        *
-        * @todo Try retrive public dir from the member function BundleNameBundle::getPublicDir() return value ?
-        * @xxx see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
         */
-       public function getLocated($file, int $lineno = 0, Source $source = null, \Exception $prev = null) {
+       public function getLocated(string $file, int $lineno = 0, ?Source $source = null, ?\Exception $prev = null): string {
                /*TODO: add a @jquery magic feature ?
                if ($file == '@jquery') {
                        #header('Content-Type: text/plain');
@@ -343,7 +314,7 @@ class PackTokenParser extends AbstractTokenParser {
 
                //Check that we have a / separator between bundle name and path
                if (($pos = strpos($file, '/')) === false) {
-                       throw new Error(sprintf('Invalid path "%s"', $file), $token->getLine(), $stream->getSourceContext());
+                       throw new Error(sprintf('Invalid path "%s"', $file), $lineno, $source);
                }
 
                //Set bundle
@@ -369,7 +340,7 @@ class PackTokenParser extends AbstractTokenParser {
                //Resolve bundle prefix
                try {
                        $prefix = $this->locator->locate($bundle);
-                       //Catch bundle does not exist or is not enabled exception
+               //Catch bundle does not exist or is not enabled exception
                } catch(\InvalidArgumentException $e) {
                        //Fix lowercase first bundle character
                        if ($bundle[1] > 'Z' || $bundle[1] < 'A') {
@@ -396,7 +367,7 @@ class PackTokenParser extends AbstractTokenParser {
                                //Catch bundle does not exist or is not enabled exception again
                        } catch(\InvalidArgumentException $e) {
                                //Bail out as bundle or path is invalid and we have no way to know what was meant
-                               throw new Error(sprintf('Invalid bundle name "%s" in path "%s". Maybe you meant "%s"', substr($file, 1, $pos - 1), $file, $bundle.'/'.$path), $token->getLine(), $stream->getSourceContext(), $e);
+                               throw new Error(sprintf('Invalid bundle name "%s" in path "%s". Maybe you meant "%s"', substr($file, 1, $pos - 1), $file, $bundle.'/'.$path), $lineno, $source, $e);
                        }
                }
 
index 3f3ab875b88151a12475d65c88c0df265ae6d183..81cd07aba8a02da2386ecccc6928cb19ca374bc5 100644 (file)
--- a/README.md
+++ b/README.md
@@ -1,3 +1,15 @@
+Contribute
+==========
+
+You may buy me a Beer, a Tea or help with Server fees with a paypal donation to
+the address <paypal@rapsys.eu>.
+
+Don't forget to show your love for this project, feel free to report bugs to
+the author, issues which are security relevant should be disclosed privately
+first.
+
+Patches are welcomed and grant credit when requested.
+
 Installation
 ============
 
@@ -8,27 +20,34 @@ Add bundle custom repository to your project's `composer.json` file:
 
 ```json
 {
-    ...,
-    "repositories": [
-           {
-                   "type": "package",
-                   "package": {
-                           "name": "rapsys/packbundle",
-                           "version": "dev-master",
-                           "source": {
-                                   "type": "git",
-                                   "url": "https://git.rapsys.eu/packbundle",
-                                   "reference": "master"
-                           },
-                           "autoload": {
-                                   "psr-4": {
-                                           "Rapsys\\PackBundle\\": ""
-                                   }
-                           }
-                   }
-           }
-    ],
-    ...
+       ...,
+       "repositories": [
+               {
+                       "type": "package",
+                       "package": {
+                               "name": "rapsys/packbundle",
+                               "version": "dev-master",
+                               "source": {
+                                       "type": "git",
+                                       "url": "https://git.rapsys.eu/packbundle",
+                                       "reference": "master"
+                               },
+                               "autoload": {
+                                       "psr-4": {
+                                               "Rapsys\\PackBundle\\": ""
+                                       }
+                               },
+                               "require": {
+                                       "symfony/asset": "^4.0|^5.0|^6.0|^7.0",
+                                       "symfony/flex": "^1.0|^2.0",
+                                       "symfony/framework-bundle": "^4.0|^5.0|^6.0|^7.0",
+                                       "symfony/process": "^4.0|^5.0|^6.0|^7.0",
+                                       "symfony/twig-bundle": "^4.0|^5.0|^6.0|^7.0"
+                               }
+                       }
+               }
+       ],
+       ...
 }
 ```
 
@@ -66,56 +85,137 @@ in the `app/AppKernel.php` file of your project:
 // ...
 class AppKernel extends Kernel
 {
-    public function registerBundles()
-    {
-        $bundles = array(
-            // ...
-            new Rapsys\PackBundle\RapsysPackBundle(),
-        );
+       public function registerBundles()
+       {
+               $bundles = array(
+                       // ...
+                       new Rapsys\PackBundle\RapsysPackBundle(),
+               );
 
-        // ...
-    }
+               // ...
+       }
 
-    // ...
+       // ...
 }
 ```
 
 ### Step 3: Configure the Bundle
 
-Verify that you have the configuration file `config/packages/rapsys_pack.yaml`
-with the following content:
+Setup configuration file `config/packages/rapsys_pack.yaml` with the following
+content available in `Resources/config/packages/rapsys_pack.yaml`:
 
 ```yaml
 #Services configuration
 services:
+    #Replace assets.packages definition
+    assets.packages:
+        class: 'Symfony\Component\Asset\Packages'
+        arguments: [ '@rapsys_pack.path_package' ]
+    #Replace assets.context definition
+    assets.context:
+        class: 'Rapsys\PackBundle\Context\RequestStackContext'
+        arguments: [ '@request_stack', '%asset.request_context.base_path%', '%asset.request_context.secure%' ]
     #Register assets pack package
-    assets.pack_package:
-        class: Rapsys\PackBundle\Asset\PathPackage
+    rapsys_pack.path_package:
+        class: 'Rapsys\PackBundle\Package\PathPackage'
         arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
+        public: true
     #Register twig pack extension
-    rapsys_pack.twig.pack_extension:
-        class: Rapsys\PackBundle\Twig\PackExtension
-        arguments: [ '@file_locator', '@service_container', '@assets.pack_package' ]
-        tags: [ twig.extension ]
+    rapsys_pack.pack_extension:
+        class: 'Rapsys\PackBundle\Extension\PackExtension'
+        arguments: [ '@service_container', '@rapsys_pack.intl_util', '@file_locator', '@rapsys_pack.path_package', '@rapsys_pack.slugger_util' ]
+        tags: [ 'twig.extension' ]
+    #Register intl util service
+    rapsys_pack.intl_util:
+        class: 'Rapsys\PackBundle\Util\IntlUtil'
+        public: true
+    #Register facebook event subscriber
+    Rapsys\PackBundle\Subscriber\FacebookSubscriber:
+        arguments: [ '@router', [] ]
+        tags: [ 'kernel.event_subscriber' ]
+    #Register intl util class alias
+    Rapsys\PackBundle\Util\IntlUtil:
+        alias: 'rapsys_pack.intl_util'
+    #Register facebook util service
+    rapsys_pack.facebook_util:
+        class: 'Rapsys\PackBundle\Util\FacebookUtil'
+        arguments: [ '@router', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+        public: true
+    #Register facebook util class alias
+    Rapsys\PackBundle\Util\FacebookUtil:
+        alias: 'rapsys_pack.facebook_util'
+    #Register image util service
+    rapsys_pack.image_util:
+        class: 'Rapsys\PackBundle\Util\ImageUtil'
+        arguments: [ '@router', '@rapsys_pack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+        public: true
+    #Register image util class alias
+    Rapsys\PackBundle\Util\ImageUtil:
+        alias: 'rapsys_pack.image_util'
+    #Register map util service
+    rapsys_pack.map_util:
+        class: 'Rapsys\PackBundle\Util\MapUtil'
+        arguments: [ '@router', '@rapsys_pack.slugger_util' ]
+        public: true
+    #Register map util class alias
+    Rapsys\PackBundle\Util\MapUtil:
+        alias: 'rapsys_pack.map_util'
+    #Register slugger util service
+    rapsys_pack.slugger_util:
+        class: 'Rapsys\PackBundle\Util\SluggerUtil'
+        arguments: [ '%kernel.secret%' ]
+        public: true
+    #Register slugger util class alias
+    Rapsys\PackBundle\Util\SluggerUtil:
+        alias: 'rapsys_pack.slugger_util'
+    #Register image controller
+    Rapsys\PackBundle\Controller\ImageController:
+        arguments: [ '@service_container', '@rapsys_pack.image_util', '@rapsys_pack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+        tags: [ 'controller.service_arguments' ]
+    #Register map controller
+    Rapsys\PackBundle\Controller\MapController:
+        arguments: [ '@service_container', '@rapsys_pack.map_util', '@rapsys_pack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+        tags: [ 'controller.service_arguments' ]
+    Rapsys\PackBundle\Form\CaptchaType:
+        arguments: [ '@rapsys_pack.image_util', '@rapsys_pack.slugger_util', '@translator' ]
+        tags: [ 'form.type' ]
 ```
 
-Open a command console, enter your project directory and execute the
-following command to see default bundle configuration:
+Setup configuration file `config/packages/myproject.yaml` with the following
+content available in `Resources/config/packages/rapsys_pack.yaml`:
+
+```yaml
+#Services configuration
+services:
+    #Register facebook event subscriber
+    Rapsys\PackBundle\Subscriber\FacebookSubscriber:
+        arguments: [ '@router', [ 'en', 'en_gb', 'en_us', 'fr', 'fr_fr' ] ]
+        tags: [ 'kernel.event_subscriber' ]
+    #Register facebook util service
+    rapsys_blog.facebook_util:
+        class: 'Rapsys\PackBundle\Util\FacebookUtil'
+        arguments: [ '@router',  '%kernel.project_dir%/var/cache', '%rapsys_pack.path%', 'facebook', '%kernel.project_dir%/public/png/facebook.png' ]
+        public: true
+```
+
+Open a command console, enter your project directory and execute the following
+command to see default bundle configuration:
 
 ```console
-$ php bin/console config:dump-reference RapsysPackBundle
+$ bin/console config:dump-reference RapsysPackBundle
 ```
 
-Open a command console, enter your project directory and execute the
-following command to see current bundle configuration:
+Open a command console, enter your project directory and execute the following
+command to see current bundle configuration:
 
 ```console
-$ php bin/console debug:config RapsysPackBundle
+$ bin/console debug:config RapsysPackBundle
 ```
 
 ### Step 4: Use the twig extension in your Template
 
-You can use a template like this to generate your first `rapsys_pack` enabled template:
+You can use a template like this to generate your first `rapsys_pack` enabled
+template:
 
 ```twig
 <!DOCTYPE html>
@@ -169,15 +269,14 @@ You can create you own mypackfilter class which call a mypack binary:
 ```php
 <?php
 
-namespace Rapsys\PackBundle\Twig\Filter;
+namespace Rapsys\PackBundle\Filter;
 
-use Rapsys\PackBundle\Twig\Filter\FilterInterface;
 use Twig\Error\Error;
 
 //This class will be defined in the parameter rapsys_pack.filters.(css|img|js).[x].class string
 class MyPackFilter implements FilterInterface {
-       //The constructor arguments ... will be replaced defined in the parameter rapsys_pack.filters.(css|img|js).[x].args array
-       public function __construct($fileName, $line, $bin = 'mypack', ...) {
+       //The constructor arguments ... will be replaced with values defined in the parameter rapsys_pack.filters.(css|img|js).[x].args array
+       public function __construct(string $fileName, int $line, string $bin = 'mypack', ...) {
                //Set fileName
                $this->fileName = $fileName;
 
@@ -200,7 +299,7 @@ class MyPackFilter implements FilterInterface {
        }
 
        //Pass merge of all inputs in content
-       public function process($content) {
+       public function process(string $content): string {
                //Create descriptors
                $descriptorSpec = array(
                        0 => array('pipe', 'r'),
@@ -211,7 +310,7 @@ class MyPackFilter implements FilterInterface {
                //Open process
                if (is_resource($proc = proc_open($this->bin, $descriptorSpec, $pipes))) {
                        //Set stderr as non blocking
-                       stream_set_blocking($pipes[2], 0);
+                       stream_set_blocking($pipes[2], false);
 
                        //Send content to stdin
                        fwrite($pipes[0], $content);
@@ -247,4 +346,4 @@ class MyPackFilter implements FilterInterface {
 }
 ```
 
-The class is required to get it's arguments through constructor and have a process method.
+The class must implements FilterInterface and get it's arguments through constructor.
index d1fd290162f4813f4ee19d7b935eca5ea17112a5..40e05638ae3b60ba57bdeac9b7f52e0f9ac63a99 100644 (file)
@@ -1,7 +1,69 @@
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
 
 namespace Rapsys\PackBundle;
 
+use Rapsys\PackBundle\DependencyInjection\RapsysPackExtension;
+
+use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
 use Symfony\Component\HttpKernel\Bundle\Bundle;
 
-class RapsysPackBundle extends Bundle {}
+/**
+ * {@inheritdoc}
+ */
+class RapsysPackBundle extends Bundle {
+       /**
+        * {@inheritdoc}
+        */
+       public function getContainerExtension(): ?ExtensionInterface {
+               //Return created container extension
+               return $this->createContainerExtension();
+       }
+
+       /**
+        * Return bundle alias
+        *
+        * @return string The bundle alias
+        */
+       public static function getAlias(): string {
+               //With namespace
+               if ($npos = strrpos(static::class, '\\')) {
+                       //Set name pos
+                       $npos++;
+               //Without namespace
+               } else {
+                       $npos = 0;
+               }
+
+               //With trailing bundle
+               if (substr(static::class, -strlen('Bundle'), strlen('Bundle')) === 'Bundle') {
+                       //Set bundle pos
+                       $bpos = strlen(static::class) - $npos - strlen('Bundle');
+               //Without bundle
+               } else {
+                       //Set bundle pos
+                       $bpos = strlen(static::class) - $npos;
+               }
+
+               //Return lowercase bundle alias
+               return strtolower(substr(static::class, $npos, $bpos));
+       }
+
+       /**
+        * Return bundle version
+        *
+        * @return string The bundle version
+        */
+       public static function getVersion(): string {
+               //Return version
+               return '0.5.0';
+       }
+}
diff --git a/Resources/config/packages/rapsys_pack.yaml b/Resources/config/packages/rapsys_pack.yaml
deleted file mode 100644 (file)
index 0fe0346..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-#Services configuration
-services:
-    #Register assets pack package
-    rapsys.path_package:
-        class: 'Rapsys\PackBundle\Asset\PathPackage'
-        arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
-    #Register twig pack extension
-    rapsys.pack_extension:
-        class: 'Rapsys\PackBundle\Twig\PackExtension'
-        arguments: [ '@file_locator', '@service_container', '@rapsys.path_package' ]
-        tags: [ 'twig.extension' ]
diff --git a/Resources/config/packages/rapsyspack.yaml b/Resources/config/packages/rapsyspack.yaml
new file mode 100644 (file)
index 0000000..f57cf74
--- /dev/null
@@ -0,0 +1,92 @@
+# Parameters configuration
+parameters:
+    # User agent
+    env(RAPSYSPACK_AGENT): "rapsyspack/Ch4ng3m3!"
+    # Shuffled printable character range
+    env(RAPSYSPACK_RANGE): 'Ch4ng3m3!'
+    # Redirect
+    env(RAPSYSPACK_REDIRECT): 20
+    # Scheme
+    env(RAPSYSPACK_SCHEME): "https"
+    # Timeout
+    env(RAPSYSPACK_TIMEOUT): 60
+
+# Services configuration
+services:
+    # Replace assets.context definition
+    assets.context:
+        class: 'Rapsys\PackBundle\Context\RequestStackContext'
+        arguments: [ '@request_stack', '%asset.request_context.base_path%', '%asset.request_context.secure%' ]
+    # Replace assets.packages definition
+    assets.packages:
+        class: 'Symfony\Component\Asset\Packages'
+        arguments: [ '@rapsyspack.path_package' ]
+    # Register facebook util service
+    rapsyspack.facebook_util:
+        class: 'Rapsys\PackBundle\Util\FacebookUtil'
+        arguments: [ '@router', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+        public: true
+    # Register image util service
+    rapsyspack.image_util:
+        class: 'Rapsys\PackBundle\Util\ImageUtil'
+        arguments: [ '@router', '@rapsyspack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+        public: true
+    # Register intl util service
+    rapsyspack.intl_util:
+        class: 'Rapsys\PackBundle\Util\IntlUtil'
+        public: true
+    # Register map util service
+    rapsyspack.map_util:
+        class: 'Rapsys\PackBundle\Util\MapUtil'
+        arguments: [ '@router', '@rapsyspack.slugger_util' ]
+        public: true
+    # Register twig pack extension
+    rapsyspack.pack_extension:
+        class: 'Rapsys\PackBundle\Extension\PackExtension'
+        arguments: [ '@rapsyspack.intl_util', '@file_locator', '@rapsyspack.path_package', '@rapsyspack.slugger_util', '%rapsyspack%' ]
+        tags: [ 'twig.extension' ]
+    # Register assets pack package
+    rapsyspack.path_package:
+        class: 'Rapsys\PackBundle\Package\PathPackage'
+        arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
+        public: true
+    # Register slugger util service
+    rapsyspack.slugger_util:
+        class: 'Rapsys\PackBundle\Util\SluggerUtil'
+        arguments: [ '%kernel.secret%' ]
+        public: true
+    # Register range command
+    Rapsys\PackBundle\Command\RangeCommand:
+        arguments: [ '%kernel.project_dir%/.env.local' ]
+        tags: [ 'console.command' ]
+    # Register image controller
+    Rapsys\PackBundle\Controller\ImageController:
+        arguments: [ '@service_container', '@rapsyspack.image_util', '@rapsyspack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+        tags: [ 'controller.service_arguments' ]
+    # Register map controller
+    Rapsys\PackBundle\Controller\MapController:
+        arguments: [ '@service_container', '@rapsyspack.map_util', '@rapsyspack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+        tags: [ 'controller.service_arguments' ]
+    # Register captcha form type
+    Rapsys\PackBundle\Form\CaptchaType:
+        arguments: [ '@rapsyspack.image_util', '@rapsyspack.slugger_util', '@translator' ]
+        tags: [ 'form.type' ]
+    # Register facebook event subscriber
+    Rapsys\PackBundle\Subscriber\FacebookSubscriber:
+        arguments: [ '@router', [] ]
+        tags: [ 'kernel.event_subscriber' ]
+    # Register facebook util class alias
+    Rapsys\PackBundle\Util\FacebookUtil:
+        alias: 'rapsyspack.facebook_util'
+    # Register image util class alias
+    Rapsys\PackBundle\Util\ImageUtil:
+        alias: 'rapsyspack.image_util'
+    # Register intl util class alias
+    Rapsys\PackBundle\Util\IntlUtil:
+        alias: 'rapsyspack.intl_util'
+    # Register map util class alias
+    Rapsys\PackBundle\Util\MapUtil:
+        alias: 'rapsyspack.map_util'
+    # Register slugger util class alias
+    Rapsys\PackBundle\Util\SluggerUtil:
+        alias: 'rapsyspack.slugger_util'
diff --git a/Resources/config/routes/rapsyspack.yaml b/Resources/config/routes/rapsyspack.yaml
new file mode 100644 (file)
index 0000000..aa0a953
--- /dev/null
@@ -0,0 +1,26 @@
+#Routes configuration
+rapsyspack_captcha:
+    path: '/captcha/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{equation<[a-zA-Z0-9=_-]+>}/{width<\d+>?120}/{height<\d+>?36}.{!_format?jpeg}'
+    controller: Rapsys\PackBundle\Controller\ImageController::captcha
+    methods: GET
+
+#TODO: replace this url with a redirection route ???
+#XXX: we don't need the mtime, maybe we can drop it in this redirect instead of apache ?
+rapsyspack_facebook:
+    path: '/bundles/rapsyspack/facebook/{mtime<\d+>}{path</.*>}.{!_format?jpeg}'
+    methods: GET
+
+rapsyspack_map:
+    path: '/map/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{latitude<\d+(\.?\d+)?>}/{longitude<\d+(\.?\d+)?>}/{zoom<\d+>?17}/{width<\d+>?640}/{height<\d+>?640}.{!_format?jpeg}'
+    controller: Rapsys\PackBundle\Controller\MapController::map
+    methods: GET
+
+rapsyspack_multimap:
+    path: '/multimap/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{latitude<\d+(\.?\d+)?>}/{longitude<\d+(\.?\d+)?>}/{coordinates<(?:\d+(\.\d+)?,\d+(\.\d+)?(-\d+(\.\d+)?,\d+(\.\d+)?)*)?>}/{zoom<\d+>?15}/{width<\d+>?640}/{height<\d+>?640}.{!_format?jpeg}'
+    controller: Rapsys\PackBundle\Controller\MapController::multimap
+    methods: GET
+
+rapsyspack_thumb:
+    path: '/thumb/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{path<[a-zA-Z0-9=_-]+>}/{width<\d+>?640}/{height<\d+>?640}.{!_format?jpeg}'
+    controller: Rapsys\PackBundle\Controller\ImageController::thumb
+    methods: GET
diff --git a/Resources/public/image/.keep b/Resources/public/image/.keep
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/Resources/public/map/.keep b/Resources/public/map/.keep
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/Subscriber/FacebookSubscriber.php b/Subscriber/FacebookSubscriber.php
new file mode 100644 (file)
index 0000000..af4eb0f
--- /dev/null
@@ -0,0 +1,79 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Subscriber;
+
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * {@inheritdoc}
+ */
+class FacebookSubscriber implements EventSubscriberInterface {
+       /*
+        * Inject router interface and locales
+        *
+        * @param RouterInterface $router The router instance
+        * @param array $locales The supported locales
+        */
+       public function __construct(protected RouterInterface $router, protected array $locales) {
+       }
+
+       /**
+        * Change locale for request with ?fb_locale=xx
+        *
+        * @param RequestEvent The request event
+        */
+       public function onKernelRequest(RequestEvent $event): void {
+               //Without main request
+               if (!$event->isMainRequest()) {
+                       return;
+               }
+
+               //Retrieve request
+               $request = $event->getRequest();
+
+               //Check for facebook locale
+               if (
+                       $request->query->has('fb_locale') &&
+                       in_array($locale = $request->query->get('fb_locale'), $this->locales)
+               ) {
+                       //Set locale
+                       $request->setLocale($locale);
+
+                       //Set default locale
+                       $request->setDefaultLocale($locale);
+
+                       //Get router context
+                       $context = $this->router->getContext();
+
+                       //Set context locale
+                       $context->setParameter('_locale', $locale);
+
+                       //Set back router context
+                       $this->router->setContext($context);
+               }
+       }
+
+       /**
+        * Get subscribed events
+        *
+        * @return array The subscribed events
+        */
+       public static function getSubscribedEvents(): array {
+               return [
+                       // must be registered before the default locale listener
+                       KernelEvents::REQUEST => [['onKernelRequest', 10]]
+               ];
+       }
+}
diff --git a/Twig/Filter/FilterInterface.php b/Twig/Filter/FilterInterface.php
deleted file mode 100644 (file)
index 2dc2b6f..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php
-
-// src/Rapsys/PackBundle/Twig/Filter/FilterInterface.php
-namespace Rapsys\PackBundle\Twig\Filter;
-
-interface FilterInterface {
-    //TODO: see if we need something else (like a constructor that read parameters or something else ?)
-    public function process($content);
-}
diff --git a/Twig/PackExtension.php b/Twig/PackExtension.php
deleted file mode 100644 (file)
index 91a7a89..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-// src/Rapsys/PackBundle/Twig/PackExtension.php
-namespace Rapsys\PackBundle\Twig;
-
-use Symfony\Component\HttpKernel\Config\FileLocator;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\Asset\PackageInterface;
-use Twig\Extension\AbstractExtension;
-
-class PackExtension extends AbstractExtension {
-       //The config
-       private $config;
-
-       //The output
-       private $output;
-
-       //The filter
-       private $filters;
-
-       //The file locator
-       protected $locator;
-
-       //The assets package
-       protected $package;
-
-       public function __construct(FileLocator $locator, ContainerInterface $container, PackageInterface $package) {
-               //Set file locator
-               $this->locator = $locator;
-
-               //Set assets packages
-               $this->package = $package;
-
-               //Retrieve bundle config
-               if ($parameters = $container->getParameter($this->getAlias())) {
-                       //Set config, output and filters arrays
-                       foreach(['config', 'output', 'filters'] as $k) {
-                               $this->$k = $parameters[$k];
-                       }
-               }
-       }
-
-       public function getTokenParsers() {
-               return [
-                       new PackTokenParser($this->locator, $this->package, $this->config, 'stylesheet', $this->output['css'], $this->filters['css']),
-                       new PackTokenParser($this->locator, $this->package, $this->config, 'javascript', $this->output['js'], $this->filters['js']),
-                       new PackTokenParser($this->locator, $this->package, $this->config, 'image', $this->output['img'], $this->filters['img'])
-               ];
-       }
-
-       /**
-        * {@inheritdoc}
-        */
-       public function getAlias() {
-               return 'rapsys_pack';
-       }
-}
diff --git a/Util/FacebookUtil.php b/Util/FacebookUtil.php
new file mode 100644 (file)
index 0000000..91664d1
--- /dev/null
@@ -0,0 +1,357 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * 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\Generator\UrlGeneratorInterface;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * Helps manage facebook images
+ */
+class FacebookUtil {
+       /**
+        * The default fonts
+        */
+       const fonts = [ 'default' => 'ttf/default.ttf' ];
+
+       /**
+        * The default font
+        */
+       const font = 'default';
+
+       /**
+        * The default font size
+        */
+       const size = 60;
+
+       /**
+        * The default width
+        */
+       const width = 15;
+
+       /**
+        * The default fill
+        */
+       const fill = 'white';
+
+       /**
+        * The default stroke
+        */
+       const stroke = '#00c3f9';
+
+       /**
+        * The default align
+        */
+       const align = 'center';
+
+       /**
+        * Creates a new facebook util
+        *
+        * @param RouterInterface $router The RouterInterface instance
+        * @param string $cache The cache directory
+        * @param string $path The public path
+        * @param string $prefix The prefix
+        * @param ?string $source The source
+        * @param array $fonts The fonts
+        * @param string $font The font
+        * @param int $size The size
+        * @param int $width The width
+        * @param string $fill The fill
+        * @param string $stroke The stroke
+        * @param string $align The align
+        */
+       function __construct(protected RouterInterface $router, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'facebook', protected ?string $source = null, protected array $fonts = self::fonts, protected string $font = self::font, protected int $size = self::size, protected int $width = self::width, protected string $fill = self::fill, protected string $stroke = self::stroke, protected string $align = self::align) {
+       }
+
+       /**
+        * Return the facebook image
+        *
+        * Generate simple image in jpeg format or load it from cache
+        *
+        * @param string $pathInfo The request path info
+        * @param array $texts The image texts
+        * @param int $updated The updated timestamp
+        * @param ?string $source The image source
+        * @param int $width The width
+        * @param int $height The height
+        * @return array The image array
+        */
+       public function getImage(string $pathInfo, array $texts, int $updated, ?string $source = null, int $width = 1200, int $height = 630): array {
+               //Without source
+               if ($source === null && $this->source === null) {
+                       //Return empty image data
+                       return [];
+               //Without local source
+               } elseif ($source === null) {
+                       //Set local source
+                       $source = $this->source;
+               }
+
+               //Set path file
+               $path = $this->path.'/'.$this->prefix.$pathInfo.'.jpeg';
+
+               //Without existing path
+               if (!is_dir($dir = dirname($path))) {
+                       //Create filesystem object
+                       $filesystem = new Filesystem();
+
+                       try {
+                               //Create path
+                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                               $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);
+                       }
+               }
+
+               //With path file
+               if (is_file($path) && ($mtime = stat($path)['mtime']) && $mtime >= $updated) {
+                       #XXX: we used to drop texts with $data['canonical'] === true !!!
+
+                       //Return image data
+                       return [
+                               'og:image' => $this->router->generate('rapsyspack_facebook', ['mtime' => $mtime, 'path' => $pathInfo], UrlGeneratorInterface::ABSOLUTE_URL),
+                               'og:image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))),
+                               'og:image:height' => $height,
+                               'og:image:width' => $width
+                       ];
+               }
+
+               //Set cache path
+               $cache = $this->cache.'/'.$this->prefix.$pathInfo.'.png';
+
+               //Without cache path
+               if (!is_dir($dir = dirname($cache))) {
+                       //Create filesystem object
+                       $filesystem = new Filesystem();
+
+                       try {
+                               //Create path
+                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                               $filesystem->mkdir($dir, 0775);
+                       } catch (IOExceptionInterface $e) {
+                               //Throw error
+                               throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                       }
+               }
+
+               //Create image object
+               $image = new \Imagick();
+
+               //Without cache image
+               if (!is_file($cache) || stat($cache)['mtime'] < stat($source)['mtime']) {
+                       //Check target directory
+                       if (!is_dir($dir = dirname($cache))) {
+                               //Create filesystem object
+                               $filesystem = new Filesystem();
+
+                               try {
+                                       //Create dir
+                                       //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                                       $filesystem->mkdir($dir, 0775);
+                               } catch (IOExceptionInterface $e) {
+                                       //Throw error
+                                       throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                               }
+                       }
+
+                       //Without source
+                       if (!is_file($source)) {
+                               //Throw error
+                               throw new \Exception(sprintf('Source file "%s" do not exists', $this->source));
+                       }
+
+                       //Convert to absolute path
+                       $source = realpath($source);
+
+                       //Read image
+                       //XXX: Imagick::readImage only supports absolute path
+                       $image->readImage($source);
+
+                       //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();
+
+                       //Save cache image
+                       if (!$image->writeImage($cache)) {
+                               //Throw error
+                               throw new \Exception(sprintf('Unable to write image "%s"', $cache));
+                       }
+               //With cache
+               } else {
+                       //Read image
+                       $image->readImage($cache);
+               }
+
+               //Create draw
+               $draw = new \ImagickDraw();
+
+               //Set stroke antialias
+               $draw->setStrokeAntialias(true);
+
+               //Set text antialias
+               $draw->setTextAntialias(true);
+
+               //Set align aliases
+               $aligns = [
+                       'left' => \Imagick::ALIGN_LEFT,
+                       'center' => \Imagick::ALIGN_CENTER,
+                       'right' => \Imagick::ALIGN_RIGHT
+               ];
+
+               //Init counter
+               $i = 1;
+
+               //Set text count
+               $count = count($texts);
+
+               //Draw each text stroke
+               foreach($texts as $text => $data) {
+                       //Set font
+                       $draw->setFont($this->fonts[$data['font']??$this->font]);
+
+                       //Set font size
+                       $draw->setFontSize($data['size']??$this->size);
+
+                       //Set stroke width
+                       $draw->setStrokeWidth($data['width']??$this->width);
+
+                       //Set text alignment
+                       $draw->setTextAlignment($align = ($aligns[$data['align']??$this->align]));
+
+                       //Get font metrics
+                       $metrics = $image->queryFontMetrics($draw, $text);
+
+                       //Without y
+                       if (empty($data['y'])) {
+                               //Position verticaly each text evenly
+                               $texts[$text]['y'] = $data['y'] = (($height + 100) / (count($texts) + 1) * $i) - 50;
+                       }
+
+                       //Without x
+                       if (empty($data['x'])) {
+                               if ($align == \Imagick::ALIGN_CENTER) {
+                                       $texts[$text]['x'] = $data['x'] = $width/2;
+                               } elseif ($align == \Imagick::ALIGN_LEFT) {
+                                       $texts[$text]['x'] = $data['x'] = 50;
+                               } elseif ($align == \Imagick::ALIGN_RIGHT) {
+                                       $texts[$text]['x'] = $data['x'] = $width - 50;
+                               }
+                       }
+
+                       //Center verticaly
+                       //XXX: add ascender part then center it back by half of textHeight
+                       //TODO: maybe add a boundingbox ???
+                       $texts[$text]['y'] = $data['y'] += $metrics['ascender'] - $metrics['textHeight']/2;
+
+                       //Set stroke color
+                       $draw->setStrokeColor(new \ImagickPixel($data['stroke']??$this->stroke));
+
+                       //Set fill color
+                       $draw->setFillColor(new \ImagickPixel($data['stroke']??$this->stroke));
+
+                       //Add annotation
+                       $draw->annotation($data['x'], $data['y'], $text);
+
+                       //Increase counter
+                       $i++;
+               }
+
+               //Create stroke object
+               $stroke = new \Imagick();
+
+               //Add new image
+               $stroke->newImage($width, $height, new \ImagickPixel('transparent'));
+
+               //Draw on image
+               $stroke->drawImage($draw);
+
+               //Blur image
+               //XXX: blur the stroke canvas only
+               $stroke->blurImage(5,3);
+
+               //Set opacity to 0.5
+               //XXX: see https://www.php.net/manual/en/image.evaluateimage.php
+               $stroke->evaluateImage(\Imagick::EVALUATE_DIVIDE, 1.5, \Imagick::CHANNEL_ALPHA);
+
+               //Compose image
+               $image->compositeImage($stroke, \Imagick::COMPOSITE_OVER, 0, 0);
+
+               //Clear stroke
+               $stroke->clear();
+
+               //Destroy stroke
+               unset($stroke);
+
+               //Clear draw
+               $draw->clear();
+
+               //Set text antialias
+               $draw->setTextAntialias(true);
+
+               //Draw each text
+               foreach($texts as $text => $data) {
+                       //Set font
+                       $draw->setFont($this->fonts[$data['font']??$this->font]);
+
+                       //Set font size
+                       $draw->setFontSize($data['size']??$this->size);
+
+                       //Set text alignment
+                       $draw->setTextAlignment($aligns[$data['align']??$this->align]);
+
+                       //Set fill color
+                       $draw->setFillColor(new \ImagickPixel($data['fill']??$this->fill));
+
+                       //Add annotation
+                       $draw->annotation($data['x'], $data['y'], $text);
+
+                       //With canonical text
+                       if (!empty($data['canonical'])) {
+                               //Prevent canonical to finish in alt
+                               unset($texts[$text]);
+                       }
+               }
+
+               //Draw on image
+               $image->drawImage($draw);
+
+               //Strip image exif data and properties
+               $image->stripImage();
+
+               //Set image format
+               $image->setImageFormat('jpeg');
+
+               //Set progressive jpeg
+               $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
+
+               //Save image
+               if (!$image->writeImage($path)) {
+                       //Throw error
+                       throw new \Exception(sprintf('Unable to write image "%s"', $path));
+               }
+
+               //Return image data
+               return [
+                       'og:image' => $this->router->generate('rapsyspack_facebook', ['mtime' => stat($path)['mtime'], 'path' => $pathInfo], UrlGeneratorInterface::ABSOLUTE_URL),
+                       'og:image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))),
+                       'og:image:height' => $height,
+                       'og:image:width' => $width
+               ];
+       }
+}
diff --git a/Util/ImageUtil.php b/Util/ImageUtil.php
new file mode 100644 (file)
index 0000000..871b99a
--- /dev/null
@@ -0,0 +1,231 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * 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;
+
+/**
+ * Manages image
+ */
+class ImageUtil {
+       /**
+        * The captcha width
+        */
+       const width = 192;
+
+       /**
+        * The captcha height
+        */
+       const height = 52;
+
+       /**
+        * The captcha background color
+        */
+       const background = 'white';
+
+       /**
+        * The captcha fill color
+        */
+       const fill = '#cff';
+
+       /**
+        * The captcha font size
+        */
+       const fontSize = 45;
+
+       /**
+        * The captcha stroke color
+        */
+       const stroke = '#00c3f9';
+
+       /**
+        * The captcha stroke width
+        */
+       const strokeWidth = 2;
+
+       /**
+        * The thumb width
+        */
+       const thumbWidth = 640;
+
+       /**
+        * The thumb height
+        */
+       const thumbHeight = 640;
+
+       /**
+        * Creates a new image util
+        *
+        * @param RouterInterface $router The RouterInterface instance
+        * @param SluggerUtil $slugger The SluggerUtil instance
+        * @param string $cache The cache directory
+        * @param string $path The public path
+        * @param string $prefix The prefix
+        */
+       function __construct(protected RouterInterface $router, protected SluggerUtil $slugger, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'image', protected string $background = self::background, protected string $fill = self::fill, protected int $fontSize = self::fontSize, protected string $stroke = self::stroke, protected int $strokeWidth = self::strokeWidth) {
+       }
+
+       /**
+        * 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::width, int $height = self::height): 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('rapsyspack_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('rapsyspack_thumb', ['hash' => $link, 'updated' => $updated, 'path' => $short, 'width' => $imageWidth, 'height' => $imageHeight]),
+                       'src' => $this->router->generate('rapsyspack_thumb', ['hash' => $src, 'updated' => $updated, 'path' => $short, 'width' => $width, 'height' => $height]),
+                       'width' => $width,
+                       'height' => $height
+               ];
+       }
+
+       /**
+        * Get captcha background color
+        */
+       public function getBackground() {
+               return $this->background;
+       }
+
+       /**
+        * Get captcha fill color
+        */
+       public function getFill() {
+               return $this->fill;
+       }
+
+       /**
+        * Get captcha font size
+        */
+       public function getFontSize() {
+               return $this->fontSize;
+       }
+
+       /**
+        * Get captcha stroke color
+        */
+       public function getStroke() {
+               return $this->stroke;
+       }
+
+       /**
+        * Get captcha stroke width
+        */
+       public function getStrokeWidth() {
+               return $this->strokeWidth;
+       }
+
+       /**
+        * 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->path.'/'.$this->prefix.'/'.$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;
+       }
+}
diff --git a/Util/IntlUtil.php b/Util/IntlUtil.php
new file mode 100644 (file)
index 0000000..b37169d
--- /dev/null
@@ -0,0 +1,167 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Util;
+
+use Twig\Error\SyntaxError;
+use Twig\Environment;
+
+/**
+ * Manages intl conversions
+ */
+class IntlUtil {
+       /**
+        * Format date
+        */
+       public function date(Environment $env, \DateTime $date, string $dateFormat = 'medium', string $timeFormat = 'medium', ?string $locale = null, \IntlTimeZone|\DateTimeZone|string|null $timezone = null, ?string $calendar = null, ?string $pattern = null) {
+               //Get converted date
+               $date = twig_date_converter($env, $date, $timezone);
+
+               //Set date and time formatters
+               $formatters = [
+                       'none' => \IntlDateFormatter::NONE,
+                       'short' => \IntlDateFormatter::SHORT,
+                       'medium' => \IntlDateFormatter::MEDIUM,
+                       'long' => \IntlDateFormatter::LONG,
+                       'full' => \IntlDateFormatter::FULL,
+               ];
+
+               //Get formatter
+               $formatter = \IntlDateFormatter::create(
+                       $locale,
+                       $formatters[$dateFormat],
+                       $formatters[$timeFormat],
+                       \IntlTimeZone::createTimeZone($date->getTimezone()->getName()),
+                       'traditional' === $calendar ? \IntlDateFormatter::TRADITIONAL : \IntlDateFormatter::GREGORIAN,
+                       $pattern
+               );
+
+               //Return formatted date
+               return $formatter->format($date->getTimestamp());
+       }
+
+       /**
+        * Format number
+        */
+       public function number(int|float $number, $style = 'decimal', $type = 'default', ?string $locale = null) {
+               //Set types
+               static $types = [
+                       'default' => NumberFormatter::TYPE_DEFAULT,
+                       'int32' => NumberFormatter::TYPE_INT32,
+                       'int64' => NumberFormatter::TYPE_INT64,
+                       'double' => NumberFormatter::TYPE_DOUBLE,
+                       'currency' => NumberFormatter::TYPE_CURRENCY,
+               ];
+
+               //Get formatter
+               $formatter = $this->getNumberFormatter($locale, $style);
+
+               //Without type
+               if (!isset($types[$type])) {
+                       throw new SyntaxError(sprintf('The type "%s" does not exist. Known types are: "%s"', $type, implode('", "', array_keys($types))));
+               }
+
+               //Return formatted number
+               return $formatter->format($number, $types[$type]);
+       }
+
+       /**
+        * Format currency
+        */
+       public function currency(int|float $number, string $currency, ?string $locale = null) {
+               //Get formatter
+               $formatter = $this->getNumberFormatter($locale, 'currency');
+
+               //Return formatted currency
+               return $formatter->formatCurrency($number, $currency);
+       }
+
+       /**
+        * Compute eastern for selected year
+        *
+        * @param string $year The eastern year
+        *
+        * @return DateTime The eastern date
+        */
+       public function getEastern(string $year): \DateTime {
+               //Set static results
+               static $results = [];
+
+               //Check if already computed
+               if (isset($results[$year])) {
+                       //Return computed eastern
+                       return $results[$year];
+               }
+
+               $d = (19 * ($year % 19) + 24) % 30;
+
+               $e = (2 * ($year % 4) + 4 * ($year % 7) + 6 * $d + 5) % 7;
+
+               $day = 22 + $d + $e;
+
+               $month = 3;
+
+               if ($day > 31) {
+                       $day = $d + $e - 9;
+                       $month = 4;
+               } elseif ($d == 29 && $e == 6) {
+                       $day = 10;
+                       $month = 4;
+               } elseif ($d == 28 && $e == 6) {
+                       $day = 18;
+                       $month = 4;
+               }
+
+               //Store eastern in data
+               return ($results[$year] = new \DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day)));
+       }
+
+       /**
+        * Gets number formatter instance matching locale and style.
+        *
+        * @param ?string $locale Locale in which the number would be formatted
+        * @param string $style Style of the formatting
+        *
+        * @return NumberFormatter A NumberFormatter instance
+        */
+       protected function getNumberFormatter(?string $locale, string $style): \NumberFormatter {
+               //Set static formatters
+               static $formatters = [];
+
+               //Set locale
+               $locale = null !== $locale ? $locale : Locale::getDefault();
+
+               //With existing formatter
+               if (isset($formatters[$locale][$style])) {
+                       //Return the instance from previous call
+                       return $formatters[$locale][$style];
+               }
+
+               //Set styles
+               static $styles = [
+                       'decimal' => \NumberFormatter::DECIMAL,
+                       'currency' => \NumberFormatter::CURRENCY,
+                       'percent' => \NumberFormatter::PERCENT,
+                       'scientific' => \NumberFormatter::SCIENTIFIC,
+                       'spellout' => \NumberFormatter::SPELLOUT,
+                       'ordinal' => \NumberFormatter::ORDINAL,
+                       'duration' => \NumberFormatter::DURATION,
+               ];
+
+               //Without styles
+               if (!isset($styles[$style])) {
+                       throw new SyntaxError(sprintf('The style "%s" does not exist. Known styles are: "%s"', $style, implode('", "', array_keys($styleValues))));
+               }
+
+               //Return number formatter
+               return ($formatters[$locale][$style] = \NumberFormatter::create($locale, $styles[$style]));
+       }
+}
diff --git a/Util/MapUtil.php b/Util/MapUtil.php
new file mode 100644 (file)
index 0000000..b7d2232
--- /dev/null
@@ -0,0 +1,428 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * 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\Routing\RouterInterface;
+
+/**
+ * Manages map
+ */
+class MapUtil {
+       /**
+        * The cycle tile server
+        *
+        * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
+        */
+       const cycle = 'http://a.tile.thunderforest.com/cycle/{Z}/{X}/{Y}.png';
+
+       /**
+        * The fill color
+        */
+       const fill = '#cff';
+
+       /**
+        * The font size
+        */
+       const fontSize = 20;
+
+       /**
+        * The high fill color
+        */
+       const highFill = '#c3c3f9';
+
+       /**
+        * The high font size
+        */
+       const highFontSize = 30;
+
+       /**
+        * The high radius size
+        */
+       const highRadius = 6;
+
+       /**
+        * The high stroke color
+        */
+       const highStroke = '#3333c3';
+
+       /**
+        * The high stroke width
+        */
+       const highStrokeWidth = 4;
+
+       /**
+        * The map width
+        */
+       const width = 640;
+
+       /**
+        * The map height
+        */
+       const height = 640;
+       
+       /**
+        * The osm tile server
+        *
+        * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
+        */
+       const osm = 'https://tile.openstreetmap.org/{Z}/{X}/{Y}.png';
+
+       /**
+        * The radius size
+        */
+       const radius = 5;
+
+       /**
+        * The stroke color
+        */
+       const stroke = '#00c3f9';
+
+       /**
+        * The stroke width
+        */
+       const strokeWidth = 2;
+
+       /**
+        * The transport tile server
+        *
+        * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
+        */
+       const transport = 'http://a.tile.thunderforest.com/transport/{Z}/{X}/{Y}.png';
+
+       /**
+        * The tile size
+        */
+       const tz = 256;
+
+       /**
+        * The map zoom
+        */
+       const zoom = 17;
+
+       /**
+        * Creates a new map util
+        *
+        * @param RouterInterface $router The RouterInterface instance
+        * @param SluggerUtil $slugger The SluggerUtil instance
+        */
+       function __construct(protected RouterInterface $router, protected SluggerUtil $slugger, protected string $fill = self::fill, protected int $fontSize = self::fontSize, protected string $highFill = self::highFill, protected int $highFontSize = self::highFontSize, protected int $highRadius = self::highRadius, protected string $highStroke = self::highStroke, protected int $highStrokeWidth = self::highStrokeWidth, protected int $radius = self::radius, protected string $stroke = self::stroke, protected int $strokeWidth = self::strokeWidth) {
+       }
+
+       /**
+        * Get fill color
+        */
+       function getFill() {
+               return $this->fill;
+       }
+
+       /**
+        * Get font size
+        */
+       function getFontSize() {
+               return $this->fontSize;
+       }
+
+       /**
+        * Get high fill color
+        */
+       function getHighFill() {
+               return $this->highFill;
+       }
+
+       /**
+        * Get high font size
+        */
+       function getHighFontSize() {
+               return $this->highFontSize;
+       }
+
+       /**
+        * Get high radius size
+        */
+       function getHighRadius() {
+               return $this->highRadius;
+       }
+
+       /**
+        * Get high stroke color
+        */
+       function getHighStroke() {
+               return $this->highStroke;
+       }
+
+       /**
+        * Get high stroke width
+        */
+       function getHighStrokeWidth() {
+               return $this->highStrokeWidth;
+       }
+
+       /**
+        * Get radius size
+        */
+       function getRadius() {
+               return $this->radius;
+       }
+
+       /**
+        * Get stroke color
+        */
+       function getStroke() {
+               return $this->stroke;
+       }
+
+       /**
+        * Get stroke width
+        */
+       function getStrokeWidth() {
+               return $this->strokeWidth;
+       }
+
+       /**
+        * Get map data
+        *
+        * @param string $caption The caption
+        * @param int $updated The updated timestamp
+        * @param float $latitude The latitude
+        * @param float $longitude The longitude
+        * @param int $zoom The zoom
+        * @param int $width The width
+        * @param int $height The height
+        * @return array The map data
+        */
+       public function getMap(string $caption, int $updated, float $latitude, float $longitude, int $zoom = self::zoom, int $width = self::width, int $height = self::height): array {
+               //Set link hash
+               $link = $this->slugger->hash([$updated, $latitude, $longitude, $zoom + 1, $width * 2, $height * 2]);
+
+               //Set src hash
+               $src = $this->slugger->hash([$updated, $latitude, $longitude, $zoom, $width, $height]);
+
+               //Return array
+               return [
+                       'caption' => $caption,
+                       'link' => $this->router->generate('rapsyspack_map', ['hash' => $link, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'zoom' => $zoom + 1, 'width' => $width * 2, 'height' => $height * 2]),
+                       'src' => $this->router->generate('rapsyspack_map', ['hash' => $src, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'zoom' => $zoom, 'width' => $width, 'height' => $height]),
+                       'width' => $width,
+                       'height' => $height
+               ];
+       }
+
+       /**
+        * Get multi map data
+        *
+        * @param string $caption The caption
+        * @param int $updated The updated timestamp
+        * @param array $coordinates The coordinates array
+        * @param int $width The width
+        * @param int $height The height
+        * @return array The multi map data
+        */
+       public function getMultiMap(string $caption, int $updated, array $coordinates, int $width = self::width, int $height = self::height): array {
+               //Without coordinates
+               if (empty($coordinates)) {
+                       //Return empty array
+                       return [];
+               }
+
+               //Set latitudes
+               $latitudes = array_map(function ($v) { return $v['latitude']; }, $coordinates);
+
+               //Set longitudes
+               $longitudes = array_map(function ($v) { return $v['longitude']; }, $coordinates);
+
+               //Set latitude
+               $latitude = round((min($latitudes)+max($latitudes))/2, 6);
+
+               //Set longitude
+               $longitude = round((min($longitudes)+max($longitudes))/2, 6);
+
+               //Set zoom
+               $zoom = $this->getMultiZoom($latitude, $longitude, $coordinates, $width, $height);
+
+               //Set coordinate
+               $coordinate = implode('-', array_map(function ($v) { return $v['latitude'].','.$v['longitude']; }, $coordinates));
+
+               //Set coordinate hash
+               $hash = $this->slugger->hash($coordinate);
+
+               //Set link hash
+               $link = $this->slugger->hash([$updated, $latitude, $longitude, $hash, $zoom + 1, $width * 2, $height * 2]);
+
+               //Set src hash
+               $src = $this->slugger->hash([$updated, $latitude, $longitude, $hash, $zoom, $width, $height]);
+
+               //Return array
+               return [
+                       'caption' => $caption,
+                       'link' => $this->router->generate('rapsyspack_multimap', ['hash' => $link, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'coordinates' => $coordinate, 'zoom' => $zoom + 1, 'width' => $width * 2, 'height' => $height * 2]),
+                       'src' => $this->router->generate('rapsyspack_multimap', ['hash' => $src, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'coordinates' => $coordinate, 'zoom' => $zoom, 'width' => $width, 'height' => $height]),
+                       'width' => $width,
+                       'height' => $height
+               ];
+       }
+
+       /**
+        * Get multi zoom
+        *
+        * Compute a zoom to have all coordinates on multi map
+        * Multi map visible only from -($width / 2) until ($width / 2) and from -($height / 2) until ($height / 2)
+        *
+        * @see Wether we need to take in consideration circle radius in coordinates comparisons, likely +/-(radius / self::tz)
+        *
+        * @param float $latitude The latitude
+        * @param float $longitude The longitude
+        * @param array $coordinates The coordinates array
+        * @param int $width The width
+        * @param int $height The height
+        * @param int $zoom The zoom
+        * @return int The zoom
+        */
+       public function getMultiZoom(float $latitude, float $longitude, array $coordinates, int $width, int $height, int $zoom = self::zoom): int {
+               //Iterate on each zoom
+               for ($i = $zoom; $i >= 1; $i--) {
+                       //Get tile xy
+                       $centerX = self::longitudeToX($longitude, $i);
+                       $centerY = self::latitudeToY($latitude, $i);
+
+                       //Calculate start xy
+                       $startX = floor($centerX - $width / 2 / self::tz);
+                       $startY = floor($centerY - $height / 2 / self::tz);
+
+                       //Calculate end xy
+                       $endX = ceil($centerX + $width / 2 / self::tz);
+                       $endY = ceil($centerY + $height / 2 / self::tz);
+
+                       //Iterate on each coordinates
+                       foreach($coordinates as $k => $coordinate) {
+                               //Set dest x
+                               $destX = self::longitudeToX($coordinate['longitude'], $i);
+
+                               //With outside point
+                               if ($startX >= $destX || $endX <= $destX) {
+                                       //Skip zoom
+                                       continue(2);
+                               }
+
+                               //Set dest y
+                               $destY = self::latitudeToY($coordinate['latitude'], $i);
+
+                               //With outside point
+                               if ($startY >= $destY || $endY <= $destY) {
+                                       //Skip zoom
+                                       continue(2);
+                               }
+                       }
+
+                       //Found zoom
+                       break;
+               }
+
+               //Return zoom
+               return $i;
+       }
+
+       /**
+        * Convert longitude to tile x number
+        *
+        * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
+        *
+        * @param float $longitude The longitude
+        * @param int $zoom The zoom
+        *
+        * @return float The tile x
+        */
+       public static function longitudeToX(float $longitude, int $zoom): float {
+               return (($longitude + 180) / 360) * pow(2, $zoom);
+       }
+
+       /**
+        * Convert latitude to tile y number
+        *
+        * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
+        *
+        * @param $latitude The latitude
+        * @param $zoom The zoom
+        *
+        * @return float The tile y
+        */
+       public static function latitudeToY(float $latitude, int $zoom): float {
+               return (1 - log(tan(deg2rad($latitude)) + 1 / cos(deg2rad($latitude))) / pi()) / 2 * pow(2, $zoom);
+       }
+
+       /**
+        * Convert tile x to longitude
+        *
+        * @param float $x The tile x
+        * @param int $zoom The zoom
+        *
+        * @return float The longitude
+        */
+       public static function xToLongitude(float $x, int $zoom): float {
+               return $x / pow(2, $zoom) * 360.0 - 180.0;
+       }
+
+       /**
+        * Convert tile y to latitude
+        *
+        * @param float $y The tile y
+        * @param int $zoom The zoom
+        *
+        * @return float The latitude
+        */
+       public static function yToLatitude(float $y, int $zoom): float {
+               return rad2deg(atan(sinh(pi() * (1 - 2 * $y / pow(2, $zoom)))));
+       }
+
+       /**
+        * Convert decimal latitude to sexagesimal
+        *
+        * @param float $latitude The decimal latitude
+        *
+        * @return string The sexagesimal longitude
+        */
+       public static function latitudeToSexagesimal(float $latitude): string {
+               //Set degree
+               //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision
+               $degree = round($latitude) % 60;
+
+               //Set minute
+               $minute = round(($latitude - $degree) * 60) % 60;
+
+               //Set second
+               $second = round(($latitude - $degree - $minute / 60) * 3600) % 3600;
+
+               //Return sexagesimal longitude
+               return $degree.'°'.$minute.'\''.$second.'"'.($latitude >= 0 ? 'N' : 'S');
+       }
+
+       /**
+        * Convert decimal longitude to sexagesimal
+        *
+        * @param float $longitude The decimal longitude
+        *
+        * @return string The sexagesimal longitude
+        */
+       public static function longitudeToSexagesimal(float $longitude): string {
+               //Set degree
+               //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision
+               $degree = round($longitude) % 60;
+
+               //Set minute
+               $minute = round(($longitude - $degree) * 60) % 60;
+
+               //Set second
+               $second = round(($longitude - $degree - $minute / 60) * 3600) % 3600;
+
+               //Return sexagesimal longitude
+               return $degree.'°'.$minute.'\''.$second.'"'.($longitude >= 0 ? 'E' : 'W');
+       }
+}
diff --git a/Util/SluggerUtil.php b/Util/SluggerUtil.php
new file mode 100644 (file)
index 0000000..eb6041e
--- /dev/null
@@ -0,0 +1,241 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Util;
+
+/**
+ * Manages string conversions
+ */
+class SluggerUtil {
+       /**
+        * The alpha array
+        */
+       protected array $alpha;
+
+       /**
+        * The rev array
+        */
+       protected array $rev;
+
+       /**
+        * The alpha array key number
+        */
+       protected int $count;
+
+       /**
+        * The offset reduced from secret
+        */
+       protected int $offset;
+
+       /**
+        * Construct slugger util
+        *
+        * Run "bin/console rapsyspack:range" to generate RAPSYSPACK_RANGE="ayl[...]z9w" range in .env.local
+        *
+        * @todo Use Cache like in calendar controller through FilesystemAdapter ?
+        *
+        * @param string $secret The secret string
+        */
+       public function __construct(protected string $secret) {
+               //Without range
+               if (empty($range = $_ENV['RAPSYSPACK_RANGE']) || $range === 'Ch4ng3m3!') {
+                       //Protect member variable setup
+                       return;
+               }
+
+               /**
+                * Get pseuto-random alphabet by splitting range string
+                * TODO: see required range by json_encode result and short input (0->255 ???)
+                * XXX: The key count mismatch, count(alpha)>count(rev), resulted in a data corruption due to duplicate numeric values
+                */
+               $this->alpha = str_split($range);
+
+               //Init rev array
+               $this->count = count($rev = $this->rev = array_flip($this->alpha));
+
+               //Init split
+               $split = str_split($this->secret);
+
+               //Set offset
+               //TODO: protect undefined index ?
+               $this->offset = array_reduce($split, function ($res, $a) use ($rev) { return $res += $rev[$a]; }, count($split)) % $this->count;
+       }
+
+       /**
+        * Flatten recursively an array
+        *
+        * @param array|string $data The data tree
+        * @param string|null $current The current prefix
+        * @param string $sep The key separator
+        * @param string $prefix The key prefix
+        * @param string $suffix The key suffix
+        * @return array The flattened data
+        */
+       public function flatten($data, ?string $current = null, string $sep = '.', string $prefix = '', string $suffix = ''): array {
+               //Init result
+               $ret = [];
+
+               //Look for data array
+               if (is_array($data)) {
+                       //Iteare on each pair
+                       foreach($data as $k => $v) {
+                               //Merge flattened value in return array
+                               $ret += $this->flatten($v, empty($current) ? $k : $current.$sep.$k, $sep, $prefix, $suffix);
+                       }
+               //Look flat data
+               } else {
+                       //Store data in flattened key
+                       $ret[$prefix.$current.$suffix] = $data;
+               }
+
+               //Return result
+               return $ret;
+       }
+
+       /**
+        * Crypt and base64uri encode string
+        *
+        * @param array|string $data The data string
+        * @return string The hashed data
+        */
+       public function hash(array|string $data): string {
+               //With array
+               if (is_array($data)) {
+                       //Json encode array
+                       $data = json_encode($data);
+               }
+
+               //Return hashed data
+               //XXX: we use hash_hmac with md5 hash
+               //XXX: crypt was dropped because it provided identical signature for string starting with same pattern
+               return str_replace(['+','/'], ['-','_'], base64_encode(hash_hmac('md5', $data, $this->secret, true)));
+       }
+
+       /**
+        * Serialize then short
+        *
+        * @param array $data The data array
+        * @return string The serialized and shorted data
+        */
+       public function serialize(array $data): string {
+               //Return shorted serialized data
+               //XXX: dropped serialize use to prevent short function from dropping utf-8 characters
+               return $this->short(json_encode($data));
+       }
+
+       /**
+        * Short
+        *
+        * @param string $data The data string
+        * @return string The shorted data
+        */
+       public function short(string $data): string {
+               //Return string
+               $ret = '';
+
+               //With data
+               if (!empty($data)) {
+                       //Iterate on each character
+                       foreach(str_split($data) as $k => $c) {
+                               if (isset($this->rev[$c]) && isset($this->alpha[($this->rev[$c]+$this->offset)%$this->count])) {
+                                       //XXX: Remap char to an other one
+                                       $ret .= chr(($this->rev[$c] - $this->offset + $this->count) % $this->count);
+                               } else {
+                                       throw new \RuntimeException(sprintf('Unable to retrieve character: %c', $c));
+                               }
+                       }
+               }
+
+               //Send result
+               return str_replace(['+','/','='], ['-','_',''], base64_encode($ret));
+       }
+
+       /**
+        * Convert string to safe slug
+        *
+        * @param string $data The data string
+        * @return ?string The slugged data
+        */
+       function slug(?string $data): ?string {
+               //With null
+               if ($data === null) {
+                       //Return null
+                       return $data;
+               }
+
+               //Use Transliterator if available
+               if (class_exists('Transliterator')) {
+                       //Convert from any to latin, then to ascii and lowercase
+                       $trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
+                       //Replace every non alphanumeric character by dash then trim dash
+                       return trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $trans->transliterate($data)), '-');
+               }
+
+               //Convert from utf-8 to ascii, replace quotes with space, remove non alphanumericseparator, replace separator with dash and trim dash
+               return trim(preg_replace('/[\/_|+ -]+/', '-', strtolower(preg_replace('/[^a-zA-Z0-9\/_|+ -]/', '', str_replace(['\'', '"'], ' ', iconv('UTF-8', 'ASCII//TRANSLIT', $data))))), '-');
+       }
+
+       /**
+        * Convert string to latin
+        *
+        * @param string $data The data string
+        * @return ?string The slugged data
+        */
+       function latin(?string $data): ?string {
+               //With null
+               if ($data === null) {
+                       //Return null
+                       return $data;
+               }
+
+               //Use Transliterator if available
+               if (class_exists('Transliterator')) {
+                       //Convert from any to latin, then to ascii and lowercase
+                       $trans = \Transliterator::create('Any-Latin; Latin-ASCII');
+                       //Replace every non alphanumeric character by dash then trim dash
+                       return trim($trans->transliterate($data));
+               }
+
+               //Convert from utf-8 to ascii
+               return trim(iconv('UTF-8', 'ASCII//TRANSLIT', $data));
+       }
+
+       /**
+        * Unshort then unserialize
+        *
+        * @param string $data The data string
+        * @return array The unshorted and unserialized data
+        */
+       public function unserialize(string $data): array {
+               //Return unshorted unserialized string
+               return json_decode($this->unshort($data), true);
+       }
+
+       /**
+        * Unshort
+        *
+        * @param string $data The data string
+        * @return string The unshorted data
+        */
+       public function unshort(string $data): string {
+               //Return string
+               $ret = '';
+
+               //Iterate on each character
+               foreach(str_split(base64_decode(str_replace(['-','_'], ['+','/'], $data))) as $c) {
+                       //XXX: Reverse map char to an other one
+                       $ret .= $this->alpha[(ord($c) + $this->offset) % $this->count];
+               }
+
+               //Send result
+               return $ret;
+       }
+}
index 0d6f49d00700e58ce492283ec1e0637f0731e4d6..3c7f24b44da5e183b98004eeb20028d44ecfefff 100644 (file)
@@ -5,7 +5,7 @@
     "type": "symfony-bundle",
     "authors": [{
         "name": "Raphaël Gertz",
-        "email": "packbundle@rapsys.eu"
+        "email": "symfony@rapsys.eu"
     }],
     "autoload": {
         "psr-4": {
         }
     },
     "require": {
-        "php": "*",
-        "symfony/config": "*",
-        "symfony/dependency-injection": "*",
-        "symfony/asset": "*",
-        "twig/extensions": "*"
+        "symfony/asset": "^7.0",
+        "symfony/flex": "^2.0",
+        "symfony/framework-bundle": "^7.0",
+        "symfony/process": "^7.0",
+        "symfony/twig-bundle": "^7.0"
     },
     "extra": {
         "branch-alias": {